-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(codex,gui): restore the plan and ticket badges on the main account card #3423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
111 changes: 111 additions & 0 deletions
111
devlog/_plan/260904_main_card_badge_parity/000_evidence.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 && <span className="badge badge-green">{a.plan}</span>}` 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. |
210 changes: 210 additions & 0 deletions
210
devlog/_plan/260904_main_card_badge_parity/010_server_dto_parity.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, "updatedAt">, | ||
| ): 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. | ||
60 changes: 60 additions & 0 deletions
60
devlog/_plan/260904_main_card_badge_parity/020_gui_plan_badge.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<span className="card-badges">`, 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 && <span className="badge badge-green">{main.plan}</span>} | ||
| ``` | ||
|
|
||
| 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 | ||
| `<span class="badge badge-green">pro</span>`. | ||
| 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. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These added lines document the exact unreleased cross-account disclosure scenario—swapping
auth.jsonwhile the proxy is stopped can leave the previous account's alias-keyed quota available after restart—and the following section supplies mitigation details. Because the fix exists only in this reviewed commit, tracking this analysis under publicdevlog/_plandiscloses the weakness before it ships; keep the analysis in.tmp/and commit only the fix and regression test, or publish a sanitized retrospective under_finafter the fix is public.AGENTS.md reference: AGENTS.md:L103-L115
Useful? React with 👍 / 👎.