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,
|
ACPX_PINNED_VERSION,
|
||||||
createAcpxPluginConfigSchema,
|
createAcpxPluginConfigSchema,
|
||||||
resolveAcpxPluginConfig,
|
resolveAcpxPluginConfig,
|
||||||
toAcpMcpServers,
|
|
||||||
} from "./config.js";
|
} from "./config.js";
|
||||||
|
|
||||||
describe("acpx plugin config parsing", () => {
|
describe("acpx plugin config parsing", () => {
|
||||||
@@ -20,9 +19,9 @@ describe("acpx plugin config parsing", () => {
|
|||||||
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
|
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
|
||||||
expect(resolved.expectedVersion).toBe(ACPX_PINNED_VERSION);
|
expect(resolved.expectedVersion).toBe(ACPX_PINNED_VERSION);
|
||||||
expect(resolved.allowPluginLocalInstall).toBe(true);
|
expect(resolved.allowPluginLocalInstall).toBe(true);
|
||||||
|
expect(resolved.stripProviderAuthEnvVars).toBe(true);
|
||||||
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
|
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
|
||||||
expect(resolved.strictWindowsCmdWrapper).toBe(true);
|
expect(resolved.strictWindowsCmdWrapper).toBe(true);
|
||||||
expect(resolved.mcpServers).toEqual({});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts command override and disables plugin-local auto-install", () => {
|
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.command).toBe(path.resolve(command));
|
||||||
expect(resolved.expectedVersion).toBeUndefined();
|
expect(resolved.expectedVersion).toBeUndefined();
|
||||||
expect(resolved.allowPluginLocalInstall).toBe(false);
|
expect(resolved.allowPluginLocalInstall).toBe(false);
|
||||||
|
expect(resolved.stripProviderAuthEnvVars).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves relative command paths against workspace directory", () => {
|
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.command).toBe(path.resolve("/home/user/repos/openclaw", "../acpx/dist/cli.js"));
|
||||||
expect(resolved.expectedVersion).toBeUndefined();
|
expect(resolved.expectedVersion).toBeUndefined();
|
||||||
expect(resolved.allowPluginLocalInstall).toBe(false);
|
expect(resolved.allowPluginLocalInstall).toBe(false);
|
||||||
|
expect(resolved.stripProviderAuthEnvVars).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps bare command names as-is", () => {
|
it("keeps bare command names as-is", () => {
|
||||||
@@ -63,6 +64,7 @@ describe("acpx plugin config parsing", () => {
|
|||||||
expect(resolved.command).toBe("acpx");
|
expect(resolved.command).toBe("acpx");
|
||||||
expect(resolved.expectedVersion).toBeUndefined();
|
expect(resolved.expectedVersion).toBeUndefined();
|
||||||
expect(resolved.allowPluginLocalInstall).toBe(false);
|
expect(resolved.allowPluginLocalInstall).toBe(false);
|
||||||
|
expect(resolved.stripProviderAuthEnvVars).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts exact expectedVersion override", () => {
|
it("accepts exact expectedVersion override", () => {
|
||||||
@@ -78,6 +80,7 @@ describe("acpx plugin config parsing", () => {
|
|||||||
expect(resolved.command).toBe(path.resolve(command));
|
expect(resolved.command).toBe(path.resolve(command));
|
||||||
expect(resolved.expectedVersion).toBe("0.1.99");
|
expect(resolved.expectedVersion).toBe("0.1.99");
|
||||||
expect(resolved.allowPluginLocalInstall).toBe(false);
|
expect(resolved.allowPluginLocalInstall).toBe(false);
|
||||||
|
expect(resolved.stripProviderAuthEnvVars).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats expectedVersion=any as no version constraint", () => {
|
it("treats expectedVersion=any as no version constraint", () => {
|
||||||
@@ -134,97 +137,4 @@ describe("acpx plugin config parsing", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow("strictWindowsCmdWrapper must be a boolean");
|
).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;
|
command: string;
|
||||||
expectedVersion?: string;
|
expectedVersion?: string;
|
||||||
allowPluginLocalInstall: boolean;
|
allowPluginLocalInstall: boolean;
|
||||||
|
stripProviderAuthEnvVars: boolean;
|
||||||
installCommand: string;
|
installCommand: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
permissionMode: AcpxPermissionMode;
|
permissionMode: AcpxPermissionMode;
|
||||||
@@ -332,6 +333,7 @@ export function resolveAcpxPluginConfig(params: {
|
|||||||
workspaceDir: params.workspaceDir,
|
workspaceDir: params.workspaceDir,
|
||||||
});
|
});
|
||||||
const allowPluginLocalInstall = command === ACPX_BUNDLED_BIN;
|
const allowPluginLocalInstall = command === ACPX_BUNDLED_BIN;
|
||||||
|
const stripProviderAuthEnvVars = command === ACPX_BUNDLED_BIN;
|
||||||
const configuredExpectedVersion = normalized.expectedVersion;
|
const configuredExpectedVersion = normalized.expectedVersion;
|
||||||
const expectedVersion =
|
const expectedVersion =
|
||||||
configuredExpectedVersion === ACPX_VERSION_ANY
|
configuredExpectedVersion === ACPX_VERSION_ANY
|
||||||
@@ -343,6 +345,7 @@ export function resolveAcpxPluginConfig(params: {
|
|||||||
command,
|
command,
|
||||||
expectedVersion,
|
expectedVersion,
|
||||||
allowPluginLocalInstall,
|
allowPluginLocalInstall,
|
||||||
|
stripProviderAuthEnvVars,
|
||||||
installCommand,
|
installCommand,
|
||||||
cwd,
|
cwd,
|
||||||
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ describe("acpx ensure", () => {
|
|||||||
command: "/plugin/node_modules/.bin/acpx",
|
command: "/plugin/node_modules/.bin/acpx",
|
||||||
args: ["--version"],
|
args: ["--version"],
|
||||||
cwd: "/plugin",
|
cwd: "/plugin",
|
||||||
|
stripProviderAuthEnvVars: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,6 +149,30 @@ describe("acpx ensure", () => {
|
|||||||
command: "/custom/acpx",
|
command: "/custom/acpx",
|
||||||
args: ["--help"],
|
args: ["--help"],
|
||||||
cwd: "/custom",
|
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 () => {
|
it("fails with actionable error when npm install fails", async () => {
|
||||||
spawnAndCollectMock
|
spawnAndCollectMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export async function checkAcpxVersion(params: {
|
|||||||
command: string;
|
command: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
expectedVersion?: string;
|
expectedVersion?: string;
|
||||||
|
stripProviderAuthEnvVars?: boolean;
|
||||||
spawnOptions?: SpawnCommandOptions;
|
spawnOptions?: SpawnCommandOptions;
|
||||||
}): Promise<AcpxVersionCheckResult> {
|
}): Promise<AcpxVersionCheckResult> {
|
||||||
const expectedVersion = params.expectedVersion?.trim() || undefined;
|
const expectedVersion = params.expectedVersion?.trim() || undefined;
|
||||||
@@ -113,6 +114,7 @@ export async function checkAcpxVersion(params: {
|
|||||||
command: params.command,
|
command: params.command,
|
||||||
args: probeArgs,
|
args: probeArgs,
|
||||||
cwd,
|
cwd,
|
||||||
|
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||||
};
|
};
|
||||||
let result: Awaited<ReturnType<typeof spawnAndCollect>>;
|
let result: Awaited<ReturnType<typeof spawnAndCollect>>;
|
||||||
try {
|
try {
|
||||||
@@ -198,6 +200,7 @@ export async function ensureAcpx(params: {
|
|||||||
pluginRoot?: string;
|
pluginRoot?: string;
|
||||||
expectedVersion?: string;
|
expectedVersion?: string;
|
||||||
allowInstall?: boolean;
|
allowInstall?: boolean;
|
||||||
|
stripProviderAuthEnvVars?: boolean;
|
||||||
spawnOptions?: SpawnCommandOptions;
|
spawnOptions?: SpawnCommandOptions;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (pendingEnsure) {
|
if (pendingEnsure) {
|
||||||
@@ -214,6 +217,7 @@ export async function ensureAcpx(params: {
|
|||||||
command: params.command,
|
command: params.command,
|
||||||
cwd: pluginRoot,
|
cwd: pluginRoot,
|
||||||
expectedVersion,
|
expectedVersion,
|
||||||
|
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||||
spawnOptions: params.spawnOptions,
|
spawnOptions: params.spawnOptions,
|
||||||
});
|
});
|
||||||
if (precheck.ok) {
|
if (precheck.ok) {
|
||||||
@@ -231,6 +235,7 @@ export async function ensureAcpx(params: {
|
|||||||
command: "npm",
|
command: "npm",
|
||||||
args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`],
|
args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`],
|
||||||
cwd: pluginRoot,
|
cwd: pluginRoot,
|
||||||
|
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (install.error) {
|
if (install.error) {
|
||||||
@@ -252,6 +257,7 @@ export async function ensureAcpx(params: {
|
|||||||
command: params.command,
|
command: params.command,
|
||||||
cwd: pluginRoot,
|
cwd: pluginRoot,
|
||||||
expectedVersion,
|
expectedVersion,
|
||||||
|
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||||
spawnOptions: params.spawnOptions,
|
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, "\\$&")}"`;
|
return `"${value.replace(/["\\]/g, "\\$&")}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __testing = {
|
||||||
|
quoteCommandPart,
|
||||||
|
};
|
||||||
|
|
||||||
function toCommandLine(parts: string[]): string {
|
function toCommandLine(parts: string[]): string {
|
||||||
return parts.map(quoteCommandPart).join(" ");
|
return parts.map(quoteCommandPart).join(" ");
|
||||||
}
|
}
|
||||||
@@ -62,6 +66,7 @@ function readConfiguredAgentOverrides(value: unknown): Record<string, string> {
|
|||||||
async function loadAgentOverrides(params: {
|
async function loadAgentOverrides(params: {
|
||||||
acpxCommand: string;
|
acpxCommand: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
stripProviderAuthEnvVars?: boolean;
|
||||||
spawnOptions?: SpawnCommandOptions;
|
spawnOptions?: SpawnCommandOptions;
|
||||||
}): Promise<Record<string, string>> {
|
}): Promise<Record<string, string>> {
|
||||||
const result = await spawnAndCollect(
|
const result = await spawnAndCollect(
|
||||||
@@ -69,6 +74,7 @@ async function loadAgentOverrides(params: {
|
|||||||
command: params.acpxCommand,
|
command: params.acpxCommand,
|
||||||
args: ["--cwd", params.cwd, "config", "show"],
|
args: ["--cwd", params.cwd, "config", "show"],
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
|
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||||
},
|
},
|
||||||
params.spawnOptions,
|
params.spawnOptions,
|
||||||
);
|
);
|
||||||
@@ -87,12 +93,14 @@ export async function resolveAcpxAgentCommand(params: {
|
|||||||
acpxCommand: string;
|
acpxCommand: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
|
stripProviderAuthEnvVars?: boolean;
|
||||||
spawnOptions?: SpawnCommandOptions;
|
spawnOptions?: SpawnCommandOptions;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const normalizedAgent = normalizeAgentName(params.agent);
|
const normalizedAgent = normalizeAgentName(params.agent);
|
||||||
const overrides = await loadAgentOverrides({
|
const overrides = await loadAgentOverrides({
|
||||||
acpxCommand: params.acpxCommand,
|
acpxCommand: params.acpxCommand,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
|
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||||
spawnOptions: params.spawnOptions,
|
spawnOptions: params.spawnOptions,
|
||||||
});
|
});
|
||||||
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? params.agent;
|
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 { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
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 { createWindowsCmdShimFixture } from "../../../shared/windows-cmd-shim-test-fixtures.js";
|
||||||
import {
|
import {
|
||||||
resolveSpawnCommand,
|
resolveSpawnCommand,
|
||||||
@@ -28,6 +28,7 @@ async function createTempDir(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
while (tempDirs.length > 0) {
|
while (tempDirs.length > 0) {
|
||||||
const dir = tempDirs.pop();
|
const dir = tempDirs.pop();
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
@@ -289,4 +290,99 @@ describe("spawnAndCollect", () => {
|
|||||||
const result = await resultPromise;
|
const result = await resultPromise;
|
||||||
expect(result.error?.name).toBe("AbortError");
|
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";
|
} from "openclaw/plugin-sdk/acpx";
|
||||||
import {
|
import {
|
||||||
applyWindowsSpawnProgramPolicy,
|
applyWindowsSpawnProgramPolicy,
|
||||||
|
listKnownProviderAuthEnvVarNames,
|
||||||
materializeWindowsSpawnProgram,
|
materializeWindowsSpawnProgram,
|
||||||
|
omitEnvKeysCaseInsensitive,
|
||||||
resolveWindowsSpawnProgramCandidate,
|
resolveWindowsSpawnProgramCandidate,
|
||||||
} from "openclaw/plugin-sdk/acpx";
|
} from "openclaw/plugin-sdk/acpx";
|
||||||
|
|
||||||
@@ -125,6 +127,7 @@ export function spawnWithResolvedCommand(
|
|||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
stripProviderAuthEnvVars?: boolean;
|
||||||
},
|
},
|
||||||
options?: SpawnCommandOptions,
|
options?: SpawnCommandOptions,
|
||||||
): ChildProcessWithoutNullStreams {
|
): ChildProcessWithoutNullStreams {
|
||||||
@@ -136,9 +139,15 @@ export function spawnWithResolvedCommand(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const childEnv = omitEnvKeysCaseInsensitive(
|
||||||
|
process.env,
|
||||||
|
params.stripProviderAuthEnvVars ? listKnownProviderAuthEnvVarNames() : [],
|
||||||
|
);
|
||||||
|
childEnv.OPENCLAW_SHELL = "acp";
|
||||||
|
|
||||||
return spawn(resolved.command, resolved.args, {
|
return spawn(resolved.command, resolved.args, {
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: { ...process.env, OPENCLAW_SHELL: "acp" },
|
env: childEnv,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
shell: resolved.shell,
|
shell: resolved.shell,
|
||||||
windowsHide: resolved.windowsHide,
|
windowsHide: resolved.windowsHide,
|
||||||
@@ -180,6 +189,7 @@ export async function spawnAndCollect(
|
|||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
stripProviderAuthEnvVars?: boolean;
|
||||||
},
|
},
|
||||||
options?: SpawnCommandOptions,
|
options?: SpawnCommandOptions,
|
||||||
runtime?: {
|
runtime?: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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 { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
|
||||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||||
import {
|
import {
|
||||||
@@ -19,13 +19,14 @@ beforeAll(async () => {
|
|||||||
{
|
{
|
||||||
command: "/definitely/missing/acpx",
|
command: "/definitely/missing/acpx",
|
||||||
allowPluginLocalInstall: false,
|
allowPluginLocalInstall: false,
|
||||||
|
stripProviderAuthEnvVars: false,
|
||||||
installCommand: "n/a",
|
installCommand: "n/a",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
mcpServers: {},
|
|
||||||
permissionMode: "approve-reads",
|
permissionMode: "approve-reads",
|
||||||
nonInteractivePermissions: "fail",
|
nonInteractivePermissions: "fail",
|
||||||
strictWindowsCmdWrapper: true,
|
strictWindowsCmdWrapper: true,
|
||||||
queueOwnerTtlSeconds: 0.1,
|
queueOwnerTtlSeconds: 0.1,
|
||||||
|
mcpServers: {},
|
||||||
},
|
},
|
||||||
{ logger: NOOP_LOGGER },
|
{ logger: NOOP_LOGGER },
|
||||||
);
|
);
|
||||||
@@ -165,7 +166,7 @@ describe("AcpxRuntime", () => {
|
|||||||
for await (const _event of runtime.runTurn({
|
for await (const _event of runtime.runTurn({
|
||||||
handle,
|
handle,
|
||||||
text: "describe this image",
|
text: "describe this image",
|
||||||
attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }],
|
attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }], // pragma: allowlist secret
|
||||||
mode: "prompt",
|
mode: "prompt",
|
||||||
requestId: "req-image",
|
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 () => {
|
it("preserves leading spaces across streamed text deltas", async () => {
|
||||||
const runtime = sharedFixture?.runtime;
|
const runtime = sharedFixture?.runtime;
|
||||||
expect(runtime).toBeDefined();
|
expect(runtime).toBeDefined();
|
||||||
@@ -395,7 +430,7 @@ describe("AcpxRuntime", () => {
|
|||||||
command: "npx",
|
command: "npx",
|
||||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||||
env: {
|
env: {
|
||||||
CANVA_TOKEN: "secret",
|
CANVA_TOKEN: "secret", // pragma: allowlist secret
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
command: this.config.command,
|
command: this.config.command,
|
||||||
cwd: this.config.cwd,
|
cwd: this.config.cwd,
|
||||||
expectedVersion: this.config.expectedVersion,
|
expectedVersion: this.config.expectedVersion,
|
||||||
|
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||||
spawnOptions: this.spawnCommandOptions,
|
spawnOptions: this.spawnCommandOptions,
|
||||||
});
|
});
|
||||||
if (!versionCheck.ok) {
|
if (!versionCheck.ok) {
|
||||||
@@ -183,6 +184,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
command: this.config.command,
|
command: this.config.command,
|
||||||
args: ["--help"],
|
args: ["--help"],
|
||||||
cwd: this.config.cwd,
|
cwd: this.config.cwd,
|
||||||
|
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||||
},
|
},
|
||||||
this.spawnCommandOptions,
|
this.spawnCommandOptions,
|
||||||
);
|
);
|
||||||
@@ -309,6 +311,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
command: this.config.command,
|
command: this.config.command,
|
||||||
args,
|
args,
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
|
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||||
},
|
},
|
||||||
this.spawnCommandOptions,
|
this.spawnCommandOptions,
|
||||||
);
|
);
|
||||||
@@ -495,6 +498,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
command: this.config.command,
|
command: this.config.command,
|
||||||
cwd: this.config.cwd,
|
cwd: this.config.cwd,
|
||||||
expectedVersion: this.config.expectedVersion,
|
expectedVersion: this.config.expectedVersion,
|
||||||
|
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||||
spawnOptions: this.spawnCommandOptions,
|
spawnOptions: this.spawnCommandOptions,
|
||||||
});
|
});
|
||||||
if (!versionCheck.ok) {
|
if (!versionCheck.ok) {
|
||||||
@@ -518,6 +522,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
command: this.config.command,
|
command: this.config.command,
|
||||||
args: ["--help"],
|
args: ["--help"],
|
||||||
cwd: this.config.cwd,
|
cwd: this.config.cwd,
|
||||||
|
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||||
},
|
},
|
||||||
this.spawnCommandOptions,
|
this.spawnCommandOptions,
|
||||||
);
|
);
|
||||||
@@ -683,6 +688,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
acpxCommand: this.config.command,
|
acpxCommand: this.config.command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
agent: params.agent,
|
agent: params.agent,
|
||||||
|
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||||
spawnOptions: this.spawnCommandOptions,
|
spawnOptions: this.spawnCommandOptions,
|
||||||
});
|
});
|
||||||
const resolved = buildMcpProxyAgentCommand({
|
const resolved = buildMcpProxyAgentCommand({
|
||||||
@@ -705,6 +711,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
command: this.config.command,
|
command: this.config.command,
|
||||||
args: params.args,
|
args: params.args,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
|
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||||
},
|
},
|
||||||
this.spawnCommandOptions,
|
this.spawnCommandOptions,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ describe("createAcpxRuntimeService", () => {
|
|||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(ensureAcpxSpy).toHaveBeenCalledOnce();
|
expect(ensureAcpxSpy).toHaveBeenCalledOnce();
|
||||||
|
expect(ensureAcpxSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
stripProviderAuthEnvVars: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
|
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,9 +59,8 @@ export function createAcpxRuntimeService(
|
|||||||
});
|
});
|
||||||
const expectedVersionLabel = pluginConfig.expectedVersion ?? "any";
|
const expectedVersionLabel = pluginConfig.expectedVersion ?? "any";
|
||||||
const installLabel = pluginConfig.allowPluginLocalInstall ? "enabled" : "disabled";
|
const installLabel = pluginConfig.allowPluginLocalInstall ? "enabled" : "disabled";
|
||||||
const mcpServerCount = Object.keys(pluginConfig.mcpServers).length;
|
|
||||||
ctx.logger.info(
|
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;
|
lifecycleRevision += 1;
|
||||||
@@ -73,6 +72,7 @@ export function createAcpxRuntimeService(
|
|||||||
logger: ctx.logger,
|
logger: ctx.logger,
|
||||||
expectedVersion: pluginConfig.expectedVersion,
|
expectedVersion: pluginConfig.expectedVersion,
|
||||||
allowInstall: pluginConfig.allowPluginLocalInstall,
|
allowInstall: pluginConfig.allowPluginLocalInstall,
|
||||||
|
stripProviderAuthEnvVars: pluginConfig.stripProviderAuthEnvVars,
|
||||||
spawnOptions: {
|
spawnOptions: {
|
||||||
strictWindowsCmdWrapper: pluginConfig.strictWindowsCmdWrapper,
|
strictWindowsCmdWrapper: pluginConfig.strictWindowsCmdWrapper,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ if (command === "prompt") {
|
|||||||
sessionName: sessionFromOption,
|
sessionName: sessionFromOption,
|
||||||
stdinText,
|
stdinText,
|
||||||
openclawShell,
|
openclawShell,
|
||||||
|
openaiApiKey: process.env.OPENAI_API_KEY || "",
|
||||||
|
githubToken: process.env.GITHUB_TOKEN || "",
|
||||||
});
|
});
|
||||||
const requestId = "req-1";
|
const requestId = "req-1";
|
||||||
|
|
||||||
@@ -326,6 +328,7 @@ export async function createMockRuntimeFixture(params?: {
|
|||||||
const config: ResolvedAcpxPluginConfig = {
|
const config: ResolvedAcpxPluginConfig = {
|
||||||
command: scriptPath,
|
command: scriptPath,
|
||||||
allowPluginLocalInstall: false,
|
allowPluginLocalInstall: false,
|
||||||
|
stripProviderAuthEnvVars: false,
|
||||||
installCommand: "n/a",
|
installCommand: "n/a",
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
permissionMode: params?.permissionMode ?? "approve-all",
|
permissionMode: params?.permissionMode ?? "approve-all",
|
||||||
@@ -378,6 +381,7 @@ export async function readMockRuntimeLogEntries(
|
|||||||
|
|
||||||
export async function cleanupMockRuntimeFixtures(): Promise<void> {
|
export async function cleanupMockRuntimeFixtures(): Promise<void> {
|
||||||
delete process.env.MOCK_ACPX_LOG;
|
delete process.env.MOCK_ACPX_LOG;
|
||||||
|
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
|
||||||
sharedMockCliScriptPath = null;
|
sharedMockCliScriptPath = null;
|
||||||
logFileSequence = 0;
|
logFileSequence = 0;
|
||||||
while (tempDirs.length > 0) {
|
while (tempDirs.length > 0) {
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
||||||
import {
|
import {
|
||||||
|
buildAcpClientStripKeys,
|
||||||
resolveAcpClientSpawnEnv,
|
resolveAcpClientSpawnEnv,
|
||||||
resolveAcpClientSpawnInvocation,
|
resolveAcpClientSpawnInvocation,
|
||||||
resolvePermissionRequest,
|
resolvePermissionRequest,
|
||||||
|
shouldStripProviderAuthEnvVarsForAcpServer,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
|
import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
|
||||||
|
|
||||||
@@ -110,6 +112,120 @@ describe("resolveAcpClientSpawnEnv", () => {
|
|||||||
expect(env.OPENCLAW_SHELL).toBe("acp-client");
|
expect(env.OPENCLAW_SHELL).toBe("acp-client");
|
||||||
expect(env.OPENAI_API_KEY).toBeUndefined();
|
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", () => {
|
describe("resolveAcpClientSpawnInvocation", () => {
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
materializeWindowsSpawnProgram,
|
materializeWindowsSpawnProgram,
|
||||||
resolveWindowsSpawnProgram,
|
resolveWindowsSpawnProgram,
|
||||||
} from "../plugin-sdk/windows-spawn.js";
|
} from "../plugin-sdk/windows-spawn.js";
|
||||||
|
import {
|
||||||
|
listKnownProviderAuthEnvVarNames,
|
||||||
|
omitEnvKeysCaseInsensitive,
|
||||||
|
} from "../secrets/provider-env-vars.js";
|
||||||
import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js";
|
import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js";
|
||||||
|
|
||||||
const SAFE_AUTO_APPROVE_TOOL_IDS = new Set(["read", "search", "web_search", "memory_search"]);
|
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;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AcpClientSpawnEnvOptions = {
|
||||||
|
stripKeys?: Iterable<string>;
|
||||||
|
};
|
||||||
|
|
||||||
export function resolveAcpClientSpawnEnv(
|
export function resolveAcpClientSpawnEnv(
|
||||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||||
options?: { stripKeys?: ReadonlySet<string> },
|
options: AcpClientSpawnEnvOptions = {},
|
||||||
): NodeJS.ProcessEnv {
|
): NodeJS.ProcessEnv {
|
||||||
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
const env = omitEnvKeysCaseInsensitive(baseEnv, options.stripKeys ?? []);
|
||||||
if (options?.stripKeys) {
|
|
||||||
for (const key of options.stripKeys) {
|
|
||||||
delete env[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
env.OPENCLAW_SHELL = "acp-client";
|
env.OPENCLAW_SHELL = "acp-client";
|
||||||
return env;
|
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 = {
|
type AcpSpawnRuntime = {
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
@@ -456,12 +496,22 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise<AcpC
|
|||||||
const serverArgs = buildServerArgs(opts);
|
const serverArgs = buildServerArgs(opts);
|
||||||
|
|
||||||
const entryPath = resolveSelfEntryPath();
|
const entryPath = resolveSelfEntryPath();
|
||||||
const serverCommand = opts.serverCommand ?? (entryPath ? process.execPath : "openclaw");
|
const defaultServerCommand = entryPath ? process.execPath : "openclaw";
|
||||||
const effectiveArgs = opts.serverCommand || !entryPath ? serverArgs : [entryPath, ...serverArgs];
|
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 { getActiveSkillEnvKeys } = await import("../agents/skills/env-overrides.runtime.js");
|
||||||
const spawnEnv = resolveAcpClientSpawnEnv(process.env, {
|
const stripProviderAuthEnvVars = shouldStripProviderAuthEnvVarsForAcpServer({
|
||||||
stripKeys: getActiveSkillEnvKeys(),
|
serverCommand,
|
||||||
|
serverArgs: effectiveArgs,
|
||||||
|
defaultServerCommand,
|
||||||
|
defaultServerArgs,
|
||||||
});
|
});
|
||||||
|
const stripKeys = buildAcpClientStripKeys({
|
||||||
|
stripProviderAuthEnvVars,
|
||||||
|
activeSkillEnvKeys: getActiveSkillEnvKeys(),
|
||||||
|
});
|
||||||
|
const spawnEnv = resolveAcpClientSpawnEnv(process.env, { stripKeys });
|
||||||
const spawnInvocation = resolveAcpClientSpawnInvocation(
|
const spawnInvocation = resolveAcpClientSpawnInvocation(
|
||||||
{ serverCommand, serverArgs: effectiveArgs },
|
{ serverCommand, serverArgs: effectiveArgs },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,3 +32,7 @@ export {
|
|||||||
materializeWindowsSpawnProgram,
|
materializeWindowsSpawnProgram,
|
||||||
resolveWindowsSpawnProgramCandidate,
|
resolveWindowsSpawnProgramCandidate,
|
||||||
} from "./windows-spawn.js";
|
} 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");
|
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 () => {
|
it("resolves bundled extension subpaths", async () => {
|
||||||
for (const { id, load } of bundledExtensionSubpathLoaders) {
|
for (const { id, load } of bundledExtensionSubpathLoaders) {
|
||||||
const mod = await load();
|
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"],
|
byteplus: ["BYTEPLUS_API_KEY"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function listKnownSecretEnvVarNames(): string[] {
|
const EXTRA_PROVIDER_AUTH_ENV_VARS = [
|
||||||
return [...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys))];
|
"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