Skip to content

feat(codex): one card per account across Codex homes and pi logins (read-only) - #1321

Merged
robinebers merged 4 commits into
mainfrom
devin/1790754749-codex-home-pi-discovery
Sep 30, 2026
Merged

robinebers merged 4 commits into
mainfrom
devin/1790754749-codex-home-pi-discovery

Conversation

@robinebers

@robinebers robinebers commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Approved issue

Fixes #1265 — part 2 of 3 split out of #1319 (after #1320). Part 3 (#1322) adds token refresh for independent homes.

TL;DR

Every ChatGPT account signed in to a Codex home (CODEX_HOME, ~/.codex, ~/.config/codex, sibling ~/.codex-* / ~/.config/codex-*), to pi (openai-codex, openai-codex-N), to xswap, or to the Keychain becomes its own Codex card, matched by workspace + email. Every discovered credential is read-only in this PR.

What was happening

  • Only xswap slots produced account cards. Two Codex homes, or a Codex home plus a pi login, were one codex card: the meters came from whichever login won, while local spend from both accounts was summed onto it (Support multiple Codex accounts across Codex homes and pi logins #1265).
  • Nothing knew about pi's openai-codex-N multi-pass entries at all, so a pi-only account had no card.

What this changes

  • CodexHomeScanner (new): enumerates candidate homes and reads which account is signed in at each (CodexHomeLogin); a token-bearing home whose claims name no account sets hasIncompleteLogin instead of vanishing. Also owns configuredHomeValues/standardizedHome, which CodexAuthStore.authPaths and DefaultAccountObserver.observeCodex now reuse instead of their own copies.
  • PiCodexLoginScanner (new): reads pi's auth.json OAuth entries and multi-pass.json labels into PiCodexLoginScan { logins, hasIncompleteLogin }. loadAuth re-reads the live token on every use.
  • ProviderAccountAssembly.makeCodexCards merges homes + pi + Swap + a usable default Keychain login by CodexAccountIdentity and returns a CodexAccountDiscovery:
    struct CodexAccountDiscovery {
        var cards: [CodexAccountCard]
        var plainAuthHomes: [String]                       // single-account path only
        var plainPiCredentialSources: [CodexPiCredentialSource]
        var allowsUnattributedHistory: Bool
    }
    guard !swaps.isEmpty || hasEstablishedAccounts || knownIdentities.count > 1 else {
        return CodexAccountDiscovery(plainAuthHomes: …, plainPiCredentialSources: …,
                                     allowsUnattributedHistory: knownIdentities.isEmpty || !hasUnidentifiedLogin)
    }
    A single account (even across several homes and pi) stays on the plain codex provider, which now receives the discovered sibling homes and pi entries as read-only credential sources and scans those homes' rollouts for its spend tiles. A login too incomplete to name an account still turns off unattributed spend on that plain card. In card mode the one attribution rule is allowsUnattributedHistory = !hasIncompleteLogin && codexRecords == 1.
  • Card labels: a name the user chose (xswap alias, then pi multi-pass label) wins over the generic workspace label, and the first user-chosen name is kept — a later pi label never renames an aliased xswap slot, but does name an alias-less one.
  • CodexAuthStore gains piCredentialSources and a .pi Source; save(.pi) throws. Scoped stores already strip refresh tokens for every candidate, so all card credentials — homes, Swap, pi, Keychain — stay read-only; the account refresh loop is unchanged from main (tries each matching login, no token rotation).
  • CodexAccountIdentity(auth:) falls back to the access token's claims (pi entries have no id_token) and reads the email from the https://api.openai.com/profile claim.
  • CodexAccountCard gains piCredentialSources; ProviderCatalog.make(defaults:codex:) takes the whole discovery. Registry source kinds codexHome and pi added.

Heads-up

  • Read-only by design here: a card whose token has expired shows Re-login until Codex/pi renews it. feat(codex): refresh and persist tokens for independent Codex homes on account cards #1322 lets independent homes rotate their own tokens (with symlink-safe protection for Swap-managed homes).
  • Card mode is sticky via the registry (identityKey.contains("|")), same as the existing Swap behavior.
  • Not changed (flagged by Devin Review, left for a maintainer call): the plain card still imports a synced peer's legacy codex history wholesale, as on main, even when local unattributed history is off.

Tests

New suites replace #1319's two files (and their duplicated JWT helpers), organized by subsystem:

  • CodexMultiAccountFixtures — shared token/codexAuth/piAuth builders and a scratch-defaults helper.
  • CodexHomeDiscoveryTests — home enumeration (incl. a real hidden-dir scan), identity fallback, pi scan/incomplete flag/provider-id shape, observer behavior, read-only scoped candidates, pi reload + fallback through CodexProvider.refresh.
  • CodexMultiAccountAssemblyTests — single-account stays legacy, incomplete/account-only/email-only logins, incomplete pi login beside one plain account disables unattributed spend, xswap alias survives a matching pi login, home+pi merge, same-workspace users, registry order across a default switch, catalog wiring, Swap homes stay read-only through refresh.
  • CodexDiscoveryGateTests — the single-vs-many gate: a lone pi login or sibling home feeds the plain card (credentials load, stay read-only, rollouts scanned), home + distinct Keychain and pi + distinct Keychain each yield two cards, an identity-less token home disables unattributed history, an alias-less xswap slot takes a pi label.

swift build, swift test (1471 tests, 0 failures), ./script/build_and_run.sh verify.

Screenshots

Not applicable.

Link to Devin session: https://app.devin.ai/sessions/a6161a7f3fd44b62b23c5de7ca15d4ab
Open in Devin Desktop: https://app.devin.ai/desktop/session/a6161a7f3fd44b62b23c5de7ca15d4ab?variant=devin
Requested by: @robinebers


Note

Medium Risk
Changes auth discovery, multi-account card assembly, and read-only credential handling for Codex usage and spend attribution; mistakes could show wrong limits or merge/split accounts incorrectly.

Overview
OpenUsage now discovers Codex logins in default and sibling homes (~/.codex-*, ~/.config/codex-*), pi (openai-codex, openai-codex-N), xswap, and the keychain, then builds one Codex card per ChatGPT workspace + email. The same account across several homes or pi still maps to a single card; a lone account keeps the plain codex provider until a second identity (or existing multi-account registry / swap) appears.

New scanners (CodexHomeScanner, PiCodexLoginScanner) drive makeCodexCards, which wires each card with scoped authHomes, piCredentialSources, and stricter unattributed local spend (off when multiple accounts exist or any login cannot name workspace and email).

Credentials on cards are read-only: scoped stores strip refresh tokens; pi uses a new .pi auth source and save rejects it. Usage refresh re-reads live tokens and tries matching logins (including pi fallback) without rotating tokens owned by Codex, xswap, or pi. Identity parsing now falls back to access-token JWT claims so pi-only OAuth works.

Docs and tests cover discovery, assembly, read-only refresh, and catalog wiring.

Reviewed by Cursor Bugbot for commit 44855b0. Bugbot is set up for automated code reviews on this repo. Configure here.

…ead-only)

