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