diff --git a/extensions/acpx/src/codex-auth-bridge.test.ts b/extensions/acpx/src/codex-auth-bridge.test.ts index 8b4609a6cc6..4742d68dc19 100644 --- a/extensions/acpx/src/codex-auth-bridge.test.ts +++ b/extensions/acpx/src/codex-auth-bridge.test.ts @@ -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); }); }); diff --git a/extensions/acpx/src/codex-auth-bridge.ts b/extensions/acpx/src/codex-auth-bridge.ts index 60d553435f7..e63cb2609e8 100644 --- a/extensions/acpx/src/codex-auth-bridge.ts +++ b/extensions/acpx/src/codex-auth-bridge.ts @@ -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 { + 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 { + 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 { - 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), + }, + }; }