mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:00:50 +00:00
fix(acpx): bundle Codex ACP adapter
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@zed-industries/codex-acp": "0.12.0",
|
||||
"acpx": "0.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user