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:
parent
fc7d76ad54
commit
40aeb78c09
|
|
@ -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.
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"] && {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue