From 48789e73f0baa539b37929902533e9ccd9589fe8 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:09:48 +0900 Subject: [PATCH 1/3] fix(codex): recover stale main locks from two-window usage --- .../ko/reference/cli/providers-accounts.md | 5 +- .../docs/reference/cli/providers-accounts.md | 6 ++- src/codex/quota-types.ts | 3 +- src/codex/quota.ts | 9 ++-- structure/providers/openai-tiers.md | 4 +- .../main-quota-evidence-validation.test.ts | 47 +++++++++++++++++++ 6 files changed, 67 insertions(+), 7 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 5f213530fab..811b9fe214e 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,7 +101,10 @@ Reserve입니다. 차단 중에는 그 메인 계정의 Reserve를 활성화할 2차·3차 창이 명시적 `null`이거나 그 기간도 24시간 이상으로 명시되고 사용량 수치도 함께 오면 이전 5h 수치를 대체합니다. 파서의 단기·장기 구분 기준을 따르므로 주간·월간뿐 아니라 하루짜리 창도 해당합니다. 현재 창에는 동일한 98% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 -요구하지 않습니다. 2차·3차 필드가 생략되었거나, 1차 창의 기간을 모르거나, 응답 헤더만 일부 +요구하지 않습니다. WHAM이 선택적인 3차 필드를 생략한 경우에도, 2차 창이 명시적 `null`이고 +`rate_limit.allowed`가 `true`, `rate_limit.limit_reached`가 `false`이며 1차 창이 앞의 조건을 +만족하면 이전 5h 수치를 대체합니다. 화면은 98% 미만인데 정책 캐시에 오래된 5h 100%가 남아 +차단되던 주간 전용 계정도 사용량 새로고침으로 복구됩니다. 그 외 필드 누락, 1차 창의 기간을 모르거나, 응답 헤더만 일부 도착한 경우에는 이전 차단을 해제하지 않습니다. 저장되는 옵션은 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 061c7fa601e..b24171fccb8 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -164,7 +164,11 @@ its primary window explicitly lasts **at least 24 hours** and secondary/tertiary or also explicitly last at least 24 hours and report their usage. This follows the parser's short/long boundary, so a 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. +WHAM can omit the optional tertiary field. That two-window response also replaces the old 5h value +when secondary is explicitly `null`, `rate_limit.allowed` is `true`, and `rate_limit.limit_reached` +is `false`, with the same measured long primary requirement. Other omissions, an unknown primary +duration, or partial response headers cannot clear a previous block. This lets a successful quota +refresh recover a weekly-only account whose display is below 98% but whose policy retained an old 5h 100% value. 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/quota-types.ts b/src/codex/quota-types.ts index c5e8946d80c..0c20e990c5a 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -113,7 +113,8 @@ export type WhamUsageResponse = { rate_limit_upsell?: { banner_type?: unknown } | null; rate_limit?: { allowed?: unknown; - // WHAM sends explicit nulls for absent windows. + limit_reached?: unknown; + // WHAM can omit optional tertiary; an absent secondary is explicitly null. primary_window?: WhamUsageWindow | null; secondary_window?: WhamUsageWindow | null; tertiary_window?: WhamUsageWindow | null; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 6f27fd32a2b..673fac3321a 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -823,21 +823,24 @@ function filterMainPolicyMonthlyQuota( /** * Parse ordinary main-policy usage, rejecting messages with invalid numeric window percentages. * Mark a valid primary of at least 24h as replacement evidence only when both other windows - * are explicitly null or at least 24h. A null result supplies no usable policy observation. + * are explicitly null or at least 24h. An allowed, non-exhausted two-window response may omit + * tertiary only when secondary is explicitly null. A null result supplies no policy observation. */ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQuotaObservation | null { const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null; const quota = filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type)); const [primary, secondary, tertiary] = windows; - // WHAM explicitly reports absent windows as null; omissions cannot prove replacement. + // The two-window WHAM shape can omit tertiary; secondary must still be explicit. + const allowedTwoWindow = secondary === null && !Object.hasOwn(data.rate_limit!, "tertiary_window") + && data.rate_limit?.allowed === true && data.rate_limit?.limit_reached === false; // Policy trusts one complete snapshot only when every non-null window is >=24h AND // carries a valid usage reading: a long window without used_percent leaves that // window's usage unknown, and unknown usage must never release a block. // Headers never supply this proof, and reset time alone still cannot release a block. if (quota && normalizeUsagePercent(primary?.used_percent) !== undefined && isExplicitLongWindow(primary) && (secondary === null || isMeasuredLongWindow(secondary)) - && (tertiary === null || isMeasuredLongWindow(tertiary))) { + && (tertiary === null || isMeasuredLongWindow(tertiary) || allowedTwoWindow)) { return { ...quota, shortWindowAbsent: true }; } return quota; diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 733cd1aec8c..00a2ebc6470 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -330,7 +330,9 @@ short-window tuple when secondary and tertiary windows are explicitly null or al Long means **at least 24 hours**, matching the parser's short/long discriminator; a one-day primary qualifies, not only a seven-day or monthly window. The policy trusts that one reported topology; it does not require repeated observations or independently confirm upstream window completeness. -Omitted secondary/tertiary fields, a long auxiliary window without a usage reading, an unknown primary duration, partial headers, or invalid usage cannot prove that the +An omitted tertiary is also accepted for the two-window WHAM shape only when secondary is explicitly null, +`rate_limit.allowed` is exactly true, and `rate_limit.limit_reached` is exactly false; the measured long primary is still required. +Other omissions, a long auxiliary window without a usage reading, an unknown primary duration, partial headers, or invalid usage cannot prove that the short window disappeared. Replacement proof belongs only to that observation and is never persisted; the resulting weekly/monthly window still blocks at 98%. This prevents old short-window exhaustion from surviving indefinitely on a now weekly/monthly account. Coverage lives in diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts index fb87c15c3b9..32f2e9ad0ba 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -239,6 +239,53 @@ describe("main policy window replacement", () => { expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); }); + test.each([64, 97.99, 98, 100])("allowed two-window WHAM retires stale short evidence at %s percent", percent => { + retainedShort(); + // Sanitized shape observed on an updated Windows install: tertiary is absent, not null. + const data: WhamUsageResponse = { plan_type: "prolite", rate_limit: { + allowed: true, limit_reached: false, + primary_window: { used_percent: percent, limit_window_seconds: weeklySeconds }, secondary_window: null, + } }; + expect(parseMainPolicyUsageQuota(data)?.shortWindowAbsent).toBe(true); + publish(data); + expect(getMainPolicyQuota()?.shortPercent).toBeUndefined(); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(percent); + expect(getMainPolicyQuota()).not.toHaveProperty("shortWindowAbsent"); + expect(getMainAccountHardLockStatus(cfg).state).toBe(percent < 98 ? "ready" : "blocked"); + }); + + test.each([ + { allowed: undefined }, { allowed: false }, { allowed: "true" }, + { limit_reached: undefined }, { limit_reached: true }, { limit_reached: "false" }, + { secondary_window: undefined }, { secondary_window: {} }, + { tertiary_window: undefined }, { tertiary_window: {} }, + { tertiary_window: { used_percent: 0, limit_window_seconds: 18_000 } }, + { primary_window: { used_percent: 64 } }, + { primary_window: { used_percent: 101, limit_window_seconds: weeklySeconds } }, + ])("incomplete or contradictory two-window evidence retains the block: %j", patch => { + retainedShort(); + const data = { rate_limit: { + allowed: true, limit_reached: false, + primary_window: { used_percent: 64, limit_window_seconds: weeklySeconds }, + secondary_window: null, ...patch, + } } as WhamUsageResponse; + expect(parseMainPolicyUsageQuota(data)?.shortWindowAbsent).toBeUndefined(); + publish(data); + expect(getMainPolicyQuota()?.shortPercent).toBe(100); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + + test("two-window evidence from a superseded writer cannot retire the block", () => { + const staleWriter = writerFor("fixture-main-b"); + retainedShort(); + const data: WhamUsageResponse = { rate_limit: { + allowed: true, limit_reached: false, + primary_window: { used_percent: 64, limit_window_seconds: weeklySeconds }, secondary_window: null, + } }; + setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined, staleWriter, parseMainPolicyUsageQuota(data)); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + test("an explicit null secondary and long tertiary permit replacement", () => { retainedShort(); publish({ rate_limit: { From 5f30f50938c0960791306deea15515ed8c2bbb17 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 2/3] fix(codex): fence main quota publication after credential rotation --- .../ko/reference/cli/providers-accounts.md | 5 +- .../docs/reference/cli/providers-accounts.md | 2 + src/codex/auth-api/main-account-probe.ts | 9 ++- structure/providers/openai-tiers.md | 6 ++ .../main-account-hard-lock-recovery.test.ts | 72 ++++++++++++++++++- 5 files changed, 87 insertions(+), 7 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 811b9fe214e..263cdb565b3 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 @@ -97,15 +97,16 @@ Reserve입니다. 차단 중에는 그 메인 계정의 Reserve를 활성화할 실제 사용량을 다시 확인하며, 조회 실패나 잘못된 수치는 차단을 풀지 않습니다. 일시정지, 재인증, 서버의 사용량 제한은 별도로 적용됩니다. -새로운 유효한 WHAM 사용량 응답 한 건에서 1차 창의 기간이 **24시간 이상**으로 명시되고, +새로운 유효한 WHAM 사용량 응답 한 건에서 1차 창의 기간이 **24시간 이상**으로 명시되고 유효한 사용량 수치가 있으며, 2차·3차 창이 명시적 `null`이거나 그 기간도 24시간 이상으로 명시되고 사용량 수치도 함께 오면 이전 5h 수치를 대체합니다. 파서의 단기·장기 구분 기준을 따르므로 주간·월간뿐 아니라 하루짜리 창도 해당합니다. 현재 창에는 동일한 98% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 요구하지 않습니다. WHAM이 선택적인 3차 필드를 생략한 경우에도, 2차 창이 명시적 `null`이고 `rate_limit.allowed`가 `true`, `rate_limit.limit_reached`가 `false`이며 1차 창이 앞의 조건을 -만족하면 이전 5h 수치를 대체합니다. 화면은 98% 미만인데 정책 캐시에 오래된 5h 100%가 남아 +만족하고 유효한 사용량 수치도 함께 있으면 이전 5h 수치를 대체합니다. 화면은 98% 미만인데 정책 캐시에 오래된 5h 100%가 남아 차단되던 주간 전용 계정도 사용량 새로고침으로 복구됩니다. 그 외 필드 누락, 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 b24171fccb8..11156b07ba2 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -169,6 +169,8 @@ when secondary is explicitly `null`, `rate_limit.allowed` is `true`, and `rate_l is `false`, with the same measured long primary requirement. Other omissions, an unknown primary duration, or partial response headers cannot clear a previous block. This lets a successful quota refresh recover a weekly-only account whose display is below 98% but whose policy retained an old 5h 100% value. +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 792d30d250c..26519ccfd6f 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -253,9 +253,12 @@ 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)) { + // Check after body/retry awaits and before any cache, credits, policy or Reserve publication. + // Same-account bearer replacement (including A→B→A) also retires the old response, + // even when the newer read failed without publishing. Cached state supplies no recovery proof. + 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 00a2ebc6470..10b343e854b 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -346,6 +346,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 5517374e4de..dccbf2bfc2d 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -5,13 +5,14 @@ import { join } from "node:path"; import { fetchMainAccountInfo, 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"; 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"; @@ -131,6 +132,73 @@ afterEach(async () => { }); describe("main hard-lock background recovery", () => { + 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: { primary_window: { used_percent: 35, limit_window_seconds: 604_800 }, secondary_window: null, tertiary_window: null, From f3750ebc7eafb845dcf426ae070875601f5dc1b6 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 3/3] 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 | 46 ++++--- 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, 154 insertions(+), 25 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 263cdb565b3..168701185bd 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,6 +107,8 @@ Reserve입니다. 차단 중에는 그 메인 계정의 Reserve를 활성화할 차단되던 주간 전용 계정도 사용량 새로고침으로 복구됩니다. 그 외 필드 누락, 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 11156b07ba2..59e0e0f0359 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -171,6 +171,9 @@ duration, or partial response headers cannot clear a previous block. This lets a refresh recover a weekly-only account whose display is below 98% but whose policy retained an old 5h 100% value. 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 26519ccfd6f..1a377a0b53e 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -91,9 +91,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; } @@ -174,6 +174,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, @@ -212,6 +217,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"; @@ -227,7 +237,7 @@ export async function fetchMainAccountInfoWhileOwned( const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; - if (!isQuotaDispatchCurrent(dispatchSequence)) { + if (!isQuotaDispatchCurrent(dispatchSequence) || !credentialIsCurrent()) { return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; } @@ -253,20 +263,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. - // Same-account bearer replacement (including A→B→A) also retires the old response, - // even when the newer read failed without publishing. Cached state supplies no 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"; @@ -275,16 +280,23 @@ 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 the ordinary same-identity return contract, but publish no cache, plan, + // reauth, credits or hard-lock evidence. A missing writer is never permission to publish. + 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 @@ -310,9 +322,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 10b343e854b..4bc689437ce 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -347,11 +347,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 20d21eddb28..efa009484de 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 dccbf2bfc2d..5c1b7ef95e4 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"; @@ -182,6 +182,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); @@ -199,6 +201,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,