From eed9ca93cdf5a51bcbc4462179ec2c00ede761eb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 18 Oct 2026 09:52:50 -0700 Subject: [PATCH 1/2] fix(codex): limit Direct hard-lock fallback to the locked account Use the original route's credential ownership and observed-main caller check before applying Direct hard-lock fallback. Preserve binding, missing-bearer and disabled-route behavior; document and cover the boundary. --- src/codex/auth-context.ts | 16 +- .../responses/main-hard-lock-routing.ts | 44 ++++- src/server/responses/request-prepare.ts | 13 +- structure/codex-account-controls.md | 14 +- ...account-hard-lock-fallback-routing.test.ts | 179 ++++++++++++++---- .../main-account-policy-binding-wait.test.ts | 91 +++++++++ 6 files changed, 304 insertions(+), 53 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index c4cd30b325d..577ee8ebad5 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1080,6 +1080,19 @@ async function awaitMainAccountPolicyBindingSettled( if (!settled) throw new CodexMainProfileDrainingError(); } +/** Memory-only ownership check shared by Direct admission and pre-auth hard-lock routing. */ +export async function mainAccountHardLockAppliesToCaller( + headers: Headers, + policy: CodexAuthPolicyConfig, + options: Pick = {}, +): Promise { + if (options.substituteMainCredentialForDirect === true) return true; + if (!hasCallerCodexBearer(headers)) return false; + if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options); + return callerMatchesObservedMain(headers); +} + /** * Resolve the credential for one Codex turn. A main hard-lock reached through unpinned pool * selection is annotated with the earliest known pool recovery, so the refusal can name a retry @@ -1172,8 +1185,7 @@ async function resolveCodexAuthContextUnannotated( // Trusted substitution still has to claim and validate stored main below. if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); if (!substituteStoredMain) { - if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options); - if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); + if (await mainAccountHardLockAppliesToCaller(headers, policy, options)) assertMainAccountPolicy(policy); assertCallerOwnedMainPoolNotCooled(); if (reserve) { const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy }); diff --git a/src/server/responses/main-hard-lock-routing.ts b/src/server/responses/main-hard-lock-routing.ts index e24081f5d8b..931bbc5c2e4 100644 --- a/src/server/responses/main-hard-lock-routing.ts +++ b/src/server/responses/main-hard-lock-routing.ts @@ -1,6 +1,6 @@ import type { OcxConfig } from "../../types"; import { resolveComboId } from "../../combos"; -import { isBareOpenAiFamilyModel } from "../../router"; +import { isBareOpenAiFamilyModel, routeConcreteModel } from "../../router"; import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models"; import { getMainAccountHardLockStatus, @@ -10,6 +10,29 @@ import { codexQuotaScopeForModel } from "../../codex/routing/health-store"; import { getEligiblePoolAccounts } from "../../codex/routing/selection"; import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { providerCodexAccountMode } from "../../providers/registry"; +import { mainAccountHardLockAppliesToCaller } from "../../codex/auth-context"; +import { codexRouteCredentialDomainHeaders, codexRouteCredentialOwnership } from "./core-auth"; +import type { HandleResponsesOptions } from "./core-options"; + +export type MainHardLockCallerCheck = (sourceModel: string) => Promise; + +/** Inspect the original credential domain, before fallback changes the route or drops headers. */ +export function mainHardLockFallbackCallerCheck( + req: Request, config: OcxConfig, options: HandleResponsesOptions, +): MainHardLockCallerCheck { + return async sourceModel => { + let sourceRoute: ReturnType; + try { sourceRoute = routeConcreteModel(config, sourceModel); } + // This preview cannot replace the normal routing phase's existing error response. + catch { return false; } + const headers = codexRouteCredentialDomainHeaders(req, sourceRoute, options, false, config); + const { substituteMainCredential } = codexRouteCredentialOwnership(headers, config, sourceRoute, options); + return mainAccountHardLockAppliesToCaller(headers, options.codexAuthPolicy ?? config, { + substituteMainCredentialForDirect: substituteMainCredential, + signal: options.abortSignal ?? req.signal, + }); + }; +} function isConfiguredComboSelector(config: OcxConfig, modelId: string): boolean { const comboId = resolveComboId(config, modelId); @@ -72,11 +95,12 @@ function rawModelOf(body: unknown): { raw: Record; model: strin * - Luna Reserve keeps its own admission policy (`assertReserveAdmission`); its helper * traffic is not operator chat and must not silently change models. */ -export function applyMainHardLockFallbackOverride( +export async function applyMainHardLockFallbackOverride( body: unknown, config: OcxConfig, - now = Date.now(), -): MainHardLockFallbackOverride | null { + now?: number, + callerCheck?: MainHardLockCallerCheck, +): Promise { const parsed = rawModelOf(body); if (!parsed) return null; const fallback = resolveMainAccountHardLockFallback(config); @@ -85,14 +109,18 @@ export function applyMainHardLockFallbackOverride( if (!isBareOpenAiFamilyModel(parsed.model) || parsed.model === NATIVE_RESERVE_MODEL || isConfiguredComboSelector(config, parsed.model)) return null; - if (getMainAccountHardLockStatus(config, now).state !== "blocked") return null; const accountMode = providerCodexAccountMode( OPENAI_CODEX_PROVIDER_ID, config.providers?.[OPENAI_CODEX_PROVIDER_ID], ); - if (accountMode !== "direct" - && getEligiblePoolAccounts(config, undefined, now, codexQuotaScopeForModel(parsed.model)).length > 0) { - return null; + if (accountMode === "direct") { + // Direct may forward a different caller's healthy credential. Only stored-main substitution + // or an observed-main match owes this lock. Recheck usage after the startup binding wait. + if (!callerCheck || !await callerCheck(parsed.model)) return null; + if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null; + } else { + if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null; + if (getEligiblePoolAccounts(config, undefined, now ?? Date.now(), codexQuotaScopeForModel(parsed.model)).length > 0) return null; } parsed.raw.model = fallback.model; diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index e7ebf8eedf8..f64ed70fdab 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -158,7 +158,7 @@ import { } from "./core-codex-account"; import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health"; import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing"; -import { applyMainHardLockFallbackOverride } from "./main-hard-lock-routing"; +import { applyMainHardLockFallbackOverride, mainHardLockFallbackCallerCheck } from "./main-hard-lock-routing"; import { codexAuthContextLogLabel } from "../../codex/account-label"; import { conversationStateBindingFromAuth, @@ -364,7 +364,16 @@ export async function prepareResponsesRequest( // must run before comboIdFromRawBody reads `model`. if (!concreteSelection && !options.compactionRoutingOverride && !isCompactionTrigger && !options.internalDecisionCall && !shadowCallIntercepted && !shadowCallMatches && !memoryModelApplies && !threadSpawn && inboundWire === "responses") { - options.mainHardLockFallbackOverride = applyMainHardLockFallbackOverride(body, config); + try { + options.mainHardLockFallbackOverride = await applyMainHardLockFallbackOverride( + body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options), + ); + } catch (error) { + if (options.abortSignal?.aborted || req.signal.aborted) return clientCancelledResponse(); + const mapped = mapCodexAuthContextErrorToResponse(error, { now: Date.now() }); + if (mapped) return mapped; + throw error; + } } const comboId = !concreteSelection ? comboIdFromRawBody(body, config) : null; // A decision model may be a plain model or a non-JEV combo. A JEV combo here would recurse diff --git a/structure/codex-account-controls.md b/structure/codex-account-controls.md index 6b186fd74c9..6c6523b30de 100644 --- a/structure/codex-account-controls.md +++ b/structure/codex-account-controls.md @@ -84,9 +84,14 @@ protects: - `getEligiblePoolAccounts` is consulted under the request model's quota scope whenever canonical `openai` is in Pool mode, so "the Pool can serve it" shares the dispatch's own eligibility and - scoped-cooldown rules. An empty eligible list (or Direct mode, which never consults stored Pool - accounts) is what makes the fallback the request's only way through; the lock excludes the main - account there itself. + scoped-cooldown rules. An empty eligible list permits fallback; the lock excludes the main + account from that list itself. +- Direct does not consult stored Pool accounts. Fallback applies only when the original source + route's credential ownership selects stored-main substitution, or the caller's bearer and + effective workspace match the currently observed main credential. Caller-owned checks wait + boundedly for pending startup policy binding, then recheck identity and the blocked state before + rewriting. Other callers retain their native route; a missing caller bearer keeps its normal + authentication refusal. - Only bare Codex-native selectors qualify. Provider-qualified rows, combos, account namespaces (including `nativeAlias` combos), compaction triggers, shadow-intercepted helper calls and policy routes name their own destination on purpose, and Luna Reserve keeps its own admission. A @@ -105,7 +110,8 @@ model — rather than clearing it. Settings projection and writes live beside th `src/server/management/config-routes.ts`; `null` clears the section. Coverage: `tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts` and the fallback cases in `tests/config/settings-main-account-hard-lock.test.ts` plus -`gui/tests/main-account-hard-lock-setting.test.tsx`. +`gui/tests/main-account-hard-lock-setting.test.tsx`. Startup-binding rechecks are covered by +`tests/codex-integration/main-account-policy-binding-wait.test.ts`. ## Stored-account authentication policy diff --git a/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts index 14b91b46535..c807325eb77 100644 --- a/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts +++ b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyMainHardLockFallbackOverride } from "../../src/server/responses/main-hard-lock-routing"; import { resolveMainAccountHardLockFallback } from "../../src/codex/main-account-hard-lock"; -import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; import { clearAccountQuota, setAccountQuotaFromParsed, type StoredAccountQuota } from "../../src/codex/quota"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { NATIVE_RESERVE_MODEL } from "../../src/codex/catalog/native-models"; @@ -88,64 +88,164 @@ describe("main-account hard-lock fallback resolution", () => { }); }); -describe("applyMainHardLockFallbackOverride", () => { - test("a blocked lock with no eligible account rewrites the model and the effort", () => { +describe("Direct hard-lock fallback caller identity", () => { + const mainAccountId = "fallback-account-a"; + function tokenFor(accountId: string): string { + return `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/auth": { chatgpt_account_id: accountId }, + })).toString("base64url")}.signature`; + } + const mainToken = tokenFor(mainAccountId); + const otherToken = tokenFor("fallback-account-b"); + function directConfig(): OcxConfig { + return config({ + defaultProvider: "openai", + autoSwitchThreshold: 0, + providers: { + openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex" }, + rescue: { adapter: "openai-responses", authMode: "key", apiKey: "fixture-fallback-key", + baseUrl: "https://fallback.example/v1" }, + }, + codexMainAccountHardLockFallback: { model: "rescue/rescue-model" }, + } as Partial); + } + async function dispatch(callerToken: string, accountId: string, source?: "bearer" | "dedicated" | "x-api-key") { + const seen: Array<{ url: string; auth: string | null; model: string }> = []; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")); + seen.push({ url: String(input), auth: new Headers(init?.headers).get("authorization"), model: body.model }); + return Response.json({ id: "resp_caller_fixture", status: "completed", model: body.model, + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } }); + }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${callerToken}`, "chatgpt-account-id": accountId }, + body: JSON.stringify({ model: "gpt-5.3", stream: false, input: "hello" }), + }), directConfig(), { model: "", provider: "" }, source ? { + admission: { kind: "configured", keyId: "fixture-admission", source }, + } : {}); + expect(response.status).toBe(200); + await response.text(); + expect(seen).toHaveLength(1); + return seen[0]!; + } + beforeEach(() => { + observeMainQuotaCredential(mainToken, mainAccountId); + observe({ weeklyPercent: 100 }); + }); + test("another caller stays native while the observed main is blocked", async () => { + expect(await dispatch(otherToken, "fallback-account-b")).toEqual({ + url: "https://chatgpt.com/backend-api/codex/responses", auth: `Bearer ${otherToken}`, model: "gpt-5.3", + }); + }); + test("the same workspace with an unobserved bearer stays native", async () => { + const unobservedToken = mainToken.replace(".signature", ".other-signature"); + const seen = await dispatch(unobservedToken, mainAccountId); + expect(seen.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(seen.auth).toBe(`Bearer ${unobservedToken}`); + }); + test("a caller matching observed main uses the configured fallback", async () => { + expect(await dispatch(mainToken, mainAccountId)).toEqual({ + url: "https://fallback.example/v1/responses", auth: "Bearer fixture-fallback-key", model: "rescue-model", + }); + }); + test("proxy bearer admission preserves stored-main fallback without matching the proxy secret", async () => { + const seen = await dispatch("fixture-proxy-secret", "", "bearer"); + expect(seen.url).toBe("https://fallback.example/v1/responses"); + expect(seen.auth).toBe("Bearer fixture-fallback-key"); + }); + for (const source of ["dedicated", "x-api-key"] as const) { + test(`${source} admission preserves the separate caller's native credential`, async () => { + const seen = await dispatch(otherToken, "fallback-account-b", source); + expect(seen.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(seen.auth).toBe(`Bearer ${otherToken}`); + }); + } + test("an unblocked matching main stays native", async () => { + observe({ weeklyPercent: 10 }); + const seen = await dispatch(mainToken, mainAccountId); + expect(seen.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(seen.auth).toBe(`Bearer ${mainToken}`); + }); + test("a disabled original route keeps its normal 404 with fallback configured and hard lock off", async () => { + const cfg = directConfig(); + cfg.providers.openai!.disabled = true; + cfg.codexMainAccountHardLock = false; + let sends = 0; + globalThis.fetch = (async () => { sends++; throw new Error("disabled route must not send"); }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${mainToken}`, + "chatgpt-account-id": mainAccountId }, + body: JSON.stringify({ model: "gpt-5.3", stream: false, input: "hello" }), + }), cfg, { model: "", provider: "" }); + expect(response.status).toBe(404); + const body = await response.json() as { error: { type: string } }; + expect(body.error.type).toBe("invalid_request_error"); + expect(sends).toBe(0); + }); +}); + +describe("applyMainHardLockFallbackOverride", async () => { + test("a blocked lock with no eligible account rewrites the model and the effort", async () => { observe({ weeklyPercent: 98 }); const body: Record = { model: "gpt-5.3", reasoning: { effort: "high" } }; - expect(applyMainHardLockFallbackOverride(body, config({ codexMainAccountHardLockFallback: FALLBACK } as Partial))) + expect(await applyMainHardLockFallbackOverride(body, config({ codexMainAccountHardLockFallback: FALLBACK } as Partial))) .toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" }); expect(body.model).toBe("deepseek/deepseek-chat"); expect(body.reasoning).toEqual({ effort: "low" }); }); - test("each blocking window governs on its own", () => { + test("each blocking window governs on its own", async () => { const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial); observe({ shortPercent: 90 }); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull(); observe({ shortPercent: 0 }); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); observe({ weeklyPercent: 98 }); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull(); observe({ weeklyPercent: 0 }); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); }); - test("a monthly-only account governs on monthly; supplementary monthly data never blocks", () => { + test("a monthly-only account governs on monthly; supplementary monthly data never blocks", async () => { const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial); // No short or weekly evidence at all: monthly is the account's only window. observe({ monthlyPercent: 99 }); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull(); observe({ monthlyPercent: 50 }); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); // Supplementary monthly data on a 5h/weekly account never becomes a lock. observe({ shortPercent: 10, weeklyPercent: 10, monthlyPercent: 99 }); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); }); - test("release is the same observation that admits: dropping below the threshold stops the rewrite", () => { + test("release is the same observation that admits: dropping below the threshold stops the rewrite", async () => { const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial); observe({ weeklyPercent: 99 }); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull(); observe({ weeklyPercent: 97.99 }); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); }); - test("unknown usage, the opt-out, and a missing section all keep the refusal behavior", () => { - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config({ codexMainAccountHardLockFallback: FALLBACK } as Partial))).toBeNull(); + test("unknown usage, the opt-out, and a missing section all keep the refusal behavior", async () => { + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config({ codexMainAccountHardLockFallback: FALLBACK } as Partial))).toBeNull(); observe({ weeklyPercent: 100 }); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config({ codexMainAccountHardLock: false, codexMainAccountHardLockFallback: FALLBACK } as Partial))).toBeNull(); - expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config())).toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config({ codexMainAccountHardLock: false, codexMainAccountHardLockFallback: FALLBACK } as Partial))).toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config())).toBeNull(); }); - test("an eligible pool account keeps the request on Codex", () => { + test("an eligible pool account keeps the request on Codex", async () => { observe({ weeklyPercent: 100 }); const cfg = withPoolAccount(config({ codexMainAccountHardLockFallback: FALLBACK } as Partial)); const body: Record = { model: "gpt-5.3" }; - expect(applyMainHardLockFallbackOverride(body, cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride(body, cfg)).toBeNull(); expect(body.model).toBe("gpt-5.3"); }); - test("a pool account in shared reset-derived cooldown or unused under Direct mode does not suppress the fallback", () => { + test("a pool account in shared reset-derived cooldown or unused under Direct mode does not suppress the fallback", async () => { observe({ weeklyPercent: 100 }); const cfg = withPoolAccount(config({ codexMainAccountHardLockFallback: FALLBACK } as Partial)); setScopedHealth("pool-a", "shared", { @@ -158,7 +258,7 @@ describe("applyMainHardLockFallbackOverride", () => { cooldownGeneration: 1, }); const cooledBody: Record = { model: "gpt-5.3" }; - expect(applyMainHardLockFallbackOverride(cooledBody, cfg, now)) + expect(await applyMainHardLockFallbackOverride(cooledBody, cfg, now)) .toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" }); expect(cooledBody.model).toBe("deepseek/deepseek-chat"); @@ -175,11 +275,14 @@ describe("applyMainHardLockFallbackOverride", () => { codexMainAccountHardLockFallback: FALLBACK, } as Partial)); const directBody: Record = { model: "gpt-5.3" }; - expect(applyMainHardLockFallbackOverride(directBody, directCfg, now)) + // Direct fails closed without the caller ownership proof supplied by request preparation. + expect(await applyMainHardLockFallbackOverride(directBody, directCfg, now)).toBeNull(); + expect(directBody.model).toBe("gpt-5.3"); + expect(await applyMainHardLockFallbackOverride(directBody, directCfg, now, async () => true)) .toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" }); }); - test("only bare Codex-native selectors qualify", () => { + test("only bare Codex-native selectors qualify", async () => { observe({ weeklyPercent: 100 }); const cfg = config({ codexMainAccountHardLockFallback: FALLBACK, @@ -194,40 +297,42 @@ describe("applyMainHardLockFallbackOverride", () => { } as Partial); for (const model of ["deepseek/deepseek-chat", "combo/x", "openai-codex/gpt-5.3", NATIVE_RESERVE_MODEL, "gpt-5.4"]) { const body: Record = { model }; - expect(applyMainHardLockFallbackOverride(body, cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride(body, cfg)).toBeNull(); expect(body.model).toBe(model); } }); - test("a Codex-native fallback target never applies: the refusal is the honest answer", () => { + test("a Codex-native fallback target never applies: the refusal is the honest answer", async () => { observe({ weeklyPercent: 100 }); for (const target of ["gpt-5.4", "openai/gpt-5.4"]) { const body: Record = { model: "gpt-5.3" }; - expect(applyMainHardLockFallbackOverride(body, config({ codexMainAccountHardLockFallback: { model: target } } as Partial))).toBeNull(); + expect(await applyMainHardLockFallbackOverride(body, config({ codexMainAccountHardLockFallback: { model: target } } as Partial))).toBeNull(); expect(body.model).toBe("gpt-5.3"); } }); - test("a fallback without an effort leaves the request's own reasoning untouched", () => { + test("a fallback without an effort leaves the request's own reasoning untouched", async () => { observe({ weeklyPercent: 100 }); const cfg = config({ codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat" } } as Partial); const body: Record = { model: "gpt-5.3", reasoning: { effort: "high" } }; - expect(applyMainHardLockFallbackOverride(body, cfg)).toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" }); + expect(await applyMainHardLockFallbackOverride(body, cfg)).toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" }); expect(body.model).toBe("deepseek/deepseek-chat"); expect(body.reasoning).toEqual({ effort: "high" }); }); - test("a body without a usable model selector is never touched", () => { + test("a body without a usable model selector is never touched", async () => { observe({ weeklyPercent: 100 }); const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial); - expect(applyMainHardLockFallbackOverride(null, cfg)).toBeNull(); - expect(applyMainHardLockFallbackOverride([], cfg)).toBeNull(); - expect(applyMainHardLockFallbackOverride({ model: "" }, cfg)).toBeNull(); - expect(applyMainHardLockFallbackOverride({ model: 42 }, cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride(null, cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride([], cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: "" }, cfg)).toBeNull(); + expect(await applyMainHardLockFallbackOverride({ model: 42 }, cfg)).toBeNull(); }); test("handleResponses strips caller credentials, preserves requestedModel on combo fallback, and yields to shadow/compaction turns", async () => { observe({ weeklyPercent: 100 }); + // This caller is the observed main credential that actually owes the hard lock. + observeMainQuotaCredential("caller-chatgpt-token", "fallback-account-a"); const cfg = config({ defaultProvider: "gateway", providers: { @@ -284,7 +389,7 @@ describe("applyMainHardLockFallbackOverride", () => { "content-type": "application/json", "session_id": "lane-hard-lock-fallback", "authorization": "Bearer caller-chatgpt-token", - "chatgpt-account-id": "acct-caller", + "chatgpt-account-id": "fallback-account-a", }); const lane = sessionLaneIdFromRequest(headers); rememberComboForLane(lane, "prior", { provider: "gateway", model: "prior-model" }, "prior-model", captureConfigGeneration()); diff --git a/tests/codex-integration/main-account-policy-binding-wait.test.ts b/tests/codex-integration/main-account-policy-binding-wait.test.ts index 3e5c0d61fa5..67d8c7a8f89 100644 --- a/tests/codex-integration/main-account-policy-binding-wait.test.ts +++ b/tests/codex-integration/main-account-policy-binding-wait.test.ts @@ -22,6 +22,8 @@ import { clearAccountQuota, setAccountQuotaFromParsed } from "../../src/codex/qu import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { handleResponses } from "../../src/server/responses"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; /** * The admission fence during an owned startup's main-policy binding (#5694 made it default-on). @@ -44,6 +46,7 @@ const previousHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; let releases: Array<() => Promise> = []; let openGates: Array<() => void> = []; +const originalFetch = globalThis.fetch; function bearerFor(id: string): string { const payload = Buffer.from(JSON.stringify({ @@ -162,6 +165,7 @@ beforeEach(() => { }); afterEach(async () => { + globalThis.fetch = originalFetch; // A case may end with the hold still up (the deadline case has to). Open every gate first: // `release` below awaits convergence, which cannot finish behind a closed barrier. for (const open of openGates.splice(0)) open(); @@ -178,6 +182,93 @@ afterEach(async () => { }); describe("a caller-owned request waits out the startup policy binding", () => { + for (const source of ["loopback", "dedicated"] as const) { + test(`handleResponses missing caller bearer keeps immediate 401 during binding under ${source} admission`, async () => { + const startup = await holdStartup(); + const cfg = directConfig(); + cfg.codexMainAccountHardLockFallback = { model: "rescue/rescue-model" }; + cfg.providers.rescue = { adapter: "openai-responses", authMode: "key", apiKey: "fixture-rescue", + baseUrl: "https://fallback.example/v1" }; + let sends = 0; + globalThis.fetch = (async () => { sends++; throw new Error("missing caller must not send"); }) as typeof fetch; + const request = handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.3", stream: false, input: "hello" }), + }), cfg, { model: "", provider: "" }, { + admission: source === "loopback" ? { kind: "loopback", source } + : { kind: "configured", keyId: "fixture-admission", source }, + }); + const settled = trackSettlement(request); + const outcome = request.then(response => ({ response }), error => ({ error })); + try { + await Bun.sleep(25); + expect(startup.pending()).toBe(true); + expect(settled()).toBe(true); + const result = await outcome; + if (!("response" in result)) throw result.error; + expect(result.response.status).toBe(401); + await result.response.text(); + expect(sends).toBe(0); + } finally { + startup.release(); + const result = await outcome; + if ("response" in result && !result.response.bodyUsed && !result.response.body?.locked) { + await result.response.body?.cancel(); + } + } + }); + } + + for (const [initialUsage, releaseBlocked] of [[100, true], [100, false], [undefined, true]] as const) { + test(`handleResponses rechecks fallback after binding settles from ${initialUsage ?? "unknown"} with blocked=${releaseBlocked}`, async () => { + const startup = await holdStartup(); + observeMainQuotaIdentity(accountId); + observeMainQuotaCredential(token, accountId); + const writer = captureMainQuotaWriter(accountId)!; + if (initialUsage !== undefined) setAccountQuotaFromParsed(MAIN, { weeklyPercent: initialUsage }, undefined, writer); + const cfg = directConfig(); + cfg.providers.rescue = { adapter: "openai-responses", authMode: "key", apiKey: "fixture-rescue", + baseUrl: "https://fallback.example/v1" }; + cfg.codexMainAccountHardLockFallback = { model: "rescue/rescue-model" }; + const seen: string[] = []; + const releaseSpend = acquireOwnedSpendHome(); + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + seen.push(String(input)); + const body = JSON.parse(String(init?.body ?? "{}")); + return Response.json({ id: "resp_binding_fixture", status: "completed", model: body.model, + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } }); + }) as typeof fetch; + const request = handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: callerHeaders(), + body: JSON.stringify({ model: "gpt-5.3", stream: false, input: "hello" }), + }), cfg, { model: "", provider: "" }); + // Observe rejection too, so a failed assertion never leaves an unhandled request. + const outcome = request.then(response => ({ response }), error => ({ error })); + try { + await Bun.sleep(25); + expect(startup.pending()).toBe(true); + expect(seen).toEqual([]); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: releaseBlocked ? 100 : 10 }, undefined, writer); + startup.release(); + const result = await outcome; + if (!("response" in result)) throw result.error; + expect(result.response.status).toBe(200); + await result.response.text(); + expect(seen).toEqual([releaseBlocked + ? "https://fallback.example/v1/responses" : "https://chatgpt.com/backend-api/codex/responses"]); + } finally { + startup.release(); + try { + const result = await outcome; + if ("response" in result && !result.response.bodyUsed && !result.response.body?.locked) { + await result.response.body?.cancel(); + } + } finally { releaseSpend(); } + } + }); + } + test("default config waits for convergence and then resolves an auth context", async () => { const startup = await holdStartup(); expect(startup.pending()).toBe(true); From 901fdde8c6a309f2ce45fbf1dd607084febb1669 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 18 Oct 2026 10:31:57 -0700 Subject: [PATCH 2/2] fix(responses): break hard-lock caller-check import cycle Move the caller-check factory into core-auth, which owns the credential-domain helpers, and update its request-preparation import. Keep the function body and routing/authentication policy unchanged. --- src/server/responses/core-auth.ts | 20 ++++++++++++++++ .../responses/main-hard-lock-routing.ts | 24 +------------------ src/server/responses/request-prepare.ts | 3 ++- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/server/responses/core-auth.ts b/src/server/responses/core-auth.ts index 4734debe617..ea44d955a22 100644 --- a/src/server/responses/core-auth.ts +++ b/src/server/responses/core-auth.ts @@ -2,6 +2,7 @@ import type { OcxProviderConfig, OcxConfig } from "../../types"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import type { CodexAuthContext } from "../../codex/auth-context"; import type { RouteResult } from "../../router"; +import { routeConcreteModel } from "../../router"; import type { HandleResponsesOptions } from "./core-options"; import { hasForwardableCodexBearer, @@ -24,6 +25,7 @@ import { CodexAuthContextError, applyCodexAuthContextToProvider, stripCodexRuntimeProviderFields, + mainAccountHardLockAppliesToCaller, } from "../../codex/auth-context"; import { codexAccountSelectionForTurn, tryClaimNativeMainProfileForTurn } from "../lifecycle"; import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup"; @@ -590,3 +592,21 @@ export async function refreshNativeMainForwardAuth(args: { }) ?? nativeMainRefreshFailureResponse(error) }; } } + +/** Inspect the original credential domain, before fallback changes the route or drops headers. */ +export function mainHardLockFallbackCallerCheck( + req: Request, config: OcxConfig, options: HandleResponsesOptions, +): (sourceModel: string) => Promise { + return async sourceModel => { + let sourceRoute: ReturnType; + try { sourceRoute = routeConcreteModel(config, sourceModel); } + // This preview cannot replace the normal routing phase's existing error response. + catch { return false; } + const headers = codexRouteCredentialDomainHeaders(req, sourceRoute, options, false, config); + const { substituteMainCredential } = codexRouteCredentialOwnership(headers, config, sourceRoute, options); + return mainAccountHardLockAppliesToCaller(headers, options.codexAuthPolicy ?? config, { + substituteMainCredentialForDirect: substituteMainCredential, + signal: options.abortSignal ?? req.signal, + }); + }; +} diff --git a/src/server/responses/main-hard-lock-routing.ts b/src/server/responses/main-hard-lock-routing.ts index 931bbc5c2e4..9ff94a98b69 100644 --- a/src/server/responses/main-hard-lock-routing.ts +++ b/src/server/responses/main-hard-lock-routing.ts @@ -1,6 +1,6 @@ import type { OcxConfig } from "../../types"; import { resolveComboId } from "../../combos"; -import { isBareOpenAiFamilyModel, routeConcreteModel } from "../../router"; +import { isBareOpenAiFamilyModel } from "../../router"; import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models"; import { getMainAccountHardLockStatus, @@ -10,30 +10,8 @@ import { codexQuotaScopeForModel } from "../../codex/routing/health-store"; import { getEligiblePoolAccounts } from "../../codex/routing/selection"; import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { providerCodexAccountMode } from "../../providers/registry"; -import { mainAccountHardLockAppliesToCaller } from "../../codex/auth-context"; -import { codexRouteCredentialDomainHeaders, codexRouteCredentialOwnership } from "./core-auth"; -import type { HandleResponsesOptions } from "./core-options"; - export type MainHardLockCallerCheck = (sourceModel: string) => Promise; -/** Inspect the original credential domain, before fallback changes the route or drops headers. */ -export function mainHardLockFallbackCallerCheck( - req: Request, config: OcxConfig, options: HandleResponsesOptions, -): MainHardLockCallerCheck { - return async sourceModel => { - let sourceRoute: ReturnType; - try { sourceRoute = routeConcreteModel(config, sourceModel); } - // This preview cannot replace the normal routing phase's existing error response. - catch { return false; } - const headers = codexRouteCredentialDomainHeaders(req, sourceRoute, options, false, config); - const { substituteMainCredential } = codexRouteCredentialOwnership(headers, config, sourceRoute, options); - return mainAccountHardLockAppliesToCaller(headers, options.codexAuthPolicy ?? config, { - substituteMainCredentialForDirect: substituteMainCredential, - signal: options.abortSignal ?? req.signal, - }); - }; -} - function isConfiguredComboSelector(config: OcxConfig, modelId: string): boolean { const comboId = resolveComboId(config, modelId); return Boolean(comboId && Object.hasOwn(config.combos ?? {}, comboId)); diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index f64ed70fdab..6b33dd11f41 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -100,6 +100,7 @@ import type { import { codexRouteCredentialDomainHeaders, codexRouteCredentialOwnership, + mainHardLockFallbackCallerCheck, resolveResponsesCodexAuth, withClaudeNativeSession, } from "./core-auth"; @@ -158,7 +159,7 @@ import { } from "./core-codex-account"; import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health"; import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing"; -import { applyMainHardLockFallbackOverride, mainHardLockFallbackCallerCheck } from "./main-hard-lock-routing"; +import { applyMainHardLockFallbackOverride } from "./main-hard-lock-routing"; import { codexAuthContextLogLabel } from "../../codex/account-label"; import { conversationStateBindingFromAuth,