From e2931401da8ff3e9201fa8ab182f6edf03b52812 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 29 Aug 2026 04:58:46 +0900 Subject: [PATCH 1/3] fix(codex): accept request-scoped native main credentials Co-authored-by: Beda Schmid --- src/codex/account-usability.ts | 3 ++ src/codex/auth-context.ts | 49 +++++++++++++++++++++++++++----- src/codex/routing.ts | 3 +- src/server/auth-cors.ts | 9 ++++++ src/server/responses/compact.ts | 11 ++++++- src/server/responses/core.ts | 23 +++++++++++---- tests/codex-auth-context.test.ts | 38 +++++++++++++++++++++++++ tests/server-auth.test.ts | 33 +++++++++++++++++++++ 8 files changed, 155 insertions(+), 14 deletions(-) diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index d508e19f4d..5c1bd0745a 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -10,6 +10,8 @@ export interface CodexAccountUsabilityOptions { nativeMainSelectionOnly?: boolean; /** Test seam for proving whether routing attempted a physical native-token read. */ isMainAccountTokenLive?: typeof isMainAccountTokenLive; + /** A validated native Codex bearer is available for this request only. */ + requestScopedMainCredential?: boolean; /** Confirmed account ids for an account-gated model; omitted for ordinary native models. */ modelEligibleAccountIds?: ReadonlySet; } @@ -27,6 +29,7 @@ export function isCodexAccountUsable( // A legacy pool row with the sentinel makes an active `__main__` ambiguous. // Fail closed until the authenticated compatibility-delete path removes it. if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false; + if (options.requestScopedMainCredential) return true; if (isAccountNeedsReauth(accountId)) return false; // A selection-only caller owns the recovery/drain fence and will reject main // before reservation or token materialization. Treat cached main as a routing diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index dcf9bb88df..b50dc00ba2 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -39,6 +39,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; import { retainedUtf8Bytes } from "../lib/admission"; +import { extractAccountId } from "../oauth/chatgpt"; const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; const CODEX_APP_AFFINITY_KEY = randomBytes(32); @@ -95,14 +96,12 @@ export type CodexAuthContext = /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */ probeQuotaScope?: CodexQuotaScope; } - | { + | ({ // Main Codex account participating in rotation: token injected from ~/.codex/auth.json - // (Option A). Distinct from "main" (passthrough fallback that forwards the client token). + // or forwarded from this native Codex request. Distinct from "main" (Direct mode). kind: "main-pool"; accountId: string; writerGeneration: number; - accessToken: string; - chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ fixedAccount?: boolean; /** See `pool.affinityKey`. */ @@ -111,7 +110,16 @@ export type CodexAuthContext = probeLeaseId?: string; quotaScope?: CodexQuotaScope; probeQuotaScope?: CodexQuotaScope; - }; + } & ( + | { + credentialSource?: "auth-file"; + accessToken: string; + chatgptAccountId: string; + } + | { + credentialSource: "caller"; + } + )); /** Probe lease carried by this context, when it holds one. */ export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | undefined { @@ -336,6 +344,8 @@ export interface ResolveCodexAuthContextOptions { 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 `__main__` for this request only. */ + requestScopedMainCredential?: boolean; /** Test seam for a Direct request's own forwarded ChatGPT credential. */ isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise; } @@ -353,6 +363,8 @@ export async function resolveCodexAuthContext( options: ResolveCodexAuthContextOptions = {}, ): Promise { const writerGeneration = captureConfigGeneration(); + const requestScopedMainCredential = options.requestScopedMainCredential === true + && hasCallerCodexBearer(headers); const fixedAccountId = options.accountId; if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); @@ -434,6 +446,7 @@ export async function resolveCodexAuthContext( // it. Retained recovery makes main wholly ineligible so pool routing continues. nativeMainSelectionOnly, isMainAccountTokenLive: options.isMainAccountTokenLive, + requestScopedMainCredential, modelEligibleAccountIds, }; // A pre-drain selector reserves the native identity while reconciliation and @@ -512,7 +525,8 @@ export async function resolveCodexAuthContext( if (isCodexAccountPaused(config, accountId)) { throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); } - if (isAccountNeedsReauth(accountId)) { + if (isAccountNeedsReauth(accountId) + && !(accountId === MAIN_CODEX_ACCOUNT_ID && requestScopedMainCredential)) { throw new CodexPoolAuthenticationError("Selected Codex account needs reauthentication"); } if (!isCodexAccountUsable(config, accountId, selectionOptions)) { @@ -561,6 +575,19 @@ export async function resolveCodexAuthContext( } if (accountId === MAIN_CODEX_ACCOUNT_ID) { + if (requestScopedMainCredential) { + return { + kind: "main-pool", + accountId, + writerGeneration, + credentialSource: "caller", + ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), + ...(affinityKey ? { affinityKey } : {}), + ...(quotaScope ? { quotaScope } : {}), + ...(probeLeaseId ? { probeLeaseId } : {}), + ...(probeQuotaScope ? { probeQuotaScope } : {}), + }; + } // Main account in rotation: inject the read-only auth.json token and fail closed if it vanished. const token = (options.getMainAccountToken ?? getMainAccountToken)(); if (!token) { @@ -626,6 +653,7 @@ export function applyCodexAuthContextToProvider( mode: CodexAccountMode | undefined, ): OcxRuntimeProviderConfig { if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider; + if (ctx.kind === "main-pool" && ctx.credentialSource === "caller") return provider; return { ...provider, _codexAccountOverride: { @@ -667,11 +695,17 @@ export function materializeCodexUpstreamAuth( const value = headers.get(name); if (value) selected.set(name, value); } - if (ctx.kind === "pool" || ctx.kind === "main-pool") { + if (ctx.kind === "pool" || (ctx.kind === "main-pool" && ctx.credentialSource !== "caller")) { selected.set("authorization", `Bearer ${ctx.accessToken}`); selected.set("chatgpt-account-id", ctx.chatgptAccountId); return selected; } + if (ctx.kind === "main-pool" && ctx.credentialSource === "caller" + && !selected.has("chatgpt-account-id")) { + const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const accountId = bearer ? extractAccountId(undefined, bearer) : undefined; + if (accountId) selected.set("chatgpt-account-id", accountId); + } if (ctx.kind === "main" && options.substituteMainCredential === true) { const stored = getMainAccountToken(); // Fail BEFORE any upstream I/O. Falling through here would send the admission secret. @@ -692,6 +726,7 @@ export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthConte export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean { if (ctx.kind === "main") return true; + if (ctx.kind === "main-pool" && ctx.credentialSource === "caller") return true; if (ctx.kind === "main-pool") return isCodexAccountUsable(config, ctx.accountId); return isCodexAccountUsable(config, ctx.accountId) && isCodexAccountGenerationLive(ctx.accountId, ctx.generation); } diff --git a/src/codex/routing.ts b/src/codex/routing.ts index b1d5a26b01..abcda23d68 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1023,7 +1023,8 @@ function getEligiblePoolAccounts( if ( excludeId !== MAIN_CODEX_ACCOUNT_ID && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) - && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) + && (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) + || selectionOptions?.requestScopedMainCredential === true) && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) && (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now)) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0d62f232c9..dde87eb3a0 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1,4 +1,5 @@ import { timingSafeEqual } from "node:crypto"; +import { extractAccountId } from "../oauth/chatgpt"; import { formatErrorResponse } from "../bridge"; import { codexAutoStartEnabled, @@ -431,6 +432,14 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError(); } +/** Whether Authorization carries a caller-owned native Codex credential safe to forward. */ +export function hasForwardableCodexBearer(headers: Headers, config: OcxConfig): boolean { + const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const accountId = headers.get("chatgpt-account-id")?.trim() + || (bearer ? extractAccountId(undefined, bearer) : undefined); + return !!bearer && !!accountId && !isProxyAdmissionSecret(bearer, config); +} + /** * Resolving form of `hasValidApiAuth`: identical header precedence, identical * decision, but it names the admission instead of collapsing it to a boolean. diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 4416173312..fa432fe13a 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -74,7 +74,11 @@ import { upstreamHostHealthKey, type UpstreamHostAdmissionLease, } from "../../codex/upstream-host-health"; -import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; +import { + ForwardAdmissionCredentialError, + hasForwardableCodexBearer, + validateForwardAdmissionCredential, +} from "../auth-cors"; import type { DataPlaneAdmission } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; @@ -165,6 +169,7 @@ async function resolveAlternateCompactContext(args: { const authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { ...(selectedModelId ? { modelId: selectedModelId } : {}), excludeAccountId, + requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config), beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease), }); if (!authCtx.accountId || authCtx.accountId === excludeAccountId) return null; @@ -326,6 +331,9 @@ export async function handleResponsesCompact( // consume that credential. See the longer note in core.ts resolveResponsesCodexAuth. const substituteMainCredential = admission?.source === "bearer" && route.codexAccountMode !== undefined; + const requestScopedMainCredential = route.codexAccountMode !== undefined + && !substituteMainCredential + && hasForwardableCodexBearer(req.headers, config); if (route.codexAccountMode === "direct" && !substituteMainCredential) { try { validateForwardAdmissionCredential(req.headers, config); } catch (err) { @@ -372,6 +380,7 @@ export async function handleResponsesCompact( accountId: route.codexAccountId, modelId: selectedModelId, substituteMainCredentialForDirect: substituteMainCredential, + requestScopedMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease), }); logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 561cd72d70..ee415430fa 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -172,7 +172,11 @@ import { fetchWithTransientRetry, prepareSameTarget429Wait, } from "../../lib/upstream-retry"; -import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; +import { + ForwardAdmissionCredentialError, + hasForwardableCodexBearer, + validateForwardAdmissionCredential, +} from "../auth-cors"; import type { DataPlaneAdmission } from "../auth-cors"; import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; @@ -484,11 +488,15 @@ function bindRouteReasoningReplayScope(args: { || args.codexAuthContext?.kind === "main-pool" ? args.codexAuthContext : undefined; + const storedPoolContext = poolContext?.kind === "main-pool" + && poolContext.credentialSource === "caller" + ? undefined + : poolContext; credentialIdentity = reasoningReplayCodexCredentialIdentity({ - authorization: poolContext - ? `Bearer ${poolContext.accessToken}` + authorization: storedPoolContext + ? `Bearer ${storedPoolContext.accessToken}` : args.forwardHeaders?.get("authorization"), - chatgptAccountId: poolContext?.chatgptAccountId + chatgptAccountId: storedPoolContext?.chatgptAccountId ?? args.forwardHeaders?.get("chatgpt-account-id"), accountId: poolContext?.accountId, credentialGeneration: poolContext?.kind === "pool" @@ -503,7 +511,7 @@ function bindRouteReasoningReplayScope(args: { // refused, so direct-forward turns get no durable scope (fail closed; the in-process // cache still covers same-process replay). const codexDurableHandle = poolContext?.accountId - ?? poolContext?.chatgptAccountId + ?? storedPoolContext?.chatgptAccountId ?? undefined; credentialDurableIdentity = durableReplayCredentialIdentity( "codex", @@ -1075,6 +1083,7 @@ async function retryCodexPoolOnAlternateAccount( { excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId, + requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config), beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), resolveCodexModelEntitlements: entitlementResolver, }, @@ -1655,6 +1664,9 @@ async function resolveResponsesCodexAuth( // no-ChatGPT-login install keeps working. const substituteMainCredential = options.admission?.source === "bearer" && (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider)); + const requestScopedMainCredential = route.codexAccountMode !== undefined + && !substituteMainCredential + && hasForwardableCodexBearer(req.headers, config); if (route.codexAccountMode === "direct" && !substituteMainCredential) { validateForwardAdmissionCredential(req.headers, config); } @@ -1664,6 +1676,7 @@ async function resolveResponsesCodexAuth( accountId: route.codexAccountId, modelId: route.modelId, substituteMainCredentialForDirect: substituteMainCredential, + requestScopedMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), resolveCodexModelEntitlements: options.resolveCodexModelEntitlements, }); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 6135066cdb..3fbaa8fc36 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -71,6 +71,7 @@ import { tryAdmitTurn, } from "../src/server/lifecycle"; import type { CodexModelEntitlementSnapshot } from "../src/codex/model-entitlements"; +import { hasForwardableCodexBearer } from "../src/server/auth-cors"; let testDir: string; let previousOpencodexHome: string | undefined; @@ -1018,6 +1019,43 @@ describe("Codex auth context", () => { }); expect(cfg.activeCodexAccountId).toBe("pool-a"); }); + + test("uses a validated native caller bearer for main without persisting it", async () => { + const cfg = config(); + cfg.codexAccounts = []; + cfg.activeCodexAccountId = undefined; + const inbound = new Headers({ + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + "openai-beta": "responses=experimental", + }); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + + try { + expect(hasForwardableCodexBearer(inbound, cfg)).toBe(true); + expect(hasForwardableCodexBearer(new Headers({ + authorization: ["Bearer ocx", "data", "not-forwardable"].join("_"), + "chatgpt-account-id": "caller-keyring-account", + }), cfg)).toBe(false); + const ctx = await resolveCodexAuthContext(inbound, cfg, "pool", { + requestScopedMainCredential: true, + }); + expect(ctx).toMatchObject({ + kind: "main-pool", + accountId: MAIN_CODEX_ACCOUNT_ID, + credentialSource: "caller", + }); + expect(ctx).not.toHaveProperty("accessToken"); + expect(ctx).not.toHaveProperty("chatgptAccountId"); + + const upstream = materializeCodexUpstreamAuth(inbound, ctx); + expect(upstream.get("authorization")).toBe("Bearer caller-keyring-token"); + expect(upstream.get("chatgpt-account-id")).toBe("caller-keyring-account"); + expect(upstream.get("openai-beta")).toBe("responses=experimental"); + } finally { + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + } + }); test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index c9bec1df66..4840b80e1a 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -1512,6 +1512,39 @@ describe("server local API auth", () => { clearCodexUpstreamHealth(); rmSync(join(isolatedCodexHome!.path, "auth.json"), { force: true }); + const nativeCallerConfig = { + ...mainOnlyConfig(), + hostname: "0.0.0.0", + } as OcxConfig; + saveConfig(nativeCallerConfig); + const beforeNativeCaller = seen.length; + const nativeCaller = startServer(0, { inspectNativeCodexOwnership }); + try { + await waitForNativeMainStartupGate(); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + + expect((await request(nativeCaller, { + authorization: "Bearer local-secret", + "chatgpt-account-id": "must-not-forward", + })).status).toBe(401); + expect(seen).toHaveLength(beforeNativeCaller); + + const nativeHeaders = { + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }; + expect((await request(nativeCaller, nativeHeaders)).status).toBe(200); + expect((await compact(nativeCaller, nativeHeaders)).status).toBe(200); + expect(seen.slice(beforeNativeCaller)).toEqual(Array.from({ length: 2 }, () => ({ + host: "chatgpt.com", + authorization: "Bearer caller-keyring-token", + chatgptAccountId: "caller-keyring-account", + }))); + } finally { + await nativeCaller.stop(true); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + } + saveConfig({ port: 0, hostname: "0.0.0.0", From 9289c4d7ca1554e32dfa623e7c21355db15fc4cd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 29 Aug 2026 05:42:12 +0900 Subject: [PATCH 2/3] fix(codex): isolate request-owned credentials Keep caller-owned Codex bearers outside persisted Pool identity and state so their failures cannot poison the file-backed main account. Bind process-local reasoning replay to each bearer while refusing a durable caller identity. --- src/codex/account-usability.ts | 3 - src/codex/auth-context.ts | 49 +++++----------- src/codex/routing.ts | 3 +- src/server/responses/core.ts | 12 ++-- tests/codex-auth-context.test.ts | 8 ++- tests/reasoning-replay-identity.test.ts | 18 ++++++ tests/server-auth.test.ts | 74 +++++++++++++++++++++++-- 7 files changed, 112 insertions(+), 55 deletions(-) diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index 5c1bd0745a..d508e19f4d 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -10,8 +10,6 @@ export interface CodexAccountUsabilityOptions { nativeMainSelectionOnly?: boolean; /** Test seam for proving whether routing attempted a physical native-token read. */ isMainAccountTokenLive?: typeof isMainAccountTokenLive; - /** A validated native Codex bearer is available for this request only. */ - requestScopedMainCredential?: boolean; /** Confirmed account ids for an account-gated model; omitted for ordinary native models. */ modelEligibleAccountIds?: ReadonlySet; } @@ -29,7 +27,6 @@ export function isCodexAccountUsable( // A legacy pool row with the sentinel makes an active `__main__` ambiguous. // Fail closed until the authenticated compatibility-delete path removes it. if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false; - if (options.requestScopedMainCredential) return true; if (isAccountNeedsReauth(accountId)) return false; // A selection-only caller owns the recovery/drain fence and will reject main // before reservation or token materialization. Treat cached main as a routing diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index b50dc00ba2..4c44fed2af 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -96,12 +96,14 @@ export type CodexAuthContext = /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */ probeQuotaScope?: CodexQuotaScope; } - | ({ + | { // Main Codex account participating in rotation: token injected from ~/.codex/auth.json - // or forwarded from this native Codex request. Distinct from "main" (Direct mode). + // (Option A). Distinct from "main" (request-owned passthrough or Direct mode). kind: "main-pool"; accountId: string; writerGeneration: number; + accessToken: string; + chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ fixedAccount?: boolean; /** See `pool.affinityKey`. */ @@ -110,16 +112,7 @@ export type CodexAuthContext = probeLeaseId?: string; quotaScope?: CodexQuotaScope; probeQuotaScope?: CodexQuotaScope; - } & ( - | { - credentialSource?: "auth-file"; - accessToken: string; - chatgptAccountId: string; - } - | { - credentialSource: "caller"; - } - )); + }; /** Probe lease carried by this context, when it holds one. */ export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | undefined { @@ -344,7 +337,7 @@ export interface ResolveCodexAuthContextOptions { 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 `__main__` for this request only. */ + /** 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; @@ -369,9 +362,14 @@ export async function resolveCodexAuthContext( if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); } + const useCallerCredential = (mode === "direct" && fixedAccountId === undefined) + || (requestScopedMainCredential + && (fixedAccountId === undefined || fixedAccountId === MAIN_CODEX_ACCOUNT_ID)); // 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. - if (mode === "direct" && fixedAccountId === undefined) { + // 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 (useCallerCredential) { if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); const substituteStoredMain = options.substituteMainCredentialForDirect === true; if (!substituteStoredMain) { @@ -446,7 +444,6 @@ export async function resolveCodexAuthContext( // it. Retained recovery makes main wholly ineligible so pool routing continues. nativeMainSelectionOnly, isMainAccountTokenLive: options.isMainAccountTokenLive, - requestScopedMainCredential, modelEligibleAccountIds, }; // A pre-drain selector reserves the native identity while reconciliation and @@ -525,8 +522,7 @@ export async function resolveCodexAuthContext( if (isCodexAccountPaused(config, accountId)) { throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); } - if (isAccountNeedsReauth(accountId) - && !(accountId === MAIN_CODEX_ACCOUNT_ID && requestScopedMainCredential)) { + if (isAccountNeedsReauth(accountId)) { throw new CodexPoolAuthenticationError("Selected Codex account needs reauthentication"); } if (!isCodexAccountUsable(config, accountId, selectionOptions)) { @@ -575,19 +571,6 @@ export async function resolveCodexAuthContext( } if (accountId === MAIN_CODEX_ACCOUNT_ID) { - if (requestScopedMainCredential) { - return { - kind: "main-pool", - accountId, - writerGeneration, - credentialSource: "caller", - ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), - ...(affinityKey ? { affinityKey } : {}), - ...(quotaScope ? { quotaScope } : {}), - ...(probeLeaseId ? { probeLeaseId } : {}), - ...(probeQuotaScope ? { probeQuotaScope } : {}), - }; - } // Main account in rotation: inject the read-only auth.json token and fail closed if it vanished. const token = (options.getMainAccountToken ?? getMainAccountToken)(); if (!token) { @@ -653,7 +636,6 @@ export function applyCodexAuthContextToProvider( mode: CodexAccountMode | undefined, ): OcxRuntimeProviderConfig { if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider; - if (ctx.kind === "main-pool" && ctx.credentialSource === "caller") return provider; return { ...provider, _codexAccountOverride: { @@ -695,12 +677,12 @@ export function materializeCodexUpstreamAuth( const value = headers.get(name); if (value) selected.set(name, value); } - if (ctx.kind === "pool" || (ctx.kind === "main-pool" && ctx.credentialSource !== "caller")) { + if (ctx.kind === "pool" || ctx.kind === "main-pool") { selected.set("authorization", `Bearer ${ctx.accessToken}`); selected.set("chatgpt-account-id", ctx.chatgptAccountId); return selected; } - if (ctx.kind === "main-pool" && ctx.credentialSource === "caller" + if (ctx.kind === "main" && options.substituteMainCredential !== true && !selected.has("chatgpt-account-id")) { const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); const accountId = bearer ? extractAccountId(undefined, bearer) : undefined; @@ -726,7 +708,6 @@ export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthConte export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean { if (ctx.kind === "main") return true; - if (ctx.kind === "main-pool" && ctx.credentialSource === "caller") return true; if (ctx.kind === "main-pool") return isCodexAccountUsable(config, ctx.accountId); return isCodexAccountUsable(config, ctx.accountId) && isCodexAccountGenerationLive(ctx.accountId, ctx.generation); } diff --git a/src/codex/routing.ts b/src/codex/routing.ts index abcda23d68..b1d5a26b01 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1023,8 +1023,7 @@ function getEligiblePoolAccounts( if ( excludeId !== MAIN_CODEX_ACCOUNT_ID && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) - && (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) - || selectionOptions?.requestScopedMainCredential === true) + && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) && (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now)) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ee415430fa..b450f0ed4e 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -488,15 +488,11 @@ function bindRouteReasoningReplayScope(args: { || args.codexAuthContext?.kind === "main-pool" ? args.codexAuthContext : undefined; - const storedPoolContext = poolContext?.kind === "main-pool" - && poolContext.credentialSource === "caller" - ? undefined - : poolContext; credentialIdentity = reasoningReplayCodexCredentialIdentity({ - authorization: storedPoolContext - ? `Bearer ${storedPoolContext.accessToken}` + authorization: poolContext + ? `Bearer ${poolContext.accessToken}` : args.forwardHeaders?.get("authorization"), - chatgptAccountId: storedPoolContext?.chatgptAccountId + chatgptAccountId: poolContext?.chatgptAccountId ?? args.forwardHeaders?.get("chatgpt-account-id"), accountId: poolContext?.accountId, credentialGeneration: poolContext?.kind === "pool" @@ -511,7 +507,7 @@ function bindRouteReasoningReplayScope(args: { // refused, so direct-forward turns get no durable scope (fail closed; the in-process // cache still covers same-process replay). const codexDurableHandle = poolContext?.accountId - ?? storedPoolContext?.chatgptAccountId + ?? poolContext?.chatgptAccountId ?? undefined; credentialDurableIdentity = durableReplayCredentialIdentity( "codex", diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 3fbaa8fc36..eec919927e 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -1041,17 +1041,19 @@ describe("Codex auth context", () => { requestScopedMainCredential: true, }); expect(ctx).toMatchObject({ - kind: "main-pool", - accountId: MAIN_CODEX_ACCOUNT_ID, - credentialSource: "caller", + kind: "main", + accountId: null, }); expect(ctx).not.toHaveProperty("accessToken"); expect(ctx).not.toHaveProperty("chatgptAccountId"); + expect(ctx).not.toHaveProperty("affinityKey"); + expect(ctx).not.toHaveProperty("writerGeneration"); const upstream = materializeCodexUpstreamAuth(inbound, ctx); expect(upstream.get("authorization")).toBe("Bearer caller-keyring-token"); expect(upstream.get("chatgpt-account-id")).toBe("caller-keyring-account"); expect(upstream.get("openai-beta")).toBe("responses=experimental"); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); } finally { clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); } diff --git a/tests/reasoning-replay-identity.test.ts b/tests/reasoning-replay-identity.test.ts index 9737895b6a..659d4c9cde 100644 --- a/tests/reasoning-replay-identity.test.ts +++ b/tests/reasoning-replay-identity.test.ts @@ -4,6 +4,7 @@ import { bindReasoningReplayScope, clearReasoningReplayCacheForTests, commitReasoningReplayServingIdentity, + durableReplayCredentialIdentity, peekReasoningForCall, reasoningReplayCodexCredentialIdentity, reasoningReplayCredentialIdentity, @@ -348,6 +349,23 @@ describe("reasoning replay provider and credential identity", () => { expect(destination).not.toContain("opaque-secret"); }); + test("request-owned Codex bearers are distinct in process and refuse durable replay identity", () => { + const callerA = reasoningReplayCodexCredentialIdentity({ + authorization: "Bearer caller-token-a", + chatgptAccountId: "caller-account-a", + }); + const callerB = reasoningReplayCodexCredentialIdentity({ + authorization: "Bearer caller-token-b", + chatgptAccountId: "caller-account-b", + }); + + expect(callerA).toBeDefined(); + expect(callerB).toBeDefined(); + expect(callerA).not.toBe(callerB); + expect(durableReplayCredentialIdentity("codex", undefined, undefined, Buffer.alloc(32, 7))) + .toBeUndefined(); + }); + test("a bridge created before credential rotation writes under the holder's current identity", async () => { const oldScope = scope(); rememberReasoningForCall(CALL_ID, "old reasoning", oldScope); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 4840b80e1a..081211a242 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -1353,12 +1353,33 @@ describe("server local API auth", () => { const upstream = Bun.serve({ port: 0, fetch(req) { - seen.push({ + const observed = { host: req.headers.get("x-test-original-host") ?? "", authorization: req.headers.get("authorization"), chatgptAccountId: req.headers.get("chatgpt-account-id"), - }); - return Response.json({ id: "resp_tier", object: "response", status: "completed", output: [] }); + }; + seen.push(observed); + const status = observed.authorization === "Bearer caller-invalid-401" + ? 401 + : observed.authorization === "Bearer caller-invalid-403" + ? 403 + : observed.authorization === "Bearer caller-quota-429" + ? 429 + : observed.authorization === "Bearer caller-transient-500" + ? 500 + : 200; + const quotaHeaders = observed.authorization === "Bearer caller-quota-headers" + || observed.authorization === "Bearer caller-quota-429" + ? { + "x-codex-primary-used-percent": "100", + "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-at": "1900000000", + } + : undefined; + return Response.json( + { id: "resp_tier", object: "response", status: "completed", output: [] }, + { status, headers: quotaHeaders }, + ); }, }); let whamRequests = 0; @@ -1510,6 +1531,7 @@ describe("server local API auth", () => { } clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); clearCodexUpstreamHealth(); + clearAccountQuota(); rmSync(join(isolatedCodexHome!.path, "auth.json"), { force: true }); const nativeCallerConfig = { @@ -1521,28 +1543,70 @@ describe("server local API auth", () => { const nativeCaller = startServer(0, { inspectNativeCodexOwnership }); try { await waitForNativeMainStartupGate(); - markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); expect((await request(nativeCaller, { authorization: "Bearer local-secret", "chatgpt-account-id": "must-not-forward", })).status).toBe(401); expect(seen).toHaveLength(beforeNativeCaller); + writeMainToken("opaque-file-main-token"); + + const fileMainBaseline = { + reauth: isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID), + quota: structuredClone(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)), + health: structuredClone(getCodexUpstreamHealth(MAIN_CODEX_ACCOUNT_ID)), + active: loadConfig().activeCodexAccountId, + }; + const expectFileMainUnchanged = () => { + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(fileMainBaseline.reauth); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)).toEqual(fileMainBaseline.quota); + expect(getCodexUpstreamHealth(MAIN_CODEX_ACCOUNT_ID)).toEqual(fileMainBaseline.health); + expect(loadConfig().activeCodexAccountId).toBe(fileMainBaseline.active); + }; + const isolatedCallerFailures = [ + ["caller-invalid-401", 401], + ["caller-invalid-403", 403], + ["caller-quota-429", 429], + ["caller-transient-500", 500], + ] as const; + for (const [token, status] of isolatedCallerFailures) { + const headers = { + authorization: `Bearer ${token}`, + "chatgpt-account-id": `${token}-account`, + }; + expect((await request(nativeCaller, headers)).status).toBe(status); + expect((await compact(nativeCaller, headers)).status).toBe(status); + expect(await wsTurn(nativeCaller, headers)).toContain(String(status)); + expectFileMainUnchanged(); + } + const quotaOnlyHeaders = { + authorization: "Bearer caller-quota-headers", + "chatgpt-account-id": "caller-quota-headers-account", + }; + expect((await request(nativeCaller, quotaOnlyHeaders)).status).toBe(200); + expect((await compact(nativeCaller, quotaOnlyHeaders)).status).toBe(200); + expect(await wsTurn(nativeCaller, quotaOnlyHeaders)).toContain("resp_tier"); + expectFileMainUnchanged(); + + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const nativeHeaders = { authorization: "Bearer caller-keyring-token", "chatgpt-account-id": "caller-keyring-account", }; + const beforeHealthyNativeCaller = seen.length; expect((await request(nativeCaller, nativeHeaders)).status).toBe(200); expect((await compact(nativeCaller, nativeHeaders)).status).toBe(200); - expect(seen.slice(beforeNativeCaller)).toEqual(Array.from({ length: 2 }, () => ({ + expect(seen.slice(beforeHealthyNativeCaller)).toEqual(Array.from({ length: 2 }, () => ({ host: "chatgpt.com", authorization: "Bearer caller-keyring-token", chatgptAccountId: "caller-keyring-account", }))); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); } finally { await nativeCaller.stop(true); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + rmSync(join(isolatedCodexHome!.path, "auth.json"), { force: true }); } saveConfig({ From a0ae971375cc0cc9b7d1b6b6f89f36b71af6a6b6 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 29 Aug 2026 05:50:53 +0900 Subject: [PATCH 3/3] fix(codex): preserve stored pool selection Use a request-owned bearer only when no stored pool credential is selected, while keeping it outside file-main affinity and state. This preserves candidate-scope recovery previews without restoring shared caller state. --- src/codex/auth-context.ts | 31 +++++++++++++++++++++---------- tests/server-auth.test.ts | 4 +++- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 4c44fed2af..5f6a54f6d8 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -362,14 +362,7 @@ export async function resolveCodexAuthContext( if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); } - const useCallerCredential = (mode === "direct" && fixedAccountId === undefined) - || (requestScopedMainCredential - && (fixedAccountId === undefined || fixedAccountId === MAIN_CODEX_ACCOUNT_ID)); - // 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 (useCallerCredential) { + const resolveCallerOwnedMainContext = async (): Promise => { if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); const substituteStoredMain = options.substituteMainCredentialForDirect === true; if (!substituteStoredMain) { @@ -415,8 +408,21 @@ export async function resolveCodexAuthContext( // the enclosing turn lease until the request or transferred stream settles. directSelectionAdmission.release(); } + }; + // 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 ((mode === "direct" && fixedAccountId === undefined) + || (requestScopedMainCredential && fixedAccountId === MAIN_CODEX_ACCOUNT_ID)) { + return resolveCallerOwnedMainContext(); } - const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined; + // 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 = fixedAccountId === undefined && !requestScopedMainCredential + ? codexPoolAffinityKey(headers) + : undefined; // Retained startup recovery makes the physical main identity ineligible. Routing // can still preserve service by selecting a healthy configured pool account. const nativeMainTrafficBlocked = isNativeMainTrafficBlocked(); @@ -443,7 +449,9 @@ export async function resolveCodexAuthContext( // Temporary switch drain keeps the candidate until the atomic claim rejects // it. Retained recovery makes main wholly ineligible so pool routing continues. nativeMainSelectionOnly, - isMainAccountTokenLive: options.isMainAccountTokenLive, + isMainAccountTokenLive: requestScopedMainCredential + ? () => false + : options.isMainAccountTokenLive, modelEligibleAccountIds, }; // A pre-drain selector reserves the native identity while reconciliation and @@ -476,6 +484,9 @@ export async function resolveCodexAuthContext( if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); const selected = resolution.status === "selected" ? resolution.accountId : null; if (!selected) { + if (requestScopedMainCredential && fixedAccountId === undefined && !options.excludeAccountId) { + return await resolveCallerOwnedMainContext(); + } if (fixedAccountId !== undefined) { throw new CodexPoolAuthenticationError( modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId) diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 081211a242..f53fdaf2e2 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -1375,7 +1375,9 @@ describe("server local API auth", () => { "x-codex-primary-window-minutes": "300", "x-codex-primary-reset-at": "1900000000", } - : undefined; + : observed.authorization === "Bearer caller-transient-500" + ? { "retry-after": "0" } + : undefined; return Response.json( { id: "resp_tier", object: "response", status: "completed", output: [] }, { status, headers: quotaHeaders },