diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 356ee9012f..fd6c525468 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1385,6 +1385,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); @@ -1462,6 +1463,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/catalog.md b/structure/catalog.md index 03636900b0..e88919dc7c 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -432,3 +432,5 @@ Exact [model input declarations](config.md#explicit-per-model-capability-declara `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. Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged. + +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 a6cb4c11a4..f0dfef5c1d 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -285,3 +285,5 @@ The account history response can include a [low-confidence effective capacity es 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. Upstream API-key usage follows the [physical-attempt account attribution contract](gui-and-management-api.md#upstream-key-account-attribution), independently of subscription quota observations. + +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 e756575a91..d54f209277 100644 --- a/structure/config.md +++ b/structure/config.md @@ -322,3 +322,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/feature-flags.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/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index c4acea5037..376dde6716 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -11,7 +11,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/gui-and-management-api.md b/structure/gui-and-management-api.md index b4d9490772..b9e8ad1376 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -651,6 +651,6 @@ 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. Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 19b585c087..8a15aeb3da 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -394,3 +394,5 @@ Provider-scoped approval reviewer settings are projected by the [catalog owner]( 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. Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](../transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged. + +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 38f72878ab..2fabff8d15 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -484,7 +484,9 @@ 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. +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 neutral and client/shutdown cancellation does not manufacture an account failure. diff --git a/structure/runtime.md b/structure/runtime.md index 09ce8a1f11..b588f075ff 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -512,3 +512,5 @@ stamps the configured key selected for the physical request. `src/server/request retains per-key attempt usage, and `src/usage/log.ts` validates and persists labels. The [account attribution contract](gui-and-management-api.md#upstream-key-account-attribution) defines identity, unknown records, and aggregation boundaries. + +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 bebd8fe79b..1bd4116328 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -386,3 +386,5 @@ Provider-scoped approval reviewer settings are projected by the [catalog owner]( 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. Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged. + +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/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index b3d8d83ae4..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"; @@ -1660,27 +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"); - }); + 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..92f5e16698 --- /dev/null +++ b/tests/helpers/stored-direct-identity.ts @@ -0,0 +1,79 @@ +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("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"], + ])("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"); + }); + +} 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" }, 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" };