mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
fix(acpx): isolate Codex ACP config from desktop hooks
Isolate Codex ACP launches with an OpenClaw-managed CODEX_HOME/config wrapper so global Codex desktop notify hooks do not leak into acpx sessions.\n\nValidation:\n- OPENCLAW_LOCAL_CHECK=0 OPENCLAW_VITEST_MAX_WORKERS=1 pnpm check:changed\n- pnpm test extensions/acpx/src/codex-auth-bridge.test.ts\n\nThanks @91wan.
This commit is contained in:
@@ -27,6 +27,23 @@ function restoreEnv(name: keyof typeof previousEnv): void {
|
||||
}
|
||||
}
|
||||
|
||||
function generatedCodexPaths(stateDir: string): {
|
||||
configPath: string;
|
||||
wrapperPath: string;
|
||||
} {
|
||||
const baseDir = path.join(stateDir, "acpx");
|
||||
const codexHome = path.join(baseDir, "codex-home");
|
||||
return {
|
||||
configPath: path.join(codexHome, "config.toml"),
|
||||
wrapperPath: path.join(baseDir, "codex-acp-wrapper.mjs"),
|
||||
};
|
||||
}
|
||||
|
||||
function expectCodexWrapperCommand(command: string | undefined, wrapperPath: string): void {
|
||||
expect(command).toContain(process.execPath);
|
||||
expect(command).toContain(wrapperPath);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv("CODEX_HOME");
|
||||
restoreEnv("OPENCLAW_AGENT_DIR");
|
||||
@@ -37,10 +54,11 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("prepareAcpxCodexAuthConfig", () => {
|
||||
it("does not synthesize a Codex ACP auth home from canonical OpenClaw OAuth", async () => {
|
||||
it("installs an isolated Codex ACP wrapper without synthesizing auth from canonical OpenClaw OAuth", async () => {
|
||||
const root = await makeTempDir();
|
||||
const agentDir = path.join(root, "agent");
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
@@ -53,10 +71,10 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
stateDir,
|
||||
});
|
||||
|
||||
expect(resolved.agents.codex).toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(stateDir, "acpx", "codex-acp-wrapper.mjs")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
|
||||
await expect(fs.access(generated.wrapperPath)).resolves.toBeUndefined();
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain('"--", "codex-acp"');
|
||||
await expect(
|
||||
fs.access(path.join(agentDir, "acp-auth", "codex", "auth.json")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
@@ -66,11 +84,17 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const agentDir = path.join(root, "agent");
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "auth.json"),
|
||||
`${JSON.stringify({ auth_mode: "apikey", OPENAI_API_KEY: "test-api-key" }, null, 2)}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "config.toml"),
|
||||
'notify = ["SkyComputerUseClient", "turn-ended"]\n',
|
||||
);
|
||||
process.env.CODEX_HOME = sourceCodexHome;
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
@@ -81,10 +105,16 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
});
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir: path.join(root, "state"),
|
||||
stateDir,
|
||||
});
|
||||
|
||||
expect(resolved.agents.codex).toBeUndefined();
|
||||
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
|
||||
const isolatedConfig = await fs.readFile(generated.configPath, "utf8");
|
||||
expect(isolatedConfig).not.toContain("notify");
|
||||
expect(isolatedConfig).not.toContain("SkyComputerUseClient");
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain("CODEX_HOME: codexHome");
|
||||
expect(wrapper).not.toContain(sourceCodexHome);
|
||||
await expect(
|
||||
fs.access(path.join(agentDir, "acp-auth", "codex-source", "auth.json")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
@@ -93,13 +123,22 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("does not override an explicitly configured Codex agent command", async () => {
|
||||
it("wraps an explicitly configured Codex agent command with isolated CODEX_HOME", async () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "config.toml"),
|
||||
'notify = ["SkyComputerUseClient", "turn-ended"]\n',
|
||||
);
|
||||
process.env.CODEX_HOME = sourceCodexHome;
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
codex: {
|
||||
command: "custom-codex-acp",
|
||||
command: "npx @zed-industries/codex-acp@^0.11.1 -c 'model=\"gpt-5.4\"'",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -108,9 +147,18 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir: path.join(root, "state"),
|
||||
stateDir,
|
||||
});
|
||||
|
||||
expect(resolved.agents.codex).toBe("custom-codex-acp");
|
||||
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
|
||||
expect(resolved.agents.codex).toContain("npx @zed-industries/codex-acp@^0.11.1");
|
||||
expect(resolved.agents.codex).toContain("-c 'model=\"gpt-5.4\"'");
|
||||
const isolatedConfig = await fs.readFile(generated.configPath, "utf8");
|
||||
expect(isolatedConfig).not.toContain("notify");
|
||||
expect(isolatedConfig).not.toContain("SkyComputerUseClient");
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain("process.argv.slice(2)");
|
||||
expect(wrapper).toContain("CODEX_HOME: codexHome");
|
||||
expect(wrapper).not.toContain(sourceCodexHome);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,118 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ResolvedAcpxPluginConfig } from "./config.js";
|
||||
|
||||
const CODEX_ACP_PACKAGE = "@zed-industries/codex-acp";
|
||||
const CODEX_ACP_PACKAGE_RANGE = "^0.11.1";
|
||||
const CODEX_ACP_BIN = "codex-acp";
|
||||
|
||||
function quoteCommandPart(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildCodexAcpWrapperScript(): string {
|
||||
return `#!/usr/bin/env node
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const codexHome = fileURLToPath(new URL("./codex-home/", import.meta.url));
|
||||
const env = {
|
||||
...process.env,
|
||||
CODEX_HOME: codexHome,
|
||||
};
|
||||
const configuredArgs = process.argv.slice(2);
|
||||
|
||||
function resolveNpmCliPath() {
|
||||
const candidate = path.resolve(
|
||||
path.dirname(process.execPath),
|
||||
"..",
|
||||
"lib",
|
||||
"node_modules",
|
||||
"npm",
|
||||
"bin",
|
||||
"npm-cli.js",
|
||||
);
|
||||
return existsSync(candidate) ? candidate : undefined;
|
||||
}
|
||||
|
||||
const npmCliPath = resolveNpmCliPath();
|
||||
const defaultCommand = npmCliPath ? process.execPath : process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
const defaultArgs = npmCliPath
|
||||
? [npmCliPath, "exec", "--yes", "--package", "${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_RANGE}", "--", "${CODEX_ACP_BIN}"]
|
||||
: ["--yes", "--package", "${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_RANGE}", "--", "${CODEX_ACP_BIN}"];
|
||||
const command = configuredArgs[0] ?? defaultCommand;
|
||||
const args = configuredArgs.length > 0 ? configuredArgs.slice(1) : defaultArgs;
|
||||
|
||||
const child = spawn(command, args, {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
process.once(signal, () => {
|
||||
child.kill(signal);
|
||||
});
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(\`[openclaw] failed to launch isolated Codex ACP wrapper: \${error.message}\`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code !== null) {
|
||||
process.exit(code);
|
||||
}
|
||||
process.exit(signal ? 1 : 0);
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
async function prepareIsolatedCodexHome(baseDir: string): Promise<string> {
|
||||
const codexHome = path.join(baseDir, "codex-home");
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(codexHome, "config.toml"),
|
||||
"# Generated by OpenClaw for Codex ACP sessions.\n",
|
||||
"utf8",
|
||||
);
|
||||
return codexHome;
|
||||
}
|
||||
|
||||
async function writeCodexAcpWrapper(baseDir: string): Promise<string> {
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
const wrapperPath = path.join(baseDir, "codex-acp-wrapper.mjs");
|
||||
await fs.writeFile(wrapperPath, buildCodexAcpWrapperScript(), { encoding: "utf8" });
|
||||
await fs.chmod(wrapperPath, 0o755);
|
||||
return wrapperPath;
|
||||
}
|
||||
|
||||
function buildCodexAcpWrapperCommand(wrapperPath: string, configuredCommand?: string): string {
|
||||
const baseCommand = `${quoteCommandPart(process.execPath)} ${quoteCommandPart(wrapperPath)}`;
|
||||
const trimmedConfiguredCommand = configuredCommand?.trim();
|
||||
// ACPX stores agent commands as shell-like strings and splits them before spawn.
|
||||
return trimmedConfiguredCommand ? `${baseCommand} ${trimmedConfiguredCommand}` : baseCommand;
|
||||
}
|
||||
|
||||
export async function prepareAcpxCodexAuthConfig(params: {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
stateDir: string;
|
||||
logger?: unknown;
|
||||
}): Promise<ResolvedAcpxPluginConfig> {
|
||||
void params.stateDir;
|
||||
void params.logger;
|
||||
return params.pluginConfig;
|
||||
const codexBaseDir = path.join(params.stateDir, "acpx");
|
||||
await prepareIsolatedCodexHome(codexBaseDir);
|
||||
const wrapperPath = await writeCodexAcpWrapper(codexBaseDir);
|
||||
const configuredCodexCommand = params.pluginConfig.agents.codex;
|
||||
|
||||
return {
|
||||
...params.pluginConfig,
|
||||
agents: {
|
||||
...params.pluginConfig.agents,
|
||||
codex: buildCodexAcpWrapperCommand(wrapperPath, configuredCodexCommand),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user