From 978d2e01ab5be3f612a562809603430c64eedd60 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 14 Sep 2026 11:25:10 +0900 Subject: [PATCH 1/6] fix(audio): bind stored direct account identity --- src/codex/auth-context.ts | 1 + structure/data-planes/inbound-compat.md | 3 ++- structure/providers/openai-tiers.md | 3 ++- tests/server/audio-transcriptions.test.ts | 21 +++++++++++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 32d124b7fd..8332dba51e 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); diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index aa9aa15f52..275c7c4dc0 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 a1824746dc..5294f8484a 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -417,7 +417,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/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" }; From 52f0f80fd0a4f26f7e1929389a9a47b5ca68429b Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:15:48 +0900 Subject: [PATCH 2/6] fix(auth): clear caller account header on async Direct substitution --- src/codex/auth-context.ts | 1 + .../codex-auth-context.test.ts | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 8332dba51e..353ab1a2e1 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1238,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/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index b488bc7855..ffff0961ed 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, @@ -1668,6 +1669,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. From e8e4004dfbbbfd8d4bfdc1582a433a7326bf4e6c Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:28:04 +0900 Subject: [PATCH 3/6] test(auth): cover Direct identity on both Responses endpoints --- .../responses-native-main-refresh.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/responses/responses-native-main-refresh.test.ts b/tests/responses/responses-native-main-refresh.test.ts index 3b78ff6402..f70bc07f1e 100644 --- a/tests/responses/responses-native-main-refresh.test.ts +++ b/tests/responses/responses-native-main-refresh.test.ts @@ -9,6 +9,8 @@ import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "../../src/codex import { withNativeMainSharedClaim } from "../../src/codex/native-main-claim"; import type { NativeProfileContext } from "../../src/codex/native-profile-store"; import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { resolveResponsesApiAuth } from "../../src/server/auth-cors"; +import { tryAdmitTurn } from "../../src/server/lifecycle"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; @@ -109,6 +111,54 @@ function install401ThenRefreshHarness(): { sends: string[]; refreshes: string[] } describe("native main 401 refresh and replay", () => { + test.each(["/v1/responses", "/v1/responses/compact"] as const)( + "%s strips caller account identity when a bearer key selects stored Direct", + async path => { + const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86_400 })).toString("base64url"); + const storedCredential = `header.${payload}.signature`; + writeFileSync(join(home, "auth.json"), JSON.stringify({ + tokens: { access_token: storedCredential }, + })); + const cfg = config(); + cfg.hostname = "0.0.0.0"; + cfg.providers.openai!.codexAccountMode = "direct"; + cfg.apiKeys = [{ + id: "direct-test", name: "direct-test", key: "ocx_data_direct_ingress", + createdAt: "2026-09-14T00:00:00.000Z", + }]; + const req = request(path); + req.headers.set("authorization", "Bearer ocx_data_direct_ingress"); + req.headers.set("chatgpt-account-id", "caller-account"); + const admission = resolveResponsesApiAuth(req, cfg); + expect(admission?.source).toBe("bearer"); + const sent: Headers[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname.endsWith("/responses") || url.pathname.endsWith("/responses/compact")) { + sent.push(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))); + return Response.json({ id: "resp_direct", object: "response", status: "completed", output: [] }); + } + return Response.json({ rate_limit: { primary_window: { used_percent: 10 } } }); + }) as typeof fetch; + + const turn = tryAdmitTurn(); + expect(turn).not.toBeNull(); + try { + const log = { model: "", provider: "" } as RequestLogContext; + const response = path === "/v1/responses" + ? await handleResponses(req, cfg, log, { admission: admission!, turnAdmissionLease: turn! }) + : await handleResponsesCompact(req, cfg, log, turn!, admission!); + expect(response.status).toBe(200); + await response.text(); + expect(sent).toHaveLength(1); + expect(sent[0]!.get("authorization")).toBe(`Bearer ${storedCredential}`); + expect(sent[0]!.get("chatgpt-account-id")).toBeNull(); + } finally { + turn?.release(); + } + }, + ); + test("refreshes a refresh-only native main credential before upstream I/O", async () => { writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { refresh_token: "refresh-grant", account_id: "account-main" }, From 4abcd34b4da3d5712496da237ff930acc4bb29e3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:31:07 +0900 Subject: [PATCH 4/6] docs: link stored Direct identity from all source owners --- structure/catalog.md | 2 ++ structure/codex-home.md | 2 ++ structure/config.md | 2 ++ structure/gui-and-management-api.md | 2 +- structure/ops/docs-and-release.md | 2 ++ structure/providers/openai-tiers.md | 1 + structure/runtime.md | 2 ++ structure/subagents.md | 2 ++ 8 files changed, 14 insertions(+), 1 deletion(-) diff --git a/structure/catalog.md b/structure/catalog.md index 47a827426b..a6049d108f 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -349,3 +349,5 @@ Exact [model input declarations](config.md#explicit-per-model-capability-declara ## Renamed destination reasoning metadata `src/providers/derive.ts` fills missing reasoning tables for renamed providers accepted by the existing fixed-key destination matcher. Model entries are cloned and explicit user entries (including empty arrays) win. Provider-wide effort defaults fill only when undefined; Command Code unknown models therefore keep the registry's empty picker policy unless overridden. Identity, transport and other capability axes are unchanged. The gathered row drives client exports; this metadata contract does not prove arbitrary gateway routing. + +Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. diff --git a/structure/codex-home.md b/structure/codex-home.md index 339358ea9b..867fc11d61 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -275,3 +275,5 @@ Pool quota producers and account commands follow the [bounded raw-observation co The account history response can include a [low-confidence effective capacity estimate](providers/openai-tiers.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. + +Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. diff --git a/structure/config.md b/structure/config.md index a4c0b97ead..63d949170f 100644 --- a/structure/config.md +++ b/structure/config.md @@ -308,3 +308,5 @@ The text-only consumer reads exact inputModalities declarations before legacy hi ## Catalog auto-refresh `catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults off: an absent key, an explicit false, and a malformed value all leave the scheduler dormant. `src/config.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` is the module-singleton interval `src/server/background-lifecycle.ts` starts beside the quota reset poller; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures) and carries no provider or account detail. + +Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 0496735405..566053e36f 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -597,4 +597,4 @@ The [explicit model-capability contract](config.md#explicit-per-model-capability Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. -The raw provider editor round-trips `autoReviewModel` and `autoReviewModelOverrides` through editor-owned DTO fields. POST/PATCH/PUT share validation; PUT copies schema-normalized values into the persisted and live candidate before adoption. Canonical `openai` rejects these fields, including clear forms. Field-masked writes (PATCH, editor PUT, reload) pin every registry-seed key and ignore operator overlays the seed never defines, most commonly `selectedModels`; POST keeps the exact-key comparison. Canonical `openai` still rejects `allowPrivateNetwork`, which must not short-circuit destination DNS checks on the ChatGPT forward row. Existing authentication, origin checks and stale-baseline protection still govern the writes. See [reviewer projection](catalog.md#provider-scoped-approval-reviewer). +The raw provider editor round-trips `autoReviewModel` and `autoReviewModelOverrides` through editor-owned DTO fields. POST/PATCH/PUT share validation; PUT copies schema-normalized values into the persisted and live candidate before adoption. Canonical `openai` rejects these fields, including clear forms. Field-masked writes (PATCH, editor PUT, reload) pin every registry-seed key and ignore operator overlays the seed never defines, most commonly `selectedModels`; POST keeps the exact-key comparison. Canonical `openai` still rejects `allowPrivateNetwork`, which must not short-circuit destination DNS checks on the ChatGPT forward row. Existing authentication, origin checks and stale-baseline protection still govern the writes. See [reviewer projection](catalog.md#provider-scoped-approval-reviewer). Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index f4474c6419..fcd7057dc9 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -382,3 +382,5 @@ Exact [model input declarations](../config.md#explicit-per-model-capability-decl Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. + +Stored Direct substitution follows the [credential identity contract](../providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 5294f8484a..3925347f87 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -418,6 +418,7 @@ 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, replacing both bearer and account identity exclusively from that credential. +Both synchronous and asynchronous stored-main substitution in `src/codex/auth-context.ts` remove a caller account header before copying the stored identity; an absent stored account ID leaves no account header. Caller-owned native Direct authentication retains its existing passthrough behavior. `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 diff --git a/structure/runtime.md b/structure/runtime.md index aa977c51df..5571c357ee 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -416,3 +416,5 @@ Translated audio/file admission follows the [final-adapter input contract](adapt The combo may advance to its next eligible unattempted target before output commitment. It records no target/provider cooldown for these request-local mismatches and does not silently drop reasoning controls or raise `none` to a supported rung. Cancellation, origin/cyber-policy rejection, non-replayable post-send errors and the existing streaming commit boundary stay authoritative. Other invalid requests remain terminal. Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. + +Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. diff --git a/structure/subagents.md b/structure/subagents.md index 69047b076b..3a5f2a526c 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -371,3 +371,5 @@ Exact [model input declarations](config.md#explicit-per-model-capability-declara Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. + +Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. From face7c32f54370501ad7502e92bc68228aa2e4f5 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:33:53 +0900 Subject: [PATCH 5/6] refactor: split changed contracts to respect the file-size ratchet Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../codex-auth-context.test.ts | 50 +---------------- tests/helpers/stored-direct-identity.ts | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+), 48 deletions(-) create mode 100644 tests/helpers/stored-direct-identity.ts diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index d046ded03d..dd6d480900 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -1,3 +1,4 @@ +import { registerStoredDirectIdentityTests } from "../helpers/stored-direct-identity"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -19,7 +20,6 @@ import { cooldownErrorResponse, headersForCodexAuthContext, materializeCodexUpstreamAuth, - materializeCodexUpstreamAuthAsync, CodexMainSubstitutionUnavailableError, isCodexAuthContextUsable, codexPoolAffinityKey, @@ -1661,53 +1661,7 @@ describe("Codex auth context", () => { }); - test("an admission bearer on main substitutes the stored credential, never forwards it (#1686)", () => { - // The caller proved admission with one of OUR secrets. That secret must never leave the - // process, so the only acceptable outcome is the stored main credential in its place. - const admissionSecret = "ocx_data_localsecret"; - const storedCredential = liveJwt(); - writeFileSync(join(testDir, "auth.json"), JSON.stringify({ - tokens: { access_token: storedCredential, account_id: "stored_main_acc" }, - })); - - const headers = materializeCodexUpstreamAuth( - new Headers({ authorization: `Bearer ${admissionSecret}`, "openai-beta": "responses=experimental" }), - { kind: "main", accountId: null }, - { substituteMainCredential: true }, - ); - - expect(headers.get("authorization")).not.toContain(admissionSecret); - expect(headers.get("authorization")).toBe(`Bearer ${storedCredential}`); - expect(headers.get("chatgpt-account-id")).toBe("stored_main_acc"); - // Unrelated forwarded headers still ride along. - 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"); - }); + registerStoredDirectIdentityTests(() => testDir, liveJwt); test("substitution fails closed when no usable main credential exists (#1686)", () => { // Falling through here would forward the admission secret upstream, which is exactly diff --git a/tests/helpers/stored-direct-identity.ts b/tests/helpers/stored-direct-identity.ts new file mode 100644 index 0000000000..815f59282e --- /dev/null +++ b/tests/helpers/stored-direct-identity.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { materializeCodexUpstreamAuth, materializeCodexUpstreamAuthAsync } from "../../src/codex/auth-context"; + +export function registerStoredDirectIdentityTests(getTestDir: () => string, liveJwt: () => string): void { + test("an admission bearer on main substitutes the stored credential, never forwards it (#1686)", () => { + // The caller proved admission with one of OUR secrets. That secret must never leave the + // process, so the only acceptable outcome is the stored main credential in its place. + const admissionSecret = "ocx_data_localsecret"; + const storedCredential = liveJwt(); + writeFileSync(join(getTestDir(), "auth.json"), JSON.stringify({ + tokens: { access_token: storedCredential, account_id: "stored_main_acc" }, + })); + + const headers = materializeCodexUpstreamAuth( + new Headers({ authorization: `Bearer ${admissionSecret}`, "openai-beta": "responses=experimental" }), + { kind: "main", accountId: null }, + { substituteMainCredential: true }, + ); + + expect(headers.get("authorization")).not.toContain(admissionSecret); + expect(headers.get("authorization")).toBe(`Bearer ${storedCredential}`); + expect(headers.get("chatgpt-account-id")).toBe("stored_main_acc"); + // Unrelated forwarded headers still ride along. + 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(getTestDir(), "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"); + }); + +} From c843765f92d0eda05f3ccfff25df7779686a81b4 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:10:30 +0900 Subject: [PATCH 6/6] test(auth): cover synchronous stored Direct identity isolation Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- tests/helpers/stored-direct-identity.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/helpers/stored-direct-identity.ts b/tests/helpers/stored-direct-identity.ts index 815f59282e..92f5e16698 100644 --- a/tests/helpers/stored-direct-identity.ts +++ b/tests/helpers/stored-direct-identity.ts @@ -26,6 +26,30 @@ export function registerStoredDirectIdentityTests(getTestDir: () => string, live expect(headers.get("openai-beta")).toBe("responses=experimental"); }); + test("sync stored Direct substitution clears a missing account ID without changing inbound headers", () => { + const storedCredential = liveJwt(); + writeFileSync(join(getTestDir(), "auth.json"), JSON.stringify({ + tokens: { access_token: storedCredential }, + })); + const inbound = new Headers({ + authorization: "Bearer ocx_data_localsecret", + "chatgpt-account-id": "caller-account", + "openai-beta": "responses=experimental", + }); + const originalHeaders = [...inbound.entries()]; + + const headers = materializeCodexUpstreamAuth( + inbound, + { kind: "main", accountId: null }, + { substituteMainCredential: true }, + ); + + expect(headers.get("authorization")).toBe(`Bearer ${storedCredential}`); + expect(headers.get("chatgpt-account-id")).toBeNull(); + expect(headers.get("openai-beta")).toBe("responses=experimental"); + expect([...inbound.entries()]).toEqual(originalHeaders); + }); + test.each([ ["absent", undefined, null], ["present", "stored_main_acc", "stored_main_acc"],