Discover sibling ~/.codex-* and ~/.config/codex-* homes and pi's
openai-codex / openai-codex-N OAuth entries, merge them with Swap slots and
the Keychain by workspace + email, and give each distinct account its own
card. A lone account stays on the plain codex provider.

Every discovered credential is read-only: cards re-read their sources on
each refresh and try each matching login, but never rotate a token that
Codex, xswap, or pi owns. Unattributed local spend is excluded once more
than one account is known, or when any login can't name its account.

Split out of #1319; refs #1265.

Co-Authored-By: Robin <rob@sunstory.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@pullfrog pullfrog 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.

Important

Discovery is bypassed for some supported login combinations, and incomplete-login evidence can still be lost before spending attribution is decided. Three fixes are detailed inline.

Reviewed changes across home/pi discovery, account assembly, credential loading, persistence, tests, and documentation.

  • Login discovery: Adds configured/default/sibling Codex homes and numeric pi OAuth slots, with access-token identity fallback.
  • Account cards: Merges credentials by workspace and email, persists card identities, and wires live pi sources into read-only scoped stores.
  • Spending policy: Disables unattributed history for multi-account cards and some incomplete logins.
  • Verification: Inspected the new tests and traced runtime consumers. The macOS SwiftPM suite could not run in this Linux environment; GitHub's Build and Test check was pending.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Astra | 𝕏

Comment thread Sources/OpenUsage/Services/ProviderAccountAssembly+Codex.swift Outdated
Comment thread Sources/OpenUsage/Services/ProviderAccountAssembly+Codex.swift
Comment on lines +99 to +101
let hasIncompleteLogin = piScan.hasIncompleteLogin
|| homeLogins.contains { !CodexAccountIdentity.isComplete(key: $0.identity.key) }
let allowsUnattributed = !hasIncompleteLogin && records.count { $0.family == "codex" } == 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Carry incomplete-login evidence through discovery and the legacy path. A token-bearing home with neither workspace nor email is already filtered out of homeLogins, while a detected incomplete pi login is ignored when the single-account gate returns early; both cases can leave another account's unattributed spending enabled.

Technical details
## Affected sites
- CodexHomeScanner.swift:87-92 drops token logins when CodexAccountIdentity cannot be constructed, leaving no incompleteness flag.
- ProviderAccountAssembly+Codex.swift:41 returns before consuming piScan.hasIncompleteLogin.
- ProviderCatalog.swift:45-46 creates the plain provider with allowsUnattributedHistory defaulting to true; CodexProvider.snapshot then includes pi/OpenCode history and the default native scanner permits unattributed history.

## Required outcome
- Preserve unresolved token-login footprints independently of card identities and apply the resulting attribution policy even when the UI keeps a plain Codex card.
- Add coverage for one complete home login plus an identity-incomplete pi login without Swap, and one established complete card plus a token-bearing home whose identity is entirely missing.
- Assert the resulting runtime disables unattributed local history. The existing incomplete-pi test only covers the Swap-forced card path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The reported complete-account-plus-incomplete-login cases are fixed, but the thread remains open because knownIdentities.isEmpty still enables unattributed history when all discovered token logins are unidentified.

Pullfrog  | View workflow run | via Pullfrog | Using GPT Astra | 𝕏

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Leaving knownIdentities.isEmpty as is, deliberately. When no login names an account there is exactly one credential OpenUsage knows about (the one the plain card is about to use), so there is no second card the unowned history could be mis-assigned to — disabling history there would blank the spend tiles for every install whose token simply lacks identity claims, with nothing gained. The case the flag protects against (an unidentified token beside a known account) is covered by !hasUnidentifiedLogin. Two identity-less homes at once is theoretically ambiguous, but the scanners can't distinguish one unnamed account from two either way; happy to tighten if the maintainer prefers blanking history over showing it in that case.

cursor[bot]

This comment was marked as resolved.

…xswap aliases over pi labels

Co-Authored-By: Robin <rob@sunstory.com>
pullfrog[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

👀 1 finding needs your review

Devin fixed 3 of 4 findings on 96994f0. Click a finding below to jump to its comment.

For your review (1)

Fixed by Devin (3)

  • Pi-only account cannot show live limits
  • Distinct Keychain account disappears behind home
  • Incomplete home login retains unattributed spending

View all findings in Devin Review

Devin Review

…ount Keychain logins, and flag identity-less tokens

- The plain codex card now receives the read-only sibling-home and pi logins discovery found, so a
  single account that lives outside the configured home no longer reports Not logged in.
- A usable Keychain login counts toward the one-vs-many decision before the gate, so home A +
  Keychain B produces two cards on a fresh install.
- A token-bearing home whose token names no account flags incompleteness; both the plain card and
  account cards then exclude history with no provable owner.
- Only a real xswap alias is treated as a user-chosen name, so a pi label can name an alias-less slot.

Co-Authored-By: Robin <rob@sunstory.com>

@pullfrog pullfrog 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.

Important

A lone sibling-home account can now load live usage, but its native session spending is still omitted. One new finding is detailed inline; the partially addressed attribution thread remains open.

Reviewed changes since 96994f06, focusing on single-account routing and the discovery gate.

  • Preserved discovered credentials: Passed lone home/pi sources through CodexAccountDiscovery into the plain provider while keeping them read-only.
  • Counted Keychain accounts: Included the default Keychain identity before choosing plain-card versus account-card mode.
  • Retained incomplete-login evidence: Added a home-scan flag for identity-less tokens and propagated it into history policy.
  • Corrected label precedence: Allowed explicit pi labels to name Swap slots without aliases.
  • Added regression coverage: Added credential-use, read-only, Keychain-gate, history-policy, and label tests, and updated documentation.

Tests were inspected but not run locally because the package requires macOS frameworks and this environment is Linux. GitHub's Build and Test check was in progress when checked.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Astra | 𝕏

Comment thread Sources/OpenUsage/Providers/ProviderCatalog.swift Outdated
…spend

Co-Authored-By: Robin <rob@sunstory.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Devin Review

Comment on lines +47 to +50
authStore: CodexAuthStore(
additionalAuthHomes: codex.plainAuthHomes,
piCredentialSources: codex.plainPiCredentialSources
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Unattributed peer spending survives exclusion

When an incomplete pi login disables local history, codexAllowsUnattributedHistory leaves the card bare. UsageHistoryAggregator.merged still imports its unowned peer history, so spend tiles can show another account's spending.

Learn more

The plain provider's allowsUnattributedHistory gates local scanning and cache stripping. However, UsageHistoryAggregator.merged handles a bare codex card whose identity lacks | using document.providers[providerID], without consulting this gate. When peers are loaded, rebuildRenderedSnapshots puts their history back on the visible snapshot.

Example: A single known Codex login and an incomplete pi login disable local history. A synced peer document with legacy codex spending still adds that spending to the card after launch.

Recommended fix: Pass Codex's unattributed-history policy into peer history merging and exclude unowned peer contributions when the plain provider disallows them.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirmed, but leaving it for a maintainer call rather than growing this PR. The behavior is inherited from main: the plain codex card has always imported a synced peer's providers["codex"] history wholesale (UsageHistoryAggregator.merged only account-filters Claude cards and |-keyed Codex cards). This PR's allowsUnattributedHistory gates the local scanners; it doesn't reach the peer merge, so a second Mac's legacy codex history still lands on the plain card when an incomplete pi login has turned local history off.

Fixing it means threading the Codex policy into UsageHistoryAggregator.merged (alongside the existing localClaudeCards argument) and skipping peer import for the plain card when it's false — the plain card carries no identity, so nothing from a peer can be proven to be its own. Small change, but it touches the sync/aggregation layer that #1265 didn't ask about; I'd do it as a follow-up unless you want it here.

@pullfrog pullfrog 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.

Important

No new issues found in this delta. The earlier incomplete-login attribution finding remains open, so approval is still withheld.

Reviewed changes since afc4bde1, focused on native spending for a lone sibling-home account.

  • Connected discovered log roots: Passed plainAuthHomes to the plain card's native log scanner without changing its attribution policy.
  • Added regression coverage: Verified through the catalog that the discovered sibling home reaches the scanner; this assertion would fail before the fix.
  • Updated documentation: Clarified that the plain card includes the discovered home's session logs in spending.

Inspected the new wiring test and existing additional-home rollout tests. Tests were not run locally because this Linux environment cannot run the macOS package; GitHub's Build and Test check was still in progress when checked.

Pullfrog  | Fix it ➔ | View workflow run | Using GPT Astra | 𝕏

@robinebers
robinebers merged commit 5d18be4 into main Sep 30, 2026
5 checks passed
@robinebers
robinebers deleted the devin/1790754749-codex-home-pi-discovery branch September 30, 2026 09:09
liaomingxin added a commit to liaomingxin/openusage that referenced this pull request Oct 8, 2026
Adopted from upstream:
- Pricing: Claude Sonnet 5.5, Cursor Grok 4.7/grok-bot-cua, Codex Ultrafast
  (6x GPT-6 Astra), GPT-6 Sol/Luna models; gpt-5.6-sol rate correction (robinebers#1327/robinebers#1331/robinebers#1329/robinebers#1286)
- Claude: usage-limit reset grants row (robinebers#1290), credential-rotation preservation
  (robinebers#1316), launch-cached limits on first 429 (robinebers#1325)
- Cursor: structured team usage pools (robinebers#1337), CSV export 20s deadline (robinebers#1324)
- Grok: team-principal 412 keeps local spend (robinebers#1272)
- Ollama: monthly cloud limit (robinebers#1270), unreadable-plan warning (robinebers#1300)
- OpenCode: sub-1% session reset countdown (#062fb5ec)
- UI: panel top-edge steady during height animation (robinebers#1345), per-screen
  header/footer (robinebers#1346), Report an Issue menu item (robinebers#1343), settings copy (robinebers#1328/robinebers#1330)
- Deps: PostHog 3.85.3, Sparkle, KeyboardShortcuts, actions/checkout v7

Kept fork-side (divergent designs, upstream versions dropped with their files):
- Codex multi-account: fork's scopedAuthPath/accountLabel cards instead of
  expectedIdentity/writableAuthHomes (robinebers#1321/robinebers#1322/robinebers#1349, CodexSwapAccount,
  PiCodexLoginScanner, CodexHistoryRefresh/Scope, CodexHomeScanner)
- OpenCode credentials: fork's channel-database scanning instead of Go-key
  stores (robinebers#1323/robinebers#1284)
- Claude session attribution: fork's organizationsClaimedByOtherCards (robinebers#1299)
- iCloud sync identity schema, Settings/Customize layout, Agent Usage screen

Fusion notes: CodexUsagePricing keeps fork's 4-tuple rates (cacheWrite) with
upstream's corrected gpt-5.6-sol rates + ultrafast tier + gpt-6-luna.
Claude swap cards mirror the new rateLimitResets row. pricing_supplement
deduped (WIP duplicates), gpt-6.1-sol alias restored to dot-or-dash matching.

WIP manual port (411c175) reconciled: ollama monthly + claude reset grants
replaced by upstream originals; its grok-4.7 SKU separation kept and completed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support multiple Codex accounts across Codex homes and pi logins

1 participant