mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 01:41:36 +00:00
* fix(acpx): migrate codex adapter to maintained @agentclientprotocol/codex-acp The bundled codex ACP adapter pinned the npm-deprecated @zed-industries/codex-acp@0.16.0, which embeds codex crates from the rust-v0.137.0 era: GPT-5.6-era models are unknown to it and sessions fail with 'requires a newer version of Codex'. Migrate to the maintained @agentclientprotocol/codex-acp@1.1.4 (Node CLI, depends on @openai/codex ^0.144.4 matching the codex plugin pin): - Wrapper generation resolves the adapter bin from its package manifest and delivers startup config via CODEX_CONFIG; OpenClaw-owned wrapper args are stripped before launch. Explicit new-adapter commands keep their own -c/--config args untouched; only retired Zed commands migrate, with their -c TOML overrides (dotted/quoted keys, attached forms, MCP config) faithfully converted. - Model overrides emit the bare model id plus a separate model_reasoning_effort; composite model/effort ids are accepted as parse input but never emitted. - Process reaper recognizes the maintained adapter tree and packaged codex app-server children without claiming native codex plugin processes. Live proof: direct adapter smoke with local subscription auth initialized 1.1.4, advertised model gpt-5.6-sol with separate medium effort, and completed a turn. Fixes #108664 * fix(acpx): pin codex-acp 1.1.2 to satisfy the lockfile release-age gate 1.1.4 is under the 48-hour minimumReleaseAge and fails lockfile verification repo-wide. 1.1.2 (2026-07-09) passes the gate, carries the same CODEX_CONFIG startup contract, and its ^0.144.0 codex dependency resolves to the same 0.144.4 the codex plugin pins. Lockfile re-resolved from main without any release-age bypass; shrinkwrap regenerated. * chore(acpx): split oversized auth-bridge test file, register adapter as CLI-resolved dep codex-auth-bridge.test.ts crossed the max-lines gate after the migration regressions; the command-migration cluster moves to codex-auth-bridge.migration.test.ts. knip's CLI-resolved dependency list swaps the retired zed adapter for @agentclientprotocol/codex-acp (invoked via generated wrapper/npx, never imported).
885 lines
32 KiB
TypeScript
885 lines
32 KiB
TypeScript
// ACPX tests cover codex auth bridge plugin behavior.
|
||
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, vi } from "vitest";
|
||
import { OPENCLAW_CODEX_CONFIG_ARG } from "./codex-adapter.js";
|
||
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
|
||
import { resolveAcpxPluginConfig } from "./config.js";
|
||
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
const WRAPPER_STDERR_LOG_MAX_CHARS = 256 * 1024;
|
||
const tempDirs: string[] = [];
|
||
const previousEnv = {
|
||
CODEX_HOME: process.env.CODEX_HOME,
|
||
OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR,
|
||
};
|
||
|
||
async function makeTempDir(): Promise<string> {
|
||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-codex-auth-"));
|
||
tempDirs.push(dir);
|
||
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) {
|
||
delete process.env[name];
|
||
} else {
|
||
process.env[name] = value;
|
||
}
|
||
}
|
||
|
||
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 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(quoteArg(process.execPath));
|
||
expect(command).toContain(quoteArg(wrapperPath));
|
||
}
|
||
|
||
function expectClaudeWrapperCommand(command: string | undefined, wrapperPath: string): void {
|
||
expect(command).toContain(quoteArg(process.execPath));
|
||
expect(command).toContain(quoteArg(wrapperPath));
|
||
}
|
||
|
||
function expectWrapperToContainPathSuffix(wrapper: string, pathSuffix: string[]): void {
|
||
const nativeSuffix = pathSuffix.join(path.sep);
|
||
const escapedNativeSuffix = JSON.stringify(nativeSuffix).slice(1, -1);
|
||
const posixSuffix = pathSuffix.join("/");
|
||
if (wrapper.includes(escapedNativeSuffix)) {
|
||
expect(wrapper).toContain(escapedNativeSuffix);
|
||
} else {
|
||
expect(wrapper).toContain(posixSuffix);
|
||
}
|
||
}
|
||
|
||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||
let error: unknown;
|
||
try {
|
||
await fs.access(targetPath);
|
||
} catch (caught) {
|
||
error = caught;
|
||
}
|
||
expect(error).toBeInstanceOf(Error);
|
||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
||
}
|
||
|
||
async function captureGeneratedCodexWrapperStderr(
|
||
source: string,
|
||
expectedExitCode = 0,
|
||
): Promise<{ log: string; stateDir: string }> {
|
||
const root = await makeTempDir();
|
||
const stateDir = path.join(root, "state");
|
||
const generated = generatedCodexPaths(stateDir);
|
||
const stderrScript = path.join(root, "emit-stderr.mjs");
|
||
await fs.writeFile(stderrScript, source, "utf8");
|
||
const pluginConfig = resolveAcpxPluginConfig({ rawConfig: {}, workspaceDir: root });
|
||
await prepareAcpxCodexAuthConfig({
|
||
pluginConfig,
|
||
stateDir,
|
||
resolveInstalledCodexAcpBinPath: async () => path.join(root, "unused-codex-acp.js"),
|
||
});
|
||
|
||
const leaseId = "lease-unicode";
|
||
const execution = execFileAsync(
|
||
process.execPath,
|
||
[
|
||
generated.wrapperPath,
|
||
"--openclaw-run-configured",
|
||
process.execPath,
|
||
stderrScript,
|
||
OPENCLAW_ACPX_LEASE_ID_ARG,
|
||
leaseId,
|
||
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
|
||
"gateway-test",
|
||
],
|
||
{ maxBuffer: WRAPPER_STDERR_LOG_MAX_CHARS * 2 },
|
||
);
|
||
if (expectedExitCode === 0) {
|
||
await execution;
|
||
} else {
|
||
await expect(execution).rejects.toMatchObject({ code: expectedExitCode });
|
||
}
|
||
const log = await fs.readFile(
|
||
path.join(stateDir, "acpx", `codex-acp-wrapper.stderr.${leaseId}.log`),
|
||
"utf8",
|
||
);
|
||
return { log, stateDir };
|
||
}
|
||
|
||
afterEach(async () => {
|
||
vi.restoreAllMocks();
|
||
restoreEnv("CODEX_HOME");
|
||
restoreEnv("OPENCLAW_AGENT_DIR");
|
||
for (const dir of tempDirs.splice(0)) {
|
||
await fs.rm(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
describe("prepareAcpxCodexAuthConfig", () => {
|
||
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);
|
||
const generatedClaude = generatedClaudePaths(stateDir);
|
||
const installedBinPath = path.join(
|
||
root,
|
||
"node_modules",
|
||
"@agentclientprotocol",
|
||
"codex-acp",
|
||
"dist",
|
||
"index.js",
|
||
);
|
||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||
|
||
const pluginConfig = resolveAcpxPluginConfig({
|
||
rawConfig: {},
|
||
workspaceDir: root,
|
||
});
|
||
const resolved = await prepareAcpxCodexAuthConfig({
|
||
pluginConfig,
|
||
stateDir,
|
||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||
});
|
||
|
||
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]");
|
||
await expectPathMissing(path.join(agentDir, "acp-auth", "codex", "auth.json"));
|
||
});
|
||
|
||
it("keeps generated wrappers usable when chmod is rejected by the state filesystem", async () => {
|
||
const root = await makeTempDir();
|
||
const stateDir = path.join(root, "state");
|
||
const generatedCodex = generatedCodexPaths(stateDir);
|
||
const generatedClaude = generatedClaudePaths(stateDir);
|
||
const chmodError = Object.assign(new Error("operation not permitted"), { code: "EPERM" });
|
||
const chmodSpy = vi.spyOn(fs, "chmod").mockRejectedValue(chmodError);
|
||
const pluginConfig = resolveAcpxPluginConfig({
|
||
rawConfig: {},
|
||
workspaceDir: root,
|
||
});
|
||
|
||
const resolved = await prepareAcpxCodexAuthConfig({
|
||
pluginConfig,
|
||
stateDir,
|
||
});
|
||
|
||
expect(chmodSpy).toHaveBeenCalledWith(generatedCodex.wrapperPath, 0o755);
|
||
expect(chmodSpy).toHaveBeenCalledWith(generatedClaude.wrapperPath, 0o755);
|
||
expectCodexWrapperCommand(resolved.agents.codex, generatedCodex.wrapperPath);
|
||
expectClaudeWrapperCommand(resolved.agents.claude, generatedClaude.wrapperPath);
|
||
await expect(fs.access(generatedCodex.wrapperPath)).resolves.toBeUndefined();
|
||
await expect(fs.access(generatedClaude.wrapperPath)).resolves.toBeUndefined();
|
||
});
|
||
|
||
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('"@agentclientprotocol/codex-acp@1.1.2"');
|
||
expect(wrapper).toContain('"--", "codex-acp"');
|
||
expect(wrapper).not.toContain("@zed-industries/codex-acp");
|
||
});
|
||
|
||
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.55.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.0");
|
||
});
|
||
|
||
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("@agentclientprotocol/codex-acp");
|
||
expectWrapperToContainPathSuffix(wrapper, ["dist", "index.js"]);
|
||
expect(wrapper).toContain("defaultArgs = [installedBinPath]");
|
||
});
|
||
|
||
it("keeps the orphaned wrapper alive long enough to force-kill the child process group", 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('killChildTree("SIGTERM")');
|
||
expect(wrapper).toContain('killChildTree("SIGKILL", { force: true })');
|
||
expect(wrapper).toMatch(
|
||
/forceKillTimer = setTimeout\(\(\) => \{\s*killChildTree\("SIGKILL", \{ force: true \}\);\s*childExitCode = 1;/s,
|
||
);
|
||
expect(wrapper).toMatch(
|
||
/child\.on\("exit", \(code, signal\) => \{\s*if \(parentWatcher\) \{\s*clearInterval\(parentWatcher\);\s*\}\s*if \(orphanCleanupStarted\) \{\s*return;\s*\}/s,
|
||
);
|
||
expect(wrapper).toMatch(
|
||
/child\.on\("close", \(\) => \{\s*finishStderrLog\(\);\s*process\.exit\(childExitCode\);/s,
|
||
);
|
||
expect(wrapper).not.toMatch(
|
||
/forceKillTimer = setTimeout\(\(\) => killChildTree\("SIGKILL"\), 1_500\);\s*forceKillTimer\.unref\?\.\(\);\s*process\.exit\(1\);/s,
|
||
);
|
||
// Orphan detection must trigger on any PPID change, not only when the new
|
||
// PPID is init (1). Systemd user services and container init reparent
|
||
// orphaned processes to a session manager or container init (PID != 1),
|
||
// and the older `process.ppid !== 1` guard would silently leak the codex
|
||
// adapter tree there.
|
||
expect(wrapper).not.toContain("process.ppid !== 1");
|
||
expect(wrapper).toMatch(
|
||
/setInterval\(\(\) => \{[\s\S]*?if \(process\.ppid === originalParentPid\) \{\s*return;\s*\}/,
|
||
);
|
||
});
|
||
|
||
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");
|
||
expectWrapperToContainPathSuffix(wrapper, ["dist", "index.js"]);
|
||
expect(wrapper).toContain("defaultArgs = [installedBinPath]");
|
||
});
|
||
|
||
it("launches maintained Codex ACP with isolated CODEX_HOME and model overrides", 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), codexConfig: process.env.CODEX_CONFIG, 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,
|
||
"--openclaw-acpx-lease-id",
|
||
"lease-1",
|
||
"--openclaw-gateway-instance-id",
|
||
"gateway-1",
|
||
OPENCLAW_CODEX_CONFIG_ARG,
|
||
JSON.stringify({ model_providers: { custom: { wire_api: "responses" } } }),
|
||
OPENCLAW_CODEX_CONFIG_ARG,
|
||
JSON.stringify({ model: "gpt-5.6-sol", model_reasoning_effort: "medium" }),
|
||
],
|
||
{
|
||
cwd: root,
|
||
env: {
|
||
...process.env,
|
||
CODEX_CONFIG: JSON.stringify({
|
||
sandbox_mode: "workspace-write",
|
||
model_providers: { custom: { base_url: "https://example.test/v1" } },
|
||
}),
|
||
},
|
||
},
|
||
);
|
||
const launched = JSON.parse(stdout.trim()) as {
|
||
argv?: unknown;
|
||
codexConfig?: unknown;
|
||
codexHome?: unknown;
|
||
};
|
||
expect(launched.argv).toStrictEqual([]);
|
||
expect(JSON.parse(String(launched.codexConfig))).toEqual({
|
||
sandbox_mode: "workspace-write",
|
||
model_providers: {
|
||
custom: { base_url: "https://example.test/v1", wire_api: "responses" },
|
||
},
|
||
model: "gpt-5.6-sol",
|
||
model_reasoning_effort: "medium",
|
||
});
|
||
const expectedCodexHome = await fs.realpath(path.join(stateDir, "acpx", "codex-home"));
|
||
expect(path.resolve(String(launched.codexHome))).toBe(expectedCodexHome);
|
||
});
|
||
|
||
it("writes API-key auth into the isolated Codex ACP home when env auth is present", 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({ codexHome: process.env.CODEX_HOME }));\n",
|
||
"utf8",
|
||
);
|
||
const pluginConfig = resolveAcpxPluginConfig({
|
||
rawConfig: {},
|
||
workspaceDir: root,
|
||
});
|
||
|
||
await prepareAcpxCodexAuthConfig({
|
||
pluginConfig,
|
||
stateDir,
|
||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||
});
|
||
|
||
await execFileAsync(process.execPath, [generated.wrapperPath], {
|
||
cwd: root,
|
||
env: { ...process.env, CODEX_API_KEY: "", OPENAI_API_KEY: "sk-test-api-key" },
|
||
});
|
||
|
||
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
|
||
const auth = JSON.parse(await fs.readFile(authPath, "utf8")) as {
|
||
auth_mode?: unknown;
|
||
OPENAI_API_KEY?: unknown;
|
||
};
|
||
expect(auth).toMatchObject({
|
||
OPENAI_API_KEY: "sk-test-api-key",
|
||
});
|
||
expect(auth).not.toHaveProperty("auth_mode");
|
||
if (process.platform !== "win32") {
|
||
const mode = (await fs.stat(authPath)).mode & 0o777;
|
||
expect(mode).toBe(0o600);
|
||
}
|
||
});
|
||
|
||
it("preserves existing isolated Codex auth when env auth is present", 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('ok');\n", "utf8");
|
||
const pluginConfig = resolveAcpxPluginConfig({
|
||
rawConfig: {},
|
||
workspaceDir: root,
|
||
});
|
||
|
||
await prepareAcpxCodexAuthConfig({
|
||
pluginConfig,
|
||
stateDir,
|
||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||
});
|
||
|
||
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
|
||
const existingAuth = {
|
||
auth_mode: "chatgpt",
|
||
tokens: { access_token: "existing-token" },
|
||
last_refresh: null,
|
||
};
|
||
await fs.writeFile(authPath, `${JSON.stringify(existingAuth)}\n`, { mode: 0o600 });
|
||
|
||
await execFileAsync(process.execPath, [generated.wrapperPath], {
|
||
cwd: root,
|
||
env: { ...process.env, OPENAI_API_KEY: "sk-test-api-key" },
|
||
});
|
||
|
||
expect(JSON.parse(await fs.readFile(authPath, "utf8"))).toEqual(existingAuth);
|
||
});
|
||
|
||
it("updates existing isolated Codex API-key auth when env auth changes", 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('ok');\n", "utf8");
|
||
const pluginConfig = resolveAcpxPluginConfig({
|
||
rawConfig: {},
|
||
workspaceDir: root,
|
||
});
|
||
|
||
await prepareAcpxCodexAuthConfig({
|
||
pluginConfig,
|
||
stateDir,
|
||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||
});
|
||
|
||
const authPath = path.join(stateDir, "acpx", "codex-home", "auth.json");
|
||
await fs.writeFile(
|
||
authPath,
|
||
`${JSON.stringify({
|
||
OPENAI_API_KEY: "sk-old-api-key",
|
||
tokens: null,
|
||
last_refresh: null,
|
||
})}\n`,
|
||
{ mode: 0o600 },
|
||
);
|
||
|
||
await execFileAsync(process.execPath, [generated.wrapperPath], {
|
||
cwd: root,
|
||
env: { ...process.env, CODEX_API_KEY: "sk-new-api-key", OPENAI_API_KEY: "sk-other-key" },
|
||
});
|
||
|
||
expect(JSON.parse(await fs.readFile(authPath, "utf8"))).toMatchObject({
|
||
OPENAI_API_KEY: "sk-new-api-key",
|
||
tokens: null,
|
||
last_refresh: null,
|
||
});
|
||
});
|
||
|
||
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");
|
||
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"),
|
||
[
|
||
'model = "gpt-5.5-1"',
|
||
'model_provider = "azure_foundry"',
|
||
'model_reasoning_effort = "high"',
|
||
'sandbox_mode = "workspace-write"',
|
||
'notify = ["SkyComputerUseClient", "turn-ended"]',
|
||
"",
|
||
"[model_providers.azure_foundry]",
|
||
'name = "Azure Foundry"',
|
||
'base_url = "https://example.azure.com/openai/v1"',
|
||
'wire_api = "responses"',
|
||
'env_key = "AZURE_OPENAI_API_KEY"',
|
||
'http_headers = { "api-key" = "inline-secret-key" }',
|
||
'query_params = { "api-version" = "2026-01-01", "secret" = "inline-secret-param" }',
|
||
'experimental_bearer_token = "inline-secret-bearer"',
|
||
"",
|
||
"[model_providers.azure_foundry.auth]",
|
||
'command = "bash"',
|
||
'args = ["-lc", "printf %s test-key"]',
|
||
"",
|
||
"[model_providers.secret_only]",
|
||
'experimental_bearer_token = "secret-only-token"',
|
||
"",
|
||
`[projects.${JSON.stringify(path.join(root, "project-with-model-key"))}]`,
|
||
'model = "nested-project-model"',
|
||
"",
|
||
].join("\n"),
|
||
);
|
||
process.env.CODEX_HOME = sourceCodexHome;
|
||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||
|
||
const pluginConfig = resolveAcpxPluginConfig({
|
||
rawConfig: {},
|
||
workspaceDir: root,
|
||
});
|
||
const resolved = await prepareAcpxCodexAuthConfig({
|
||
pluginConfig,
|
||
stateDir,
|
||
resolveInstalledCodexAcpBinPath: async () => undefined,
|
||
});
|
||
|
||
expectCodexWrapperCommand(resolved.agents.codex, generated.wrapperPath);
|
||
const isolatedConfig = await fs.readFile(generated.configPath, "utf8");
|
||
expect(isolatedConfig).toContain('model = "gpt-5.5-1"');
|
||
expect(isolatedConfig).toContain('model_provider = "azure_foundry"');
|
||
expect(isolatedConfig).toContain('model_reasoning_effort = "high"');
|
||
expect(isolatedConfig).toContain('sandbox_mode = "workspace-write"');
|
||
expect(isolatedConfig).toContain("[model_providers.azure_foundry]");
|
||
expect(isolatedConfig).toContain('base_url = "https://example.azure.com/openai/v1"');
|
||
expect(isolatedConfig).toContain('env_key = "AZURE_OPENAI_API_KEY"');
|
||
expect(isolatedConfig).not.toContain("http_headers");
|
||
expect(isolatedConfig).not.toContain("query_params");
|
||
expect(isolatedConfig).not.toContain("experimental_bearer_token");
|
||
expect(isolatedConfig).not.toContain("[model_providers.azure_foundry.auth]");
|
||
expect(isolatedConfig).not.toContain("[model_providers.secret_only]");
|
||
expect(isolatedConfig).not.toContain("nested-project-model");
|
||
expect(isolatedConfig).not.toContain("inline-secret");
|
||
expect(isolatedConfig).not.toContain('args = ["-lc", "printf %s test-key"]');
|
||
expect(isolatedConfig).not.toContain("notify");
|
||
expect(isolatedConfig).not.toContain("SkyComputerUseClient");
|
||
expect(isolatedConfig).toContain(`[projects.${JSON.stringify(path.resolve(root))}]`);
|
||
expect(isolatedConfig).toContain('trust_level = "trusted"');
|
||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||
expect(wrapper).toContain("CODEX_HOME: codexHome");
|
||
expect(wrapper).not.toContain(sourceCodexHome);
|
||
await expectPathMissing(path.join(agentDir, "acp-auth", "codex-source", "auth.json"));
|
||
await expectPathMissing(path.join(agentDir, "acp-auth", "codex", "auth.json"));
|
||
});
|
||
|
||
it("copies only trusted Codex project declarations into the isolated Codex home", async () => {
|
||
const root = await makeTempDir();
|
||
const sourceCodexHome = path.join(root, "source-codex");
|
||
const stateDir = path.join(root, "state");
|
||
const explicitProject = path.join(root, "explicit project");
|
||
const inlineProject = path.join(root, "inline-project");
|
||
const mapProject = path.join(root, "map-project");
|
||
const untrustedProject = path.join(root, "untrusted-project");
|
||
const generated = generatedCodexPaths(stateDir);
|
||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||
await fs.writeFile(
|
||
path.join(sourceCodexHome, "config.toml"),
|
||
[
|
||
'notify = ["SkyComputerUseClient", "turn-ended"]',
|
||
`projects = { ${JSON.stringify(mapProject)} = { trust_level = "trusted" }, ${JSON.stringify(untrustedProject)} = { trust_level = "untrusted" } }`,
|
||
"[projects]",
|
||
`${JSON.stringify(inlineProject)} = { trust_level = "trusted" }`,
|
||
`[projects.${JSON.stringify(explicitProject)}]`,
|
||
'trust_level = "trusted"',
|
||
"",
|
||
].join("\n"),
|
||
);
|
||
process.env.CODEX_HOME = sourceCodexHome;
|
||
const pluginConfig = resolveAcpxPluginConfig({
|
||
rawConfig: {},
|
||
workspaceDir: root,
|
||
});
|
||
|
||
await prepareAcpxCodexAuthConfig({
|
||
pluginConfig,
|
||
stateDir,
|
||
resolveInstalledCodexAcpBinPath: async () => undefined,
|
||
});
|
||
|
||
const isolatedConfig = await fs.readFile(generated.configPath, "utf8");
|
||
expect(isolatedConfig).toContain(`[projects.${JSON.stringify(path.resolve(root))}]`);
|
||
expect(isolatedConfig).toContain(`[projects.${JSON.stringify(path.resolve(explicitProject))}]`);
|
||
expect(isolatedConfig).toContain(`[projects.${JSON.stringify(path.resolve(inlineProject))}]`);
|
||
expect(isolatedConfig).toContain(`[projects.${JSON.stringify(path.resolve(mapProject))}]`);
|
||
expect(isolatedConfig).not.toContain(untrustedProject);
|
||
expect(isolatedConfig).not.toContain("notify");
|
||
expect(isolatedConfig).not.toContain("SkyComputerUseClient");
|
||
});
|
||
|
||
it("captures Codex wrapper stderr in a stream-aware redacted per-lease log", async () => {
|
||
const { log, stateDir } = await captureGeneratedCodexWrapperStderr(
|
||
`const chunks = [
|
||
"token=sk-test",
|
||
"secret1234567890\\n",
|
||
"Authorization: Bearer bearer-secret",
|
||
"-token-1234567890\\n",
|
||
'{"client_secret":"json-secret-1234567890","api_key":"json-api-key-1234567890"}\\n',
|
||
"client-secret: kebab-secret-1234567890\\n",
|
||
"standalone sk-live-secret",
|
||
"1234567890\\n",
|
||
"url=https://example.test/callback?token=query-secret",
|
||
"-1234567890\\n",
|
||
"github_pat_1234567890",
|
||
"abcdefghijklmnopqrstuvwxyz\\n",
|
||
"-----BEGIN PRIVATE KEY-----\\nprivate-secret-body\\n",
|
||
"-----END PRIVATE KEY-----\\n",
|
||
"tail-token=tail-secret-1234567890",
|
||
"\\n-----BEGIN PRIVATE KEY-----\\ntruncated-private-secret",
|
||
];
|
||
let index = 0;
|
||
function writeNext() {
|
||
if (index >= chunks.length) {
|
||
process.exit(1);
|
||
return;
|
||
}
|
||
process.stderr.write(chunks[index]);
|
||
index += 1;
|
||
setTimeout(writeNext, 5);
|
||
}
|
||
writeNext();`,
|
||
1,
|
||
);
|
||
expect(log).toContain("token=[REDACTED]");
|
||
expect(log).toContain("Authorization: Bearer [REDACTED]");
|
||
expect(log).toContain('"client_secret":"[REDACTED]"');
|
||
expect(log).toContain('"api_key":"[REDACTED]"');
|
||
expect(log).toContain("client-secret: [REDACTED]");
|
||
expect(log).toContain("standalone [REDACTED_OPENAI_KEY]");
|
||
expect(log).toContain("?token=[REDACTED]");
|
||
expect(log).toContain("[REDACTED_GITHUB_TOKEN]");
|
||
expect(log).toContain("[REDACTED_PRIVATE_KEY]");
|
||
expect(log).toContain("tail-token=[REDACTED]");
|
||
expect(log).not.toContain("sk-testsecret1234567890");
|
||
expect(log).not.toContain("bearer-secret-token-1234567890");
|
||
expect(log).not.toContain("json-secret-1234567890");
|
||
expect(log).not.toContain("json-api-key-1234567890");
|
||
expect(log).not.toContain("kebab-secret-1234567890");
|
||
expect(log).not.toContain("query-secret-1234567890");
|
||
expect(log).not.toContain("github_pat_1234567890abcdefghijklmnopqrstuvwxyz");
|
||
expect(log).not.toContain("private-secret-body");
|
||
expect(log).not.toContain("truncated-private-secret");
|
||
expect(log).not.toContain("tail-secret-1234567890");
|
||
await expectPathMissing(path.join(stateDir, "acpx", "codex-acp-wrapper.stderr.log"));
|
||
});
|
||
|
||
it("removes a previous per-lease stderr log when the adapter writes no stderr", async () => {
|
||
const root = await makeTempDir();
|
||
const stateDir = path.join(root, "state");
|
||
const generated = generatedCodexPaths(stateDir);
|
||
const noisyScript = path.join(root, "noisy-adapter.mjs");
|
||
const quietScript = path.join(root, "quiet-adapter.mjs");
|
||
await fs.writeFile(noisyScript, 'process.stderr.write("previous diagnostics\\n");\n', "utf8");
|
||
await fs.writeFile(quietScript, "process.exit(0);\n", "utf8");
|
||
const pluginConfig = resolveAcpxPluginConfig({
|
||
rawConfig: {
|
||
agents: {
|
||
codex: {
|
||
command: `${process.execPath} ${quietScript}`,
|
||
},
|
||
},
|
||
},
|
||
workspaceDir: root,
|
||
});
|
||
|
||
await prepareAcpxCodexAuthConfig({
|
||
pluginConfig,
|
||
stateDir,
|
||
resolveInstalledCodexAcpBinPath: async () => path.join(root, "codex-acp.js"),
|
||
});
|
||
|
||
const wrapperArgs = [
|
||
OPENCLAW_ACPX_LEASE_ID_ARG,
|
||
"quiet-lease",
|
||
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
|
||
"gateway-test",
|
||
];
|
||
await execFileAsync(process.execPath, [
|
||
generated.wrapperPath,
|
||
"--openclaw-run-configured",
|
||
process.execPath,
|
||
noisyScript,
|
||
...wrapperArgs,
|
||
]);
|
||
const stderrLogPath = path.join(stateDir, "acpx", "codex-acp-wrapper.stderr.quiet-lease.log");
|
||
await expect(fs.readFile(stderrLogPath, "utf8")).resolves.toBe("previous diagnostics\n");
|
||
|
||
await execFileAsync(process.execPath, [
|
||
generated.wrapperPath,
|
||
"--openclaw-run-configured",
|
||
process.execPath,
|
||
quietScript,
|
||
...wrapperArgs,
|
||
]);
|
||
|
||
await expectPathMissing(stderrLogPath);
|
||
});
|
||
|
||
it("starts the adapter when a previous stderr log cannot be removed", async () => {
|
||
const root = await makeTempDir();
|
||
const stateDir = path.join(root, "state");
|
||
const generated = generatedCodexPaths(stateDir);
|
||
const quietScript = path.join(root, "quiet-adapter.mjs");
|
||
await fs.writeFile(quietScript, "process.exit(0);\n", "utf8");
|
||
const pluginConfig = resolveAcpxPluginConfig({ rawConfig: {}, workspaceDir: root });
|
||
|
||
await prepareAcpxCodexAuthConfig({
|
||
pluginConfig,
|
||
stateDir,
|
||
resolveInstalledCodexAcpBinPath: async () => path.join(root, "codex-acp.js"),
|
||
});
|
||
|
||
const stderrLogPath = path.join(stateDir, "acpx", "codex-acp-wrapper.stderr.blocked-lease.log");
|
||
await fs.mkdir(stderrLogPath);
|
||
|
||
await expect(
|
||
execFileAsync(process.execPath, [
|
||
generated.wrapperPath,
|
||
"--openclaw-run-configured",
|
||
process.execPath,
|
||
quietScript,
|
||
OPENCLAW_ACPX_LEASE_ID_ARG,
|
||
"blocked-lease",
|
||
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
|
||
"gateway-test",
|
||
]),
|
||
).resolves.toMatchObject({ stderr: "" });
|
||
});
|
||
|
||
it("keeps the persisted stderr line tail UTF-16 safe at the 256 KiB boundary", async () => {
|
||
const { log } = await captureGeneratedCodexWrapperStderr(`
|
||
const maxChars = 256 * 1024;
|
||
process.stderr.write("🚀\\n");
|
||
process.stderr.write("a".repeat(maxChars - 3) + "\\n");
|
||
`);
|
||
|
||
expect(log).toBe(`\n${"a".repeat(WRAPPER_STDERR_LOG_MAX_CHARS - 3)}\n`);
|
||
expect(log).not.toContain("<22>");
|
||
});
|
||
|
||
it("keeps the pending no-newline stderr tail UTF-16 safe", async () => {
|
||
const { log } = await captureGeneratedCodexWrapperStderr(`
|
||
const maxChars = 256 * 1024;
|
||
process.stderr.write("🚀" + "a".repeat(maxChars - 1));
|
||
`);
|
||
|
||
expect(log).toBe("a".repeat(WRAPPER_STDERR_LOG_MAX_CHARS - 1));
|
||
expect(log).not.toContain("<22>");
|
||
});
|
||
|
||
it("decodes split UTF-8 and flushes a partial final sequence", async () => {
|
||
const { log } = await captureGeneratedCodexWrapperStderr(`
|
||
process.stderr.write(Buffer.from([0xf0, 0x9f]));
|
||
setTimeout(() => {
|
||
process.stderr.write(Buffer.from([0x9a, 0x80, 0x0a, 0xe2]));
|
||
}, 50);
|
||
`);
|
||
|
||
expect(log).toBe("🚀\n<>");
|
||
});
|
||
|
||
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.4 --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);
|
||
});
|
||
});
|