Skip to content

a personal and a team claude plan on one email are two accounts - #38

Open
rubrot[bot] wants to merge 2 commits into
mainfrom
rubrot/claude-org-accounts
Open

rubrot[bot] wants to merge 2 commits into
mainfrom
rubrot/claude-org-accounts

Conversation

@rubrot

@rubrot rubrot Bot commented Oct 2, 2026

Copy link
Copy Markdown

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.uuid in externalAccountId), so signInOauth treated 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 the accounts_anthropic_external unique 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): externalAccountId holds profile.organization.uuid and externalUserId holds profile.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. fetchClaudeProfile already returned the organization id and just dropped it.

  • domain.ts: sameExternalIdentity decides "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). saveAccount and signInOauth both use it, so the CLI and the store agree.
  • storage.ts: accounts_anthropic_external (unique on the user) is dropped and replaced by accounts_anthropic_external_user on the pair, matching the openai index.
  • claude.ts: registration stores the pair. assertIdentity compares 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 as IDENTITY_CHANGED.
  • Labels: the CLI resolves accounts by label and (provider, label) is unique, so the second subscription needs its own label. distinctLabel keeps the plain email unless it is already taken by another account, then tries email (plan) (e.g. dev@example.com (team)), then email (<org uuid prefix>). The single-account case is unchanged. The schema refine now accepts identity or identity (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 externalAccountId and a null externalUserId. They keep working with no rewrite at startup:

  • They still match a re-login of the same user, so a re-login updates the credential in place.
  • The next probe (the daemon probes every minute) fills in the pair from the profile response: organization into externalAccountId, user into externalUserId. After that, logging into the user's other organization creates a new account.
  • assertIdentity reads the user from externalUserId ?? externalAccountId, so migrated and unmigrated rows never get a false IDENTITY_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. signInOauth matched 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 with DUPLICATE_ACCOUNT on the label. Both are fixed by the shared sameExternalIdentity and distinctLabel. The codex probe also keeps a qualified label now. Grok identifies by user_id and 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 ran bun test on 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 old accounts_anthropic_external index 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 cover sameExternalIdentity for claude, legacy rows and codex workspaces, and distinctLabel only 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

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

try this branch: bun add -g tokenmaxx@0.0.79-alpha.117

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Personal & Organisation Account Shows Only One Account

0 participants