fix(acpx): bundle Codex ACP adapter

This commit is contained in:
Peter Steinberger
2026-04-28 04:38:58 +01:00
parent 4fb543796b
commit 093dba3806
9 changed files with 260 additions and 41 deletions

View File

@@ -4,6 +4,7 @@
"description": "OpenClaw ACP runtime backend",
"type": "module",
"dependencies": {
"@zed-industries/codex-acp": "0.12.0",
"acpx": "0.6.1"
},
"devDependencies": {

View File

@@ -211,8 +211,8 @@ ${ACPX_CMD} codex sessions close oc-codex-<conversationId>
Defaults are:
- `openclaw -> openclaw acp`
- `claude -> npx -y @zed-industries/claude-agent-acp@0.21.0`
- `codex -> npx @zed-industries/codex-acp@^0.9.5`
- `claude -> npx -y @agentclientprotocol/claude-agent-acp@^0.31.0`
- `codex -> bundled @zed-industries/codex-acp@0.12.0 through OpenClaw's isolated CODEX_HOME wrapper`
- `copilot -> copilot --acp --stdio`
- `cursor -> cursor-agent acp`
- `droid -> droid exec --output-format acp`

View File

@@ -1,10 +1,13 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
import { resolveAcpxPluginConfig } from "./config.js";
const execFileAsync = promisify(execFile);
const tempDirs: string[] = [];
const previousEnv = {
CODEX_HOME: process.env.CODEX_HOME,
@@ -59,6 +62,14 @@ describe("prepareAcpxCodexAuthConfig", () => {
const agentDir = path.join(root, "agent");
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const installedBinPath = path.join(
root,
"node_modules",
"@zed-industries",
"codex-acp",
"bin",
"codex-acp.js",
);
process.env.OPENCLAW_AGENT_DIR = agentDir;
delete process.env.PI_CODING_AGENT_DIR;
@@ -69,17 +80,90 @@ describe("prepareAcpxCodexAuthConfig", () => {
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
});
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"');
expect(wrapper).toContain(JSON.stringify(installedBinPath));
expect(wrapper).toContain("defaultArgs = [installedBinPath]");
await expect(
fs.access(path.join(agentDir, "acp-auth", "codex", "auth.json")),
).rejects.toMatchObject({ code: "ENOENT" });
});
it("falls back to the current Codex ACP package range when the local adapter is unavailable", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => undefined,
});
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
expect(wrapper).toContain('"@zed-industries/codex-acp@^0.12.0"');
expect(wrapper).toContain('"--", "codex-acp"');
expect(wrapper).not.toContain("@zed-industries/codex-acp@^0.11.1");
});
it("uses the bundled Codex ACP dependency by default when it is installed", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
});
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
expect(wrapper).toContain("@zed-industries/codex-acp");
expect(wrapper).toContain("bin/codex-acp.js");
expect(wrapper).toContain("defaultArgs = [installedBinPath]");
});
it("launches the locally installed Codex ACP bin with isolated CODEX_HOME", async () => {
const root = await makeTempDir();
const stateDir = path.join(root, "state");
const generated = generatedCodexPaths(stateDir);
const installedBinPath = path.join(root, "codex-acp-bin.js");
await fs.writeFile(
installedBinPath,
"console.log(JSON.stringify({ argv: process.argv.slice(2), codexHome: process.env.CODEX_HOME }));\n",
"utf8",
);
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
});
const { stdout } = await execFileAsync(process.execPath, [generated.wrapperPath], {
cwd: root,
});
const launched = JSON.parse(stdout.trim()) as { argv?: unknown; codexHome?: unknown };
expect(launched.argv).toEqual([]);
const expectedCodexHome = await fs.realpath(path.join(stateDir, "acpx", "codex-home"));
expect(path.resolve(String(launched.codexHome))).toBe(expectedCodexHome);
});
it("does not copy source Codex auth", async () => {
const root = await makeTempDir();
const sourceCodexHome = path.join(root, "source-codex");
@@ -106,6 +190,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => undefined,
});
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
@@ -138,7 +223,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
rawConfig: {
agents: {
codex: {
command: "npx @zed-industries/codex-acp@^0.11.1 -c 'model=\"gpt-5.4\"'",
command: "npx @zed-industries/codex-acp@0.12.0 -c 'model=\"gpt-5.4\"'",
},
},
},
@@ -148,10 +233,11 @@ describe("prepareAcpxCodexAuthConfig", () => {
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
resolveInstalledCodexAcpBinPath: async () => path.join(root, "codex-acp.js"),
});
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
expect(resolved.agents.codex).toContain("npx @zed-industries/codex-acp@^0.11.1");
expect(resolved.agents.codex).toContain("npx @zed-industries/codex-acp@0.12.0");
expect(resolved.agents.codex).toContain("-c 'model=\"gpt-5.4\"'");
const isolatedConfig = await fs.readFile(generated.configPath, "utf8");
expect(isolatedConfig).not.toContain("notify");

View File

@@ -1,16 +1,60 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
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_PACKAGE_RANGE = "^0.12.0";
const CODEX_ACP_BIN = "codex-acp";
const requireFromHere = createRequire(import.meta.url);
type PackageManifest = {
name?: unknown;
bin?: unknown;
};
function quoteCommandPart(value: string): string {
return JSON.stringify(value);
}
function buildCodexAcpWrapperScript(): string {
function resolvePackageBinPath(
packageJsonPath: string,
manifest: PackageManifest,
): string | undefined {
const { bin } = manifest;
const relativeBinPath =
typeof bin === "string"
? bin
: bin && typeof bin === "object"
? (bin as Record<string, unknown>)[CODEX_ACP_BIN]
: undefined;
if (typeof relativeBinPath !== "string" || relativeBinPath.trim() === "") {
return undefined;
}
return path.resolve(path.dirname(packageJsonPath), relativeBinPath);
}
async function resolveInstalledCodexAcpBinPath(): Promise<string | undefined> {
try {
// Keep OpenClaw's isolated CODEX_HOME wrapper, but launch the plugin-local
// Codex ACP adapter when runtime-deps staging made it available.
const packageJsonPath = requireFromHere.resolve(`${CODEX_ACP_PACKAGE}/package.json`);
const manifest = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as PackageManifest;
if (manifest.name !== CODEX_ACP_PACKAGE) {
return undefined;
}
const binPath = resolvePackageBinPath(packageJsonPath, manifest);
if (!binPath) {
return undefined;
}
await fs.access(binPath);
return binPath;
} catch {
return undefined;
}
}
function buildCodexAcpWrapperScript(installedBinPath?: string): string {
return `#!/usr/bin/env node
import { existsSync } from "node:fs";
import path from "node:path";
@@ -38,10 +82,19 @@ function resolveNpmCliPath() {
}
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 installedBinPath = ${installedBinPath ? quoteCommandPart(installedBinPath) : "undefined"};
let defaultCommand;
let defaultArgs;
if (installedBinPath) {
defaultCommand = process.execPath;
defaultArgs = [installedBinPath];
} else if (npmCliPath) {
defaultCommand = process.execPath;
defaultArgs = [npmCliPath, "exec", "--yes", "--package", "${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_RANGE}", "--", "${CODEX_ACP_BIN}"];
} else {
defaultCommand = process.platform === "win32" ? "npx.cmd" : "npx";
defaultArgs = ["--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;
@@ -82,10 +135,12 @@ async function prepareIsolatedCodexHome(baseDir: string): Promise<string> {
return codexHome;
}
async function writeCodexAcpWrapper(baseDir: string): Promise<string> {
async function writeCodexAcpWrapper(baseDir: string, installedBinPath?: 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.writeFile(wrapperPath, buildCodexAcpWrapperScript(installedBinPath), {
encoding: "utf8",
});
await fs.chmod(wrapperPath, 0o755);
return wrapperPath;
}
@@ -101,11 +156,15 @@ export async function prepareAcpxCodexAuthConfig(params: {
pluginConfig: ResolvedAcpxPluginConfig;
stateDir: string;
logger?: unknown;
resolveInstalledCodexAcpBinPath?: () => Promise<string | undefined>;
}): Promise<ResolvedAcpxPluginConfig> {
void params.logger;
const codexBaseDir = path.join(params.stateDir, "acpx");
await prepareIsolatedCodexHome(codexBaseDir);
const wrapperPath = await writeCodexAcpWrapper(codexBaseDir);
const installedBinPath = await (
params.resolveInstalledCodexAcpBinPath ?? resolveInstalledCodexAcpBinPath
)();
const wrapperPath = await writeCodexAcpWrapper(codexBaseDir, installedBinPath);
const configuredCodexCommand = params.pluginConfig.agents.codex;
return {

View File

@@ -17,6 +17,7 @@ describe("acpx package manifest", () => {
) as AcpxPackageManifest;
expect(packageJson.dependencies?.acpx).toBeDefined();
expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.12.0");
expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true);
});
});

View File

@@ -9,7 +9,7 @@ type TestSessionStore = {
const DOCUMENTED_OPENCLAW_BRIDGE_COMMAND =
"env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main";
const CODEX_ACP_COMMAND = "npx @zed-industries/codex-acp@^0.11.1";
const CODEX_ACP_COMMAND = "npx @zed-industries/codex-acp@^0.12.0";
const CODEX_ACP_WRAPPER_COMMAND = `node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"`;
function makeRuntime(
@@ -189,7 +189,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
reasoningEffort: "medium",
}),
).toBe(
"npx @zed-industries/codex-acp@^0.11.1 -c model=gpt-5.4 -c model_reasoning_effort=medium",
"npx @zed-industries/codex-acp@^0.12.0 -c model=gpt-5.4 -c model_reasoning_effort=medium",
);
expect(__testing.isCodexAcpCommand("openclaw acp")).toBe(false);
});