Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- [2026-10-02] a personal and a team claude plan on one email are two accounts, fixes #26
- [2026-10-01] mac app polish
- [2026-09-30] proxy refuses web pages
- [2026-09-30] a native macOS app for tokenmaxx, with a menu bar item
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,5 @@
"post-commit": "bun x @rubriclab/package post-commit"
},
"type": "module",
"version": "0.0.78"
"version": "0.0.79"
}
63 changes: 62 additions & 1 deletion src/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ describe('registerClaudeAccount', () => {
vault
})
expect(account.identity).toBe('lennard@example.com')
expect(account.externalAccountId).toBe('account-uuid')
expect(account.externalAccountId).toBe('org-uuid')
expect(account.externalUserId).toBe('account-uuid')
expect(vault.items.get(account.secretReference ?? '')).toBe(JSON.stringify(stored))
})
})
Expand Down Expand Up @@ -257,3 +258,63 @@ describe('claude banked resets on the wire', () => {
expect(outcome).toEqual({ code: 'reset', windowsReset: 3 })
})
})

describe('one claude login with a personal and a team subscription', () => {
// a fresh access token per probe, so the verified-identity cache never answers for another test
const fresh = { ...stored, expiresAt: Date.now() + 3_600_000 }
const usage = { five_hour: { utilization: 10 }, seven_day: { utilization: 20 } }
const teamAccount: Extract<Account, { provider: 'anthropic' }> = {
auth: 'oauth',
createdAt: '2026-09-01T00:00:00.000Z',
enabled: true,
externalAccountId: 'team-org',
externalUserId: 'user-uuid',
health: 'ready',
id: '00000000-0000-4000-8000-000000000401',
identity: 'dev@example.com',
label: 'dev@example.com (team)',
onThreshold: 'switch',
plan: 'team',
profilePath: null,
provider: 'anthropic',
secretReference: reference,
updatedAt: '2026-09-01T00:00:00.000Z'
}
const probe = (account: Extract<Account, { provider: 'anthropic' }>, organization: string) =>
probeClaude({
account,
fetchImplementation: async request =>
Response.json(
String(request).includes('/profile')
? {
account: { email: 'dev@example.com', uuid: 'user-uuid' },
organization: { uuid: organization }
}
: usage
),
now: () => new Date(),
vault: memoryVault({
[reference]: JSON.stringify({ ...fresh, accessToken: crypto.randomUUID() })
})
})

test('a row saved before organizations were recorded gets its organization on the next probe', async () => {
const legacy = { ...teamAccount, externalAccountId: 'user-uuid', externalUserId: null }
const result = await probe({ ...legacy, label: 'dev@example.com' }, 'personal-org')
expect(result.account.externalAccountId).toBe('personal-org')
expect(result.account.externalUserId).toBe('user-uuid')
expect(result.account.label).toBe('dev@example.com')
})

test('a probe keeps the qualified label of the second subscription', async () => {
const result = await probe(teamAccount, 'team-org')
expect(result.account.label).toBe('dev@example.com (team)')
expect(result.account.identity).toBe('dev@example.com')
})

test('a credential that now answers for another organization is flagged', async () => {
await expect(probe(teamAccount, 'personal-org')).rejects.toMatchObject({
code: 'IDENTITY_CHANGED'
})
})
})
67 changes: 48 additions & 19 deletions src/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type ResetCreditCounts,
type ResetCreditsView,
type ResetOutcome,
relabel,
type UsageSnapshot,
type UsageWindow
} from './domain.ts'
Expand Down Expand Up @@ -315,10 +316,16 @@ export async function refreshClaudeCredential(input: {
})
}

interface ClaudeProfile {
email: string | null
organizationId: string | null
userId: string
}

