fix(acp): strip provider auth env for child ACP processes (openclaw#42250)

Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: rodrigouroz <384037+rodrigouroz@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Rodrigo Uroz
2026-03-10 18:50:10 -03:00
committed by GitHub
parent 5ed96da990
commit ff2e7a2945
19 changed files with 598 additions and 116 deletions

View File

@@ -5,7 +5,6 @@ import {
ACPX_PINNED_VERSION,
createAcpxPluginConfigSchema,
resolveAcpxPluginConfig,
toAcpMcpServers,
} from "./config.js";
describe("acpx plugin config parsing", () => {
@@ -20,9 +19,9 @@ describe("acpx plugin config parsing", () => {
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
expect(resolved.expectedVersion).toBe(ACPX_PINNED_VERSION);
expect(resolved.allowPluginLocalInstall).toBe(true);
expect(resolved.stripProviderAuthEnvVars).toBe(true);
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
expect(resolved.strictWindowsCmdWrapper).toBe(true);
expect(resolved.mcpServers).toEqual({});
});
it("accepts command override and disables plugin-local auto-install", () => {
@@ -37,6 +36,7 @@ describe("acpx plugin config parsing", () => {
expect(resolved.command).toBe(path.resolve(command));
expect(resolved.expectedVersion).toBeUndefined();
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("resolves relative command paths against workspace directory", () => {
@@ -50,6 +50,7 @@ describe("acpx plugin config parsing", () => {
expect(resolved.command).toBe(path.resolve("/home/user/repos/openclaw", "../acpx/dist/cli.js"));
expect(resolved.expectedVersion).toBeUndefined();
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("keeps bare command names as-is", () => {
@@ -63,6 +64,7 @@ describe("acpx plugin config parsing", () => {
expect(resolved.command).toBe("acpx");
expect(resolved.expectedVersion).toBeUndefined();
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("accepts exact expectedVersion override", () => {
@@ -78,6 +80,7 @@ describe("acpx plugin config parsing", () => {
expect(resolved.command).toBe(path.resolve(command));
expect(resolved.expectedVersion).toBe("0.1.99");
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("treats expectedVersion=any as no version constraint", () => {
@@ -134,97 +137,4 @@ describe("acpx plugin config parsing", () => {
}),
).toThrow("strictWindowsCmdWrapper must be a boolean");
});
it("accepts mcp server maps", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
mcpServers: {
canva: {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
CANVA_TOKEN: "secret",
},
},
},
},
workspaceDir: "/tmp/workspace",
});
expect(resolved.mcpServers).toEqual({
canva: {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
CANVA_TOKEN: "secret",
},
},
});
});
it("rejects invalid mcp server definitions", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
mcpServers: {
canva: {
command: "npx",
args: ["-y", 1],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toThrow(
"mcpServers.canva must have a command string, optional args array, and optional env object",
);
});
it("schema accepts mcp server config", () => {
const schema = createAcpxPluginConfigSchema();
if (!schema.safeParse) {
throw new Error("acpx config schema missing safeParse");
}
const parsed = schema.safeParse({
mcpServers: {
canva: {
command: "npx",
args: ["-y", "mcp-remote@latest"],
env: {
CANVA_TOKEN: "secret",
},
},
},
});
expect(parsed.success).toBe(true);
});
});
describe("toAcpMcpServers", () => {
it("converts plugin config maps into ACP stdio MCP entries", () => {
expect(
toAcpMcpServers({
canva: {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
CANVA_TOKEN: "secret",
},
},
}),
).toEqual([
{
name: "canva",
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: [
{
name: "CANVA_TOKEN",
value: "secret",
},
],
},
]);
});
});

View File

@@ -47,6 +47,7 @@ export type ResolvedAcpxPluginConfig = {
command: string;
expectedVersion?: string;
allowPluginLocalInstall: boolean;
stripProviderAuthEnvVars: boolean;
installCommand: string;
cwd: string;
permissionMode: AcpxPermissionMode;
@@ -332,6 +333,7 @@ export function resolveAcpxPluginConfig(params: {
workspaceDir: params.workspaceDir,
});
const allowPluginLocalInstall = command === ACPX_BUNDLED_BIN;
const stripProviderAuthEnvVars = command === ACPX_BUNDLED_BIN;
const configuredExpectedVersion = normalized.expectedVersion;
const expectedVersion =
configuredExpectedVersion === ACPX_VERSION_ANY
@@ -343,6 +345,7 @@ export function resolveAcpxPluginConfig(params: {
command,
expectedVersion,
allowPluginLocalInstall,
stripProviderAuthEnvVars,
installCommand,
cwd,
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,

View File

@@ -77,6 +77,7 @@ describe("acpx ensure", () => {
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars: undefined,
});
});
@@ -148,6 +149,30 @@ describe("acpx ensure", () => {
command: "/custom/acpx",
args: ["--help"],
cwd: "/custom",
stripProviderAuthEnvVars: undefined,
});
});
it("forwards stripProviderAuthEnvVars to version checks", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "Usage: acpx [options]\n",
stderr: "",
code: 0,
error: null,
});
await checkAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: undefined,
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock).toHaveBeenCalledWith({
command: "/plugin/node_modules/.bin/acpx",
args: ["--help"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
});
@@ -186,6 +211,54 @@ describe("acpx ensure", () => {
});
});
it("threads stripProviderAuthEnvVars through version probes and install", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "added 1 package\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
await ensureAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock.mock.calls[0]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock.mock.calls[2]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
});
it("fails with actionable error when npm install fails", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({

View File

@@ -102,6 +102,7 @@ export async function checkAcpxVersion(params: {
command: string;
cwd?: string;
expectedVersion?: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<AcpxVersionCheckResult> {
const expectedVersion = params.expectedVersion?.trim() || undefined;
@@ -113,6 +114,7 @@ export async function checkAcpxVersion(params: {
command: params.command,
args: probeArgs,
cwd,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
};
let result: Awaited<ReturnType<typeof spawnAndCollect>>;
try {
@@ -198,6 +200,7 @@ export async function ensureAcpx(params: {
pluginRoot?: string;
expectedVersion?: string;
allowInstall?: boolean;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<void> {
if (pendingEnsure) {
@@ -214,6 +217,7 @@ export async function ensureAcpx(params: {
command: params.command,
cwd: pluginRoot,
expectedVersion,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
if (precheck.ok) {
@@ -231,6 +235,7 @@ export async function ensureAcpx(params: {
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`],
cwd: pluginRoot,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
});
if (install.error) {
@@ -252,6 +257,7 @@ export async function ensureAcpx(params: {
command: params.command,
cwd: pluginRoot,
expectedVersion,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from "vitest";
const { spawnAndCollectMock } = vi.hoisted(() => ({
spawnAndCollectMock: vi.fn(),
}));
vi.mock("./process.js", () => ({
spawnAndCollect: spawnAndCollectMock,
}));
import { __testing, resolveAcpxAgentCommand } from "./mcp-agent-command.js";
describe("resolveAcpxAgentCommand", () => {
it("threads stripProviderAuthEnvVars through the config show probe", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({
agents: {
codex: {
command: "custom-codex",
},
},
}),
stderr: "",
code: 0,
error: null,
});
const command = await resolveAcpxAgentCommand({
acpxCommand: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
agent: "codex",
stripProviderAuthEnvVars: true,
});
expect(command).toBe("custom-codex");
expect(spawnAndCollectMock).toHaveBeenCalledWith(
{
command: "/plugin/node_modules/.bin/acpx",
args: ["--cwd", "/plugin", "config", "show"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
},
undefined,
);
});
});
describe("buildMcpProxyAgentCommand", () => {
it("escapes Windows-style proxy paths without double-escaping backslashes", () => {
const quoted = __testing.quoteCommandPart(
"C:\\repo\\extensions\\acpx\\src\\runtime-internals\\mcp-proxy.mjs",
);
expect(quoted).toBe(
'"C:\\\\repo\\\\extensions\\\\acpx\\\\src\\\\runtime-internals\\\\mcp-proxy.mjs"',
);
expect(quoted).not.toContain("\\\\\\");
});
});

View File

@@ -37,6 +37,10 @@ function quoteCommandPart(value: string): string {
return `"${value.replace(/["\\]/g, "\\$&")}"`;
}
export const __testing = {
quoteCommandPart,
};
function toCommandLine(parts: string[]): string {
return parts.map(quoteCommandPart).join(" ");
}
@@ -62,6 +66,7 @@ function readConfiguredAgentOverrides(value: unknown): Record<string, string> {
async function loadAgentOverrides(params: {
acpxCommand: string;
cwd: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<Record<string, string>> {
const result = await spawnAndCollect(
@@ -69,6 +74,7 @@ async function loadAgentOverrides(params: {
command: params.acpxCommand,
args: ["--cwd", params.cwd, "config", "show"],
cwd: params.cwd,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
},
params.spawnOptions,
);
@@ -87,12 +93,14 @@ export async function resolveAcpxAgentCommand(params: {
acpxCommand: string;
cwd: string;
agent: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<string> {
const normalizedAgent = normalizeAgentName(params.agent);
const overrides = await loadAgentOverrides({
acpxCommand: params.acpxCommand,
cwd: params.cwd,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? params.agent;

View File

@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWindowsCmdShimFixture } from "../../../shared/windows-cmd-shim-test-fixtures.js";
import {
resolveSpawnCommand,
@@ -28,6 +28,7 @@ async function createTempDir(): Promise<string> {
}
afterEach(async () => {
vi.unstubAllEnvs();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
@@ -289,4 +290,99 @@ describe("spawnAndCollect", () => {
const result = await resultPromise;
expect(result.error?.name).toBe("AbortError");
});
it("strips shared provider auth env vars from spawned acpx children", async () => {
vi.stubEnv("OPENAI_API_KEY", "openai-secret");
vi.stubEnv("GITHUB_TOKEN", "gh-secret");
vi.stubEnv("HF_TOKEN", "hf-secret");
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
const result = await spawnAndCollect({
command: process.execPath,
args: [
"-e",
"process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
],
cwd: process.cwd(),
stripProviderAuthEnvVars: true,
});
expect(result.code).toBe(0);
expect(result.error).toBeNull();
const parsed = JSON.parse(result.stdout) as {
openai?: string;
github?: string;
hf?: string;
openclaw?: string;
shell?: string;
};
expect(parsed.openai).toBeUndefined();
expect(parsed.github).toBeUndefined();
expect(parsed.hf).toBeUndefined();
expect(parsed.openclaw).toBe("keep-me");
expect(parsed.shell).toBe("acp");
});
it("strips provider auth env vars case-insensitively", async () => {
vi.stubEnv("OpenAI_Api_Key", "openai-secret");
vi.stubEnv("Github_Token", "gh-secret");
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
const result = await spawnAndCollect({
command: process.execPath,
args: [
"-e",
"process.stdout.write(JSON.stringify({openai:process.env.OpenAI_Api_Key,github:process.env.Github_Token,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
],
cwd: process.cwd(),
stripProviderAuthEnvVars: true,
});
expect(result.code).toBe(0);
expect(result.error).toBeNull();
const parsed = JSON.parse(result.stdout) as {
openai?: string;
github?: string;
openclaw?: string;
shell?: string;
};
expect(parsed.openai).toBeUndefined();
expect(parsed.github).toBeUndefined();
expect(parsed.openclaw).toBe("keep-me");
expect(parsed.shell).toBe("acp");
});
it("preserves provider auth env vars for explicit custom commands by default", async () => {
vi.stubEnv("OPENAI_API_KEY", "openai-secret");
vi.stubEnv("GITHUB_TOKEN", "gh-secret");
vi.stubEnv("HF_TOKEN", "hf-secret");
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
const result = await spawnAndCollect({
command: process.execPath,
args: [
"-e",
"process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
],
cwd: process.cwd(),
});
expect(result.code).toBe(0);
expect(result.error).toBeNull();
const parsed = JSON.parse(result.stdout) as {
openai?: string;
github?: string;
hf?: string;
openclaw?: string;
shell?: string;
};
expect(parsed.openai).toBe("openai-secret");
expect(parsed.github).toBe("gh-secret");
expect(parsed.hf).toBe("hf-secret");
expect(parsed.openclaw).toBe("keep-me");
expect(parsed.shell).toBe("acp");
});
});

View File

@@ -7,7 +7,9 @@ import type {
} from "openclaw/plugin-sdk/acpx";
import {
applyWindowsSpawnProgramPolicy,
listKnownProviderAuthEnvVarNames,
materializeWindowsSpawnProgram,
omitEnvKeysCaseInsensitive,
resolveWindowsSpawnProgramCandidate,
} from "openclaw/plugin-sdk/acpx";
@@ -125,6 +127,7 @@ export function spawnWithResolvedCommand(
command: string;
args: string[];
cwd: string;
stripProviderAuthEnvVars?: boolean;
},
options?: SpawnCommandOptions,
): ChildProcessWithoutNullStreams {
@@ -136,9 +139,15 @@ export function spawnWithResolvedCommand(
options,
);
const childEnv = omitEnvKeysCaseInsensitive(
process.env,
params.stripProviderAuthEnvVars ? listKnownProviderAuthEnvVarNames() : [],
);
childEnv.OPENCLAW_SHELL = "acp";
return spawn(resolved.command, resolved.args, {
cwd: params.cwd,
env: { ...process.env, OPENCLAW_SHELL: "acp" },
env: childEnv,
stdio: ["pipe", "pipe", "pipe"],
shell: resolved.shell,
windowsHide: resolved.windowsHide,
@@ -180,6 +189,7 @@ export async function spawnAndCollect(
command: string;
args: string[];
cwd: string;
stripProviderAuthEnvVars?: boolean;
},
options?: SpawnCommandOptions,
runtime?: {

View File

@@ -1,6 +1,6 @@
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
import {
@@ -19,13 +19,14 @@ beforeAll(async () => {
{
command: "/definitely/missing/acpx",
allowPluginLocalInstall: false,
stripProviderAuthEnvVars: false,
installCommand: "n/a",
cwd: process.cwd(),
mcpServers: {},
permissionMode: "approve-reads",
nonInteractivePermissions: "fail",
strictWindowsCmdWrapper: true,
queueOwnerTtlSeconds: 0.1,
mcpServers: {},
},
{ logger: NOOP_LOGGER },
);
@@ -165,7 +166,7 @@ describe("AcpxRuntime", () => {
for await (const _event of runtime.runTurn({
handle,
text: "describe this image",
attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }],
attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }], // pragma: allowlist secret
mode: "prompt",
requestId: "req-image",
})) {
@@ -186,6 +187,40 @@ describe("AcpxRuntime", () => {
]);
});
it("preserves provider auth env vars when runtime uses a custom acpx command", async () => {
vi.stubEnv("OPENAI_API_KEY", "openai-secret"); // pragma: allowlist secret
vi.stubEnv("GITHUB_TOKEN", "gh-secret"); // pragma: allowlist secret
try {
const { runtime, logPath } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:custom-env",
agent: "codex",
mode: "persistent",
});
for await (const _event of runtime.runTurn({
handle,
text: "custom-env",
mode: "prompt",
requestId: "req-custom-env",
})) {
// Drain events; assertions inspect the mock runtime log.
}
const logs = await readMockRuntimeLogEntries(logPath);
const prompt = logs.find(
(entry) =>
entry.kind === "prompt" &&
String(entry.sessionName ?? "") === "agent:codex:acp:custom-env",
);
expect(prompt?.openaiApiKey).toBe("openai-secret");
expect(prompt?.githubToken).toBe("gh-secret");
} finally {
vi.unstubAllEnvs();
}
});
it("preserves leading spaces across streamed text deltas", async () => {
const runtime = sharedFixture?.runtime;
expect(runtime).toBeDefined();
@@ -395,7 +430,7 @@ describe("AcpxRuntime", () => {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
CANVA_TOKEN: "secret",
CANVA_TOKEN: "secret", // pragma: allowlist secret
},
},
},

View File

@@ -170,6 +170,7 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
cwd: this.config.cwd,
expectedVersion: this.config.expectedVersion,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
spawnOptions: this.spawnCommandOptions,
});
if (!versionCheck.ok) {
@@ -183,6 +184,7 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
);
@@ -309,6 +311,7 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args,
cwd: state.cwd,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
);
@@ -495,6 +498,7 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
cwd: this.config.cwd,
expectedVersion: this.config.expectedVersion,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
spawnOptions: this.spawnCommandOptions,
});
if (!versionCheck.ok) {
@@ -518,6 +522,7 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
);
@@ -683,6 +688,7 @@ export class AcpxRuntime implements AcpRuntime {
acpxCommand: this.config.command,
cwd: params.cwd,
agent: params.agent,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
spawnOptions: this.spawnCommandOptions,
});
const resolved = buildMcpProxyAgentCommand({
@@ -705,6 +711,7 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args: params.args,
cwd: params.cwd,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
{

View File

@@ -89,6 +89,11 @@ describe("createAcpxRuntimeService", () => {
await vi.waitFor(() => {
expect(ensureAcpxSpy).toHaveBeenCalledOnce();
expect(ensureAcpxSpy).toHaveBeenCalledWith(
expect.objectContaining({
stripProviderAuthEnvVars: true,
}),
);
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
});

View File

@@ -59,9 +59,8 @@ export function createAcpxRuntimeService(
});
const expectedVersionLabel = pluginConfig.expectedVersion ?? "any";
const installLabel = pluginConfig.allowPluginLocalInstall ? "enabled" : "disabled";
const mcpServerCount = Object.keys(pluginConfig.mcpServers).length;
ctx.logger.info(
`acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel}${mcpServerCount > 0 ? `, mcpServers: ${mcpServerCount}` : ""})`,
`acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel})`,
);
lifecycleRevision += 1;
@@ -73,6 +72,7 @@ export function createAcpxRuntimeService(
logger: ctx.logger,
expectedVersion: pluginConfig.expectedVersion,
allowInstall: pluginConfig.allowPluginLocalInstall,
stripProviderAuthEnvVars: pluginConfig.stripProviderAuthEnvVars,
spawnOptions: {
strictWindowsCmdWrapper: pluginConfig.strictWindowsCmdWrapper,
},

View File

@@ -204,6 +204,8 @@ if (command === "prompt") {
sessionName: sessionFromOption,
stdinText,
openclawShell,
openaiApiKey: process.env.OPENAI_API_KEY || "",
githubToken: process.env.GITHUB_TOKEN || "",
});
const requestId = "req-1";
@@ -326,6 +328,7 @@ export async function createMockRuntimeFixture(params?: {
const config: ResolvedAcpxPluginConfig = {
command: scriptPath,
allowPluginLocalInstall: false,
stripProviderAuthEnvVars: false,
installCommand: "n/a",
cwd: dir,
permissionMode: params?.permissionMode ?? "approve-all",
@@ -378,6 +381,7 @@ export async function readMockRuntimeLogEntries(
export async function cleanupMockRuntimeFixtures(): Promise<void> {
delete process.env.MOCK_ACPX_LOG;
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
sharedMockCliScriptPath = null;
logFileSequence = 0;
while (tempDirs.length > 0) {

View File

@@ -4,9 +4,11 @@ import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
buildAcpClientStripKeys,
resolveAcpClientSpawnEnv,
resolveAcpClientSpawnInvocation,
resolvePermissionRequest,
shouldStripProviderAuthEnvVarsForAcpServer,
} from "./client.js";
import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
@@ -110,6 +112,120 @@ describe("resolveAcpClientSpawnEnv", () => {
expect(env.OPENCLAW_SHELL).toBe("acp-client");
expect(env.OPENAI_API_KEY).toBeUndefined();
});
it("strips provider auth env vars for the default OpenClaw bridge", () => {
const stripKeys = new Set(["OPENAI_API_KEY", "GITHUB_TOKEN", "HF_TOKEN"]);
const env = resolveAcpClientSpawnEnv(
{
OPENAI_API_KEY: "openai-secret", // pragma: allowlist secret
GITHUB_TOKEN: "gh-secret", // pragma: allowlist secret
HF_TOKEN: "hf-secret", // pragma: allowlist secret
OPENCLAW_API_KEY: "keep-me",
PATH: "/usr/bin",
},
{ stripKeys },
);
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.GITHUB_TOKEN).toBeUndefined();
expect(env.HF_TOKEN).toBeUndefined();
expect(env.OPENCLAW_API_KEY).toBe("keep-me");
expect(env.PATH).toBe("/usr/bin");
expect(env.OPENCLAW_SHELL).toBe("acp-client");
});
it("strips provider auth env vars case-insensitively", () => {
const env = resolveAcpClientSpawnEnv(
{
OpenAI_Api_Key: "openai-secret", // pragma: allowlist secret
Github_Token: "gh-secret", // pragma: allowlist secret
OPENCLAW_API_KEY: "keep-me",
},
{ stripKeys: new Set(["OPENAI_API_KEY", "GITHUB_TOKEN"]) },
);
expect(env.OpenAI_Api_Key).toBeUndefined();
expect(env.Github_Token).toBeUndefined();
expect(env.OPENCLAW_API_KEY).toBe("keep-me");
expect(env.OPENCLAW_SHELL).toBe("acp-client");
});
it("preserves provider auth env vars for explicit custom ACP servers", () => {
const env = resolveAcpClientSpawnEnv({
OPENAI_API_KEY: "openai-secret", // pragma: allowlist secret
GITHUB_TOKEN: "gh-secret", // pragma: allowlist secret
HF_TOKEN: "hf-secret", // pragma: allowlist secret
OPENCLAW_API_KEY: "keep-me",
});
expect(env.OPENAI_API_KEY).toBe("openai-secret");
expect(env.GITHUB_TOKEN).toBe("gh-secret");
expect(env.HF_TOKEN).toBe("hf-secret");
expect(env.OPENCLAW_API_KEY).toBe("keep-me");
expect(env.OPENCLAW_SHELL).toBe("acp-client");
});
});
describe("shouldStripProviderAuthEnvVarsForAcpServer", () => {
it("strips provider auth env vars for the default bridge", () => {
expect(shouldStripProviderAuthEnvVarsForAcpServer()).toBe(true);
expect(
shouldStripProviderAuthEnvVarsForAcpServer({
serverCommand: "openclaw",
serverArgs: ["acp"],
defaultServerCommand: "openclaw",
defaultServerArgs: ["acp"],
}),
).toBe(true);
});
it("preserves provider auth env vars for explicit custom ACP servers", () => {
expect(
shouldStripProviderAuthEnvVarsForAcpServer({
serverCommand: "custom-acp-server",
serverArgs: ["serve"],
defaultServerCommand: "openclaw",
defaultServerArgs: ["acp"],
}),
).toBe(false);
});
it("preserves provider auth env vars when an explicit override uses the default executable with different args", () => {
expect(
shouldStripProviderAuthEnvVarsForAcpServer({
serverCommand: process.execPath,
serverArgs: ["custom-entry.js"],
defaultServerCommand: process.execPath,
defaultServerArgs: ["dist/entry.js", "acp"],
}),
).toBe(false);
});
});
describe("buildAcpClientStripKeys", () => {
it("always includes active skill env keys", () => {
const stripKeys = buildAcpClientStripKeys({
stripProviderAuthEnvVars: false,
activeSkillEnvKeys: ["SKILL_SECRET", "OPENAI_API_KEY"],
});
expect(stripKeys.has("SKILL_SECRET")).toBe(true);
expect(stripKeys.has("OPENAI_API_KEY")).toBe(true);
expect(stripKeys.has("GITHUB_TOKEN")).toBe(false);
});
it("adds provider auth env vars for the default bridge", () => {
const stripKeys = buildAcpClientStripKeys({
stripProviderAuthEnvVars: true,
activeSkillEnvKeys: ["SKILL_SECRET"],
});
expect(stripKeys.has("SKILL_SECRET")).toBe(true);
expect(stripKeys.has("OPENAI_API_KEY")).toBe(true);
expect(stripKeys.has("GITHUB_TOKEN")).toBe(true);
expect(stripKeys.has("HF_TOKEN")).toBe(true);
expect(stripKeys.has("OPENCLAW_API_KEY")).toBe(false);
});
});
describe("resolveAcpClientSpawnInvocation", () => {

View File

@@ -19,6 +19,10 @@ import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
} from "../plugin-sdk/windows-spawn.js";
import {
listKnownProviderAuthEnvVarNames,
omitEnvKeysCaseInsensitive,
} from "../secrets/provider-env-vars.js";
import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js";
const SAFE_AUTO_APPROVE_TOOL_IDS = new Set(["read", "search", "web_search", "memory_search"]);
@@ -346,20 +350,56 @@ function buildServerArgs(opts: AcpClientOptions): string[] {
return args;
}
type AcpClientSpawnEnvOptions = {
stripKeys?: Iterable<string>;
};
export function resolveAcpClientSpawnEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
options?: { stripKeys?: ReadonlySet<string> },
options: AcpClientSpawnEnvOptions = {},
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...baseEnv };
if (options?.stripKeys) {
for (const key of options.stripKeys) {
delete env[key];
}
}
const env = omitEnvKeysCaseInsensitive(baseEnv, options.stripKeys ?? []);
env.OPENCLAW_SHELL = "acp-client";
return env;
}
export function shouldStripProviderAuthEnvVarsForAcpServer(
params: {
serverCommand?: string;
serverArgs?: string[];
defaultServerCommand?: string;
defaultServerArgs?: string[];
} = {},
): boolean {
const serverCommand = params.serverCommand?.trim();
if (!serverCommand) {
return true;
}
const defaultServerCommand = params.defaultServerCommand?.trim();
if (!defaultServerCommand || serverCommand !== defaultServerCommand) {
return false;
}
const serverArgs = params.serverArgs ?? [];
const defaultServerArgs = params.defaultServerArgs ?? [];
return (
serverArgs.length === defaultServerArgs.length &&
serverArgs.every((arg, index) => arg === defaultServerArgs[index])
);
}
export function buildAcpClientStripKeys(params: {
stripProviderAuthEnvVars?: boolean;
activeSkillEnvKeys?: Iterable<string>;
}): Set<string> {
const stripKeys = new Set<string>(params.activeSkillEnvKeys ?? []);
if (params.stripProviderAuthEnvVars) {
for (const key of listKnownProviderAuthEnvVarNames()) {
stripKeys.add(key);
}
}
return stripKeys;
}
type AcpSpawnRuntime = {
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
@@ -456,12 +496,22 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise<AcpC
const serverArgs = buildServerArgs(opts);
const entryPath = resolveSelfEntryPath();
const serverCommand = opts.serverCommand ?? (entryPath ? process.execPath : "openclaw");
const effectiveArgs = opts.serverCommand || !entryPath ? serverArgs : [entryPath, ...serverArgs];
const defaultServerCommand = entryPath ? process.execPath : "openclaw";
const defaultServerArgs = entryPath ? [entryPath, ...serverArgs] : serverArgs;
const serverCommand = opts.serverCommand ?? defaultServerCommand;
const effectiveArgs = opts.serverCommand || !entryPath ? serverArgs : defaultServerArgs;
const { getActiveSkillEnvKeys } = await import("../agents/skills/env-overrides.runtime.js");
const spawnEnv = resolveAcpClientSpawnEnv(process.env, {
stripKeys: getActiveSkillEnvKeys(),
const stripProviderAuthEnvVars = shouldStripProviderAuthEnvVarsForAcpServer({
serverCommand,
serverArgs: effectiveArgs,
defaultServerCommand,
defaultServerArgs,
});
const stripKeys = buildAcpClientStripKeys({
stripProviderAuthEnvVars,
activeSkillEnvKeys: getActiveSkillEnvKeys(),
});
const spawnEnv = resolveAcpClientSpawnEnv(process.env, { stripKeys });
const spawnInvocation = resolveAcpClientSpawnInvocation(
{ serverCommand, serverArgs: effectiveArgs },
{

View File

@@ -32,3 +32,7 @@ export {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgramCandidate,
} from "./windows-spawn.js";
export {
listKnownProviderAuthEnvVarNames,
omitEnvKeysCaseInsensitive,
} from "../secrets/provider-env-vars.js";

View File

@@ -98,6 +98,12 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof msteamsSdk.loadOutboundMediaFromUrl).toBe("function");
});
it("exports acpx helpers", async () => {
const acpxSdk = await import("openclaw/plugin-sdk/acpx");
expect(typeof acpxSdk.listKnownProviderAuthEnvVarNames).toBe("function");
expect(typeof acpxSdk.omitEnvKeysCaseInsensitive).toBe("function");
});
it("resolves bundled extension subpaths", async () => {
for (const { id, load } of bundledExtensionSubpathLoaders) {
const mod = await load();

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
listKnownProviderAuthEnvVarNames,
listKnownSecretEnvVarNames,
omitEnvKeysCaseInsensitive,
} from "./provider-env-vars.js";
describe("provider env vars", () => {
it("keeps the auth scrub list broader than the global secret env list", () => {
expect(listKnownProviderAuthEnvVarNames()).toEqual(
expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]),
);
expect(listKnownSecretEnvVarNames()).not.toEqual(listKnownProviderAuthEnvVarNames());
expect(listKnownSecretEnvVarNames()).not.toEqual(
expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]),
);
expect(listKnownSecretEnvVarNames()).not.toContain("OPENCLAW_API_KEY");
});
it("omits env keys case-insensitively", () => {
const env = omitEnvKeysCaseInsensitive(
{
OpenAI_Api_Key: "openai-secret",
Github_Token: "gh-secret",
OPENCLAW_API_KEY: "keep-me",
},
["OPENAI_API_KEY", "GITHUB_TOKEN"],
);
expect(env.OpenAI_Api_Key).toBeUndefined();
expect(env.Github_Token).toBeUndefined();
expect(env.OPENCLAW_API_KEY).toBe("keep-me");
});
});

View File

@@ -26,6 +26,62 @@ export const PROVIDER_ENV_VARS: Record<string, readonly string[]> = {
byteplus: ["BYTEPLUS_API_KEY"],
};
export function listKnownSecretEnvVarNames(): string[] {
return [...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys))];
const EXTRA_PROVIDER_AUTH_ENV_VARS = [
"VOYAGE_API_KEY",
"GROQ_API_KEY",
"DEEPGRAM_API_KEY",
"CEREBRAS_API_KEY",
"NVIDIA_API_KEY",
"COPILOT_GITHUB_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
"ANTHROPIC_OAUTH_TOKEN",
"CHUTES_OAUTH_TOKEN",
"CHUTES_API_KEY",
"QWEN_OAUTH_TOKEN",
"QWEN_PORTAL_API_KEY",
"MINIMAX_OAUTH_TOKEN",
"OLLAMA_API_KEY",
"VLLM_API_KEY",
] as const;
const KNOWN_SECRET_ENV_VARS = [
...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys)),
];
// OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must
// remain available to child bridge/runtime processes.
const KNOWN_PROVIDER_AUTH_ENV_VARS = [
...new Set([...KNOWN_SECRET_ENV_VARS, ...EXTRA_PROVIDER_AUTH_ENV_VARS]),
];
export function listKnownProviderAuthEnvVarNames(): string[] {
return [...KNOWN_PROVIDER_AUTH_ENV_VARS];
}
export function listKnownSecretEnvVarNames(): string[] {
return [...KNOWN_SECRET_ENV_VARS];
}
export function omitEnvKeysCaseInsensitive(
baseEnv: NodeJS.ProcessEnv,
keys: Iterable<string>,
): NodeJS.ProcessEnv {
const env = { ...baseEnv };
const denied = new Set<string>();
for (const key of keys) {
const normalizedKey = key.trim();
if (normalizedKey) {
denied.add(normalizedKey.toUpperCase());
}
}
if (denied.size === 0) {
return env;
}
for (const actualKey of Object.keys(env)) {
if (denied.has(actualKey.toUpperCase())) {
delete env[actualKey];
}
}
return env;
}