From 1457884fbcf902137dd50944bdd8dd44b6769bc2 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:23:43 +0900 Subject: [PATCH 1/3] fix(codex): fall back to caller main during pool cooldown Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> Carried-from: lidge-jun/opencodex#3997 (094e509f042f573cf4104d91562c249b2310cb0c) --- .../content/docs/guides/codex-integration.md | 7 ++++ .../docs/ko/guides/codex-integration.md | 6 +++ src/codex/auth-context.ts | 7 ++++ .../codex-auth-context.test.ts | 39 +++++++++++++++++++ .../main-account-hard-lock-auth.test.ts | 30 +++++++++++++- 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 5b75a21b41..fe190e4db2 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -20,6 +20,13 @@ plus `openai-apikey/` for the configured API key. Pool includes main plus Direct uses only the caller/main bearer. The routes do not fall back to one another. Shipped v1 configs migrate to marker 2 and preserve `config.json.pre-openai-tiers-v2.bak` for manual restore. +Within Pool mode, a request carrying a validated native Codex login can use that login when the +selected stored account is cooling down and no eligible stored alternative or recovery probe is +available. This also covers a new request blocked before sending, following the same caller +validation used after an upstream rejection. Existing model-permission and main-account policy +checks still apply. The fallback preserves the stored account's cooldown and does not persist the +caller credential as the Pool selection. An exact account binding remains bound to that account. + ## Config injection `ocx init`, `ocx start`, and `ocx sync` call the injector. On the default loopback bind, it keeps diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index ea7ff40cde..976da6c96a 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -7,6 +7,12 @@ opencodex는 Codex가 읽는 두 가지, 즉 설정(`$CODEX_HOME/config.toml`, 프록시는 bare `openai` Codex 로그인 경로 하나와 Pool(기본) 및 Direct 계정 모드, 그리고 설정된 API 키용 `openai-apikey/`을 제공합니다. Pool은 메인 계정과 추가된 계정을 포함하고, Direct는 호출자/메인 bearer만 사용합니다. 경로들은 서로 fallback하지 않습니다. shipped v1 config는 marker 2로 이관되며, 수동 복원을 위해 `config.json.pre-openai-tiers-v2.bak`를 보존합니다. +Pool 모드에서는 선택된 저장 계정이 쿨다운 중이고 사용 가능한 다른 저장 계정이나 복구 probe가 +없을 때, 요청에 포함된 검증된 native Codex 로그인을 사용할 수 있습니다. 상류 거절 후 재시도와 +같은 호출자 검증을 적용하므로, 전송 전에 막힌 새 요청도 이 경로를 사용할 수 있습니다. 기존 모델 +권한과 main 계정 정책 검사는 유지됩니다. 이 fallback은 저장 계정의 쿨다운을 해제하거나 호출자 +인증을 Pool 선택으로 저장하지 않습니다. 특정 계정에 정확히 고정된 요청은 그 계정에 계속 묶입니다. + ## 설정 주입 `ocx init`, `ocx start`, `ocx sync`는 모두 인젝터를 호출합니다. 기본 loopback 바인드에서는 Codex의 빌트인 `openai` 프로바이더 id를 그대로 유지한 채, 그 프로바이더가 opencodex를 바라보게 합니다. diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index c3de264721..6da759affe 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -894,6 +894,13 @@ export async function resolveCodexAuthContext( ? 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 + && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID) { + return await resolveCallerOwnedMainContext(); + } throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope); } } diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 73417b85f7..673655532b 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -55,6 +55,7 @@ import { CODEX_QUOTA_PROBE_INTERVAL_MS, clearCodexUpstreamHealth, clearThreadAccountMap, + getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome, resetCodexRoutingForManualSelection, } from "../../src/codex/routing"; @@ -1262,6 +1263,44 @@ describe("Codex auth context", () => { expect(directEntitlementChecks).toBe(1); }); + test("a fresh request can reuse caller main after the selected Pool account enters cooldown", async () => { + const cfg = { ...config(), autoSwitchThreshold: 0 }; + const now = 1_800_000_000_000; + const originalNow = Date.now; + const inbound = new Headers({ + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_token", refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc", + }); + try { + Date.now = () => now; + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("pool-a", "shared"); + expect(cooldown).not.toBeNull(); + Date.now = () => now + 1_000; + const options = { requestScopedMainCredential: true, modelId: "gpt-5.6-terra" }; + await expect(resolveCodexAuthContext(inbound, cfg, "pool", { + ...options, excludeAccountId: "pool-a", + })).resolves.toMatchObject({ kind: "main", accountId: null }); + + const context = await resolveCodexAuthContext(inbound, cfg, "pool", options); + expect(context).toMatchObject({ kind: "main", accountId: null }); + const forwarded = headersForCodexAuthContext(inbound, context); + expect(forwarded.get("authorization")).toBe("Bearer caller-keyring-token"); + expect(forwarded.get("chatgpt-account-id")).toBe("caller-keyring-account"); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(cfg.activeCodexAccountPinned).toBeUndefined(); + expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown); + } finally { + Date.now = originalNow; + } + }); + test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", diff --git a/tests/codex-integration/main-account-hard-lock-auth.test.ts b/tests/codex-integration/main-account-hard-lock-auth.test.ts index d5e2ac70f9..c3caaabe2c 100644 --- a/tests/codex-integration/main-account-hard-lock-auth.test.ts +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -26,7 +26,7 @@ import { observeMainQuotaIdentity, } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; -import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth } from "../../src/codex/routing"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../../src/providers/openai-sidecar"; import { mapCodexAuthContextErrorToResponse } from "../../src/server/responses/codex-auth-error"; import { handleResponses } from "../../src/server/responses/core"; @@ -343,6 +343,34 @@ describe("main quota policy at native admission", () => { })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); }); + for (const percent of [98.99, 99]) { + test(`Pool cooldown caller fallback keeps the main ${percent}% policy boundary`, async () => { + const cfg = config(); + addAlternative(cfg); + cfg.activeCodexAccountId = "hard-lock-pool"; + observeMainQuotaCredential(bearer(), accountId); + quota(percent); + const now = Date.now(); + recordCodexUpstreamOutcome(cfg, "hard-lock-pool", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("hard-lock-pool", "shared"); + expect(cooldown).not.toBeNull(); + spyOn(Date, "now").mockReturnValue(now + 1_000); + forbidPhysicalReads(); + const context = resolveCodexAuthContext(caller(), cfg, "pool", { + requestScopedMainCredential: true, modelId: "gpt-5.6-terra", + }); + if (percent < 99) { + await expect(context).resolves.toMatchObject({ kind: "main", accountId: null }); + } else { + await expect(context).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + } + expect(cfg.activeCodexAccountId).toBe("hard-lock-pool"); + expect(getCodexQuotaHealthSnapshot("hard-lock-pool", "shared")).toEqual(cooldown); + }); + } + test("unmatched, spoofed-claim, and conflicting-workspace callers do not inherit main policy", async () => { observeMainQuotaCredential(bearer(), accountId); quota(99); From e282d4d749019fd9416748f7f48342c3fbc020b2 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 9 Sep 2026 18:12:55 +0900 Subject: [PATCH 2/3] fix(codex): keep the cooled subscription out of its own caller-main fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The early-cooldown fallback could serve the very subscription that is cooling down when the caller's keyring login is also registered as a Pool account. Guard it with a fail-closed identity ladder: a distinct workspace account id always passes; the exact materialized bearer+account tuple or a matching (account id, email) pair for a rotated token denies; a different email on a shared workspace account id is a distinct team member and passes; an unreadable identity fails closed. No physical-main read, no caller persistence, no cooldown or probe-lease changes. Same-email personal/business coexistence over-denies during the cooldown — the safe direction. Review-finding correction on the carried #3997 (maintainer commit). --- src/codex/auth-context.ts | 43 +++++++++++++-- .../codex-auth-context.test.ts | 54 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 6da759affe..1286a92895 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,9 +1,10 @@ -import { createHmac, randomBytes } from "node:crypto"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, + getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, } from "./account-store"; @@ -49,7 +50,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"; +import { extractAccountId, extractEmail } from "../oauth/chatgpt"; import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock"; import { captureMainAccountIdentityGeneration, @@ -455,6 +456,41 @@ function callerMatchesObservedMain(headers: Headers): boolean { 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, or a matching (account id, email) pair for a rotated token, marks the + * same subscription; a different email on a shared workspace account id is a distinct team + * member. 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 = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + 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; + 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; +} + function captureObservedMainWriter(): MainQuotaWriter | undefined { const identityKey = getObservedMainQuotaIdentityKey(); return identityKey === undefined ? undefined : { @@ -898,7 +934,8 @@ export async function resolveCodexAuthContext( // 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 - && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID) { + && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID + && !callerIsCooledPoolAccount(headers, config, accountId)) { return await resolveCallerOwnedMainContext(); } throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 673655532b..a014d4f9fd 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -1301,6 +1301,60 @@ describe("Codex auth context", () => { } }); + test("cooldown caller-main fallback never resurrects the cooled subscription", async () => { + const now = 1_800_000_000_000; + const originalNow = Date.now; + const cfg = { ...config(), autoSwitchThreshold: 0 }; + // config() registers pool-a with email pool@example.test and workspace account pool_acc. + const callerJwt = (email?: string) => `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(now / 1000) + 86_400, + ...(email ? { email } : {}), + "https://api.openai.com/auth": { chatgpt_account_id: "pool_acc" }, + })).toString("base64url")}.signature`; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_token", refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc", + }); + try { + Date.now = () => now; + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("pool-a", "shared"); + expect(cooldown).not.toBeNull(); + Date.now = () => now + 1_000; + const options = { requestScopedMainCredential: true, modelId: "gpt-5.6-terra" }; + const resolve = (headers: Headers) => resolveCodexAuthContext(headers, cfg, "pool", options); + + // The cooled account's exact materialized credential cannot use the fallback. + await expect(resolve(new Headers({ + authorization: "Bearer pool_token", "chatgpt-account-id": "pool_acc", + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + // A rotated token of the same account (same workspace id and email) is still that subscription. + await expect(resolve(new Headers({ + authorization: `Bearer ${callerJwt("pool@example.test")}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + // A distinct team member on the shared workspace account id may serve the request. + await expect(resolve(new Headers({ + authorization: `Bearer ${callerJwt("teammate@example.test")}`, + }))).resolves.toMatchObject({ kind: "main", accountId: null }); + // An unreadable caller identity fails closed. + await expect(resolve(new Headers({ + authorization: "Bearer opaque-caller-token", + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + // The workspace account id without a readable email cannot be distinguished: fail closed. + await expect(resolve(new Headers({ + authorization: `Bearer ${callerJwt()}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + + // Nothing mutated the cooldown or the Pool selection. + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown); + } finally { + Date.now = originalNow; + } + }); + test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", From 00b96bba314c65d850835d78da200083782db13a Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 9 Sep 2026 19:21:58 +0900 Subject: [PATCH 3/3] fix(codex): identify the cooled subscription by its stable user id The cooldown guard compared the workspace account id, then the exact bearer material, then the configured email. Email is the wrong last word on identity in both directions: a caller whose token carries no email claim was refused even when its user id proved it was a different member of the same workspace, and the cooled subscription's own rotated token was admitted whenever the recorded email had since changed, which let it skip its own cooldown. Compare the native ChatGPT user identity the reserve path already trusts (chatgpt_user_id then user_id under the OpenAI auth namespace, never sub) whenever both the caller bearer and the cooled account's stored credential carry one: the same id denies across email changes and token rotation, a different id allows, and a credential whose own two encodings disagree identifies nobody and fails closed. Email stays the fallback when no comparable id exists on both sides. reserve-availability now exports those claims; its private userId keeps the raw precedence, so an empty or non-string chatgpt_user_id still blocks the user_id fallback exactly as before and the reserve path is unchanged. Review-finding correction on the carried #3997 (maintainer commit). --- src/codex/auth-context.ts | 23 +++++++-- src/codex/reserve-availability.ts | 26 ++++++++-- .../codex-auth-context.test.ts | 49 +++++++++++++++++++ .../reserve-availability.test.ts | 31 +++++++++++- 4 files changed, 118 insertions(+), 11 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 1286a92895..cccb9e1242 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -63,7 +63,7 @@ import { } 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, type MainReserveAuthorization } from "./reserve-availability"; +import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability"; import { UpstreamRetryEvidenceError } from "../lib/upstream-retry"; const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; @@ -465,10 +465,12 @@ function sameCredentialMaterial(a: string, b: string): boolean { * 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, or a matching (account id, email) pair for a rotated token, marks the - * same subscription; a different email on a shared workspace account id is a distinct team - * member. Coexisting personal/business registrations with the same email and account id - * over-deny during the cooldown — the safe direction. + * 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 = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); @@ -485,6 +487,17 @@ function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountI 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; diff --git a/src/codex/reserve-availability.ts b/src/codex/reserve-availability.ts index 7d03e840ac..b96b9e8153 100644 --- a/src/codex/reserve-availability.ts +++ b/src/codex/reserve-availability.ts @@ -44,14 +44,30 @@ function owned(token: Token, writer: MainQuotaWriter): boolean { function record(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function userId(token: string): string | undefined { +/** + * The ChatGPT per-user identity carried by a native credential, plus whether the token's own two + * encodings of it disagree. Precedence stays on the RAW claims, so an empty or non-string + * `chatgpt_user_id` still blocks the `user_id` fallback exactly as before; `conflict` is a + * separate observation for callers that must fail closed on an ambiguous identity. + */ +export function nativeUserIdClaims(token: string): { userId: string | undefined; conflict: boolean } { + const none = { userId: undefined, conflict: false }; try { const payload: unknown = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")); const auth = record(payload) ? payload["https://api.openai.com/auth"] : undefined; - if (!record(auth)) return; - const value = auth.chatgpt_user_id ?? auth.user_id; - return typeof value === "string" && value.length > 0 ? value : undefined; - } catch { return; } + if (!record(auth)) return none; + const named = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + const primary = named(auth.chatgpt_user_id); + const secondary = named(auth.user_id); + return { + userId: named(auth.chatgpt_user_id ?? auth.user_id), + conflict: primary !== undefined && secondary !== undefined && primary !== secondary, + }; + } catch { return none; } +} +function userId(token: string): string | undefined { + return nativeUserIdClaims(token).userId; } function identityMatches(data: WhamUsageResponse, token: Token): boolean { if (data.account_id != null && data.account_id !== token.chatgptAccountId) return false; diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index a014d4f9fd..ac09216229 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -1355,6 +1355,55 @@ describe("Codex auth context", () => { } }); + test("cooldown caller-main fallback follows the stable user id, not the recorded email", async () => { + const now = 1_800_000_000_000; + const originalNow = Date.now; + const cfg = { ...config(), autoSwitchThreshold: 0 }; + // config() registers pool-a with email pool@example.test on workspace account pool_acc. + const jwt = (claims: Record, email?: string) => `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(now / 1000) + 86_400, + ...(email ? { email } : {}), + "https://api.openai.com/auth": { chatgpt_account_id: "pool_acc", ...claims }, + })).toString("base64url")}.signature`; + const storeCooled = (accessToken: string) => saveCodexAccountCredential("pool-a", { + accessToken, refreshToken: "pool_refresh", + expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc", + }); + storeCooled(jwt({ chatgpt_user_id: "user-cooled" }, "pool@example.test")); + try { + Date.now = () => now; + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("pool-a", "shared"); + expect(cooldown).not.toBeNull(); + Date.now = () => now + 1_000; + const options = { requestScopedMainCredential: true, modelId: "gpt-5.6-terra" }; + const resolve = (headers: Headers) => resolveCodexAuthContext(headers, cfg, "pool", options); + + // Distinct members of one workspace account, with no email claim anywhere to separate them. + await expect(resolve(new Headers({ + authorization: `Bearer ${jwt({ chatgpt_user_id: "user-teammate" })}`, + }))).resolves.toMatchObject({ kind: "main", accountId: null }); + // The same user stays inside its own cooldown after an email change and a token rotation. + await expect(resolve(new Headers({ + authorization: `Bearer ${jwt({ chatgpt_user_id: "user-cooled" }, "renamed@example.test")}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + + // A stored credential whose own user-id claims disagree identifies nobody, so even a caller + // the email rule would have waved through as a teammate fails closed. + storeCooled(jwt({ chatgpt_user_id: "user-cooled", user_id: "user-other" }, "pool@example.test")); + await expect(resolve(new Headers({ + authorization: `Bearer ${jwt({ chatgpt_user_id: "user-teammate" }, "teammate@example.test")}`, + }))).rejects.toBeInstanceOf(CodexAccountCooldownError); + + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown); + } finally { + Date.now = originalNow; + } + }); + test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", diff --git a/tests/codex-integration/reserve-availability.test.ts b/tests/codex-integration/reserve-availability.test.ts index d5298ea2f7..38d10e7ada 100644 --- a/tests/codex-integration/reserve-availability.test.ts +++ b/tests/codex-integration/reserve-availability.test.ts @@ -3,7 +3,8 @@ import { clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity, } from "../../src/codex/main-account-cache"; import { - getMainReserveAuthorization, isMainReserveAuthorizationLive, observeMainReserveRevocation, + getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, + observeMainReserveRevocation, } from "../../src/codex/reserve-availability"; import type { WhamUsageResponse } from "../../src/codex/quota-types"; @@ -239,3 +240,31 @@ describe("owned main Reserve capability", () => { } finally { timer.mockRestore(); response.resolve(Response.json(grant())); } }); }); + +describe("native user identity claims", () => { + const token = (auth: Record) => + `fixture.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": auth })).toString("base64url")}.signature`; + + test("precedence stays on the raw claims, so an unusable chatgpt_user_id blocks the fallback", () => { + expect(nativeUserIdClaims(token({ chatgpt_user_id: "user-a", user_id: "user-a" }))) + .toEqual({ userId: "user-a", conflict: false }); + expect(nativeUserIdClaims(token({ user_id: "user-b" }))).toEqual({ userId: "user-b", conflict: false }); + // An empty or non-string primary claim selects nothing rather than falling through. + expect(nativeUserIdClaims(token({ chatgpt_user_id: "", user_id: "user-c" }))) + .toEqual({ userId: undefined, conflict: false }); + expect(nativeUserIdClaims(token({ chatgpt_user_id: 17, user_id: "user-d" }))) + .toEqual({ userId: undefined, conflict: false }); + }); + + test("two disagreeing encodings report a conflict without changing the selected id", () => { + expect(nativeUserIdClaims(token({ chatgpt_user_id: "user-a", user_id: "user-b" }))) + .toEqual({ userId: "user-a", conflict: true }); + }); + + test("absent, unparseable, and foreign-namespace tokens report nothing", () => { + expect(nativeUserIdClaims(token({}))).toEqual({ userId: undefined, conflict: false }); + expect(nativeUserIdClaims("not-a-token")).toEqual({ userId: undefined, conflict: false }); + expect(nativeUserIdClaims(`fixture.${Buffer.from(JSON.stringify({ sub: "user-a" })).toString("base64url")}.sig`)) + .toEqual({ userId: undefined, conflict: false }); + }); +});