mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(acpx): stage Claude ACP adapter runtime dependency
This commit is contained in:
@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Export/session: keep inline export HTML scripts and vendor libraries injected after template formatting so generated session exports open with the app code, markdown renderer, and syntax highlighter present. Fixes #41862 and #49957; carries forward #41861 and #68947. Thanks @briannewman, @martenzi, and @armanddp.
|
||||
- Agents/ACPX: stage the patched Claude ACP adapter as an ACPX runtime dependency and route known Codex/Claude ACP commands through local wrappers, so Gateway runtime no longer depends on live `npx` adapter resolution. Fixes #73202. Thanks @joerod26.
|
||||
- Gateway/hooks: route non-delivered hook completion and error summaries to the target agent's main session instead of the default agent session, preserving multi-agent hook isolation. Fixes #24693; carries forward #68667. Thanks @abersonFAC and @bluesky6868.
|
||||
- Control UI/models: request the configured Gateway model-list view so dashboards with only `models.providers.*.models` show those configured models first instead of flooding the picker with the full built-in catalog. Fixes #65405. Thanks @wbyanclaw.
|
||||
- CLI/models: keep default-model and allowlist pickers on explicit `models.providers.*.models` entries when `models.mode` is `replace` instead of loading the full built-in catalog. Fixes #64950. Thanks @mrozentsvayg.
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.31.0",
|
||||
"@zed-industries/codex-acp": "0.12.0",
|
||||
"acpx": "0.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.31.0",
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
|
||||
@@ -21,6 +21,10 @@ async function makeTempDir(): Promise<string> {
|
||||
return dir;
|
||||
}
|
||||
|
||||
function quoteArg(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function restoreEnv(name: keyof typeof previousEnv): void {
|
||||
const value = previousEnv[name];
|
||||
if (value === undefined) {
|
||||
@@ -42,11 +46,25 @@ function generatedCodexPaths(stateDir: string): {
|
||||
};
|
||||
}
|
||||
|
||||
function generatedClaudePaths(stateDir: string): {
|
||||
wrapperPath: string;
|
||||
} {
|
||||
const baseDir = path.join(stateDir, "acpx");
|
||||
return {
|
||||
wrapperPath: path.join(baseDir, "claude-agent-acp-wrapper.mjs"),
|
||||
};
|
||||
}
|
||||
|
||||
function expectCodexWrapperCommand(command: string | undefined, wrapperPath: string): void {
|
||||
expect(command).toContain(process.execPath);
|
||||
expect(command).toContain(wrapperPath);
|
||||
}
|
||||
|
||||
function expectClaudeWrapperCommand(command: string | undefined, wrapperPath: string): void {
|
||||
expect(command).toContain(process.execPath);
|
||||
expect(command).toContain(wrapperPath);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv("CODEX_HOME");
|
||||
restoreEnv("OPENCLAW_AGENT_DIR");
|
||||
@@ -62,6 +80,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
const agentDir = path.join(root, "agent");
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const generatedClaude = generatedClaudePaths(stateDir);
|
||||
const installedBinPath = path.join(
|
||||
root,
|
||||
"node_modules",
|
||||
@@ -84,7 +103,9 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
});
|
||||
|
||||
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
|
||||
expectClaudeWrapperCommand(resolved.agents.claude, generatedClaude.wrapperPath);
|
||||
await expect(fs.access(generated.wrapperPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(generatedClaude.wrapperPath)).resolves.toBeUndefined();
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain(JSON.stringify(installedBinPath));
|
||||
expect(wrapper).toContain("defaultArgs = [installedBinPath]");
|
||||
@@ -114,6 +135,28 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
expect(wrapper).not.toContain("@zed-industries/codex-acp@^0.11.1");
|
||||
});
|
||||
|
||||
it("falls back to the patched Claude ACP package when the local adapter is unavailable", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledClaudeAcpBinPath: async () => undefined,
|
||||
});
|
||||
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain('"@agentclientprotocol/claude-agent-acp@0.31.0"');
|
||||
expect(wrapper).toContain('"--", "claude-agent-acp"');
|
||||
expect(wrapper).not.toContain("@agentclientprotocol/claude-agent-acp@^0.31.0");
|
||||
expect(wrapper).not.toContain("@agentclientprotocol/claude-agent-acp@0.31.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");
|
||||
@@ -134,6 +177,26 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
expect(wrapper).toContain("defaultArgs = [installedBinPath]");
|
||||
});
|
||||
|
||||
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
});
|
||||
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
expect(wrapper).toContain("@agentclientprotocol/claude-agent-acp");
|
||||
expect(wrapper).toContain("dist/index.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");
|
||||
@@ -164,6 +227,39 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
expect(path.resolve(String(launched.codexHome))).toBe(expectedCodexHome);
|
||||
});
|
||||
|
||||
it("launches the locally installed Claude ACP bin without going through npm", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const installedBinPath = path.join(root, "claude-agent-acp-bin.js");
|
||||
await fs.writeFile(
|
||||
installedBinPath,
|
||||
"console.log(JSON.stringify({ argv: process.argv.slice(2), codexHome: process.env.CODEX_HOME ?? null }));\n",
|
||||
"utf8",
|
||||
);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledClaudeAcpBinPath: async () => installedBinPath,
|
||||
});
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[generated.wrapperPath, "--permission-mode", "bypass"],
|
||||
{
|
||||
cwd: root,
|
||||
},
|
||||
);
|
||||
const launched = JSON.parse(stdout.trim()) as { argv?: unknown; codexHome?: unknown };
|
||||
expect(launched.argv).toEqual(["--permission-mode", "bypass"]);
|
||||
expect(launched.codexHome).toBeNull();
|
||||
});
|
||||
|
||||
it("does not copy source Codex auth", async () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
@@ -208,7 +304,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("wraps an explicitly configured Codex agent command with isolated CODEX_HOME", async () => {
|
||||
it("normalizes an explicitly configured Codex ACP command to the local wrapper", async () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const stateDir = path.join(root, "state");
|
||||
@@ -237,8 +333,9 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
});
|
||||
|
||||
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
|
||||
expect(resolved.agents.codex).toContain("npx @zed-industries/codex-acp@0.12.0");
|
||||
expect(resolved.agents.codex).toContain("-c 'model=\"gpt-5.4\"'");
|
||||
expect(resolved.agents.codex).not.toContain("npx @zed-industries/codex-acp@0.12.0");
|
||||
expect(resolved.agents.codex).toContain(quoteArg("-c"));
|
||||
expect(resolved.agents.codex).toContain(quoteArg('model="gpt-5.4"'));
|
||||
const isolatedConfig = await fs.readFile(generated.configPath, "utf8");
|
||||
expect(isolatedConfig).not.toContain("notify");
|
||||
expect(isolatedConfig).not.toContain("SkyComputerUseClient");
|
||||
@@ -247,4 +344,79 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
expect(wrapper).toContain("CODEX_HOME: codexHome");
|
||||
expect(wrapper).not.toContain(sourceCodexHome);
|
||||
});
|
||||
|
||||
it("normalizes an explicitly configured Claude ACP npx command to the local wrapper", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
claude: {
|
||||
command: "npx -y @agentclientprotocol/claude-agent-acp@0.31.0 --permission-mode bypass",
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
|
||||
});
|
||||
|
||||
expectClaudeWrapperCommand(resolved.agents.claude, generated.wrapperPath);
|
||||
expect(resolved.agents.claude).not.toContain("npx -y @agentclientprotocol/claude-agent-acp");
|
||||
expect(resolved.agents.claude).toContain("--permission-mode");
|
||||
expect(resolved.agents.claude).toContain("bypass");
|
||||
});
|
||||
|
||||
it("leaves a custom Claude agent command alone", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
claude: {
|
||||
command: "node ./custom-claude-wrapper.mjs --flag",
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
|
||||
});
|
||||
|
||||
expect(resolved.agents.claude).toBe("node ./custom-claude-wrapper.mjs --flag");
|
||||
});
|
||||
|
||||
it("does not normalize custom Claude commands that only mention the package name", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const command =
|
||||
"node ./custom-claude-wrapper.mjs @agentclientprotocol/claude-agent-acp@0.31.0 --flag";
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
claude: {
|
||||
command,
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir: root,
|
||||
});
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
|
||||
});
|
||||
|
||||
expect(resolved.agents.claude).toBe(command);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { ResolvedAcpxPluginConfig } from "./config.js";
|
||||
const CODEX_ACP_PACKAGE = "@zed-industries/codex-acp";
|
||||
const CODEX_ACP_PACKAGE_RANGE = "^0.12.0";
|
||||
const CODEX_ACP_BIN = "codex-acp";
|
||||
const CLAUDE_ACP_PACKAGE = "@agentclientprotocol/claude-agent-acp";
|
||||
const CLAUDE_ACP_PACKAGE_VERSION = "0.31.0";
|
||||
const CLAUDE_ACP_BIN = "claude-agent-acp";
|
||||
const RUN_CONFIGURED_COMMAND_SENTINEL = "--openclaw-run-configured";
|
||||
const requireFromHere = createRequire(import.meta.url);
|
||||
|
||||
type PackageManifest = {
|
||||
@@ -17,16 +21,68 @@ function quoteCommandPart(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function splitCommandParts(value: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaping = false;
|
||||
|
||||
for (const ch of value) {
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\" && quote !== "'") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (current) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current += "\\";
|
||||
}
|
||||
if (current) {
|
||||
parts.push(current);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function basename(value: string): string {
|
||||
return value.split(/[\\/]/).pop() ?? value;
|
||||
}
|
||||
|
||||
function resolvePackageBinPath(
|
||||
packageJsonPath: string,
|
||||
manifest: PackageManifest,
|
||||
binName: string,
|
||||
): string | undefined {
|
||||
const { bin } = manifest;
|
||||
const relativeBinPath =
|
||||
typeof bin === "string"
|
||||
? bin
|
||||
: bin && typeof bin === "object"
|
||||
? (bin as Record<string, unknown>)[CODEX_ACP_BIN]
|
||||
? (bin as Record<string, unknown>)[binName]
|
||||
: undefined;
|
||||
if (typeof relativeBinPath !== "string" || relativeBinPath.trim() === "") {
|
||||
return undefined;
|
||||
@@ -34,16 +90,17 @@ function resolvePackageBinPath(
|
||||
return path.resolve(path.dirname(packageJsonPath), relativeBinPath);
|
||||
}
|
||||
|
||||
async function resolveInstalledCodexAcpBinPath(): Promise<string | undefined> {
|
||||
async function resolveInstalledAcpPackageBinPath(
|
||||
packageName: string,
|
||||
binName: string,
|
||||
): 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 packageJsonPath = requireFromHere.resolve(`${packageName}/package.json`);
|
||||
const manifest = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as PackageManifest;
|
||||
if (manifest.name !== CODEX_ACP_PACKAGE) {
|
||||
if (manifest.name !== packageName) {
|
||||
return undefined;
|
||||
}
|
||||
const binPath = resolvePackageBinPath(packageJsonPath, manifest);
|
||||
const binPath = resolvePackageBinPath(packageJsonPath, manifest, binName);
|
||||
if (!binPath) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -54,18 +111,30 @@ async function resolveInstalledCodexAcpBinPath(): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildCodexAcpWrapperScript(installedBinPath?: string): string {
|
||||
async function resolveInstalledCodexAcpBinPath(): Promise<string | undefined> {
|
||||
// Keep OpenClaw's isolated CODEX_HOME wrapper, but launch the plugin-local
|
||||
// Codex ACP adapter when runtime-deps staging made it available.
|
||||
return await resolveInstalledAcpPackageBinPath(CODEX_ACP_PACKAGE, CODEX_ACP_BIN);
|
||||
}
|
||||
|
||||
async function resolveInstalledClaudeAcpBinPath(): Promise<string | undefined> {
|
||||
return await resolveInstalledAcpPackageBinPath(CLAUDE_ACP_PACKAGE, CLAUDE_ACP_BIN);
|
||||
}
|
||||
|
||||
function buildAdapterWrapperScript(params: {
|
||||
displayName: string;
|
||||
packageSpec: string;
|
||||
binName: string;
|
||||
installedBinPath?: string;
|
||||
envSetup: string;
|
||||
}): 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,
|
||||
};
|
||||
${params.envSetup}
|
||||
const configuredArgs = process.argv.slice(2);
|
||||
|
||||
function resolveNpmCliPath() {
|
||||
@@ -82,7 +151,7 @@ function resolveNpmCliPath() {
|
||||
}
|
||||
|
||||
const npmCliPath = resolveNpmCliPath();
|
||||
const installedBinPath = ${installedBinPath ? quoteCommandPart(installedBinPath) : "undefined"};
|
||||
const installedBinPath = ${params.installedBinPath ? quoteCommandPart(params.installedBinPath) : "undefined"};
|
||||
let defaultCommand;
|
||||
let defaultArgs;
|
||||
if (installedBinPath) {
|
||||
@@ -90,13 +159,22 @@ if (installedBinPath) {
|
||||
defaultArgs = [installedBinPath];
|
||||
} else if (npmCliPath) {
|
||||
defaultCommand = process.execPath;
|
||||
defaultArgs = [npmCliPath, "exec", "--yes", "--package", "${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_RANGE}", "--", "${CODEX_ACP_BIN}"];
|
||||
defaultArgs = [npmCliPath, "exec", "--yes", "--package", "${params.packageSpec}", "--", "${params.binName}"];
|
||||
} else {
|
||||
defaultCommand = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
defaultArgs = ["--yes", "--package", "${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_RANGE}", "--", "${CODEX_ACP_BIN}"];
|
||||
defaultArgs = ["--yes", "--package", "${params.packageSpec}", "--", "${params.binName}"];
|
||||
}
|
||||
const command =
|
||||
configuredArgs[0] === "${RUN_CONFIGURED_COMMAND_SENTINEL}" ? configuredArgs[1] : defaultCommand;
|
||||
const args =
|
||||
configuredArgs[0] === "${RUN_CONFIGURED_COMMAND_SENTINEL}"
|
||||
? configuredArgs.slice(2)
|
||||
: [...defaultArgs, ...configuredArgs];
|
||||
|
||||
if (!command) {
|
||||
console.error("[openclaw] missing configured ${params.displayName} ACP command");
|
||||
process.exit(1);
|
||||
}
|
||||
const command = configuredArgs[0] ?? defaultCommand;
|
||||
const args = configuredArgs.length > 0 ? configuredArgs.slice(1) : defaultArgs;
|
||||
|
||||
const child = spawn(command, args, {
|
||||
env,
|
||||
@@ -111,7 +189,7 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(\`[openclaw] failed to launch isolated Codex ACP wrapper: \${error.message}\`);
|
||||
console.error(\`[openclaw] failed to launch ${params.displayName} ACP wrapper: \${error.message}\`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -124,6 +202,33 @@ child.on("exit", (code, signal) => {
|
||||
`;
|
||||
}
|
||||
|
||||
function buildCodexAcpWrapperScript(installedBinPath?: string): string {
|
||||
return buildAdapterWrapperScript({
|
||||
displayName: "Codex",
|
||||
packageSpec: `${CODEX_ACP_PACKAGE}@${CODEX_ACP_PACKAGE_RANGE}`,
|
||||
binName: CODEX_ACP_BIN,
|
||||
installedBinPath,
|
||||
envSetup: `const codexHome = fileURLToPath(new URL("./codex-home/", import.meta.url));
|
||||
const env = {
|
||||
...process.env,
|
||||
CODEX_HOME: codexHome,
|
||||
};`,
|
||||
});
|
||||
}
|
||||
|
||||
function buildClaudeAcpWrapperScript(installedBinPath?: string): string {
|
||||
return buildAdapterWrapperScript({
|
||||
displayName: "Claude",
|
||||
// This package is patched in OpenClaw; fallback must not float to an unpatched newer release.
|
||||
packageSpec: `${CLAUDE_ACP_PACKAGE}@${CLAUDE_ACP_PACKAGE_VERSION}`,
|
||||
binName: CLAUDE_ACP_BIN,
|
||||
installedBinPath,
|
||||
envSetup: `const env = {
|
||||
...process.env,
|
||||
};`,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareIsolatedCodexHome(baseDir: string): Promise<string> {
|
||||
const codexHome = path.join(baseDir, "codex-home");
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
@@ -145,11 +250,99 @@ async function writeCodexAcpWrapper(baseDir: string, installedBinPath?: string):
|
||||
return wrapperPath;
|
||||
}
|
||||
|
||||
async function writeClaudeAcpWrapper(baseDir: string, installedBinPath?: string): Promise<string> {
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
const wrapperPath = path.join(baseDir, "claude-agent-acp-wrapper.mjs");
|
||||
await fs.writeFile(wrapperPath, buildClaudeAcpWrapperScript(installedBinPath), {
|
||||
encoding: "utf8",
|
||||
});
|
||||
await fs.chmod(wrapperPath, 0o755);
|
||||
return wrapperPath;
|
||||
}
|
||||
|
||||
function buildWrapperCommand(wrapperPath: string, args: string[] = []): string {
|
||||
return [process.execPath, wrapperPath, ...args].map(quoteCommandPart).join(" ");
|
||||
}
|
||||
|
||||
function isAcpPackageSpec(value: string, packageName: string): boolean {
|
||||
const escapedPackageName = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`^${escapedPackageName}(?:@.+)?$`, "i").test(value.trim());
|
||||
}
|
||||
|
||||
function isAcpBinName(value: string, binName: string): boolean {
|
||||
const commandName = basename(value);
|
||||
const escapedBinName = binName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`^${escapedBinName}(?:\\.exe|\\.[cm]?js)?$`, "i").test(commandName);
|
||||
}
|
||||
|
||||
function isPackageRunnerCommand(value: string): boolean {
|
||||
return /^(?:npx|npm|pnpm|bunx)(?:\.cmd|\.exe)?$/i.test(basename(value));
|
||||
}
|
||||
|
||||
function extractConfiguredAdapterArgs(params: {
|
||||
configuredCommand?: string;
|
||||
packageName: string;
|
||||
binName: string;
|
||||
}): string[] | undefined {
|
||||
const trimmedConfiguredCommand = params.configuredCommand?.trim();
|
||||
if (!trimmedConfiguredCommand) {
|
||||
return [];
|
||||
}
|
||||
const parts = splitCommandParts(trimmedConfiguredCommand);
|
||||
if (!parts.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const packageIndex = parts.findIndex((part) => isAcpPackageSpec(part, params.packageName));
|
||||
if (packageIndex >= 0) {
|
||||
if (!isPackageRunnerCommand(parts[0] ?? "")) {
|
||||
return undefined;
|
||||
}
|
||||
const afterPackage = parts.slice(packageIndex + 1);
|
||||
if (afterPackage[0] === "--" && isAcpBinName(afterPackage[1] ?? "", params.binName)) {
|
||||
return afterPackage.slice(2);
|
||||
}
|
||||
if (isAcpBinName(afterPackage[0] ?? "", params.binName)) {
|
||||
return afterPackage.slice(1);
|
||||
}
|
||||
return afterPackage[0] === "--" ? afterPackage.slice(1) : afterPackage;
|
||||
}
|
||||
|
||||
if (isAcpBinName(parts[0] ?? "", params.binName)) {
|
||||
return parts.slice(1);
|
||||
}
|
||||
if (basename(parts[0] ?? "") === "node" && isAcpBinName(parts[1] ?? "", params.binName)) {
|
||||
return parts.slice(2);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
const configuredAdapterArgs = extractConfiguredAdapterArgs({
|
||||
configuredCommand,
|
||||
packageName: CODEX_ACP_PACKAGE,
|
||||
binName: CODEX_ACP_BIN,
|
||||
});
|
||||
if (configuredAdapterArgs) {
|
||||
return buildWrapperCommand(wrapperPath, configuredAdapterArgs);
|
||||
}
|
||||
return buildWrapperCommand(wrapperPath, [
|
||||
RUN_CONFIGURED_COMMAND_SENTINEL,
|
||||
...splitCommandParts(configuredCommand?.trim() ?? ""),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildClaudeAcpWrapperCommand(wrapperPath: string, configuredCommand?: string): string {
|
||||
const configuredAdapterArgs = extractConfiguredAdapterArgs({
|
||||
configuredCommand,
|
||||
packageName: CLAUDE_ACP_PACKAGE,
|
||||
binName: CLAUDE_ACP_BIN,
|
||||
});
|
||||
if (configuredAdapterArgs) {
|
||||
return buildWrapperCommand(wrapperPath, configuredAdapterArgs);
|
||||
}
|
||||
return configuredCommand?.trim() || buildWrapperCommand(wrapperPath);
|
||||
}
|
||||
|
||||
export async function prepareAcpxCodexAuthConfig(params: {
|
||||
@@ -157,21 +350,28 @@ export async function prepareAcpxCodexAuthConfig(params: {
|
||||
stateDir: string;
|
||||
logger?: unknown;
|
||||
resolveInstalledCodexAcpBinPath?: () => Promise<string | undefined>;
|
||||
resolveInstalledClaudeAcpBinPath?: () => Promise<string | undefined>;
|
||||
}): Promise<ResolvedAcpxPluginConfig> {
|
||||
void params.logger;
|
||||
const codexBaseDir = path.join(params.stateDir, "acpx");
|
||||
await prepareIsolatedCodexHome(codexBaseDir);
|
||||
const installedBinPath = await (
|
||||
const installedCodexBinPath = await (
|
||||
params.resolveInstalledCodexAcpBinPath ?? resolveInstalledCodexAcpBinPath
|
||||
)();
|
||||
const wrapperPath = await writeCodexAcpWrapper(codexBaseDir, installedBinPath);
|
||||
const installedClaudeBinPath = await (
|
||||
params.resolveInstalledClaudeAcpBinPath ?? resolveInstalledClaudeAcpBinPath
|
||||
)();
|
||||
const wrapperPath = await writeCodexAcpWrapper(codexBaseDir, installedCodexBinPath);
|
||||
const claudeWrapperPath = await writeClaudeAcpWrapper(codexBaseDir, installedClaudeBinPath);
|
||||
const configuredCodexCommand = params.pluginConfig.agents.codex;
|
||||
const configuredClaudeCommand = params.pluginConfig.agents.claude;
|
||||
|
||||
return {
|
||||
...params.pluginConfig,
|
||||
agents: {
|
||||
...params.pluginConfig.agents,
|
||||
codex: buildCodexAcpWrapperCommand(wrapperPath, configuredCodexCommand),
|
||||
claude: buildClaudeAcpWrapperCommand(claudeWrapperPath, configuredClaudeCommand),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
type AcpxPackageManifest = {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
openclaw?: {
|
||||
bundle?: {
|
||||
stageRuntimeDependencies?: boolean;
|
||||
@@ -18,6 +19,8 @@ describe("acpx package manifest", () => {
|
||||
|
||||
expect(packageJson.dependencies?.acpx).toBeDefined();
|
||||
expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.12.0");
|
||||
expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.31.0");
|
||||
expect(packageJson.devDependencies?.["@agentclientprotocol/claude-agent-acp"]).toBeUndefined();
|
||||
expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -217,6 +217,9 @@ importers:
|
||||
|
||||
extensions/acpx:
|
||||
dependencies:
|
||||
'@agentclientprotocol/claude-agent-acp':
|
||||
specifier: 0.31.0
|
||||
version: 0.31.0(patch_hash=e8b472d71289ac8de9813c57d79abac524889ca96f279f6f3ad08043434f6615)
|
||||
'@zed-industries/codex-acp':
|
||||
specifier: 0.12.0
|
||||
version: 0.12.0
|
||||
@@ -224,9 +227,6 @@ importers:
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
devDependencies:
|
||||
'@agentclientprotocol/claude-agent-acp':
|
||||
specifier: 0.31.0
|
||||
version: 0.31.0(patch_hash=e8b472d71289ac8de9813c57d79abac524889ca96f279f6f3ad08043434f6615)
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
Reference in New Issue
Block a user