mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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("\\\\\\");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -89,6 +89,11 @@ describe("createAcpxRuntimeService", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ensureAcpxSpy).toHaveBeenCalledOnce();
|
||||
expect(ensureAcpxSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stripProviderAuthEnvVars: true,
|
||||
}),
|
||||
);
|
||||
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -32,3 +32,7 @@ export {
|
||||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgramCandidate,
|
||||
} from "./windows-spawn.js";
|
||||
export {
|
||||
listKnownProviderAuthEnvVarNames,
|
||||
omitEnvKeysCaseInsensitive,
|
||||
} from "../secrets/provider-env-vars.js";
|
||||
|
||||
@@ -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();
|
||||
|
||||
34
src/secrets/provider-env-vars.test.ts
Normal file
34
src/secrets/provider-env-vars.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user