diff --git a/src/main/claude-accounts/claude-account-identity-status.test.ts b/src/main/claude-accounts/claude-account-identity-status.test.ts new file mode 100644 index 00000000000..b1dbc1f3f64 --- /dev/null +++ b/src/main/claude-accounts/claude-account-identity-status.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { compareClaudeAccountIdentity } from './claude-account-identity-status' + +const account = { + email: 'alice@example.com', + organizationUuid: 'org-1', + accountUuid: 'uuid-alice' +} + +describe('compareClaudeAccountIdentity', () => { + it('matches when the account UUID agrees', () => { + expect( + compareClaudeAccountIdentity({ accountUuid: 'uuid-alice', emailAddress: 'x@y.z' }, account) + ).toBe('match') + }) + + it('reports foreign on a UUID mismatch even when the email matches', () => { + // Ablation: turning the UUID branch into a fallback chain — falling through to the email when + // the UUID disagrees — turns this green for a genuinely different account. A shared or stale + // email must never soften a UUID mismatch. + expect( + compareClaudeAccountIdentity( + { accountUuid: 'uuid-bob', emailAddress: 'alice@example.com' }, + account + ) + ).toBe('foreign') + }) + + it('falls to the email only when a UUID is missing on either side', () => { + expect(compareClaudeAccountIdentity({ emailAddress: 'alice@example.com' }, account)).toBe( + 'match' + ) + expect(compareClaudeAccountIdentity({ emailAddress: 'bob@example.com' }, account)).toBe( + 'foreign' + ) + }) + + it('treats a conflicting organization as foreign even when the email matches', () => { + expect( + compareClaudeAccountIdentity( + { emailAddress: 'alice@example.com', organizationUuid: 'org-2' }, + account + ) + ).toBe('foreign') + }) + + it('normalizes case and surrounding whitespace before comparing', () => { + expect(compareClaudeAccountIdentity({ emailAddress: ' ALICE@Example.com ' }, account)).toBe( + 'match' + ) + }) + + it.each([ + ['null', null], + ['a non-object', 'not-an-object'], + ['an array', []], + ['an empty record', {}], + ['a record with only blank fields', { accountUuid: ' ', emailAddress: '' }] + ])('reports unknown for %s rather than guessing', (_label, oauthAccount) => { + // Ablation: returning 'foreign' for any of these badges a healthy account as someone else's on + // no evidence, and the user's only remedy is an unnecessary re-login. + expect(compareClaudeAccountIdentity(oauthAccount, account)).toBe('unknown') + }) + + it('reports unknown when Orca has no stable field of its own to compare', () => { + expect( + compareClaudeAccountIdentity( + { accountUuid: 'uuid-bob' }, + { email: '', organizationUuid: null } + ) + ).toBe('unknown') + }) + + it('does not consult non-identity fields the CLI rewrites', () => { + // The CLI rewrites cache/session fields in this record. Deep equality would call that foreign. + expect( + compareClaudeAccountIdentity( + { accountUuid: 'uuid-alice', lastRefreshedAt: 123, cachedThing: { a: 1 } }, + account + ) + ).toBe('match') + }) +}) diff --git a/src/main/claude-accounts/claude-account-identity-status.ts b/src/main/claude-accounts/claude-account-identity-status.ts new file mode 100644 index 00000000000..f45a0f03f70 --- /dev/null +++ b/src/main/claude-accounts/claude-account-identity-status.ts @@ -0,0 +1,83 @@ +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' + +/** + * Whether the account's own home is signed in as the identity Orca recorded for it. + * + * `unknown` is not a soft `foreign`. An unreadable or torn identity record, or one that exposes no + * stable field, tells us nothing — and badging an account as someone else's on no evidence is worse + * than saying nothing, because the user's only remedy is to sign in again. + */ +export type ClaudeAccountIdentityStatus = 'match' | 'foreign' | 'unknown' + +function normalized(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed === '' ? null : trimmed +} + +function normalizedEmail(value: unknown): string | null { + return normalized(value)?.toLowerCase() ?? null +} + +type OauthAccountIdentity = { + accountUuid: string | null + email: string | null + organizationUuid: string | null +} + +/** The CLI writes this record; treat every field as absent until proven otherwise. */ +export function readClaudeOauthAccountIdentity(oauthAccount: unknown): OauthAccountIdentity { + if (!oauthAccount || typeof oauthAccount !== 'object' || Array.isArray(oauthAccount)) { + return { accountUuid: null, email: null, organizationUuid: null } + } + const record = oauthAccount as Record + return { + accountUuid: normalized(record.accountUuid) ?? normalized(record.accountId), + email: normalizedEmail(record.emailAddress) ?? normalizedEmail(record.email), + organizationUuid: normalized(record.organizationUuid) ?? normalized(record.organizationId) + } +} + +/** + * Compares the home's identity record against the account Orca thinks it is. + * + * **Precedence is strict, not a fallback chain.** When both sides expose the account UUID, that + * comparison decides on its own and the weaker fields are never consulted. Falling through to email + * after a UUID mismatch would let a shared or stale email mask a genuinely different account — the + * softening this check exists to prevent. Only when the stronger field is missing on either side do + * we fall to the next one. + * + * Never deep-equals the records: the CLI rewrites cache and non-identity fields in this file, and + * treating that as a mismatch would badge a healthy account as foreign. + */ +export function compareClaudeAccountIdentity( + oauthAccount: unknown, + account: Pick & { + accountUuid?: string | null + } +): ClaudeAccountIdentityStatus { + const home = readClaudeOauthAccountIdentity(oauthAccount) + + const expectedUuid = normalized(account.accountUuid) + if (home.accountUuid !== null && expectedUuid !== null) { + return home.accountUuid === expectedUuid ? 'match' : 'foreign' + } + + const expectedEmail = normalizedEmail(account.email) + if (home.email !== null && expectedEmail !== null) { + if (home.email !== expectedEmail) { + return 'foreign' + } + // A matching email is only provisional evidence: two accounts in different organizations can + // share one. A conflicting org on top of a matching email is still a different account. + const expectedOrg = normalized(account.organizationUuid) + if (home.organizationUuid !== null && expectedOrg !== null) { + return home.organizationUuid === expectedOrg ? 'match' : 'foreign' + } + return 'match' + } + + return 'unknown' +} diff --git a/src/main/claude-accounts/claude-account-identity-tracker.ts b/src/main/claude-accounts/claude-account-identity-tracker.ts new file mode 100644 index 00000000000..b70ac58a935 --- /dev/null +++ b/src/main/claude-accounts/claude-account-identity-tracker.ts @@ -0,0 +1,35 @@ +import type { ClaudeAccountIdentityStatus } from './claude-account-identity-status' + +/** + * Holds the most recent identity verdict per account. + * + * Why a tracker rather than computing it inside the snapshot: the verdict needs the account's own + * `.claude.json`, and the snapshot builder is synchronous and called on every render of the status + * bar. Reading the filesystem there would put disk I/O on that path. Instead the lanes that already + * touch the account's home — pane launch and the usage read — record what they saw, and the + * snapshot merges the last verdict in by account id. + * + * An account with no recorded verdict is absent from the map, and callers render `unknown`. That is + * deliberate: "we have not looked yet" and "we looked and could not tell" are both states in which + * telling the user their account is someone else's would be wrong. + */ +export class ClaudeAccountIdentityTracker { + private readonly statuses = new Map() + + /** Returns true when the verdict changed, which is the caller's cue to publish a snapshot. */ + record(accountId: string, status: ClaudeAccountIdentityStatus): boolean { + if (this.statuses.get(accountId) === status) { + return false + } + this.statuses.set(accountId, status) + return true + } + + get(accountId: string): ClaudeAccountIdentityStatus | undefined { + return this.statuses.get(accountId) + } + + forget(accountId: string): void { + this.statuses.delete(accountId) + } +} diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index d81f2346eb7..e8a2064153c 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -6,6 +6,8 @@ import type { import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' +import type { ClaudeAccountIdentityStatus } from './claude-account-identity-status' +import { ClaudeAccountIdentityTracker } from './claude-account-identity-tracker' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, @@ -23,7 +25,8 @@ export class ClaudeAccountSelection { private readonly store: Store, private readonly rateLimits: RateLimitService, private readonly runtimeAuth: ClaudeRuntimeAuthService, - private readonly removeManagedAuth: (accountId: string, path: string) => Promise + private readonly removeManagedAuth: (accountId: string, path: string) => Promise, + private readonly identityStatuses: ClaudeAccountIdentityTracker = new ClaudeAccountIdentityTracker() ) {} list(): ClaudeRateLimitAccountsState { @@ -119,7 +122,7 @@ export class ClaudeAccountSelection { const settings = this.store.getSettings() return { accounts: settings.claudeManagedAccounts - .map(toClaudeAccountSummary) + .map((account) => toClaudeAccountSummary(account, this.identityStatuses.get(account.id))) .sort((a, b) => b.updatedAt - a.updatedAt), activeAccountId: normalizeClaudeRuntimeSelection(settings).host, activeAccountIdsByRuntime: normalizeClaudeRuntimeSelection(settings) @@ -175,10 +178,16 @@ export class ClaudeAccountSelection { } } -function toClaudeAccountSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { +function toClaudeAccountSummary( + account: ClaudeManagedAccount, + identityStatus?: ClaudeAccountIdentityStatus +): ClaudeManagedAccountSummary { return { id: account.id, email: account.email, + // Absent when no lane has looked at this account's home yet. Left undefined rather than + // defaulted to 'match', so "not looked at" never renders as a positive assurance. + ...(identityStatus === undefined ? {} : { identityStatus }), managedAuthRuntime: account.managedAuthRuntime ?? 'host', wslDistro: account.wslDistro ?? null, authMethod: account.authMethod ?? 'unknown', diff --git a/src/main/claude-accounts/claude-account-service-account-selection.test.ts b/src/main/claude-accounts/claude-account-service-account-selection.test.ts index 3baa0b4ee3c..99521a92fe0 100644 --- a/src/main/claude-accounts/claude-account-service-account-selection.test.ts +++ b/src/main/claude-accounts/claude-account-service-account-selection.test.ts @@ -83,6 +83,7 @@ describe('ClaudeAccountService credential capture', () => { }) } const runtimeAuth = { + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } @@ -145,6 +146,7 @@ describe('ClaudeAccountService credential capture', () => { }) } const runtimeAuth = { + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } @@ -215,6 +217,7 @@ describe('ClaudeAccountService credential capture', () => { }) } const runtimeAuth = { + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } @@ -373,6 +376,7 @@ describe('ClaudeAccountService credential capture', () => { }) } const runtimeAuth = { + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } @@ -441,6 +445,7 @@ describe('ClaudeAccountService credential capture', () => { updateSettings: vi.fn() } const runtimeAuth = { + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } @@ -515,6 +520,7 @@ describe('ClaudeAccountService credential capture', () => { }) } const runtimeAuth = { + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } diff --git a/src/main/claude-accounts/claude-account-service-add-account.test.ts b/src/main/claude-accounts/claude-account-service-add-account.test.ts index 3e17f4c00eb..5eeb45bf545 100644 --- a/src/main/claude-accounts/claude-account-service-add-account.test.ts +++ b/src/main/claude-accounts/claude-account-service-add-account.test.ts @@ -85,6 +85,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } @@ -173,6 +174,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => { throw new Error('rematerialize failed') @@ -255,6 +257,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } @@ -337,6 +340,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } diff --git a/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts b/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts index ea4479dcce4..7a9c710dd35 100644 --- a/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts +++ b/src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts @@ -72,6 +72,7 @@ describe('ClaudeAccountService.addAccountFromConfigDir', () => { const rateLimits = { evictInactiveClaudeCache: vi.fn() } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } return { store, rateLimits, runtimeAuth, getSettings: () => settings } diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts index 43e051a95fd..6df10bbb65c 100644 --- a/src/main/claude-accounts/claude-account-service-login-process.test.ts +++ b/src/main/claude-accounts/claude-account-service-login-process.test.ts @@ -284,6 +284,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } const rateLimits = { @@ -408,6 +409,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } const rateLimits = { @@ -468,6 +470,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } const rateLimits = { @@ -552,6 +555,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } const rateLimits = { diff --git a/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts b/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts index 15e7ab696f5..4c500ea0213 100644 --- a/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts +++ b/src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts @@ -88,6 +88,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), syncForCurrentSelection: vi.fn(async () => { throw new Error('materialize failed') @@ -161,6 +162,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), syncForCurrentSelection: vi.fn(async () => { throw new Error('materialize failed') @@ -235,6 +237,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), syncForCurrentSelection: vi.fn() } @@ -311,6 +314,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), syncForCurrentSelection: vi.fn(async () => { rmSync(oauthPath, { force: true }) @@ -382,6 +386,7 @@ describe('ClaudeAccountService credential capture', () => { } const runtimeAuth = { clearLastWrittenCredentialsJson: vi.fn(), + setIdentityStatusListener: vi.fn(), syncForCurrentSelection: vi.fn(async () => {}), forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) } diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 084d4c9362f..a05daf8a52e 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -17,11 +17,20 @@ import { readComposedClaudeCredentials } from './claude-credential-read-result' import { readClaudeManagedAuthFile } from './managed-auth-path' +import { + compareClaudeAccountIdentity, + type ClaudeAccountIdentityStatus +} from './claude-account-identity-status' +import { RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR } from './runtime-auth/runtime-auth-types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { private readonly startupMigrations: Promise + private identityStatusListener: + | ((accountId: string, status: ClaudeAccountIdentityStatus) => void) + | null = null constructor(store: Store) { super(store) @@ -51,6 +60,30 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { await this.startupMigrations } + setIdentityStatusListener( + listener: ((accountId: string, status: ClaudeAccountIdentityStatus) => void) | null + ): void { + this.identityStatusListener = listener + } + + /** + * Reads the account's own `.claude.json` and reports whether it is signed in as the identity + * Orca recorded. Read-only by design: a home signed in as someone else is reported, never + * rewritten. Reverting it is what generated the defects this work removed. + */ + private reportIdentityStatus(account: ClaudeManagedAccount, configDir: string): void { + if (!this.identityStatusListener) { + return + } + const oauthAccount = this.readRuntimeOauthAccount(configDir) + this.identityStatusListener( + account.id, + oauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR + ? 'unknown' + : compareClaudeAccountIdentity(oauthAccount, account) + ) + } + private async migrateToScopedStore(): Promise { if (process.platform !== 'darwin') { return @@ -116,6 +149,11 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { await this.syncForCurrentSelection(effectiveTarget) } const preparation = this.getPreparation(effectiveTarget) + // The pane is about to run against this home, so this is the moment its identity is worth + // checking. Reporting only; nothing here writes. + if (selected && preparation.envPatch.CLAUDE_CONFIG_DIR === preparation.configDir) { + this.reportIdentityStatus(selected, preparation.configDir) + } return preparation } diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 8f1077ba1a5..addb77171e6 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,4 +1,5 @@ import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' +import { ClaudeAccountIdentityTracker } from './claude-account-identity-tracker' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' import { ClaudeAccountRegistration } from './claude-account-registration' @@ -35,6 +36,7 @@ export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() private cancelPendingClaudeLogin: (() => boolean) | null = null private readonly storage = new ClaudeManagedAuthStorage() + private readonly identityStatuses = new ClaudeAccountIdentityTracker() private readonly selection: ClaudeAccountSelection private readonly registration: ClaudeAccountRegistration @@ -43,9 +45,23 @@ export class ClaudeAccountService { rateLimits: RateLimitService, private readonly runtimeAuth: ClaudeRuntimeAuthService ) { - this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth, (accountId, path) => - this.safeRemoveManagedAuth(accountId, path) + this.selection = new ClaudeAccountSelection( + store, + rateLimits, + runtimeAuth, + (accountId, path) => this.safeRemoveManagedAuth(accountId, path), + this.identityStatuses ) + // Only a change is published: recomputing the same verdict on every launch must not push a + // fresh snapshot to the renderer each time a pane opens. + // Optional call on purpose: the badge is an enhancement over the account list, so a runtime + // auth service that cannot take a listener degrades to never updating it rather than failing + // account construction outright. + runtimeAuth.setIdentityStatusListener?.((accountId, status) => { + if (this.identityStatuses.record(accountId, status)) { + rateLimits.publishClaudeAccountsChanged() + } + }) this.registration = new ClaudeAccountRegistration({ store, rateLimits, diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts index 5d977991af4..cbf74a0a280 100644 --- a/src/main/rate-limits/service/service-inactive-accounts.ts +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -186,6 +186,17 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP } } + /** + * Republishes account state that changed outside a usage fetch. + * + * Why this exists: `onChanged` on the account controller is wired only to this service's state + * listeners, so a change with no rate-limit component — an account's identity turning out to be + * someone else's, say — would sit in main and never reach the status bar. + */ + publishClaudeAccountsChanged(): void { + this.pushToRenderer() + } + evictInactiveClaudeCache(accountId: string): void { this.inactiveClaudeAccountsGeneration += 1 this.inactiveClaudeCache.delete(accountId) diff --git a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx index c5414d66d9a..19b0e48b4e8 100644 --- a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx +++ b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx @@ -278,6 +278,20 @@ export function ClaudeSwitcherMenu({
{target.label} + {target.signedInAsAnotherAccount ? ( + + {translate( + 'auto.components.status.bar.tooltip.c47a1e90b3', + 'Signed in as another account' + )} + + ) : null} {target.active ? ( {translate('auto.components.status.bar.StatusBar.ff0fbe9311', 'Active')} diff --git a/src/renderer/src/components/status-bar/status-bar-claude-accounts.ts b/src/renderer/src/components/status-bar/status-bar-claude-accounts.ts index b537a2c59eb..7e0970d3e85 100644 --- a/src/renderer/src/components/status-bar/status-bar-claude-accounts.ts +++ b/src/renderer/src/components/status-bar/status-bar-claude-accounts.ts @@ -94,7 +94,10 @@ export function buildClaudeStatusSwitchGroups( id: account.id, label: account.email, active: account.id === activeId, - runtimeTarget: target + runtimeTarget: target, + // Only a proven mismatch is carried through. `unknown` and "not looked at yet" both mean + // we have no business telling the user their account is someone else's. + signedInAsAnotherAccount: account.identityStatus === 'foreign' })) ] } @@ -180,5 +183,34 @@ export function resolveClaudeStatusAccountState( if (settings?.activeRuntimeEnvironmentId?.trim()) { return runtimeState } - return getClaudeStatusAccountsFromSettings(settings) ?? runtimeState + const fromSettings = getClaudeStatusAccountsFromSettings(settings) + if (!fromSettings) { + return runtimeState + } + return { + ...fromSettings, + accounts: withIdentityStatusFromRuntime(fromSettings.accounts, runtimeState) + } +} + +/** + * Carries `identityStatus` over from the runtime snapshot onto the settings-derived summaries. + * + * Whether an account's home is signed in as the right identity is decided by reading that home, + * which only the host can do — persisted settings have no such field and never will. Without this + * merge the settings-derived summary silently wins locally and erases a `foreign` verdict computed + * in main, which is the one case the badge exists for. + * + * The value is left absent when the runtime has none, so "not looked at yet" stays distinguishable + * from "looked and matched" rather than being flattened into a false assurance. + */ +function withIdentityStatusFromRuntime( + accounts: ClaudeRateLimitAccountsState['accounts'], + runtimeState: ClaudeRateLimitAccountsState +): ClaudeRateLimitAccountsState['accounts'] { + const byId = new Map(runtimeState.accounts.map((account) => [account.id, account.identityStatus])) + return accounts.map((account) => { + const identityStatus = byId.get(account.id) + return identityStatus === undefined ? account : { ...account, identityStatus } + }) } diff --git a/src/renderer/src/components/status-bar/status-bar-claude-identity-status.test.ts b/src/renderer/src/components/status-bar/status-bar-claude-identity-status.test.ts new file mode 100644 index 00000000000..5bef4bccc84 --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-claude-identity-status.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeRateLimitAccountsState } from '../../../../shared/managed-account-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { resolveClaudeStatusAccountState } from './status-bar-claude-accounts' + +function settingsWithAccount(overrides: Partial = {}): GlobalSettings { + return { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'alice@example.com', + managedAuthPath: '/tmp/a/auth', + managedAuthRuntime: 'host', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1', + ...overrides + } as unknown as GlobalSettings +} + +function runtimeState( + identityStatus?: 'match' | 'foreign' | 'unknown' +): ClaudeRateLimitAccountsState { + return { + accounts: [ + { + id: 'account-1', + email: 'alice@example.com', + managedAuthRuntime: 'host', + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 1, + ...(identityStatus === undefined ? {} : { identityStatus }) + } + ], + activeAccountId: 'account-1' + } +} + +describe('resolveClaudeStatusAccountState identity status', () => { + it('carries a foreign verdict from the runtime onto the settings-derived summary', () => { + // Ablation: returning the settings-derived state directly (the previous behaviour) drops the + // verdict on the floor locally, which is the exact case the badge exists for. + const resolved = resolveClaudeStatusAccountState(settingsWithAccount(), runtimeState('foreign')) + expect(resolved.accounts[0]?.identityStatus).toBe('foreign') + }) + + it('leaves the field absent when the runtime has not looked yet', () => { + // Absent must not become 'match': "we have not checked" is not an assurance. + const resolved = resolveClaudeStatusAccountState(settingsWithAccount(), runtimeState()) + expect(resolved.accounts[0]?.identityStatus).toBeUndefined() + }) + + it('keeps the settings-derived identity for accounts the runtime does not know', () => { + const resolved = resolveClaudeStatusAccountState(settingsWithAccount(), { + accounts: [], + activeAccountId: null + }) + expect(resolved.accounts).toHaveLength(1) + expect(resolved.accounts[0]?.identityStatus).toBeUndefined() + }) + + it('uses the host snapshot wholesale for a remote environment', () => { + // On a remote server the host owns the answer; local settings describe this desktop. + const resolved = resolveClaudeStatusAccountState( + settingsWithAccount({ activeRuntimeEnvironmentId: 'remote-1' } as Partial), + runtimeState('foreign') + ) + expect(resolved.accounts[0]?.identityStatus).toBe('foreign') + }) +}) diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-targets.ts b/src/renderer/src/components/status-bar/status-bar-runtime-targets.ts index 463a4eb5290..b4936d09b01 100644 --- a/src/renderer/src/components/status-bar/status-bar-runtime-targets.ts +++ b/src/renderer/src/components/status-bar/status-bar-runtime-targets.ts @@ -27,6 +27,8 @@ export type ClaudeStatusSwitchTarget = { label: string active: boolean runtimeTarget: CodexStatusRuntimeTarget + /** True only for a proven identity mismatch; absent covers both "matches" and "not checked". */ + signedInAsAnotherAccount?: boolean } export type ClaudeStatusSwitchGroup = { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ae55d41dee5..7dd2c8cf17a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3844,6 +3844,8 @@ "1804cd8c3f": "Refreshing sign-in", "f8f0f9d8cc": "Network issue", "bf2e739f18": "Sign-in unavailable", + "c47a1e90b3": "Signed in as another account", + "8b2f4c6d19": "This Claude account’s home is signed in as a different account. Run /login in its terminal to sign back in.", "3ac91d7e42": "Account files missing", "f8b8dbed85": "Usage unavailable", "3d3c9c0c1f": "Claude usage will refresh after the live Claude terminal rotates its credentials.", diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index 7239c62e6ad..7442d70e474 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -73,6 +73,14 @@ export type ClaudeManagedAccount = { export type ClaudeManagedAccountSummary = { id: string email: string + /** + * Whether this account's own home is signed in as the identity Orca recorded for it. + * + * Optional on the wire on purpose: a client and a remote host update independently, so a newer + * client talking to a host that predates this field receives nothing and must read that as + * `unknown`. Never branch on it being present — see docs/reference/remote-wire-compatibility.md. + */ + identityStatus?: 'match' | 'foreign' | 'unknown' managedAuthRuntime?: 'host' | 'wsl' wslDistro?: string | null authMethod: 'subscription-oauth' | 'unknown'