diff --git a/devlog/_plan/260904_main_card_badge_parity/000_evidence.md b/devlog/_plan/260904_main_card_badge_parity/000_evidence.md new file mode 100644 index 0000000000..9992d11d42 --- /dev/null +++ b/devlog/_plan/260904_main_card_badge_parity/000_evidence.md @@ -0,0 +1,111 @@ +# 000 — Evidence: main account card is missing two badges + +Unit: `260904_main_card_badge_parity` +Opened: 2026-09-04 +Branch base: `dev` @ `8b60e4c44` + +## Reported symptom + +On the Codex Auth dashboard the MAIN account card shows neither the plan badge +(`pro`) nor the reset-credit ticket badge, while every pool card shows both. + +## Live evidence (read-only, port 10100) + +`GET /api/codex-auth/accounts` at 2026-09-04, main entry: + +```json +{"id":"__main__","email":"k***1@gmail.com","plan":"pro","isMain":true, + "quota":{"weeklyPercent":28,"weeklyResetAt":1788749167,"updatedAt":1788490155601}} +``` + +A pool entry from the same response: + +```json +{"id":"chatgpt-1786626108327","plan":"pro", + "quota":{"updatedAt":1788490159314,"weeklyPercent":26,"weeklyResetAt":1788748127,"resetCredits":2}} +``` + +On-disk cache `~/.opencodex/codex-quota-cache.json`, `__main__` entry: + +```json +{"updatedAt":1788490155601,"weeklyPercent":28,"weeklyResetAt":1788749167, + "customWindows":[{"label":"GPT-5.3-Codex-Spark Weekly","percent":0,"resetAt":1789094955}], + "resetCredits":1} +``` + +So the store HAS `resetCredits: 1` for the main account, and the response DTO +drops it. That is the whole of defect 2. + +## Two independent defects + +**D1 — plan badge absent from the main card markup.** The server sends +`plan: "pro"`. `gui/src/components/codex-account-pool-cards.tsx:91` renders +`{a.plan && {a.plan}}` inside +`card-badges`. The equivalent block in +`gui/src/components/codex-account-pool-main-card.tsx:87-99` has no such line. +Purely a missing element; no data problem. + +**D2 — resetCredits never reaches the main DTO.** +`CodexTicketBadge` (`codex-account-pool-helpers.tsx:28-51`) returns `null` +when `account.quota` is non-null but `quota.resetCredits === undefined`. The +main card passes `{...main, id:"__main__"}`, so it inherits whatever the DTO +carries — and the DTO carries no `resetCredits`. + +## Why the two DTO paths diverge + +Pool path, `src/codex/auth-api.ts`: + +- `commitPoolQuotaResponse` writes the parsed snapshot with + `setAccountQuotaFromParsed(accountId, quota, writerGeneration)` (line 1195) + and then returns `quota: getAccountQuota(accountId)` (line 1197) — it reads + the value **back out of the merged store**. +- `poolAccountDto` (line 277) serializes that store-read object, so it carries + every field the store merged, including a `resetCredits` that arrived on an + earlier partial snapshot. + +Main path, same file: + +- `fetchMainAccountInfoWhileOwned` (line 807+) parses the same WHAM payload, + mirrors it into the store with `setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, ...)` + (line 860) — and then caches and returns `result.quota`, the **pre-merge parse + result**, not the store value. +- `listCodexAuthAccountsSnapshot` (line 1632-1647) builds the main DTO from + `mainInfo.quota`, spreading it and patching in only `updatedAt` from + `getAccountQuota(MAIN_CODEX_ACCOUNT_ID)`: + +```ts +quota: mainInfo.quota ? { + ...quotaForPlan({ + ...mainInfo.quota, + updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(), + }, mainInfo.plan), +} : null, +``` + +That reaches into the store for exactly one field. Every other merged field — +`resetCredits` above all — is lost whenever the current `/wham/usage` response +omits `rate_limit_reset_credits.available_count`. + +## Why the current response omits it + +`parseUsageQuota` (`src/codex/quota.ts:561`) only sets `resetCredits` when the +payload carries `rate_limit_reset_credits.available_count`. `/wham/usage` +includes that summary inconsistently, and the dedicated +`/wham/rate-limit-reset-credits` endpoint is separately rate limited — a live +probe for `__main__` returned `{"error":"Upstream error 429"}`. The store is +specifically designed to survive that: `setAccountQuotaFromParsed` +(`quota.ts:339-340`) carries `existing.resetCredits` forward when the new +snapshot omits it. The pool DTO benefits from that carry-forward because it +re-reads the store. The main DTO does not, because it does not. + +This also matches upstream Codex, where `RateLimitsWithResetCredits` +(`codex-rs/backend-client/src/types.rs:45-48`) models the reset-credit summary +as `Option` alongside rate limits rather than as a field guaranteed on every +usage read. + +## Conclusion + +D2 is a server-layer bug, not a GUI bug: the main account is the only account +whose DTO bypasses the merged quota store. Fixing it in the GUI (for example by +reading `/api/codex-auth/quota` separately) would paper over an asymmetry that +also affects any other consumer of the main DTO. diff --git a/devlog/_plan/260904_main_card_badge_parity/010_server_dto_parity.md b/devlog/_plan/260904_main_card_badge_parity/010_server_dto_parity.md new file mode 100644 index 0000000000..f5a223bb55 --- /dev/null +++ b/devlog/_plan/260904_main_card_badge_parity/010_server_dto_parity.md @@ -0,0 +1,210 @@ +# 010 — Phase 1: main-account DTO reads the merged quota store + +Work phase: `wp1`. Depends on: nothing. Consumed by: `020`. + +## Goal + +The main account's DTO quota must be the same merged store object a pool +account's DTO quota is, so every field the store carries forward (today +`resetCredits`, tomorrow anything else) reaches the dashboard. + +## Scope boundary + +IN: `src/codex/auth-api.ts` main DTO construction; a focused test in +`tests/codex-auth-api.test.ts`. +OUT: `src/codex/quota.ts` merge semantics (already correct), the pool path, +any WHAM fetch/refresh policy, credential handling. + +## File change map + +### `src/codex/auth-api.ts` — `listCodexAuthAccountsSnapshot`, main DTO (~line 1642) + +Before: + +```ts +quota: mainInfo.quota ? { + ...quotaForPlan({ + ...mainInfo.quota, + updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(), + }, mainInfo.plan), +} : null, +``` + +After: + +```ts +quota: mainInfo.quota ? { + ...quotaForPlan(mergeMainQuotaWithStore(mainInfo.quota), mainInfo.plan), +} : null, +``` + +with a small local helper next to the DTO builders: + +```ts +/** + * The main account is the only account whose DTO quota came from the raw parse + * result rather than the merged store, so a resetCredits the store had carried + * forward vanished from the response whenever the current /wham/usage payload + * omitted `rate_limit_reset_credits`. Pool DTOs never had that hole because + * commitPoolQuotaResponse re-reads getAccountQuota() after committing. + * + * Only resetCredits is filled from the store, deliberately. The window fields + * have *clearing* semantics -- a monthly-only snapshot must drop a stale weekly + * value (#382) -- so a blanket spread of the stored object would resurrect a + * window the parse intended to clear whenever the store write was refused by + * generation gating. resetCredits is the one field setAccountQuotaFromParsed + * itself carries forward (quota.ts:339-340), so mirroring exactly that rule + * here keeps the DTO consistent with the store instead of inventing a second, + * looser merge policy. + */ +function mainQuotaWithStoredResetCredits( + parsed: Omit, +): StoredAccountQuota { + const stored = getAccountQuota(MAIN_CODEX_ACCOUNT_ID); + return { + ...parsed, + ...(parsed.resetCredits === undefined && stored?.resetCredits !== undefined + ? { resetCredits: stored.resetCredits } + : {}), + updatedAt: stored?.updatedAt ?? Date.now(), + }; +} +``` + +Call site becomes `quotaForPlan(mainQuotaWithStoredResetCredits(mainInfo.quota), mainInfo.plan)`. + +Precedence rationale: a freshly parsed `resetCredits` always wins, including a +deliberate `0` (0 is defined, so it is present in `parsed` and the fill branch +does not run). The store supplies the value only when the parse omitted the key +entirely. Every other field is untouched, so no window-clearing behaviour +changes. + +### Audit finding folded in (blocker 1) + +The first draft of this document proposed `{ ...stored, ...parsed }`. That is +unsafe: `setAccountQuotaFromParsed` refuses to commit when +`mayCommitAccountQuota` fails generation gating (`quota.ts:280`), so the store +can legitimately hold a PRE-clear snapshot while `parsed` is monthly-only. The +blanket spread would then re-introduce the stale `weeklyPercent` that #382 +exists to clear, and it would show up as a phantom weekly bar on the main card. +Narrowing the merge to `resetCredits` removes that failure mode entirely. + +### Identity-change safety (corrected — audit blocker 3) + +The first two drafts claimed a swapped identity "cannot leak" a previous +account's credits. **That claim was wrong**, and the second reviewer +(muse-spark-1.3-contributor) refuted it with the exact path: + +- In-process swaps ARE safe: `reconcileMainCodexAccountRuntimeState` + (`account-lifecycle.ts:60-70`) purges alias-keyed `__main__` quota when it + observes the account id change, and `mainSnapshotLive === false` forces + `EMPTY_MAIN_ACCOUNT_INFO`, whose null quota short-circuits the DTO guard. +- Across a RESTART it is not. `observedMainChatgptAccountId` + (`account-lifecycle.ts:21`) is memory-only, and the first observation after a + restart hits the `previousAccountId === undefined` early return with no purge + (`:67`). If `~/.codex/auth.json` was swapped while the proxy was down, the + disk-hydrated `__main__` quota entry still belongs to the PREVIOUS login, and + a store-based fill would print its ticket count on the new account's card. + Pool accounts never have this hole because their store key is the account id + itself; `__main__` is an alias. + +Fix: do not read the fill value from the store at all. Keep an in-process, +identity-tagged observation of the last parsed count +(`mainResetCreditsProvenance = { accountId, credits }`), recorded in +`fetchMainAccountInfoWhileOwned` next to the existing `freshResetCredits`, and +return it only when `getMainChatgptAccountId()` still matches. A restart simply +starts with no observation, so the badge waits for the first response that +carries the summary rather than showing a stale or foreign number. + +```ts +let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null; + +function mainResetCreditsForCurrentIdentity(): number | undefined { + if (!mainResetCreditsProvenance) return undefined; + const currentAccountId = getMainChatgptAccountId(); + if (currentAccountId === null) return undefined; + if (currentAccountId !== mainResetCreditsProvenance.accountId) { + mainResetCreditsProvenance = null; + return undefined; + } + return mainResetCreditsProvenance.credits; +} +``` + +`updatedAt` still comes from the store, unchanged from today's behaviour. + +### Consume-route interaction (audit question Q2c) + +`auth-api.ts:2135` deliberately refuses to report a preserved cached +`resetCredits` as the consume response's `remaining`. That governs a +*transactional* claim about a just-executed redeem and is a different guarantee +from best-effort display state, so the DTO carry does not violate it. The real +overlap, recorded rather than fixed: if the forced post-consume refresh omits +the summary, the main card keeps showing the pre-consume count until the next +response that carries it — exactly the staleness every pool card already has. + +### Upstream omission semantics (residual, non-blocking) + +If upstream ever omits `rate_limit_reset_credits` to MEAN zero, a carried +non-zero would persist until the next explicit reading. Nothing in this +repository settles that question, the risk is pre-existing in the store merge, +and it is shared with every pool card. Named here rather than guessed at. + +### quotaForPlan interaction + +`quotaForPlan` already forwards `resetCredits` for 30-day plans +(`auth-api.ts:272`) and `withSparkVisibility` only filters `customWindows`, +which this helper does not touch. No change needed in either. + +Note on `quotaForPlan`: it already passes `resetCredits` through for 30-day +plans (`auth-api.ts:272`), so no change is needed there. + +## Accept criteria + +1. Given a main WHAM parse result without `resetCredits` and a store entry for + `__main__` holding `resetCredits: 1`, the main DTO carries `resetCredits: 1`. +2. Given a parse result WITH `resetCredits: 0` and a store entry holding + `resetCredits: 3`, the DTO carries `0` — fresh wins, including zero. +3. Given no store entry, the DTO is byte-identical to today's output. +4. `updatedAt` behaviour is unchanged (store value, else now). +5. Window fields are never taken from the store: a monthly-only parse with a + stored weekly value still produces a DTO without `weeklyPercent`. +6. A carried count is dropped when the physical main account id changes. + +### Activation scenario for the conditional path + +The new helper's store branch only runs when `getAccountQuota("__main__")` +returns an entry. The test triggers it by calling +`setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { resetCredits: 1 })` before +listing accounts, and proves it ran by asserting `resetCredits` is present in +the returned DTO where it is absent today. + +## Verifier + +`bun test tests/codex-auth-api.test.ts` — this file already exercises the main +DTO and the `__main__` quota store (it references +`getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits` at lines 2631, 2668, +2706), so it observes the change target directly. + +## Field chain (PLAN-FIELD-CHAIN-01) + +`resetCredits: number | undefined` is not a new field; this phase changes which +object the DTO reads. Chain for completeness: + +- creation: `parseUsageQuota` (`quota.ts:562`) from + `rate_limit_reset_credits.available_count`; also + `updateAccountQuota(..., resetCredits)` (`quota.ts:473`). +- store merge: `setAccountQuotaFromParsed` (`quota.ts:294, 339-340`). +- serialization: `poolAccountDto` (store-read) and the main DTO (this fix). +- deserialization: `hydrateAccountQuotasFromDisk` reads + `codex-quota-cache.json`; N/A for the DTO, which is response-only. +- consumers: `CodexTicketBadge` (`gui/.../codex-account-pool-helpers.tsx:29`), + `src/cli/account-auth.ts`, the reset-credit consume route + (`auth-api.ts:2135`). + +## Bypass record (PLAN-BYPASS-NAMED-01) + +This phase adds no enforcement. Tier: n/a. Executing surface: n/a. Known bypass: +n/a. Residual risk: a future main DTO rewrite could reintroduce the raw-parse +read; the regression test in criterion 1 is the early warning. Final enforcement +layer: none. diff --git a/devlog/_plan/260904_main_card_badge_parity/020_gui_plan_badge.md b/devlog/_plan/260904_main_card_badge_parity/020_gui_plan_badge.md new file mode 100644 index 0000000000..3671a780f4 --- /dev/null +++ b/devlog/_plan/260904_main_card_badge_parity/020_gui_plan_badge.md @@ -0,0 +1,60 @@ +# 020 — Phase 2: main card renders the plan badge + +Work phase: `wp2`. Depends on: `010` (only for the ticket badge to have data; +the plan badge itself is independent). + +## Goal + +The main account card shows its plan as a badge, exactly as pool cards do. + +## Scope boundary + +IN: `gui/src/components/codex-account-pool-main-card.tsx` badge row. +OUT: card layout, the skeleton block, pool card markup, styling changes. + +## File change map + +### `gui/src/components/codex-account-pool-main-card.tsx` (~line 87) + +Inside ``, as the FIRST child — matching the pool +card's order at `codex-account-pool-cards.tsx:91` so both cards read +plan → paused → priority → pinned → ticket → health: + +```tsx +{main?.plan && {main.plan}} +``` + +The existing ticket badge line moves after the pinned badge so the two cards +share one badge order. Nothing else in the row changes. + +### Skeleton parity (`~line 279`) — decision recorded + +The load skeleton reserves a ticket slot and a `badge-primary` slot. The +question was whether the new plan badge needs a matching muted strut. + +**Decision: no strut.** The skeleton already omits the priority and pinned +badges that the ready state can render, so approximate width parity is the +established norm for this card rather than a regression introduced here. Adding +a strut for the plan badge alone would make the skeleton wider than the common +ready state (an account with no plan renders no badge at all), trading one +small shift for a different one. Recorded per the audit's blocker 2, which +asked for the strut or a stated reason. + +## Accept criteria + +1. With `main.plan === "pro"`, the rendered main card contains + `pro`. +2. With `main.plan` undefined, no plan badge element is rendered (no empty span). +3. Badge order in the main card matches the pool card. + +## Verifier + +Rendered-DOM observation on the running dashboard (C-RENDER-GROUNDING-01) plus +`bun run lint:gui` and `bun run typecheck`. There is no existing GUI unit-test +harness for this component, so the DOM observation is the acceptance evidence and +that is recorded rather than claimed as a gate. + +## Bypass record + +No enforcement added. Final enforcement layer: none; the visual check is human/DOM +observation. diff --git a/devlog/_plan/260904_main_card_badge_parity/030_verification_and_pr.md b/devlog/_plan/260904_main_card_badge_parity/030_verification_and_pr.md new file mode 100644 index 0000000000..a55b4df00e --- /dev/null +++ b/devlog/_plan/260904_main_card_badge_parity/030_verification_and_pr.md @@ -0,0 +1,43 @@ +# 030 — Phase 3: verification and pull request + +Work phase: `wp3`. Depends on: `010`, `020`. + +## Goal + +Prove both fixes on the running dashboard, pass the repository gates, and open a +PR against `dev` that satisfies the repository's own CI gates. + +## Steps + +1. `bun run typecheck` — expect exit 0. +2. `bun test tests/codex-auth-api.test.ts` — expect exit 0, new case passing. +3. `bun run lint:gui` — expect exit 0. +3b. `bun run privacy:scan` — expect exit 0 (AGENTS.md CI gate; added after audit + blocker 3 noted it was missing from this list). +3c. Docs-site evaluation: this change restores badge parity that the dashboard + documentation already describes generically; record "no docs-site change + needed" in the PR unless a page names the missing badges explicitly. +4. `bun run build:gui`, restart the local proxy from this checkout, load the + dashboard, and capture a screenshot of the main card showing BOTH badges. + The proxy on port 10100 is the user's live service: restart it only through + the normal `ocx` service path already used for source dogfooding, and verify + `/healthz` afterwards. +5. `bun run test` — full suite, required before marking the PR review-ready + (AGENTS.md PR-ready gate). +6. Branch `codex/260904-main-card-badge-parity`, commit per phase, push, open a + PR targeting `dev` with all three template sections and the screenshot + (`enforce-target` rejects a gui PR without one). +7. `gh pr checks` at the exact head SHA; merge only on a green rollup. + +## Accept criteria + +- Screenshot shows `pro` badge and ticket badge on the main card. +- Full suite and typecheck exit 0 at the PR head SHA. +- `git merge-base --is-ancestor origin/dev` succeeds after merge. + +## Bypass record + +CI gates here are repository-owned (E8, GitHub Actions). Known bypass: +`--no-verify` on local hooks does not bypass branch protection on `dev`. +Residual risk: none beyond maintainer merge authority. Final layer: branch +ruleset on `dev`. diff --git a/devlog/_plan/260904_main_card_badge_parity/040_promotion_and_release.md b/devlog/_plan/260904_main_card_badge_parity/040_promotion_and_release.md new file mode 100644 index 0000000000..eacd2c3b18 --- /dev/null +++ b/devlog/_plan/260904_main_card_badge_parity/040_promotion_and_release.md @@ -0,0 +1,34 @@ +# 040 — Phase 4: promotion to preview and main, release + +Work phase: `wp4`. Depends on: `030`. + +## Goal + +Promote the merged `dev` state to `preview` and `main` and cut a release, as +the user explicitly authorized ("main preview 머지후 릴리즈까지 진행"). + +## Steps + +1. Re-read `MAINTAINERS.md` and `scripts/release.ts` before acting; the release + script is the release authority and is security-reviewed surface — do not edit + it. +2. Confirm `dev` carries the merge commit and CI is green at that exact SHA. +3. Promote `dev` → `preview`, then `dev` → `main`, using the repository's + established promotion path (pull request or maintainer promotion as + `MAINTAINERS.md` prescribes; branch rulesets forbid direct pushes). +4. Cut the release through `scripts/release.ts`. +5. Verify: ancestry proof for both branches, the release run/tag, and the running + proxy's `/healthz` version after upgrade. + +## Escalation + +If promotion or the release requires an approval the user has not delegated, or +the release script asks for a credential this session must not spend, stop and +report `NEEDS_HUMAN` with the exact blocking step rather than improvising. + +## Bypass record + +Tier: E8 (branch rulesets + release workflow). Executing surface: GitHub Actions +and branch protection. Known bypass: none available to this session. Residual +risk: a maintainer could promote manually. Final layer: branch ruleset on +`main`/`preview`. diff --git a/devlog/_plan/260904_main_card_badge_parity/evidence/main-card-badges.png b/devlog/_plan/260904_main_card_badge_parity/evidence/main-card-badges.png new file mode 100644 index 0000000000..50e06f9ee8 Binary files /dev/null and b/devlog/_plan/260904_main_card_badge_parity/evidence/main-card-badges.png differ diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index e42dc9536c..dba055fa5a 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -85,7 +85,7 @@ export function CodexAccountPoolMainCard({ {t("codexAuth.mainAccount")} - {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} + {main?.plan && {main.plan}} {main?.paused && ( {t("codexAuth.paused")} @@ -93,6 +93,7 @@ export function CodexAccountPoolMainCard({ )} {pinnedId === "__main__" && !main?.paused && {t("codexAuth.pinned")}} + {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} {healthLabel && ( {healthLabel} )} diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 5fc6dcd657..36295027e9 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -274,6 +274,69 @@ function quotaForPlan | StoredAc } as T; } +/** + * Last reset-credit count this process parsed for the main account, tagged with the + * physical ChatGPT account it was read from. + * + * It is deliberately memory-only. The quota store is keyed by the stable `__main__` + * ALIAS, and `~/.codex/auth.json` can be swapped for another account while the proxy is + * not running — `reconcileMainCodexAccountRuntimeState` only purges alias-keyed state + * when it observes the id CHANGE, and its first observation after a restart has nothing + * to compare against. A disk-hydrated `__main__` entry can therefore belong to the + * previous login, so filling the DTO from it would show one account's tickets on + * another's card. Pool accounts have no such hole because their store key IS the account + * id. Binding the value to `requestAccountId` keeps the fill honest: after a restart the + * badge simply waits for the first usage response that carries the summary. + */ +let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null; + +function rememberMainResetCredits(accountId: string | null, credits: number | undefined): void { + if (accountId === null || credits === undefined) return; + mainResetCreditsProvenance = { accountId, credits }; +} + +/** Forget the remembered count when the physical main identity is no longer the same. */ +function mainResetCreditsForCurrentIdentity(): number | undefined { + if (!mainResetCreditsProvenance) return undefined; + const currentAccountId = getMainChatgptAccountId(); + if (currentAccountId === null) return undefined; + if (currentAccountId !== mainResetCreditsProvenance.accountId) { + mainResetCreditsProvenance = null; + return undefined; + } + return mainResetCreditsProvenance.credits; +} + +/** + * The main account is the only account whose DTO quota comes from the raw WHAM parse + * result instead of the merged store: `poolAccountDto` serializes what + * `commitPoolQuotaResponse` read back out of `getAccountQuota()`, while the main DTO + * spreads `mainInfo.quota` directly. `/wham/usage` carries `rate_limit_reset_credits` + * only intermittently, and the store exists to bridge that gap + * (`setAccountQuotaFromParsed` carries an existing `resetCredits` forward when the new + * snapshot omits it), so the main card lost its ticket badge on every response that + * happened to omit the summary while pool cards kept theirs. + * + * Only `resetCredits` is carried, deliberately, and only from an identity-tagged + * in-process observation rather than the alias-keyed store. The window fields have + * *clearing* semantics — a monthly-only snapshot must drop a stale weekly value (#382) — + * so reinstating the whole stored object would resurrect a window the parse meant to + * clear whenever the store write was refused by generation gating. A freshly parsed value + * always wins, including `0`: zero is defined, so it never takes the fill branch. + */ +function mainQuotaWithCarriedResetCredits( + parsed: Omit, +): StoredAccountQuota { + const carried = parsed.resetCredits === undefined + ? mainResetCreditsForCurrentIdentity() + : undefined; + return { + ...parsed, + ...(carried !== undefined ? { resetCredits: carried } : {}), + updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(), + }; +} + function poolAccountDto( account: CodexAccount, quotaResult: PoolQuotaResult, @@ -836,6 +899,9 @@ async function fetchMainAccountInfoWhileOwned( const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) }); 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, @@ -1640,10 +1706,7 @@ export async function listCodexAuthAccountsSnapshot( hasCredential: hasMainCredential, needsReauth: mainNeedsReauth, quota: mainInfo.quota ? { - ...quotaForPlan({ - ...mainInfo.quota, - updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(), - }, mainInfo.plan), + ...quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan), } : null, ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth), }; diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index b3a9b67abc..5aa534fbe9 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -2600,6 +2600,115 @@ describe("codex-auth API", () => { } }); + test("the main account DTO keeps its resetCredits when a later WHAM usage omits the summary", async () => { + // /wham/usage carries rate_limit_reset_credits only intermittently. Pool DTOs survive + // that because they re-read the merged store; the main DTO used to serialize the raw + // parse result, so the ticket badge disappeared on every response that omitted it. + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-dto-credits", account_id: "acct-main-dto-credits" }, + })); + reconcileMainCodexAccountRuntimeState(); + const originalFetch = globalThis.fetch; + let includeCredits = true; + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/backend-api/wham/usage")) { + return Response.json({ + email: "main@example.test", + plan_type: "pro", + rate_limit: { primary_window: { used_percent: 28, reset_at: 1788749167 } }, + ...(includeCredits ? { rate_limit_reset_credits: { available_count: 1 } } : {}), + }); + } + return originalFetch(input); + }) as typeof fetch; + + const first = await listCodexAuthAccounts(makeConfig(), true); + expect(first.find(a => a.id === MAIN_CODEX_ACCOUNT_ID)!.quota?.resetCredits).toBe(1); + + includeCredits = false; + const second = await listCodexAuthAccounts(makeConfig(), true); + const main = second.find(a => a.id === MAIN_CODEX_ACCOUNT_ID)!; + expect(main.quota?.resetCredits).toBe(1); + expect(main.quota?.weeklyPercent).toBe(28); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("the main account DTO never carries resetCredits across a main identity change", async () => { + // `__main__` is an alias: ~/.codex/auth.json can be swapped for another physical + // ChatGPT account, so a carried ticket count must be bound to the identity it was read + // from or one account's credits show up on another's card. + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-ident-a", account_id: "acct-main-ident-a" }, + })); + reconcileMainCodexAccountRuntimeState(); + const originalFetch = globalThis.fetch; + let includeCredits = true; + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/backend-api/wham/usage")) { + return Response.json({ + email: "main@example.test", + plan_type: "pro", + rate_limit: { primary_window: { used_percent: 40, reset_at: 1788749167 } }, + ...(includeCredits ? { rate_limit_reset_credits: { available_count: 5 } } : {}), + }); + } + return originalFetch(input); + }) as typeof fetch; + + const first = await listCodexAuthAccounts(makeConfig(), true); + expect(first.find(a => a.id === MAIN_CODEX_ACCOUNT_ID)!.quota?.resetCredits).toBe(5); + + // The operator signs in as a different physical account and the next usage response + // happens not to carry the summary. + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-ident-b", account_id: "acct-main-ident-b" }, + })); + includeCredits = false; + const second = await listCodexAuthAccounts(makeConfig(), true); + const main = second.find(a => a.id === MAIN_CODEX_ACCOUNT_ID)!; + expect(main.quota?.resetCredits).toBeUndefined(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("a freshly parsed main resetCredits of zero overrides the stored value", async () => { + // Zero is a real reading, not an absence: the DTO fill must never resurrect a stale + // non-zero ticket count over it. + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-dto-zero", account_id: "acct-main-dto-zero" }, + })); + reconcileMainCodexAccountRuntimeState(); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, undefined, undefined, undefined, undefined, 3); + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/backend-api/wham/usage")) { + return Response.json({ + email: "main@example.test", + plan_type: "pro", + rate_limit: { primary_window: { used_percent: 10, reset_at: 1788749167 } }, + rate_limit_reset_credits: { available_count: 0 }, + }); + } + return originalFetch(input); + }) as typeof fetch; + + const accounts = await listCodexAuthAccounts(makeConfig(), true); + const main = accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)!; + expect(main.quota?.resetCredits).toBe(0); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("reset-credit consume omits remaining when main WHAM refresh is non-2xx", async () => { writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: "main-reset-fail", account_id: "acct-main-reset-fail" },