Account-first Phase 1: account registry, default identity, cache stamp, plain-matching CLI/API#1027
Merged
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit aeb0938. Configure here.
…e stamp, bare-id resolver Phase 1 of the account-first plan (docs/research/account-first-plan.md). Structural flip with zero visible change while each family has one account: - ProviderAccountsStore (openusage.providerAccounts.v1): every Claude/Codex account is a record with an opaque identity key and a stable id minted at creation. The first account observed at a family's default home keeps the bare id (claude/codex) — existing installs migrate by doing nothing. The default home is a source holding an exclusive per-family badge, never a key. - DefaultAccountObserver reads which account is signed in at each default home (Claude's state file, Codex's auth.json with the strict account_id/id_token rule — an account that can't name itself is reported unresolved, never guessed by path). - ProviderAccountAssembly runs the pass at launch on the live shell env or the Phase-0 snapshot, reconciles the registry, and logs the soak trail. - Snapshot cache v9: entries remember the producing account; after a swap at the same home the previous account's cached limits/plan are discarded at launch instead of painting under the new login. Cards whose identity can't be resolved keep today's behavior exactly. - ProviderCardResolver threads the bare-id alias rule (default-source holder -> sole enabled family card -> family empty state) through the one-shot CLI and local HTTP API; pass-through in practice today, pinned by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…unt guard Three findings from the local review: - The one-shot CLI served TTL-fresh cache entries without the account guard, so `openusage claude` could print the previous account's limits for up to the cache TTL after a default-home swap. UsageReader now filters entries through the same predicate and counts a stale-stamped provider as needing a refresh. - `WidgetDataStore.refresh` honored a TTL-fresh entry as a cache hit even when its stamp named another account (reachable under the CLI's persisted freshness), copying the rejected snapshot back in. The cache-hit gate now treats a stale-stamped entry as a miss so the fetch overwrites it. - Codex identity trusted auth.json alone, but the provider falls back to the keychain credential when file auth fails — a stale file could stamp one account while usage came from another. While a Codex keychain item exists (attributes-only probe, no secret read, no prompt), the family now reports unresolved: no record, no stamp, behavior exactly as before account awareness. A later phase binds keyring identities properly. All three paths share one predicate, ProviderSnapshotCache .hasStaleAccountStamp(providerID:currentIdentityKey:), so they can't drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…identity Bugbot follow-up: the existence probe now reports present/absent/unknown, and observeCodex only trusts auth.json's identity on a definite "no keychain item". A timed-out or locked-keychain probe lands on the unresolved side — resolving from the file while a keychain fallback might exist is the exact wrong-account stamp the rule prevents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Remote Bugbot flagged that the CLI's account identity read could resolve CLAUDE_CONFIG_DIR/CODEX_HOME differently from the providers' own auth stores: identity read on the main thread saw only the process environment, while provider refreshes (off-main) could trigger the login-shell capture and see shell exports too, mis-stamping the shared v9 cache. The CLI now warms the login-shell capture off-main before the account pass, so both sides resolve overrides through the same layers (process environment first, captured shell environment second). The persisted shell snapshot is never pinned on the CLI path. Co-authored-by: Cursor <cursoragent@cursor.com>
Bugbot flagged that the launch account pass could stall app startup for ~750ms on the main thread: the identity read busy-waited up to 0.5s for the login-shell capture, and the Codex keychain probe spawned a /usr/bin/security subprocess with a 0.25s timeout. Both waits are gone. The identity read now uses the live capture only when it already landed, and otherwise falls straight back to the persisted shell-environment snapshot (no wait — that fallback is the normal cold-launch path, not the exception). The keychain existence probe is now an in-process Security-framework attributes query that returns in microseconds, requests no secret, and forbids any UI, so it can neither prompt nor stall. Co-authored-by: Cursor <cursoragent@cursor.com>
Bugbot found the launch account pass and the provider auth stores could still resolve CLAUDE_CONFIG_DIR/CODEX_HOME differently on a cold Finder/Dock launch: identity was read from the persisted shell-environment snapshot while providers read through ProcessEnvironmentReader, whose main-thread reads see nothing until the async login-shell capture lands — so a snapshot could be stamped with one home's identity but fetched from another. It also flagged that ScopedEnvironmentReader's "pinned absent" overrides were built with dictionary semantics that could drop the pin. Both fixed by moving the fallback into the one shared reader: ProcessEnvironmentReader now layers process environment → live login-shell capture → persisted shell-environment snapshot, so every consumer (identity pass, auth stores, log scanners) resolves the same home overrides by construction. ScopedEnvironmentReader and the snapshot-pinning identityEnvironment() are gone, and the account pass no longer picks an environment — it only skips on a genuinely first launch where an exported override would be invisible. Co-authored-by: Cursor <cursoragent@cursor.com>
Bugbot follow-up: identity was resolved once at init (from the persisted shell-environment snapshot while the capture was cold), but later provider refreshes read the freshly landed capture — so a CLAUDE_CONFIG_DIR/CODEX_HOME export changed since the previous run would split them mid-session: usage fetched from the new home, stamped with the old home's identity, until the next relaunch. ProcessEnvironmentReader now pins the identity-relevant keys (ShellEnvironmentSnapshot.capturedKeys) to the launch snapshot for the entire process: every reader — the launch account pass, provider auth stores, log scanners — sees the same home overrides no matter when the async capture lands. A changed export applies from the next launch (the snapshot refresh task persists and logs it). All other keys (API keys etc.) keep reading the live capture exactly as before. Co-authored-by: Cursor <cursoragent@cursor.com>
…ent already answers Remote Bugbot: the first-launch guard (login shell cold, no persisted snapshot) also skipped the account pass when CLAUDE_CONFIG_DIR/ CODEX_HOME were already present in the process environment — where the shell layers are irrelevant because the process environment wins the read for the identity pass and every provider alike. Such a launch now proceeds; only a launch where a shell-exported override could genuinely be invisible still skips. Co-authored-by: Cursor <cursoragent@cursor.com>
Bugbot follow-up on the previous guard fix: bypassing the skip when ANY identity key sat in the process environment let an unrelated export (XDG_CONFIG_HOME via launchctl setenv) run the whole pass while CLAUDE_CONFIG_DIR/CODEX_HOME still lived only in the unread shell profile — observing default homes the providers wouldn't use. The skip is now decided per family, on the one fact that matters: a family is observed only when its own home override is answerable (shell facts readable, or that specific key in the process environment). An unobserved family gets no identity key and no reconciliation — exactly as if the pass never ran for it. Co-authored-by: Cursor <cursoragent@cursor.com>
The bare-id alias rules (default-badge holder → sole enabled family card → bare id) made CLI/API answers depend on runtime state — which account is logged in, what's enabled. Dropped entirely in favor of dumb matching: a requested id names an exact card, or, as a family id, every card of that family; every match is returned; an id naming nothing is 404. The same request always names the same cards. Both surfaces now always return the multi-provider shape. The limits envelope already was one; /v1/usage/:id breaks from a single object to an array, and the 204 no-snapshot answers become 200 with an empty body shape. One deliberate /v1 break made now, before multi-account ships, instead of aliasing forever. ProviderCardResolver and its tests are deleted; the assembly no longer exposes resolvedFamilyIDs. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The CLI's exit code 3 path became unreachable once known tokens always produce a limits envelope; remove the error case and its docs. Also fix comments and the debugging doc still describing the removed bare-id resolver and the old skip log line. Co-authored-by: Cursor <cursoragent@cursor.com>
robinebers
force-pushed
the
account-first-phase-1
branch
from
July 18, 2026 15:06
49b7e62 to
b511704
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.

TL;DR
The account-first structural core (Phase 1 of
docs/research/account-first-plan.md), invisible to every existing user: each Claude/Codex login becomes an account record with a stable id, cached snapshots remember which account produced them, and the CLI/API answer ids by plain string matching — no alias resolution.What was happening
~/.claude/~/.codexbetween launches painted the previous account's cached limits and plan under the new login until the first refresh.claude/codexas plain provider ids, with no defined behavior for the multi-account phases to build on.What this changes
ProviderAccountsStore(openusage.providerAccounts.v1): every account is a record with an opaque identity key and a stable id minted at creation. The first account observed at a family's default home keeps the bare id — existing installs migrate by doing nothing. "Default" is an exclusive per-family badge on a source, never a key.DefaultAccountObserverreads which account is signed in at each default home only (no candidate scanning): Claude's state file (org-scoped identity), Codex'sauth.jsonunder the strict rule —tokens.account_idor the id_token's ChatGPT account claim. An account that can't name itself is reported unresolved, never guessed by path. While a Codex keychain item exists (in-process attributes-only probe; the secret is never read at launch), the family stays unresolved — a later phase binds keyring identities.ProviderAccountAssemblyruns the pass at launch off the main thread's critical path.ProcessEnvironmentReaderpins identity-relevant shell facts (CLAUDE_CONFIG_DIR,CODEX_HOME, …) to Phase 0's persisted snapshot for the whole session, so the identity pass and every provider read resolve the same homes by construction; the pass skips per family only on a genuinely first launch where that family's override could be invisible. Soak trail documented indocs/debugging.md.producedByIdentityKeyssidecar. One shared predicate (hasStaleAccountStamp) guards all three read paths — launch paint, the refresh cache-hit gate, and the CLI's persisted-freshness reads — so a swap discards the stale entry instead of serving it. Unresolved identity = today's behavior exactly./v1change). A requested id names an exact card, or, as a family id, every card of that family; every match is returned and an id naming nothing is 404. The answer never depends on runtime state. Both surfaces always return the multi-provider shape: the limits envelope (already keyed by card id), and/v1/usage/:idnow returns an array (was a single object; the 204 no-snapshot answers became 200 with an empty shape).ProviderCardResolverand its alias rules are deleted.Heads-up
/v1breaking change (owner decision):/v1/usage/:idsingle-object → array, 204s → 200 + empty shape. Made now, before multi-account ships, so the shape never forks on how many cards an id names.3("no cached snapshot") is gone — that state can no longer occur (a known id always yields an envelope, empty or not); unknown ids exit2as before.Tests
ProviderAccountsStoreTests,DefaultAccountObserverTests,ProviderAccountAssemblyTests,WidgetDataStoreAccountCacheTests; plus family-matching and status-code coverage in theLocalUsageAPI/LocalLimitsAPItests and environment-layering tests. Full suite: 1249 green.curlverified the new array/404 shapes and the family-matching envelope.Made with Cursor