From abedf9c1f44f11d47eef23853f62807895f0c21e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 21:25:19 +0100 Subject: [PATCH] fix(acpx): remove codex auth file fallback --- CHANGELOG.md | 1 + docs/help/testing.md | 1 + extensions/acpx/src/codex-auth-bridge.test.ts | 83 ++++++++++++++---- extensions/acpx/src/codex-auth-bridge.ts | 84 ++----------------- src/gateway/gateway-acp-bind.live.test.ts | 39 +++++++++ 5 files changed, 117 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1740d9167e5..3410c39fae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenAI: stop advertising the removed `gpt-5.3-codex-spark` Codex model through fallback catalogs, and suppress stale rows with a GPT-5.5 recovery hint. - Plugins/QR: replace legacy `qrcode-terminal` QR rendering with bounded `qrcode-tui` helpers for plugin login/setup flows. (#65969) Thanks @vincentkoc. +- ACPX/Codex: stop the embedded Codex ACP auth bridge from falling back to raw `~/.codex` file copies; ACPX now only uses OpenClaw's canonical Codex OAuth bridge. - Auto-reply/system events: route async exec-event completion replies through the persisted session delivery context, so long-running command results return to the originating channel instead of being dropped when live origin metadata is missing. (#70258) Thanks @wzfukui. - OpenAI/image generation: send reference-image edits as guarded multipart uploads instead of JSON data URLs, restoring complex multi-reference `gpt-image-2` edits. Fixes #70642. Thanks @dashhuang. - QA channel/security: reject non-HTTP(S) inbound attachment URLs before media fetch, and log rejected schemes so suspicious or misconfigured payloads are visible during debugging. (#70708) Thanks @vincentkoc. diff --git a/docs/help/testing.md b/docs/help/testing.md index 09dc34da1b6..401f8ed5f08 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -641,6 +641,7 @@ Notes: - `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini` - `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND='npx -y @agentclientprotocol/claude-agent-acp@'` - `OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL=gpt-5.5` + - `OPENCLAW_LIVE_ACP_BIND_PARENT_MODEL=openai/gpt-5.4` - Notes: - This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally. - When `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND` is unset, the test uses the embedded `acpx` plugin's built-in agent registry for the selected ACP harness agent. diff --git a/extensions/acpx/src/codex-auth-bridge.test.ts b/extensions/acpx/src/codex-auth-bridge.test.ts index 991304572df..143f40872d3 100644 --- a/extensions/acpx/src/codex-auth-bridge.test.ts +++ b/extensions/acpx/src/codex-auth-bridge.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { saveAuthProfileStore } from "openclaw/plugin-sdk/agent-runtime"; import { afterEach, describe, expect, it } from "vitest"; import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js"; import { resolveAcpxPluginConfig } from "./config.js"; @@ -41,18 +42,28 @@ afterEach(async () => { }); describe("prepareAcpxCodexAuthConfig", () => { - it("wraps built-in Codex ACP with an isolated CODEX_HOME copy", async () => { + it("wraps built-in Codex ACP with an isolated CODEX_HOME from canonical OpenClaw OAuth", 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`, + saveAuthProfileStore( + { + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + accountId: "acct-123", + idToken: "id-token", + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, ); - 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; @@ -69,21 +80,65 @@ describe("prepareAcpxCodexAuthConfig", () => { 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 bridgeRoot = path.join(agentDir, "acp-auth", "codex"); + const bridgeDirs = await fs.readdir(bridgeRoot); + expect(bridgeDirs).toHaveLength(1); + const bridgeDir = bridgeDirs[0]; + if (!bridgeDir) { + throw new Error("expected one Codex auth bridge directory"); + } + const isolatedAuthPath = path.join(bridgeRoot, bridgeDir, "auth.json"); const copiedAuth = JSON.parse(await fs.readFile(isolatedAuthPath, "utf8")) as { auth_mode?: string; - OPENAI_API_KEY?: string; + tokens?: Record; }; - expect(copiedAuth).toEqual({ auth_mode: "apikey", OPENAI_API_KEY: "test-api-key" }); + expect(copiedAuth).toEqual({ + auth_mode: "chatgpt", + tokens: { + id_token: "id-token", + access_token: "access-token", + refresh_token: "refresh-token", + account_id: "acct-123", + }, + last_refresh: expect.any(String), + }); 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'); + fs.access(path.join(agentDir, "acp-auth", "codex-source", "auth.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); const wrapper = await fs.readFile(wrapperPath, "utf8"); expect(wrapper).toContain(`CODEX_HOME: ${JSON.stringify(path.dirname(isolatedAuthPath))}`); expect(wrapper).toContain("for (const key of [])"); - expect(wrapper).not.toContain("test-api-key"); + expect(wrapper).not.toContain("access-token"); + }); + + it("does not copy source Codex auth when canonical OpenClaw OAuth is unavailable", async () => { + const root = await makeTempDir(); + const sourceCodexHome = path.join(root, "source-codex"); + const agentDir = path.join(root, "agent"); + 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`, + ); + 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: path.join(root, "state"), + }); + + expect(resolved.agents.codex).toBeUndefined(); + await expect( + fs.access(path.join(agentDir, "acp-auth", "codex-source", "auth.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); }); it("does not override an explicitly configured Codex agent command", async () => { diff --git a/extensions/acpx/src/codex-auth-bridge.ts b/extensions/acpx/src/codex-auth-bridge.ts index 5f89917f312..9b3fc221f8f 100644 --- a/extensions/acpx/src/codex-auth-bridge.ts +++ b/extensions/acpx/src/codex-auth-bridge.ts @@ -1,9 +1,7 @@ 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"; @@ -13,67 +11,6 @@ const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default"; // launches. Keep those env vars visible to the child so its auth method matches. const CODEX_AUTH_ENV_CLEAR_KEYS: string[] = []; -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, `'\\''`)}'`; } @@ -125,28 +62,21 @@ export async function prepareAcpxCodexAuthConfig(params: { } 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, - })); + const bridge = await prepareCodexAuthBridge({ + agentDir, + bridgeDir: "acp-auth", + profileId: DEFAULT_CODEX_AUTH_PROFILE_ID, + }); if (!bridge) { - params.logger?.debug?.("codex ACP auth bridge skipped: no Codex auth source found"); + params.logger?.debug?.("codex ACP auth bridge skipped: no canonical OpenClaw OAuth found"); return params.pluginConfig; } const wrapperCommand = await writeCodexAcpWrapper({ wrapperPath: path.join(params.stateDir, "acpx", "codex-acp-wrapper.mjs"), codexHome: bridge.codexHome, - clearEnv: bridge.clearEnv, + clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS], }); return { diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index 0d8349b3a28..e041afb69b1 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -37,6 +37,7 @@ const describeLive = LIVE && ACP_BIND_LIVE ? describe : describe.skip; const CONNECT_TIMEOUT_MS = 90_000; const LIVE_TIMEOUT_MS = 240_000; const DEFAULT_LIVE_CODEX_MODEL = "gpt-5.5"; +const DEFAULT_LIVE_PARENT_MODEL = "openai/gpt-5.5"; type LiveAcpAgent = "claude" | "codex" | "gemini"; function createSlackCurrentConversationBindingRegistry() { @@ -135,6 +136,28 @@ function logLiveStep(message: string): void { console.info(`[live-acp-bind] ${message}`); } +function normalizeOpenAiModelRef(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return DEFAULT_LIVE_PARENT_MODEL; + } + return trimmed.includes("/") ? trimmed : `openai/${trimmed}`; +} + +function resolveLiveParentModel(): string { + return normalizeOpenAiModelRef( + process.env.OPENCLAW_LIVE_ACP_BIND_PARENT_MODEL?.trim() || + process.env.OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL?.trim() || + DEFAULT_LIVE_PARENT_MODEL, + ); +} + +function resolveModelObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + async function prepareCodexHomeForLiveBindTest(): Promise { const home = process.env.HOME?.trim(); if (!home) { @@ -449,6 +472,7 @@ describeLive("gateway live (ACP bind)", () => { const tempConfigPath = path.join(tempRoot, "openclaw.json"); const port = await getFreeGatewayPort(); const token = `test-${randomUUID()}`; + const parentModel = resolveLiveParentModel(); const originalSessionKey = "main"; const slackUserId = `U${randomUUID().replace(/-/g, "").slice(0, 10).toUpperCase()}`; const conversationId = `user:${slackUserId}`; @@ -480,6 +504,20 @@ describeLive("gateway live (ACP bind)", () => { : {}; const nextCfg = { ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { + ...resolveModelObject(cfg.agents?.defaults?.model), + primary: parentModel, + }, + models: { + ...cfg.agents?.defaults?.models, + [parentModel]: cfg.agents?.defaults?.models?.[parentModel] ?? {}, + }, + }, + }, gateway: { ...cfg.gateway, mode: "local", @@ -534,6 +572,7 @@ describeLive("gateway live (ACP bind)", () => { }; await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`); process.env.OPENCLAW_CONFIG_PATH = tempConfigPath; + logLiveStep(`using parent live model ${parentModel}`); clearConfigCache(); clearRuntimeConfigSnapshot(); clearPluginLoaderCache();