diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index dcf9bb88df..5f6a54f6d8 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); @@ -97,7 +98,7 @@ export type CodexAuthContext = } | { // Main Codex account participating in rotation: token injected from ~/.codex/auth.json - // (Option A). Distinct from "main" (passthrough fallback that forwards the client token). + // (Option A). Distinct from "main" (request-owned passthrough or Direct mode). kind: "main-pool"; accountId: string; writerGeneration: number; @@ -336,6 +337,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 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; } @@ -353,13 +356,13 @@ 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"); } - // 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) { + const resolveCallerOwnedMainContext = async (): Promise => { if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); const substituteStoredMain = options.substituteMainCredentialForDirect === true; if (!substituteStoredMain) { @@ -405,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(); @@ -433,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 @@ -466,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) @@ -672,6 +693,12 @@ export function materializeCodexUpstreamAuth( selected.set("chatgpt-account-id", ctx.chatgptAccountId); return selected; } + 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; + 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. 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..b450f0ed4e 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"; @@ -1075,6 +1079,7 @@ async function retryCodexPoolOnAlternateAccount( { excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId, + requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config), beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), resolveCodexModelEntitlements: entitlementResolver, }, @@ -1655,6 +1660,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 +1672,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..eec919927e 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,45 @@ 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", + 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); + } + }); test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", 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 c9bec1df66..f53fdaf2e2 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -1353,12 +1353,35 @@ 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", + } + : observed.authorization === "Bearer caller-transient-500" + ? { "retry-after": "0" } + : undefined; + return Response.json( + { id: "resp_tier", object: "response", status: "completed", output: [] }, + { status, headers: quotaHeaders }, + ); }, }); let whamRequests = 0; @@ -1510,8 +1533,84 @@ describe("server local API auth", () => { } clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); clearCodexUpstreamHealth(); + clearAccountQuota(); 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(); + + 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(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({ port: 0, hostname: "0.0.0.0",