mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Report when an account's home is signed in as someone else
Isolating each account into its own config dir gives a foreign login somewhere to hide. Sign in as the wrong identity at a prompt in account A's pane and A's home now holds B's credentials: the switcher still says A, the usage numbers shown are B's, and the work runs against B's quota. Nothing is lost and signing in again recovers, but until you notice, Orca is telling you something false about which account you are on. Detection is read-only. It compares the identity record in the account's own home against the one Orca recorded, and reports match, foreign or unknown. It never rewrites either store - reverting a foreign login is what produced the defects the single-owner change removed. Precedence is strict rather than a fallback chain. When both sides expose the account UUID that comparison decides alone and the weaker fields are never consulted, so a shared or stale email cannot soften a genuine UUID mismatch. Only when the stronger field is missing on either side do we fall to the next. Deep equality is never used: the CLI rewrites cache fields in that record and comparing whole objects would badge a healthy account as foreign. `unknown` is not a soft `foreign`. A torn read, an unreadable record, or one exposing no stable field all report unknown and render nothing, because the user's only remedy for a badge is an unnecessary re-login. The verdict rides on the account summary as an OPTIONAL wire field. A client and a remote host update independently, so a newer client against an older host receives no field and must read that as unknown rather than a confident match. The status bar merges the host's value in by account id: previously the settings-derived summary won locally and silently dropped the verdict, which is the one case the badge exists for. Publishing needed its own path. `onChanged` is wired only to rate-limit state, so an identity change with no usage change would have sat in main and never reached the renderer. Only a transition publishes, so reopening panes does not push a snapshot each time.
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
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<ClaudeManagedAccount, 'email' | 'organizationUuid'> & {
|
||||
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'
|
||||
}
|
||||
@@ -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<string, ClaudeAccountIdentityStatus>()
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
@@ -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<void>
|
||||
private readonly removeManagedAuth: (accountId: string, path: string) => Promise<void>,
|
||||
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',
|
||||
|
||||
@@ -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 () => {})
|
||||
}
|
||||
|
||||
@@ -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 () => {})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 () => {})
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<unknown> = 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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -278,6 +278,20 @@ export function ClaudeSwitcherMenu({
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate">{target.label}</span>
|
||||
{target.signedInAsAnotherAccount ? (
|
||||
<span
|
||||
className="shrink-0 text-[10px] font-medium text-destructive"
|
||||
title={translate(
|
||||
'auto.components.status.bar.tooltip.8b2f4c6d19',
|
||||
'This Claude account\u2019s home is signed in as a different account. Run /login in its terminal to sign back in.'
|
||||
)}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.status.bar.tooltip.c47a1e90b3',
|
||||
'Signed in as another account'
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{target.active ? (
|
||||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
|
||||
{translate('auto.components.status.bar.StatusBar.ff0fbe9311', 'Active')}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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<GlobalSettings>),
|
||||
runtimeState('foreign')
|
||||
)
|
||||
expect(resolved.accounts[0]?.identityStatus).toBe('foreign')
|
||||
})
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user