From 40aeb78c09735ab858104546cefdfcddcb5450d4 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Thu, 7 May 2026 11:29:19 +0530 Subject: [PATCH] feat(core): add per-project env block to ProjectConfig (#1679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): add per-project env block to ProjectConfig Adds an optional `env: Record` field to ProjectConfig that forwards environment variables into worker session runtimes. Useful for scoping per-project tokens like GH_TOKEN to pin gh auth per project. The merge order in session-manager runtime.create environment is: agent.getEnvironment → PATH/GH_PATH → AO_AGENT_GH_TRACE → project.env → AO_* internals. AO-internal vars always win over user-supplied values. Closes #169 * fix(core): protect PATH and GH_PATH from project.env override Per greptile review on #1679: spreading `project.env` after PATH/GH_PATH let a user-supplied PATH or GH_PATH silently clobber the carefully constructed agent path. Apply the same "protected key" treatment as AO_* internals — spread project.env BEFORE PATH/GH_PATH/AO_AGENT_GH_TRACE at all three runtime.create call sites (worker spawn, orchestrator spawn, restore). Extend the precedence test to assert PATH and GH_PATH still win over a colliding project.env entry. --------- Co-authored-by: Prateek --- .changeset/per-project-env.md | 5 ++ agent-orchestrator.yaml.example | 6 ++ .../src/__tests__/config-validation.test.ts | 39 +++++++++ .../__tests__/session-manager/spawn.test.ts | 83 +++++++++++++++++++ packages/core/src/config.ts | 1 + packages/core/src/session-manager.ts | 3 + packages/core/src/types.ts | 3 + 7 files changed, 140 insertions(+) create mode 100644 .changeset/per-project-env.md diff --git a/.changeset/per-project-env.md b/.changeset/per-project-env.md new file mode 100644 index 000000000..c9945a22e --- /dev/null +++ b/.changeset/per-project-env.md @@ -0,0 +1,5 @@ +--- +"@aoagents/ao-core": minor +--- + +Add optional per-project `env` block to `ProjectConfig` that forwards string-to-string env vars into worker session runtimes (e.g. pin `GH_TOKEN` per project). AO-internal vars (`AO_SESSION`, `AO_PROJECT_ID`, etc.) always take precedence. diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index 04d5f476c..7659fd35e 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -79,6 +79,12 @@ projects: # deliveryHeader: x-github-delivery # maxBodyBytes: 1048576 + # Per-project environment variables forwarded into worker session runtimes. + # Useful for scoping per-project tokens (e.g. pinning gh auth via GH_TOKEN). + # AO-internal vars (AO_SESSION, AO_PROJECT_ID, etc.) always take precedence. + # env: + # GH_TOKEN: ghp_xxx + # Files to symlink into workspaces # symlinks: [.env, .claude] diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index 57a60b5cd..1a6ce9c49 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -447,6 +447,45 @@ describe("Config Schema Validation", () => { expect(validated.projects.proj1.sessionPrefix).toBe("test"); // "test" is 4 chars, used as-is }); + it("accepts a string-to-string env map at the project level", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + env: { + GH_TOKEN: "ghp_xxx", + CUSTOM_FLAG: "1", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.env).toEqual({ + GH_TOKEN: "ghp_xxx", + CUSTOM_FLAG: "1", + }); + }); + + it("rejects non-string values in project env map", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + env: { + GH_TOKEN: 123, + }, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow(); + }); + it("accepts orchestratorModel in agentConfig", () => { const config = { projects: { diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 9ffd7aefc..970ea6aa0 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -103,6 +103,89 @@ describe("spawn", () => { } }); + it("forwards project.env into spawned agent runtime env", async () => { + const projectConfig = config.projects["my-app"]; + if (!projectConfig) throw new Error("test setup: my-app missing"); + const configWithEnv: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...projectConfig, + env: { + GH_TOKEN: "ghp_project_scoped", + CUSTOM_VAR: "hello", + }, + }, + }, + }; + + const sm = createSessionManager({ config: configWithEnv, registry: mockRegistry }); + await sm.spawn({ projectId: "my-app" }); + + expect(mockRuntime.create).toHaveBeenCalledWith( + expect.objectContaining({ + environment: expect.objectContaining({ + GH_TOKEN: "ghp_project_scoped", + CUSTOM_VAR: "hello", + }), + }), + ); + }); + + it("AO_* internals override project.env values with the same key", async () => { + const projectConfig = config.projects["my-app"]; + if (!projectConfig) throw new Error("test setup: my-app missing"); + const configWithEnv: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...projectConfig, + env: { + AO_SESSION: "should-not-win", + AO_PROJECT_ID: "should-not-win", + }, + }, + }, + }; + + const sm = createSessionManager({ config: configWithEnv, registry: mockRegistry }); + await sm.spawn({ projectId: "my-app" }); + + const call = (mockRuntime.create as ReturnType).mock.calls[0]?.[0]; + expect(call?.environment?.AO_SESSION).not.toBe("should-not-win"); + expect(call?.environment?.AO_SESSION).toBe("app-1"); + expect(call?.environment?.AO_PROJECT_ID).toBe("my-app"); + }); + + it("PATH and GH_PATH override project.env values with the same key", async () => { + const projectConfig = config.projects["my-app"]; + if (!projectConfig) throw new Error("test setup: my-app missing"); + const configWithEnv: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...projectConfig, + env: { + PATH: "/should/not/win", + GH_PATH: "/should/not/win", + }, + }, + }, + }; + + const sm = createSessionManager({ config: configWithEnv, registry: mockRegistry }); + await sm.spawn({ projectId: "my-app" }); + + const call = (mockRuntime.create as ReturnType).mock.calls[0]?.[0]; + expect(call?.environment?.PATH).not.toBe("/should/not/win"); + expect(call?.environment?.PATH).toContain(".ao/bin"); + expect(call?.environment?.GH_PATH).not.toBe("/should/not/win"); + expect(call?.environment?.GH_PATH).toBe("/usr/local/bin/gh"); + }); + it("uses issue ID to derive branch name", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 38a43a02e..f75c13f0b 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -249,6 +249,7 @@ const ProjectConfigSchema = z.object({ runtime: z.string().optional(), agent: z.string().optional(), workspace: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), tracker: TrackerConfigSchema.optional(), scm: SCMConfigSchema.optional(), symlinks: z.array(z.string()).optional(), diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 721eafef6..1cec42a94 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1299,6 +1299,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM environment: { ...environment, ...(opencodeConfigFile ? { OPENCODE_CONFIG: opencodeConfigFile } : {}), + ...(project.env ?? {}), PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]), GH_PATH: PREFERRED_GH_PATH, ...(process.env["AO_AGENT_GH_TRACE"] && { @@ -1670,6 +1671,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM launchCommand, environment: { ...environment, + ...(project.env ?? {}), PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]), GH_PATH: PREFERRED_GH_PATH, ...(process.env["AO_AGENT_GH_TRACE"] && { @@ -2935,6 +2937,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM environment: { ...environment, ...(opencodeConfigPath ? { OPENCODE_CONFIG: opencodeConfigPath } : {}), + ...(project.env ?? {}), PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]), GH_PATH: PREFERRED_GH_PATH, ...(process.env["AO_AGENT_GH_TRACE"] && { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e6c610d0e..3a41ef9f3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1504,6 +1504,9 @@ export interface ProjectConfig { /** Override default workspace */ workspace?: string; + /** Environment variables forwarded into worker session runtimes (AO_* internals always win) */ + env?: Record; + /** Issue tracker configuration */ tracker?: TrackerConfig;