diff --git a/extensions/acpx/src/codex-auth-bridge.test.ts b/extensions/acpx/src/codex-auth-bridge.test.ts new file mode 100644 index 00000000000..4e96b502932 --- /dev/null +++ b/extensions/acpx/src/codex-auth-bridge.test.ts @@ -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 { + 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"); + }); +}); diff --git a/extensions/acpx/src/codex-auth-bridge.ts b/extensions/acpx/src/codex-auth-bridge.ts new file mode 100644 index 00000000000..1f4126209ec --- /dev/null +++ b/extensions/acpx/src/codex-auth-bridge.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }, + }; +} diff --git a/extensions/acpx/src/service.test.ts b/extensions/acpx/src/service.test.ts index 0d0d0fecf55..bc95bd1313c 100644 --- a/extensions/acpx/src/service.test.ts +++ b/extensions/acpx/src/service.test.ts @@ -6,6 +6,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const { runtimeRegistry } = vi.hoisted(() => ({ runtimeRegistry: new Map 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 { 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)) { diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts index 9864213a390..27175434b9a 100644 --- a/extensions/acpx/src/service.ts +++ b/extensions/acpx/src/service.ts @@ -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, diff --git a/src/gateway/client.ts b/src/gateway/client.ts index d92e90590db..997b7227df5 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -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"); diff --git a/src/gateway/client.watchdog.test.ts b/src/gateway/client.watchdog.test.ts index 5c217b8bdc4..4ab4e385568 100644 --- a/src/gateway/client.watchdog.test.ts +++ b/src/gateway/client.watchdog.test.ts @@ -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 }).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 { diff --git a/src/plugin-sdk/provider-auth-runtime.test.ts b/src/plugin-sdk/provider-auth-runtime.test.ts index 0f89cb4df18..20f36777a91 100644 --- a/src/plugin-sdk/provider-auth-runtime.test.ts +++ b/src/plugin-sdk/provider-auth-runtime.test.ts @@ -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 { + 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"); + }); }); diff --git a/src/plugin-sdk/provider-auth-runtime.ts b/src/plugin-sdk/provider-auth-runtime.ts index 0c506f0f435..b9f7eaf68cd 100644 --- a/src/plugin-sdk/provider-auth-runtime.ts +++ b/src/plugin-sdk/provider-auth-runtime.ts @@ -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 = {}, @@ -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"), diff --git a/test/helpers/plugins/provider-runtime-contract.ts b/test/helpers/plugins/provider-runtime-contract.ts index 5e71814a117..152d8a9c891 100644 --- a/test/helpers/plugins/provider-runtime-contract.ts +++ b/test/helpers/plugins/provider-runtime-contract.ts @@ -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 & Pick) { return { id: overrides.id,