From 9c1d7db4eabead1acd65a058ecc32580eadeee18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Fri, 25 Sep 2026 18:51:24 +0900 Subject: [PATCH 1/8] fix(codex): fence main quota publication after credential rotation --- .../ko/reference/cli/providers-accounts.md | 3 +- .../docs/reference/cli/providers-accounts.md | 2 + src/codex/auth-api/main-account-probe.ts | 4 +- structure/providers/openai-tiers.md | 6 ++ .../main-account-hard-lock-recovery.test.ts | 70 ++++++++++++++++++- 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index b70293c2a8..0d915c45cd 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -101,12 +101,13 @@ Pool 모드에서 사용량 조회의 `--refresh`는 캐시 유효기간을 무 조회가 연기되면 새 진단 시도로 기록하지 않습니다. 일시정지, 재인증, 서버의 사용량 제한은 별도로 적용됩니다. -새로운 유효한 WHAM 사용량 응답 한 건에서 1차 창의 기간이 **24시간 이상**으로 명시되고, +새로운 유효한 WHAM 사용량 응답 한 건에서 1차 창의 기간이 **24시간 이상**으로 명시되고 유효한 사용량 수치가 있으며, 2차·3차 창이 명시적 `null`이거나 그 기간도 24시간 이상으로 명시되고 사용량 수치도 함께 오면 이전 5h 수치를 대체합니다. 파서의 단기·장기 구분 기준을 따르므로 주간·월간뿐 아니라 하루짜리 창도 해당합니다. 현재 창에는 동일한 98% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 요구하지 않습니다. 2차·3차 필드가 생략되었거나, 1차 창의 기간을 모르거나, 응답 헤더만 일부 도착한 경우에는 이전 차단을 해제하지 않습니다. +같은 계정에서도 인증 토큰 교체가 관측되면 교체 전 요청의 지연 응답은 사용량 캐시나 차단 상태를 갱신하지 않습니다. 저장되는 옵션은 OpenCodex의 `config.json`에 있는 `"codexMainAccountHardLock"`입니다. 값이 없거나 `true`이면 켜짐이고, `false`일 때만 꺼집니다. 스위치를 끄면 이 `false`가 저장됩니다. 기본값이 diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 1a8178fe89..c3b5a5d19d 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -168,6 +168,8 @@ or also explicitly last at least 24 hours and report their usage. This follows t one-day window qualifies as well as weekly/monthly windows. The current window still uses the same 98% threshold. This relies on the single reported snapshot; repeated observations are not required. Omitted secondary/tertiary fields, an unknown primary duration, or partial response headers cannot clear a previous block. +Once a credential replacement is observed, a delayed response from an earlier request cannot update +the usage cache or release the lock, even for the same account or after restoring the original token. The persisted option is `"codexMainAccountHardLock"` in OpenCodex's `config.json`. An absent key or `true` means on; only an explicit `false` turns it off, and that is what switching the setting off diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 9b6611a67c..fb68ec2f2c 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -296,7 +296,9 @@ export async function fetchMainAccountInfoWhileOwned( } // Check after body/retry awaits and before any cache, credits, policy or // Reserve publication. Returning cached state supplies no fresh recovery proof. - if (!isQuotaDispatchCurrent(dispatchSequence)) { + if (!isQuotaDispatchCurrent(dispatchSequence) || (mainQuotaWriter + && (mainQuotaCredentialGeneration !== getMainQuotaCredentialGeneration() + || !matchesMainQuotaCredential(tokens.access_token, tokens.account_id)))) { return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; } diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index d70a49bce9..1875a75983 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -377,6 +377,12 @@ invalidates old evidence. Request-owned bearers are matched only against a crede workspace already observed under native ownership; an unrelated or unmatched keyring credential is not attributed to stored main and introduces no physical-main read. Credential equality tags remain process-local and never enter disk, logs, or management DTOs. +`src/codex/auth-api/main-account-probe.ts` rechecks the captured credential generation and bearer +after body/retry awaits, before publishing main usage, credits, plan, reauth or Reserve state. +An observed same-account credential replacement, including A→B→A, retires the prior response even +when a newer read fails without publishing; an unchanged credential still permits an older success. +Retired responses return cached info without fresh quota or recovery proof. The request/body races +are covered by `tests/codex-integration/main-account-hard-lock-recovery.test.ts`. Owned startup rebuilds this binding from its pinned auth path under the native owner and exclusive claim, after journal recovery and stage cleanup, before publishing ready. That work now runs for diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index 25ae915fe5..a11848d7c8 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -9,10 +9,10 @@ import { fetchMainAccountInfoAttempt } from "../../src/codex/auth-api/main-accou import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; -import { captureMainQuotaWriter, clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { setMainAccountPlan } from "../../src/codex/main-account"; -import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { clearAccountQuota, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; import { clearCodexUpstreamHealth, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -294,6 +294,72 @@ describe("main hard-lock background recovery", () => { await runMainAccountHardLockRecovery(config()); expect(calls).toHaveLength(2); }); + for (const phase of ["request", "body"] as const) { + test.each(["unchanged", "replaced", "restored"] as const)(`delayed ${phase} response respects %s same-account credentials`, async transition => { + const started = deferred(); + const finish = deferred(); + const authPath = join(home, "auth.json"); + const originalAuth = readFileSync(authPath, "utf8"); + const replacement = JSON.parse(originalAuth); + replacement.tokens.access_token += "-rotated"; + const data = { plan_type: "prolite", rate_limit: { + allowed: true, limit_reached: false, + primary_window: { used_percent: 64, limit_window_seconds: 604_800 }, secondary_window: null, + }, rate_limit_reset_credits: { available_count: 2 } }; + let reads = 0; + globalThis.fetch = Object.assign(async (input: Parameters[0]) => { + expect(String(input)).toBe(whamUrl); + if (++reads > 1) return new Response(null, { status: 503 }); + if (phase === "request") { + started.resolve(); + await finish.promise; + } + const response = Response.json(data); + if (phase === "body") response.json = async () => { + started.resolve(); + await finish.promise; + return data; + }; + return response; + }, { preconnect: previousFetch.preconnect }); + markAccountNeedsReauth(MAIN); + const pending = fetchMainAccountInfoAttempt(true, 0); + try { + await started.promise; + if (transition !== "unchanged") writeFileSync(authPath, JSON.stringify(replacement)); + // A newer read observes the bearer but fails, so it cannot advance the publication fence. + expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + if (transition === "restored") { + writeFileSync(authPath, originalAuth); + expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + } + const policy = getMainPolicyQuota(); + const display = structuredClone(getAccountQuota(MAIN)); + const info = structuredClone(getMainAccountInfoCache()); + finish.resolve(); + const result = await pending; + if (transition === "unchanged") { + expect(getMainAccountHardLockStatus(config()).state).toBe("ready"); + expect(result.freshQuota?.weeklyPercent).toBe(64); + expect(result.resetRecoveryProof).toBeDefined(); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + } else { + expect(getMainPolicyQuota()).toEqual(policy); + expect(getMainAccountHardLockStatus(config()).state).toBe("blocked"); + expect(getAccountQuota(MAIN)).toEqual(display); + expect(getMainAccountInfoCache()).toEqual(info); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(result.freshQuota).toBeUndefined(); + expect(result.freshResetCredits).toBeUndefined(); + expect(result.resetRecoveryProof).toBeUndefined(); + expect(result.quotaRefresh).toBeUndefined(); + } + } finally { + finish.resolve(); + await pending; + } + }); + } test("owned metadata recovery replaces an obsolete short block with the current weekly window", async () => { const calls = fetchWith(async () => Response.json({ plan_type: "pro", rate_limit: { From c18542741cd6469701290ae01b8fceac4f3a426f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Sat, 26 Sep 2026 09:32:52 +0900 Subject: [PATCH 2/8] fix(codex): preserve quota returns while fencing stale credential writes --- .../ko/reference/cli/providers-accounts.md | 2 + .../docs/reference/cli/providers-accounts.md | 3 + src/codex/auth-api/main-account-probe.ts | 51 ++++---- structure/providers/openai-tiers.md | 12 +- .../codex-integration/codex-auth-api.test.ts | 4 +- .../main-account-hard-lock-recovery.test.ts | 112 +++++++++++++++++- 6 files changed, 156 insertions(+), 28 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 0d915c45cd..369d326d65 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -108,6 +108,8 @@ Pool 모드에서 사용량 조회의 `--refresh`는 캐시 유효기간을 무 요구하지 않습니다. 2차·3차 필드가 생략되었거나, 1차 창의 기간을 모르거나, 응답 헤더만 일부 도착한 경우에는 이전 차단을 해제하지 않습니다. 같은 계정에서도 인증 토큰 교체가 관측되면 교체 전 요청의 지연 응답은 사용량 캐시나 차단 상태를 갱신하지 않습니다. +해당 요청자에게 파싱된 조회 결과를 반환할 수는 있지만, 공유 상태나 차단 해제 근거에는 반영하지 않습니다. +계정 정보가 충돌하거나 이전 토큰의 401/403 응답이 늦게 도착한 경우에는 현재 캐시를 유지하고 재인증 상태를 변경하지 않습니다. 저장되는 옵션은 OpenCodex의 `config.json`에 있는 `"codexMainAccountHardLock"`입니다. 값이 없거나 `true`이면 켜짐이고, `false`일 때만 꺼집니다. 스위치를 끄면 이 `false`가 저장됩니다. 기본값이 diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index c3b5a5d19d..8923431fde 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -170,6 +170,9 @@ one-day window qualifies as well as weekly/monthly windows. The current window s Omitted secondary/tertiary fields, an unknown primary duration, or partial response headers cannot clear a previous block. Once a credential replacement is observed, a delayed response from an earlier request cannot update the usage cache or release the lock, even for the same account or after restoring the original token. +Its parsed ordinary usage can still be returned to the requesting caller, without shared-state updates +or recovery evidence. Conflicting account identities and stale 401/403 replies retain the current +cached info and cannot clear or set the current account's reauthentication state. The persisted option is `"codexMainAccountHardLock"` in OpenCodex's `config.json`. An absent key or `true` means on; only an explicit `false` turns it off, and that is what switching the setting off diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index fb68ec2f2c..58ed405759 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -106,9 +106,9 @@ export interface MainAccountInfoFetchResult { hasCredential: boolean; /** Main identity generation captured while the native-main claim was held. */ identityGeneration?: number; - /** Present only when this call freshly parsed a WHAM usage response. */ + /** Freshly parsed usage from the current credential; stale ordinary return values are excluded. */ freshQuota?: Omit; - /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ + /** Current-credential response's `rate_limit_reset_credits.available_count`, when present. */ freshResetCredits?: number; } @@ -193,6 +193,11 @@ export async function fetchMainAccountInfoAttempt( } } +/** + * Read native-main usage while ownership is held, publishing only current credential evidence. + * A replaced same-account bearer may return its parsed ordinary info without mutating shared + * state or supplying recovery proof. Conflicting identities and stale errors return cached info. + */ export async function fetchMainAccountInfoWhileOwned( forceRefresh: boolean, retriesRemaining: number, @@ -234,6 +239,11 @@ export async function fetchMainAccountInfoWhileOwned( ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); + /** Revalidate identity, bearer and its generation after each upstream await. */ + const credentialIsCurrent = (): boolean => mainQuotaWriter !== undefined + && isMainQuotaWriterLive(mainQuotaWriter) + && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() + && matchesMainQuotaCredential(tokens.access_token, tokens.account_id); // Keep diagnostics separate from authentication and freshness policy. Never serialize errors. const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS); let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; @@ -250,11 +260,10 @@ export async function fetchMainAccountInfoWhileOwned( signal: quotaSignal, }, () => { dispatchSequence = nextQuotaDispatchSequence(); }, paced ? { pacingKey: baseKey } : { unpaced: true }); - if (!admission) return { info: cached ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + if (!admission) return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, hasCredential: true }; if (admission.kind === "joined") { - const current = mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() - && matchesMainQuotaCredential(tokens.access_token, tokens.account_id) - && writerGeneration === captureConfigGeneration(); + const current = credentialIsCurrent() && writerGeneration === captureConfigGeneration(); if (!current) return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; if (explicitRefresh && (admission.result.quotaRefresh?.status === "ok" @@ -268,7 +277,7 @@ export async function fetchMainAccountInfoWhileOwned( const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced); if (retried) return retried; - if (!isQuotaDispatchCurrent(dispatchSequence)) { + if (!isQuotaDispatchCurrent(dispatchSequence) || !credentialIsCurrent()) { return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; } @@ -294,19 +303,15 @@ export async function fetchMainAccountInfoWhileOwned( if (data === null || typeof data !== "object" || Array.isArray(data)) { throw new Error("Invalid WHAM usage object"); } - // Check after body/retry awaits and before any cache, credits, policy or - // Reserve publication. Returning cached state supplies no fresh recovery proof. - if (!isQuotaDispatchCurrent(dispatchSequence) || (mainQuotaWriter - && (mainQuotaCredentialGeneration !== getMainQuotaCredentialGeneration() - || !matchesMainQuotaCredential(tokens.access_token, tokens.account_id)))) { + // A newer published response wins over this attempt, including its returned display info. + if (!isQuotaDispatchCurrent(dispatchSequence)) { return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; } quotaPhase = "publish"; // A delayed response from a replaced bearer cannot revoke a newer Reserve grant, // even in the same workspace or after an A→B→A credential transition. - if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() - && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) { + if (credentialIsCurrent()) { observeMainReserveRevocation(data, mainQuotaWriter); } quotaPhase = "decode"; @@ -315,16 +320,22 @@ export async function fetchMainAccountInfoWhileOwned( const quota = parseUsageQuota(usage); const policyQuota = parseMainPolicyUsageQuota(usage); quotaPhase = "publish"; - const freshResetCredits = quota?.resetCredits; - // Tag the count with the identity it was read from, so a later response that omits the - // summary can restore the badge without ever crossing an account boundary. - rememberMainResetCredits(requestAccountId, freshResetCredits); const result = { email: data.email ?? null, plan, quota, ts: Date.now(), }; + if (!credentialIsCurrent()) { + // Preserve same-identity ordinary info, but never publish stale evidence or state. + return { info: mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter) + ? result : getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, hasCredential: true }; + } + const freshResetCredits = quota?.resetCredits; + // Tag the count with the identity it was read from, so a later response that omits the + // summary can restore the badge without ever crossing an account boundary. + rememberMainResetCredits(requestAccountId, freshResetCredits); setMainAccountInfoCache(result); // Only an explicit refresh may retract a reauth quarantine. A 200 from // /wham/usage proves the token authenticates to the usage endpoint; it does not @@ -351,9 +362,7 @@ export async function fetchMainAccountInfoWhileOwned( credentialChecked: true, hasCredential: true, ...(quota ? { freshQuota: quota } : {}), - ...(quota && mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter) - && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() - && matchesMainQuotaCredential(tokens.access_token, tokens.account_id) + ...(quota && mainQuotaWriter && credentialIsCurrent() ? { resetRecoveryProof: { writer: mainQuotaWriter, credentialGeneration: mainQuotaCredentialGeneration, dispatchSequence } } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 1875a75983..dfa33b8cf6 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -378,11 +378,15 @@ workspace already observed under native ownership; an unrelated or unmatched key is not attributed to stored main and introduces no physical-main read. Credential equality tags remain process-local and never enter disk, logs, or management DTOs. `src/codex/auth-api/main-account-probe.ts` rechecks the captured credential generation and bearer -after body/retry awaits, before publishing main usage, credits, plan, reauth or Reserve state. -An observed same-account credential replacement, including A→B→A, retires the prior response even +after body/retry awaits, before publishing main usage, credits, plan, reauth or Reserve state, +including terminal 401/403 mutations. A missing identity writer cannot bypass this check. +An observed same-account credential replacement, including A→B→A, prevents publication even when a newer read fails without publishing; an unchanged credential still permits an older success. -Retired responses return cached info without fresh quota or recovery proof. The request/body races -are covered by `tests/codex-integration/main-account-hard-lock-recovery.test.ts`. +Successful same-identity responses may still return parsed ordinary info to their caller, without +shared-state updates, fresh quota or recovery proof. Conflicting identities and stale errors return +cached info. The request/body races are covered by +`tests/codex-integration/main-account-hard-lock-recovery.test.ts`; the ordinary return and Reserve +revocation contract remains covered by `tests/codex-integration/reserve-passive-revocation.test.ts`. Owned startup rebuilds this binding from its pinned auth path under the native owner and exclusive claim, after journal recovery and stage cleanup, before publishing ready. That work now runs for diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index acd42d4843..7a6ef7a398 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -484,8 +484,8 @@ describe("main quota refresh diagnostics", () => { } else { expect(result).not.toHaveProperty("quotaRefresh"); } - // The existing terminal-auth decision still applies, independently of diagnostic freshness. - if (outcome === "terminal_http") expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + // Terminal errors can quarantine only the still-current identity and credential. + if (outcome === "terminal_http") expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(invalidation === "none"); expect(result).not.toHaveProperty("quotaRefreshGeneration"); expect(JSON.stringify(result)).not.toContain("quotaRefreshGeneration"); expect(JSON.stringify(result)).not.toContain("canary"); diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index a11848d7c8..271dcc31c0 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -9,7 +9,7 @@ import { fetchMainAccountInfoAttempt } from "../../src/codex/auth-api/main-accou import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; -import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountInfoCache, setMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { setMainAccountPlan } from "../../src/codex/main-account"; import { clearAccountQuota, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; @@ -344,6 +344,8 @@ describe("main hard-lock background recovery", () => { expect(result.resetRecoveryProof).toBeDefined(); expect(isAccountNeedsReauth(MAIN)).toBe(false); } else { + // Ordinary callers retain the parsed result; only authoritative publication is fenced. + expect(result.info.quota?.weeklyPercent).toBe(64); expect(getMainPolicyQuota()).toEqual(policy); expect(getMainAccountHardLockStatus(config()).state).toBe("blocked"); expect(getAccountQuota(MAIN)).toEqual(display); @@ -361,6 +363,114 @@ describe("main hard-lock background recovery", () => { }); } + for (const status of [401, 403]) { + for (const phase of ["request", "error-body"] as const) { + test.each(["unchanged", "replaced", "restored"] as const)(`terminal ${status} delayed ${phase} respects %s credentials`, async transition => { + const started = deferred(); + const finish = deferred(); + const authPath = join(home, "auth.json"); + const originalAuth = readFileSync(authPath, "utf8"); + const replacement = JSON.parse(originalAuth); + replacement.tokens.access_token += "-rotated"; + setMainAccountInfoCache({ email: null, plan: "plus", quota: { shortPercent: 99 }, ts: 1 }); + let reads = 0; + globalThis.fetch = Object.assign(async (input: Parameters[0]) => { + expect(String(input)).toBe(whamUrl); + if (++reads > 1) return new Response(null, { status: 503 }); + const body = JSON.stringify({ error: { code: "invalid_workspace_selected" } }); + if (phase === "request") { + started.resolve(); + await finish.promise; + return new Response(body, { status }); + } + return new Response(new ReadableStream({ + start(controller) { + started.resolve(); + void finish.promise.then(() => { controller.enqueue(new TextEncoder().encode(body)); controller.close(); }); + }, + }), { status }); + }, { preconnect: previousFetch.preconnect }); + const pending = fetchMainAccountInfoAttempt(true, 0); + try { + await Promise.race([started.promise, pending.then(() => { throw new Error("Terminal WHAM never started"); })]); + if (transition !== "unchanged") writeFileSync(authPath, JSON.stringify(replacement)); + expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + if (transition === "restored") { + writeFileSync(authPath, originalAuth); + expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + } + const info = structuredClone(getMainAccountInfoCache()); + const policy = getMainPolicyQuota(); + finish.resolve(); + const result = await pending; + if (transition === "unchanged") { + expect(getMainAccountInfoCache()).toBeNull(); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(result.quotaRefresh).toEqual({ status: "http_error", httpStatus: status }); + } else { + expect(getMainAccountInfoCache()).toEqual(info); + expect(getMainPolicyQuota()).toEqual(policy); + expect(getMainAccountHardLockStatus(config()).state).toBe("blocked"); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + expect(result.info).toEqual(info); + expect(result.quotaRefresh).toBeUndefined(); + expect(result.resetRecoveryProof).toBeUndefined(); + } + } finally { + finish.resolve(); + await pending; + } + }); + } + } + + for (const status of [200, 401, 403]) { + test.each([false, true])(`conflicting main tuple cannot publish ${status}, replacement=%s`, async replaced => { + const authPath = join(home, "auth.json"); + const valid = JSON.parse(readFileSync(authPath, "utf8")); + writeFileSync(authPath, JSON.stringify({ tokens: { ...valid.tokens, account_id: "fixture-other-header" } })); + setMainAccountInfoCache({ email: null, plan: "plus", quota: { shortPercent: 99 }, ts: 1 }); + if (status === 200) markAccountNeedsReauth(MAIN); + const started = deferred(); + const finish = deferred(); + let reads = 0; + globalThis.fetch = Object.assign(async (input: Parameters[0]) => { + expect(String(input)).toBe(whamUrl); + if (++reads > 1) return new Response(null, { status: 503 }); + started.resolve(); + await finish.promise; + return status === 200 ? usage(0) + : Response.json({ error: { code: "invalid_workspace_selected" } }, { status }); + }, { preconnect: previousFetch.preconnect }); + const pending = fetchMainAccountInfoAttempt(true, 0); + try { + await Promise.race([started.promise, pending.then(() => { throw new Error("Conflicting WHAM never started"); })]); + if (replaced) { + valid.tokens.access_token += "-rotated"; + writeFileSync(authPath, JSON.stringify(valid)); + expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + } + const info = structuredClone(getMainAccountInfoCache()); + const policy = getMainPolicyQuota(); + const display = structuredClone(getAccountQuota(MAIN)); + finish.resolve(); + const result = await pending; + expect(getMainAccountInfoCache()).toEqual(info); + expect(getMainPolicyQuota()).toEqual(policy); + expect(getAccountQuota(MAIN)).toEqual(display); + expect(isAccountNeedsReauth(MAIN)).toBe(status === 200); + expect(result.info).toEqual(info); + expect(result.freshQuota).toBeUndefined(); + expect(result.freshResetCredits).toBeUndefined(); + expect(result.resetRecoveryProof).toBeUndefined(); + expect(result.quotaRefresh).toBeUndefined(); + } finally { + finish.resolve(); + await pending; + } + }); + } + test("owned metadata recovery replaces an obsolete short block with the current weekly window", async () => { const calls = fetchWith(async () => Response.json({ plan_type: "pro", rate_limit: { primary_window: { used_percent: 35, limit_window_seconds: 604_800 }, secondary_window: null, tertiary_window: null, From 406491acc20473298340a96fdf9edf48da3c99f4 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 16:55:56 +0900 Subject: [PATCH 3/8] fix(codex): ignore malformed usage from replaced main credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 정우철 --- src/codex/auth-api/main-account-probe.ts | 4 +++ .../main-account-hard-lock-recovery.test.ts | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 58ed405759..7bbf4c2edc 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -370,6 +370,10 @@ export async function fetchMainAccountInfoWhileOwned( } catch (error) { const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced); if (retried) return retried; + if (!credentialIsCurrent()) { + return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, hasCredential: true }; + } let status: CodexQuotaRefreshOutcome["status"] = "internal_error"; if ((quotaPhase === "request" || quotaPhase === "body") && quotaSignal.aborted) status = "timeout"; else if (quotaPhase === "request") status = "network_error"; diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index 271dcc31c0..0e9824a9d1 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -294,6 +294,42 @@ describe("main hard-lock background recovery", () => { await runMainAccountHardLockRecovery(config()); expect(calls).toHaveLength(2); }); + test("a replaced credential's delayed malformed body returns current cached info", async () => { + const started = deferred(); + const finish = deferred(); + const authPath = join(home, "auth.json"); + const replacement = JSON.parse(readFileSync(authPath, "utf8")); + replacement.tokens.access_token += "-rotated"; + setMainAccountInfoCache({ email: null, plan: "plus", quota: { shortPercent: 99 }, ts: 1 }); + let reads = 0; + globalThis.fetch = Object.assign(async (input: Parameters[0]) => { + expect(String(input)).toBe(whamUrl); + if (++reads > 1) return new Response(null, { status: 503 }); + const response = Response.json({}); + response.json = async () => { + started.resolve(); + await finish.promise; + throw new SyntaxError("malformed fixture"); + }; + return response; + }, { preconnect: previousFetch.preconnect }); + const pending = fetchMainAccountInfoAttempt(true, 0); + try { + await started.promise; + writeFileSync(authPath, JSON.stringify(replacement)); + expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + const info = structuredClone(getMainAccountInfoCache()); + finish.resolve(); + const result = await pending; + expect(result.info).toEqual(info); + expect(result.quotaRefresh).toBeUndefined(); + expect(result.freshQuota).toBeUndefined(); + } finally { + finish.resolve(); + await pending; + } + }); + for (const phase of ["request", "body"] as const) { test.each(["unchanged", "replaced", "restored"] as const)(`delayed ${phase} response respects %s same-account credentials`, async transition => { const started = deferred(); From 0efca0c2948fa7c14ab5bcf20e0c8798722cceef Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:37:18 +0900 Subject: [PATCH 4/8] test(codex): keep paced skips outside main-lock proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 정우철 --- .../main-account-hard-lock-recovery.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index 0e9824a9d1..a7763b646c 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -167,7 +167,10 @@ describe("main hard-lock background recovery", () => { await runMainAccountHardLockRecovery(config()); for (let tick = 0; tick < 14; tick++) { now += 60_000; - await fetchMainAccountInfo(true); + const skipped = await fetchMainAccountInfoAttempt(true, 0); + expect(skipped.freshQuota).toBeUndefined(); + expect(skipped.resetRecoveryProof).toBeUndefined(); + expect(skipped.quotaRefresh).toBeUndefined(); await runMainAccountHardLockRecovery(config()); } expect(calls).toEqual([whamUrl]); From 355cdd038695417a37297fc77c1fd8581cf58ebf Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 19:10:13 +0900 Subject: [PATCH 5/8] test(codex): release unchanged main response before another read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 정우철 --- .../main-account-hard-lock-recovery.test.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index a7763b646c..8bd3f5796e 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -365,12 +365,14 @@ describe("main hard-lock background recovery", () => { const pending = fetchMainAccountInfoAttempt(true, 0); try { await started.promise; - if (transition !== "unchanged") writeFileSync(authPath, JSON.stringify(replacement)); - // A newer read observes the bearer but fails, so it cannot advance the publication fence. - expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); - if (transition === "restored") { - writeFileSync(authPath, originalAuth); + if (transition !== "unchanged") { + writeFileSync(authPath, JSON.stringify(replacement)); + // A newer read observes the bearer but fails, so it cannot advance the publication fence. expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + if (transition === "restored") { + writeFileSync(authPath, originalAuth); + expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + } } const policy = getMainPolicyQuota(); const display = structuredClone(getAccountQuota(MAIN)); @@ -378,6 +380,7 @@ describe("main hard-lock background recovery", () => { finish.resolve(); const result = await pending; if (transition === "unchanged") { + expect(reads).toBe(1); expect(getMainAccountHardLockStatus(config()).state).toBe("ready"); expect(result.freshQuota?.weeklyPercent).toBe(64); expect(result.resetRecoveryProof).toBeDefined(); @@ -432,17 +435,20 @@ describe("main hard-lock background recovery", () => { const pending = fetchMainAccountInfoAttempt(true, 0); try { await Promise.race([started.promise, pending.then(() => { throw new Error("Terminal WHAM never started"); })]); - if (transition !== "unchanged") writeFileSync(authPath, JSON.stringify(replacement)); - expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); - if (transition === "restored") { - writeFileSync(authPath, originalAuth); + if (transition !== "unchanged") { + writeFileSync(authPath, JSON.stringify(replacement)); expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + if (transition === "restored") { + writeFileSync(authPath, originalAuth); + expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + } } const info = structuredClone(getMainAccountInfoCache()); const policy = getMainPolicyQuota(); finish.resolve(); const result = await pending; if (transition === "unchanged") { + expect(reads).toBe(1); expect(getMainAccountInfoCache()).toBeNull(); expect(isAccountNeedsReauth(MAIN)).toBe(true); expect(result.quotaRefresh).toEqual({ status: "http_error", httpStatus: status }); From d39cd0e162f1c723d91872cc35ab5b6cb8bc9427 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 19:36:50 +0900 Subject: [PATCH 6/8] fix(codex): keep unpublished main usage off account cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 정우철 --- .../ko/reference/cli/providers-accounts.md | 1 + .../docs/reference/cli/providers-accounts.md | 3 +- src/codex/auth-api/account-list.ts | 8 +++- src/codex/auth-api/main-account-probe.ts | 10 ++-- structure/providers/openai-tiers.md | 5 +- .../main-account-hard-lock-recovery.test.ts | 47 +++++++++++++++++-- 6 files changed, 63 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 369d326d65..1d3e0e4877 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -109,6 +109,7 @@ Pool 모드에서 사용량 조회의 `--refresh`는 캐시 유효기간을 무 도착한 경우에는 이전 차단을 해제하지 않습니다. 같은 계정에서도 인증 토큰 교체가 관측되면 교체 전 요청의 지연 응답은 사용량 캐시나 차단 상태를 갱신하지 않습니다. 해당 요청자에게 파싱된 조회 결과를 반환할 수는 있지만, 공유 상태나 차단 해제 근거에는 반영하지 않습니다. +계정 카드에는 공유 캐시에 반영된 사용량만 표시하여 차단 상태와 수치가 일치하도록 합니다. 계정 정보가 충돌하거나 이전 토큰의 401/403 응답이 늦게 도착한 경우에는 현재 캐시를 유지하고 재인증 상태를 변경하지 않습니다. 저장되는 옵션은 OpenCodex의 `config.json`에 있는 `"codexMainAccountHardLock"`입니다. 값이 없거나 diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 8923431fde..3068cbbc8f 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -171,7 +171,8 @@ Omitted secondary/tertiary fields, an unknown primary duration, or partial respo Once a credential replacement is observed, a delayed response from an earlier request cannot update the usage cache or release the lock, even for the same account or after restoring the original token. Its parsed ordinary usage can still be returned to the requesting caller, without shared-state updates -or recovery evidence. Conflicting account identities and stale 401/403 replies retain the current +or recovery evidence. The account card shows the published cached usage, keeping its quota aligned +with the lock status. Conflicting account identities and stale 401/403 replies retain the current cached info and cannot clear or set the current account's reauthentication state. The persisted option is `"codexMainAccountHardLock"` in OpenCodex's `config.json`. An absent key or diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index a666667612..ee73d680be 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -12,7 +12,7 @@ import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { captureConfigGeneration } from "../../lib/state-store-sweeper"; -import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, isMainAccountIdentityGenerationLive } from "../main-account-cache"; +import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, isMainAccountIdentityGenerationLive } from "../main-account-cache"; import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome"; import { getMainAccountHardLockStatus } from "../main-account-hard-lock"; import type { MainAccountHardLockStatus } from "../main-account-hard-lock"; @@ -328,7 +328,11 @@ export async function listCodexAuthAccountsSnapshot( }); const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration(); const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration); - const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO; + // An ordinary same-account return can be parsed after its credential was replaced. + // The card and hard-lock status must describe the same published quota snapshot. + const mainInfo = mainSnapshotLive + ? mainResult.infoUnpublished ? getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO : mainResult.info + : EMPTY_MAIN_ACCOUNT_INFO; const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked ? mainResult.hasCredential : getMainAccountCredentialPresence() ?? false; diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 7bbf4c2edc..585aae5b01 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -95,6 +95,8 @@ export interface MainResetQuotaProof { export interface MainAccountInfoFetchResult { info: MainAccountInfo; + /** Parsed ordinary info from a stale credential; callers must not display it as shared state. */ + infoUnpublished?: true; resetRecoveryProof?: MainResetQuotaProof & { dispatchSequence: number }; /** Ephemeral result of this attempt, omitted when no WHAM request was made. */ quotaRefresh?: CodexQuotaRefreshOutcome; @@ -328,9 +330,11 @@ export async function fetchMainAccountInfoWhileOwned( }; if (!credentialIsCurrent()) { // Preserve same-identity ordinary info, but never publish stale evidence or state. - return { info: mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter) - ? result : getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, - credentialChecked: true, hasCredential: true }; + if (mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter)) { + return { info: result, infoUnpublished: true, credentialChecked: true, hasCredential: true }; + } + return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, hasCredential: true }; } const freshResetCredits = quota?.resetCredits; // Tag the count with the identity it was read from, so a later response that omits the diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index dfa33b8cf6..939908447a 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -383,8 +383,9 @@ including terminal 401/403 mutations. A missing identity writer cannot bypass th An observed same-account credential replacement, including A→B→A, prevents publication even when a newer read fails without publishing; an unchanged credential still permits an older success. Successful same-identity responses may still return parsed ordinary info to their caller, without -shared-state updates, fresh quota or recovery proof. Conflicting identities and stale errors return -cached info. The request/body races are covered by +shared-state updates, fresh quota or recovery proof. The account-list card uses the published cache +for such a return, so its displayed quota agrees with the hard-lock state. Conflicting identities +and stale errors return cached info. The request/body races and card projection are covered by `tests/codex-integration/main-account-hard-lock-recovery.test.ts`; the ordinary return and Reserve revocation contract remains covered by `tests/codex-integration/reserve-passive-revocation.test.ts`. diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index 8bd3f5796e..bedbf4e95e 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { - fetchMainAccountInfo, registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecovery, + fetchMainAccountInfo, listCodexAuthAccounts, registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecovery, } from "../../src/codex/auth-api"; import { fetchMainAccountInfoAttempt } from "../../src/codex/auth-api/main-account-probe"; import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; @@ -342,8 +342,7 @@ describe("main hard-lock background recovery", () => { const replacement = JSON.parse(originalAuth); replacement.tokens.access_token += "-rotated"; const data = { plan_type: "prolite", rate_limit: { - allowed: true, limit_reached: false, - primary_window: { used_percent: 64, limit_window_seconds: 604_800 }, secondary_window: null, + primary_window: { used_percent: 64, limit_window_seconds: 604_800 }, secondary_window: null, tertiary_window: null, }, rate_limit_reset_credits: { available_count: 2 } }; let reads = 0; globalThis.fetch = Object.assign(async (input: Parameters[0]) => { @@ -405,6 +404,48 @@ describe("main hard-lock background recovery", () => { }); } + test("account list shows cached quota when a replaced token's result is unpublished", async () => { + const started = deferred(); + const finish = deferred(); + const authPath = join(home, "auth.json"); + const replacement = JSON.parse(readFileSync(authPath, "utf8")); + replacement.tokens.access_token += "-rotated"; + setMainAccountInfoCache({ email: null, plan: "plus", quota: { shortPercent: 99 }, ts: 1 }); + const data = { plan_type: "prolite", rate_limit: { + primary_window: { used_percent: 64, limit_window_seconds: 604_800 }, + secondary_window: null, tertiary_window: null, + } }; + let reads = 0; + globalThis.fetch = Object.assign(async (input: Parameters[0]) => { + expect(String(input)).toBe(whamUrl); + if (++reads > 1) return new Response(null, { status: 503 }); + const response = Response.json(data); + response.json = async () => { + started.resolve(); + await finish.promise; + return data; + }; + return response; + }, { preconnect: previousFetch.preconnect }); + const pending = listCodexAuthAccounts(config(), true); + try { + await started.promise; + writeFileSync(authPath, JSON.stringify(replacement)); + expect((await fetchMainAccountInfoAttempt(true, 0)).quotaRefresh?.status).toBe("http_error"); + const cached = structuredClone(getMainAccountInfoCache()); + finish.resolve(); + const main = (await pending).find(account => account.isMain); + expect(main?.plan).toBe("plus"); + expect(main?.quota?.shortPercent).toBe(99); + expect(main?.quota?.weeklyPercent).toBeUndefined(); + expect(main?.mainAccountHardLock?.state).toBe("blocked"); + expect(getMainAccountInfoCache()).toEqual(cached); + } finally { + finish.resolve(); + await pending; + } + }); + for (const status of [401, 403]) { for (const phase of ["request", "error-body"] as const) { test.each(["unchanged", "replaced", "restored"] as const)(`terminal ${status} delayed ${phase} respects %s credentials`, async transition => { From 908c2d44873024f816e86e56aa510a587da92513 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 22:08:31 +0900 Subject: [PATCH 7/8] fix(codex): recheck stored main credential before quota publication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 정우철 --- .../ko/reference/cli/providers-accounts.md | 4 +- .../docs/reference/cli/providers-accounts.md | 8 ++- src/codex/auth-api/main-account-probe.ts | 21 +++++-- src/providers/quota/vendor-probes-oauth.ts | 6 +- structure/providers/openai-tiers.md | 15 +++-- .../main-account-hard-lock-recovery.test.ts | 61 ++++++++++++++++++- tests/providers/provider-quota.test.ts | 21 +++++++ 7 files changed, 119 insertions(+), 17 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 1d3e0e4877..5f94fd837b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -107,9 +107,11 @@ Pool 모드에서 사용량 조회의 `--refresh`는 캐시 유효기간을 무 현재 창에는 동일한 98% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 요구하지 않습니다. 2차·3차 필드가 생략되었거나, 1차 창의 기간을 모르거나, 응답 헤더만 일부 도착한 경우에는 이전 차단을 해제하지 않습니다. -같은 계정에서도 인증 토큰 교체가 관측되면 교체 전 요청의 지연 응답은 사용량 캐시나 차단 상태를 갱신하지 않습니다. +지연 응답을 반영하기 전에 저장된 인증정보를 다시 확인합니다. 파일을 읽을 수 없거나 같은 계정의 인증 토큰이 +교체되었다면 별도 사용량 조회가 없어도 이전 응답은 사용량 캐시나 차단 상태를 갱신하거나 새 토큰을 재인증 대상으로 표시하지 않습니다. 해당 요청자에게 파싱된 조회 결과를 반환할 수는 있지만, 공유 상태나 차단 해제 근거에는 반영하지 않습니다. 계정 카드에는 공유 캐시에 반영된 사용량만 표시하여 차단 상태와 수치가 일치하도록 합니다. +Direct 모드의 공급자 사용량 보고서에서도 공유 상태에 반영되지 않은 응답과 이전 캐시 보고서를 표시하지 않습니다. 계정 정보가 충돌하거나 이전 토큰의 401/403 응답이 늦게 도착한 경우에는 현재 캐시를 유지하고 재인증 상태를 변경하지 않습니다. 저장되는 옵션은 OpenCodex의 `config.json`에 있는 `"codexMainAccountHardLock"`입니다. 값이 없거나 diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 3068cbbc8f..42022ab361 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -168,11 +168,13 @@ or also explicitly last at least 24 hours and report their usage. This follows t one-day window qualifies as well as weekly/monthly windows. The current window still uses the same 98% threshold. This relies on the single reported snapshot; repeated observations are not required. Omitted secondary/tertiary fields, an unknown primary duration, or partial response headers cannot clear a previous block. -Once a credential replacement is observed, a delayed response from an earlier request cannot update -the usage cache or release the lock, even for the same account or after restoring the original token. +The proxy checks the stored credential again before applying a delayed response. An unreadable file +or replaced bearer cannot update the usage cache, release the lock, or quarantine the new credential, +even for the same account with no second quota read. Its parsed ordinary usage can still be returned to the requesting caller, without shared-state updates or recovery evidence. The account card shows the published cached usage, keeping its quota aligned -with the lock status. Conflicting account identities and stale 401/403 replies retain the current +with the lock status; Direct provider quota omits an unpublished response and its older cached report. Conflicting account +identities and stale 401/403 replies retain the current cached info and cannot clear or set the current account's reauthentication state. The persisted option is `"codexMainAccountHardLock"` in OpenCodex's `config.json`. An absent key or diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 585aae5b01..6e404df63a 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -116,6 +116,8 @@ export interface MainAccountInfoFetchResult { export interface MainAccountInfoSnapshot { info: MainAccountInfo; + /** Ordinary info that was never published and must not become provider quota. */ + infoUnpublished?: true; mainIdentityGeneration: number; quotaRefresh?: CodexQuotaRefreshOutcome; } @@ -124,6 +126,7 @@ export async function fetchMainAccountInfoSnapshot(forceRefresh = false, config? const result = await fetchMainAccountInfoAttempt(forceRefresh, 1, undefined, false, forceRefresh, false, config); return { info: result.info, + ...(result.infoUnpublished ? { infoUnpublished: true as const } : {}), ...(result.quotaRefresh && result.quotaRefreshGeneration !== undefined && isMainAccountIdentityGenerationLive(result.quotaRefreshGeneration) ? { quotaRefresh: result.quotaRefresh } : {}), @@ -241,11 +244,19 @@ export async function fetchMainAccountInfoWhileOwned( ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); - /** Revalidate identity, bearer and its generation after each upstream await. */ - const credentialIsCurrent = (): boolean => mainQuotaWriter !== undefined - && isMainQuotaWriterLive(mainQuotaWriter) - && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() - && matchesMainQuotaCredential(tokens.access_token, tokens.account_id); + /** A disk replacement may have no second probe to advance the credential generation. */ + const credentialIsCurrent = (): boolean => { + const current = readCodexTokensResult(undefined, { bounded: true }); + if (current.status !== "ok") return false; + const effectiveAccountId = extractAccountId(current.tokens.id_token, current.tokens.access_token) + ?? (current.tokens.account_id || null); + if (effectiveAccountId !== current.tokens.account_id || effectiveAccountId !== requestAccountId) return false; + observeMainQuotaCredential(current.tokens.access_token, current.tokens.account_id); + return mainQuotaWriter !== undefined + && isMainQuotaWriterLive(mainQuotaWriter) + && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() + && matchesMainQuotaCredential(tokens.access_token, tokens.account_id); + }; // Keep diagnostics separate from authentication and freshness policy. Never serialize errors. const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS); let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; diff --git a/src/providers/quota/vendor-probes-oauth.ts b/src/providers/quota/vendor-probes-oauth.ts index fcad53a267..56ad530a7b 100644 --- a/src/providers/quota/vendor-probes-oauth.ts +++ b/src/providers/quota/vendor-probes-oauth.ts @@ -18,6 +18,7 @@ import { aggregateCodexPoolCapacity, CODEX_CAPACITY_MAX_QUOTA_AGE_MS, type Codex import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS, toFiniteNumber } from "../quota-wire"; import { providerCodexAccountMode } from "../registry"; import { + TERMINAL_QUOTA_FAILURE, hasQuotaRows, providerLabel, providerQuotaFromCodexQuota, @@ -25,6 +26,7 @@ import { report, tagNativeMainReport, type CodexAuthAccountsSnapshotPromise, + type ProviderQuotaProbeResult, type ProviderQuotaReport, } from "./report-cache"; import { @@ -46,9 +48,11 @@ export async function fetchChatGptForwardQuota( providerConfig: OcxProviderConfig, forceRefresh: boolean, prefetchedSnapshot?: CodexAuthAccountsSnapshotPromise, -): Promise { +): Promise { if (providerCodexAccountMode(provider, providerConfig) === "direct") { const snapshot = await fetchMainAccountInfoSnapshot(forceRefresh, config); + // A parsed return from a replaced credential cannot retain an older cached report either. + if (snapshot.infoUnpublished) return TERMINAL_QUOTA_FAILURE; const quota = providerQuotaFromCodexQuota(snapshot.info.quota); if (quota) quota.updatedAt = Date.now(); return quota diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 939908447a..20498e6319 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -377,14 +377,17 @@ invalidates old evidence. Request-owned bearers are matched only against a crede workspace already observed under native ownership; an unrelated or unmatched keyring credential is not attributed to stored main and introduces no physical-main read. Credential equality tags remain process-local and never enter disk, logs, or management DTOs. -`src/codex/auth-api/main-account-probe.ts` rechecks the captured credential generation and bearer -after body/retry awaits, before publishing main usage, credits, plan, reauth or Reserve state, -including terminal 401/403 mutations. A missing identity writer cannot bypass this check. -An observed same-account credential replacement, including A→B→A, prevents publication even -when a newer read fails without publishing; an unchanged credential still permits an older success. +`src/codex/auth-api/main-account-probe.ts` re-reads the bounded stored main credential and +rechecks its writer, bearer and generation after body/retry awaits, before publishing main usage, +credits, plan, reauth or Reserve state, including terminal 401/403 mutations. An unreadable file +or missing identity writer cannot bypass this check. A same-account bearer replacement is detected +even with no second probe; an observed A→B→A transition remains fenced by its generation. An +unchanged credential still permits an older success. Successful same-identity responses may still return parsed ordinary info to their caller, without shared-state updates, fresh quota or recovery proof. The account-list card uses the published cache -for such a return, so its displayed quota agrees with the hard-lock state. Conflicting identities +for such a return, so its displayed quota agrees with the hard-lock state. The snapshot retains the +unpublished marker, and Direct provider quota drops that response and any older cached report +rather than reporting stale windows. Conflicting identities and stale errors return cached info. The request/body races and card projection are covered by `tests/codex-integration/main-account-hard-lock-recovery.test.ts`; the ordinary return and Reserve revocation contract remains covered by `tests/codex-integration/reserve-passive-revocation.test.ts`. diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index bedbf4e95e..f8b719207e 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -3,7 +3,8 @@ import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { - fetchMainAccountInfo, listCodexAuthAccounts, registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecovery, + fetchMainAccountInfo, fetchMainAccountInfoSnapshot, listCodexAuthAccounts, + registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecovery, } from "../../src/codex/auth-api"; import { fetchMainAccountInfoAttempt } from "../../src/codex/auth-api/main-account-probe"; import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; @@ -446,6 +447,64 @@ describe("main hard-lock background recovery", () => { } }); + for (const status of [200, 401, 403]) { + test.each(["unchanged", "replaced", "unreadable"] as const)( + `single delayed ${status} checks the stored credential: %s`, async transition => { + const started = deferred(); + const finish = deferred(); + const authPath = join(home, "auth.json"); + const replacement = JSON.parse(readFileSync(authPath, "utf8")); + replacement.tokens.access_token += "-rotated"; + setMainAccountInfoCache({ email: null, plan: "plus", quota: { shortPercent: 99 }, ts: 1 }); + if (status === 200) markAccountNeedsReauth(MAIN); + let reads = 0; + globalThis.fetch = Object.assign(async (input: Parameters[0]) => { + expect(String(input)).toBe(whamUrl); + reads++; + started.resolve(); + await finish.promise; + return status === 200 ? Response.json({ plan_type: "prolite", rate_limit: { + primary_window: { used_percent: 64, limit_window_seconds: 604_800 }, + secondary_window: null, tertiary_window: null, + } }) : Response.json({ error: { code: "invalid_workspace_selected" } }, { status }); + }, { preconnect: previousFetch.preconnect }); + const pending = fetchMainAccountInfoSnapshot(true, config()); + try { + await started.promise; + if (transition === "replaced") writeFileSync(authPath, JSON.stringify(replacement)); + if (transition === "unreadable") writeFileSync(authPath, "{"); + const cached = structuredClone(getMainAccountInfoCache()); + const policy = getMainPolicyQuota(); + finish.resolve(); + const snapshot = await pending; + expect(reads).toBe(1); + if (transition === "unchanged") { + if (status === 200) { + expect(getMainAccountInfoCache()?.quota?.weeklyPercent).toBe(64); + expect(getMainAccountHardLockStatus(config()).state).toBe("ready"); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + expect(snapshot.infoUnpublished).toBeUndefined(); + expect(snapshot.quotaRefresh?.status).toBe("ok"); + } else { + expect(getMainAccountInfoCache()).toBeNull(); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(snapshot.quotaRefresh).toEqual({ status: "http_error", httpStatus: status }); + } + } else { + expect(getMainAccountInfoCache()).toEqual(cached); + expect(getMainPolicyQuota()).toEqual(policy); + expect(getMainAccountHardLockStatus(config()).state).toBe("blocked"); + expect(isAccountNeedsReauth(MAIN)).toBe(status === 200); + expect(snapshot.quotaRefresh).toBeUndefined(); + if (status === 200) expect(snapshot.infoUnpublished).toBe(true); + } + } finally { + finish.resolve(); + await pending; + } + }); + } + for (const status of [401, 403]) { for (const phase of ["request", "error-body"] as const) { test.each(["unchanged", "replaced", "restored"] as const)(`terminal ${status} delayed ${phase} respects %s credentials`, async transition => { diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 2235e33f7b..a273d45673 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -118,6 +118,27 @@ afterEach(() => { }); describe("fetchProviderQuotaReports", () => { + test("direct main omits unpublished usage but reports a published snapshot", async () => { + const config = testConfig(); + config.providers = { openai: { ...config.providers.openai!, codexAccountMode: "direct" } }; + const info = { email: null, plan: "plus", quota: { weeklyPercent: 64 } }; + const snapshot = { info, mainIdentityGeneration: 1 }; + const probe = spyOn(authApi, "fetchMainAccountInfoSnapshot") + .mockImplementation(async () => snapshot); + try { + const published = await fetchProviderQuotaReports(config, true); + expect(published.reports.find(row => row.provider === "openai")?.quota.weeklyPercent).toBe(64); + probe.mockImplementation(async () => ({ ...snapshot, infoUnpublished: true as const })); + const stale = await fetchProviderQuotaReports(config, true); + expect(stale.reports.find(row => row.provider === "openai")).toBeUndefined(); + probe.mockImplementation(async () => snapshot); + const refreshed = await fetchProviderQuotaReports(config, true); + expect(refreshed.reports.find(row => row.provider === "openai")?.quota.weeklyPercent).toBe(64); + } finally { + probe.mockRestore(); + } + }); + test("provider quota probes have no direct Response.json calls", () => { // Probes live in leaves now; the facade alone no longer holds one. for (const p of ["quota.ts", "quota/vendor-probes-key.ts", "quota/vendor-probes-oauth.ts", "quota/antigravity.ts"]) expect(readFileSync(repoPath(`src/providers/${p}`), "utf8")).not.toMatch(/\.\s*json\s*\(/); From 04bb5a63037b2c3bc11320a52e594db5fef80bec Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 22:33:00 +0900 Subject: [PATCH 8/8] test(codex): bind direct quota fixture to live main identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 정우철 --- tests/providers/provider-quota.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index a273d45673..fd4ee2710f 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import * as authApi from "../../src/codex/auth-api"; import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; -import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { clearAccountQuota, updateAccountQuota, type StoredAccountQuota } from "../../src/codex/quota"; import { clearCodexUpstreamHealth } from "../../src/codex/routing"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; @@ -122,7 +122,7 @@ describe("fetchProviderQuotaReports", () => { const config = testConfig(); config.providers = { openai: { ...config.providers.openai!, codexAccountMode: "direct" } }; const info = { email: null, plan: "plus", quota: { weeklyPercent: 64 } }; - const snapshot = { info, mainIdentityGeneration: 1 }; + const snapshot = { info, mainIdentityGeneration: captureMainAccountIdentityGeneration() }; const probe = spyOn(authApi, "fetchMainAccountInfoSnapshot") .mockImplementation(async () => snapshot); try {