fix: isolate Codex ACP auth

This commit is contained in:
Peter Steinberger
2026-04-22 23:07:49 +01:00
parent 819ff0463a
commit 87f8e82347
9 changed files with 457 additions and 2 deletions

View File

@@ -0,0 +1,109 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
import { resolveAcpxPluginConfig } from "./config.js";
const tempDirs: string[] = [];
const previousEnv = {
CODEX_HOME: process.env.CODEX_HOME,
OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR,
PI_CODING_AGENT_DIR: process.env.PI_CODING_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 restoreEnv(name: keyof typeof previousEnv): void {
const value = previousEnv[name];
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
function unquoteCommandPath(command: string): string {
return command.replace(/^'|'$/g, "").replace(/'\\''/g, "'");
}
afterEach(async () => {
restoreEnv("CODEX_HOME");
restoreEnv("OPENCLAW_AGENT_DIR");
restoreEnv("PI_CODING_AGENT_DIR");
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
describe("prepareAcpxCodexAuthConfig", () => {
it("wraps built-in Codex ACP with an isolated CODEX_HOME copy", async () => {
const root = await makeTempDir();
const sourceCodexHome = path.join(root, "source-codex");
const agentDir = path.join(root, "agent");
const stateDir = path.join(root, "state");
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.4"\n');
process.env.CODEX_HOME = sourceCodexHome;
process.env.OPENCLAW_AGENT_DIR = agentDir;
delete process.env.PI_CODING_AGENT_DIR;
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {},
workspaceDir: root,
});
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir,
});
const wrapperPath = unquoteCommandPath(resolved.agents.codex ?? "");
expect(wrapperPath).toBe(path.join(stateDir, "acpx", "codex-acp-wrapper.mjs"));
await expect(fs.access(wrapperPath)).resolves.toBeUndefined();
const isolatedAuthPath = path.join(agentDir, "acp-auth", "codex-source", "auth.json");
const copiedAuth = JSON.parse(await fs.readFile(isolatedAuthPath, "utf8")) as {
auth_mode?: string;
OPENAI_API_KEY?: string;
};
expect(copiedAuth).toEqual({ auth_mode: "apikey", OPENAI_API_KEY: "test-api-key" });
expect((await fs.stat(isolatedAuthPath)).mode & 0o777).toBe(0o600);
await expect(
fs.readFile(path.join(agentDir, "acp-auth", "codex-source", "config.toml"), "utf8"),
).resolves.toBe('model = "gpt-5.4"\n');
const wrapper = await fs.readFile(wrapperPath, "utf8");
expect(wrapper).toContain(`CODEX_HOME: ${JSON.stringify(path.dirname(isolatedAuthPath))}`);
expect(wrapper).toContain("delete env[key]");
expect(wrapper).not.toContain("test-api-key");
});
it("does not override an explicitly configured Codex agent command", async () => {
const root = await makeTempDir();
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: {
agents: {
codex: {
command: "custom-codex-acp",
},
},
},
workspaceDir: root,
});
const resolved = await prepareAcpxCodexAuthConfig({
pluginConfig,
stateDir: path.join(root, "state"),
});
expect(resolved.agents.codex).toBe("custom-codex-acp");
});
});

View File

