diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 32d124b7fd..353ab1a2e1 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1160,6 +1160,7 @@ export function materializeCodexUpstreamAuth( if (!stored?.accessToken || !isMainAccountTokenLive()) { throw new CodexMainSubstitutionUnavailableError(); } + selected.delete("chatgpt-account-id"); selected.set("authorization", `Bearer ${stored.accessToken}`); if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId); observeSelectedMainCredential(stored, writer); @@ -1237,6 +1238,7 @@ export async function materializeCodexUpstreamAuthAsync( ...(options.nativeMainRefreshDependencies ?? {}), }); if (!stored?.accessToken) throw new CodexMainSubstitutionUnavailableError(); + selected.delete("chatgpt-account-id"); selected.set("authorization", `Bearer ${stored.accessToken}`); if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId); observeSelectedMainCredential(stored, writer); diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index e6e6ecd582..2cc844d6c3 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -8,7 +8,8 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior `src/server/audio-transcriptions.ts` owns `POST /v1/audio/transcriptions`, independently of Responses and Chat conversion. `src/server/audio-upstream.ts` resolves explicit data-plane keys on both listeners and substitutes stored OpenAI credentials. Direct stored-main access claims -the enclosing admission lease; Pool uses the existing sidecar account resolver. A selected +the enclosing admission lease and derives its account header only from that stored credential; +caller-supplied account selection is never retained. Pool uses the existing sidecar account resolver. A selected ChatGPT authentication failure never falls through to the paid OpenAI provider. The bounded multipart input accepts one nonempty file up to 25,000,000 bytes within a 32 MiB diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 527aacba6b..22fc41dc30 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -415,7 +415,8 @@ sidecar candidate and cannot hide a failed Codex credential with separately bill `src/server/audio-upstream.ts` uses the same selection for standalone transcription. Explicit native Direct auth remains caller-owned; proxy-key-only Direct claims stored main before -materialization. `src/providers/openai-sidecar.ts` releases quota-probe ownership on every +materialization, replacing both bearer and account identity exclusively from that credential. +`src/providers/openai-sidecar.ts` releases quota-probe ownership on every materialization or usability failure before transferring a resolved context to its caller. Audio reports one terminal upstream outcome after validating the response body; redirects remain neutral and client/shutdown cancellation does not manufacture an account failure. diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index c23a1cda2c..9084e1e477 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -19,6 +19,7 @@ import { cooldownErrorResponse, headersForCodexAuthContext, materializeCodexUpstreamAuth, + materializeCodexUpstreamAuthAsync, CodexMainSubstitutionUnavailableError, isCodexAuthContextUsable, resolveCodexAuthContext, @@ -1664,6 +1665,32 @@ describe("Codex auth context", () => { expect(headers.get("openai-beta")).toBe("responses=experimental"); }); + test.each([ + ["absent", undefined, null], + ["present", "stored_main_acc", "stored_main_acc"], + ])("async stored Direct substitution owns account identity when %s", async (_label, accountId, expectedAccountId) => { + const storedCredential = liveJwt(); + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: storedCredential, account_id: accountId }, + })); + const inbound = new Headers({ + authorization: "Bearer ocx_data_localsecret", + "chatgpt-account-id": "caller-account", + "openai-beta": "responses=experimental", + }); + + const headers = await materializeCodexUpstreamAuthAsync( + inbound, + { kind: "main", accountId: null }, + { substituteMainCredential: true }, + ); + + expect(headers.get("authorization")).toBe(`Bearer ${storedCredential}`); + expect(headers.get("chatgpt-account-id")).toBe(expectedAccountId); + expect(headers.get("openai-beta")).toBe("responses=experimental"); + expect(inbound.get("chatgpt-account-id")).toBe("caller-account"); + }); + test("substitution fails closed when no usable main credential exists (#1686)", () => { // Falling through here would forward the admission secret upstream, which is exactly // the leak the forward guard exists to prevent. Throw before any I/O instead. diff --git a/tests/server/audio-transcriptions.test.ts b/tests/server/audio-transcriptions.test.ts index eb6a9cfe75..7141cc1fe0 100644 --- a/tests/server/audio-transcriptions.test.ts +++ b/tests/server/audio-transcriptions.test.ts @@ -284,6 +284,27 @@ describe("standalone transcription API", () => { expect((await captured[0]!.formData()).get("model")).toBeNull(); }); + test("stored Direct credentials never inherit a caller account ID", async () => { + writeFileSync(join(codex.path, "auth.json"), JSON.stringify({ tokens: { access_token: "fixture-main-access" } })); + clearMainAccountInfoCache(); + const cfg = config(); + cfg.defaultProvider = "openai"; + cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }; + saveConfig(cfg); + + for (const headers of [ + { authorization: "", "x-opencodex-api-key": KEY, "chatgpt-account-id": "caller-workspace" }, + { authorization: "", "x-api-key": KEY, "chatgpt-account-id": "caller-workspace" }, + ]) { + expect((await request(form(), headers)).status).toBe(200); + } + expect(captured).toHaveLength(2); + for (const upstream of captured) { + expect(upstream.headers.get("authorization")).toBe("Bearer fixture-main-access"); + expect(upstream.headers.get("chatgpt-account-id")).toBeNull(); + } + }); + test("a missing stored Direct credential fails without paid-provider fallback", async () => { const cfg = config(); cfg.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" };