From 5f15bea6ce309b74e143e300fb350c90c23bd24b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 23:58:15 +0100 Subject: [PATCH] fix(codex): bootstrap app-server auth fallback --- CHANGELOG.md | 2 +- docs/plugins/codex-harness.md | 38 +++-- docs/providers/openai.md | 18 +++ .../codex/src/app-server/auth-bridge.test.ts | 138 +++++++++++++++--- .../codex/src/app-server/auth-bridge.ts | 48 +++++- .../codex/src/app-server/config.test.ts | 17 ++- extensions/codex/src/app-server/config.ts | 2 +- .../src/app-server/transport-stdio.test.ts | 28 +++- .../codex/src/app-server/transport-stdio.ts | 12 +- 9 files changed, 254 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 402da720a79..25332e961af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ Docs: https://docs.openclaw.ai - Providers/Codex: pass agent and workspace directories into provider stream wrappers so Codex native `web_search` activation can evaluate the correct auth context, and smoke-test the built status-message runtime by resolving the emitted bundle name. Carries forward #67843; refs #65909. Thanks @neilofneils404. - Cron/models: keep `payload.model` as a per-job primary that can use configured fallbacks, while still letting `payload.fallbacks: []` make cron runs strict and avoid hidden agent-primary retries. Refs #73023. Thanks @pavelyortho-cyber. - Models/fallbacks: treat user-selected session models as exact choices, so `/model ollama/...` and model-picker switches fail visibly when the selected provider is unreachable instead of answering from an unrelated configured fallback. Fixes #73023. Thanks @pavelyortho-cyber. -- Codex harness: automatically clear inherited `OPENAI_API_KEY` from spawned Codex app-server processes when the harness is using ChatGPT subscription auth, while keeping explicit Codex API-key profiles and the manual `appServer.clearEnv` escape hatch available. Fixes #73057. Thanks @holgergruenhagen. +- Codex harness: keep ChatGPT subscription app-server runs from inheriting `CODEX_API_KEY` or `OPENAI_API_KEY`, and fall back to `CODEX_API_KEY` / `OPENAI_API_KEY` app-server login only when no Codex account is available. Fixes #73057. Thanks @holgergruenhagen. - CLI/model probes: fail local `infer model run` probes when the provider returns no text output, so unreachable local providers and empty completions no longer look like successful smoke tests. Refs #73023. Thanks @pavelyortho-cyber. - CLI/Ollama: run local `infer model run` through the lean provider completion path and skip global model discovery for one-shot local probes, so Ollama smoke tests no longer pay full chat-agent/tool startup cost or hang before the native `/api/chat` request. Fixes #72851. Thanks @TotalRes2020. - Doctor/gateway services: ignore launchd/systemd companion services that only reference the gateway as a dependency, suppress inactive Linux extra-service warnings, and avoid rewriting a running systemd gateway command/entrypoint during doctor repair. Carries forward #39118. Thanks @therk. diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 37164afe451..2f19ef276db 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -179,15 +179,15 @@ Codex after changing config. - Codex app-server `0.125.0` or newer. The bundled plugin manages a compatible Codex app-server binary by default, so local `codex` commands on `PATH` do not affect normal harness startup. -- Codex auth available to the app-server process. +- Codex auth available to the app-server process or to OpenClaw's Codex auth + bridge. The plugin blocks older or unversioned app-server handshakes. That keeps OpenClaw on the protocol surface it has been tested against. -For live and Docker smoke tests, auth usually comes from `OPENAI_API_KEY`, plus -optional Codex CLI files such as `~/.codex/auth.json` and -`~/.codex/config.toml`. Use the same auth material your local Codex app-server -uses. +For live and Docker smoke tests, auth usually comes from the Codex CLI account, +an OpenClaw `openai-codex` auth profile, or `CODEX_API_KEY` / +`OPENAI_API_KEY` as a fallback when no account is present. ## Minimal config @@ -508,16 +508,24 @@ For an already-running app-server, use WebSocket transport: } ``` -Stdio app-server launches inherit OpenClaw's process environment by default. -When OpenClaw sees that the Codex harness is using a ChatGPT subscription-style -auth profile, including the local Codex CLI login imported as -`openai-codex:default`, it automatically removes `OPENAI_API_KEY` from the -spawned Codex child process. That keeps Gateway-level API keys available for -embeddings or direct OpenAI models without making native Codex app-server turns -bill through the API by accident. +Stdio app-server launches inherit OpenClaw's process environment by default, +but OpenClaw owns the Codex app-server account bridge. Auth is selected in this +order: -Explicit Codex API-key profiles are left alone. If a deployment needs additional -environment isolation, add those variables to `appServer.clearEnv`: +1. An explicit OpenClaw Codex auth profile for the agent. +2. The app-server's existing account, such as a local Codex CLI ChatGPT sign-in. +3. `CODEX_API_KEY`, then `OPENAI_API_KEY`, only when no app-server account is + present and OpenAI auth is still required. + +When OpenClaw sees a ChatGPT subscription-style Codex auth profile, it removes +`CODEX_API_KEY` and `OPENAI_API_KEY` from the spawned Codex child process. That +keeps Gateway-level API keys available for embeddings or direct OpenAI models +without making native Codex app-server turns bill through the API by accident. +Explicit Codex API-key profiles and env-key fallback use app-server login +instead of inherited child-process env. + +If a deployment needs additional environment isolation, add those variables to +`appServer.clearEnv`: ```json5 { @@ -527,7 +535,7 @@ environment isolation, add those variables to `appServer.clearEnv`: enabled: true, config: { appServer: { - clearEnv: ["OPENAI_API_KEY"], + clearEnv: ["CODEX_API_KEY", "OPENAI_API_KEY"], }, }, }, diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 198db564636..c6e24dfe2cf 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -285,6 +285,24 @@ Choose your preferred auth method and follow the setup steps. +## Native Codex app-server auth + +The native Codex app-server harness uses `openai/*` model refs plus +`agentRuntime.id: "codex"`, but its auth is still account-based. OpenClaw +selects auth in this order: + +1. An explicit OpenClaw `openai-codex` auth profile bound to the agent. +2. The app-server's existing account, such as a local Codex CLI ChatGPT sign-in. +3. `CODEX_API_KEY`, then `OPENAI_API_KEY`, only when the app-server reports no + account and still requires OpenAI auth. + +That means a local ChatGPT/Codex subscription sign-in is not replaced just +because the gateway process also has `OPENAI_API_KEY` for direct OpenAI models +or embeddings. API-key fallback is only the no-account path. When a +subscription-style Codex profile is selected, OpenClaw also keeps +`CODEX_API_KEY` and `OPENAI_API_KEY` out of the spawned stdio app-server child +and sends the selected credentials through the app-server login RPC. + ## Image generation The bundled `openai` plugin registers image generation through the `image_generate` tool. diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index 4b7d9c48d2a..74b9e23adc0 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -109,31 +109,26 @@ function createStartOptions( }; } -async function writeCodexCliAuthFile(codexHome: string): Promise { - await fs.mkdir(codexHome, { recursive: true }); - await fs.writeFile( - path.join(codexHome, "auth.json"), - JSON.stringify({ - tokens: { - access_token: "cli-access-token", - refresh_token: "cli-refresh-token", - account_id: "cli-account-123", - }, - }), - ); -} - describe("bridgeCodexAppServerStartOptions", () => { - it("clears an inherited OpenAI API key when local Codex CLI OAuth is available", async () => { + it("clears inherited API-key env vars when the default Codex profile is subscription auth", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); - const codexHome = path.join(agentDir, "codex-home"); const startOptions = createStartOptions({ - env: { CODEX_HOME: "/tmp/source-codex-home", EXISTING: "1" }, + env: { EXISTING: "1" }, clearEnv: ["FOO"], }); - vi.stubEnv("CODEX_HOME", codexHome); try { - await writeCodexCliAuthFile(codexHome); + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:default", + credential: { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "account-123", + }, + }); await expect( bridgeCodexAppServerStartOptions({ @@ -142,7 +137,7 @@ describe("bridgeCodexAppServerStartOptions", () => { }), ).resolves.toEqual({ ...startOptions, - clearEnv: ["FOO", "OPENAI_API_KEY"], + clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"], }); expect(startOptions.clearEnv).toEqual(["FOO"]); await expect(fs.access(path.join(agentDir, "harness-auth"))).rejects.toMatchObject({ @@ -178,7 +173,7 @@ describe("bridgeCodexAppServerStartOptions", () => { }), ).resolves.toEqual({ ...startOptions, - clearEnv: ["FOO", "OPENAI_API_KEY"], + clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"], }); } finally { await fs.rm(agentDir, { recursive: true, force: true }); @@ -207,7 +202,7 @@ describe("bridgeCodexAppServerStartOptions", () => { }), ).resolves.toEqual({ ...startOptions, - clearEnv: ["FOO", "OPENAI_API_KEY"], + clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"], }); } finally { await fs.rm(agentDir, { recursive: true, force: true }); @@ -380,6 +375,105 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it("falls back to CODEX_API_KEY when no auth profile and no Codex account is available", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async (method: string) => { + if (method === "account/read") { + return { account: null, requiresOpenaiAuth: true }; + } + return { type: "apiKey" }; + }); + vi.stubEnv("CODEX_API_KEY", "codex-env-api-key"); + vi.stubEnv("OPENAI_API_KEY", "openai-env-api-key"); + try { + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + }); + + expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false }); + expect(request).toHaveBeenNthCalledWith(2, "account/login/start", { + type: "apiKey", + apiKey: "codex-env-api-key", + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("falls back to OPENAI_API_KEY when CODEX_API_KEY is not set", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async (method: string) => { + if (method === "account/read") { + return { account: null, requiresOpenaiAuth: true }; + } + return { type: "apiKey" }; + }); + vi.stubEnv("CODEX_API_KEY", ""); + vi.stubEnv("OPENAI_API_KEY", "openai-env-api-key"); + try { + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + }); + + expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false }); + expect(request).toHaveBeenNthCalledWith(2, "account/login/start", { + type: "apiKey", + apiKey: "openai-env-api-key", + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("keeps an existing app-server ChatGPT account over env API-key fallback", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async (method: string) => { + if (method === "account/read") { + return { + account: { type: "chatgpt", email: "codex@example.test", planType: "plus" }, + requiresOpenaiAuth: true, + }; + } + return { type: "apiKey" }; + }); + vi.stubEnv("CODEX_API_KEY", "codex-env-api-key"); + try { + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith("account/read", { refreshToken: false }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("skips env API-key fallback when app-server does not require OpenAI auth", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async (method: string) => { + if (method === "account/read") { + return { account: null, requiresOpenaiAuth: false }; + } + return { type: "apiKey" }; + }); + vi.stubEnv("CODEX_API_KEY", "codex-env-api-key"); + try { + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith("account/read", { refreshToken: false }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("applies an OpenAI Codex token profile backed by a secret ref", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index 135fc7fffa3..5cf0503747b 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -10,11 +10,14 @@ import { import type { CodexAppServerClient } from "./client.js"; import type { CodexAppServerStartOptions } from "./config.js"; import type { ChatgptAuthTokensRefreshResponse } from "./protocol-generated/typescript/v2/ChatgptAuthTokensRefreshResponse.js"; +import type { GetAccountResponse } from "./protocol-generated/typescript/v2/GetAccountResponse.js"; import type { LoginAccountParams } from "./protocol-generated/typescript/v2/LoginAccountParams.js"; const CODEX_APP_SERVER_AUTH_PROVIDER = "openai-codex"; const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default"; +const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY"; const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY"; +const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR]; export async function bridgeCodexAppServerStartOptions(params: { startOptions: CodexAppServerStartOptions; @@ -30,7 +33,7 @@ export async function bridgeCodexAppServerStartOptions(params: { authProfileId: params.authProfileId, }); return shouldClearInheritedOpenAiApiKey - ? withClearedEnvironmentVariable(params.startOptions, OPENAI_API_KEY_ENV_VAR) + ? withClearedEnvironmentVariables(params.startOptions, CODEX_APP_SERVER_API_KEY_ENV_VARS) : params.startOptions; } @@ -44,6 +47,13 @@ export async function applyCodexAppServerAuthProfile(params: { authProfileId: params.authProfileId, }); if (!loginParams) { + const fallbackLoginParams = await resolveCodexAppServerEnvApiKeyLoginParams({ + client: params.client, + env: process.env, + }); + if (fallbackLoginParams) { + await params.client.request("account/login/start", fallbackLoginParams); + } return; } await params.client.request("account/login/start", loginParams); @@ -105,6 +115,23 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: { return loginParams; } +async function resolveCodexAppServerEnvApiKeyLoginParams(params: { + client: CodexAppServerClient; + env: NodeJS.ProcessEnv; +}): Promise { + const apiKey = readFirstNonEmptyEnv(params.env, CODEX_APP_SERVER_API_KEY_ENV_VARS); + if (!apiKey) { + return undefined; + } + const response = await params.client.request("account/read", { + refreshToken: false, + }); + if (response.account || !response.requiresOpenaiAuth) { + return undefined; + } + return { type: "apiKey", apiKey }; +} + async function resolveLoginParamsForCredential( profileId: string, credential: AuthProfileCredential, @@ -189,20 +216,31 @@ function isCodexSubscriptionCredential(credential: AuthProfileCredential | undef return credential.type === "oauth" || credential.type === "token"; } -function withClearedEnvironmentVariable( +function withClearedEnvironmentVariables( startOptions: CodexAppServerStartOptions, - envVar: string, + envVars: readonly string[], ): CodexAppServerStartOptions { const clearEnv = startOptions.clearEnv ?? []; - if (clearEnv.includes(envVar)) { + const missingEnvVars = envVars.filter((envVar) => !clearEnv.includes(envVar)); + if (missingEnvVars.length === 0) { return startOptions; } return { ...startOptions, - clearEnv: [...clearEnv, envVar], + clearEnv: [...clearEnv, ...missingEnvVars], }; } +function readFirstNonEmptyEnv(env: NodeJS.ProcessEnv, keys: readonly string[]): string | undefined { + for (const key of keys) { + const value = env[key]?.trim(); + if (value) { + return value; + } + } + return undefined; +} + function buildChatgptAuthTokensParams( profileId: string, credential: AuthProfileCredential, diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 1fc858cec55..ed8d4e48ecd 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -18,7 +18,6 @@ describe("Codex app-server config", () => { transport: "websocket", url: "ws://127.0.0.1:39175", headers: { "X-Test": "yes" }, - clearEnv: ["OPENAI_API_KEY"], approvalPolicy: "on-request", sandbox: "danger-full-access", approvalsReviewer: "guardian_subagent", @@ -41,12 +40,26 @@ describe("Codex app-server config", () => { transport: "websocket", url: "ws://127.0.0.1:39175", headers: { "X-Test": "yes" }, - clearEnv: ["OPENAI_API_KEY"], }), }), ); }); + it("ignores app-server environment clearing for websocket transports", () => { + const runtime = resolveCodexAppServerRuntimeOptions({ + pluginConfig: { + appServer: { + transport: "websocket", + url: "ws://127.0.0.1:39175", + clearEnv: ["OPENAI_API_KEY"], + }, + }, + env: {}, + }); + + expect(runtime.start).not.toHaveProperty("clearEnv"); + }); + it("normalizes app-server environment variables to clear", () => { const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: { diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index cd4c002c1f4..53d19b4f077 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -214,7 +214,7 @@ export function resolveCodexAppServerRuntimeOptions( ...(url ? { url } : {}), ...(authToken ? { authToken } : {}), headers, - ...(clearEnv.length > 0 ? { clearEnv } : {}), + ...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}), }, requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000), approvalPolicy: diff --git a/extensions/codex/src/app-server/transport-stdio.test.ts b/extensions/codex/src/app-server/transport-stdio.test.ts index 8d2df0313e8..d1f7ba5d162 100644 --- a/extensions/codex/src/app-server/transport-stdio.test.ts +++ b/extensions/codex/src/app-server/transport-stdio.test.ts @@ -3,7 +3,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { CodexAppServerStartOptions } from "./config.js"; -import { resolveCodexAppServerSpawnInvocation } from "./transport-stdio.js"; +import { + resolveCodexAppServerSpawnEnv, + resolveCodexAppServerSpawnInvocation, +} from "./transport-stdio.js"; const tempDirs: string[] = []; @@ -86,3 +89,26 @@ describe("resolveCodexAppServerSpawnInvocation", () => { }); }); }); + +describe("resolveCodexAppServerSpawnEnv", () => { + it("applies configured env overrides before clearing denied env vars", () => { + expect( + resolveCodexAppServerSpawnEnv( + { + env: { + OPENAI_API_KEY: "configured-openai-key", + KEEP: "override", + }, + clearEnv: ["OPENAI_API_KEY", "CODEX_API_KEY", "MISSING"], + }, + { + OPENAI_API_KEY: "parent-openai-key", + CODEX_API_KEY: "parent-codex-key", + KEEP: "parent", + }, + ), + ).toEqual({ + KEEP: "override", + }); + }); +}); diff --git a/extensions/codex/src/app-server/transport-stdio.ts b/extensions/codex/src/app-server/transport-stdio.ts index c3c60c110aa..16a7b2a8cf4 100644 --- a/extensions/codex/src/app-server/transport-stdio.ts +++ b/extensions/codex/src/app-server/transport-stdio.ts @@ -41,14 +41,22 @@ export function resolveCodexAppServerSpawnInvocation( }; } -export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport { +export function resolveCodexAppServerSpawnEnv( + options: Pick, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { const env = { - ...process.env, + ...baseEnv, ...options.env, }; for (const key of options.clearEnv ?? []) { delete env[key]; } + return env; +} + +export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport { + const env = resolveCodexAppServerSpawnEnv(options); const invocation = resolveCodexAppServerSpawnInvocation(options, { platform: process.platform, env,