From 20d76a79ad1d79694c4bf8794a1afe588b0aff60 Mon Sep 17 00:00:00 2001 From: OpenCodex Tier Replay Date: Tue, 6 Oct 2026 17:16:32 +0900 Subject: [PATCH 1/8] fix(codex): renew main credit evidence before expiry --- .../content/docs/guides/codex-integration.md | 2 ++ .../docs/zh-cn/guides/codex-integration.md | 2 ++ src/codex/auth-api/pool-mode-gate.ts | 27 ++++++++++++++----- structure/providers/openai-tiers.md | 8 ++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index b439ef26acc..c127cc7bc0e 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1043,6 +1043,8 @@ In **Codex Set → Multi-auth**, enable the **Codex credits** switch in the **Co When an account reaches 100% on a usage window and still holds credits, upstream keeps serving it and draws the balance. OpenCodex does not let that happen by default: an account at 100% is switched out while its weekly or monthly window (only monthly on 30-day plans) or its 5-hour window is full, and used again once that window resets. The order of the other accounts does not change, and when no other account is available, selection finds none rather than spending credits. A request for the main account is refused like a hard-lock refusal until the reset. A full 5-hour window without a reset time holds an account only while that reading is fresh. Allowing the main account does not lift its hard lock (on by default at 98%), which still stops it first; turn the lock off if the main account should spend credits (a lock at 100% still stops it at 100%). To let accounts keep working from their credits, turn on **Use credits** next to the **Codex credits** switch in the Codex Auth header. The switch allows every account. To choose accounts one by one, open an account card's **⋯** menu and use **Use credits after limit** there; the header switch shows a middle position when only some accounts are on. An account allowed to spend carries a **Uses credits** badge on its card. New accounts start off. The choice is stored in `creditCodexAccountIds` and never redeems reset credits; the **Codex credits** display switch only shows balances and never changes routing. +For the main account, enabling **Use credits after limit** also lets the existing recovery cycle refresh its credit balance before the cached observation expires while a usage window is full. This is an authenticated usage lookup, not a model validation request. An empty balance, a spending restriction, a failed refresh or the separate main-account hard lock can still prevent requests from serving. + Background revalidation is separate and off by default. It requires Token Guardian, the `openai` provider's `proactive` refresh policy, and `tokenGuardian.codexWarmupEnabled`. It skips accounts awaiting deferred registration validation. ### Cancelling main-account device reauthentication diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 9b19850626b..8a7aa7db5d9 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -327,6 +327,8 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 在 **Codex Set → Multi-auth** 中,打开 **Codex Auth** 标题栏中的 **Codex 额度** 开关,即可在 Week 下方显示主账号和各池账号最近查询到的积分。默认关闭,并保存为 `showCodexCredits`。余额按地区格式显示;上游报告时会显示无限额或超额使用上限警告。由于没有积分总上限,条形表示可用状态而非百分比。此开关仅控制显示,新登录需等待其自身的查询结果。 +主账号允许在额度耗尽后使用积分时,现有恢复周期会在用量窗口已满且缓存观察即将过期时刷新积分余额。这是已认证的用量查询,不发送模型验证请求。余额为空、支出限制、刷新失败或独立的主账号硬锁仍可能阻止请求。 + 后台重新验证是独立功能,默认关闭。它要求 Token Guardian、`openai` 的 `proactive` 刷新策略及 `tokenGuardian.codexWarmupEnabled`,并跳过等待注册验证的账号。 ### 账号停止处理请求的原因 diff --git a/src/codex/auth-api/pool-mode-gate.ts b/src/codex/auth-api/pool-mode-gate.ts index e3c9393bff7..51d9ed27feb 100644 --- a/src/codex/auth-api/pool-mode-gate.ts +++ b/src/codex/auth-api/pool-mode-gate.ts @@ -1,15 +1,18 @@ -import { codexAccountUsesCreditsAfterLimit } from "../account-credit-use"; +import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt } from "../account-credit-use"; import { nextCodexUsageQueryAt, nextQuotaQueryDelay, pruneRemovedCodexPoolUsageAccounts } from "../quota-query-backoff"; import { CODEX_PRIORITY_FAILBACK_REFRESH_MS } from "../account-priority"; import { codexQuotaHasFreshUsage } from "../quota-observation-freshness"; import { getCodexAccountCredential, getValidCodexToken, readCodexAccountRecord } from "../account-store"; -import { getAccountQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota"; +import { getAccountQuota, getMainPolicyQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota"; +import { isCodexAccountPaused } from "../account-pause"; +import { hasSpendableCodexCredits } from "../quota-types"; +import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing"; import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { claimDueCodexQuotaRecoveryProbes, settleCodexQuotaRecoveryProbe } from "../routing"; import { readCodexTokens } from "../auth-collision"; import { isAccountNeedsReauth } from "../account-runtime-state"; import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account"; -import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper"; +import { captureConfigGeneration, registerStateSweepAfterTick, STATE_SWEEP_INTERVAL_MS } from "../../lib/state-store-sweeper"; import { observeMainQuotaCredential, getMainQuotaCredentialGeneration, captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache"; import { getMainAccountHardLockStatus } from "../main-account-hard-lock"; import type { OcxConfig } from "../../types"; @@ -79,11 +82,21 @@ let mainHardLockRecoveryAttempt: { identity: number; credential: number; after: /** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise { if (mainHardLockRecoveryInFlight) return mainHardLockRecoveryInFlight; + const creditRecoveryNeeded = (): boolean => { + const now = Date.now(); + const quota = getMainPolicyQuota(); + // Only schedule an earlier real observation. Admission still uses the actual clock, + // and a successful WHAM response supplies its own identity-bound observedAt. + return codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID) + && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && codexUsageLimitResetAt(quota, undefined, now) !== undefined + && !hasSpendableCodexCredits(quota, now + STATE_SWEEP_INTERVAL_MS + WHAM_REQUEST_TIMEOUT_MS); + }; const status = getMainAccountHardLockStatus(config); - if (status.state !== "blocked") { mainHardLockRecoveryAttempt = undefined; return; } + if (status.state !== "blocked" && !creditRecoveryNeeded()) { mainHardLockRecoveryAttempt = undefined; return; } if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; // A predicted reset is a scheduling hint, never proof that the hard lock can be lifted. - if (status.resetAt !== undefined && status.resetAt > Date.now()) return; + if (status.state === "blocked" && status.resetAt !== undefined && status.resetAt > Date.now()) return; // Reconcile the owned physical credential before skipping work for an older one. reconcileMainCodexAccountRuntimeState(); const physical = readCodexTokens(); @@ -98,7 +111,7 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise if (!lease) return; mainHardLockRecoveryInFlight = (async () => { reconcileMainCodexAccountRuntimeState(); - if (getMainAccountHardLockStatus(config).state !== "blocked" + if ((getMainAccountHardLockStatus(config).state !== "blocked" && !creditRecoveryNeeded()) || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; const identityGeneration = captureMainAccountIdentityGeneration(); try { @@ -138,7 +151,7 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise if (transientAuth) mainHardLockRecoveryAttempt = undefined; else { const delay = nextQuotaQueryDelay(previousDelay || undefined); - mainHardLockRecoveryAttempt = getMainAccountHardLockStatus(config).state === "blocked" + mainHardLockRecoveryAttempt = getMainAccountHardLockStatus(config).state === "blocked" || creditRecoveryNeeded() ? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter) } : undefined; } diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 99202c57eee..a1d7737c565 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -316,6 +316,14 @@ payloads cannot clear cooldowns, actual request refusals still drive cooldown/fa default-on main-account hard lock retains its separate local admission policy. Registration warmup remains conservative and does not spend paid credits to validate an exhausted account. +For an opted-in, unpaused main account with a currently full usage window, the existing +`src/codex/auth-api/pool-mode-gate.ts` recovery sweep refreshes authenticated WHAM evidence before +its credit observation expires. Scheduling looks ahead by the shared sweep interval and WHAM +timeout; admission still evaluates the actual clock and the original spending controls. Native +profile ownership, credential generations, single-flight, query pacing and failure backoff remain +in force. The hard lock remains independent, and refreshing usage never redeems reset credits or +validates a pending account through inference. + Credit parsing, expiry, partial updates and reset-ticket separation are covered in `tests/codex-integration/codex-quota-parser-parity.test.ts`; selection and bulk-pause behavior are covered in `tests/codex-integration/codex-credits-after-limit.test.ts` and From 53d0886b6d9c079327c023e93e9f7e82c58d84c5 Mon Sep 17 00:00:00 2001 From: OpenCodex Tier Replay Date: Tue, 6 Oct 2026 18:00:35 +0900 Subject: [PATCH 2/8] test(codex): cover main credit evidence recovery --- .../main-account-hard-lock-recovery.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) 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 72f7bac7a25..ac4c9eaea1f 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -14,6 +14,8 @@ import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountInfoCa import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { isMainAccountRefreshGrantRejected, setMainAccountPlan } from "../../src/codex/main-account"; import { clearAccountQuota, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { CODEX_CREDITS_FRESHNESS_MS, hasSpendableCodexCredits } from "../../src/codex/quota-types"; +import { WHAM_REQUEST_TIMEOUT_MS } from "../../src/codex/quota-recovery-timing"; import { clearCodexUpstreamHealth, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -70,6 +72,25 @@ function usage(percent = 0): Response { } }); } +function creditRecoveryConfig(now: number, observedAt = now - CODEX_CREDITS_FRESHNESS_MS + 60_000, + resetAt = now + 3_600_000): OcxConfig { + clearAccountQuota(MAIN); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: resetAt, + credits: { hasCredits: true, unlimited: false, balance: 5, allowed: true, + overageLimitReached: false, observedAt } }, undefined, captureMainQuotaWriter(accountId)); + return { ...config(), codexMainAccountHardLock: false, creditCodexAccountIds: [MAIN] }; +} + +function creditUsage(restriction?: string): Response { + return Response.json({ plan_type: "plus", rate_limit: { + primary_window: { used_percent: 100, limit_window_seconds: 604_800, + reset_at: Math.floor((Date.now() + 3_600_000) / 1000) }, + secondary_window: null, tertiary_window: null, + }, credits: { has_credits: restriction !== "empty", unlimited: false, + balance: restriction === "empty" ? 0 : 5, overage_limit_reached: restriction === "overage" }, + spend_control: { reached: restriction === "spending-off" } }); +} + /** * Stub recovery HTTP calls, requiring a known metadata/token URL and an active native-main drain. * Return the captured URL list so tests can verify the requests made by background recovery. @@ -135,6 +156,88 @@ afterEach(async () => { } }); +describe("opted-in main credit evidence recovery", () => { + test("renews before credit expiry without extending the admission clock", async () => { + let now = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + const initialObservation = now; + const cfg = creditRecoveryConfig(now, initialObservation); + const expectedBearer = JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).tokens.access_token; + const calls = fetchWith(async (_url, init) => { + expect(new Headers(init?.headers).get("chatgpt-account-id")).toBe(accountId); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${expectedBearer}`); + return creditUsage(); + }); + now += CODEX_CREDITS_FRESHNESS_MS - sweeper.STATE_SWEEP_INTERVAL_MS - WHAM_REQUEST_TIMEOUT_MS; + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([]); + now++; + expect(hasSpendableCodexCredits(getMainPolicyQuota(), now)).toBe(true); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 100, + credits: { observedAt: now, allowed: true, hasCredits: true, balance: 5 } }); + now = initialObservation + CODEX_CREDITS_FRESHNESS_MS + 1; + expect(hasSpendableCodexCredits(getMainPolicyQuota(), now)).toBe(true); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + expect(getNativeMainProfileRequestCount()).toBe(0); + } finally { clock.mockRestore(); } + }); + + test.each(["no-consent", "paused", "reauth", "fresh", "reset", "hard-lock"])( + "%s does not trigger the credit renewal path", async condition => { + const now = Date.now(); + const cfg = creditRecoveryConfig(now, condition === "fresh" ? now : undefined, + condition === "reset" ? now - 1 : undefined); + if (condition === "no-consent") cfg.creditCodexAccountIds = []; + if (condition === "paused") cfg.pausedCodexAccountIds = [MAIN]; + if (condition === "reauth") markAccountNeedsReauth(MAIN); + if (condition === "hard-lock") cfg.codexMainAccountHardLock = true; + const calls = fetchWith(async () => creditUsage()); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([]); + expect(getNativeMainProfileRequestCount()).toBe(0); + }); + + test.each(["empty", "spending-off", "overage"])( + "%s preserves refusal and bounds repeated renewal attempts", async restriction => { + const cfg = creditRecoveryConfig(Date.now()); + const calls = fetchWith(async () => creditUsage(restriction)); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + expect(hasSpendableCodexCredits(getMainPolicyQuota())).toBe(false); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(100); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + }); + + test("credit renewal retains upstream Retry-After until a real later observation", async () => { + let now = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + const cfg = creditRecoveryConfig(now); + const previousObservation = getMainPolicyQuota()?.credits?.observedAt; + let reads = 0; + const calls = fetchWith(async () => ++reads === 1 + ? new Response("{}", { status: 429, headers: { "Retry-After": "900" } }) : creditUsage()); + await runMainAccountHardLockRecovery(cfg); + now += 899_999; + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + expect(getMainPolicyQuota()?.credits?.observedAt).toBe(previousObservation); + expect(hasSpendableCodexCredits(getMainPolicyQuota(), now)).toBe(false); + now++; + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl, whamUrl]); + expect(getMainPolicyQuota()?.credits?.observedAt).toBe(now); + expect(hasSpendableCodexCredits(getMainPolicyQuota(), now)).toBe(true); + } finally { clock.mockRestore(); } + }); +}); + describe("main hard-lock background recovery", () => { test("known reset waits locally, then verifies recovery instead of unlocking by time", async () => { let now = Date.now(); From 743c148fac6e34eee89318531f8890e998b6d363 Mon Sep 17 00:00:00 2001 From: OpenCodex Tier Replay Date: Wed, 7 Oct 2026 00:14:17 +0900 Subject: [PATCH 3/8] fix(codex): distinguish stale credit evidence in refusal hints --- .../content/docs/guides/codex-integration.md | 2 + .../docs/zh-cn/guides/codex-integration.md | 2 + src/codex/auth-api/pool-mode-gate.ts | 1 + src/codex/auth-context.ts | 41 ++++- structure/codex-account-controls.md | 5 + structure/providers/openai-tiers.md | 9 ++ .../codex-credits-after-limit-main.test.ts | 151 +++++++++++++++++- .../main-account-hard-lock-recovery.test.ts | 2 + 8 files changed, 206 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 4d5fddf78e5..80a6b264c2a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1049,6 +1049,8 @@ When an account reaches 100% on a usage window and still holds credits, upstream For the main account, enabling **Use credits after limit** also lets the existing recovery cycle refresh its credit balance before the cached observation expires while a usage window is full. This is an authenticated usage lookup, not a model validation request. An empty balance, a spending restriction, a failed refresh or the separate main-account hard lock can still prevent requests from serving. +The main-account refusal explains whether spending is disabled, the reported balance is unavailable, spending is restricted, or the balance cannot be verified. If previously spendable balance information has expired, the request is still refused but can receive a shorter retry hint. That hint only suggests when to check again; it does not guarantee recovery or shorten a known upstream delay. **Use credits** grants permission, not a balance: a fresh zero balance still refuses, and incomplete usage information does not renew old balance evidence. + Background revalidation is separate and off by default. It requires Token Guardian, the `openai` provider's `proactive` refresh policy, and `tokenGuardian.codexWarmupEnabled`. It skips accounts awaiting deferred registration validation. ### Cancelling main-account device reauthentication diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 8a7aa7db5d9..ccc1a8aaa4a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -329,6 +329,8 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 主账号允许在额度耗尽后使用积分时,现有恢复周期会在用量窗口已满且缓存观察即将过期时刷新积分余额。这是已认证的用量查询,不发送模型验证请求。余额为空、支出限制、刷新失败或独立的主账号硬锁仍可能阻止请求。 +主账号的拒绝提示会区分未允许支出、已报告余额不可用、支出受限和无法确认余额。只有原本可用的余额信息已过期时,仍被拒绝的请求才可能收到较短的重试提示;它只表示可以再次检查,不保证恢复,也不会缩短已知的上游等待期限。“使用积分”只授权支出,不提供余额:最新余额为零仍会拒绝,不完整的用量信息也不会更新旧余额的观察时间。 + 后台重新验证是独立功能,默认关闭。它要求 Token Guardian、`openai` 的 `proactive` 刷新策略及 `tokenGuardian.codexWarmupEnabled`,并跳过等待注册验证的账号。 ### 账号停止处理请求的原因 diff --git a/src/codex/auth-api/pool-mode-gate.ts b/src/codex/auth-api/pool-mode-gate.ts index 51d9ed27feb..f47aefde4b0 100644 --- a/src/codex/auth-api/pool-mode-gate.ts +++ b/src/codex/auth-api/pool-mode-gate.ts @@ -82,6 +82,7 @@ let mainHardLockRecoveryAttempt: { identity: number; credential: number; after: /** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise { if (mainHardLockRecoveryInFlight) return mainHardLockRecoveryInFlight; + /** Schedule a real credit observation while leaving actual-time admission unchanged. */ const creditRecoveryNeeded = (): boolean => { const now = Date.now(); const quota = getMainPolicyQuota(); diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 84929653161..7f99042e75e 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,5 +1,7 @@ import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt } from "./pool-credit-policy"; -import { hasSpendableCodexCredits } from "./quota-types"; +import { CODEX_CREDITS_FRESHNESS_MS, hasSpendableCodexCredits } from "./quota-types"; +import { nextCodexUsageQueryAt } from "./quota-query-backoff"; +import { WHAM_REQUEST_TIMEOUT_MS } from "./quota-recovery-timing"; import { noteMainAccountActivity } from "./main-account-external-usage"; import { codexAccountPriorityFailbackEnabled } from "./account-priority"; import type { PoolQuotaWriter } from "./quota-types"; @@ -70,7 +72,7 @@ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, getMainPolicyQuota, parseUs import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt } from "./account-credit-use"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; -import { captureConfigGeneration } from "../lib/state-store-sweeper"; +import { captureConfigGeneration, STATE_SWEEP_INTERVAL_MS } from "../lib/state-store-sweeper"; import { extractAccountId, extractEmail } from "../oauth/chatgpt"; import { resolveMainAccountHardLockThresholds, @@ -82,6 +84,7 @@ import { captureMainAccountIdentityGeneration, captureMainQuotaWriter, getObservedMainQuotaIdentityKey, + getMainQuotaCredentialGeneration, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, @@ -772,7 +775,39 @@ function assertMainAccountPolicy( const status = getMainAccountHardLockStatus(config); if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt, status.thresholds); const creditsResetAt = mainCreditsHoldResetAt(config); - if (creditsResetAt !== undefined) throw new CodexMainAccountCreditsOffError(creditsResetAt); + if (creditsResetAt !== undefined) { + const error = new CodexMainAccountCreditsOffError(creditsResetAt); + const quota = getMainPolicyQuota(); + const credits = quota?.credits; + const now = Date.now(); + const observed = credits && Number.isFinite(credits.observedAt) + && credits.observedAt >= 0 && credits.observedAt <= now; + if (!codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)) { + error.message = "Codex main account reached its usage limit, and spending ChatGPT credits is off for this account." + + " Wait for the limit to reset or allow the main account under \"Use credits\" in Codex Auth."; + } else if (credits?.allowed === false || credits?.overageLimitReached === true) { + error.message = "Codex main account reached its usage limit, and the latest credit information reports a spending restriction." + + " Wait for the limit to reset or review the account's credit spending controls."; + } else if (observed && !(credits.hasCredits === false && credits.unlimited === true) + && (credits.hasCredits === false || (credits.unlimited !== true + && typeof credits.balance === "number" && Number.isFinite(credits.balance) && credits.balance <= 0))) { + error.message = "Codex main account reached its usage limit, and the latest credit information reports no spendable balance." + + " Allowing credits does not provide a balance. Refresh quotas or wait for the limit to reset."; + } else if (observed && now - credits.observedAt > CODEX_CREDITS_FRESHNESS_MS + && hasSpendableCodexCredits(quota, credits.observedAt)) { + // This suggests when to check again, never when recovery or a WHAM send is guaranteed. + // Worker-private delays remain intact; known upstream/query deadlines cannot be shortened. + error.cooldownUntil = Math.max(now + STATE_SWEEP_INTERVAL_MS + WHAM_REQUEST_TIMEOUT_MS, + nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${getMainQuotaCredentialGeneration()}`) ?? 0, + getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, undefined, now)?.cooldownUntil ?? 0); + error.message = "Codex main account reached its usage limit, and its credit balance information has expired." + + " Spending credits is enabled, but a fresh balance observation is required. Retry later or refresh quotas in Codex Auth."; + } else { + error.message = "Codex main account reached its usage limit, and its spendable credit balance cannot be verified." + + " Refresh quotas for current balance information or wait for the limit to reset."; + } + throw error; + } } // Only an admitted request is opencodex's own use of the main account. Counting a refused one // would hide outside usage from the warning exactly while the lock is holding. diff --git a/structure/codex-account-controls.md b/structure/codex-account-controls.md index 9b6a0488af6..32db2e78b3c 100644 --- a/structure/codex-account-controls.md +++ b/structure/codex-account-controls.md @@ -44,6 +44,11 @@ receives no traffic and therefore no new observation, so the reading has to end window without a reset is not trusted. Fresh spendable-credit evidence follows the [WHAM credit contract](providers/openai-tiers.md#spendable-codex-credits); included-plan refusal is not a credit-spending veto. +Main refusal messages identify disabled spending, reported balance unavailability, spending +restrictions or unverified/expired balance information. A shorter check-again hint applies only +to expired evidence that was otherwise spendable; it never grants funds or overrides the +independent hard lock, actual upstream delay or recovery worker's backoff. + The hold is checked wherever plan exclusion is checked in `src/codex/routing/selection.ts`: the eligible list (its pool filter and its main branch), `isCodexAccountSelectable`, and `codexAccountBlockReason`, which reports `credits_off`. `isCodexAccountRotationExcluded` carries diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a1d7737c565..b1d725e7de1 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -324,6 +324,15 @@ profile ownership, credential generations, single-flight, query pacing and failu in force. The hard lock remains independent, and refreshing usage never redeems reset credits or validates a pending account through inference. +Main-account credit refusals distinguish disabled consent, reported balance unavailability, +spending restrictions and unverified evidence. Only an opted-in observation that was spendable +at its own valid timestamp and has since expired receives a shorter client check-again hint. +`src/codex/auth-context.ts` keeps the same 429 error and real usage `resetAt`, while its existing +`cooldownUntil` respects the sweep-plus-WHAM allowance and later same-generation query or +account-wide cooldown deadlines. The hint does not promise recovery or a WHAM dispatch; +worker-private backoff still governs actual reads. Missing-credit responses do not renew the +credit clock, and a fresh zero balance never becomes spendable through consent or retry alone. + Credit parsing, expiry, partial updates and reset-ticket separation are covered in `tests/codex-integration/codex-quota-parser-parity.test.ts`; selection and bulk-pause behavior are covered in `tests/codex-integration/codex-credits-after-limit.test.ts` and diff --git a/tests/codex-integration/codex-credits-after-limit-main.test.ts b/tests/codex-integration/codex-credits-after-limit-main.test.ts index dae93421fbf..836ef9206e9 100644 --- a/tests/codex-integration/codex-credits-after-limit-main.test.ts +++ b/tests/codex-integration/codex-credits-after-limit-main.test.ts @@ -16,11 +16,16 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth } from "../../src/codex/a import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import * as mainAccount from "../../src/codex/main-account"; import * as authCollision from "../../src/codex/auth-collision"; -import { captureMainQuotaWriter, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { captureMainQuotaWriter, getMainQuotaCredentialGeneration, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getAccountQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; -import { hasSpendableCodexCredits } from "../../src/codex/quota-types"; -import { clearCodexUpstreamHealth, clearThreadAccountMap, pickLowestUsageCodexAccount } from "../../src/codex/routing"; -import { setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { CODEX_CREDITS_FRESHNESS_MS, hasSpendableCodexCredits, type CodexSpendableCredits } from "../../src/codex/quota-types"; +import { fetchCodexUsage, resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; +import { runMainAccountHardLockRecovery } from "../../src/codex/auth-api/pool-mode-gate"; +import { captureConfigGeneration, STATE_SWEEP_INTERVAL_MS } from "../../src/lib/state-store-sweeper"; +import { WHAM_REQUEST_TIMEOUT_MS } from "../../src/codex/quota-recovery-timing"; +import { codexAuthContextErrorResponse } from "../../src/server/responses/codex-auth-error"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexQuotaHealthSnapshot, pickLowestUsageCodexAccount, recordCodexUpstreamOutcome } from "../../src/codex/routing"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -66,6 +71,31 @@ function mainWeekly(percent: number, resetAt: number): void { credits: { hasCredits: true, balance: 42.5, observedAt: Date.now() } }, undefined, writer); } +/** Seed current full-window policy with a separate, explicitly controlled credit observation. */ +function mainCreditObservation(now: number, credits: CodexSpendableCredits | null): void { + clearAccountQuota(MAIN); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: now + DAY_MS, + credits }, undefined, captureMainQuotaWriter(accountId)!); + observeMainQuotaCredential(bearer(), accountId); +} + +/** Exercise caller-owned admission and the production HTTP error mapping without auth-file I/O. */ +async function creditRefusal(cfg: OcxConfig, now: number) { + const headers = new Headers({ authorization: `Bearer ${bearer()}`, "chatgpt-account-id": accountId }); + const error = await resolveCodexAuthContext(headers, cfg, "direct", { requestScopedMainCredential: true }) + .catch(cause => cause); + expect(error).toBeInstanceOf(CodexMainAccountCreditsOffError); + expect(error.resetAt).toBe(now + DAY_MS); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + expect(shouldMarkAccountNeedsReauthForCodexAuthFailure(error)).toBe(false); + const response = codexAuthContextErrorResponse(error, { now })!; + expect(response.status).toBe(429); + const body = await response.json(); + expect(body.error.type).toBe("rate_limit_error"); + expect(body.error.message).toBe(error.message); + return { error, response }; +} + function addPoolAccount(cfg: OcxConfig): void { cfg.codexAccounts = [{ id: POOL, email: "pool@example.test", isMain: false }]; saveCodexAccountCredential(POOL, { @@ -85,12 +115,14 @@ beforeEach(() => { process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = home; setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); resetMainCodexAccountIdentityTrackingForTests(); clearAccountQuota(); clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountNeedsReauth(MAIN); clearAccountNeedsReauth(POOL); + resetQuotaQueryBackoffForTests(); mainAccount.setMainAccountPlan(null); writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: bearer(), refresh_token: "fixture-refresh", account_id: accountId }, @@ -105,9 +137,11 @@ afterEach(() => { clearCodexUpstreamHealth(); clearAccountNeedsReauth(MAIN); clearAccountNeedsReauth(POOL); + resetQuotaQueryBackoffForTests(); resetMainCodexAccountIdentityTrackingForTests(); mainAccount.setMainAccountPlan(null); setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; @@ -173,6 +207,115 @@ test("main credit consent without a fresh balance cannot release a full window", .rejects.toBeInstanceOf(CodexMainAccountCreditsOffError); }); +test.each([false, true])("only expired spendable evidence gets a short check-again hint (unlimited=%j)", async unlimited => { + const now = Date.now(); + spyOn(Date, "now").mockReturnValue(now); + const cfg = config(); + setCodexAccountCreditsAfterLimit(cfg, MAIN, true); + mainCreditObservation(now, { observedAt: now - CODEX_CREDITS_FRESHNESS_MS - 1, + hasCredits: true, unlimited, ...(unlimited ? {} : { balance: 5 }) }); + const forbidden = () => { throw new Error("credit refusal read physical main"); }; + spyOn(authCollision, "readCodexTokens").mockImplementation(forbidden); + spyOn(mainAccount, "getMainAccountToken").mockImplementation(forbidden); + spyOn(mainAccount, "getValidMainAccountToken").mockImplementation(forbidden); + const { error, response } = await creditRefusal(cfg, now); + expect(error.cooldownUntil).toBe(now + STATE_SWEEP_INTERVAL_MS + WHAM_REQUEST_TIMEOUT_MS); + expect(response.headers.get("Retry-After")).toBe("68"); + expect(error.message).toContain("information has expired"); + expect(error.message).not.toContain("spending ChatGPT credits is off"); +}); + +test.each(["consent-off", "no-credits", "zero-balance", "spending-off", "overage"])( + "%s retains the real reset deadline and reports the actual blocker", async condition => { + const now = Date.now(); + spyOn(Date, "now").mockReturnValue(now); + const cfg = config(); + setCodexAccountCreditsAfterLimit(cfg, MAIN, condition !== "consent-off"); + mainCreditObservation(now, { observedAt: now, hasCredits: condition !== "no-credits", + balance: condition === "zero-balance" ? 0 : 5, unlimited: false, + allowed: condition !== "spending-off", overageLimitReached: condition === "overage" }); + const { error, response } = await creditRefusal(cfg, now); + expect(error.cooldownUntil).toBe(now + DAY_MS); + expect(response.headers.get("Retry-After")).toBe(String(DAY_MS / 1000)); + expect(error.message).toContain(condition === "consent-off" ? "spending ChatGPT credits is off" + : condition === "spending-off" || condition === "overage" ? "spending restriction" : "no spendable balance"); + expect(error.message).not.toContain("information has expired"); + }); + +test.each(["missing", "balance-missing", "flags-missing", "future", "invalid-time", "negative-time", "invalid-balance", "inconsistent"])( + "%s evidence stays unverified rather than claiming expired spendable funds", async condition => { + const now = Date.now(); + spyOn(Date, "now").mockReturnValue(now); + const cfg = config(); + setCodexAccountCreditsAfterLimit(cfg, MAIN, true); + const credits: CodexSpendableCredits = { observedAt: now - CODEX_CREDITS_FRESHNESS_MS - 1, + hasCredits: true, balance: 5 }; + if (condition === "balance-missing") delete credits.balance; + if (condition === "flags-missing") delete credits.hasCredits; + if (condition === "future") credits.observedAt = now + 60_000; + if (condition === "invalid-time") credits.observedAt = Number.NaN; + if (condition === "negative-time") credits.observedAt = -1; + if (condition === "invalid-balance") credits.balance = Number.NaN; + if (condition === "inconsistent") { credits.hasCredits = false; credits.unlimited = true; } + mainCreditObservation(now, condition === "missing" ? null : credits); + const { error, response } = await creditRefusal(cfg, now); + expect(error.cooldownUntil).toBe(now + DAY_MS); + expect(response.headers.get("Retry-After")).toBe(String(DAY_MS / 1000)); + expect(error.message).toContain("cannot be verified"); + expect(error.message).not.toContain("information has expired"); + }); + +test.each([true, false])("the short hint respects only the current WHAM credential's Retry-After (%j)", async sameCredential => { + const now = Date.now(); + spyOn(Date, "now").mockReturnValue(now); + const cfg = config(); + setCodexAccountCreditsAfterLimit(cfg, MAIN, true); + mainCreditObservation(now, { observedAt: now - CODEX_CREDITS_FRESHNESS_MS - 1, hasCredits: true, balance: 5 }); + spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 429, headers: { "Retry-After": "900" } })); + const generation = getMainQuotaCredentialGeneration() + (sameCredential ? 0 : 1); + const query = await fetchCodexUsage(`main:${captureConfigGeneration()}:${generation}`, {}); + if (!query || query.kind !== "owner") throw new Error("fixture WHAM query was not dispatched"); + query.settle(false); + const { error, response } = await creditRefusal(cfg, now); + expect(error.cooldownUntil).toBe(now + (sameCredential ? 900_000 : 68_000)); + expect(response.headers.get("Retry-After")).toBe(sameCredential ? "900" : "68"); +}); + +test("a real account-wide cooldown is never shortened by the stale-credit hint", async () => { + const now = Date.now(); + spyOn(Date, "now").mockReturnValue(now); + const cfg = config(); + setCodexAccountCreditsAfterLimit(cfg, MAIN, true); + mainCreditObservation(now, { observedAt: now - CODEX_CREDITS_FRESHNESS_MS - 1, hasCredits: true, balance: 5 }); + recordCodexUpstreamOutcome(cfg, MAIN, 429, { now, retryAfter: "900" }); + const deadline = getCodexQuotaHealthSnapshot(MAIN, undefined, now)!.cooldownUntil!; + const { error, response } = await creditRefusal(cfg, now); + expect(error.cooldownUntil).toBe(deadline); + expect(response.headers.get("Retry-After")).toBe(String(Math.ceil((deadline - now) / 1000))); +}); + +test.each([true, false])("a later WHAM200 with credits empty or omitted still refuses (%j)", async empty => { + let now = Math.floor(Date.now() / 1000) * 1000; + spyOn(Date, "now").mockImplementation(() => now); + const cfg = config(); + setCodexAccountCreditsAfterLimit(cfg, MAIN, true); + const observedAt = now - CODEX_CREDITS_FRESHNESS_MS - 1; + mainCreditObservation(now, { observedAt, hasCredits: true, balance: 5 }); + expect((await creditRefusal(cfg, now)).response.headers.get("Retry-After")).toBe("68"); + const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ plan_type: "plus", + rate_limit: { primary_window: { used_percent: 100, limit_window_seconds: 604_800, + reset_at: Math.floor((now + DAY_MS) / 1000) }, secondary_window: null, tertiary_window: null }, + ...(empty ? { credits: { has_credits: false, unlimited: false, balance: 0 } } : {}) })); + await runMainAccountHardLockRecovery(cfg); + const { error, response } = await creditRefusal(cfg, now); + expect(error.message).toContain(empty ? "no spendable balance" : "information has expired"); + expect(response.headers.get("Retry-After")).toBe(empty ? String(DAY_MS / 1000) : "68"); + if (!empty) expect(getAccountQuota(MAIN)?.credits?.observedAt).toBe(observedAt); + now += 68_000; + await runMainAccountHardLockRecovery(cfg); + expect(fetchSpy).toHaveBeenCalledTimes(1); +}); + test("by default a full main login is refused as a cooldown that names its reset", async () => { const cfg = config(); const resetAt = Date.now() + DAY_MS; 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 ac4c9eaea1f..0be59669855 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -72,6 +72,7 @@ function usage(percent = 0): Response { } }); } +/** Seed an opted-in full window with independently timed credit evidence. */ function creditRecoveryConfig(now: number, observedAt = now - CODEX_CREDITS_FRESHNESS_MS + 60_000, resetAt = now + 3_600_000): OcxConfig { clearAccountQuota(MAIN); @@ -81,6 +82,7 @@ function creditRecoveryConfig(now: number, observedAt = now - CODEX_CREDITS_FRES return { ...config(), codexMainAccountHardLock: false, creditCodexAccountIds: [MAIN] }; } +/** Return a WHAM observation with a full included window and explicit credit controls. */ function creditUsage(restriction?: string): Response { return Response.json({ plan_type: "plus", rate_limit: { primary_window: { used_percent: 100, limit_window_seconds: 604_800, From d9a23fb15732fbd506aae62605bda7976ca4cafe Mon Sep 17 00:00:00 2001 From: OpenCodex Tier Replay Date: Wed, 7 Oct 2026 00:56:37 +0900 Subject: [PATCH 4/8] test(codex): use existing auth error response mapper --- .../codex-integration/codex-credits-after-limit-main.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/codex-integration/codex-credits-after-limit-main.test.ts b/tests/codex-integration/codex-credits-after-limit-main.test.ts index 836ef9206e9..d24aba0bcfd 100644 --- a/tests/codex-integration/codex-credits-after-limit-main.test.ts +++ b/tests/codex-integration/codex-credits-after-limit-main.test.ts @@ -23,7 +23,7 @@ import { fetchCodexUsage, resetQuotaQueryBackoffForTests } from "../../src/codex import { runMainAccountHardLockRecovery } from "../../src/codex/auth-api/pool-mode-gate"; import { captureConfigGeneration, STATE_SWEEP_INTERVAL_MS } from "../../src/lib/state-store-sweeper"; import { WHAM_REQUEST_TIMEOUT_MS } from "../../src/codex/quota-recovery-timing"; -import { codexAuthContextErrorResponse } from "../../src/server/responses/codex-auth-error"; +import { mapCodexAuthContextErrorToResponse } from "../../src/server/responses/codex-auth-error"; import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexQuotaHealthSnapshot, pickLowestUsageCodexAccount, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import type { OcxConfig } from "../../src/types"; @@ -88,7 +88,7 @@ async function creditRefusal(cfg: OcxConfig, now: number) { expect(error.resetAt).toBe(now + DAY_MS); expect(isAccountNeedsReauth(MAIN)).toBe(false); expect(shouldMarkAccountNeedsReauthForCodexAuthFailure(error)).toBe(false); - const response = codexAuthContextErrorResponse(error, { now })!; + const response = mapCodexAuthContextErrorToResponse(error, { now })!; expect(response.status).toBe(429); const body = await response.json(); expect(body.error.type).toBe("rate_limit_error"); From 2fe6433b40b8f670c6e1c2d00cb0dc8c57977590 Mon Sep 17 00:00:00 2001 From: OpenCodex Tier Replay Date: Wed, 7 Oct 2026 01:36:41 +0900 Subject: [PATCH 5/8] test(codex): isolate main credit recovery generations --- .../codex-credits-after-limit-main.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-credits-after-limit-main.test.ts b/tests/codex-integration/codex-credits-after-limit-main.test.ts index d24aba0bcfd..914e38de690 100644 --- a/tests/codex-integration/codex-credits-after-limit-main.test.ts +++ b/tests/codex-integration/codex-credits-after-limit-main.test.ts @@ -16,7 +16,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth } from "../../src/codex/a import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import * as mainAccount from "../../src/codex/main-account"; import * as authCollision from "../../src/codex/auth-collision"; -import { captureMainQuotaWriter, getMainQuotaCredentialGeneration, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainQuotaCredentialGeneration, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getAccountQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; import { CODEX_CREDITS_FRESHNESS_MS, hasSpendableCodexCredits, type CodexSpendableCredits } from "../../src/codex/quota-types"; import { fetchCodexUsage, resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; @@ -116,6 +116,8 @@ beforeEach(() => { process.env.CODEX_HOME = home; setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + // A new fixture home with the same account/bearer must not inherit another case's recovery generation. + clearMainAccountInfoCache(); resetMainCodexAccountIdentityTrackingForTests(); clearAccountQuota(); clearThreadAccountMap(); @@ -132,6 +134,7 @@ beforeEach(() => { afterEach(() => { mock.restore(); + clearMainAccountInfoCache(); clearAccountQuota(); clearThreadAccountMap(); clearCodexUpstreamHealth(); @@ -307,6 +310,7 @@ test.each([true, false])("a later WHAM200 with credits empty or omitted still re reset_at: Math.floor((now + DAY_MS) / 1000) }, secondary_window: null, tertiary_window: null }, ...(empty ? { credits: { has_credits: false, unlimited: false, balance: 0 } } : {}) })); await runMainAccountHardLockRecovery(cfg); + expect(fetchSpy).toHaveBeenCalledTimes(1); const { error, response } = await creditRefusal(cfg, now); expect(error.message).toContain(empty ? "no spendable balance" : "information has expired"); expect(response.headers.get("Retry-After")).toBe(empty ? String(DAY_MS / 1000) : "68"); From ddddbcd36656300f747181dd077726cf17e110c2 Mon Sep 17 00:00:00 2001 From: OpenCodex Tier Replay Date: Wed, 7 Oct 2026 02:58:02 +0900 Subject: [PATCH 6/8] docs(codex): explain historical credit spendability check --- src/codex/auth-context.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 7f99042e75e..8625199da67 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -794,6 +794,7 @@ function assertMainAccountPolicy( error.message = "Codex main account reached its usage limit, and the latest credit information reports no spendable balance." + " Allowing credits does not provide a balance. Refresh quotas or wait for the limit to reset."; } else if (observed && now - credits.observedAt > CODEX_CREDITS_FRESHNESS_MS + // Check flags/balance at observation time; checking now would always reject this expired snapshot. && hasSpendableCodexCredits(quota, credits.observedAt)) { // This suggests when to check again, never when recovery or a WHAM send is guaranteed. // Worker-private delays remain intact; known upstream/query deadlines cannot be shortened. From cda6402eb83c964ac921cbad68e0b8a70d49e600 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 7 Oct 2026 22:54:46 +0900 Subject: [PATCH 7/8] fix(codex): renew opted-in main credits before expiry (#6692) Restrict renewal to valid previously spendable identity-bound credit evidence from three minutes of age. Prepare a token before WHAM and recheck eligibility. Preserve reauth during passive renewal and identity retries, retaining pacing. Remove the out-of-scope refusal-message and retry-hint changes from #6669. Co-authored-by: AiriDea <28827642+AiriDea@users.noreply.github.com> --- .../content/docs/guides/codex-integration.md | 4 +- .../docs/zh-cn/guides/codex-integration.md | 4 +- scripts/test-layout/layout.json | 2 +- src/codex/auth-api/main-account-probe.ts | 24 +- src/codex/auth-api/pool-mode-gate.ts | 26 +- src/codex/auth-context.ts | 42 +-- structure/codex-account-controls.md | 5 - structure/providers/openai-accounts.md | 2 + structure/providers/openai-tiers.md | 29 +- .../codex-credits-after-limit-main.test.ts | 155 +------- .../main-account-credit-renewal.test.ts | 334 ++++++++++++++++++ .../main-account-hard-lock-recovery.test.ts | 105 ------ tests/fixtures/test-layout-expected.json | 2 +- 13 files changed, 395 insertions(+), 339 deletions(-) create mode 100644 tests/codex-integration/main-account-credit-renewal.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 80a6b264c2a..7d756a212d7 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1047,9 +1047,7 @@ In **Codex Set → Multi-auth**, enable the **Codex credits** switch in the **Co When an account reaches 100% on a usage window and still holds credits, upstream keeps serving it and draws the balance. OpenCodex does not let that happen by default: an account at 100% is switched out while its weekly or monthly window (only monthly on 30-day plans) or its 5-hour window is full, and used again once that window resets. The order of the other accounts does not change, and when no other account is available, selection finds none rather than spending credits. A request for the main account is refused like a hard-lock refusal until the reset. A full 5-hour window without a reset time holds an account only while that reading is fresh. Allowing the main account does not lift its hard lock (on by default at 98%), which still stops it first; turn the lock off if the main account should spend credits (a lock at 100% still stops it at 100%). To let accounts keep working from their credits, turn on **Use credits** next to the **Codex credits** switch in the Codex Auth header. The switch allows every account. To choose accounts one by one, open an account card's **⋯** menu and use **Use credits after limit** there; the header switch shows a middle position when only some accounts are on. An account allowed to spend carries a **Uses credits** badge on its card. New accounts start off. The choice is stored in `creditCodexAccountIds` and never redeems reset credits; the **Codex credits** display switch only shows balances and never changes routing. -For the main account, enabling **Use credits after limit** also lets the existing recovery cycle refresh its credit balance before the cached observation expires while a usage window is full. This is an authenticated usage lookup, not a model validation request. An empty balance, a spending restriction, a failed refresh or the separate main-account hard lock can still prevent requests from serving. - -The main-account refusal explains whether spending is disabled, the reported balance is unavailable, spending is restricted, or the balance cannot be verified. If previously spendable balance information has expired, the request is still refused but can receive a shorter retry hint. That hint only suggests when to check again; it does not guarantee recovery or shorten a known upstream delay. **Use credits** grants permission, not a balance: a fresh zero balance still refuses, and incomplete usage information does not renew old balance evidence. +For an opted-in main account with a full usage window, the background recovery cycle renews previously spendable credit observations from three minutes of age, even while the dashboard is closed. It prepares a valid token before the authenticated usage lookup so the balance can be observed again before its five-minute freshness limit. Missing, zero, restricted or retracted credits do not trigger this renewal. A failed or incomplete lookup does not refresh old balance evidence, and requests are still refused once that evidence expires. The separate main-account hard lock and upstream retry delays still apply. This metadata lookup does not send a model validation request or set or clear the account's needs-reauth state. Background revalidation is separate and off by default. It requires Token Guardian, the `openai` provider's `proactive` refresh policy, and `tokenGuardian.codexWarmupEnabled`. It skips accounts awaiting deferred registration validation. diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index ccc1a8aaa4a..925a226790f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -327,9 +327,7 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 在 **Codex Set → Multi-auth** 中,打开 **Codex Auth** 标题栏中的 **Codex 额度** 开关,即可在 Week 下方显示主账号和各池账号最近查询到的积分。默认关闭,并保存为 `showCodexCredits`。余额按地区格式显示;上游报告时会显示无限额或超额使用上限警告。由于没有积分总上限,条形表示可用状态而非百分比。此开关仅控制显示,新登录需等待其自身的查询结果。 -主账号允许在额度耗尽后使用积分时,现有恢复周期会在用量窗口已满且缓存观察即将过期时刷新积分余额。这是已认证的用量查询,不发送模型验证请求。余额为空、支出限制、刷新失败或独立的主账号硬锁仍可能阻止请求。 - -主账号的拒绝提示会区分未允许支出、已报告余额不可用、支出受限和无法确认余额。只有原本可用的余额信息已过期时,仍被拒绝的请求才可能收到较短的重试提示;它只表示可以再次检查,不保证恢复,也不会缩短已知的上游等待期限。“使用积分”只授权支出,不提供余额:最新余额为零仍会拒绝,不完整的用量信息也不会更新旧余额的观察时间。 +允许主账号在额度耗尽后使用积分且用量窗口已满时,后台恢复周期会从观察满三分钟起更新此前可用的积分余额,即使仪表盘已关闭。查询前先准备有效令牌,为五分钟有效期到期前重新观察余额留出时间。缺失、零余额、受限或已撤回的积分不会触发更新。查询失败或信息不完整不会刷新旧余额证据;证据过期后请求仍会被拒绝。独立的主账号硬锁和上游重试等待期限仍然适用。这是已认证的用量查询,不发送模型验证请求,也不会设置或清除账号的 needs-reauth 状态。 后台重新验证是独立功能,默认关闭。它要求 Token Guardian、`openai` 的 `proactive` 刷新策略及 `tokenGuardian.codexWarmupEnabled`,并跳过等待注册验证的账号。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5a6f6c0f21f..edb2f1d7b72 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1158,7 +1158,7 @@ "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-default.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", - "main-account-hard-lock-recovery.test.ts": "codex-integration", + "main-account-hard-lock-recovery.test.ts": "codex-integration", "main-account-credit-renewal.test.ts": "codex-integration", "main-account-policy-binding-wait.test.ts": "codex-integration", "main-device-reauth-api.test.ts": "codex-integration", "main-device-reauth-ui.test.ts": "gui", diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 80472a77929..5d036047e9e 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -146,6 +146,11 @@ export async function fetchMainAccountInfo(forceRefresh = false, config?: OcxCon return (await fetchMainAccountInfoSnapshot(forceRefresh, config)).info; } +export interface MainAccountInfoProbeOptions { + /** Metadata-only renewal never sets or clears the traffic reauth quarantine. */ + passive?: boolean; +} + export const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null }; export async function retryMainAccountInfoIfIdentityChanged( @@ -154,12 +159,13 @@ export async function retryMainAccountInfoIfIdentityChanged( nativeMainLease: AdmissionLease, explicitRefresh: boolean, paced: boolean, + options: MainAccountInfoProbeOptions = {}, ): Promise { const currentAccountId = getMainChatgptAccountId(); if (currentAccountId === null || currentAccountId === requestAccountId) return null; reconcileMainCodexAccountRuntimeState(); return retriesRemaining > 0 - ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh, false, paced) + ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh, false, paced, options) : { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; } @@ -171,6 +177,7 @@ export async function fetchMainAccountInfoAttempt( explicitRefresh: boolean = forceRefresh, postReset = false, config?: OcxConfig, + options: MainAccountInfoProbeOptions = {}, ): Promise { const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim(); if (!nativeMainLease) { @@ -184,7 +191,7 @@ export async function fetchMainAccountInfoAttempt( try { const paced = sharedMainQuotaPacingEnabled(config ?? loadConfig()); const operation = async () => ({ - ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh, postReset, paced), + ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh, postReset, paced, options), identityGeneration: captureMainAccountIdentityGeneration(), }); if (nativeMainSharedClaimHeld) return await operation(); @@ -225,6 +232,7 @@ export async function fetchMainAccountInfoWhileOwned( /** Reset-credit consume needs a fresh post-spend observation, not an earlier read. */ postReset = false, paced = true, + options: MainAccountInfoProbeOptions = {}, ): Promise { const writerGeneration = captureConfigGeneration(); reconcileMainCodexAccountRuntimeState(); @@ -287,7 +295,7 @@ export async function fetchMainAccountInfoWhileOwned( 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" + if (!options.passive && explicitRefresh && (admission.result.quotaRefresh?.status === "ok" || admission.result.quotaRefresh?.status === "not_reported")) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); return admission.result; } @@ -297,13 +305,13 @@ export async function fetchMainAccountInfoWhileOwned( if (!resp.ok) { const authFailure = await classifyMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); const terminalAuthFailure = authFailure.terminal; - const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced); + const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced, options); if (retried) return retried; if (!isQuotaDispatchCurrent(dispatchSequence) || !credentialIsCurrent()) { return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; } - if (terminalAuthFailure) { + if (terminalAuthFailure && !options.passive) { // Account for this attempt's own synchronous invalidation, never prior external drift. const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration); clearMainAccountInfoCache(); @@ -324,7 +332,7 @@ export async function fetchMainAccountInfoWhileOwned( quotaPhase = "body"; const data = (await resp.json()) as WhamUsageResponse; quotaPhase = "publish"; - const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced); + const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced, options); if (retried) return retried; quotaPhase = "decode"; if (data === null || typeof data !== "object" || Array.isArray(data)) { @@ -376,7 +384,7 @@ export async function fetchMainAccountInfoWhileOwned( // never settled and the dashboard kept showing nothing — the symptom #327 reported. // An explicit refresh is an operator asking to re-evaluate, normally right after // signing in again, so it stays authoritative. - if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + if (explicitRefresh && !options.passive) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); // Mirror main quota + plan into the shared stores so the rotation engine can // score and auto-switch the main account exactly like a pool account (Option A). setMainAccountPlan(result.plan); @@ -398,7 +406,7 @@ export async function fetchMainAccountInfoWhileOwned( ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch (error) { - const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced); + const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced, options); if (retried) return retried; if (!credentialIsCurrent()) { return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, diff --git a/src/codex/auth-api/pool-mode-gate.ts b/src/codex/auth-api/pool-mode-gate.ts index f47aefde4b0..be8d93fdd93 100644 --- a/src/codex/auth-api/pool-mode-gate.ts +++ b/src/codex/auth-api/pool-mode-gate.ts @@ -6,13 +6,12 @@ import { getCodexAccountCredential, getValidCodexToken, readCodexAccountRecord } import { getAccountQuota, getMainPolicyQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota"; import { isCodexAccountPaused } from "../account-pause"; import { hasSpendableCodexCredits } from "../quota-types"; -import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing"; import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { claimDueCodexQuotaRecoveryProbes, settleCodexQuotaRecoveryProbe } from "../routing"; import { readCodexTokens } from "../auth-collision"; import { isAccountNeedsReauth } from "../account-runtime-state"; import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account"; -import { captureConfigGeneration, registerStateSweepAfterTick, STATE_SWEEP_INTERVAL_MS } from "../../lib/state-store-sweeper"; +import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper"; import { observeMainQuotaCredential, getMainQuotaCredentialGeneration, captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache"; import { getMainAccountHardLockStatus } from "../main-account-hard-lock"; import type { OcxConfig } from "../../types"; @@ -76,6 +75,8 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da return cooldownRecoveryInFlight; } +const MAIN_CREDIT_RENEWAL_AGE_MS = 3 * 60_000; + let mainHardLockRecoveryInFlight: Promise | null = null; let mainHardLockRecoveryAttempt: { identity: number; credential: number; after: number; delay: number } | undefined; @@ -86,12 +87,15 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise const creditRecoveryNeeded = (): boolean => { const now = Date.now(); const quota = getMainPolicyQuota(); - // Only schedule an earlier real observation. Admission still uses the actual clock, - // and a successful WHAM response supplies its own identity-bound observedAt. + const observedAt = quota?.credits?.observedAt; + // getMainPolicyQuota binds evidence to the observed physical identity. Only renew + // valid formerly spendable funds; admission retains the actual five-minute clock. return codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID) && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) && codexUsageLimitResetAt(quota, undefined, now) !== undefined - && !hasSpendableCodexCredits(quota, now + STATE_SWEEP_INTERVAL_MS + WHAM_REQUEST_TIMEOUT_MS); + && observedAt !== undefined && observedAt > 0 + && now - observedAt >= MAIN_CREDIT_RENEWAL_AGE_MS + && hasSpendableCodexCredits(quota, observedAt); }; const status = getMainAccountHardLockStatus(config); if (status.state !== "blocked" && !creditRecoveryNeeded()) { mainHardLockRecoveryAttempt = undefined; return; } @@ -115,6 +119,7 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise if ((getMainAccountHardLockStatus(config).state !== "blocked" && !creditRecoveryNeeded()) || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; const identityGeneration = captureMainAccountIdentityGeneration(); + const passive = getMainAccountHardLockStatus(config).state !== "blocked"; try { // Refresh can require an exclusive credential claim: never hold WHAM's shared // claim while obtaining a valid token. The runtime lease spans both operations. @@ -125,7 +130,12 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise // Native refresh retains its own grant-scoped refusal; global quarantine would outlive it. return; } - if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; + const currentStatus = getMainAccountHardLockStatus(config); + if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) + || !isMainAccountIdentityGenerationLive(identityGeneration) + || (passive && !creditRecoveryNeeded()) + || (currentStatus.state === "blocked" && currentStatus.resetAt !== undefined + && currentStatus.resetAt > Date.now())) return; const credential = getMainQuotaCredentialGeneration(); // Reconcile the physical bearer before consulting the previous credential's delay. if (previous?.identity === identityGeneration && previous.credential === credential @@ -137,7 +147,7 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise after: currentQueryAfter }; return; } - const result = await fetchMainAccountInfoAttempt(true, 1, lease, false, false, false, config); + const result = await fetchMainAccountInfoAttempt(true, 1, lease, false, false, false, config, { passive }); // Never charge a replacement credential for a late result from its predecessor. if (isMainAccountIdentityGenerationLive(identityGeneration) && credential === getMainQuotaCredentialGeneration()) { @@ -148,7 +158,7 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise const authStatus = result.quotaRefresh.status === "http_error" ? result.quotaRefresh.httpStatus : undefined; const transientAuth = (authStatus === 401 || authStatus === 403) - && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + && !result.terminalAuthFailure && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); if (transientAuth) mainHardLockRecoveryAttempt = undefined; else { const delay = nextQuotaQueryDelay(previousDelay || undefined); diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 8625199da67..84929653161 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,7 +1,5 @@ import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt } from "./pool-credit-policy"; -import { CODEX_CREDITS_FRESHNESS_MS, hasSpendableCodexCredits } from "./quota-types"; -import { nextCodexUsageQueryAt } from "./quota-query-backoff"; -import { WHAM_REQUEST_TIMEOUT_MS } from "./quota-recovery-timing"; +import { hasSpendableCodexCredits } from "./quota-types"; import { noteMainAccountActivity } from "./main-account-external-usage"; import { codexAccountPriorityFailbackEnabled } from "./account-priority"; import type { PoolQuotaWriter } from "./quota-types"; @@ -72,7 +70,7 @@ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, getMainPolicyQuota, parseUs import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt } from "./account-credit-use"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; -import { captureConfigGeneration, STATE_SWEEP_INTERVAL_MS } from "../lib/state-store-sweeper"; +import { captureConfigGeneration } from "../lib/state-store-sweeper"; import { extractAccountId, extractEmail } from "../oauth/chatgpt"; import { resolveMainAccountHardLockThresholds, @@ -84,7 +82,6 @@ import { captureMainAccountIdentityGeneration, captureMainQuotaWriter, getObservedMainQuotaIdentityKey, - getMainQuotaCredentialGeneration, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, @@ -775,40 +772,7 @@ function assertMainAccountPolicy( const status = getMainAccountHardLockStatus(config); if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt, status.thresholds); const creditsResetAt = mainCreditsHoldResetAt(config); - if (creditsResetAt !== undefined) { - const error = new CodexMainAccountCreditsOffError(creditsResetAt); - const quota = getMainPolicyQuota(); - const credits = quota?.credits; - const now = Date.now(); - const observed = credits && Number.isFinite(credits.observedAt) - && credits.observedAt >= 0 && credits.observedAt <= now; - if (!codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)) { - error.message = "Codex main account reached its usage limit, and spending ChatGPT credits is off for this account." - + " Wait for the limit to reset or allow the main account under \"Use credits\" in Codex Auth."; - } else if (credits?.allowed === false || credits?.overageLimitReached === true) { - error.message = "Codex main account reached its usage limit, and the latest credit information reports a spending restriction." - + " Wait for the limit to reset or review the account's credit spending controls."; - } else if (observed && !(credits.hasCredits === false && credits.unlimited === true) - && (credits.hasCredits === false || (credits.unlimited !== true - && typeof credits.balance === "number" && Number.isFinite(credits.balance) && credits.balance <= 0))) { - error.message = "Codex main account reached its usage limit, and the latest credit information reports no spendable balance." - + " Allowing credits does not provide a balance. Refresh quotas or wait for the limit to reset."; - } else if (observed && now - credits.observedAt > CODEX_CREDITS_FRESHNESS_MS - // Check flags/balance at observation time; checking now would always reject this expired snapshot. - && hasSpendableCodexCredits(quota, credits.observedAt)) { - // This suggests when to check again, never when recovery or a WHAM send is guaranteed. - // Worker-private delays remain intact; known upstream/query deadlines cannot be shortened. - error.cooldownUntil = Math.max(now + STATE_SWEEP_INTERVAL_MS + WHAM_REQUEST_TIMEOUT_MS, - nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${getMainQuotaCredentialGeneration()}`) ?? 0, - getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, undefined, now)?.cooldownUntil ?? 0); - error.message = "Codex main account reached its usage limit, and its credit balance information has expired." - + " Spending credits is enabled, but a fresh balance observation is required. Retry later or refresh quotas in Codex Auth."; - } else { - error.message = "Codex main account reached its usage limit, and its spendable credit balance cannot be verified." - + " Refresh quotas for current balance information or wait for the limit to reset."; - } - throw error; - } + if (creditsResetAt !== undefined) throw new CodexMainAccountCreditsOffError(creditsResetAt); } // Only an admitted request is opencodex's own use of the main account. Counting a refused one // would hide outside usage from the warning exactly while the lock is holding. diff --git a/structure/codex-account-controls.md b/structure/codex-account-controls.md index 32db2e78b3c..9b6a0488af6 100644 --- a/structure/codex-account-controls.md +++ b/structure/codex-account-controls.md @@ -44,11 +44,6 @@ receives no traffic and therefore no new observation, so the reading has to end window without a reset is not trusted. Fresh spendable-credit evidence follows the [WHAM credit contract](providers/openai-tiers.md#spendable-codex-credits); included-plan refusal is not a credit-spending veto. -Main refusal messages identify disabled spending, reported balance unavailability, spending -restrictions or unverified/expired balance information. A shorter check-again hint applies only -to expired evidence that was otherwise spendable; it never grants funds or overrides the -independent hard lock, actual upstream delay or recovery worker's backoff. - The hold is checked wherever plan exclusion is checked in `src/codex/routing/selection.ts`: the eligible list (its pool filter and its main branch), `isCodexAccountSelectable`, and `codexAccountBlockReason`, which reports `credits_off`. `isCodexAccountRotationExcluded` carries diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index bb3e6df8ef6..dc29ae2cb1f 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -479,6 +479,8 @@ false. Exact-account and Direct routes are unchanged. ## Main-account policy observations +Opted-in main-account credit renewal follows the [spendable credit contract](openai-tiers.md#spendable-codex-credits). The existing background sweep prepares a token before WHAM, rechecks eligibility, and uses a passive probe that neither sets nor clears needs-reauth, including after identity retries. Other probes keep their existing auth behavior. + The main-account admission policy defaults to 90% for short windows and 98% for long windows; `codexMainAccountHardLockThresholds` permits ordered integer thresholds from 80 through 100. Policy evidence retention uses the minimum configurable 80%, so a partial update or elapsed reset diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index b1d725e7de1..889bfd3c656 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -317,21 +317,20 @@ default-on main-account hard lock retains its separate local admission policy. R warmup remains conservative and does not spend paid credits to validate an exhausted account. For an opted-in, unpaused main account with a currently full usage window, the existing -`src/codex/auth-api/pool-mode-gate.ts` recovery sweep refreshes authenticated WHAM evidence before -its credit observation expires. Scheduling looks ahead by the shared sweep interval and WHAM -timeout; admission still evaluates the actual clock and the original spending controls. Native -profile ownership, credential generations, single-flight, query pacing and failure backoff remain -in force. The hard lock remains independent, and refreshing usage never redeems reset credits or -validates a pending account through inference. - -Main-account credit refusals distinguish disabled consent, reported balance unavailability, -spending restrictions and unverified evidence. Only an opted-in observation that was spendable -at its own valid timestamp and has since expired receives a shorter client check-again hint. -`src/codex/auth-context.ts` keeps the same 429 error and real usage `resetAt`, while its existing -`cooldownUntil` respects the sweep-plus-WHAM allowance and later same-generation query or -account-wide cooldown deadlines. The hint does not promise recovery or a WHAM dispatch; -worker-private backoff still governs actual reads. Missing-credit responses do not renew the -credit clock, and a fresh zero balance never becomes spendable through consent or retry alone. +`src/codex/auth-api/pool-mode-gate.ts` recovery sweep renews credit observations from three minutes +of age, leaving time for token preparation before the five-minute freshness limit. Only valid, +previously spendable positive or unlimited evidence bound to the same physical account schedules +renewal; missing, zero, restricted or retracted credits do not. Admission still uses the actual +clock and original spending controls. Native profile ownership, generation fences, single-flight, +query pacing and failure backoff remain in force, including upstream Retry-After. Eligibility is +checked again after token preparation. The independent hard lock still applies. + +Credit renewal supplies the passive option to `src/codex/auth-api/main-account-probe.ts`, including +identity retries: WHAM success and terminal 401/403 responses never set or clear needs-reauth in +this mode. Native token preparation also preserves the traffic quarantine. Other callers retain +the existing auth behavior. Failed or incomplete observations never renew the credit clock; +refreshing usage does not redeem reset credits or validate pending accounts through inference. +Coverage: `tests/codex-integration/main-account-credit-renewal.test.ts`. Credit parsing, expiry, partial updates and reset-ticket separation are covered in `tests/codex-integration/codex-quota-parser-parity.test.ts`; selection and bulk-pause behavior diff --git a/tests/codex-integration/codex-credits-after-limit-main.test.ts b/tests/codex-integration/codex-credits-after-limit-main.test.ts index 914e38de690..dae93421fbf 100644 --- a/tests/codex-integration/codex-credits-after-limit-main.test.ts +++ b/tests/codex-integration/codex-credits-after-limit-main.test.ts @@ -16,16 +16,11 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth } from "../../src/codex/a import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import * as mainAccount from "../../src/codex/main-account"; import * as authCollision from "../../src/codex/auth-collision"; -import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainQuotaCredentialGeneration, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { captureMainQuotaWriter, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getAccountQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; -import { CODEX_CREDITS_FRESHNESS_MS, hasSpendableCodexCredits, type CodexSpendableCredits } from "../../src/codex/quota-types"; -import { fetchCodexUsage, resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; -import { runMainAccountHardLockRecovery } from "../../src/codex/auth-api/pool-mode-gate"; -import { captureConfigGeneration, STATE_SWEEP_INTERVAL_MS } from "../../src/lib/state-store-sweeper"; -import { WHAM_REQUEST_TIMEOUT_MS } from "../../src/codex/quota-recovery-timing"; -import { mapCodexAuthContextErrorToResponse } from "../../src/server/responses/codex-auth-error"; -import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexQuotaHealthSnapshot, pickLowestUsageCodexAccount, recordCodexUpstreamOutcome } from "../../src/codex/routing"; -import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { hasSpendableCodexCredits } from "../../src/codex/quota-types"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, pickLowestUsageCodexAccount } from "../../src/codex/routing"; +import { setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -71,31 +66,6 @@ function mainWeekly(percent: number, resetAt: number): void { credits: { hasCredits: true, balance: 42.5, observedAt: Date.now() } }, undefined, writer); } -/** Seed current full-window policy with a separate, explicitly controlled credit observation. */ -function mainCreditObservation(now: number, credits: CodexSpendableCredits | null): void { - clearAccountQuota(MAIN); - setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: now + DAY_MS, - credits }, undefined, captureMainQuotaWriter(accountId)!); - observeMainQuotaCredential(bearer(), accountId); -} - -/** Exercise caller-owned admission and the production HTTP error mapping without auth-file I/O. */ -async function creditRefusal(cfg: OcxConfig, now: number) { - const headers = new Headers({ authorization: `Bearer ${bearer()}`, "chatgpt-account-id": accountId }); - const error = await resolveCodexAuthContext(headers, cfg, "direct", { requestScopedMainCredential: true }) - .catch(cause => cause); - expect(error).toBeInstanceOf(CodexMainAccountCreditsOffError); - expect(error.resetAt).toBe(now + DAY_MS); - expect(isAccountNeedsReauth(MAIN)).toBe(false); - expect(shouldMarkAccountNeedsReauthForCodexAuthFailure(error)).toBe(false); - const response = mapCodexAuthContextErrorToResponse(error, { now })!; - expect(response.status).toBe(429); - const body = await response.json(); - expect(body.error.type).toBe("rate_limit_error"); - expect(body.error.message).toBe(error.message); - return { error, response }; -} - function addPoolAccount(cfg: OcxConfig): void { cfg.codexAccounts = [{ id: POOL, email: "pool@example.test", isMain: false }]; saveCodexAccountCredential(POOL, { @@ -115,16 +85,12 @@ beforeEach(() => { process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = home; setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); - setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); - // A new fixture home with the same account/bearer must not inherit another case's recovery generation. - clearMainAccountInfoCache(); resetMainCodexAccountIdentityTrackingForTests(); clearAccountQuota(); clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountNeedsReauth(MAIN); clearAccountNeedsReauth(POOL); - resetQuotaQueryBackoffForTests(); mainAccount.setMainAccountPlan(null); writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: bearer(), refresh_token: "fixture-refresh", account_id: accountId }, @@ -134,17 +100,14 @@ beforeEach(() => { afterEach(() => { mock.restore(); - clearMainAccountInfoCache(); clearAccountQuota(); clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountNeedsReauth(MAIN); clearAccountNeedsReauth(POOL); - resetQuotaQueryBackoffForTests(); resetMainCodexAccountIdentityTrackingForTests(); mainAccount.setMainAccountPlan(null); setIcaclsRunnerForTests(null); - setAsyncIcaclsRunnerForTests(null); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; @@ -210,116 +173,6 @@ test("main credit consent without a fresh balance cannot release a full window", .rejects.toBeInstanceOf(CodexMainAccountCreditsOffError); }); -test.each([false, true])("only expired spendable evidence gets a short check-again hint (unlimited=%j)", async unlimited => { - const now = Date.now(); - spyOn(Date, "now").mockReturnValue(now); - const cfg = config(); - setCodexAccountCreditsAfterLimit(cfg, MAIN, true); - mainCreditObservation(now, { observedAt: now - CODEX_CREDITS_FRESHNESS_MS - 1, - hasCredits: true, unlimited, ...(unlimited ? {} : { balance: 5 }) }); - const forbidden = () => { throw new Error("credit refusal read physical main"); }; - spyOn(authCollision, "readCodexTokens").mockImplementation(forbidden); - spyOn(mainAccount, "getMainAccountToken").mockImplementation(forbidden); - spyOn(mainAccount, "getValidMainAccountToken").mockImplementation(forbidden); - const { error, response } = await creditRefusal(cfg, now); - expect(error.cooldownUntil).toBe(now + STATE_SWEEP_INTERVAL_MS + WHAM_REQUEST_TIMEOUT_MS); - expect(response.headers.get("Retry-After")).toBe("68"); - expect(error.message).toContain("information has expired"); - expect(error.message).not.toContain("spending ChatGPT credits is off"); -}); - -test.each(["consent-off", "no-credits", "zero-balance", "spending-off", "overage"])( - "%s retains the real reset deadline and reports the actual blocker", async condition => { - const now = Date.now(); - spyOn(Date, "now").mockReturnValue(now); - const cfg = config(); - setCodexAccountCreditsAfterLimit(cfg, MAIN, condition !== "consent-off"); - mainCreditObservation(now, { observedAt: now, hasCredits: condition !== "no-credits", - balance: condition === "zero-balance" ? 0 : 5, unlimited: false, - allowed: condition !== "spending-off", overageLimitReached: condition === "overage" }); - const { error, response } = await creditRefusal(cfg, now); - expect(error.cooldownUntil).toBe(now + DAY_MS); - expect(response.headers.get("Retry-After")).toBe(String(DAY_MS / 1000)); - expect(error.message).toContain(condition === "consent-off" ? "spending ChatGPT credits is off" - : condition === "spending-off" || condition === "overage" ? "spending restriction" : "no spendable balance"); - expect(error.message).not.toContain("information has expired"); - }); - -test.each(["missing", "balance-missing", "flags-missing", "future", "invalid-time", "negative-time", "invalid-balance", "inconsistent"])( - "%s evidence stays unverified rather than claiming expired spendable funds", async condition => { - const now = Date.now(); - spyOn(Date, "now").mockReturnValue(now); - const cfg = config(); - setCodexAccountCreditsAfterLimit(cfg, MAIN, true); - const credits: CodexSpendableCredits = { observedAt: now - CODEX_CREDITS_FRESHNESS_MS - 1, - hasCredits: true, balance: 5 }; - if (condition === "balance-missing") delete credits.balance; - if (condition === "flags-missing") delete credits.hasCredits; - if (condition === "future") credits.observedAt = now + 60_000; - if (condition === "invalid-time") credits.observedAt = Number.NaN; - if (condition === "negative-time") credits.observedAt = -1; - if (condition === "invalid-balance") credits.balance = Number.NaN; - if (condition === "inconsistent") { credits.hasCredits = false; credits.unlimited = true; } - mainCreditObservation(now, condition === "missing" ? null : credits); - const { error, response } = await creditRefusal(cfg, now); - expect(error.cooldownUntil).toBe(now + DAY_MS); - expect(response.headers.get("Retry-After")).toBe(String(DAY_MS / 1000)); - expect(error.message).toContain("cannot be verified"); - expect(error.message).not.toContain("information has expired"); - }); - -test.each([true, false])("the short hint respects only the current WHAM credential's Retry-After (%j)", async sameCredential => { - const now = Date.now(); - spyOn(Date, "now").mockReturnValue(now); - const cfg = config(); - setCodexAccountCreditsAfterLimit(cfg, MAIN, true); - mainCreditObservation(now, { observedAt: now - CODEX_CREDITS_FRESHNESS_MS - 1, hasCredits: true, balance: 5 }); - spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 429, headers: { "Retry-After": "900" } })); - const generation = getMainQuotaCredentialGeneration() + (sameCredential ? 0 : 1); - const query = await fetchCodexUsage(`main:${captureConfigGeneration()}:${generation}`, {}); - if (!query || query.kind !== "owner") throw new Error("fixture WHAM query was not dispatched"); - query.settle(false); - const { error, response } = await creditRefusal(cfg, now); - expect(error.cooldownUntil).toBe(now + (sameCredential ? 900_000 : 68_000)); - expect(response.headers.get("Retry-After")).toBe(sameCredential ? "900" : "68"); -}); - -test("a real account-wide cooldown is never shortened by the stale-credit hint", async () => { - const now = Date.now(); - spyOn(Date, "now").mockReturnValue(now); - const cfg = config(); - setCodexAccountCreditsAfterLimit(cfg, MAIN, true); - mainCreditObservation(now, { observedAt: now - CODEX_CREDITS_FRESHNESS_MS - 1, hasCredits: true, balance: 5 }); - recordCodexUpstreamOutcome(cfg, MAIN, 429, { now, retryAfter: "900" }); - const deadline = getCodexQuotaHealthSnapshot(MAIN, undefined, now)!.cooldownUntil!; - const { error, response } = await creditRefusal(cfg, now); - expect(error.cooldownUntil).toBe(deadline); - expect(response.headers.get("Retry-After")).toBe(String(Math.ceil((deadline - now) / 1000))); -}); - -test.each([true, false])("a later WHAM200 with credits empty or omitted still refuses (%j)", async empty => { - let now = Math.floor(Date.now() / 1000) * 1000; - spyOn(Date, "now").mockImplementation(() => now); - const cfg = config(); - setCodexAccountCreditsAfterLimit(cfg, MAIN, true); - const observedAt = now - CODEX_CREDITS_FRESHNESS_MS - 1; - mainCreditObservation(now, { observedAt, hasCredits: true, balance: 5 }); - expect((await creditRefusal(cfg, now)).response.headers.get("Retry-After")).toBe("68"); - const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ plan_type: "plus", - rate_limit: { primary_window: { used_percent: 100, limit_window_seconds: 604_800, - reset_at: Math.floor((now + DAY_MS) / 1000) }, secondary_window: null, tertiary_window: null }, - ...(empty ? { credits: { has_credits: false, unlimited: false, balance: 0 } } : {}) })); - await runMainAccountHardLockRecovery(cfg); - expect(fetchSpy).toHaveBeenCalledTimes(1); - const { error, response } = await creditRefusal(cfg, now); - expect(error.message).toContain(empty ? "no spendable balance" : "information has expired"); - expect(response.headers.get("Retry-After")).toBe(empty ? String(DAY_MS / 1000) : "68"); - if (!empty) expect(getAccountQuota(MAIN)?.credits?.observedAt).toBe(observedAt); - now += 68_000; - await runMainAccountHardLockRecovery(cfg); - expect(fetchSpy).toHaveBeenCalledTimes(1); -}); - test("by default a full main login is refused as a cooldown that names its reset", async () => { const cfg = config(); const resetAt = Date.now() + DAY_MS; diff --git a/tests/codex-integration/main-account-credit-renewal.test.ts b/tests/codex-integration/main-account-credit-renewal.test.ts new file mode 100644 index 00000000000..993b5885715 --- /dev/null +++ b/tests/codex-integration/main-account-credit-renewal.test.ts @@ -0,0 +1,334 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CodexMainAccountCreditsOffError, resolveCodexAuthContext } from "../../src/codex/auth-context"; +import { fetchMainAccountInfoAttempt } from "../../src/codex/auth-api/main-account-probe"; +import { registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecovery } from "../../src/codex/auth-api/pool-mode-gate"; +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, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { hasSpendableCodexCredits, type CodexSpendableCredits } from "../../src/codex/quota-types"; +import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; +import { clearCodexUpstreamHealth } from "../../src/codex/routing"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import * as mainAccount from "../../src/codex/main-account"; +import * as sweeper from "../../src/lib/state-store-sweeper"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { getNativeMainProfileRequestCount, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { mapCodexAuthContextErrorToResponse } from "../../src/server/responses/codex-auth-error"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const accountId = "fixture-credit-renewal"; +const whamUrl = "https://chatgpt.com/backend-api/wham/usage"; +const tokenUrl = "https://auth.openai.com/oauth/token"; +const HOUR = 3_600_000; +let home: string; +let now: number; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let previousFetch: typeof fetch; +let cfg: OcxConfig; +let calls: string[]; + +function bearer(id = accountId, expired = false): string { + const payload = Buffer.from(JSON.stringify({ exp: Math.floor(now / 1000) + (expired ? -120 : 86_400), + "https://api.openai.com/auth": { chatgpt_account_id: id } })).toString("base64url"); + return `header.${payload}.signature`; +} + +function writeMain(id = accountId, expired = false): void { + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + access_token: bearer(id, expired), refresh_token: "fixture-refresh", account_id: id, + } })); + reconcileMainCodexAccountRuntimeState(); + observeMainQuotaCredential(bearer(id, expired), id); +} + +function seed(credits: CodexSpendableCredits | null | undefined = { + hasCredits: true, balance: 5, allowed: true, observedAt: now - 180_000, +}): void { + clearAccountQuota(MAIN); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: now + HOUR, + ...(credits !== undefined ? { credits } : {}) }, undefined, captureMainQuotaWriter(accountId)); +} + +function usage(restriction?: string): Response { + return Response.json({ plan_type: "plus", rate_limit: { + primary_window: { used_percent: 100, limit_window_seconds: 604_800, + reset_at: Math.floor((now + HOUR) / 1000) }, + secondary_window: null, tertiary_window: null, + }, ...(restriction === "omitted" ? {} : { credits: restriction === "retracted" ? null : { + has_credits: restriction !== "empty", balance: restriction === "empty" ? 0 : 5, + overage_limit_reached: restriction === "overage", + } }), spend_control: { reached: restriction === "restricted" } }); +} + +function fetchWith(handler: (url: string, init?: RequestInit) => Promise): void { + globalThis.fetch = Object.assign(async (input: Parameters[0], init?: RequestInit) => { + const url = String(input); + calls.push(url); + expect([whamUrl, tokenUrl]).toContain(url); + expect(getNativeMainProfileRequestCount()).toBeGreaterThan(0); + return handler(url, init); + }, { preconnect: previousFetch.preconnect }); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +async function refused(): Promise { + const error = await resolveCodexAuthContext(new Headers(), cfg, "pool").catch(error => error); + expect(error).toBeInstanceOf(CodexMainAccountCreditsOffError); + const response = mapCodexAuthContextErrorToResponse(error, { now })!; + expect(response.status).toBe(429); + expect(isAccountNeedsReauth(MAIN)).toBe(false); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + previousFetch = globalThis.fetch; + home = mkdtempSync(join(tmpdir(), "ocx-credit-renewal-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + now = Math.floor(Date.now() / 1000) * 1000; + spyOn(Date, "now").mockImplementation(() => now); + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + clearAccountQuota(); + clearAccountNeedsReauth(MAIN); + clearCodexUpstreamHealth(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + resetQuotaQueryBackoffForTests(); + mainAccount.setMainAccountPlan(null); + writeMain(); + calls = []; + cfg = { port: 10100, defaultProvider: "openai", codexMainAccountHardLock: false, + activeCodexAccountId: MAIN, creditCodexAccountIds: [MAIN], codexAccounts: [], providers: { openai: { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: "pool", + } } }; + seed(); + fetchWith(async () => usage()); +}); + +afterEach(async () => { + mock.restore(); + globalThis.fetch = previousFetch; + clearAccountQuota(); + clearAccountNeedsReauth(MAIN); + clearCodexUpstreamHealth(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + resetQuotaQueryBackoffForTests(); + mainAccount.setMainAccountPlan(null); + await flushConfigDirHardeningForTests(); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); +}); + +test("without renewal, five-minute credit expiry produces a local 429", async () => { + seed({ hasCredits: true, balance: 5, observedAt: now }); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")).resolves.toMatchObject({ kind: "main-pool" }); + now += 300_001; + await refused(); + expect(calls).toEqual([]); +}); + +test.each([false, true])("hidden-dashboard sweep renews at three minutes and admits past original expiry (unlimited=%j)", async unlimited => { + const original = now; + seed({ hasCredits: true, ...(unlimited ? { unlimited: true } : { balance: 5 }), observedAt: now }); + let worker: (() => void) | undefined; + spyOn(sweeper, "registerStateSweepAfterTick").mockImplementation(registration => { worker = registration.afterTick; }); + registerCodexCooldownRecoveryProbeWorker(cfg); + now += 179_999; + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([]); + now++; + expect(worker).toBeDefined(); + worker!(); // No account-list/dashboard request is made. + await runMainAccountHardLockRecovery(cfg); // Join the already scheduled sweep. + expect(calls).toEqual([whamUrl]); + expect(getMainPolicyQuota()?.credits?.observedAt).toBe(now); + now = original + 300_001; + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")).resolves.toMatchObject({ kind: "main-pool" }); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test.each([503, 401, 403])("failed renewal HTTP %s preserves evidence and eventual refusal", async status => { + seed({ hasCredits: true, balance: 5, observedAt: now - 240_000 }); + const observation = getMainPolicyQuota()!.credits!.observedAt; + fetchWith(async () => Response.json({ detail: { code: "invalid_workspace_selected" } }, { status })); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + expect(getMainPolicyQuota()?.credits?.observedAt).toBe(observation); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + now += 120_001; + await refused(); +}); + +test.each(["empty", "restricted", "overage", "retracted", "omitted"])("renewal with %s credits cannot authorize expired evidence", async restriction => { + fetchWith(async () => usage(restriction)); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + now += 120_001; + await refused(); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); +}); + +test.each(["missing", "retracted", "zero", "negative", "invalid-balance", "no-flags", "no-balance", "has-false", "restricted", "overage", "future", "invalid-time", "negative-time"])("%s credit evidence never initiates renewal", async condition => { + const credits: CodexSpendableCredits = { hasCredits: true, balance: 5, observedAt: now - 180_000 }; + if (condition === "zero") credits.balance = 0; + if (condition === "negative") credits.balance = -1; + if (condition === "invalid-balance") credits.balance = Number.NaN; + if (condition === "no-flags") delete credits.hasCredits; + if (condition === "no-balance") delete credits.balance; + if (condition === "has-false") { credits.hasCredits = false; credits.unlimited = true; } + if (condition === "restricted") credits.allowed = false; + if (condition === "overage") credits.overageLimitReached = true; + if (condition === "future") credits.observedAt = now + 1; + if (condition === "invalid-time") credits.observedAt = Number.NaN; + if (condition === "negative-time") credits.observedAt = -1; + seed(condition === "retracted" ? null : credits); + if (condition === "missing") { clearAccountQuota(MAIN); setAccountQuotaFromParsed(MAIN, + { weeklyPercent: 100, weeklyResetAt: now + HOUR }, undefined, captureMainQuotaWriter(accountId)); } + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([]); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test.each(["no-consent", "paused", "reauth", "fresh", "reset", "hard-lock", "identity"])("%s excludes credit renewal", async condition => { + if (condition === "no-consent") cfg.creditCodexAccountIds = []; + if (condition === "paused") cfg.pausedCodexAccountIds = [MAIN]; + if (condition === "reauth") markAccountNeedsReauth(MAIN); + if (condition === "fresh") seed({ hasCredits: true, balance: 5, observedAt: now }); + if (condition === "reset") setAccountQuotaFromParsed(MAIN, { weeklyPercent: 0 }, undefined, captureMainQuotaWriter(accountId)); + if (condition === "hard-lock") cfg.codexMainAccountHardLock = true; + if (condition === "identity") writeMain("fixture-replacement"); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([]); +}); + +test("expired access token is refreshed before credit WHAM", async () => { + writeMain(accountId, true); + seed(); + fetchWith(async (url, init) => { + if (url === tokenUrl) return Response.json({ access_token: bearer(), refresh_token: "fixture-rotated", expires_in: 86_400 }); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${bearer()}`); + return usage(); + }); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([tokenUrl, whamUrl]); + expect(getMainPolicyQuota()?.credits?.observedAt).toBe(now); + now += 120_001; + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")).resolves.toMatchObject({ kind: "main-pool" }); +}); + +test("failed refresh leaves stale-credit refusal and never calls WHAM", async () => { + writeMain(accountId, true); + seed(); + fetchWith(async () => Response.json({ error: "invalid_grant" }, { status: 400 })); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([tokenUrl]); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + now += 120_001; + await refused(); +}); + +test.each(["consent", "pause", "credits", "reauth", "identity", "hard-lock"])("eligibility is checked after token preparation changes %s", async change => { + const original = mainAccount.getValidMainAccountToken; + spyOn(mainAccount, "getValidMainAccountToken").mockImplementation(async options => { + const token = await original(options); + if (change === "consent") cfg.creditCodexAccountIds = []; + if (change === "pause") cfg.pausedCodexAccountIds = [MAIN]; + if (change === "credits") seed(null); + if (change === "reauth") markAccountNeedsReauth(MAIN); + if (change === "identity") writeMain("fixture-replacement"); + if (change === "hard-lock") cfg.codexMainAccountHardLock = true; + return token; + }); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([]); +}); + +test("overlapping sweeps share one credit renewal and release ownership", async () => { + const entered = deferred(); + const finish = deferred(); + fetchWith(async () => { entered.resolve(); return finish.promise; }); + const first = runMainAccountHardLockRecovery(cfg); + await entered.promise; + const others = [runMainAccountHardLockRecovery(cfg), runMainAccountHardLockRecovery(cfg)]; + expect(calls).toEqual([whamUrl]); + finish.resolve(usage()); + await Promise.all([first, ...others]); + expect(getNativeMainProfileRequestCount()).toBe(0); + expect(getMainPolicyQuota()?.credits?.observedAt).toBe(now); +}); + +test("upstream Retry-After paces credit renewal until its deadline", async () => { + let reads = 0; + fetchWith(async () => ++reads === 1 ? new Response("{}", { status: 429, headers: { "Retry-After": "900" } }) : usage()); + await runMainAccountHardLockRecovery(cfg); + now += 899_999; + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + expect(hasSpendableCodexCredits(getMainPolicyQuota())).toBe(false); + now++; + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl, whamUrl]); + expect(hasSpendableCodexCredits(getMainPolicyQuota())).toBe(true); +}); + + +test.each([401, 403])("passive option survives identity retry for terminal WHAM %s", async status => { + fetchWith(async () => { + if (calls.length === 1) { writeMain("fixture-replacement"); return usage(); } + return Response.json({ detail: { code: "invalid_workspace_selected" } }, { status }); + }); + const result = await fetchMainAccountInfoAttempt(true, 1, undefined, false, true, false, cfg, { passive: true }); + expect(calls).toEqual([whamUrl, whamUrl]); + expect(result.terminalAuthFailure).toBe(true); + expect(isAccountNeedsReauth(MAIN)).toBe(false); +}); + +test.each([true, false])("passive successful probe retains reauth marked in flight (identity retry=%j)", async retry => { + fetchWith(async () => { + if (retry && calls.length === 1) { writeMain("fixture-replacement"); return usage(); } + markAccountNeedsReauth(MAIN); + return usage(); + }); + await fetchMainAccountInfoAttempt(true, 1, undefined, false, true, false, cfg, { passive: true }); + expect(calls).toHaveLength(retry ? 2 : 1); + expect(isAccountNeedsReauth(MAIN)).toBe(true); +}); + + +test.each([401, 403])("terminal passive WHAM %s retains recovery backoff without quarantine", async status => { + fetchWith(async () => Response.json({ detail: { code: "invalid_workspace_selected" } }, { status })); + await runMainAccountHardLockRecovery(cfg); + now += 60_000; + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + now += 240_000; + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl, whamUrl]); + expect(isAccountNeedsReauth(MAIN)).toBe(false); +}); 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 0be59669855..72f7bac7a25 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -14,8 +14,6 @@ import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountInfoCa import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { isMainAccountRefreshGrantRejected, setMainAccountPlan } from "../../src/codex/main-account"; import { clearAccountQuota, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; -import { CODEX_CREDITS_FRESHNESS_MS, hasSpendableCodexCredits } from "../../src/codex/quota-types"; -import { WHAM_REQUEST_TIMEOUT_MS } from "../../src/codex/quota-recovery-timing"; import { clearCodexUpstreamHealth, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome } from "../../src/codex/routing"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -72,27 +70,6 @@ function usage(percent = 0): Response { } }); } -/** Seed an opted-in full window with independently timed credit evidence. */ -function creditRecoveryConfig(now: number, observedAt = now - CODEX_CREDITS_FRESHNESS_MS + 60_000, - resetAt = now + 3_600_000): OcxConfig { - clearAccountQuota(MAIN); - setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: resetAt, - credits: { hasCredits: true, unlimited: false, balance: 5, allowed: true, - overageLimitReached: false, observedAt } }, undefined, captureMainQuotaWriter(accountId)); - return { ...config(), codexMainAccountHardLock: false, creditCodexAccountIds: [MAIN] }; -} - -/** Return a WHAM observation with a full included window and explicit credit controls. */ -function creditUsage(restriction?: string): Response { - return Response.json({ plan_type: "plus", rate_limit: { - primary_window: { used_percent: 100, limit_window_seconds: 604_800, - reset_at: Math.floor((Date.now() + 3_600_000) / 1000) }, - secondary_window: null, tertiary_window: null, - }, credits: { has_credits: restriction !== "empty", unlimited: false, - balance: restriction === "empty" ? 0 : 5, overage_limit_reached: restriction === "overage" }, - spend_control: { reached: restriction === "spending-off" } }); -} - /** * Stub recovery HTTP calls, requiring a known metadata/token URL and an active native-main drain. * Return the captured URL list so tests can verify the requests made by background recovery. @@ -158,88 +135,6 @@ afterEach(async () => { } }); -describe("opted-in main credit evidence recovery", () => { - test("renews before credit expiry without extending the admission clock", async () => { - let now = Date.now(); - const clock = spyOn(Date, "now").mockImplementation(() => now); - try { - const initialObservation = now; - const cfg = creditRecoveryConfig(now, initialObservation); - const expectedBearer = JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).tokens.access_token; - const calls = fetchWith(async (_url, init) => { - expect(new Headers(init?.headers).get("chatgpt-account-id")).toBe(accountId); - expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${expectedBearer}`); - return creditUsage(); - }); - now += CODEX_CREDITS_FRESHNESS_MS - sweeper.STATE_SWEEP_INTERVAL_MS - WHAM_REQUEST_TIMEOUT_MS; - await runMainAccountHardLockRecovery(cfg); - expect(calls).toEqual([]); - now++; - expect(hasSpendableCodexCredits(getMainPolicyQuota(), now)).toBe(true); - await runMainAccountHardLockRecovery(cfg); - expect(calls).toEqual([whamUrl]); - expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 100, - credits: { observedAt: now, allowed: true, hasCredits: true, balance: 5 } }); - now = initialObservation + CODEX_CREDITS_FRESHNESS_MS + 1; - expect(hasSpendableCodexCredits(getMainPolicyQuota(), now)).toBe(true); - await runMainAccountHardLockRecovery(cfg); - expect(calls).toEqual([whamUrl]); - expect(getNativeMainProfileRequestCount()).toBe(0); - } finally { clock.mockRestore(); } - }); - - test.each(["no-consent", "paused", "reauth", "fresh", "reset", "hard-lock"])( - "%s does not trigger the credit renewal path", async condition => { - const now = Date.now(); - const cfg = creditRecoveryConfig(now, condition === "fresh" ? now : undefined, - condition === "reset" ? now - 1 : undefined); - if (condition === "no-consent") cfg.creditCodexAccountIds = []; - if (condition === "paused") cfg.pausedCodexAccountIds = [MAIN]; - if (condition === "reauth") markAccountNeedsReauth(MAIN); - if (condition === "hard-lock") cfg.codexMainAccountHardLock = true; - const calls = fetchWith(async () => creditUsage()); - await runMainAccountHardLockRecovery(cfg); - expect(calls).toEqual([]); - expect(getNativeMainProfileRequestCount()).toBe(0); - }); - - test.each(["empty", "spending-off", "overage"])( - "%s preserves refusal and bounds repeated renewal attempts", async restriction => { - const cfg = creditRecoveryConfig(Date.now()); - const calls = fetchWith(async () => creditUsage(restriction)); - await runMainAccountHardLockRecovery(cfg); - expect(calls).toEqual([whamUrl]); - expect(hasSpendableCodexCredits(getMainPolicyQuota())).toBe(false); - expect(getMainPolicyQuota()?.weeklyPercent).toBe(100); - await runMainAccountHardLockRecovery(cfg); - expect(calls).toEqual([whamUrl]); - expect(isAccountNeedsReauth(MAIN)).toBe(false); - }); - - test("credit renewal retains upstream Retry-After until a real later observation", async () => { - let now = Date.now(); - const clock = spyOn(Date, "now").mockImplementation(() => now); - try { - const cfg = creditRecoveryConfig(now); - const previousObservation = getMainPolicyQuota()?.credits?.observedAt; - let reads = 0; - const calls = fetchWith(async () => ++reads === 1 - ? new Response("{}", { status: 429, headers: { "Retry-After": "900" } }) : creditUsage()); - await runMainAccountHardLockRecovery(cfg); - now += 899_999; - await runMainAccountHardLockRecovery(cfg); - expect(calls).toEqual([whamUrl]); - expect(getMainPolicyQuota()?.credits?.observedAt).toBe(previousObservation); - expect(hasSpendableCodexCredits(getMainPolicyQuota(), now)).toBe(false); - now++; - await runMainAccountHardLockRecovery(cfg); - expect(calls).toEqual([whamUrl, whamUrl]); - expect(getMainPolicyQuota()?.credits?.observedAt).toBe(now); - expect(hasSpendableCodexCredits(getMainPolicyQuota(), now)).toBe(true); - } finally { clock.mockRestore(); } - }); -}); - describe("main hard-lock background recovery", () => { test("known reset waits locally, then verifies recovery instead of unlocking by time", async () => { let now = Date.now(); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 700841acd0a..a9ebc6e5cd1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -683,7 +683,7 @@ "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-default.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", - "main-account-hard-lock-recovery.test.ts": "codex-integration", + "main-account-hard-lock-recovery.test.ts": "codex-integration", "main-account-credit-renewal.test.ts": "codex-integration", "main-account-policy-binding-wait.test.ts": "codex-integration", "main-device-reauth-api.test.ts": "codex-integration", "main-device-reauth-ui.test.ts": "gui", "main-device-reauth.test.ts": "codex-integration", "main-quota-evidence-validation.test.ts": "codex-integration", From 9fa86a5901e7e56af5c7c527680669886ffaafd3 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 7 Oct 2026 23:10:26 +0900 Subject: [PATCH 8/8] fix(codex): preserve joiner auth behavior during passive renewal --- src/codex/auth-api/main-account-probe.ts | 9 +++ structure/providers/openai-accounts.md | 2 +- .../main-account-credit-renewal.test.ts | 68 ++++++++++++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 5d036047e9e..5e208e5e1ec 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -295,6 +295,15 @@ export async function fetchMainAccountInfoWhileOwned( const current = credentialIsCurrent() && writerGeneration === captureConfigGeneration(); if (!current) return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + // Single-flight shares evidence, but each caller retains its own auth behavior. + if (!options.passive && admission.result.terminalAuthFailure) { + const diagnosticStillLive = admission.result.quotaRefreshGeneration !== undefined + && isMainAccountIdentityGenerationLive(admission.result.quotaRefreshGeneration); + clearMainAccountInfoCache(); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); + return { ...admission.result, ...(diagnosticStillLive + ? { quotaRefreshGeneration: captureMainAccountIdentityGeneration() } : {}) }; + } if (!options.passive && explicitRefresh && (admission.result.quotaRefresh?.status === "ok" || admission.result.quotaRefresh?.status === "not_reported")) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); return admission.result; diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index dc29ae2cb1f..7c44d37e8b3 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -479,7 +479,7 @@ false. Exact-account and Direct routes are unchanged. ## Main-account policy observations -Opted-in main-account credit renewal follows the [spendable credit contract](openai-tiers.md#spendable-codex-credits). The existing background sweep prepares a token before WHAM, rechecks eligibility, and uses a passive probe that neither sets nor clears needs-reauth, including after identity retries. Other probes keep their existing auth behavior. +Opted-in main-account credit renewal follows the [spendable credit contract](openai-tiers.md#spendable-codex-credits). The existing background sweep prepares a token before WHAM, rechecks eligibility, and uses a passive probe that neither sets nor clears needs-reauth, including after identity retries. Other probes keep their existing auth behavior. A non-passive caller joining renewal applies terminal-auth quarantine, or clears it on a successful explicit refresh, only while its credential and configuration fences remain current; shared evidence does not inherit the owner's passive intent. The main-account admission policy defaults to 90% for short windows and 98% for long windows; `codexMainAccountHardLockThresholds` permits ordered integer thresholds from 80 through 100. diff --git a/tests/codex-integration/main-account-credit-renewal.test.ts b/tests/codex-integration/main-account-credit-renewal.test.ts index 993b5885715..f5a1504007a 100644 --- a/tests/codex-integration/main-account-credit-renewal.test.ts +++ b/tests/codex-integration/main-account-credit-renewal.test.ts @@ -8,7 +8,7 @@ import { registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecover 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, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountInfoCache, isMainAccountIdentityGenerationLive, observeMainQuotaCredential, setMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; import { hasSpendableCodexCredits, type CodexSpendableCredits } from "../../src/codex/quota-types"; import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; @@ -16,6 +16,7 @@ import { clearCodexUpstreamHealth } from "../../src/codex/routing"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import * as mainAccount from "../../src/codex/main-account"; import * as sweeper from "../../src/lib/state-store-sweeper"; +import * as quotaQueries from "../../src/codex/quota-query-backoff"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { getNativeMainProfileRequestCount, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; import { mapCodexAuthContextErrorToResponse } from "../../src/server/responses/codex-auth-error"; @@ -332,3 +333,68 @@ test.each([401, 403])("terminal passive WHAM %s retains recovery backoff without expect(calls).toEqual([whamUrl, whamUrl]); expect(isAccountNeedsReauth(MAIN)).toBe(false); }); + + +async function joinPassiveRenewal(explicitRefresh: boolean) { + const entered = deferred(); + const joined = deferred(); + const finish = deferred(); + const originalQuery = quotaQueries.fetchCodexUsage; + let queries = 0; + spyOn(quotaQueries, "fetchCodexUsage").mockImplementation((...args: Parameters) => { + const read = originalQuery(...args); + if (++queries === 2) joined.resolve(); + return read; + }); + fetchWith(async () => { entered.resolve(); return finish.promise; }); + const renewal = runMainAccountHardLockRecovery(cfg); + await entered.promise; + const caller = fetchMainAccountInfoAttempt(true, 1, undefined, false, explicitRefresh, false, cfg); + await joined.promise; // The real single-flight join is pending before releasing WHAM. + expect(calls).toEqual([whamUrl]); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + return { renewal, caller, finish }; +} + +test.each([ + { status: 401, explicitRefresh: true }, { status: 403, explicitRefresh: true }, + { status: 401, explicitRefresh: false }, { status: 403, explicitRefresh: false }, +])("ordinary caller joining passive renewal quarantines terminal WHAM %j", async ({ status, explicitRefresh }) => { + const { renewal, caller, finish } = await joinPassiveRenewal(explicitRefresh); + setMainAccountInfoCache({ email: null, plan: "plus", quota: null, ts: now }); + finish.resolve(Response.json({ detail: { code: "invalid_workspace_selected" } }, { status })); + const [, result] = await Promise.all([renewal, caller]); + expect(calls).toEqual([whamUrl]); + expect(result.terminalAuthFailure).toBe(true); + expect(result.quotaRefresh).toEqual({ status: "http_error", httpStatus: status, code: "invalid_workspace_selected" }); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(getMainAccountInfoCache()).toBeNull(); + expect(isMainAccountIdentityGenerationLive(result.quotaRefreshGeneration!)).toBe(true); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test.each([true, false])("successful caller joining passive renewal clears quarantine only for explicit refresh=%j", async explicitRefresh => { + const { renewal, caller, finish } = await joinPassiveRenewal(explicitRefresh); + markAccountNeedsReauth(MAIN); + finish.resolve(usage()); + const [, result] = await Promise.all([renewal, caller]); + expect(calls).toEqual([whamUrl]); + expect(result.quotaRefresh?.status).toBe("ok"); + expect(isAccountNeedsReauth(MAIN)).toBe(!explicitRefresh); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test.each(["credential", "configuration"])("joined terminal-auth evidence cannot quarantine after %s changes", async fence => { + const { renewal, caller, finish } = await joinPassiveRenewal(true); + if (fence === "credential") writeMain(accountId, true); + else { + const generation = sweeper.captureConfigGeneration(); + spyOn(sweeper, "captureConfigGeneration").mockReturnValue(generation + 1); + } + finish.resolve(Response.json({ detail: { code: "invalid_workspace_selected" } }, { status: 403 })); + const [, result] = await Promise.all([renewal, caller]); + expect(calls).toEqual([whamUrl]); + expect(result.terminalAuthFailure).toBeUndefined(); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + expect(getNativeMainProfileRequestCount()).toBe(0); +});