diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 258d4ece11..ea6813f599 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -322,7 +322,10 @@ export async function handleResponsesCompact( // #1686: a bearer-presented admission secret is one of ours, so the stored main credential // is substituted below instead of the caller bearer being forwarded. - const substituteMainCredential = admission?.source === "bearer"; + // #2132: and only when the route is a native Codex one, which is the only route that can + // consume that credential. See the longer note in core.ts resolveResponsesCodexAuth. + const substituteMainCredential = admission?.source === "bearer" + && route.codexAccountMode !== undefined; if (route.codexAccountMode === "direct" && !substituteMainCredential) { try { validateForwardAdmissionCredential(req.headers, config); } catch (err) { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 9e2813d0b5..90fac70755 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1085,7 +1085,15 @@ async function resolveResponsesCodexAuth( // Refusing it here is what made the codex-cli `env_key` contract unusable against Direct. // Admitting it is only safe because the stored main credential is substituted below, so // the admission secret still never leaves this process. - const substituteMainCredential = options.admission?.source === "bearer"; + // + // #2132: substitution answers "does THIS ROUTE need our stored ChatGPT credential", not + // "how did the caller authenticate". Only a native Codex route reaches the ChatGPT backend + // and can consume that credential; a key-authenticated routed provider carries its own and + // never touches it. Keying on the caller alone made an install that deliberately never + // logged into ChatGPT fail every routed request with "No usable Codex main credential". + // `codexAccountMode` is set only for the native openai row, which is exactly that test. + const substituteMainCredential = options.admission?.source === "bearer" + && route.codexAccountMode !== undefined; if (route.codexAccountMode === "direct" && !substituteMainCredential) { validateForwardAdmissionCredential(req.headers, config); } diff --git a/tests/bearer-admission-routed-provider.test.ts b/tests/bearer-admission-routed-provider.test.ts new file mode 100644 index 0000000000..2baa300393 --- /dev/null +++ b/tests/bearer-admission-routed-provider.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; + +/** + * Issue #2132: bearer admission must not require a stored ChatGPT credential. + * + * #1686 made a caller that proves admission with one of OUR secrets substitute the stored + * main credential, so the admission secret never leaves the process. That is right for a + * route that actually reaches the ChatGPT backend. It was applied by asking HOW the caller + * authenticated and never WHERE the request routes, so a request bound for a + * key-authenticated provider — which carries its own credential and never touches ChatGPT — + * was gated on a credential it has no use for. An install that deliberately never logged + * into ChatGPT got 401 "No usable Codex main credential" on every request. + * + * The substitution itself is unchanged and still fails closed for native routes; only the + * question it is asked changes. + */ + +const originalFetch = globalThis.fetch; +const previousOcxHome = process.env.OPENCODEX_HOME; +const previousCodexHome = process.env.CODEX_HOME; +const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; + +let ocxHome = ""; +let codexHome = ""; +let routedAuth: Array = []; +let nativeAuth: Array = []; + +const ADMISSION_SECRET = "ocx_data_2132secret"; +const ROUTED_KEY = "sk-routed-provider-key"; + +/** A JWT whose `exp` is far in the future, so a stored main token reads as live. */ +function liveJwt(): string { + const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86_400 })).toString("base64url"); + return `header.${payload}.signature`; +} + +/** + * A remote bind (so admission is required rather than loopback-waived) with BOTH a native + * openai row and a key-authenticated routed provider. The routed provider is the one under + * test; the native row has to exist for the negative case to be reachable. + */ +function mixedConfig(): OcxConfig { + return { + port: 0, + hostname: "0.0.0.0", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + defaultModel: "gpt-5.6-luna", + }, + gateway: { + adapter: "openai-chat", + baseUrl: "https://gateway.example.com/v1", + authMode: "key", + apiKey: ROUTED_KEY, + models: ["gateway-model"], + }, + }, + apiKeys: [ + { id: "env-key", name: "env_key", key: ADMISSION_SECRET, createdAt: "2026-08-20T00:00:00.000Z" }, + ], + } as OcxConfig; +} + +beforeEach(() => { + ocxHome = mkdtempSync(join(tmpdir(), "ocx-2132-home-")); + codexHome = mkdtempSync(join(tmpdir(), "ocx-2132-codex-")); + process.env.OPENCODEX_HOME = ocxHome; + process.env.CODEX_HOME = codexHome; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + routedAuth = []; + nativeAuth = []; + globalThis.fetch = (async (input, init) => { + const raw = input instanceof Request ? input.url : String(input); + const url = new URL(raw); + const headers = new Headers(input instanceof Request ? input.headers : init?.headers); + if (url.hostname === "gateway.example.com") { + routedAuth.push(headers.get("authorization")); + return Response.json({ + id: "chatcmpl_2132", + object: "chat.completion", + created: 0, + model: "gateway-model", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + }); + } + if (url.hostname === "chatgpt.com" || url.hostname === "api.openai.com") { + nativeAuth.push(headers.get("authorization")); + return Response.json({ id: "resp_2132", object: "response", status: "completed", output: [] }); + } + return originalFetch(input, init); + }) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken; + if (ocxHome) rmSync(ocxHome, { recursive: true, force: true }); + if (codexHome) rmSync(codexHome, { recursive: true, force: true }); + ocxHome = ""; + codexHome = ""; +}); + +async function postResponses(url: string | URL, model: string): Promise { + return originalFetch(new URL("/v1/responses", url), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${ADMISSION_SECRET}` }, + body: JSON.stringify({ model, input: "hi", stream: false }), + }); +} + +describe("#2132 bearer admission does not require a ChatGPT credential for routed providers", () => { + test("a key-authenticated route is served with no stored main credential", async () => { + saveConfig(mixedConfig()); + // The reported install: no ChatGPT login was ever performed. + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0); + try { + const response = await postResponses(server.url, "gateway/gateway-model"); + + // Before this change the same request answered 401 "No usable Codex main credential", + // because admission-by-bearer alone decided a ChatGPT token had to be substituted. + expect(response.status).toBe(200); + // The provider's own key is what authenticates it, and our admission secret stays home. + expect(routedAuth).toEqual([`Bearer ${ROUTED_KEY}`]); + expect(routedAuth.join("|")).not.toContain(ADMISSION_SECRET); + expect(nativeAuth).toHaveLength(0); + } finally { + await server.stop(true); + } + }); + + test("a native route with no stored main credential still fails closed", async () => { + saveConfig(mixedConfig()); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0); + try { + const response = await postResponses(server.url, "gpt-5.6-luna"); + + // This is the #1686 guarantee and it must survive: a native route genuinely needs the + // stored credential, so it fails BEFORE any upstream I/O rather than forwarding ours. + expect(response.status).toBe(401); + expect(nativeAuth).toHaveLength(0); + expect(routedAuth).toHaveLength(0); + } finally { + await server.stop(true); + } + }); + + test("a native route still substitutes the stored main credential when one exists", async () => { + saveConfig(mixedConfig()); + const stored = liveJwt(); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = startServer(0); + try { + const response = await postResponses(server.url, "gpt-5.6-luna"); + + expect(response.status).toBe(200); + expect(nativeAuth).toEqual([`Bearer ${stored}`]); + expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); + } finally { + await server.stop(true); + } + }); +}); +