fix: strip skill-injected env vars from ACP harness spawn env (#36280) (#36316)

* fix: strip skill-injected env vars from ACP harness spawn env

Skill apiKey entries (e.g., openai-image-gen with primaryEnv=OPENAI_API_KEY)
are set on process.env during agent runs and only reverted after the run
completes. ACP harnesses like Codex CLI inherit these vars, causing them
to silently use API billing instead of their own auth (e.g., OAuth).

The fix tracks which env vars are actively injected by skill overrides in
a module-level Set (activeSkillEnvKeys) and strips them in
resolveAcpClientSpawnEnv() before spawning ACP child processes.

Fixes #36280

* ACP: type spawn env for stripped keys

* Skills: cover active env key lifecycle

* Changelog: note ACP skill env isolation

* ACP: preserve shell marker after env stripping

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Drew Wagner
2026-03-06 18:18:13 -05:00
committed by GitHub
parent 03b9abab84
commit ae96a81916
6 changed files with 76 additions and 2 deletions

View File

@@ -60,6 +60,49 @@ describe("resolveAcpClientSpawnEnv", () => {
});
expect(env.OPENCLAW_SHELL).toBe("acp-client");
});
it("strips skill-injected env keys when stripKeys is provided", () => {
const stripKeys = new Set(["OPENAI_API_KEY", "ELEVENLABS_API_KEY"]);
const env = resolveAcpClientSpawnEnv(
{
PATH: "/usr/bin",
OPENAI_API_KEY: "sk-leaked-from-skill",
ELEVENLABS_API_KEY: "el-leaked",
ANTHROPIC_API_KEY: "sk-keep-this",
},
{ stripKeys },
);
expect(env.PATH).toBe("/usr/bin");
expect(env.OPENCLAW_SHELL).toBe("acp-client");
expect(env.ANTHROPIC_API_KEY).toBe("sk-keep-this");
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.ELEVENLABS_API_KEY).toBeUndefined();
});
it("does not modify the original baseEnv when stripping keys", () => {
const baseEnv: NodeJS.ProcessEnv = {
OPENAI_API_KEY: "sk-original",
PATH: "/usr/bin",
};
const stripKeys = new Set(["OPENAI_API_KEY"]);
resolveAcpClientSpawnEnv(baseEnv, { stripKeys });
expect(baseEnv.OPENAI_API_KEY).toBe("sk-original");
});
it("preserves OPENCLAW_SHELL even when stripKeys contains it", () => {
const env = resolveAcpClientSpawnEnv(
{
OPENCLAW_SHELL: "skill-overridden",
OPENAI_API_KEY: "sk-leaked",
},
{ stripKeys: new Set(["OPENCLAW_SHELL", "OPENAI_API_KEY"]) },
);
expect(env.OPENCLAW_SHELL).toBe("acp-client");
expect(env.OPENAI_API_KEY).toBeUndefined();
});
});
describe("resolveAcpClientSpawnInvocation", () => {

View File

@@ -348,8 +348,16 @@ function buildServerArgs(opts: AcpClientOptions): string[] {
export function resolveAcpClientSpawnEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
options?: { stripKeys?: ReadonlySet<string> },
): NodeJS.ProcessEnv {
return { ...baseEnv, OPENCLAW_SHELL: "acp-client" };
const env: NodeJS.ProcessEnv = { ...baseEnv };
if (options?.stripKeys) {
for (const key of options.stripKeys) {
delete env[key];
}
}
env.OPENCLAW_SHELL = "acp-client";
return env;
}
type AcpSpawnRuntime = {
@@ -450,7 +458,10 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise<AcpC
const entryPath = resolveSelfEntryPath();
const serverCommand = opts.serverCommand ?? (entryPath ? process.execPath : "openclaw");
const effectiveArgs = opts.serverCommand || !entryPath ? serverArgs : [entryPath, ...serverArgs];
const spawnEnv = resolveAcpClientSpawnEnv();
const { getActiveSkillEnvKeys } = await import("../agents/skills/env-overrides.runtime.js");
const spawnEnv = resolveAcpClientSpawnEnv(process.env, {
stripKeys: getActiveSkillEnvKeys(),
});
const spawnInvocation = resolveAcpClientSpawnInvocation(
{ serverCommand, serverArgs: effectiveArgs },
{