Repository navigation
a personal and a team claude plan on one email are two accounts - #38
Open
rubrot[bot] wants to merge 2 commits into
Open
rubrot[bot] wants to merge 2 commits into
rubrot[bot] wants to merge 2 commits into
Conversation
|
try this branch: |
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.
One Claude login (one email, one user uuid) can belong to several organizations, for example a personal Max plan and a company Team plan. tokenmaxx identified an anthropic account by the user uuid alone (
profile.account.uuidinexternalAccountId), sosignInOauthtreated the second login as a re-auth of the first, kept the first account's id and overwrote its credential. Storage enforced the same rule through theaccounts_anthropic_externalunique index. The result is what #26 describes: two subscriptions, one account.Fix
An anthropic account is now the (organization uuid, user uuid) pair, stored the way codex already stores (workspace, user):
externalAccountIdholdsprofile.organization.uuidandexternalUserIdholdsprofile.account.uuid. I reused the two existing columns rather than adding a field, so the SQLite schema, the IPC payload and the Rust model (external_user_id: Option<String>, already#[serde(default)]) all stay as they are.fetchClaudeProfilealready returned the organization id and just dropped it.domain.ts:sameExternalIdentitydecides "is this the same account" for every provider in one place. When both sides have a user id it compares the pair; otherwise it falls back to the one id an older row knows (the user for anthropic, the workspace for openai).saveAccountandsignInOauthboth use it, so the CLI and the store agree.storage.ts:accounts_anthropic_external(unique on the user) is dropped and replaced byaccounts_anthropic_external_useron the pair, matching the openai index.claude.ts: registration stores the pair.assertIdentitycompares the user as before, and also the organization once one is stored, so a credential that starts answering for a different org is still flagged asIDENTITY_CHANGED.(provider, label)is unique, so the second subscription needs its own label.distinctLabelkeeps the plain email unless it is already taken by another account, then triesemail (plan)(e.g.dev@example.com (team)), thenemail (<org uuid prefix>). The single-account case is unchanged. The schema refine now acceptsidentityoridentity (qualifier)as the label, and probes and re-logins keep the stored label instead of resetting it to the email (relabel).Migration
Existing anthropic rows have the user uuid in
externalAccountIdand a nullexternalUserId. They keep working with no rewrite at startup:externalAccountId, user intoexternalUserId. After that, logging into the user's other organization creates a new account.assertIdentityreads the user fromexternalUserId ?? externalAccountId, so migrated and unmigrated rows never get a falseIDENTITY_CHANGED. The organization is only compared once one is stored.One caveat: if someone upgrades and logs into the second organization before the daemon has probed the old row even once, that login still counts as a re-auth of the old row, as it does today. After one probe it works as intended. Downgrading after adding a second subscription would hide the new rows from the old build. The store skips rows it cannot parse rather than failing, but the old unique index could refuse to build if two users share an org, so a downgrade is not clean.
Codex and Grok
Codex had a smaller version of the same problem.
signInOauthmatched by workspace id only, so a second user in the same ChatGPT workspace counted as a re-auth of the first, even though storage already allowed both. Also, one email in two workspaces failed withDUPLICATE_ACCOUNTon the label. Both are fixed by the sharedsameExternalIdentityanddistinctLabel. The codex probe also keeps a qualified label now. Grok identifies byuser_idand has no organization concept, so it is unchanged.Verification
On Linux, Bun 1.4.2:
bun install --frozen-lockfile,bun run check(tsc, biome check, bun test: 164 pass, 2 skipped macOS-only, 0 fail),bun run build. Also ranbun teston Bun 1.2.20 with the same result. New tests:claude.test.ts: registration stores org and user. A legacy row gets its organization on the next probe without being flagged. A probe keeps the(team)label. A credential answering for another organization is flagged.storage.test.ts: a database with the oldaccounts_anthropic_externalindex opens, keeps its row, backfills, and accepts a second subscription of the same user, while a duplicate pair is still refused. An unmigrated row still collides with a new row of the same user. Tests also coversameExternalIdentityfor claude, legacy rows and codex workspaces, anddistinctLabelonly qualifying on collision.Not tested against a real Anthropic account with two organizations. The tests use the profile shape already used in
claude.test.ts(organization.uuid).Fixes #26