async function fetchClaudeProfile(
accessToken: string,
fetchImplementation: FetchImplementation = fetch
): Promise<{ accountId: string; email: string | null; organizationId: string | null }> {
): Promise<ClaudeProfile> {
const response = await fetchImplementation(profileEndpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
Expand All @@ -336,19 +343,33 @@ async function fetchClaudeProfile(
)
}
const profile = ProfileSchema.parse(await response.json())
const accountId = profile.account?.uuid ?? profile.uuid
if (accountId === undefined) {
const userId = profile.account?.uuid ?? profile.uuid
if (userId === undefined) {
throw new ApplicationError('ACCOUNT_ID_MISSING', 'Claude profile response has no account id')
}
return {
accountId,
email:
profile.account?.email_address ??
profile.account?.email ??
profile.email_address ??
profile.email ??
null,
organizationId: profile.organization?.uuid ?? null
organizationId: profile.organization?.uuid ?? null,
userId
}
}

// One Claude login can hold several subscriptions (a personal plan and a team plan), each its
// own organization, so an account is the (organization, user) pair, stored the way codex
// stores (workspace, user). Rows saved before this hold only the user id in externalAccountId.
function claudeExternalIds(
profile: ClaudeProfile,
account?: Extract<Account, { provider: 'anthropic' }>
): { externalAccountId: string | null; externalUserId: string } {
const storedOrganizationId = account?.externalUserId == null ? null : account.externalAccountId
return {
externalAccountId: profile.organizationId ?? storedOrganizationId,
externalUserId: profile.userId
}
}

Expand Down Expand Up @@ -386,8 +407,7 @@ export async function registerClaudeAccount(input: {
auth: 'oauth',
createdAt: now,
enabled: true,
externalAccountId: profile.accountId,
externalUserId: null,
...claudeExternalIds(profile),
health: 'ready',
id,
identity: email.data,
Expand Down Expand Up @@ -817,20 +837,29 @@ function refreshTokenHealth(

function assertIdentity(
account: Extract<Account, { provider: 'anthropic' }>,
accountId: string
profile: ClaudeProfile
): void {
if (account.externalAccountId !== null && account.externalAccountId !== accountId) {
const userId = account.externalUserId ?? account.externalAccountId
if (userId !== null && userId !== profile.userId) {
throw new ApplicationError(
'IDENTITY_CHANGED',
'Stored Claude credential belongs to a different account'
)
}
if (
account.externalUserId !== null &&
account.externalAccountId !== null &&
profile.organizationId !== null &&
account.externalAccountId !== profile.organizationId
) {
throw new ApplicationError(
'IDENTITY_CHANGED',
'Stored Claude credential belongs to a different organization'
)
}
}

const verifiedIdentities = new Map<
string,
{ accessToken: string; accountId: string; email: string | null }
>()
const verifiedIdentities = new Map<string, { accessToken: string } & ClaudeProfile>()

export async function probeClaude(input: {
account: Extract<Account, { provider: 'anthropic' }>
Expand Down Expand Up @@ -877,10 +906,10 @@ export async function probeClaude(input: {
'Claude credential does not include the user:profile scope required for usage'
)
}
const verifyIdentity = async (): Promise<{ accountId: string; email: string | null }> => {
const verifyIdentity = async (): Promise<ClaudeProfile> => {
const cached = verifiedIdentities.get(reference)
if (cached !== undefined && cached.accessToken === credential.accessToken) {
return { accountId: cached.accountId, email: cached.email }
return { email: cached.email, organizationId: cached.organizationId, userId: cached.userId }
}
const fetched = await fetchClaudeProfile(credential.accessToken, fetchImplementation)
verifiedIdentities.set(reference, { accessToken: credential.accessToken, ...fetched })
Expand All @@ -893,7 +922,7 @@ export async function probeClaude(input: {
credential = await refresh(credential.accessToken)
return verifyIdentity()
})
assertIdentity(account, profile.accountId)
assertIdentity(account, profile)
const usage = await fetchClaudeUsage({
accessToken: credential.accessToken,
accountId: account.id,
Expand All @@ -910,14 +939,14 @@ export async function probeClaude(input: {
fetchImplementation
})
})
assertIdentity(account, profile.accountId)
assertIdentity(account, profile)
const email = AccountEmailSchema.safeParse(profile.email)
return {
account: {
...account,
...claudeExternalIds(profile, account),
...relabel(account, email.success ? email.data : account.identity),
health: refreshTokenHealth(credential.refreshTokenExpiresAt, input.now()),
identity: email.success ? email.data : account.identity,
label: email.success ? email.data : account.label,
plan: claudePlanTier(credential) ?? account.plan ?? null,
updatedAt: input.now().toISOString()
},
Expand Down
23 changes: 14 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ import {
uninstallPiConfig,
uninstallProviderConfig
} from './config-install.ts'
import { type Account, PROVIDERS, type ProviderId, ProviderIdSchema } from './domain.ts'
import {
type Account,
distinctLabel,
PROVIDERS,
type ProviderId,
ProviderIdSchema,
relabel,
sameExternalIdentity
} from './domain.ts'
import { ApplicationError, errorMessage } from './errors.ts'
import { registerGrokAccount } from './grok.ts'
import {
Expand Down Expand Up @@ -641,22 +649,19 @@ async function promptApiKey(

async function signInOauth(context: ApplicationContext, provider: ProviderId): Promise<boolean> {
const authenticated = await registerIsolatedAccount(provider)
const existing = context.store
.listAccounts(provider)
.find(
candidate =>
candidate.externalAccountId !== null &&
candidate.externalAccountId === authenticated.externalAccountId
)
const account =
const stored = context.store.listAccounts(provider)
const existing = stored.find(candidate => sameExternalIdentity(candidate, authenticated))
const signedIn =
existing === undefined
? authenticated
: {
...authenticated,
...relabel(existing, authenticated.identity),
createdAt: existing.createdAt,
enabled: existing.enabled,
id: existing.id
}
const account = { ...signedIn, label: distinctLabel(signedIn, stored) }
try {
await requestAccountSave(context.paths.managerSocket, account, {
profilePath: existing?.profilePath ?? null,
Expand Down
4 changes: 2 additions & 2 deletions src/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ProviderProbeResult,
type ResetCreditsView,
type ResetOutcome,
relabel,
type UsageSnapshot,
type UsageWindow
} from './domain.ts'
Expand Down Expand Up @@ -666,8 +667,7 @@ export async function probeCodex(input: {
externalAccountId: identity.accountId,
externalUserId: identity.userId,
health: 'ready',
identity: email.success ? email.data : account.identity,
label: email.success ? email.data : account.label,
...relabel(account, email.success ? email.data : account.identity),
plan: identity.plan ?? account.plan ?? null,
updatedAt: input.now().toISOString()
},
Expand Down
54 changes: 51 additions & 3 deletions src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const AccountSchema = z
secretReference: z.string().trim().min(1)
}).strict(),
AccountFieldsSchema.extend({
externalUserId: z.null().default(null),
externalUserId: z.string().trim().min(1).nullable().default(null),
profilePath: z.string().trim().min(1).nullable(),
provider: z.literal('anthropic'),
secretReference: z.string().trim().min(1).nullable()
Expand All @@ -66,8 +66,8 @@ export const AccountSchema = z
secretReference: z.string().trim().min(1)
}).strict()
])
.refine(account => account.label === account.identity, {
message: 'Account label must equal its identity',
.refine(account => isLabelFor(account.identity, account.label), {
message: 'Account label must equal its identity, optionally with a qualifier',
path: ['label']
})
.refine(
Expand All @@ -79,6 +79,54 @@ export const AccountSchema = z
)
export type Account = z.infer<typeof AccountSchema>

function qualifiedLabel(identity: string, qualifier: string): string {
return `${identity} (${qualifier})`
}

function isLabelFor(identity: string, label: string): boolean {
return label === identity || (label.startsWith(`${identity} (`) && label.endsWith(')'))
}

// Rows saved before user ids were recorded know a single id: the ChatGPT workspace for
// openai, the Claude user for anthropic. Those rows match on that id alone.
function singleExternalId(account: Account): string | null {
return account.provider === 'anthropic'
? (account.externalUserId ?? account.externalAccountId)
: account.externalAccountId
}

export function sameExternalIdentity(left: Account, right: Account): boolean {
if (left.provider !== right.provider) {
return false
}
if (left.externalUserId !== null && right.externalUserId !== null) {
return (
left.externalAccountId === right.externalAccountId &&
left.externalUserId === right.externalUserId
)
}
const id = singleExternalId(left)
return id !== null && id === singleExternalId(right)
}

export function distinctLabel(account: Account, stored: readonly Account[]): string {
const taken = new Set(
stored
.filter(other => other.provider === account.provider && other.id !== account.id)
.map(other => other.label)
)
const qualifiers = [
account.plan?.replace(/^default_(claude_)?/, ''),
account.externalAccountId?.slice(0, 8),
account.id.slice(0, 8)
].flatMap(qualifier => (qualifier ? [qualifiedLabel(account.identity, qualifier)] : []))
return [account.label, ...qualifiers].find(label => !taken.has(label)) ?? account.label
}

export function relabel(account: Account, identity: string): { identity: string; label: string } {
return { identity, label: identity === account.identity ? account.label : identity }
}

const UsageWindowSchema = z
.object({
id: z.string().trim().min(1),
Expand Down
Loading
Loading