diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 2f19ef276db..1e1e7cf5b69 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -185,9 +185,9 @@ Codex after changing config. 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 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. +For live and Docker smoke tests, auth usually comes from the Codex CLI account +or an OpenClaw `openai-codex` auth profile. Local stdio app-server launches can +also fall back to `CODEX_API_KEY` / `OPENAI_API_KEY` when no account is present. ## Minimal config @@ -514,15 +514,18 @@ order: 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. +3. For local stdio app-server launches only, `CODEX_API_KEY`, then + `OPENAI_API_KEY`, 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. +Explicit Codex API-key profiles and local stdio env-key fallback use app-server +login instead of inherited child-process env. WebSocket app-server connections +do not receive Gateway env API-key fallback; use an explicit auth profile or the +remote app-server's own account. If a deployment needs additional environment isolation, add those variables to `appServer.clearEnv`: diff --git a/docs/providers/openai.md b/docs/providers/openai.md index c6e24dfe2cf..e4e83e088b4 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -293,15 +293,17 @@ 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. +3. For local stdio app-server launches only, `CODEX_API_KEY`, then + `OPENAI_API_KEY`, 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. +or embeddings. Env API-key fallback is only the local stdio no-account path; it +is not sent to WebSocket app-server connections. 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 diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index 74b9e23adc0..1895f4874f6 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -389,6 +389,7 @@ describe("bridgeCodexAppServerStartOptions", () => { await applyCodexAppServerAuthProfile({ client: { request } as never, agentDir, + startOptions: createStartOptions(), }); expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false }); @@ -415,6 +416,7 @@ describe("bridgeCodexAppServerStartOptions", () => { await applyCodexAppServerAuthProfile({ client: { request } as never, agentDir, + startOptions: createStartOptions(), }); expect(request).toHaveBeenNthCalledWith(1, "account/read", { refreshToken: false }); @@ -443,6 +445,7 @@ describe("bridgeCodexAppServerStartOptions", () => { await applyCodexAppServerAuthProfile({ client: { request } as never, agentDir, + startOptions: createStartOptions(), }); expect(request).toHaveBeenCalledTimes(1); @@ -465,6 +468,7 @@ describe("bridgeCodexAppServerStartOptions", () => { await applyCodexAppServerAuthProfile({ client: { request } as never, agentDir, + startOptions: createStartOptions(), }); expect(request).toHaveBeenCalledTimes(1); @@ -474,6 +478,32 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it("does not send env API-key fallback to websocket app-server connections", 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, + startOptions: createStartOptions({ + transport: "websocket", + url: "ws://127.0.0.1:1455", + }), + }); + + expect(request).not.toHaveBeenCalled(); + } 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 5cf0503747b..dcd5462bb46 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -41,12 +41,16 @@ export async function applyCodexAppServerAuthProfile(params: { client: CodexAppServerClient; agentDir: string; authProfileId?: string; + startOptions?: CodexAppServerStartOptions; }): Promise { const loginParams = await resolveCodexAppServerAuthProfileLoginParams({ agentDir: params.agentDir, authProfileId: params.authProfileId, }); if (!loginParams) { + if (params.startOptions?.transport !== "stdio") { + return; + } const fallbackLoginParams = await resolveCodexAppServerEnvApiKeyLoginParams({ client: params.client, env: process.env, diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index ed8d4e48ecd..7ebab3cd6e0 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -311,6 +311,16 @@ describe("Codex app-server config", () => { }); expect(first).not.toEqual(second); + expect( + codexAppServerStartOptionsKey({ + transport: "websocket", + command: "codex", + args: [], + url: "ws://127.0.0.1:39175", + authToken: "tok_first", + headers: {}, + }), + ).toEqual(first); expect(first).not.toContain("tok_first"); expect(second).not.toContain("tok_second"); }); @@ -332,6 +342,15 @@ describe("Codex app-server config", () => { }); expect(first).not.toEqual(second); + expect( + codexAppServerStartOptionsKey({ + transport: "stdio", + command: "codex", + args: ["app-server"], + headers: {}, + env: { OPENAI_API_KEY: "sk-first" }, + }), + ).toEqual(first); expect(first).not.toContain("sk-first"); expect(second).not.toContain("sk-second"); }); diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 53d19b4f077..d9dd759cc6f 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -1,7 +1,9 @@ -import { createHash } from "node:crypto"; +import { createHmac, randomBytes } from "node:crypto"; import { z } from "zod"; import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js"; +const START_OPTIONS_KEY_SECRET = randomBytes(32); + export type CodexAppServerTransportMode = "stdio" | "websocket"; export type CodexAppServerPolicyMode = "yolo" | "guardian"; export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted"; @@ -300,13 +302,13 @@ export function codexAppServerStartOptionsKey( commandSource: options.commandSource ?? null, args: options.args, url: options.url ?? null, - authToken: hashSecretForKey(options.authToken), + authToken: hashSecretForKey(options.authToken, "authToken"), headers: Object.entries(options.headers).toSorted(([left], [right]) => left.localeCompare(right), ), env: Object.entries(options.env ?? {}) .toSorted(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => [key, hashSecretForKey(value)]), + .map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]), clearEnv: [...(options.clearEnv ?? [])].toSorted(), authProfileId: params.authProfileId ?? null, }); @@ -431,11 +433,15 @@ function readNonEmptyString(value: unknown): string | undefined { return trimmed || undefined; } -function hashSecretForKey(value: string | undefined): string | null { +function hashSecretForKey(value: string | undefined, label: string): string | null { if (!value) { return null; } - return createHash("sha256").update(value).digest("hex"); + return createHmac("sha256", START_OPTIONS_KEY_SECRET) + .update(label) + .update("\0") + .update(value) + .digest("hex"); } function splitShellWords(value: string): string[] { diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index b6d82eeb969..98669dc717b 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -59,6 +59,7 @@ export async function getSharedCodexAppServerClient(options?: { client, agentDir, authProfileId: options?.authProfileId, + startOptions, }); return client; } catch (error) { @@ -104,6 +105,7 @@ export async function createIsolatedCodexAppServerClient(options?: { client, agentDir, authProfileId: options?.authProfileId, + startOptions, }); return client; } catch (error) {