Skip to content

Account-first Phase 1: account registry, default identity, cache stamp, plain-matching CLI/API#1027

Merged
robinebers merged 12 commits into
mainfrom
account-first-phase-1
Jul 19, 2026
Merged

Account-first Phase 1: account registry, default identity, cache stamp, plain-matching CLI/API#1027
robinebers merged 12 commits into
mainfrom
account-first-phase-1

Conversation

@robinebers

@robinebers robinebers commented Jul 18, 2026

Copy link
Copy Markdown
Owner

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

  • Nothing modeled which account a Claude/Codex card shows. Swapping the login at ~/.claude/~/.codex between launches painted the previous account's cached limits and plan under the new login until the first refresh.
  • The CLI and local HTTP API treated claude/codex as 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.
  • DefaultAccountObserver reads which account is signed in at each default home only (no candidate scanning): Claude's state file (org-scoped identity), Codex's auth.json under the strict rule — tokens.account_id or 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.
  • ProviderAccountAssembly runs the pass at launch off the main thread's critical path. ProcessEnvironmentReader pins 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 in docs/debugging.md.
  • Snapshot cache v9: entries carry a producedByIdentityKeys sidecar. 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.
  • CLI + local HTTP API: plain matching, no aliasing (breaking /v1 change). 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/:id now returns an array (was a single object; the 204 no-snapshot answers became 200 with an empty shape). ProviderCardResolver and its alias rules are deleted.

Heads-up

  • Deliberate /v1 breaking change (owner decision): /v1/usage/:id single-object → array, 204s → 200 + empty shape. Made now, before multi-account ships, so the shape never forks on how many cards an id names.
  • The CLI's exit code 3 ("no cached snapshot") is gone — that state can no longer occur (a known id always yields an envelope, empty or not); unknown ids exit 2 as before.
  • Zero visible change in the app while each family has one account — that's the point of the phase; the flip ships before any multi-account discovery exists.
  • The v8→v9 cache key bump refetches once on upgrade (established pattern), so every retained entry is stamped from its first write.
  • Signed-out rendering + "Remove Account…" slid to Phase 2 per the plan (meaningless while a family can only have one card).

Tests

  • New suites: ProviderAccountsStoreTests, DefaultAccountObserverTests, ProviderAccountAssemblyTests, WidgetDataStoreAccountCacheTests; plus family-matching and status-code coverage in the LocalUsageAPI/LocalLimitsAPI tests and environment-layering tests. Full suite: 1249 green.
  • Multiple local + remote Bugbot rounds; all findings fixed (CLI stale-cache gap, refresh cache-hit gap, Codex keychain-fallback stamping, main-thread launch stalls, identity/provider environment splits, per-family first-launch skip).
  • Live run: both identities resolve on this machine, correct stamps; curl verified the new array/404 shapes and the family-matching envelope.

Made with Cursor

Comment thread Sources/OpenUsage/Services/ProviderAccountAssembly.swift
Comment thread Sources/OpenUsage/Services/ShellEnvironmentSnapshot.swift Outdated
Comment thread Sources/OpenUsage/Services/ProviderAccountAssembly.swift Outdated
@robinebers robinebers added the gate-passed Passed the automated PR gatekeeper checks (gatekeeper skips it on later runs) label Jul 18, 2026 — with Cursor
Comment thread Sources/OpenUsage/App/AppContainer.swift

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread Sources/OpenUsage/Services/ProviderAccountAssembly.swift
Base automatically changed from account-first-phase-0 to main July 18, 2026 14:22
robinebers and others added 12 commits July 18, 2026 19:05
…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
robinebers force-pushed the account-first-phase-1 branch from 49b7e62 to b511704 Compare July 18, 2026 15:06
@robinebers robinebers changed the title Account-first Phase 1: account registry, default identity, cache stamp, bare-id resolver Account-first Phase 1: account registry, default identity, cache stamp, plain-matching CLI/API Jul 18, 2026
@robinebers
robinebers merged commit 7723025 into main Jul 19, 2026
3 checks passed
@robinebers
robinebers deleted the account-first-phase-1 branch July 19, 2026 03:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs gate-passed Passed the automated PR gatekeeper checks (gatekeeper skips it on later runs) provider tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant