diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 121fa93fe3..def443de65 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1165,9 +1165,19 @@ async function resolveResponsesCodexAuth( // 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. + // + // But ask that question the way the ADAPTER asks it. `codexAccountMode` is derived from the + // provider NAME (`providerCodexAccountMode`), while the passthrough adapter decides whether + // to forward caller credentials from the TRANSPORT — adapter, auth mode, and base URL + // (`isCanonicalOpenAiForwardProvider`). A row the operator named anything other than + // `openai`, pointed at the canonical ChatGPT backend with `authMode: "forward"`, satisfies + // the adapter's test and fails this one, so substitution was skipped and the adapter then + // forwarded our own admission secret upstream. Two predicates answering one question is the + // bug; the transport is the authority, because the transport is what actually carries the + // header. A key-authenticated routed provider is still not canonical-forward, so #2132's + // no-ChatGPT-login install keeps working. const substituteMainCredential = options.admission?.source === "bearer" - && route.codexAccountMode !== undefined; + && (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider)); 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 index 2baa300393..be4449c144 100644 --- a/tests/bearer-admission-routed-provider.test.ts +++ b/tests/bearer-admission-routed-provider.test.ts @@ -186,3 +186,73 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route }); }); +/** + * The predicate above must be keyed on TRANSPORT, not on the provider's name. + * + * `codexAccountMode` comes from `providerCodexAccountMode`, which special-cases the id + * `openai`. The passthrough adapter decides whether it may forward caller credentials from + * `isCanonicalOpenAiForwardProvider` — adapter, auth mode, and base URL. A row the operator + * named anything else, pointed at the canonical ChatGPT backend, satisfies the adapter's test + * and fails the name-based one. Substitution was therefore skipped and the adapter forwarded + * our own admission secret to ChatGPT. + * + * These assert the invariant rather than the implementation: an admission bearer must never + * reach the wire, whatever the row is called. + */ +describe("an admission bearer never reaches a canonical ChatGPT transport, whatever the row is named", () => { + function customNamedCanonicalConfig(): OcxConfig { + const base = mixedConfig(); + return { + ...base, + defaultProvider: "mirror", + providers: { + ...base.providers, + // Same adapter, same authMode, same canonical base URL as the `openai` row above. + // Only the name differs — and the name is not what carries the header upstream. + mirror: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + defaultModel: "gpt-5.6-luna", + }, + }, + } as OcxConfig; + } + + test("with no stored credential it fails closed instead of forwarding our secret", async () => { + saveConfig(customNamedCanonicalConfig()); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); + + const server = startServer(0); + try { + const response = await postResponses(server.url, "mirror/gpt-5.6-luna"); + + // Fail-before-I/O is the contract (src/codex/auth-context.ts): the only two acceptable + // outcomes for an admission bearer are replaced-with-stored-main, or refused. Reaching + // upstream at all with our secret in hand is the failure this pins. + expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); + expect(response.status).not.toBe(200); + } finally { + await server.stop(true); + } + }); + + test("with a stored credential the stored one is what goes upstream", async () => { + saveConfig(customNamedCanonicalConfig()); + const stored = liveJwt(); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored_main_acc" } }), + ); + + const server = startServer(0); + try { + await postResponses(server.url, "mirror/gpt-5.6-luna"); + + expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); + for (const sent of nativeAuth) expect(sent).toBe(`Bearer ${stored}`); + } finally { + await server.stop(true); + } + }); +});