feat(core): add per-project env block to ProjectConfig (#1679)

* feat(core): add per-project env block to ProjectConfig

Adds an optional `env: Record<string, string>` 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 <karnalprateek@gmail.com>
This commit is contained in:
i-trytoohard 2026-05-07 11:29:19 +05:30 committed by GitHub
parent fc7d76ad54
commit 40aeb78c09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 140 additions and 0 deletions

View File

@ -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.

View File

@ -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]

View File

@ -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: {

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).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 });

View File

@ -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(),

View File

@ -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"] && {

View File

@ -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<string, string>;
/** Issue tracker configuration */
tracker?: TrackerConfig;