@@ -0,0 +1,157 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth";
import { prepareCodexAuthBridge } from "openclaw/plugin-sdk/provider-auth-runtime";
import { writePrivateSecretFileAtomic } from "openclaw/plugin-sdk/secret-file-runtime";
import type { PluginLogger } from "../runtime-api.js";
import type { ResolvedAcpxPluginConfig } from "./config.js";
const CODEX_AGENT_ID = "codex";
const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default";
const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"];
type PreparedAcpxCodexAuth = {
codexHome: string;
clearEnv: string[];
};
function resolveSourceCodexHome(env: NodeJS.ProcessEnv = process.env): string {
const configured = env.CODEX_HOME?.trim();
if (configured) {
if (configured === "~") {
return os.homedir();
}
if (configured.startsWith("~/")) {
return path.join(os.homedir(), configured.slice(2));
}
return path.resolve(configured);
}
return path.join(os.homedir(), ".codex");
}
async function readOptionalFile(filePath: string): Promise<string | undefined> {
try {
return await fs.readFile(filePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return undefined;
}
throw error;
}
}
async function prepareCopiedCodexHome(params: {
agentDir: string;
sourceCodexHome: string;
}): Promise<PreparedAcpxCodexAuth | null> {
const authJson = await readOptionalFile(path.join(params.sourceCodexHome, "auth.json"));
if (!authJson) {
return null;
}
const codexHome = path.join(params.agentDir, "acp-auth", "codex-source");
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "auth.json"),
content: authJson,
});
const configToml = await readOptionalFile(path.join(params.sourceCodexHome, "config.toml"));
if (configToml) {
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "config.toml"),
content: configToml,
});
}
return {
codexHome,
clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS],
};
}
function shellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
async function writeCodexAcpWrapper(params: {
wrapperPath: string;
codexHome: string;
clearEnv: string[];
}): Promise<string> {
await fs.mkdir(path.dirname(params.wrapperPath), { recursive: true, mode: 0o700 });
const content = `#!/usr/bin/env node
import { spawn } from "node:child_process";
const env = { ...process.env, CODEX_HOME: ${JSON.stringify(params.codexHome)} };
for (const key of ${JSON.stringify(params.clearEnv)}) {
delete env[key];
}
const child = spawn("npx", ["@zed-industries/codex-acp@^0.11.1"], {
stdio: "inherit",
env,
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
child.on("error", (error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
`;
await fs.writeFile(params.wrapperPath, content, { mode: 0o700 });
await fs.chmod(params.wrapperPath, 0o700);
return shellArg(params.wrapperPath);
}
export async function prepareAcpxCodexAuthConfig(params: {
pluginConfig: ResolvedAcpxPluginConfig;
stateDir: string;
logger?: PluginLogger;
}): Promise<ResolvedAcpxPluginConfig> {
if (params.pluginConfig.agents[CODEX_AGENT_ID]) {
return params.pluginConfig;
}
const agentDir = resolveOpenClawAgentDir();
const sourceCodexHome = resolveSourceCodexHome();
const bridge =
(await prepareCodexAuthBridge({
agentDir,
bridgeDir: "acp-auth",
profileId: DEFAULT_CODEX_AUTH_PROFILE_ID,
sourceCodexHome,
})) ??
(await prepareCopiedCodexHome({
agentDir,
sourceCodexHome,
}));
if (!bridge) {
params.logger?.debug?.("codex ACP auth bridge skipped: no Codex auth source found");
return params.pluginConfig;
}
const wrapperCommand = await writeCodexAcpWrapper({
wrapperPath: path.join(params.stateDir, "acpx", "codex-acp-wrapper.mjs"),
codexHome: bridge.codexHome,
clearEnv: bridge.clearEnv,
});
return {
...params.pluginConfig,
agents: {
...params.pluginConfig.agents,
[CODEX_AGENT_ID]: wrapperCommand,
},
};
}

View File

@@ -6,6 +6,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const { runtimeRegistry } = vi.hoisted(() => ({
runtimeRegistry: new Map<string, { runtime: unknown; healthy?: () => boolean }>(),
}));
const { prepareAcpxCodexAuthConfigMock } = vi.hoisted(() => ({
prepareAcpxCodexAuthConfigMock: vi.fn(
async ({ pluginConfig }: { pluginConfig: unknown }) => pluginConfig,
),
}));
vi.mock("../runtime-api.js", () => ({
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
@@ -24,6 +29,10 @@ vi.mock("./runtime.js", () => ({
createFileSessionStore: vi.fn(() => ({})),
}));
vi.mock("./codex-auth-bridge.js", () => ({
prepareAcpxCodexAuthConfig: prepareAcpxCodexAuthConfigMock,
}));
import { getAcpRuntimeBackend } from "../runtime-api.js";
import { createAcpxRuntimeService } from "./service.js";
@@ -37,6 +46,7 @@ async function makeTempDir(): Promise<string> {
afterEach(async () => {
runtimeRegistry.clear();
prepareAcpxCodexAuthConfigMock.mockClear();
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
for (const dir of tempDirs.splice(0)) {

View File

@@ -7,6 +7,7 @@ import type {
PluginLogger,
} from "../runtime-api.js";
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
import {
resolveAcpxPluginConfig,
toAcpMcpServers,
@@ -97,10 +98,15 @@ export function createAcpxRuntimeService(
return;
}
const pluginConfig = resolveAcpxPluginConfig({
const basePluginConfig = resolveAcpxPluginConfig({
rawConfig: params.pluginConfig,
workspaceDir: ctx.workspaceDir,
});
const pluginConfig = await prepareAcpxCodexAuthConfig({
pluginConfig: basePluginConfig,
stateDir: ctx.stateDir,
logger: ctx.logger,
});
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
warnOnIgnoredLegacyCompatibilityConfig({
pluginConfig,

View File

@@ -874,6 +874,9 @@ export class GatewayClient {
if (!this.lastTick) {
return;
}
if (this.pending.size > 0) {
return;
}
const gap = Date.now() - this.lastTick;
if (gap > this.tickIntervalMs * 2) {
this.ws?.close(4000, "tick timeout");

View File

@@ -145,6 +145,52 @@ describe("GatewayClient", () => {
}
}, 4000);
test("lets pending requests own their timeout when ticks are missing", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
requestTimeoutMs: 10_000,
tickWatchMinIntervalMs: 5,
});
const close = vi.fn();
const pending = (client as unknown as { pending: Map<string, unknown> }).pending;
Object.assign(
client as unknown as { ws: unknown; tickIntervalMs: number; lastTick: number },
{
ws: {
readyState: WebSocket.OPEN,
send: vi.fn(),
close,
},
tickIntervalMs: 5,
lastTick: Date.now(),
},
);
pending.set("long-rpc", {
resolve: vi.fn(),
reject: vi.fn(),
expectFinal: false,
timeout: null,
});
(
client as unknown as {
startTickWatch: () => void;
}
).startTickWatch();
await vi.advanceTimersByTimeAsync(20);
expect(close).not.toHaveBeenCalled();
pending.clear();
await vi.advanceTimersByTimeAsync(5);
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
} finally {
vi.useRealTimers();
}
});
test("times out unresolved requests and clears pending state", async () => {
vi.useFakeTimers();
try {

View File

@@ -1,7 +1,25 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { saveAuthProfileStore } from "./agent-runtime.js";
import * as providerAuthRuntime from "./provider-auth-runtime.js";
describe("plugin-sdk provider-auth-runtime", () => {
const tempDirs: string[] = [];
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-auth-runtime-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("exports the runtime-ready auth helper", () => {
expect(typeof providerAuthRuntime.getRuntimeAuthForModel).toBe("function");
});
@@ -15,4 +33,85 @@ describe("plugin-sdk provider-auth-runtime", () => {
expect(typeof providerAuthRuntime.parseOAuthCallbackInput).toBe("function");
expect(typeof providerAuthRuntime.waitForLocalOAuthCallback).toBe("function");
});
it("does not write incomplete Codex ChatGPT auth without an id token", async () => {
const agentDir = await makeTempDir();
saveAuthProfileStore(
{
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
const bridge = await providerAuthRuntime.prepareCodexAuthBridge({
agentDir,
bridgeDir: "harness-auth",
profileId: "openai-codex:default",
});
expect(bridge).toBeUndefined();
});
it("hydrates missing Codex id token from a matching source auth file", async () => {
const root = await makeTempDir();
const agentDir = path.join(root, "agent");
const sourceCodexHome = path.join(root, "codex-home");
await fs.mkdir(sourceCodexHome, { recursive: true });
await fs.writeFile(
path.join(sourceCodexHome, "auth.json"),
`${JSON.stringify(
{
auth_mode: "chatgpt",
tokens: {
id_token: "source-id-token",
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-123",
},
},
null,
2,
)}\n`,
);
saveAuthProfileStore(
{
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
accountId: "acct-123",
expires: Date.now() + 60_000,
},
},
},
agentDir,
{ filterExternalAuthProfiles: false },
);
const bridge = await providerAuthRuntime.prepareCodexAuthBridge({
agentDir,
bridgeDir: "harness-auth",
profileId: "openai-codex:default",
sourceCodexHome,
});
expect(bridge?.codexHome).toContain(path.join(agentDir, "harness-auth", "codex"));
const authFile = JSON.parse(
await fs.readFile(path.join(bridge?.codexHome ?? "", "auth.json"), "utf8"),
);
expect(authFile.tokens.id_token).toBe("source-id-token");
});
});

View File

@@ -227,6 +227,23 @@ export function resolveCodexAuthBridgeHome(params: {
return path.join(params.agentDir, params.bridgeDir, "codex", digest);
}
function assertExistingCodexAuthBridgeFileSafe(codexHome: string): void {
const authFile = path.join(codexHome, "auth.json");
try {
const stat = fs.lstatSync(authFile);
if (stat.isSymbolicLink()) {
throw new Error(`Private secret file ${authFile} must not be a symlink.`);
}
if (!stat.isFile()) {
throw new Error(`Private secret file ${authFile} must be a regular file.`);
}
} catch (error) {
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
}
}
export function buildCodexAuthBridgeFile(
credential: OAuthCredential,
material: Partial<CodexAuthBridgeMaterial> = {},
@@ -268,11 +285,15 @@ export async function prepareCodexAuthBridge(params: {
}
const codexHome = resolveCodexAuthBridgeHome(params);
assertExistingCodexAuthBridgeFileSafe(codexHome);
const material = resolveCodexAuthBridgeMaterial({
credential,
sourceCodexHome: params.sourceCodexHome,
env: { ...process.env, ...params.env },
});
if (!readCodexAuthString(credential.idToken) && !readCodexAuthString(material.idToken)) {
return undefined;
}
await writePrivateSecretFileAtomic({
rootDir: params.agentDir,
filePath: path.join(codexHome, "auth.json"),

View File

@@ -31,6 +31,10 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => {
};
});
vi.mock("../../../extensions/openai/openai-codex-provider.runtime.js", () => ({
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
}));
function createModel(overrides: Partial<ProviderRuntimeModel> & Pick<ProviderRuntimeModel, "id">) {
return {
id: overrides.id,