import type { OcxRuntimeProviderConfig } from "./auth-context-provider"; export type { OcxRuntimeProviderConfig } from "./auth-context-provider"; export { stripCodexRuntimeProviderFields } from "./auth-context-provider"; import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt, poolCreditRecoveryNeeded, poolContextCreditRecoveryNeeded, poolCreditRecoveryRetryAt } from "./pool-credit-policy"; import { hasSpendableCodexCredits } from "./quota-types"; import { nextQuotaQueryDelay } from "./quota-query-backoff"; import { noteMainAccountActivity } from "./main-account-external-usage"; import { codexAccountPriorityFailbackEnabled } from "./account-priority"; import type { PoolQuotaWriter } from "./quota-types"; import { createHash, timingSafeEqual } from "node:crypto"; import { CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, getCodexAccountCredential, getValidCodexToken, capturePoolQuotaWriter, isCodexAccountGenerationLive, readCodexAccountRecord, } from "./account-store"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { ConfigMutationLockError } from "../config"; import { NativeProfileError } from "./native-profile-types"; import { codexAccountUnusableReason, isCodexAccountUsable } from "./account-usability"; import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { MAIN_CODEX_ACCOUNT_ID, MainAccountTokenRefreshError, MainAuthJsonChangedDuringRefreshError, getMainAccountToken, getMainAccountCredentialStatus, getValidMainAccountToken, mainAccountTokenQuotaWriter, isMainAccountTokenLive, type NativeMainRefreshDependencies, } from "./main-account"; import { isMainAccountPolicyBindingPending, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup"; import type { NativeMainStartupBlockReason } from "./native-profile-startup"; import { waitForMainAccountPolicyBinding } from "./main-account-policy-wait"; import { codexQuotaScopeForModel, computeCodexUsageScore, getCodexQuotaHealthSnapshot, isEffectiveCodexAccountPinned, releaseCodexQuotaProbeLease, releaseCodexQuotaScopeProbeLease, tryAcquireCodexQuotaProbeLease, tryAcquireCodexQuotaScopeProbeLease, pickAlternateCodexAccount, resolveCodexAccountForThreadDetailed, type CodexAffinityDecision, type CodexThreadResolution, type TransientProbeGrant, } from "./routing"; // The half-open TRANSIENT-HOLD lease (#4701). Not the quota-cooldown probe lease imported from // ./routing above -- different module, different domain, and a request never holds both. import { releaseTransientProbe } from "../routing/probe-lease"; import { trackBackgroundWork } from "../lib/background-work"; import { codexConversationIdentity, recordCodexThreadLineage, resolveCodexThreadLineage, type CodexThreadLineage, } from "./lineage"; import { cachedDeniedCodexAccountIdsForModel, entitledCodexAccountIdsForModel, isDirectCallerEntitledToCodexModel, resolveCodexModelEntitlements, } from "./model-entitlements"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./catalog/native-models"; import type { CodexCooldownSource, CodexQuotaScope } from "./routing"; import { maskAccountId } from "../lib/privacy"; import { formatErrorResponse, formatUsageLimitReachedResponse } from "../bridge"; import { nextCodexPoolRecoveryAt } from "./routing/pool-recovery"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, getMainPolicyQuota, isValidWhamHistoryObservation, parseUsageQuota, parseMainPolicyUsageQuota, setAccountQuotaFromParsed } from "./quota"; import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt } from "./account-credit-use"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { isolateCodexPoolAccountHeaders } from "./forward-transport-headers"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; import { extractAccountId, extractEmail } from "../oauth/chatgpt"; import { resolveMainAccountHardLockThresholds, getMainAccountHardLockStatus, isMainAccountHardLockEnabled, isMainAccountHardLocked, } from "./main-account-hard-lock"; import { captureMainAccountIdentityGeneration, captureMainQuotaWriter, captureMainQuotaDispatch, getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, type MainQuotaWriter, type MainQuotaDispatch, } from "./main-account-cache"; import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported, isCodexReserveRequestEligible } from "./loopback-target"; import type { DataPlaneAdmission } from "../server/auth-cors"; import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability"; import { UpstreamRetryEvidenceError } from "../lib/upstream-retry"; import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch"; import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate } from "./collision-identity"; import { AgentAssertionUnsupportedError, authorizationCredential, hasAgentAssertionAuthorization } from "../lib/authorization-scheme"; /** * A request-owned bearer cannot inspect the physical main credential for its plan, but cached * WHAM usage is still valid routing evidence for the same logical main account. Score it with * the conservative unknown-plan rule: an unobserved governing window preserves the pin, while * any known weekly/monthly/short value at the threshold releases it through the ordinary Pool * path. This keeps the keyring boundary intact instead of reading auth.json just to classify a * request that already brought its own credential (#3157). */ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean { const threshold = getEffectiveCodexAutoSwitchThreshold(config, MAIN_CODEX_ACCOUNT_ID); if (threshold <= 0) return true; const usage = computeCodexUsageScore(getAccountQuota(MAIN_CODEX_ACCOUNT_ID), undefined, Date.now(), codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)); return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold; } /** * Whether a request carrying its OWN main credential still serves on main because the operator * manually pinned it (#3166), split from the surrounding resolution so request preview can ask * the identical question (#4850). * * Exported for exactly one reason: two copies of this fence is how #4850 happened. Final * authentication honoured the ownership boundary while request preview, computing its fence from * recovery and drain state alone, still handed pool eligibility the default liveness probe and * opened the physical `auth.json` twice per spawn. A predicate one caller can forget is a * predicate the other caller will eventually disagree with. * * Read-free by construction, which is what makes it usable on the fenced side. Every input is * config, policy, or in-memory runtime state: the pin fields, the paused list, the cached quota * score, the main policy quota the hard lock and the credits switch read, and `callerMatchesObservedMain`, which compares HMAC digests against the observed * credential record in `main-account-cache.ts`. Nothing here opens a file. * * `candidate` is the pin before the hard-lock question, because the caller still owes the * pending-binding check that only final authentication can fail closed on. */ export function requestOwnedMainPinState( headers: Headers, config: OcxConfig, policy: CodexAuthPolicyConfig, requestScopedMainCredential: boolean, fixedAccountId: string | undefined, quotaScope?: CodexQuotaScope, ): { candidate: boolean; preserve: boolean } { const candidate = requestScopedMainCredential && fixedAccountId === undefined && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID && isEffectiveCodexAccountPinned(config) && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) && requestOwnedMainPinHasQuotaHeadroom(config); return { candidate, preserve: candidate && !(callerMatchesObservedMain(headers) && (isMainAccountHardLocked(policy) || mainCreditsHoldResetAt(policy) !== undefined || getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope)?.cooldownUntil)), }; } /** * One conversation tree keys as ONE cohort (#4780). * * Upstream keys its prompt cache on something the whole tree shares -- `prompt_cache_key` is the * session id, or `{source}:{parent_thread_id}` for an internal session -- and one `AgentControl` * whose `session_id` is the root thread's id is shared with every sub-agent. While the proxy * keyed per thread, two requests could carry an identical `prompt_cache_key` and be served by * different accounts, so the split member asserted a warm prefix that was deterministically cold * on its account. Nothing failed; the prompt was replayed in full and the tokens burned. * * THIS IS NOT A REVERT OF #4546 wp8. wp8 fixed a different defect -- a child bound under the RAW * parent id, an identity unrelated to the root's own binding, so siblings shared an entry the * root was not on and a grandchild landed on a key nobody had bound. A cohort key has no such * incoherence, because the root's own binding IS the cohort key. What wp8 additionally gave each * thread, a binding of its own, is what this deliberately gives up. * * Which requests produce no key at all is unchanged, through both units. * * The derivation itself lives in ./lineage so a lineage record's conversation key and the key * the thread actually binds under can never drift apart. */ export function codexPoolAffinityKey(headers: Headers, now = Date.now()): string | undefined { // `now` is threaded rather than read inside because a parent-only turn resolves its key // through the recorded lineage, and that record is TTL-bounded: a caller working against a // fixed clock would otherwise see a live record as expired and fall back to a key the parent // never bound under. return codexConversationIdentity(headers, now)?.conversationKey; } /** What a caller needs to know to answer the Pool-state question below before auth has run. */ export interface CodexPoolStateEligibility { /** An exact account selector from the route, i.e. `options.accountId` here. */ readonly accountId?: string; readonly modelId?: string; readonly admission?: Pick; /** The caller presented its own forwardable ChatGPT credential for this route. */ readonly requestScopedMainCredential?: boolean; } /** The one expression both the resolution below and any preview must agree on. */ function poolStateEligible( fixedAccountId: string | undefined, requestScopedMainCredential: boolean, ): boolean { return fixedAccountId === undefined && !requestScopedMainCredential; } /** * Does main have a live credential for a request that carries main's own bearer? * * The one expression final authentication and every preview must agree on, for the same reason * `poolStateEligible` is: preview exists to predict the resolution, and a preview that scores main * differently hands subagent fallback a different account than the one that serves (#4850). * * A forwardable request-owned bearer IS main's live credential -- it is exactly what would be * sent if selection named main -- so the honest answer is yes, without reading the stored * credential. An effective manual pin answers yes as it always did. The answer is no while the * physical identity is fenced for a different reason, because retained recovery and a profile * drain must keep main out of routing even for a request holding its own bearer. */ export function requestOwnedMainCredentialIsLive(inputs: { preserveRequestOwnedMainPin: boolean; requestScopedMainCredential: boolean; nativeMainTrafficBlocked: boolean; mainProfileDraining: boolean; /** * The caller's own credential is one of the Pool subscriptions currently in cooldown. * * Final authentication only. Cooldown identity is not modelled by preview and never was -- * `callerIsCooledPoolAccount` has no other caller -- because it decides a refusal rather than * which account serves, so a preview that scores main while the resolution refuses still hands * subagent fallback the right account. Passed explicitly at both preview sites so the asymmetry * is stated rather than inherited from a default. */ callerOwnsCooledPoolSubscription: boolean; }): boolean { return inputs.preserveRequestOwnedMainPin || (inputs.requestScopedMainCredential && !inputs.nativeMainTrafficBlocked && !inputs.mainProfileDraining && !inputs.callerOwnsCooledPoolSubscription); } /** * May this request own Pool affinity state at all? * * Two credentials authenticate outside the Pool: an exact account selector (including the * Reserve pin) and a request-owned main bearer, which exists for one request and must never * fold into durable account state. Neither may read or write a binding, so neither may read * or write LINEAGE either. * * Exported so that a preview asks the question with the code that answers it, instead of a * restatement that can drift. It drifted once already: preview read a family relation from raw * request headers before this function had decided anything, so it could follow a Pool family * binding while the resolution below deliberately created no affinity -- and model fallback then * evaluated eligibility against an account the request would never be authenticated as. */ export function codexPoolStateEligible( headers: Headers, policy: CodexAuthPolicyConfig | undefined, options: CodexPoolStateEligibility = {}, ): boolean { const reserve = requiresReserveAuthorization(policy, options.modelId, options.admission); return poolStateEligible( reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId, options.requestScopedMainCredential === true && hasCallerCodexBearer(headers), ); } /** * The lineage a PREVIEW is allowed to see: read-only, and only for a request that may hold Pool * state. Recording is left to the resolution that actually binds, so a preview can never leave a * record behind for a request that turned out to own no Pool state at all. */ export function previewCodexPoolLineage( headers: Headers, policy: CodexAuthPolicyConfig | undefined, options: CodexPoolStateEligibility = {}, ): CodexThreadLineage | undefined { return codexPoolStateEligible(headers, policy, options) ? resolveCodexThreadLineage(headers) : undefined; } export type CodexAuthContext = | { kind: "main"; accountId: null; reserveAuthorization?: MainReserveAuthorization; /** Captured for the observed main credential actually selected for upstream. */ mainQuotaDispatch?: MainQuotaDispatch; } | { kind: "pool"; accountId: string; writerGeneration: number; generation: number; poolQuotaWriter?: PoolQuotaWriter; accessToken: string; chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ fixedAccount?: boolean; /** Pool binding key; the Desktop fallback is an opaque process-local HMAC. */ affinityKey?: string; /** * Set when this request was admitted through an active quota cooldown as * the account's single probe. Must be echoed into the upstream outcome so * only this request can clear the cooldown (#433). */ probeLeaseId?: string; /** Native model quota group selected for this request, when known. */ quotaScope?: CodexQuotaScope; /** What happened to this thread's binding on this request (#4546). */ affinityDecision?: CodexAffinityDecision; /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */ probeQuotaScope?: CodexQuotaScope; /** * Set when this request is the ONE dispatch admitted to test an account held under a * transient 5xx hold (#4701). Echo it into the upstream outcome so the trial is settled * by the request that ran it, and release it on any path that never reaches upstream. */ transientProbe?: TransientProbeGrant; } | { // Main account rotating in the Pool (Option A), token from ~/.codex/auth.json; never request-owned "main". kind: "main-pool"; accountId: string; writerGeneration: number; /** Captured before async credential work; never reconstructed after the upstream response. */ mainQuotaWriter?: MainQuotaWriter; /** Immutable credential proof captured while materializing this physical attempt's headers. */ mainQuotaDispatch?: MainQuotaDispatch; reserveAuthorization?: MainReserveAuthorization; accessToken: string; chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ fixedAccount?: boolean; affinityKey?: string; // See `pool.affinityKey`. /** See `pool.probeLeaseId`. */ probeLeaseId?: string; quotaScope?: CodexQuotaScope; probeQuotaScope?: CodexQuotaScope; /** See `pool.transientProbe`. */ transientProbe?: TransientProbeGrant; affinityDecision?: CodexAffinityDecision; }; /** Probe lease carried by this context, when it holds one. */ export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | undefined { return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeLeaseId : undefined; } /** Scope of a lease carried by this context, when it probes a model-specific quota. */ export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQuotaScope | undefined { return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined; } /** The transient-hold recovery probe carried by this context, when it holds one (#4701). */ export function codexTransientProbeGrant(ctx: CodexAuthContext | undefined): TransientProbeGrant | undefined { return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.transientProbe : undefined; } /** * Hand back a probe lease for a request that will not reach upstream. Safe to call with a context that holds no lease. * * BOTH leases, deliberately. A context can carry the quota-cooldown probe or the transient-hold * probe, and every one of the ~30 call sites that already hands back the first is a path where * the second would leak too. Releasing them together is what makes those sites correct for the * new lease without re-deriving the discard set by hand -- the failure mode being avoided is a * held account nobody may probe because the request that held the trial went away quietly. */ export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void { const transientProbe = codexTransientProbeGrant(ctx); if (transientProbe) releaseTransientProbe(transientProbe.lease); const leaseId = codexProbeLeaseId(ctx); if (!ctx || ctx.kind === "main" || !leaseId) return; if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId); else releaseCodexQuotaProbeLease(ctx.accountId!, leaseId); } export class CodexAuthContextError extends Error { accountId: string; constructor(accountId: string, cause: unknown) { super("Codex pool account auth failed", { cause }); this.name = "CodexAuthContextError"; this.accountId = accountId; } } export class CodexPoolAuthenticationError extends Error { readonly quarantinedMain: boolean; constructor(message = "OpenAI account pool has no usable account credential", options?: { quarantinedMain?: boolean }) { super(message); this.name = "CodexPoolAuthenticationError"; this.quarantinedMain = options?.quarantinedMain === true; } } export type CodexModelAvailabilityReason = "unsupported" | "temporarily_unavailable"; /** A model/account compatibility failure is not a credential failure. */ export class CodexModelAvailabilityError extends CodexPoolAuthenticationError { reason: CodexModelAvailabilityReason; constructor(reason: CodexModelAvailabilityReason, message: string) { super(message); this.name = "CodexModelAvailabilityError"; this.reason = reason; } } class CodexAccountValidationPendingError extends CodexPoolAuthenticationError { constructor() { super("Codex account validation is pending; refresh quota after recovery to validate it"); this.name = "CodexAccountValidationPendingError"; } } function assertCodexAccountValidationReady(accountId: string): void { if (accountId !== MAIN_CODEX_ACCOUNT_ID && readCodexAccountRecord(accountId)?.codexValidationPending) { throw new CodexAccountValidationPendingError(); } } export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE = "OpenCodex local native-main profile maintenance is active; retry this request"; export class CodexMainProfileDrainingError extends Error { /** * Which startup-gate state fenced this request, when one did. Undefined means the * fence came from somewhere other than the startup gate — the turn-drain claim race * throws this same error while the gate reads `ready`, and inventing a reason there * would point the next report at a gate that never closed. * * Captured here rather than at the throw sites because this is the last moment it is * both in scope and still true: every catch site has already lost it, and re-reading * the gate later can observe a recovery that completed in between (#2108). */ readonly reason?: NativeMainStartupBlockReason; constructor() { super(CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE); this.name = "CodexMainProfileDrainingError"; const gate = nativeMainStartupGateSnapshot(); if (gate.status !== "blocked") return; this.reason = gate.reason; reportNativeMainFenceReason(gate.reason); } } /** * #2108: a reboot could leave this fence closed until `ocx restart`, and the report was * unactionable because the settled reason was never written anywhere. It cannot ride the * message (claude-messages.ts matches that string exactly to keep the fence a 503 rather * than an Anthropic 529) and it cannot ride a header (/api/logs reads only error.message * from the body, and the Claude surface rebuilds its response headers from scratch), so * stdout is the one surface that covers every path this fence fires on. * * Deduped per distinct reason: the original report shows three 503s in eleven seconds and * a real client retries harder than that, so a per-request line would bury the signal. * Only the reason is emitted; the snapshot's homeId is derived from a profile directory. */ const reportedFenceReasons = new Set(); function reportNativeMainFenceReason(reason: NativeMainStartupBlockReason): void { if (reportedFenceReasons.has(reason)) return; reportedFenceReasons.add(reason); console.warn( `native-main admission is fenced (reason: ${reason}); native model requests return 503 until it clears`, ); } /** * Test-only reset for the dedup set above. * * The dedup is process-lifetime module state, so it is order-sensitive across test files * sharing one Bun process: whichever file constructs this error first consumes the one-shot * warn, and a later file asserting on it would see nothing and pass vacuously. Any test that * asserts on the warn must call this first — an `afterEach` in the asserting file is not * enough on its own, because the consuming file may not be the asserting one. */ export function __resetNativeMainFenceReasonLog(): void { reportedFenceReasons.clear(); } export function codexMainProfileDrainingResponse(): Response { const response = formatErrorResponse(503, "server_busy", CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE); const headers = new Headers(response.headers); headers.set("Retry-After", "1"); return new Response(response.body, { status: response.status, headers }); } export class CodexDirectAuthenticationError extends Error { constructor() { super("Codex Direct requires a caller Authorization bearer token"); this.name = "CodexDirectAuthenticationError"; } } export function hasCallerCodexBearer(headers: Headers): boolean { return /^Bearer\s+\S+/i.test(headers.get("authorization")?.trim() ?? ""); } export class CodexAccountCooldownError extends Error { accountId: string; cooldownUntil: number; cooldownSource?: CodexCooldownSource; quotaScope?: CodexQuotaScope; constructor( accountId: string, cooldownUntil: number, cooldownSource?: CodexCooldownSource, quotaScope?: CodexQuotaScope, ) { super("Selected Codex account is cooling down"); this.name = "CodexAccountCooldownError"; this.accountId = accountId; this.cooldownUntil = cooldownUntil; this.cooldownSource = cooldownSource; this.quotaScope = quotaScope; } } export class CodexMainAccountHardLockError extends CodexAccountCooldownError { readonly resetAt?: number; /** * Earliest known recovery of another stored pool account, set only when an unpinned pool * selection reached main (#7046). A hint for the client's retry time; never routing input. */ poolRecoveryAt?: number; constructor(resetAt?: number, thresholds = resolveMainAccountHardLockThresholds(undefined)) { super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0); this.name = "CodexMainAccountHardLockError"; this.resetAt = resetAt; this.message = `Codex main account is blocked by the main-account quota policy (5h ≥ ${thresholds.short}%, weekly ≥ ${thresholds.long}%).` + " Choose another account, wait for quota to reset, or disable codexMainAccountHardLock in Settings."; } } /** A stored pool account's spending policy is independent of account selection. */ export class CodexPoolAccountCreditsOffError extends CodexAccountCooldownError { readonly resetAt: number; constructor(accountId: string, resetAt: number, creditRecovery = false, quotaScope?: CodexQuotaScope) { // Keep the included reset; a metadata hint neither admits nor clears an upstream hold. super(accountId, creditRecovery ? poolCreditRecoveryRetryAt(accountId, resetAt, quotaScope) : resetAt); this.name = "CodexPoolAccountCreditsOffError"; this.resetAt = resetAt; this.message = creditRecovery ? "The selected Codex account reached its usage limit and needs a fresh credit observation. Retry later; spending remains blocked until fresh spendable credits are observed." : "The selected Codex account reached its usage limit, and spending credits is off or no fresh spendable balance is available." + " Wait for the limit to reset, choose another account, or explicitly allow credits for this account in Codex Auth."; } } function assertPoolAccountCredits(accountId: string, resetAt: number | undefined, creditRecovery: boolean, quotaScope?: CodexQuotaScope): void { if (resetAt !== undefined) throw new CodexPoolAccountCreditsOffError(accountId, resetAt, creditRecovery, quotaScope); } /** The main login may not spend credits and one of its usage windows is full (#6334). */ export class CodexMainAccountCreditsOffError extends CodexAccountCooldownError { readonly resetAt?: number; constructor(resetAt?: number, creditRecovery = false) { // Keep the included reset as policy evidence, but let consented callers retry within the base metadata recovery interval even when backoff grows longer. super(MAIN_CODEX_ACCOUNT_ID, creditRecovery ? Math.min(resetAt ?? Infinity, Date.now() + nextQuotaQueryDelay()) : resetAt ?? 0); this.name = "CodexMainAccountCreditsOffError"; this.resetAt = resetAt; this.message = "Codex main account reached its usage limit, and spending ChatGPT credits is off or no fresh spendable balance is available." + " Choose another account, wait for the limit to reset, or allow the main account under \"Use credits\" in Codex Auth."; } } export class CodexReserveUnavailableError extends CodexAccountCooldownError { constructor() { super(MAIN_CODEX_ACCOUNT_ID, 0); this.name = "CodexReserveUnavailableError"; this.message = "Codex Reserve is unavailable for this main credential." + " Use the stored main login or its matching caller credential, and retry when OpenAI grants Reserve access." + " Reserve compatibility requires the effective local Desktop authless opt-in; it cannot switch accounts automatically."; } } /** A local unsupported-helper refusal; retain Reserve policy error mapping on delayed sends. */ export class CodexReserveHelperUnsupportedError extends CodexReserveUnavailableError { constructor() { super(); this.name = "CodexReserveHelperUnsupportedError"; this.message = CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE; } } /** * Every account bound to this conversation is held after upstream failures, the recovery * budget for this window is spent, and there is no detour left -- so this request is refused * BEFORE any upstream I/O (#4701). * * This is not a quota cooldown, and the message below says so. It subclasses * {@link CodexAccountCooldownError} for one reason: the deadline-carrying refusal has exactly * one representation in this codebase, and roughly a dozen transports already map it to a 429 * with `Retry-After` and treat it as an expected terminal answer rather than a credential * fault. Introducing a parallel type would mean either re-deriving that handling in every one * of them or silently falling through to a 500 in the ones that were missed. * * What must NOT be inherited is the quota wording -- "cooling down", `ocx account * clear-cooldown` -- because none of it describes a 5xx hold and following it would do * nothing. {@link cooldownErrorMessage} therefore returns this class's own message verbatim, * the same escape hatch {@link CodexMainAccountHardLockError} and * {@link CodexReserveUnavailableError} already use. * * `cooldownUntil` carries the limiter's own change point, which is strictly in the future: * either the moment the held account may next be probed or the moment the recovery window * moves, whichever is later. A refusal that answered `now` would busy-loop the caller into * the same load it just declined. */ export class CodexRecoveryWithheldError extends CodexAccountCooldownError { /** The sibling still remembered for this thread, when one exists but is itself unusable. */ readonly detourAccountId?: string; constructor(accountId: string, retryAt: number, detourAccountId?: string) { super(accountId, retryAt); this.name = "CodexRecoveryWithheldError"; this.detourAccountId = detourAccountId; this.message = `Codex account (${cooldownAccountLabel(accountId)}) is held after repeated upstream` + ` failures and the pool's recovery budget for this window is spent, so nothing was sent` + ` upstream. Retry after ${new Date(retryAt).toISOString()}.` + " This clears on its own as the account recovers; no cooldown to lift and no account to switch."; } } export type CodexAuthPolicyConfig = Readonly>; interface CodexAuthMaterializationOptions { substituteMainCredential?: boolean; config?: CodexAuthPolicyConfig; modelId?: string; /** Trusted receiving-listener admission; never inferred from request headers or config. */ admission?: Pick; signal?: AbortSignal; nativeMainRefreshDependencies?: NativeMainRefreshDependencies; beginCodexAccountSelection?: () => CodexAccountSelectionAdmission | undefined; } function requiresReserveAuthorization( config: CodexAuthPolicyConfig | undefined, modelId: string | undefined, admission: Pick | undefined, ): boolean { return modelId === NATIVE_RESERVE_MODEL && !!config && isCodexReserveRequestEligible(config, admission); } function assertReserveAdmission(config: CodexAuthPolicyConfig): void { if (config.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) { throw new CodexReserveUnavailableError(); } assertMainAccountPolicy(config); const cooldown = getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "reserve"); if (cooldown?.cooldownUntil) { throw new CodexAccountCooldownError(MAIN_CODEX_ACCOUNT_ID, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope); } } /** Authorize a reserve request for the main credential, re-checking admission and writer liveness, and publish the ordinary quota observed during the capability read. */ async function authorizeReserveCredential( token: { accessToken: string; chatgptAccountId: string }, writer: MainQuotaWriter | undefined, config: CodexAuthPolicyConfig, signal?: AbortSignal, existing?: MainReserveAuthorization, writerGeneration = captureConfigGeneration(), ): Promise { assertReserveAdmission(config); if (!writer || !isMainQuotaWriterLive(writer) || !matchesMainQuotaCredential(token.accessToken, token.chatgptAccountId)) { throw new CodexReserveUnavailableError(); } const authorization = isMainReserveAuthorizationLive(existing, token) ? existing : await getMainReserveAuthorization({ token, writer, signal, /** Publish the ordinary usage observed during the reserve capability read: parse it once and reuse that observation as the advisory decision evidence when it is a valid history observation. */ observeOrdinaryQuota(data, capturedWriter) { const parsed = parseUsageQuota(data); setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, parsed, writerGeneration, capturedWriter, parseMainPolicyUsageQuota(data), undefined, false, false, isValidWhamHistoryObservation(data) ? parsed : null); }, }); // The capability read also publishes ordinary quota. A new 99% reading or cooldown wins. assertReserveAdmission(config); if (signal?.aborted) throw signal.reason; if (!authorization || !isMainReserveAuthorizationLive(authorization, token)) throw new CodexReserveUnavailableError(); return authorization; } function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAccountId: string } { return { accessToken: authorizationCredential(headers) ?? "", chatgptAccountId: headers.get("chatgpt-account-id") ?? "", }; } /** * The workspace account id a request-owned `main` credential materializes under, or * `undefined` when the caller's headers carry none. This is the `chatgpt-account-id` * `materializeCodexUpstreamAuth` would set for a caller-owned `{ kind: "main" }` context, * read here without touching a credential store so a rotation gate can compare workspace * scope before a send is ever built. */ export function callerCodexWorkspaceAccountId(headers: Headers): string | undefined { const explicit = headers.get("chatgpt-account-id"); if (explicit) return explicit; const bearer = authorizationCredential(headers); return bearer ? extractAccountId(undefined, bearer) : undefined; } function assertMaterializedReserve(headers: Headers, ctx: CodexAuthContext, options: CodexAuthMaterializationOptions): void { if (!requiresReserveAuthorization(options.config, options.modelId, options.admission)) return; assertReserveAdmission(options.config!); if (ctx.kind === "pool" || !isMainReserveAuthorizationLive(ctx.reserveAuthorization, selectedCodexToken(headers))) { throw new CodexReserveUnavailableError(); } } /** A dispatch never renews permission: the next request may obtain a fresh bounded proof. */ export function createCodexReserveDispatchGuard( ctx: CodexAuthContext, config: CodexAuthPolicyConfig, modelId: string, admission?: Pick, terminalHelper = false, ): ((headers: Headers) => void) | undefined { // Snapshot the resolved source value, not the caller's mutable admission object. Config stays // live so policy changes remain visible after pacing and retry backoff. const source = admission?.source; if (modelId !== NATIVE_RESERVE_MODEL || source !== "loopback") return undefined; // Only immutable request facts decide whether to install the callback. Flag/role eligibility // is checked inside it, including an opt-in enabled while a send waits for pacing or WS open. const ingress = Object.freeze({ source }); return headers => { if (isCodexReserveHelperUnsupported(config, modelId, ingress, terminalHelper)) { throw new CodexReserveHelperUnsupportedError(); } assertMaterializedReserve(headers, ctx, { config, modelId, admission: ingress }); }; } /** Retry history must not turn a later local admission refusal into a network failure. */ export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown { const seen = new Set(); while (error instanceof UpstreamRetryEvidenceError && !seen.has(error)) { seen.add(error); error = error.cause; } return error; } /** * When the main login's full window ends, if credits are off for it and a window is full (#6334). * Same evidence the hard lock reads, and no plan lookup: the plan can live in the physical auth * file, which several callers are forbidden to open, so every long window counts instead. */ /** Recheck live account policy at the physical send, after pacing or retry backoff. */ export function createCodexAuthDispatchGuard( ctx: CodexAuthContext, config: CodexAuthPolicyConfig, modelId: string, admission?: Pick, terminalHelper = false, ): ((headers: Headers) => void) | undefined { const reserveGuard = createCodexReserveDispatchGuard(ctx, config, modelId, admission, terminalHelper); if (ctx.kind !== "pool" && ctx.kind !== "main-pool") return reserveGuard; return headers => { if (config.pausedCodexAccountIds?.includes(ctx.accountId)) throw new CodexPoolAuthenticationError("Selected Codex account is paused; resume it in account settings"); if (ctx.kind === "main-pool") { reserveGuard?.(headers); return; } assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, config), poolContextCreditRecoveryNeeded(ctx, config), ctx.quotaScope); reserveGuard?.(headers); }; } function mainCreditsHoldResetAt(config: Pick): number | undefined { if (codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID) && hasSpendableCodexCredits(getMainPolicyQuota())) return undefined; return codexUsageLimitResetAt(getMainPolicyQuota(), undefined, Date.now()); } function assertMainAccountPolicy( config: Pick | undefined, ): void { if (config) { const status = getMainAccountHardLockStatus(config); if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt, status.thresholds); const creditsResetAt = mainCreditsHoldResetAt(config); if (creditsResetAt !== undefined) throw new CodexMainAccountCreditsOffError(creditsResetAt, codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)); } // Only an admitted request is opencodex's own use of the main account. Counting a refused one // would hide outside usage from the warning exactly while the lock is holding. noteMainAccountActivity(); } /** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */ function callerMatchesObservedMain(headers: Headers): boolean { const bearer = authorizationCredential(headers); if (!bearer) return false; const effectiveAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); return matchesMainQuotaCredential(bearer, effectiveAccountId); } /** Constant-time digest comparison so bearer bytes never drive branch timing. */ function sameCredentialMaterial(a: string, b: string): boolean { return timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest()); } /** * The early-cooldown caller-main fallback must not resurrect the subscription that is cooling * down. Fail closed on ambiguity: an unreadable caller identity cannot be distinguished from the * cooled account. A distinct workspace account id is always safe; an exact materialized * bearer + account tuple marks the same subscription. Beyond that, the stable native user id is * the strongest available evidence: it survives an email change and a token rotation, and it * separates members who share one workspace account id even when neither credential carries an * email. Email remains the fallback when no comparable user id exists on both sides. Coexisting * personal/business registrations with the same email and account id over-deny during the * cooldown — the safe direction. */ function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean { const bearer = authorizationCredential(headers); if (!bearer) return true; const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); if (callerAccountId === undefined) return true; if (accountId === MAIN_CODEX_ACCOUNT_ID) { // No physical-main read to identify the caller: the observed-main equality tag suffices. return callerMatchesObservedMain(headers); } const stored = getCodexAccountCredential(accountId); const entry = config.codexAccounts?.find(account => account.id === accountId); const cooledAccountId = stored?.chatgptAccountId || entry?.chatgptAccountId; if (!cooledAccountId) return true; if (cooledAccountId !== callerAccountId) return false; if (stored?.accessToken && sameCredentialMaterial(bearer, stored.accessToken)) return true; // Same namespace on both sides, never `sub`: this is the ChatGPT per-user identity the reserve // path already trusts. A credential whose own two encodings of it disagree cannot identify // anyone, so it fails closed even when the disagreement is on the stored side. const callerUser = nativeUserIdClaims(bearer); const cooledUser = stored?.accessToken ? nativeUserIdClaims(stored.accessToken) : { userId: undefined, conflict: false }; if (callerUser.conflict || cooledUser.conflict) return true; if (callerUser.userId !== undefined && cooledUser.userId !== undefined) { return callerUser.userId === cooledUser.userId; } const callerEmail = extractEmail(undefined, bearer)?.trim().toLowerCase() || undefined; const cooledEmail = entry?.email?.trim().toLowerCase() || undefined; if (callerEmail !== undefined && cooledEmail !== undefined) return callerEmail === cooledEmail; return true; } /** * Does the caller's own credential belong to a Pool subscription that is currently cooled? * * The cooldown fallback below asks this of the SELECTED account, which was sufficient while a * request-owned bearer could not make main a candidate: the cooled account was the selection, so * the question and the refusal sat at the same place. Once main takes part in ordering (#5019) the * cooled account is no longer selected, and serving its own credential as main would resurrect the * cooldown it is inside — so eligibility has to ask the same question of every cooled sibling. * * `callerIsCooledPoolAccount` fails closed on an unreadable caller identity, which is preserved * here: an opaque bearer counts as owning any cooled subscription rather than escaping it. */ function callerOwnsAnyCooledPoolSubscription( headers: Headers, config: OcxConfig, quotaScope: CodexQuotaScope | undefined, ): boolean { for (const account of config.codexAccounts ?? []) { if (account.id === MAIN_CODEX_ACCOUNT_ID) continue; if (!getCodexQuotaHealthSnapshot(account.id, quotaScope)?.cooldownUntil) continue; if (callerIsCooledPoolAccount(headers, config, account.id)) return true; } return false; } function captureObservedMainWriter(): MainQuotaWriter | undefined { const identityKey = getObservedMainQuotaIdentityKey(); return identityKey === undefined ? undefined : { identityKey, identityGeneration: captureMainAccountIdentityGeneration(), }; } function observeSelectedMainCredential( token: { accessToken: string; chatgptAccountId: string }, writer: MainQuotaWriter | undefined, ): MainQuotaWriter | undefined { // A claimed RPC can observe a replacement identity while the caller awaits it. Only that // exact result carries the replacement writer; stale results keep their stale fence. const ownedWriter = mainAccountTokenQuotaWriter(token); if (ownedWriter) return ownedWriter; if (!writer) return undefined; // Carry an explicitly stale writer through to quota's rejection fence; turning it into an // untagged write would instead invalidate the replacement account's trusted observation. if (!isMainQuotaWriterLive(writer)) return writer; const observed = observeMainQuotaCredential(token.accessToken, token.chatgptAccountId); return observed?.identityKey === writer.identityKey ? writer : undefined; } /** * Human-readable account label for a client-visible error. NEVER the raw id: the proxy * supports non-loopback binds (auth-cors.ts `isApiAuthRequired` requires a token there * rather than refusing), so data-plane bodies can reach remote authenticated clients. * The main login has no secret id, so it renders as the literal alias users type. */ export function cooldownAccountLabel(accountId: string): string { return accountId === MAIN_CODEX_ACCOUNT_ID ? "main" : maskAccountId(accountId) ?? "account-…????"; } /** * Actionable message for a cooled-down account: until when, why, and how to escape. * Shared by every transport so the WebSocket surface (Codex Desktop) says the same thing * as HTTP. The bare "cooling down" string left users with no route but commenting out the * injected `openai_base_url` in config.toml. */ export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string { if (err instanceof CodexMainAccountHardLockError // A credits-off refusal is a configuration policy, not a cooldown: clearing a cooldown // cannot lift it, so its own wording (wait for the reset or allow credits) is the remedy. || err instanceof CodexMainAccountCreditsOffError || err instanceof CodexPoolAccountCreditsOffError || err instanceof CodexReserveUnavailableError // A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate // one, and the quota recovery advice below would send the operator after a cooldown that // does not exist (#4701). || err instanceof CodexRecoveryWithheldError) return err.message; const until = new Date(err.cooldownUntil).toISOString(); const scopeLabels: Record = { shared: "shared native quota", reserve: "Reserve quota", }; const scope = err.quotaScope ? scopeLabels[err.quotaScope] : null; const selected = accountSelector ? `Selected Codex account selector (${accountSelector})` : `Selected Codex account (${cooldownAccountLabel(err.accountId)})`; const recovery = accountSelector ? " This request is pinned to that selector and will not switch accounts; choose another account-qualified model or retry later." : " Run 'ocx account list openai' to find the id, then" + " 'ocx account clear-cooldown openai ' to lift it, or switch accounts with 'ocx account use openai '."; return `${selected}${scope ? ` ${scope} is` : " is"} cooling down until ${until}` + ` (source: ${err.cooldownSource ?? "default"}).${recovery}`; } /** * The soonest a main hard-lock refusal is worth retrying: a sibling pool account's known * recovery when it comes first, otherwise main's own reset (#7046). A pool hint is held to at * least {@link MAIN_HARD_LOCK_MIN_POOL_RETRY_MS} so a hold about to lapse cannot turn the refusal * into a tight retry loop. Null when neither time is known and still in the future. */ export const MAIN_HARD_LOCK_MIN_POOL_RETRY_MS = 60_000; export function mainHardLockRetryAt(err: CodexMainAccountHardLockError, now: number): number | null { const future = (value: number | undefined): number | null => typeof value === "number" && Number.isFinite(value) && value > now ? value : null; const main = future(err.resetAt); const pool = future(err.poolRecoveryAt); const poolRetryAt = pool === null ? null : Math.max(pool, now + MAIN_HARD_LOCK_MIN_POOL_RETRY_MS); if (poolRetryAt !== null && (main === null || poolRetryAt < main)) return poolRetryAt; return main; } /** HTTP form of {@link cooldownErrorMessage}, carrying Retry-After for well-behaved clients. */ export function cooldownErrorResponse( err: CodexAccountCooldownError, now = Date.now(), accountSelector?: string, ): Response { if (err instanceof CodexMainAccountHardLockError) { // A local policy refusal with a known horizon, not a transient throttle (#7046): Codex keeps // a Goal active and sleeps through `rate_limit_exceeded` for the whole Retry-After, but // stops on `usage_limit_reached` and shows the reset. The guard itself is unchanged. const retryAt = mainHardLockRetryAt(err, now); return formatUsageLimitReachedResponse(cooldownErrorMessage(err, accountSelector), retryAt, retryAt === null ? null : Math.max(1, Math.ceil((retryAt - now) / 1000))); } const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err, accountSelector)); const headers = new Headers(res.headers); if (!(err instanceof CodexReserveUnavailableError)) { headers.set("Retry-After", String(Math.max(1, Math.ceil((err.cooldownUntil - now) / 1000)))); } return new Response(res.body, { status: res.status, headers }); } export class CodexThreadAffinityExpiredError extends Error { accountId: string; constructor(accountId: string) { super("Codex thread account affinity expired"); this.name = "CodexThreadAffinityExpiredError"; this.accountId = accountId; } } export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean { return !(cause instanceof CodexMainAccountHardLockError) && !(cause instanceof CodexMainAccountCreditsOffError) && !(cause instanceof CodexPoolAccountCreditsOffError) && !(cause instanceof CodexAccountValidationPendingError) && !(cause instanceof CodexReserveUnavailableError) && !(cause instanceof CodexCredentialGenerationConflictError) && !(cause instanceof CodexCredentialRefreshLockTimeoutError) && !(cause instanceof CodexCredentialRefreshBusyError) && !(cause instanceof CodexCredentialRefreshStaleError) && !(cause instanceof MainAuthJsonChangedDuringRefreshError) && !(cause instanceof MainAccountTokenRefreshError) && !(cause instanceof NativeProfileError && cause.retryable) && !(cause instanceof DOMException && cause.name === "AbortError") && !(cause instanceof ConfigMutationLockError); } export interface ResolveCodexAuthContextOptions { admission?: Pick; /** Live policy owner when the routing config is a caller-specific replay snapshot. */ codexAuthPolicy?: CodexAuthPolicyConfig; excludeAccountId?: string; /** Resolve exactly this account without consulting or mutating Pool selection. */ accountId?: string; /** Final native model selected for this request, used to select its quota group. */ modelId?: string; /** Short reservation converted to turn ownership before native `__main__` token materialization. */ beginCodexAccountSelection?: () => CodexAccountSelectionAdmission | undefined; /** Test-only native credential read seams. */ isMainAccountTokenLive?: () => boolean; getMainAccountToken?: typeof getMainAccountToken; getValidMainAccountToken?: typeof getValidMainAccountToken; nativeMainRefreshDependencies?: NativeMainRefreshDependencies; signal?: AbortSignal; /** Test seam: overrides the bounded startup policy-binding wait before the hard-lock fence. */ mainAccountPolicyBindingWaitMs?: number; primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise; /** Test seam for account-gated native model discovery. */ resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements; /** Direct requests admitted with a proxy bearer substitute the stored native-main credential. */ substituteMainCredentialForDirect?: boolean; /** A validated native Codex bearer may serve this request without entering Pool state. */ requestScopedMainCredential?: boolean; /** Test seam for a Direct request's own forwarded ChatGPT credential. */ isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise; /** * This request's conversation carries live uploaded-file references (#4778). Retains the bound * account across a VOLUNTARY quota move; involuntary release is untouched. */ retainAccountForUploadedFiles?: boolean; } export interface CodexAccountSelectionAdmission { readonly mainProfileDraining: boolean; claimMainProfile(): boolean; release(): void; } /** * Wait, bounded, for an owned startup's main-policy binding, then fail closed while it is still * in flight. * * #5694 turned `codexMainAccountHardLock` on by default, so this fence stopped being an opt-in * rarity and started intercepting ordinary requests: for the seconds a Windows startup spends * recovering, sweeping stages, and binding the pinned home, every caller-owned direct request * was answered with a 503 "native-main profile maintenance is active; retry". Waiting is the * right answer for a window that closes on its own -- the request resumes and the hard lock * below still decides on identity and quota, exactly as it does after startup. A gate still * pending at the deadline (retained recovery, a manual-recovery requirement) is a real refusal, * and that is the one case that keeps the draining error. * * Both reads are memory-only: neither this fence nor the wait probes a foreign home. */ async function awaitMainAccountPolicyBindingSettled( options: ResolveCodexAuthContextOptions, ): Promise { if (!isMainAccountPolicyBindingPending()) return; const settled = await waitForMainAccountPolicyBinding({ signal: options.signal, timeoutMs: options.mainAccountPolicyBindingWaitMs, }); 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 ownership once, then hand back a synchronous proof. Main identity or its observed bearer * can change across the await; the hard-lock reroute rechecks the proof with no await between it * and the body rewrite, so a replacement main's lock never reroutes the original caller. */ export async function mainAccountHardLockCallerProof( headers: Headers, policy: CodexAuthPolicyConfig, options: Parameters[2] = {}, ): Promise<(() => boolean) | null> { if (!await mainAccountHardLockAppliesToCaller(headers, policy, options)) return null; return options.substituteMainCredentialForDirect === true ? () => true : () => 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 * time that a sibling account may meet (#7046). */ export async function resolveCodexAuthContext( headers: Headers, config: OcxConfig, mode: CodexAccountMode, options: ResolveCodexAuthContextOptions = {}, ): Promise { // Filled by the inner resolver with the request's own fenced model evidence, so the hint obeys it. const eligibility: RecoveryHintEligibility = {}; try { return await resolveCodexAuthContextUnannotated(headers, config, mode, options, eligibility); } catch (error) { if (error instanceof CodexMainAccountHardLockError && mode === "pool" && options.accountId === undefined && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && !requiresReserveAuthorization(options.codexAuthPolicy ?? config, options.modelId, options.admission) // An account-gated model with no resolved roster has no known eligible sibling. && (!options.modelId || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId) || eligibility.modelEligibleAccountIds !== undefined)) { try { const now = Date.now(); error.poolRecoveryAt = nextCodexPoolRecoveryAt(config, now, codexQuotaScopeForModel(options.modelId), { deniedAccountIds: eligibility.deniedModelAccountIds, eligibleAccountIds: eligibility.modelEligibleAccountIds, }); } catch { /* the hint is optional; the refusal stands without it */ } } throw error; } } interface RecoveryHintEligibility { modelEligibleAccountIds?: ReadonlySet; deniedModelAccountIds?: ReadonlySet; } async function resolveCodexAuthContextUnannotated( headers: Headers, config: OcxConfig, mode: CodexAccountMode, options: ResolveCodexAuthContextOptions = {}, eligibility: RecoveryHintEligibility = {}, ): Promise { const writerGeneration = captureConfigGeneration(); const policy = options.codexAuthPolicy ?? config; const requestScopedMainCredential = options.requestScopedMainCredential === true && hasCallerCodexBearer(headers); const reserve = requiresReserveAuthorization(policy, options.modelId, options.admission); if (reserve && (options.excludeAccountId !== undefined || (options.accountId !== undefined && options.accountId !== MAIN_CODEX_ACCOUNT_ID))) { throw new CodexReserveUnavailableError(); } const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId; const quotaScope = codexQuotaScopeForModel(options.modelId); // Pool pins and fallback must not resurrect an observed main credential that is cooling // down. Unrelated caller-owned credentials and explicit Direct keep their own policy. // The identity match and scoped health read are memory-only; never probe the auth file. const callerOwnedMainPoolCooldown = () => mode === "pool" && callerMatchesObservedMain(headers) ? getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope) : null; const assertCallerOwnedMainPoolNotCooled = () => { const cooldown = callerOwnedMainPoolCooldown(); if (cooldown?.cooldownUntil) { throw new CodexAccountCooldownError( MAIN_CODEX_ACCOUNT_ID, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope, ); } }; const mainPinState = () => requestOwnedMainPinState( headers, config, policy, requestScopedMainCredential, fixedAccountId, quotaScope, ); const requestOwnedMainPinCandidate = mainPinState().candidate; // During an owned startup, equality cannot be established until recovery and the // memory-only policy binding finish. This read-only fence never probes a foreign home, and it // waits out a window that closes on its own instead of refusing a request that arrived inside // it. Only a binding still pending at the deadline fails closed. if (isMainAccountHardLockEnabled(policy) && requestOwnedMainPinCandidate) { await awaitMainAccountPolicyBindingSettled(options); } const preserveRequestOwnedMainPin = () => mainPinState().preserve; if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); } const resolveCallerOwnedMainContext = async (): Promise => { const substituteStoredMain = options.substituteMainCredentialForDirect === true; // An internal route change can strip the admission bearer before this point. // Trusted substitution still has to claim and validate stored main below. if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); if (!substituteStoredMain) { if (await mainAccountHardLockAppliesToCaller(headers, policy, options)) assertMainAccountPolicy(policy); assertCallerOwnedMainPoolNotCooled(); if (reserve) { const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy }); const token = selectedCodexToken(selected); const reserveAuthorization = await authorizeReserveCredential(token, captureMainQuotaWriter(token.chatgptAccountId), policy, options.signal, undefined, writerGeneration); return { kind: "main", accountId: null, reserveAuthorization }; } if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { const entitled = await ( options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel )(headers, options.modelId); if (!entitled) { throw new CodexModelAvailabilityError( "unsupported", "The selected ChatGPT account does not support this model", ); } } if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); assertCallerOwnedMainPoolNotCooled(); return { kind: "main", accountId: null }; } // Admission-bearer Direct requests later replace the proxy secret with the stored // native-main credential. Reserve and claim that physical profile before entitlement // discovery or materialization can read it; caller-owned Direct credentials never enter // this branch. A missing turn admission must fail closed instead of recreating an // untracked native-main read. if (isNativeMainTrafficBlocked()) throw new CodexMainProfileDrainingError(); const directSelectionAdmission = options.beginCodexAccountSelection?.(); if (!directSelectionAdmission) throw new CodexMainProfileDrainingError(); try { if ( directSelectionAdmission.mainProfileDraining || !directSelectionAdmission.claimMainProfile() || isNativeMainTrafficBlocked() ) { throw new CodexMainProfileDrainingError(); } if (isMainAccountHardLockEnabled(policy)) reconcileMainCodexAccountRuntimeState(); if (!getMainAccountToken()) { try { await (options.getValidMainAccountToken ?? getValidMainAccountToken)({ signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), }); } catch (cause) { throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, cause); } options.signal?.throwIfAborted(); } assertMainAccountPolicy(policy); if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { const entitled = entitledCodexAccountIdsForModel( await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, { signal: options.signal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, }), options.modelId, )?.has(MAIN_CODEX_ACCOUNT_ID) === true; if (!entitled) { throw new CodexModelAvailabilityError( "unsupported", "The selected ChatGPT account does not support this model", ); } } assertMainAccountPolicy(policy); return { kind: "main", accountId: null }; } finally { // The short selector reservation ends here. A successful claim remains owned by // the enclosing turn lease until the request or transferred stream settles. directSelectionAdmission.release(); } }; // Pool discovery excludes request-owned main credentials by design: they must never be folded // into stored-account entitlement, affinity, or persistence state. An effective manual main pin // is the one exception where that exclusion is selection evidence in the opposite direction. // Validate the caller's own gated-model roster before using it, and fall through to a Pool model // detour when it lacks the grant. This branch performs no physical-main credential read. if (preserveRequestOwnedMainPin()) { const callerEntitled = !options.modelId || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId) || await ( options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel )(headers, options.modelId); if (callerEntitled && preserveRequestOwnedMainPin()) { return { kind: "main", accountId: null }; } } // An explicit namespace binding is stronger than the provider's default mode. It must use the // selected stored credential even while the canonical OpenAI provider is globally Direct. // A request-owned bearer is deliberately not represented as `main-pool`: Pool account ids own // durable health, quota, and affinity state, while this credential exists for one request only. if ((reserve && hasCallerCodexBearer(headers) && !options.substituteMainCredentialForDirect && (requestScopedMainCredential || mode === "direct")) || (mode === "direct" && fixedAccountId === undefined) || (requestScopedMainCredential && fixedAccountId === MAIN_CODEX_ACCOUNT_ID)) { return resolveCallerOwnedMainContext(); } // A caller bearer can still accompany a request that selects a configured Pool account. Do not // let that request read, delete, or create a file-main affinity binding while deciding whether a // stored account is available; only the stored credential selected below may own Pool state. const affinityKey = poolStateEligible(fixedAccountId, requestScopedMainCredential) ? codexPoolAffinityKey(headers) : undefined; // The thread's family relation, recorded under the same condition as the key itself. A // first-placing child consults it; a request-owned or fixed credential never enters Pool // state, so it never enters lineage either. const lineage = affinityKey !== undefined ? recordCodexThreadLineage(headers) : undefined; // Why this request is on this account, carried to the request log so a move reads as an event // instead of something inferred from account labels across lines (#4546). let affinityDecision: CodexAffinityDecision | undefined; // The half-open trial this request was granted, if it is the one allowed to test a held // account. Declared out here because the release paths below and the returned context are on // opposite sides of several throws (#4701). let transientProbe: TransientProbeGrant | undefined; const releaseTransientProbeGrant = (): void => { if (!transientProbe) return; releaseTransientProbe(transientProbe.lease); transientProbe = undefined; }; // Retained startup recovery makes the physical main identity ineligible. Routing // can still preserve service by selecting a healthy configured pool account. A // request-owned bearer likewise cannot inspect or reconcile file-main state. const nativeMainTrafficBlocked = isNativeMainTrafficBlocked(); const selectionAdmission = options.beginCodexAccountSelection?.(); const nativeMainReadsForbidden = requestScopedMainCredential || nativeMainTrafficBlocked || selectionAdmission?.mainProfileDraining === true; const nativeMainSelectionOnly = !nativeMainTrafficBlocked && selectionAdmission?.mainProfileDraining === true; let accountId: string; let mainDiscoveryFailure: unknown; // Answering this with the manual-pin predicate made `codexAccountUnusableReason` report // `main_credential_unavailable` for every UNPINNED request, so `getEligiblePoolAccounts` never // listed main and the strategy compared only the stored accounts. With one stored sibling the // pool degraded to "stored account until it cannot serve, then main", discarding the usage, // priority and reset ordering the operator configured (#5019). const requestOwnedMainCredentialLive = () => requestOwnedMainCredentialIsLive({ preserveRequestOwnedMainPin: preserveRequestOwnedMainPin(), requestScopedMainCredential, nativeMainTrafficBlocked, mainProfileDraining: selectionAdmission?.mainProfileDraining === true, // Keeps the cooled account as the selection when the caller owns it, so the refusal is still // produced by the cooldown machinery below rather than by a second rule beside it. callerOwnsCooledPoolSubscription: requestScopedMainCredential && callerOwnsAnyCooledPoolSubscription(headers, config, quotaScope), }); try { // A fileless login can change identity even while its previous expiry observation is usable. // The selector discovers it before identity-bound quota or hard-lock policy; dispatch still revalidates it. if (selectionAdmission && !nativeMainReadsForbidden && !options.isMainAccountTokenLive && (fixedAccountId === undefined || fixedAccountId === MAIN_CODEX_ACCOUNT_ID) && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) && (!getMainAccountToken() || !getMainAccountCredentialStatus().usable)) { try { await (options.getValidMainAccountToken ?? getValidMainAccountToken)({ signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), }); } catch (cause) { mainDiscoveryFailure = cause; } options.signal?.throwIfAborted(); } const excludeAccountIds = nativeMainReadsForbidden ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; const mainModelGrantUnobserved = excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID) === true; const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId) ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, { excludeAccountIds, signal: options.signal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, }) : undefined; const entitledAccountIds = entitlementSnapshot ? entitledCodexAccountIdsForModel(entitlementSnapshot, options.modelId) : undefined; const modelEligibleAccountIds = entitledAccountIds ? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate))) : undefined; eligibility.modelEligibleAccountIds = modelEligibleAccountIds; // #4768: the flagships stay visible and never fail closed, so this is evidence routing may // ORDER by, not evidence it may refuse on. Read synchronously from rosters discovery has // already gathered -- no upstream fetch joins the request path for the most commonly // requested models in the product -- and passed to selection as a preference that is dropped // whenever honouring it would leave no candidate. // // Under the SAME exclusion the entitlement snapshot above uses. The reader validates each // cached roster against the account's current credential, and for native main that is a // synchronous read of the physical stored token -- exactly what this request is forbidden to // touch while a profile switch drains it or while it is served by a request-owned credential. // Excluding main here costs nothing: the preference is an ordering hint, so main becomes // unknown rather than denied, and unknown leaves selection exactly as it was. const deniedModelAccountIds = cachedDeniedCodexAccountIdsForModel( options.modelId, undefined, { excludeAccountIds }, ); eligibility.deniedModelAccountIds = deniedModelAccountIds; const selectionOptions = { // Temporary switch drain keeps the candidate until the atomic claim rejects // it. Retained recovery makes main wholly ineligible so pool routing continues. nativeMainSelectionOnly, isMainAccountTokenLive: requestScopedMainCredential // Main stays excluded from this request's model roster below, so an account-gated model // still cannot be served from a candidacy this answer creates. Everything else -- pool // eligibility, shared-state preservation, and a caller-owned pin surviving a model detour // -- is answered without reading or selecting the physical main credential. ? requestOwnedMainCredentialLive : options.isMainAccountTokenLive, modelEligibleAccountIds, deniedModelAccountIds, requestOwnedMainCredential: requestScopedMainCredential, // Request-scoped and deliberately absent from `sharedStateSelectionOptions`: one // conversation's attachments say nothing about where unrelated threads should be served. retainAccountForUploadedFiles: options.retainAccountForUploadedFiles === true, }; // A pre-drain selector reserves the native identity while reconciliation and // routing inspect it. Selectors arriving after the fence skip reconciliation // and may still route to non-main pool accounts without touching switch state. if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError(); if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState(); // Annotated, not inferred. The two literals below carry neither `affinity` nor // `transientProbe`, so an inferred union makes `"k" in resolution` widen those reads to // `unknown` and a discriminant narrowing fail outright. Contextually typing every branch to // the resolver's own union is what lets the reads below stay total. const resolution: CodexThreadResolution = fixedAccountId !== undefined ? { status: "selected" as const, accountId: fixedAccountId } : options.excludeAccountId ? (() => { const selected = pickAlternateCodexAccount( config, options.excludeAccountId!, Date.now(), quotaScope, selectionOptions, ); return selected ? { status: "selected" as const, accountId: selected } : { status: "none" as const }; })() : resolveCodexAccountForThreadDetailed( affinityKey ?? null, config, Date.now(), quotaScope, selectionOptions, options.modelId, lineage, ); if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); // THE REFUSAL. Every candidate is held, the recovery budget is spent, and no detour is // left -- so this request must not reach upstream at all. Returning the held account here // is what #4701 is about: under a provider-wide 503 that is every bound request piling // onto an account already known to be failing. Thrown before any credential is read, so // nothing is sent and nothing is spent. if (resolution.status === "withheld") { throw new CodexRecoveryWithheldError(resolution.accountId, resolution.retryAt, resolution.detourAccountId); } const selected = resolution.status === "selected" ? resolution.accountId : null; affinityDecision = resolution.affinity; transientProbe = resolution.status === "selected" ? resolution.transientProbe : undefined; // Main now takes part in ordering when the request carries its own main bearer (#5019), so // selection can name it outright rather than only through the no-candidate fallback below. // Serve that selection from the credential the request arrived with: the read fence above // forbids this request from claiming, reading or reconciling the stored main profile, and a // request-owned credential owns no Pool state, so there is nothing here to claim, prime or // cool down. Hand back any trial first -- no Pool account is being sent to. if (selected === MAIN_CODEX_ACCOUNT_ID && requestScopedMainCredential) { releaseTransientProbeGrant(); const context = await resolveCallerOwnedMainContext(); console.info("[codex] Pool selected caller-owned main credential."); return context; } if (!selected) { // After an entitlement-await recovery, current selection evidence outranks the saved // probe failure; retain it only while main is still unusable (as for fixed selection). if (mainDiscoveryFailure !== undefined && !getMainAccountCredentialStatus().usable) { throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, mainDiscoveryFailure); } // A retry that excluded a failed Pool account may still use the validated caller-owned // main credential. Treating every exclusion as if main itself had failed strands a healthy // native bearer after the first Pool attempt. Preserve the exactly-once boundary by refusing // this fallback only when the excluded credential is main. if ( requestScopedMainCredential && fixedAccountId === undefined && !(callerMatchesObservedMain(headers) && policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)) && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID ) { const context = await resolveCallerOwnedMainContext(); console.info("[codex] Pool used caller-owned main credential: no selectable stored account."); return context; } if (fixedAccountId !== undefined) { if (modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)) { throw new CodexModelAvailabilityError( "unsupported", "Selected Codex account does not support this model", ); } const mainReason = fixedAccountId === MAIN_CODEX_ACCOUNT_ID && !nativeMainReadsForbidden && !policy.pausedCodexAccountIds?.includes(fixedAccountId) ? codexAccountUnusableReason(config, fixedAccountId, selectionOptions) : undefined; throw new CodexPoolAuthenticationError("Selected Codex account is unavailable", { quarantinedMain: mainReason === "needs_reauth", }); } // Recovery or a turn drain deliberately makes physical main unobservable. // If no healthy pool route is available, report the temporary fence rather // than turning a credential we were forbidden to inspect into a permanent // model-entitlement denial. // A configured pool retry/exclusion that finds no alternate preserves its // ordinary pool-auth failure instead of being mislabeled as a main fence. if (nativeMainReadsForbidden && !options.excludeAccountId) { throw new CodexMainProfileDrainingError(); } if (!nativeMainReadsForbidden && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) && (!modelEligibleAccountIds || modelEligibleAccountIds.has(MAIN_CODEX_ACCOUNT_ID))) { assertMainAccountPolicy(policy); } if (modelEligibleAccountIds !== undefined) { const unsupported = entitledAccountIds?.size === 0 && !mainModelGrantUnobserved; throw new CodexModelAvailabilityError( unsupported ? "unsupported" : "temporarily_unavailable", unsupported ? "No eligible Codex account supports this model" : "Codex accounts that support this model are currently unavailable", ); } throw new CodexPoolAuthenticationError(undefined, { quarantinedMain: !nativeMainReadsForbidden && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) && codexAccountUnusableReason(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) === "needs_reauth", }); } accountId = selected; if (accountId === MAIN_CODEX_ACCOUNT_ID) assertMainAccountPolicy(policy); if (accountId === MAIN_CODEX_ACCOUNT_ID && nativeMainTrafficBlocked) { throw new CodexMainProfileDrainingError(); } if ( accountId === MAIN_CODEX_ACCOUNT_ID && selectionAdmission && !selectionAdmission.claimMainProfile() ) { throw new CodexMainProfileDrainingError(); } // Some legacy Pool fallbacks preserve a configured active account even when it is not // currently selectable, so token/cooldown code can produce the historical actionable error. // Model entitlement is different: sending the request would spend a turn on an account whose // authenticated roster already denied the model. Reassert this boundary after every selector. if (modelEligibleAccountIds && !modelEligibleAccountIds.has(accountId)) { throw new CodexModelAvailabilityError( "unsupported", fixedAccountId !== undefined ? "Selected Codex account does not support this model" : "No eligible Codex account supports this model", ); } if (fixedAccountId !== undefined) { // Preserve exact-main discovery errors only while the current credential remains unusable, as above. if (accountId === MAIN_CODEX_ACCOUNT_ID && mainDiscoveryFailure !== undefined && !getMainAccountCredentialStatus().usable) { throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, mainDiscoveryFailure); } if (policy.pausedCodexAccountIds?.includes(accountId)) { throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); } if (isAccountNeedsReauth(accountId)) { throw new CodexPoolAuthenticationError("Selected Codex account needs reauthentication", { quarantinedMain: accountId === MAIN_CODEX_ACCOUNT_ID && !nativeMainReadsForbidden, }); } const unusableReason = codexAccountUnusableReason(config, accountId, selectionOptions); if (unusableReason !== undefined) { throw new CodexPoolAuthenticationError("Selected Codex account is unavailable", { quarantinedMain: accountId === MAIN_CODEX_ACCOUNT_ID && unusableReason === "needs_reauth", }); } } } catch (cause) { // Selection granted a trial and then a later policy check refused the account. The trial // never runs, so hand it back instead of leaving the held account unprobeable until its // deadline lapses (#4701). releaseTransientProbeGrant(); throw cause; } finally { selectionAdmission?.release(); } // Legacy selectors may retain an unusable account for actionable errors. A // deferred credential must never become request auth through that fallback. try { assertCodexAccountValidationReady(accountId); if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION); assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config), poolCreditRecoveryNeeded(policy, accountId), quotaScope); } catch (cause) { // Nothing will reach upstream, so give the trial back instead of leaving the held account // unprobeable until the lease deadline lapses (#4701). releaseTransientProbeGrant(); throw cause; } // Lazy prime: if the selected account has no quota yet, the pool is likely // unprimed (dashboard never opened, or startup prime was blocked). Kick a // best-effort prime so the NEXT routing decision has real scores. This never // blocks the current request, and the helper's single-flight guard collapses // repeated triggers into one pass. Opt-in priority failback also refreshes inactive // accounts; that path has a five-minute attempt limit inside the prime helper. const priorityFailback = codexAccountPriorityFailbackEnabled(config, accountId); if (fixedAccountId === undefined && !nativeMainReadsForbidden && (!getAccountQuota(accountId) || priorityFailback)) { const reason = priorityFailback ? "priority-failback" : "pre-route"; if (options.primeCodexPoolQuotas) { void options.primeCodexPoolQuotas(config, reason).catch(() => {}); } else { // Tracked from the import on: the prime registers itself only once the module resolves. void trackBackgroundWork(`codex-pool-quota-prime-import:${reason}`, import("./auth-api") .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, reason)) .catch(() => {})); } } // Snapshot (not just the deadline) so a refused request can report WHY it is cooled: // a literal Retry-After reads very differently to a user than a reset-derived guess. const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope); const cooldownUntil = cooldown?.cooldownUntil; // A transient-hold trial and a quota cooldown cannot both describe this account: // `isTransientOnlyAffinityBlock` refuses to recognise a transient hold on an account carrying // quota health, so the cooldown branch below is unreachable while a trial is held. That is // also why no request pays two recovery permits for one send. The release is defensive -- // should that invariant ever move, the trial is handed back rather than stranded behind a // refusal that belongs to the other domain. if (cooldownUntil && transientProbe) releaseTransientProbeGrant(); // A cooled-down account never sends traffic, so upstream recovery can never be // observed and the cooldown outlives the real limit. Admit one probe per // interval; its outcome decides whether the cooldown ends (#433). let probeLeaseId: string | undefined; let probeQuotaScope: CodexQuotaScope | undefined; if (cooldownUntil) { // Exact bindings are not Pool recovery traffic. Fail closed instead of consuming the Pool's // one probe lease or selecting another account. if (fixedAccountId !== undefined) { throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope); } probeQuotaScope = cooldown?.quotaScope; probeLeaseId = probeQuotaScope ? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined : tryAcquireCodexQuotaProbeLease(accountId) ?? undefined; if (!probeLeaseId) { // The selector can retain the configured Pool account when no stored // alternate is eligible. A validated caller may still serve this request, // just as it can after an upstream rejection, without changing Pool state. if (requestScopedMainCredential && fixedAccountId === undefined && !(callerMatchesObservedMain(headers) && policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)) && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && !callerIsCooledPoolAccount(headers, config, accountId)) { const context = await resolveCallerOwnedMainContext(); console.info("[codex] Pool used caller-owned main credential: stored account cooldown."); return context; } throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope); } } if (accountId === MAIN_CODEX_ACCOUNT_ID) { // Main account in rotation: refresh auth.json before upstream I/O and fail closed if it vanished. let token: { accessToken: string; chatgptAccountId: string } | null; let mainQuotaWriter = captureObservedMainWriter(); try { token = await (options.getValidMainAccountToken ?? getValidMainAccountToken)({ signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), }); if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter); assertMainAccountPolicy(policy); } catch (cause) { releaseTransientProbeGrant(); if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); // Policy refusals, including one that lands while the token refresh was in flight, are not // authentication failures: they must stay reset-bound 429s and never mark a valid login. if (cause instanceof CodexMainAccountHardLockError || cause instanceof CodexMainAccountCreditsOffError) throw cause; if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) { markAccountNeedsReauth(accountId, writerGeneration); } throw new CodexAuthContextError(accountId, cause); } if (!token) { // Nothing will reach upstream, so give the probe back instead of burning it. releaseTransientProbeGrant(); if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); throw new CodexPoolAuthenticationError( fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined, ); } let reserveAuthorization: MainReserveAuthorization | undefined; try { reserveAuthorization = reserve ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration) : undefined; } catch (cause) { // A Reserve refusal ends the request here, so the trial it was holding never runs. releaseTransientProbeGrant(); throw cause; } return { kind: "main-pool", accountId, writerGeneration, mainQuotaWriter, ...(reserveAuthorization ? { reserveAuthorization } : {}), accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), ...(affinityKey ? { affinityKey } : {}), ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), ...(affinityDecision ? { affinityDecision } : {}), ...(transientProbe ? { transientProbe } : {}), }; } try { const token = await getValidCodexToken(accountId, { signal: options.signal }); assertCodexAccountValidationReady(accountId); if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION); assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config), poolCreditRecoveryNeeded(policy, accountId), quotaScope); return bindPoolCreditPolicy({ kind: "pool", accountId, writerGeneration, generation: token.generation, poolQuotaWriter: capturePoolQuotaWriter(accountId, token), accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), ...(affinityKey ? { affinityKey } : {}), ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), ...(affinityDecision ? { affinityDecision } : {}), ...(transientProbe ? { transientProbe } : {}), }, config, policy); } catch (cause) { releaseTransientProbeGrant(); if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); // Post-refresh eligibility refusals do not invalidate a healthy credential. if (cause instanceof CodexPoolAccountCreditsOffError || cause instanceof CodexPoolAuthenticationError) throw cause; if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) { markAccountNeedsReauth(accountId, writerGeneration); } throw new CodexAuthContextError(accountId, cause); } } export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void { if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return; if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx), poolContextCreditRecoveryNeeded(ctx), ctx.quotaScope); // A context holding the probe lease was deliberately admitted through the cooldown. if (ctx.probeLeaseId) return; const cooldown = getCodexQuotaHealthSnapshot(ctx.accountId, ctx.quotaScope); if (cooldown?.cooldownUntil) { throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope); } } export function applyCodexAuthContextToProvider( provider: OcxProviderConfig, ctx: CodexAuthContext, mode: CodexAccountMode | undefined, ): OcxRuntimeProviderConfig { if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider; assertCodexAccountValidationReady(ctx.accountId); if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx), poolContextCreditRecoveryNeeded(ctx), ctx.quotaScope); return { ...provider, _codexAccountOverride: { accessToken: ctx.accessToken, chatgptAccountId: ctx.chatgptAccountId, kind: ctx.kind, }, _codexAccountRequired: true, }; } export class CodexMainSubstitutionUnavailableError extends Error { constructor() { super("No usable Codex main credential to substitute for an admission bearer"); this.name = "CodexMainSubstitutionUnavailableError"; } } /** Dispatch identity comes only from an owned observation of the selected credential. */ function selectedMainQuotaDispatch(selected: Headers): MainQuotaDispatch | undefined { const bearer = authorizationCredential(selected); if (!bearer) return undefined; const accountId = selected.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer); return captureMainQuotaDispatch(bearer, accountId, captureConfigGeneration()); } /** * Build the upstream auth headers for one Codex turn. * * The two credential domains meet here, and only here: * * - `pool` / `main-pool` always OVERWRITE with the stored account credential. Whatever the * caller sent is irrelevant to what we send upstream. * - `main` with an admission-bearer caller (#1686) must substitute the stored main credential. * The caller proved admission with one of OUR secrets, which must never leave the process, so * the only two acceptable outcomes are replaced-with-stored-main or fail-before-any-IO. * Silently forwarding would be the leak validateForwardAdmissionCredential exists to prevent. * - `main` with a dedicated-header caller keeps the existing intentional passthrough: the bearer * there is the user's own ChatGPT credential, not ours. */ export function materializeCodexUpstreamAuth( headers: Headers, ctx: CodexAuthContext, options: CodexAuthMaterializationOptions = {}, ): Headers { // assertion_only must never be answered with a pool, stored-main, or other substituted Bearer. if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError(); if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined; const selected = new Headers(); for (const name of FORWARD_HEADERS) { const value = headers.get(name); if (value || (value !== null && (name === "x-codex-installation-id" || name === "x-codex-turn-metadata"))) selected.set(name, value); } if (ctx.kind === "pool" || ctx.kind === "main-pool") { assertCodexAccountValidationReady(ctx.accountId); if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, options.config), poolContextCreditRecoveryNeeded(ctx, options.config), ctx.quotaScope); selected.set("authorization", `Bearer ${ctx.accessToken}`); selected.set("chatgpt-account-id", ctx.chatgptAccountId); if (ctx.kind === "pool") isolateCodexPoolAccountHeaders(selected, ctx.chatgptAccountId); else selected.delete("x-oai-attestation"); if (ctx.kind === "main-pool") { ctx.mainQuotaWriter = observeSelectedMainCredential(ctx, ctx.mainQuotaWriter); assertMainAccountPolicy(options.config); ctx.mainQuotaDispatch = ctx.mainQuotaWriter && isMainQuotaWriterLive(ctx.mainQuotaWriter) ? selectedMainQuotaDispatch(selected) : undefined; } assertMaterializedReserve(selected, ctx, options); return selected; } if (ctx.kind === "main" && options.substituteMainCredential !== true && !selected.has("chatgpt-account-id")) { const bearer = authorizationCredential(selected); const accountId = bearer ? extractAccountId(undefined, bearer) : undefined; if (accountId) selected.set("chatgpt-account-id", accountId); } if (ctx.kind === "main" && options.substituteMainCredential === true) { // No config object is "no policy input", not the persisted opt-out: only a config that // actually says `false` disables the default-on lock (#5694). if (options.config !== undefined && isMainAccountHardLockEnabled(options.config)) { reconcileMainCodexAccountRuntimeState(); } const writer = captureObservedMainWriter(); const stored = getMainAccountToken(); // Fail BEFORE any upstream I/O. Falling through here would send the admission secret. 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); selected.delete("x-oai-attestation"); observeSelectedMainCredential(stored, writer); assertMainAccountPolicy(options.config); assertMaterializedReserve(selected, ctx, options); ctx.mainQuotaDispatch = selectedMainQuotaDispatch(selected); return selected; } if (callerMatchesObservedMain(selected)) assertMainAccountPolicy(options.config); assertMaterializedReserve(selected, ctx, options); if (ctx.kind === "main") ctx.mainQuotaDispatch = selectedMainQuotaDispatch(selected); return selected; } /** The model producer, not an optional context marker, decides whether a grant is required. */ async function materializeReserveUpstreamAuth( headers: Headers, ctx: CodexAuthContext, options: CodexAuthMaterializationOptions, ): Promise { if (ctx.kind === "pool") throw new CodexReserveUnavailableError(); const config = options.config!; assertReserveAdmission(config); const writerGeneration = ctx.kind === "main-pool" ? ctx.writerGeneration : captureConfigGeneration(); const storedMain = ctx.kind === "main" && (options.substituteMainCredential === true || !hasCallerCodexBearer(headers)); let admission: CodexAccountSelectionAdmission | undefined; let writer = ctx.kind === "main-pool" ? ctx.mainQuotaWriter : undefined; try { if (storedMain) { if (isNativeMainTrafficBlocked()) throw new CodexMainProfileDrainingError(); admission = options.beginCodexAccountSelection?.(); if (!admission || admission.mainProfileDraining || !admission.claimMainProfile() || isNativeMainTrafficBlocked()) { throw new CodexMainProfileDrainingError(); } reconcileMainCodexAccountRuntimeState(); writer = captureObservedMainWriter(); assertReserveAdmission(config); } // Build the real credential first, without recursively requiring a not-yet-fetched proof. // The ordinary hard lock remains enabled, including the post-refresh check. const selected = await materializeCodexUpstreamAuthAsync(headers, ctx, { ...options, modelId: undefined, substituteMainCredential: storedMain, }); const token = selectedCodexToken(selected); if (ctx.kind === "main-pool") writer = ctx.mainQuotaWriter; else if (!storedMain) writer = captureMainQuotaWriter(token.chatgptAccountId); ctx.reserveAuthorization = await authorizeReserveCredential(token, writer, config, options.signal, ctx.reserveAuthorization, writerGeneration); assertMaterializedReserve(selected, ctx, options); return selected; } finally { admission?.release(); } } export async function materializeCodexUpstreamAuthAsync( headers: Headers, ctx: CodexAuthContext, options: CodexAuthMaterializationOptions = {}, ): Promise { if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError(); if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined; if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) { return materializeReserveUpstreamAuth(headers, ctx, options); } if (ctx.kind !== "main" || options.substituteMainCredential !== true) { return materializeCodexUpstreamAuth(headers, ctx, options); } const selected = new Headers(); for (const name of FORWARD_HEADERS) { const value = headers.get(name); if (value || (value !== null && (name === "x-codex-installation-id" || name === "x-codex-turn-metadata"))) selected.set(name, value); } if (options.config !== undefined && isMainAccountHardLockEnabled(options.config)) { reconcileMainCodexAccountRuntimeState(); } const writer = captureObservedMainWriter(); const stored = await getValidMainAccountToken({ signal: options.signal, ...(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); selected.delete("x-oai-attestation"); observeSelectedMainCredential(stored, writer); assertMainAccountPolicy(options.config); // An opt-in enabled during token refresh must not turn a proof-less context into Reserve. assertMaterializedReserve(selected, ctx, options); ctx.mainQuotaDispatch = selectedMainQuotaDispatch(selected); return selected; } /** @deprecated Prefer materializeCodexUpstreamAuth; kept for call sites without admission context. */ export function headersForCodexAuthContext( headers: Headers, ctx: CodexAuthContext, config?: CodexAuthPolicyConfig, modelId?: string, admission?: Pick, ): Headers { return materializeCodexUpstreamAuth(headers, ctx, { config, modelId, admission }); } export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean { if (ctx.kind === "main") return true; if (ctx.kind === "main-pool") return isCodexAccountUsable(config, ctx.accountId); return isCodexAccountUsable(config, ctx.accountId) && isCodexAccountGenerationLive(ctx.accountId, ctx.generation); }