diff --git a/docs/claude-auth-runtime.md b/docs/claude-auth-runtime.md new file mode 100644 index 00000000000..2bef762b847 --- /dev/null +++ b/docs/claude-auth-runtime.md @@ -0,0 +1,36 @@ +# Claude Runtime Auth Switching + +Orca switches Claude Code accounts by materializing a selected managed account into Claude's shared runtime auth surfaces. The runtime surfaces are: + +- `.claude/.credentials.json` +- macOS scoped Keychain credentials for the active `CLAUDE_CONFIG_DIR` +- macOS legacy `Claude Code-credentials` +- `.claude.json` `oauthAccount` + +## Core Invariants + +1. Never write a user/runtime surface unless Orca can prove it owns the current value on that surface. +2. Treat each credential surface independently. File, scoped Keychain, and legacy Keychain can each be owned, external, missing, or unknown. +3. `oauthAccount` metadata is restored or cleared only when metadata itself matches Orca's last managed value, or when a credential surface has already proved the current runtime state belongs to the managed account being cleaned up. +4. Invalid or unparsable runtime config is unknown, not null. Unknown config must be preserved. +5. Missing managed account records are unknown. Orca clears the active selection but does not mutate runtime auth without account identity proof. +6. Missing managed credentials for an existing account can be cleaned up using the account record identity. Only surfaces whose current credentials match that account are restored or cleared. +7. Read-back of refreshed tokens must evaluate all runtime credential candidates and persist only a single unambiguous managed-account match. + +## Snapshot Policy + +Before entering managed mode from system mode, Orca captures the system-default runtime state. On restore, the snapshot is only applied to surfaces whose current value still equals Orca's managed value, except for missing-managed-credential recovery where account identity is the proof. + +Snapshots are schema-validated before use. Invalid snapshots are deleted and treated as absent. + +When recapturing while the credentials file still equals the managed account, Orca preserves any previous snapshot value for Keychain surfaces that still equal the managed credentials. This prevents a failed restore followed by restart from recapturing managed Keychain values as system defaults. + +## Read-Back Policy + +Claude can refresh OAuth tokens in any runtime credential surface. Orca reads all available candidates, filters out stale or ambiguous matches, then chooses the freshest accepted candidate. Cold-start read-back is conservative: credentials must be newer than the matched managed account. Warm read-back rejects only metadata-proven older credentials, allowing equal-expiry token rotation. + +## Failure Policy + +Keychain reads during snapshot capture must succeed for both active services on macOS; otherwise Orca aborts managed entry. Best-effort Keychain reads are acceptable for token read-back because another surface may still contain a fresh candidate. + +Add-account cleanup must restore/delete the legacy active Keychain item before reporting success. If cleanup fails, the login is treated as failed so Orca does not silently leave the user's legacy Claude state pointing at the captured account. diff --git a/src/main/claude-accounts/keychain.test.ts b/src/main/claude-accounts/keychain.test.ts new file mode 100644 index 00000000000..b03c790ba19 --- /dev/null +++ b/src/main/claude-accounts/keychain.test.ts @@ -0,0 +1,205 @@ +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + deleteActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict, + writeActiveClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentialsForRuntime +} from './keychain' + +vi.mock('node:child_process', () => ({ + execFile: vi.fn() +})) + +const execFileMock = vi.mocked(execFile) +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +function serviceForConfigDir(configDir: string): string { + const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8) + return `Claude Code-credentials-${suffix}` +} + +function invokeExecFileCallback( + callback: unknown, + error: Error | null, + stdout: string, + stderr: string +): void { + const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void + execCallback(error, stdout, stderr) +} + +describe('Claude Keychain credentials', () => { + beforeEach(() => { + setPlatform('darwin') + execFileMock.mockReset() + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('reads config-scoped Claude Code 2.1 credentials before legacy credentials', async () => { + const configDir = '/tmp/orca-claude-login-test' + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"scoped"}}\n', '') + return null as never + }) + + await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe( + '{"claudeAiOauth":{"accessToken":"scoped"}}' + ) + + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock.mock.calls[0][1]).toEqual([ + 'find-generic-password', + '-s', + scopedService, + '-a', + process.env.USER || process.env.USERNAME || 'user', + '-w' + ]) + }) + + it('falls back to the legacy unsuffixed Claude Code credentials service', async () => { + const configDir = '/tmp/orca-claude-login-test' + const notFound = Object.assign(new Error('not found'), { code: 44 }) + execFileMock + .mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, notFound, '', 'could not be found') + return null as never + }) + .mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, 'legacy\n', '') + return null as never + }) + + await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe('legacy') + + expect(execFileMock.mock.calls[1][1]).toEqual([ + 'find-generic-password', + '-s', + 'Claude Code-credentials', + '-a', + process.env.USER || process.env.USERNAME || 'user', + '-w' + ]) + }) + + it('writes active credentials to the config-scoped Claude Code service', async () => { + const configDir = '/tmp/orca-claude-login-test' + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '', '') + return null as never + }) + + await writeActiveClaudeKeychainCredentials('credentials-json', configDir) + + expect(execFileMock.mock.calls[0][1]).toEqual([ + 'add-generic-password', + '-U', + '-s', + scopedService, + '-a', + process.env.USER || process.env.USERNAME || 'user', + '-w', + 'credentials-json' + ]) + }) + + it('writes runtime credentials to scoped and legacy services for old Claude Code compatibility', async () => { + const configDir = '/tmp/orca-claude-login-test' + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementation((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '', '') + return null as never + }) + + await writeActiveClaudeKeychainCredentialsForRuntime('credentials-json', configDir) + + expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ + [ + 'add-generic-password', + '-U', + '-s', + scopedService, + '-a', + process.env.USER || process.env.USERNAME || 'user', + '-w', + 'credentials-json' + ], + [ + 'add-generic-password', + '-U', + '-s', + 'Claude Code-credentials', + '-a', + process.env.USER || process.env.USERNAME || 'user', + '-w', + 'credentials-json' + ] + ]) + }) + + it('strictly reads only the requested active credentials service', async () => { + const configDir = '/tmp/orca-claude-login-test' + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, 'scoped\n', '') + return null as never + }) + + await expect(readActiveClaudeKeychainCredentialsStrict(configDir)).resolves.toBe('scoped') + + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock.mock.calls[0][1]).toEqual([ + 'find-generic-password', + '-s', + scopedService, + '-a', + process.env.USER || process.env.USERNAME || 'user', + '-w' + ]) + }) + + it('deletes both scoped and legacy active credentials for config-dir cleanup', async () => { + const configDir = '/tmp/orca-claude-login-test' + const scopedService = serviceForConfigDir(configDir) + execFileMock.mockImplementation((_file, _args, _options, callback) => { + invokeExecFileCallback(callback, null, '', '') + return null as never + }) + + await deleteActiveClaudeKeychainCredentials(configDir) + + expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([ + [ + 'delete-generic-password', + '-s', + scopedService, + '-a', + process.env.USER || process.env.USERNAME || 'user' + ], + [ + 'delete-generic-password', + '-s', + 'Claude Code-credentials', + '-a', + process.env.USER || process.env.USERNAME || 'user' + ] + ]) + }) +}) diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index 647bf975d27..ff9afdc66a2 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -1,22 +1,56 @@ import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' -export async function readActiveClaudeKeychainCredentials(): Promise { - return readKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser()) +export async function readActiveClaudeKeychainCredentials( + configDir?: string +): Promise { + for (const service of getActiveClaudeServices(configDir)) { + const credentials = await readKeychainPassword(service, getKeychainUser()) + if (credentials) { + return credentials + } + } + return null } -export async function writeActiveClaudeKeychainCredentials(contents: string): Promise { - await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser(), contents) +export async function readActiveClaudeKeychainCredentialsStrict( + configDir?: string +): Promise { + return readKeychainPassword(getActiveClaudeService(configDir), getKeychainUser()) } -export async function deleteActiveClaudeKeychainCredentials(): Promise { - await deleteKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser()) +export async function writeActiveClaudeKeychainCredentials( + contents: string, + configDir?: string +): Promise { + await writeKeychainPassword(getActiveClaudeService(configDir), getKeychainUser(), contents) } -export async function deleteActiveClaudeKeychainCredentialsStrict(): Promise { - await deleteKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser(), { +export async function writeActiveClaudeKeychainCredentialsForRuntime( + contents: string, + configDir: string +): Promise { + const user = getKeychainUser() + const scopedService = getActiveClaudeService(configDir) + await writeKeychainPassword(scopedService, user, contents) + if (scopedService !== ACTIVE_CLAUDE_SERVICE) { + await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, user, contents) + } +} + +export async function deleteActiveClaudeKeychainCredentials(configDir?: string): Promise { + for (const service of getActiveClaudeServices(configDir)) { + await deleteKeychainPassword(service, getKeychainUser()) + } +} + +export async function deleteActiveClaudeKeychainCredentialsStrict( + configDir?: string +): Promise { + await deleteKeychainPassword(getActiveClaudeService(configDir), getKeychainUser(), { failOnAccessError: true }) } @@ -42,6 +76,23 @@ function getKeychainUser(): string { return process.env.USER || process.env.USERNAME || 'user' } +function getActiveClaudeService(configDir?: string): string { + if (!configDir) { + return ACTIVE_CLAUDE_SERVICE + } + // Why: Claude Code 2.1+ scopes macOS Keychain credentials by config dir + // using the first 8 hex chars of sha256(CLAUDE_CONFIG_DIR). + const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8) + return `${ACTIVE_CLAUDE_SERVICE}-${suffix}` +} + +function getActiveClaudeServices(configDir?: string): string[] { + const scopedService = getActiveClaudeService(configDir) + return scopedService === ACTIVE_CLAUDE_SERVICE + ? [ACTIVE_CLAUDE_SERVICE] + : [scopedService, ACTIVE_CLAUDE_SERVICE] +} + async function readKeychainPassword(service: string, account: string): Promise { if (process.platform !== 'darwin') { return null diff --git a/src/main/claude-accounts/managed-auth-path.ts b/src/main/claude-accounts/managed-auth-path.ts new file mode 100644 index 00000000000..2914b72e033 --- /dev/null +++ b/src/main/claude-accounts/managed-auth-path.ts @@ -0,0 +1,112 @@ +import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { join, relative, resolve, sep } from 'node:path' +import { app } from 'electron' +import { writeFileAtomically } from '../codex-accounts/fs-utils' + +const MANAGED_AUTH_MARKER = '.orca-managed-claude-auth' + +export function getClaudeManagedAccountsRoot(): string { + return join(app.getPath('userData'), 'claude-accounts') +} + +export function resolveOwnedClaudeManagedAuthPath( + accountId: string, + candidatePath: string, + options: { adoptLegacyMarker?: boolean } = {} +): string | null { + const rootPath = getClaudeManagedAccountsRoot() + const resolvedCandidate = resolve(candidatePath) + if (!existsSync(resolvedCandidate) || !existsSync(rootPath)) { + return null + } + try { + if (lstatSync(resolvedCandidate).isSymbolicLink()) { + return null + } + const canonicalCandidate = realpathSync(resolvedCandidate) + const canonicalRoot = realpathSync(rootPath) + if ( + canonicalCandidate === canonicalRoot || + !canonicalCandidate.startsWith(canonicalRoot + sep) + ) { + return null + } + const relativePath = relative(canonicalRoot, canonicalCandidate) + const relativeParts = relativePath.split(sep) + const escaped = relativePath.startsWith('..') || relativePath.includes(`..${sep}`) + if ( + escaped || + relativeParts.length !== 2 || + relativeParts[0] !== accountId || + relativeParts[1] !== 'auth' + ) { + return null + } + const markerPath = join(canonicalCandidate, MANAGED_AUTH_MARKER) + const markerValid = isManagedAuthMarkerValid(markerPath, accountId) + if (!markerValid && options.adoptLegacyMarker) { + writeFileSync(markerPath, `${accountId}\n`, { encoding: 'utf-8', mode: 0o600, flag: 'wx' }) + } + if (!markerValid && !isManagedAuthMarkerValid(markerPath, accountId)) { + return null + } + return canonicalCandidate + } catch { + return null + } +} + +export function readClaudeManagedAuthFile( + managedAuthPath: string, + filename: '.credentials.json' | 'oauth-account.json' +): string | null { + const filePath = resolve(managedAuthPath, filename) + try { + if (!isOwnedChildFile(managedAuthPath, filePath)) { + return null + } + return readFileSync(filePath, 'utf-8') + } catch { + return null + } +} + +export function writeClaudeManagedAuthFile( + managedAuthPath: string, + filename: '.credentials.json' | 'oauth-account.json', + contents: string +): void { + const filePath = resolve(managedAuthPath, filename) + if (existsSync(filePath) && !isOwnedChildFile(managedAuthPath, filePath)) { + throw new Error('Managed Claude auth child file is not owned by Orca.') + } + writeFileAtomically(filePath, contents, { mode: 0o600 }) +} + +function isManagedAuthMarkerValid(markerPath: string, accountId: string): boolean { + try { + if ( + !existsSync(markerPath) || + lstatSync(markerPath).isSymbolicLink() || + !lstatSync(markerPath).isFile() + ) { + return false + } + return readFileSync(markerPath, 'utf-8').trim() === accountId + } catch { + return false + } +} + +function isOwnedChildFile(managedAuthPath: string, filePath: string): boolean { + if ( + !existsSync(filePath) || + lstatSync(filePath).isSymbolicLink() || + !lstatSync(filePath).isFile() + ) { + return false + } + const canonicalAuthPath = realpathSync(managedAuthPath) + const canonicalFilePath = realpathSync(filePath) + return canonicalFilePath.startsWith(canonicalAuthPath + sep) +} diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index 6957a58131c..7e76ddb69ab 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -3,11 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmodSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, + symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -15,13 +17,26 @@ import { join } from 'node:path' import { getDefaultSettings } from '../../shared/constants' import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types' +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const testState = { userDataDir: '', fakeHomeDir: '', activeKeychainCredentials: null as string | null, + scopedKeychainCredentials: null as string | null, + legacyKeychainCredentials: null as string | null, + throwScopedKeychainRead: false, + throwLegacyKeychainRead: false, + throwRuntimeKeychainWrite: false, + throwLegacyRuntimeKeychainWrite: false, + throwScopedKeychainWrite: false, + runtimeWriteConfigDir: null as string | null, managedKeychainCredentials: new Map() } +function expectedRuntimeConfigDir(): string { + return join(testState.fakeHomeDir, '.claude') +} + vi.mock('electron', () => ({ app: { getPath: () => testState.userDataDir @@ -37,13 +52,79 @@ vi.mock('node:os', async () => { }) vi.mock('./keychain', () => ({ - readActiveClaudeKeychainCredentials: vi.fn(async () => testState.activeKeychainCredentials), - writeActiveClaudeKeychainCredentials: vi.fn(async (contents: string) => { + readActiveClaudeKeychainCredentials: vi.fn(async (configDir?: string) => { + if (configDir) { + if (configDir !== expectedRuntimeConfigDir()) { + return testState.legacyKeychainCredentials + } + return testState.scopedKeychainCredentials ?? testState.legacyKeychainCredentials + } + return testState.legacyKeychainCredentials + }), + writeActiveClaudeKeychainCredentials: vi.fn(async (contents: string, configDir?: string) => { + if (configDir) { + if (configDir !== expectedRuntimeConfigDir()) { + throw new Error(`Unexpected Claude config dir: ${configDir}`) + } + if (testState.throwScopedKeychainWrite) { + throw new Error('scoped keychain write failed') + } + testState.scopedKeychainCredentials = contents + } else { + testState.legacyKeychainCredentials = contents + } testState.activeKeychainCredentials = contents }), deleteActiveClaudeKeychainCredentials: vi.fn(async () => { + testState.scopedKeychainCredentials = null + testState.legacyKeychainCredentials = null testState.activeKeychainCredentials = null }), + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => { + if (configDir) { + if (configDir !== expectedRuntimeConfigDir()) { + throw new Error(`Unexpected Claude config dir: ${configDir}`) + } + testState.scopedKeychainCredentials = null + } else { + testState.legacyKeychainCredentials = null + } + testState.activeKeychainCredentials = null + }), + readActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => + configDir + ? (() => { + if (testState.throwScopedKeychainRead) { + throw new Error('scoped keychain read failed') + } + return configDir === expectedRuntimeConfigDir() + ? testState.scopedKeychainCredentials + : null + })() + : (() => { + if (testState.throwLegacyKeychainRead) { + throw new Error('legacy keychain read failed') + } + return testState.legacyKeychainCredentials + })() + ), + writeActiveClaudeKeychainCredentialsForRuntime: vi.fn( + async (contents: string, configDir: string) => { + if (configDir !== expectedRuntimeConfigDir()) { + throw new Error(`Unexpected Claude config dir: ${configDir}`) + } + if (testState.throwRuntimeKeychainWrite) { + throw new Error('runtime keychain write failed') + } + testState.runtimeWriteConfigDir = configDir + testState.scopedKeychainCredentials = contents + if (testState.throwLegacyRuntimeKeychainWrite) { + throw new Error('legacy runtime keychain write failed') + } + testState.legacyKeychainCredentials = contents + testState.activeKeychainCredentials = contents + } + ), readManagedClaudeKeychainCredentials: vi.fn( async (accountId: string) => testState.managedKeychainCredentials.get(accountId) ?? null ), @@ -52,6 +133,13 @@ vi.mock('./keychain', () => ({ }) })) +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + function createSettings(overrides: Partial = {}): GlobalSettings { return { ...getDefaultSettings(testState.fakeHomeDir), @@ -84,6 +172,7 @@ function createManagedClaudeAuth( ): string { const managedAuthPath = join(rootDir, 'claude-accounts', accountId, 'auth') mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8') writeFileSync(join(managedAuthPath, '.credentials.json'), credentialsJson, 'utf-8') writeFileSync(join(managedAuthPath, 'oauth-account.json'), oauthAccountJson, 'utf-8') testState.managedKeychainCredentials.set(accountId, credentialsJson) @@ -147,11 +236,30 @@ function readManagedCredentialsForTest(accountId: string, managedAuthPath: strin return readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8') } +function readRuntimeOauthAccountForTest(): unknown { + const configPath = join(testState.fakeHomeDir, '.claude.json') + if (!existsSync(configPath)) { + return null + } + return ( + (JSON.parse(readFileSync(configPath, 'utf-8')) as Record).oauthAccount ?? null + ) +} + describe('ClaudeRuntimeAuthService', () => { beforeEach(() => { + setPlatform('darwin') vi.resetModules() vi.clearAllMocks() testState.activeKeychainCredentials = null + testState.scopedKeychainCredentials = null + testState.legacyKeychainCredentials = null + testState.throwScopedKeychainRead = false + testState.throwLegacyKeychainRead = false + testState.throwRuntimeKeychainWrite = false + testState.throwLegacyRuntimeKeychainWrite = false + testState.throwScopedKeychainWrite = false + testState.runtimeWriteConfigDir = null testState.managedKeychainCredentials.clear() testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-claude-runtime-')) testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-home-')) @@ -159,16 +267,20 @@ describe('ClaudeRuntimeAuthService', () => { }) afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } rmSync(testState.userDataDir, { recursive: true, force: true }) rmSync(testState.fakeHomeDir, { recursive: true, force: true }) }) it('rematerializes unchanged managed credentials when the runtime file is missing', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') const managedAuthPath = createManagedClaudeAuth( testState.userDataDir, 'account-1', - '{"token":"managed"}\n' + managedCredentials ) const settings = createSettings({ claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], @@ -180,21 +292,335 @@ describe('ClaudeRuntimeAuthService', () => { const service = new ClaudeRuntimeAuthService(store as never) await service.syncForCurrentSelection() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"managed"}\n') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) rmSync(runtimeCredentialsPath, { force: true }) await service.prepareForClaudeLaunch() - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"managed"}\n') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(testState.runtimeWriteConfigDir).toBe(expectedRuntimeConfigDir()) + }) + + it('restores system default instead of materializing corrupt managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', '{not-json') + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('restores system default instead of materializing wrong-shaped managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', '{}\n') + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('does not materialize managed credentials from unowned auth paths', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') + mkdirSync(unownedAuthPath, { recursive: true }) + writeFileSync(join(unownedAuthPath, '.credentials.json'), managedCredentials, 'utf-8') + writeFileSync( + join(unownedAuthPath, 'oauth-account.json'), + `${JSON.stringify({ accountUuid: 'account-1' })}\n`, + 'utf-8' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', unownedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('adopts canonical legacy managed auth paths without existing markers', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.credentials.json'), managedCredentials, 'utf-8') + writeFileSync( + join(managedAuthPath, 'oauth-account.json'), + '{"accountUuid":"account-1"}\n', + 'utf-8' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + const markerPath = join(managedAuthPath, '.orca-managed-claude-auth') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(lstatSync(markerPath).isFile()).toBe(true) + expect(readFileSync(markerPath, 'utf-8')).toBe('account-1\n') + }) + + it('rejects symlinked managed credential children', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const escapedCredentials = createClaudeCredentialsJson('user@example.com', 'escaped') + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + const escapedCredentialsPath = join(testState.fakeHomeDir, 'escaped-credentials.json') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + join(managedAuthPath, 'oauth-account.json'), + '{"accountUuid":"account-1"}\n', + 'utf-8' + ) + writeFileSync(escapedCredentialsPath, escapedCredentials, 'utf-8') + symlinkSync(escapedCredentialsPath, join(managedAuthPath, '.credentials.json')) + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + }) + + it('restores system auth when switching from an owned account to an unowned account', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const ownedCredentials = createClaudeCredentialsJson('owned@example.com', 'owned') + const unownedCredentials = createClaudeCredentialsJson('unowned@example.com', 'unowned') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + const ownedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + ownedCredentials + ) + const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') + mkdirSync(unownedAuthPath, { recursive: true }) + writeFileSync(join(unownedAuthPath, '.credentials.json'), unownedCredentials, 'utf-8') + let settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', ownedAuthPath, { email: 'owned@example.com' }), + createClaudeAccount('account-2', unownedAuthPath, { email: 'unowned@example.com' }) + ], + activeClaudeManagedAccountId: null + }) + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { + ...settings, + ...updates, + notifications: { + ...settings.notifications, + ...updates.notifications + } + } + return settings + }) + } + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(ownedCredentials) + + store.updateSettings({ activeClaudeManagedAccountId: 'account-2' }) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('restores system auth when the previously synced account is no longer in settings', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const ownedCredentials = createClaudeCredentialsJson('owned@example.com', 'owned') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + const ownedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + ownedCredentials + ) + const unownedAuthPath = join(testState.fakeHomeDir, 'unowned-claude-auth') + mkdirSync(unownedAuthPath, { recursive: true }) + let settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', ownedAuthPath, { email: 'owned@example.com' }) + ], + activeClaudeManagedAccountId: null + }) + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { + ...settings, + ...updates, + notifications: { + ...settings.notifications, + ...updates.notifications + } + } + return settings + }) + } + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(ownedCredentials) + + store.updateSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-2', unownedAuthPath, { email: 'unowned@example.com' }) + ], + activeClaudeManagedAccountId: 'account-2' + }) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('restores system auth when switching from an owned account to missing credentials', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = join(testState.userDataDir, 'claude-accounts', 'account-2', 'auth') + mkdirSync(managedAuthPath2, { recursive: true }) + writeFileSync(join(managedAuthPath2, '.orca-managed-claude-auth'), 'account-2\n', 'utf-8') + writeFileSync( + join(managedAuthPath2, 'oauth-account.json'), + '{"accountUuid":"account-2"}\n', + 'utf-8' + ) + let settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: null + }) + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { + ...settings, + ...updates, + notifications: { + ...settings.notifications, + ...updates.notifications + } + } + return settings + }) + } + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + store.updateSettings({ activeClaudeManagedAccountId: 'account-1' }) + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) + + store.updateSettings({ activeClaudeManagedAccountId: 'account-2' }) + await service.syncForCurrentSelection() + + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) }) it('removes runtime credentials when deselecting with a missing system-default snapshot', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') - writeFileSync(runtimeCredentialsPath, '{"token":"managed"}\n', 'utf-8') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') const managedAuthPath = createManagedClaudeAuth( testState.userDataDir, 'account-1', - '{"token":"managed"}\n' + managedCredentials ) const settings = createSettings({ claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], @@ -210,7 +636,8 @@ describe('ClaudeRuntimeAuthService', () => { expect(existsSync(runtimeCredentialsPath)).toBe(false) if (process.platform === 'darwin') { - expect(testState.activeKeychainCredentials).toBeNull() + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() } }) @@ -221,10 +648,12 @@ describe('ClaudeRuntimeAuthService', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const rotatedCredentials = createClaudeCredentialsJson('user@example.com', 'rotated') const managedAuthPath = createManagedClaudeAuth( testState.userDataDir, 'account-1', - '{"token":"managed"}\n' + managedCredentials ) const settings = createSettings({ claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] @@ -236,8 +665,8 @@ describe('ClaudeRuntimeAuthService', () => { settings.activeClaudeManagedAccountId = 'account-1' await service.syncForCurrentSelection() - testState.managedKeychainCredentials.set('account-1', '{"token":"rotated"}\n') - writeFileSync(join(managedAuthPath, '.credentials.json'), '{"token":"rotated"}\n', 'utf-8') + testState.managedKeychainCredentials.set('account-1', rotatedCredentials) + writeFileSync(join(managedAuthPath, '.credentials.json'), rotatedCredentials, 'utf-8') chmodSync(runtimeCredentialsPath, 0o000) try { await service.syncForCurrentSelection() @@ -248,7 +677,7 @@ describe('ClaudeRuntimeAuthService', () => { warn.mockRestore() } - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"rotated"}\n') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(rotatedCredentials) }) it('tightens credential file permissions when unchanged content is already present', async () => { @@ -257,10 +686,11 @@ describe('ClaudeRuntimeAuthService', () => { } const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') const managedAuthPath = createManagedClaudeAuth( testState.userDataDir, 'account-1', - '{"token":"managed"}\n' + managedCredentials ) const settings = createSettings({ claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] @@ -303,6 +733,37 @@ describe('ClaudeRuntimeAuthService', () => { expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) }) + it('rejects wrong-shaped refreshed credentials during read-back', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const wrongShapedRefresh = `${JSON.stringify({ + claudeAiOauth: { + email: 'user@example.com', + expiresAt: Date.now() + 120_000 + } + })}\n` + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, wrongShapedRefresh, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) + }) + it('reads back verified same-account credentials on first sync after restart', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const originalCredentials = createClaudeCredentialsJson( @@ -506,6 +967,34 @@ describe('ClaudeRuntimeAuthService', () => { expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials) }) + it('rejects no-email refreshed credentials even when organization identity matches', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original', 'org-a') + const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed', 'org-a') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-a' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials) + }) + it('rejects unverifiable refreshed runtime credentials', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const originalCredentials = createClaudeCredentialsWithoutEmail('original') @@ -596,12 +1085,14 @@ describe('ClaudeRuntimeAuthService', () => { expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) }) - it('restores system default when stale Claude credentials are rejected on deselect', async () => { + it('preserves external stale Claude credentials without writing them to managed storage', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') const selectedCredentials = createClaudeCredentialsJson('selected@example.com', 'selected') const staleCredentials = createClaudeCredentialsJson('stale@example.com', 'stale') writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials const managedAuthPath = createManagedClaudeAuth( testState.userDataDir, 'account-1', @@ -624,7 +1115,9 @@ describe('ClaudeRuntimeAuthService', () => { await service.syncForCurrentSelection() expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials) - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) }) it('does not persist unverifiable stale Claude credentials into another active account', async () => { @@ -664,6 +1157,72 @@ describe('ClaudeRuntimeAuthService', () => { expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) }) + it('materializes only the selected managed account into shared Claude runtime files', async () => { + setPlatform('linux') + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one-token') + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two-token') + const account1Oauth = '{"accountUuid":"account-1","emailAddress":"one@example.com"}\n' + const account2Oauth = '{"accountUuid":"account-2","emailAddress":"two@example.com"}\n' + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials, + account1Oauth + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials, + account2Oauth + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ + accountUuid: 'account-1', + emailAddress: 'one@example.com' + }) + + settings.activeClaudeManagedAccountId = 'account-2' + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ + accountUuid: 'account-2', + emailAddress: 'two@example.com' + }) + + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Credentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ + accountUuid: 'account-1', + emailAddress: 'one@example.com' + }) + + // Why: switching rewrites only the shared Claude runtime surface; the + // managed account files remain per-account sources of truth. + expect(readFileSync(join(managedAuthPath1, '.credentials.json'), 'utf-8')).toBe( + account1Credentials + ) + expect(readFileSync(join(managedAuthPath2, '.credentials.json'), 'utf-8')).toBe( + account2Credentials + ) + expect(readFileSync(join(managedAuthPath1, 'oauth-account.json'), 'utf-8')).toBe(account1Oauth) + expect(readFileSync(join(managedAuthPath2, 'oauth-account.json'), 'utf-8')).toBe(account2Oauth) + }) + it('does not carry the reauth read-back skip across Claude account switches', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') @@ -771,10 +1330,14 @@ describe('ClaudeRuntimeAuthService', () => { rmSync(runtimeCredentialsPath, { force: true }) testState.activeKeychainCredentials = null + testState.scopedKeychainCredentials = null + testState.legacyKeychainCredentials = null settings.activeClaudeManagedAccountId = null await service.syncForCurrentSelection() expect(existsSync(runtimeCredentialsPath)).toBe(false) + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() }) it('reads back refreshed active keychain credentials on macOS', async () => { @@ -800,13 +1363,121 @@ describe('ClaudeRuntimeAuthService', () => { const service = new ClaudeRuntimeAuthService(store as never) await service.syncForCurrentSelection() - testState.activeKeychainCredentials = refreshedCredentials + testState.scopedKeychainCredentials = refreshedCredentials await service.syncForCurrentSelection() expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) }) + it('reads back refreshed legacy keychain credentials on old Claude Code builds', async () => { + if (process.platform !== 'darwin') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + testState.scopedKeychainCredentials = originalCredentials + testState.legacyKeychainCredentials = refreshedCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + expect(testState.scopedKeychainCredentials).toBe(refreshedCredentials) + expect(testState.legacyKeychainCredentials).toBe(refreshedCredentials) + }) + + it('rejects stale legacy keychain credentials after a fresher managed write', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) + const managedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'managed-newer', + null, + 2_000 + ) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + testState.scopedKeychainCredentials = managedCredentials + testState.legacyKeychainCredentials = staleCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + expect(testState.legacyKeychainCredentials).toBe(managedCredentials) + }) + + it('uses fresher file credentials when scoped keychain is stale', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) + const managedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'managed', + null, + 2_000 + ) + const refreshedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'refreshed', + null, + 3_000 + ) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + testState.scopedKeychainCredentials = staleCredentials + testState.legacyKeychainCredentials = managedCredentials + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + expect(testState.scopedKeychainCredentials).toBe(refreshedCredentials) + expect(testState.legacyKeychainCredentials).toBe(refreshedCredentials) + }) + it('restores system default when mismatched Claude keychain auth appears before deselect', async () => { if (process.platform !== 'darwin') { return @@ -820,7 +1491,8 @@ describe('ClaudeRuntimeAuthService', () => { 'external' ) writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') - testState.activeKeychainCredentials = systemCredentials + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials const managedAuthPath = createManagedClaudeAuth( testState.userDataDir, 'account-1', @@ -836,12 +1508,1044 @@ describe('ClaudeRuntimeAuthService', () => { settings.activeClaudeManagedAccountId = 'account-1' await service.syncForCurrentSelection() - testState.activeKeychainCredentials = externalKeychainCredentials + testState.scopedKeychainCredentials = externalKeychainCredentials + testState.legacyKeychainCredentials = externalKeychainCredentials settings.activeClaudeManagedAccountId = null await service.syncForCurrentSelection() expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) - expect(testState.activeKeychainCredentials).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(externalKeychainCredentials) + expect(testState.legacyKeychainCredentials).toBe(externalKeychainCredentials) + }) + + it('restores unchanged scoped keychain while preserving external legacy keychain logout', async () => { + if (process.platform !== 'darwin') { + return + } + + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + testState.legacyKeychainCredentials = null + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBeNull() + }) + + it('preserves external scoped keychain login while restoring unchanged legacy keychain', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalScopedCredentials = createClaudeCredentialsJson( + 'external@example.com', + 'external' + ) + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const { readActiveClaudeKeychainCredentials } = await import('./keychain') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(expectedRuntimeConfigDir()) + + testState.scopedKeychainCredentials = externalScopedCredentials + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(externalScopedCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('restores oauth metadata when credentials prove managed ownership', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + const externalOauthAccount = { accountUuid: 'external-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: externalOauthAccount })}\n`, + 'utf-8' + ) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('restores owned oauth metadata when external credentials change but metadata does not', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('restores owned oauth metadata when only keychain proves managed ownership', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + 'null\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('uses managed credentials as ownership baseline after restart with partial external changes', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalScopedCredentials = createClaudeCredentialsJson( + 'external@example.com', + 'external' + ) + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: null, + keychainCredentialsJson: systemCredentials, + scopedKeychainCredentialsJson: systemCredentials, + legacyKeychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + testState.scopedKeychainCredentials = externalScopedCredentials + testState.legacyKeychainCredentials = managedCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(externalScopedCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('preserves external file and scoped login while restoring unchanged legacy keychain', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + testState.scopedKeychainCredentials = externalCredentials + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) + expect(testState.scopedKeychainCredentials).toBe(externalCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('restores legacy keychain credentials from old system-default snapshots', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: null, + keychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + testState.scopedKeychainCredentials = managedCredentials + testState.legacyKeychainCredentials = managedCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('does not recapture managed file as system default after a partial keychain write failure', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + testState.throwRuntimeKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow('runtime keychain write failed') + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + testState.throwRuntimeKeychainWrite = false + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('restores scoped keychain after legacy runtime keychain write fails', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + testState.throwLegacyRuntimeKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'legacy runtime keychain write failed' + ) + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('keeps managed ownership baseline when keychain restore fails and retries', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + testState.throwScopedKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain write failed') + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + testState.throwScopedKeychainWrite = false + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('preserves previous keychain snapshot after restart following partial restore failure', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + let service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + testState.throwScopedKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain write failed') + + testState.throwScopedKeychainWrite = false + service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + }) + + it('does not enter managed mode when keychain snapshot capture fails', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + testState.throwScopedKeychainRead = true + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'Cannot capture current Claude Keychain credentials' + ) + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials) + warn.mockRestore() + }) + + it('treats corrupt system-default snapshots as missing and clears owned runtime auth', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync(snapshotPath, '{not-json', 'utf-8') + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = managedCredentials + testState.legacyKeychainCredentials = managedCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(snapshotPath)).toBe(false) + expect(existsSync(runtimeCredentialsPath)).toBe(false) + expect(readRuntimeOauthAccountForTest()).toBeNull() + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + warn.mockRestore() + }) + + it('treats wrong-shaped system-default snapshots as missing and clears owned runtime auth', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: { token: 'system' }, + keychainCredentialsJson: managedCredentials, + scopedKeychainCredentialsJson: { token: 'scoped' }, + legacyKeychainCredentialsJson: managedCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = managedCredentials + testState.legacyKeychainCredentials = managedCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(snapshotPath)).toBe(false) + expect(existsSync(runtimeCredentialsPath)).toBe(false) + expect(readRuntimeOauthAccountForTest()).toBeNull() + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + warn.mockRestore() + }) + + it('treats snapshots missing all keychain credential fields as invalid', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: null, + configOauthAccount: null, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + testState.scopedKeychainCredentials = managedCredentials + testState.legacyKeychainCredentials = managedCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(snapshotPath)).toBe(false) + expect(existsSync(runtimeCredentialsPath)).toBe(false) + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + warn.mockRestore() + }) + + it('treats snapshots missing credentialsJson as invalid and clears missing-managed runtime auth', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + configOauthAccount: null, + keychainCredentialsJson: null, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.prepareForClaudeLaunch() + + expect(existsSync(snapshotPath)).toBe(true) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + warn.mockRestore() + }) + + it('clears missing-managed oauth metadata when only keychain proves ownership', async () => { + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.prepareForClaudeLaunch() + + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) + + it('preserves missing-managed oauth metadata without credential ownership proof', async () => { + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + const managedOauthAccount = { accountUuid: 'account-1' } + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: managedOauthAccount })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = externalCredentials + testState.legacyKeychainCredentials = externalCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.prepareForClaudeLaunch() + + expect(readRuntimeOauthAccountForTest()).toEqual(managedOauthAccount) + expect(testState.scopedKeychainCredentials).toBe(externalCredentials) + expect(testState.legacyKeychainCredentials).toBe(externalCredentials) + }) + + it('preserves invalid external runtime oauth metadata when deselecting', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync(runtimeConfigPath, `${JSON.stringify({})}\n`, 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + 'null\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') + }) + + it('preserves invalid runtime config while materializing managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + expect(testState.legacyKeychainCredentials).toBe(managedCredentials) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') + + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('{not-json') + }) + + it('preserves non-object runtime config while materializing managed credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync(runtimeConfigPath, '[]', 'utf-8') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('[]') + }) + + it('does not use skipped oauth writes as ownership proof on deselect', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const externalCredentials = createClaudeCredentialsJson('external@example.com', 'external') + const managedOauthAccount = { accountUuid: 'account-1' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync(runtimeConfigPath, '{not-json', 'utf-8') + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + `${JSON.stringify(managedOauthAccount)}\n` + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: managedOauthAccount })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = externalCredentials + testState.legacyKeychainCredentials = externalCredentials + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(externalCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(managedOauthAccount) + expect(testState.scopedKeychainCredentials).toBe(externalCredentials) + expect(testState.legacyKeychainCredentials).toBe(externalCredentials) + }) + + it('preserves external oauth logout when managed oauth metadata is null', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'system-account' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials + testState.legacyKeychainCredentials = systemCredentials + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + 'null\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + rmSync(runtimeCredentialsPath, { force: true }) + testState.scopedKeychainCredentials = null + testState.legacyKeychainCredentials = null + writeFileSync(runtimeConfigPath, `${JSON.stringify({})}\n`, 'utf-8') + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(existsSync(runtimeCredentialsPath)).toBe(false) + expect(readRuntimeOauthAccountForTest()).toBeNull() + expect(testState.scopedKeychainCredentials).toBeNull() + expect(testState.legacyKeychainCredentials).toBeNull() + }) + + it('restores reordered owned oauth metadata using stable json equality', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account', emailAddress: 'system@example.com' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + '{"accountUuid":"account-1","emailAddress":"user@example.com"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + writeFileSync( + runtimeConfigPath, + '{"oauthAccount":{"emailAddress":"user@example.com","accountUuid":"account-1"}}\n', + 'utf-8' + ) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('restores owned oauth metadata during rollback after removing the added account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount })}\n`, + 'utf-8' + ) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials, + '{"accountUuid":"account-1"}\n' + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + + settings.activeClaudeManagedAccountId = null + settings.claudeManagedAccounts = [] + await service.forceMaterializeCurrentSelectionForRollback() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount) + }) + + it('reads back refreshed file credentials when keychain reads fail', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') + const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + originalCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') + testState.throwScopedKeychainRead = true + testState.throwLegacyKeychainRead = true + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + warn.mockRestore() }) it('captures a fresh system-default snapshot when re-entering managed mode', async () => { @@ -877,6 +2581,58 @@ describe('ClaudeRuntimeAuthService', () => { expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials2) }) + it('refreshes keychain and oauth snapshot surfaces when the credentials file is unchanged', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1') + const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2') + const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') + const systemOauthAccount1 = { accountUuid: 'system-account-1' } + const systemOauthAccount2 = { accountUuid: 'system-account-2' } + writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount1 })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials1 + testState.legacyKeychainCredentials = systemCredentials1 + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)] + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, managedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: systemOauthAccount2 })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = systemCredentials2 + testState.legacyKeychainCredentials = systemCredentials2 + settings.activeClaudeManagedAccountId = 'account-1' + await service.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials1) + expect(readRuntimeOauthAccountForTest()).toEqual(systemOauthAccount2) + expect(testState.scopedKeychainCredentials).toBe(systemCredentials2) + expect(testState.legacyKeychainCredentials).toBe(systemCredentials2) + }) + it('reads back refreshed credentials for the outgoing Claude account before switching', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original') @@ -945,17 +2701,62 @@ describe('ClaudeRuntimeAuthService', () => { // Orca selected account-2. Persist that refresh to account-1, then restore // the selected account in the shared Claude runtime credentials. writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8') - testState.activeKeychainCredentials = account1Refreshed + testState.scopedKeychainCredentials = account1Refreshed await service.syncForCurrentSelection() expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed) expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) if (process.platform === 'darwin') { - expect(testState.activeKeychainCredentials).toBe(account2Credentials) + expect(testState.scopedKeychainCredentials).toBe(account2Credentials) + expect(testState.legacyKeychainCredentials).toBe(account2Credentials) } }) + it('rejects stale cold-start read-back for inactive matching account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1ManagedNewer = createClaudeCredentialsJson( + 'one@example.com', + 'one-managed-newer', + null, + 5_000 + ) + const account1RuntimeStale = createClaudeCredentialsJson( + 'one@example.com', + 'one-runtime-stale', + null, + 2_000 + ) + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', null, 1_000) + writeFileSync(runtimeCredentialsPath, account1RuntimeStale, 'utf-8') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1ManagedNewer + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' }) + ], + activeClaudeManagedAccountId: 'account-2' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1ManagedNewer) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials) + }) + it('rejects ambiguous Claude read-back instead of choosing a managed account', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const originalCredentials = createClaudeCredentialsJson('same@example.com', 'same-original') @@ -991,18 +2792,176 @@ describe('ClaudeRuntimeAuthService', () => { await service.syncForCurrentSelection() writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') - testState.activeKeychainCredentials = refreshedCredentials + testState.scopedKeychainCredentials = refreshedCredentials await service.syncForCurrentSelection() expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(originalCredentials) expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(originalCredentials) expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(activeCredentials) if (process.platform === 'darwin') { - expect(testState.activeKeychainCredentials).toBe(activeCredentials) + expect(testState.scopedKeychainCredentials).toBe(activeCredentials) + expect(testState.legacyKeychainCredentials).toBe(activeCredentials) } }) - it('clears an invalid active Claude account before launch preparation', async () => { + it('rejects same-email read-back when another account needs organization proof', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const noOrgCredentials = createClaudeCredentialsJson('same@example.com', 'no-org') + const orgCredentials = createClaudeCredentialsJson('same@example.com', 'org', 'org-b') + const refreshedWithoutOrg = createClaudeCredentialsJson('same@example.com', 'refreshed') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + noOrgCredentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + orgCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { + email: 'same@example.com', + organizationUuid: 'org-b' + }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, refreshedWithoutOrg, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(noOrgCredentials) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(orgCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(noOrgCredentials) + }) + + it('ignores unrelated org-scoped accounts when reading back no-org credentials', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one') + const account1RefreshedCredentials = createClaudeCredentialsJson( + 'one@example.com', + 'one-refreshed' + ) + const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two', 'org-b') + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + account1Credentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + account2Credentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { + email: 'two@example.com', + organizationUuid: 'org-b' + }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe( + account1RefreshedCredentials + ) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials) + }) + + it('rejects same-email read-back with conflicting organization for no-org accounts', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const noOrgCredentials = createClaudeCredentialsJson('same@example.com', 'no-org') + const orgCredentials = createClaudeCredentialsJson('same@example.com', 'org', 'org-b') + const conflictingOrgCredentials = createClaudeCredentialsJson( + 'same@example.com', + 'conflicting-org', + 'org-c' + ) + const managedAuthPath1 = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + noOrgCredentials + ) + const managedAuthPath2 = createManagedClaudeAuth( + testState.userDataDir, + 'account-2', + orgCredentials + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }), + createClaudeAccount('account-2', managedAuthPath2, { + email: 'same@example.com', + organizationUuid: 'org-b' + }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + + writeFileSync(runtimeCredentialsPath, conflictingOrgCredentials, 'utf-8') + await service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(noOrgCredentials) + expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(orgCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(noOrgCredentials) + }) + + it('preserves unknown runtime auth when invalid active account has no ownership proof', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: systemOauthAccount, + keychainCredentialsJson: systemCredentials, + scopedKeychainCredentialsJson: systemCredentials, + legacyKeychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'missing-account' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials const settings = createSettings({ activeClaudeManagedAccountId: 'missing-account' }) @@ -1013,8 +2972,208 @@ describe('ClaudeRuntimeAuthService', () => { const preparation = await service.prepareForClaudeLaunch() expect(store.updateSettings).toHaveBeenCalledWith({ activeClaudeManagedAccountId: null }) + expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) expect(preparation.stripAuthEnv).toBe(false) expect(preparation.provenance).toBe('system') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'missing-account' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) + + it('restores system snapshot when active account credentials are missing but runtime matches account', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: systemOauthAccount, + keychainCredentialsJson: systemCredentials, + scopedKeychainCredentialsJson: systemCredentials, + legacyKeychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation.provenance).toBe('system') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) + + it('keeps missing-managed selection until cleanup can retry after keychain failure', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: systemOauthAccount, + keychainCredentialsJson: systemCredentials, + scopedKeychainCredentialsJson: systemCredentials, + legacyKeychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + testState.throwScopedKeychainWrite = true + const failedPreparation = await service.prepareForClaudeLaunch() + + expect(failedPreparation.provenance).toBe('system') + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + + testState.throwScopedKeychainWrite = false + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation.provenance).toBe('system') + expect(store.getSettings().activeClaudeManagedAccountId).toBeNull() + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) + + it('restores missing-managed oauth metadata when only keychain proves ownership', async () => { + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const snapshotPath = join( + testState.userDataDir, + 'claude-runtime-auth', + 'system-default-auth.json' + ) + const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + const systemOauthAccount = { accountUuid: 'system-account' } + const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(join(testState.userDataDir, 'claude-runtime-auth'), { recursive: true }) + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + snapshotPath, + `${JSON.stringify({ + credentialsJson: systemCredentials, + configOauthAccount: systemOauthAccount, + keychainCredentialsJson: systemCredentials, + scopedKeychainCredentialsJson: systemCredentials, + legacyKeychainCredentialsJson: systemCredentials, + capturedAt: Date.now() + })}\n`, + 'utf-8' + ) + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'account-1' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { email: 'managed@example.com' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + await service.prepareForClaudeLaunch() + + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'account-1' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) + }) + + it('preserves unknown runtime auth when invalid active account has no system snapshot', async () => { + const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const runtimeConfigPath = join(testState.fakeHomeDir, '.claude.json') + const staleManagedCredentials = createClaudeCredentialsJson('managed@example.com', 'managed') + writeFileSync(runtimeCredentialsPath, staleManagedCredentials, 'utf-8') + writeFileSync( + runtimeConfigPath, + `${JSON.stringify({ oauthAccount: { accountUuid: 'missing-account' } })}\n`, + 'utf-8' + ) + testState.scopedKeychainCredentials = staleManagedCredentials + testState.legacyKeychainCredentials = staleManagedCredentials + const settings = createSettings({ + activeClaudeManagedAccountId: 'missing-account' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation.provenance).toBe('system') + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(staleManagedCredentials) + expect(readRuntimeOauthAccountForTest()).toEqual({ accountUuid: 'missing-account' }) + expect(testState.scopedKeychainCredentials).toBe(staleManagedCredentials) + expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) }) it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => { diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 31c7e3104ce..75f6f5fa838 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -8,16 +8,24 @@ import type { ClaudeManagedAccount } from '../../shared/types' import type { Store } from '../persistence' import { writeFileAtomically } from '../codex-accounts/fs-utils' import type { ClaudeEnvPatch } from './environment' +import { + readClaudeManagedAuthFile, + resolveOwnedClaudeManagedAuthPath, + writeClaudeManagedAuthFile +} from './managed-auth-path' import { ClaudeRuntimePathResolver } from './runtime-paths' import { - deleteActiveClaudeKeychainCredentials, + deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict, readManagedClaudeKeychainCredentials, writeActiveClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentialsForRuntime, writeManagedClaudeKeychainCredentials } from './keychain' export type ClaudeRuntimeAuthPreparation = { + configDir: string envPatch: ClaudeEnvPatch stripAuthEnv: boolean provenance: string @@ -27,6 +35,10 @@ type ClaudeSystemDefaultSnapshot = { credentialsJson: string | null configOauthAccount: unknown keychainCredentialsJson: string | null + scopedKeychainCredentialsJson?: string | null + legacyKeychainCredentialsJson?: string | null + scopedKeychainCredentialsCaptured?: boolean + legacyKeychainCredentialsCaptured?: boolean capturedAt: number } @@ -37,8 +49,16 @@ type ClaudeAuthIdentity = { type ClaudeReadBackResult = { status: 'unchanged' | 'persisted' | 'rejected' } type ClaudeReadBackMatch = - | { kind: 'matched'; account: ClaudeManagedAccount } + | { kind: 'matched'; account: ClaudeManagedAccount; managedCredentialsJson: string } | { kind: 'none' | 'ambiguous' } +type ClaudeKeychainReadResult = + | { status: 'captured'; credentialsJson: string | null } + | { status: 'failed' } +type ClaudeKeychainSnapshotValue = + | { status: 'captured'; credentialsJson: string | null } + | { status: 'unknown' } + +const RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR = Symbol('runtime-oauth-account-parse-error') export class ClaudeRuntimeAuthService { private readonly pathResolver = new ClaudeRuntimePathResolver() @@ -49,6 +69,9 @@ export class ClaudeRuntimeAuthService { // an external login (e.g. `claude auth login`) overwrote it — so Orca adopts // the file as the new system default instead of restoring a stale snapshot. private lastWrittenCredentialsJson: string | null = null + private hasMaterializedRuntimeAuth = false + private hasLastWrittenOauthAccount = false + private lastWrittenOauthAccount: unknown = null private skipNextReadBackForAccountId: string | null = null constructor(private readonly store: Store) { @@ -74,7 +97,14 @@ export class ClaudeRuntimeAuthService { await this.serializeMutation(async () => { const settings = this.store.getSettings() if (!settings.activeClaudeManagedAccountId) { - await this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) + const previousAccount = this.getActiveAccount( + settings.claudeManagedAccounts, + this.lastSyncedAccountId + ) + await this.restoreSystemDefaultSnapshot( + previousAccount ? await this.readManagedCredentials(previousAccount) : null, + previousAccount ? this.readManagedOauthAccount(previousAccount) : undefined + ) this.lastSyncedAccountId = null return } @@ -115,40 +145,93 @@ export class ClaudeRuntimeAuthService { settings.claudeManagedAccounts, this.lastSyncedAccountId ) - let outgoingReadBackResult: ClaudeReadBackResult = { status: 'unchanged' } + const previousManagedCredentialsJson = previousAccount + ? await this.readManagedCredentials(previousAccount) + : null + const previousManagedOauthAccount = previousAccount + ? this.readManagedOauthAccount(previousAccount) + : null if (previousAccount && previousAccount.id !== activeAccount?.id) { - outgoingReadBackResult = await this.readBackRefreshedTokensForAccount(previousAccount, { - updateLastWrittenCredentialsJson: true - }) + if (previousManagedCredentialsJson) { + await this.readBackRefreshedTokens(previousManagedCredentialsJson, { + updateLastWrittenCredentialsJson: true + }) + } } if (!activeAccount) { if (settings.activeClaudeManagedAccountId) { this.store.updateSettings({ activeClaudeManagedAccountId: null }) } if (this.lastSyncedAccountId !== null) { - await this.restoreSystemDefaultSnapshot({ - detectExternalLogin: outgoingReadBackResult.status !== 'rejected' - }) + await (previousAccount + ? this.restoreSystemDefaultSnapshot( + previousManagedCredentialsJson, + previousManagedOauthAccount + ) + : this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined)) this.lastSyncedAccountId = null } return } + if (!this.getOwnedManagedAuthPath(activeAccount)) { + console.warn( + '[claude-runtime-auth] Active managed account is not owned by Orca, restoring system default' + ) + if (this.lastSyncedAccountId !== null) { + if ( + previousAccount && + (previousAccount.id !== activeAccount.id || + this.hasMaterializedRuntimeAuth || + this.runtimeOauthAccountMatches(this.readManagedOauthAccount(previousAccount))) + ) { + await this.restoreSystemDefaultSnapshotForMissingManagedCredentials( + previousAccount, + previousManagedOauthAccount + ) + } else if (!previousAccount && this.hasMaterializedRuntimeAuth) { + await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined) + } + } + this.store.updateSettings({ activeClaudeManagedAccountId: null }) + this.lastSyncedAccountId = null + return + } + let credentialsJson = await this.readManagedCredentials(activeAccount) - if (!credentialsJson) { + if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { console.warn( - '[claude-runtime-auth] Active managed account is missing credentials, restoring system default' + '[claude-runtime-auth] Active managed account is missing or has invalid credentials, restoring system default' ) - this.store.updateSettings({ activeClaudeManagedAccountId: null }) if (this.lastSyncedAccountId !== null) { - await this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) - this.lastSyncedAccountId = null + if ( + previousAccount && + (previousAccount.id !== activeAccount.id || + this.hasMaterializedRuntimeAuth || + this.runtimeOauthAccountMatches(previousManagedOauthAccount)) + ) { + await this.restoreSystemDefaultSnapshotForMissingManagedCredentials( + previousAccount, + previousManagedOauthAccount + ) + } else if (!previousAccount && this.hasMaterializedRuntimeAuth) { + await this.restoreSystemDefaultSnapshot(this.lastWrittenCredentialsJson, undefined) + } } + this.store.updateSettings({ activeClaudeManagedAccountId: null }) + this.lastSyncedAccountId = null return } if (this.lastSyncedAccountId === null) { - await this.captureSystemDefaultSnapshot({ force: true }) + const paths = this.pathResolver.getRuntimePaths() + const runtimeCredentialsJson = existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + await this.captureSystemDefaultSnapshotForManagedEntry( + runtimeCredentialsJson, + credentialsJson + ) } // Why: Claude CLI refreshes expired OAuth tokens and writes them back to @@ -163,7 +246,10 @@ export class ClaudeRuntimeAuthService { updateLastWrittenCredentialsJson: true }) if (readBackResult.status === 'persisted') { - credentialsJson = (await this.readManagedCredentials(activeAccount)) ?? credentialsJson + const updatedCredentialsJson = await this.readManagedCredentials(activeAccount) + if (updatedCredentialsJson && this.isValidCredentialsJsonObject(updatedCredentialsJson)) { + credentialsJson = updatedCredentialsJson + } } } } @@ -171,12 +257,31 @@ export class ClaudeRuntimeAuthService { if (this.lastSyncedAccountId !== activeAccount.id) { this.skipNextReadBackForAccountId = null } + const paths = this.pathResolver.getRuntimePaths() this.writeRuntimeCredentials(credentialsJson) if (process.platform === 'darwin') { - await writeActiveClaudeKeychainCredentials(credentialsJson) + // Why: Claude Code 2.1+ reads the scoped service, while older builds read + // the legacy unsuffixed service. Runtime switching must satisfy both. + try { + await writeActiveClaudeKeychainCredentialsForRuntime(credentialsJson, paths.configDir) + } catch (error) { + await this.restoreSystemDefaultSnapshot( + credentialsJson, + this.readManagedOauthAccount(activeAccount) + ) + throw error + } + } + const managedOauthAccount = this.readManagedOauthAccount(activeAccount) + if (this.writeRuntimeOauthAccount(managedOauthAccount)) { + this.lastWrittenOauthAccount = managedOauthAccount + this.hasLastWrittenOauthAccount = true + } else { + this.lastWrittenOauthAccount = null + this.hasLastWrittenOauthAccount = false } - this.writeRuntimeOauthAccount(this.readManagedOauthAccount(activeAccount)) this.lastSyncedAccountId = activeAccount.id + this.hasMaterializedRuntimeAuth = true } // Why: called by ClaudeAccountService before syncForCurrentSelection() after @@ -197,40 +302,64 @@ export class ClaudeRuntimeAuthService { options: { updateLastWrittenCredentialsJson: boolean } ): Promise { try { - const runtimeContents = await this.readRuntimeCredentialsForReadBack(baselineCredentialsJson) - if (!runtimeContents) { + const candidates = + await this.readRuntimeCredentialCandidatesForReadBack(baselineCredentialsJson) + if (candidates.length === 0) { return { status: 'unchanged' } } - if ( - this.lastWrittenCredentialsJson !== null && - runtimeContents === this.lastWrittenCredentialsJson - ) { + const changedCandidates = + this.lastWrittenCredentialsJson === null + ? candidates + : candidates.filter((candidate) => candidate !== this.lastWrittenCredentialsJson) + if (changedCandidates.length === 0) { return { status: 'unchanged' } } - const match = await this.findManagedAccountForRuntimeCredentials(runtimeContents) - if (match.kind !== 'matched') { + const acceptedCandidates: { + credentialsJson: string + match: Extract + }[] = [] + let sawAmbiguousCandidate = false + for (const runtimeContents of changedCandidates) { + if (!this.isValidCredentialsJsonObject(runtimeContents)) { + continue + } + const match = await this.findManagedAccountForRuntimeCredentials(runtimeContents) if (match.kind === 'ambiguous') { + sawAmbiguousCandidate = true + continue + } + if (match.kind !== 'matched') { + continue + } + // Why: on cold app start we cannot tell whether matching runtime + // credentials are a fresh CLI refresh or stale state unless token + // metadata proves runtime is newer than managed storage. + if (this.lastWrittenCredentialsJson === null) { + if (!this.runtimeCredentialsAreFresher(runtimeContents, match.managedCredentialsJson)) { + continue + } + } else if (this.runtimeCredentialsAreOlder(runtimeContents, match.managedCredentialsJson)) { + continue + } + acceptedCandidates.push({ credentialsJson: runtimeContents, match }) + } + if (acceptedCandidates.length === 0) { + if (sawAmbiguousCandidate) { console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back') } return { status: 'rejected' } } - // Why: on cold app start we cannot tell whether matching runtime - // credentials are a fresh CLI refresh or stale state unless token - // metadata proves runtime is newer than managed storage. - if ( - this.lastWrittenCredentialsJson === null && - !this.runtimeCredentialsAreFresher(runtimeContents, baselineCredentialsJson) - ) { - return { status: 'rejected' } - } + const { credentialsJson: runtimeContents, match } = + this.chooseFreshestReadBackCandidate(acceptedCandidates) await this.writeManagedCredentials(match.account, runtimeContents) if (options.updateLastWrittenCredentialsJson) { this.writeRuntimeCredentials(runtimeContents) this.lastWrittenCredentialsJson = runtimeContents if (process.platform === 'darwin') { - await writeActiveClaudeKeychainCredentials(runtimeContents) + const paths = this.pathResolver.getRuntimePaths() + await writeActiveClaudeKeychainCredentialsForRuntime(runtimeContents, paths.configDir) } } return { status: 'persisted' } @@ -243,43 +372,35 @@ export class ClaudeRuntimeAuthService { } } - private async readRuntimeCredentialsForReadBack( + private async readRuntimeCredentialCandidatesForReadBack( baselineCredentialsJson: string - ): Promise { + ): Promise { const paths = this.pathResolver.getRuntimePaths() const fileCredentials = existsSync(paths.credentialsPath) ? readFileSync(paths.credentialsPath, 'utf-8') : null + const candidates: string[] = [] + const pushCandidate = (credentialsJson: string | null): void => { + if (credentialsJson && !candidates.includes(credentialsJson)) { + candidates.push(credentialsJson) + } + } if (process.platform === 'darwin') { - const keychainCredentials = await readActiveClaudeKeychainCredentials() + const scopedKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort( + paths.configDir + ) + const legacyKeychainCredentials = await this.readActiveClaudeKeychainCredentialsBestEffort() if (this.lastWrittenCredentialsJson === null) { - if (keychainCredentials && keychainCredentials !== baselineCredentialsJson) { - return keychainCredentials - } - if (fileCredentials && fileCredentials !== baselineCredentialsJson) { - return fileCredentials - } - return keychainCredentials ?? fileCredentials - } - if (keychainCredentials && keychainCredentials !== this.lastWrittenCredentialsJson) { - return keychainCredentials + pushCandidate(scopedKeychainCredentials) + pushCandidate(legacyKeychainCredentials) + pushCandidate(fileCredentials) + return candidates.filter((candidate) => candidate !== baselineCredentialsJson) } + pushCandidate(scopedKeychainCredentials) + pushCandidate(legacyKeychainCredentials) } - if (!fileCredentials) { - return null - } - return fileCredentials - } - - private async readBackRefreshedTokensForAccount( - account: ClaudeManagedAccount, - options: { updateLastWrittenCredentialsJson: boolean } - ): Promise { - const managedCredentialsJson = await this.readManagedCredentials(account) - if (!managedCredentialsJson) { - return { status: 'unchanged' } - } - return this.readBackRefreshedTokens(managedCredentialsJson, options) + pushCandidate(fileCredentials) + return candidates } private getPreparation(): ClaudeRuntimeAuthPreparation { @@ -287,6 +408,7 @@ export class ClaudeRuntimeAuthService { const paths = this.pathResolver.getRuntimePaths() const activeAccountId = settings.activeClaudeManagedAccountId return { + configDir: paths.configDir, envPatch: paths.envPatch, stripAuthEnv: Boolean(activeAccountId), provenance: activeAccountId ? `managed:${activeAccountId}` : 'system' @@ -306,28 +428,30 @@ export class ClaudeRuntimeAuthService { private async findManagedAccountForRuntimeCredentials( runtimeCredentialsJson: string ): Promise { - const matches: ClaudeManagedAccount[] = [] + const matches: { account: ClaudeManagedAccount; managedCredentialsJson: string }[] = [] + let unverifiableCount = 0 for (const account of this.store.getSettings().claudeManagedAccounts) { const managedCredentialsJson = await this.readManagedCredentials(account) if (!managedCredentialsJson) { continue } - if ( - this.runtimeCredentialsMatchAccount( - runtimeCredentialsJson, - account, - managedCredentialsJson, - this.readManagedOauthAccount(account) - ) === 'match' - ) { - matches.push(account) + const match = this.runtimeCredentialsMatchAccount( + runtimeCredentialsJson, + account, + managedCredentialsJson, + this.readManagedOauthAccount(account) + ) + if (match === 'match') { + matches.push({ account, managedCredentialsJson }) + } else if (match === 'unverifiable') { + unverifiableCount += 1 } } - if (matches.length === 1) { - return { kind: 'matched', account: matches[0] } + if (matches.length === 1 && unverifiableCount === 0) { + return { kind: 'matched', ...matches[0] } } - return { kind: matches.length === 0 ? 'none' : 'ambiguous' } + return { kind: matches.length === 0 && unverifiableCount === 0 ? 'none' : 'ambiguous' } } private runtimeCredentialsMatchAccount( @@ -351,6 +475,12 @@ export class ClaudeRuntimeAuthService { managedIdentity?.organizationUuid ?? managedOauthIdentity.organizationUuid ) + if (!identity.email) { + return 'unverifiable' + } + if (account.email && this.normalizeField(account.email) !== identity.email) { + return 'mismatch' + } if (selectedOrganizationUuid && !identity.organizationUuid) { return 'unverifiable' } @@ -361,12 +491,9 @@ export class ClaudeRuntimeAuthService { ) { return 'mismatch' } - if (!identity.email) { + if (!selectedOrganizationUuid && identity.organizationUuid) { return 'unverifiable' } - if (account.email && identity.email && this.normalizeField(account.email) !== identity.email) { - return 'mismatch' - } return 'match' } @@ -387,6 +514,16 @@ export class ClaudeRuntimeAuthService { } } + private isValidCredentialsJsonObject(credentialsJson: string): boolean { + try { + const parsed = this.asRecord(JSON.parse(credentialsJson)) + const oauth = this.asRecord(parsed?.claudeAiOauth) + return this.normalizeField(this.readString(oauth, 'accessToken')) !== null + } catch { + return false + } + } + private runtimeCredentialsAreFresher( runtimeCredentialsJson: string, managedCredentialsJson: string @@ -398,6 +535,39 @@ export class ClaudeRuntimeAuthService { ) } + private runtimeCredentialsAreOlder( + runtimeCredentialsJson: string, + managedCredentialsJson: string + ): boolean { + const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson) + const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson) + return ( + runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness < managedFreshness + ) + } + + private chooseFreshestReadBackCandidate( + candidates: { + credentialsJson: string + match: Extract + }[] + ): { + credentialsJson: string + match: Extract + } { + return candidates.reduce((freshest, candidate) => { + const candidateFreshness = this.readFreshnessFromCredentials(candidate.credentialsJson) + const freshestFreshness = this.readFreshnessFromCredentials(freshest.credentialsJson) + if ( + candidateFreshness !== null && + (freshestFreshness === null || candidateFreshness > freshestFreshness) + ) { + return candidate + } + return freshest + }) + } + private readFreshnessFromCredentials(credentialsJson: string): number | null { let parsed: Record try { @@ -459,136 +629,462 @@ export class ClaudeRuntimeAuthService { } private async readManagedCredentials(account: ClaudeManagedAccount): Promise { + const managedAuthPath = this.getOwnedManagedAuthPath(account) + if (!managedAuthPath) { + return null + } if (process.platform === 'darwin') { return readManagedClaudeKeychainCredentials(account.id) } - const credentialsPath = join(account.managedAuthPath, '.credentials.json') - if (!existsSync(credentialsPath)) { - return null - } - return readFileSync(credentialsPath, 'utf-8') + return readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') } private async writeManagedCredentials( account: ClaudeManagedAccount, credentialsJson: string ): Promise { + const managedAuthPath = this.getOwnedManagedAuthPath(account) + if (!managedAuthPath) { + throw new Error('Managed Claude auth storage is not owned by Orca.') + } if (process.platform === 'darwin') { await writeManagedClaudeKeychainCredentials(account.id, credentialsJson) return } - const credentialsPath = join(account.managedAuthPath, '.credentials.json') - writeFileAtomically(credentialsPath, credentialsJson, { mode: 0o600 }) + writeClaudeManagedAuthFile(managedAuthPath, '.credentials.json', credentialsJson) } private readManagedOauthAccount(account: ClaudeManagedAccount): unknown { - const oauthPath = join(account.managedAuthPath, 'oauth-account.json') - if (!existsSync(oauthPath)) { + const managedAuthPath = this.getOwnedManagedAuthPath(account) + if (!managedAuthPath) { return null } try { - return JSON.parse(readFileSync(oauthPath, 'utf-8')) as unknown + const contents = readClaudeManagedAuthFile(managedAuthPath, 'oauth-account.json') + return contents ? (JSON.parse(contents) as unknown) : null } catch { return null } } - private async captureSystemDefaultSnapshot(options: { force: boolean }): Promise { + private getOwnedManagedAuthPath(account: ClaudeManagedAccount): string | null { + return resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { + adoptLegacyMarker: true + }) + } + + private async captureSystemDefaultSnapshotForManagedEntry( + runtimeCredentialsJson: string | null, + managedCredentialsJson: string + ): Promise { + const snapshotPath = this.getSystemDefaultSnapshotPath() + const existingSnapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (runtimeCredentialsJson !== managedCredentialsJson) { + await this.captureSystemDefaultSnapshot({ + force: true, + previousSnapshot: existingSnapshot, + managedCredentialsJson + }) + return + } + if (existingSnapshot) { + await this.captureSystemDefaultSnapshot({ + force: true, + credentialsJsonOverride: existingSnapshot.credentialsJson, + previousSnapshot: existingSnapshot, + managedCredentialsJson + }) + return + } + await this.captureSystemDefaultSnapshot({ force: false }) + } + + private async captureSystemDefaultSnapshot(options: { + force: boolean + credentialsJsonOverride?: string | null + previousSnapshot?: ClaudeSystemDefaultSnapshot | null + managedCredentialsJson?: string + }): Promise { const snapshotPath = this.getSystemDefaultSnapshotPath() if (!options.force && existsSync(snapshotPath)) { return } const paths = this.pathResolver.getRuntimePaths() - const credentialsJson = existsSync(paths.credentialsPath) - ? readFileSync(paths.credentialsPath, 'utf-8') - : null - const keychainCredentialsJson = await readActiveClaudeKeychainCredentials() + const credentialsJson = + options.credentialsJsonOverride !== undefined + ? options.credentialsJsonOverride + : existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + const keychainCredentialsJson = await this.readAggregateClaudeKeychainCredentialsBestEffort( + paths.configDir + ) + const scopedKeychainCredentials = + process.platform === 'darwin' + ? await this.readActiveClaudeKeychainCredentialsForSnapshot(paths.configDir) + : ({ status: 'captured', credentialsJson: null } as const) + const legacyKeychainCredentialsJson = + process.platform === 'darwin' + ? await this.readActiveClaudeKeychainCredentialsForSnapshot() + : ({ status: 'captured', credentialsJson: null } as const) + if ( + scopedKeychainCredentials.status === 'failed' || + legacyKeychainCredentialsJson.status === 'failed' + ) { + throw new Error('Cannot capture current Claude Keychain credentials') + } + const scopedKeychainCredentialsJson = + scopedKeychainCredentials.status === 'captured' + ? this.snapshotKeychainCredentials( + scopedKeychainCredentials.credentialsJson, + options.previousSnapshot, + 'scoped', + options.managedCredentialsJson + ) + : undefined + const legacyKeychainSnapshotJson = + legacyKeychainCredentialsJson.status === 'captured' + ? this.snapshotKeychainCredentials( + legacyKeychainCredentialsJson.credentialsJson, + options.previousSnapshot, + 'legacy', + options.managedCredentialsJson + ) + : undefined + const configOauthAccount = this.readRuntimeOauthAccount() const snapshot: ClaudeSystemDefaultSnapshot = { credentialsJson, - configOauthAccount: this.readRuntimeOauthAccount(), + configOauthAccount: + configOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR ? null : configOauthAccount, keychainCredentialsJson, + scopedKeychainCredentialsJson, + legacyKeychainCredentialsJson: legacyKeychainSnapshotJson, + scopedKeychainCredentialsCaptured: scopedKeychainCredentials.status === 'captured', + legacyKeychainCredentialsCaptured: legacyKeychainCredentialsJson.status === 'captured', capturedAt: Date.now() } this.writeJson(snapshotPath, snapshot) } - private async restoreSystemDefaultSnapshot(options: { - detectExternalLogin: boolean - }): Promise { - const externalState = options.detectExternalLogin - ? await this.detectExternalLoginAndUpdateSnapshot() - : 'none' - if (externalState === 'file-logout') { - return - } - + private async restoreSystemDefaultSnapshot( + ownedCredentialsJson?: string | null, + ownedOauthAccount?: unknown + ): Promise { const snapshotPath = this.getSystemDefaultSnapshotPath() - if (!existsSync(snapshotPath)) { - rmSync(this.pathResolver.getRuntimePaths().credentialsPath, { force: true }) - if (process.platform === 'darwin') { - await deleteActiveClaudeKeychainCredentials() - } - this.lastWrittenCredentialsJson = null - return - } - const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as ClaudeSystemDefaultSnapshot - if (snapshot.credentialsJson !== null) { - this.writeRuntimeCredentials(snapshot.credentialsJson) - } else { - rmSync(this.pathResolver.getRuntimePaths().credentialsPath, { force: true }) - } - this.writeRuntimeOauthAccount(snapshot.configOauthAccount) + const paths = this.pathResolver.getRuntimePaths() + const previouslyWrittenCredentialsJson = + this.lastWrittenCredentialsJson ?? ownedCredentialsJson ?? null + const snapshot = this.readSystemDefaultSnapshot(snapshotPath) + + const fileCredentialsOwned = this.hasUnchangedRuntimeCredentials( + previouslyWrittenCredentialsJson + ) + let hasCredentialSurfaceOwnership = fileCredentialsOwned + // Why: runtime auth restore is two-phase: prove ownership before mutating + // any surface, then restore OAuth first. If OAuth fails, the credential + // proof remains intact for retry. + this.lastWrittenCredentialsJson = previouslyWrittenCredentialsJson + let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null + let legacySnapshot: ClaudeKeychainSnapshotValue | null = null + let scopedKeychainOwned = false + let legacyKeychainOwned = false if (process.platform === 'darwin') { - if (externalState !== 'keychain-change') { - await (snapshot.keychainCredentialsJson !== null - ? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson) - : deleteActiveClaudeKeychainCredentials()) + scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped') + legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy') + scopedKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials( + scopedSnapshot, + previouslyWrittenCredentialsJson, + paths.configDir + ) + legacyKeychainOwned = await this.hasUnchangedActiveClaudeKeychainCredentials( + legacySnapshot, + previouslyWrittenCredentialsJson + ) + hasCredentialSurfaceOwnership = + fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned + } + this.restoreRuntimeOauthAccountIfOwned( + snapshot?.configOauthAccount ?? null, + this.getOwnedRuntimeOauthBaseline(ownedOauthAccount, hasCredentialSurfaceOwnership), + { allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership } + ) + if (fileCredentialsOwned) { + this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null) + } + if (process.platform === 'darwin') { + if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { + await this.restoreActiveClaudeKeychainCredentials( + scopedSnapshot.credentialsJson, + paths.configDir + ) + } + if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { + await this.restoreActiveClaudeKeychainCredentials(legacySnapshot.credentialsJson) + } + } + this.lastWrittenCredentialsJson = null + this.lastWrittenOauthAccount = null + this.hasLastWrittenOauthAccount = false + this.hasMaterializedRuntimeAuth = false + } + + private getOwnedRuntimeOauthBaseline( + ownedOauthAccount: unknown, + hasCredentialSurfaceOwnership: boolean + ): unknown { + if (this.hasLastWrittenOauthAccount) { + return this.lastWrittenOauthAccount + } + // Why: persisted managed metadata is an account identity hint, not proof + // that Orca wrote .claude.json. Use it only after another surface proves + // the current runtime auth still belongs to the managed account. + if (hasCredentialSurfaceOwnership && ownedOauthAccount !== undefined) { + return ownedOauthAccount + } + return null + } + + private readSystemDefaultSnapshot(snapshotPath: string): ClaudeSystemDefaultSnapshot | null { + if (!existsSync(snapshotPath)) { + return null + } + try { + const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown + if (this.isSystemDefaultSnapshot(parsed)) { + return parsed + } + throw new Error('Invalid Claude system-default auth snapshot shape') + } catch (error) { + console.warn('[claude-runtime-auth] Ignoring invalid system-default auth snapshot:', error) + rmSync(snapshotPath, { force: true }) + return null + } + } + + private async clearRuntimeAuthForAccount( + account: ClaudeManagedAccount, + managedOauthAccount: unknown + ): Promise { + const paths = this.pathResolver.getRuntimePaths() + const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount( + this.readRuntimeCredentialsFile(), + account, + managedOauthAccount + ) + let scopedKeychainOwned = false + let legacyKeychainOwned = false + if (process.platform === 'darwin') { + scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( + account, + managedOauthAccount, + paths.configDir + ) + legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( + account, + managedOauthAccount + ) + } + const hasCredentialSurfaceOwnership = + fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned + this.restoreRuntimeOauthAccountIfOwned( + null, + this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), + { + allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership + } + ) + if (fileCredentialsOwned) { + rmSync(paths.credentialsPath, { force: true }) + } + if (process.platform === 'darwin') { + if (scopedKeychainOwned) { + await deleteActiveClaudeKeychainCredentialsStrict(paths.configDir) + } + if (legacyKeychainOwned) { + await deleteActiveClaudeKeychainCredentialsStrict() } } } - // Why: detects whether an external tool (e.g. `claude auth login`) overwrote - // the credentials file while a managed account was active. If the file - // differs from what Orca last wrote, that external login becomes the new - // system default — no manual "refresh" button needed. - private async detectExternalLoginAndUpdateSnapshot(): Promise< - 'none' | 'file-logout' | 'keychain-change' - > { - if (this.lastWrittenCredentialsJson === null) { - return 'none' + private async restoreSystemDefaultSnapshotForMissingManagedCredentials( + account: ClaudeManagedAccount, + managedOauthAccount: unknown + ): Promise { + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + if (!snapshot) { + await this.clearRuntimeAuthForAccount(account, managedOauthAccount) + this.clearLastWrittenRuntimeState() + return } const paths = this.pathResolver.getRuntimePaths() - if (!existsSync(paths.credentialsPath)) { - const currentKeychainCredentials = - process.platform === 'darwin' ? await readActiveClaudeKeychainCredentials() : null - if ( - process.platform === 'darwin' && - currentKeychainCredentials === this.lastWrittenCredentialsJson - ) { - return 'none' - } - const snapshotPath = this.getSystemDefaultSnapshotPath() - rmSync(snapshotPath, { force: true }) - this.lastWrittenCredentialsJson = null - return 'file-logout' + const fileCredentialsOwned = this.runtimeCredentialsBelongToAccount( + this.readRuntimeCredentialsFile(), + account, + managedOauthAccount + ) + let scopedSnapshot: ClaudeKeychainSnapshotValue | null = null + let legacySnapshot: ClaudeKeychainSnapshotValue | null = null + let scopedKeychainOwned = false + let legacyKeychainOwned = false + if (process.platform === 'darwin') { + scopedSnapshot = this.readKeychainSnapshotValue(snapshot, 'scoped') + legacySnapshot = this.readKeychainSnapshotValue(snapshot, 'legacy') + scopedKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( + account, + managedOauthAccount, + paths.configDir + ) + legacyKeychainOwned = await this.hasActiveKeychainCredentialsForAccount( + account, + managedOauthAccount + ) } - const currentCredentials = readFileSync(paths.credentialsPath, 'utf-8') - const currentKeychainCredentials = - process.platform === 'darwin' ? await readActiveClaudeKeychainCredentials() : null - if (currentCredentials === this.lastWrittenCredentialsJson) { - if ( - process.platform === 'darwin' && - currentKeychainCredentials !== this.lastWrittenCredentialsJson - ) { - return 'keychain-change' + const hasCredentialSurfaceOwnership = + fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned + this.restoreRuntimeOauthAccountIfOwned( + snapshot.configOauthAccount, + this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), + { + allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership } - return 'none' + ) + if (fileCredentialsOwned) { + this.restoreRuntimeCredentials(snapshot.credentialsJson) } - const snapshotPath = this.getSystemDefaultSnapshotPath() - rmSync(snapshotPath, { force: true }) + if (process.platform === 'darwin') { + if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { + await this.restoreActiveClaudeKeychainCredentials( + scopedSnapshot.credentialsJson, + paths.configDir + ) + } + if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { + await this.restoreActiveClaudeKeychainCredentials(legacySnapshot.credentialsJson) + } + } + this.clearLastWrittenRuntimeState() + } + + private readRuntimeCredentialsFile(): string | null { + const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath + return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null + } + + private runtimeCredentialsBelongToAccount( + credentialsJson: string | null, + account: ClaudeManagedAccount, + managedOauthAccount: unknown + ): boolean { + if (!credentialsJson) { + return false + } + const identity = this.readIdentityFromCredentials(credentialsJson) + if ( + !identity?.email || + (account.email && this.normalizeField(account.email) !== identity.email) + ) { + return false + } + const oauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount) + const selectedOrganizationUuid = this.normalizeField( + account.organizationUuid ?? oauthIdentity.organizationUuid + ) + if (selectedOrganizationUuid) { + return identity.organizationUuid === selectedOrganizationUuid + } + return !identity.organizationUuid + } + + private clearLastWrittenRuntimeState(): void { this.lastWrittenCredentialsJson = null - return 'file-logout' + this.lastWrittenOauthAccount = null + this.hasLastWrittenOauthAccount = false + this.hasMaterializedRuntimeAuth = false + } + + private hasUnchangedRuntimeCredentials(previouslyWrittenCredentialsJson: string | null): boolean { + if (previouslyWrittenCredentialsJson === null) { + return false + } + const paths = this.pathResolver.getRuntimePaths() + const currentCredentialsJson = existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + return currentCredentialsJson === previouslyWrittenCredentialsJson + } + + private restoreRuntimeCredentials(credentialsJson: string | null): void { + const paths = this.pathResolver.getRuntimePaths() + if (credentialsJson !== null) { + this.writeRuntimeCredentials(credentialsJson) + } else { + rmSync(paths.credentialsPath, { force: true }) + } + } + + private restoreRuntimeOauthAccountIfOwned( + oauthAccount: unknown, + ownedOauthAccount: unknown, + options: { allowCredentialSurfaceOwnership: boolean } + ): void { + const currentOauthAccount = this.readRuntimeOauthAccount() + if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) { + return + } + if (options.allowCredentialSurfaceOwnership) { + this.writeRuntimeOauthAccount(oauthAccount) + return + } + if ( + (ownedOauthAccount === null || ownedOauthAccount === undefined) && + !options.allowCredentialSurfaceOwnership + ) { + return + } + if (!this.jsonValuesEqual(currentOauthAccount, ownedOauthAccount)) { + return + } + this.writeRuntimeOauthAccount(oauthAccount) + } + + private async hasUnchangedActiveClaudeKeychainCredentials( + snapshotValue: ClaudeKeychainSnapshotValue, + previouslyWrittenCredentialsJson: string | null, + configDir?: string + ): Promise { + if (snapshotValue.status === 'unknown') { + return false + } + const currentCredentialsJson = + await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) + return ( + previouslyWrittenCredentialsJson !== null && + currentCredentialsJson === previouslyWrittenCredentialsJson + ) + } + + private async restoreActiveClaudeKeychainCredentials( + credentialsJson: string | null, + configDir?: string + ): Promise { + await (credentialsJson !== null + ? writeActiveClaudeKeychainCredentials(credentialsJson, configDir) + : deleteActiveClaudeKeychainCredentialsStrict(configDir)) + } + + private async hasActiveKeychainCredentialsForAccount( + account: ClaudeManagedAccount, + managedOauthAccount: unknown, + configDir?: string + ): Promise { + const currentCredentialsJson = + await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) + return this.runtimeCredentialsBelongToAccount( + currentCredentialsJson, + account, + managedOauthAccount + ) } private readRuntimeOauthAccount(): unknown { @@ -597,22 +1093,182 @@ export class ClaudeRuntimeAuthService { return null } try { - const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record - return parsed.oauthAccount ?? null + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as unknown + const record = this.asRecord(parsed) + if (!record) { + return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR + } + return record.oauthAccount ?? null } catch { - return null + return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR } } - private writeRuntimeOauthAccount(oauthAccount: unknown): void { + private runtimeOauthAccountMatches(managedOauthAccount: unknown): boolean { + if (managedOauthAccount === null || managedOauthAccount === undefined) { + return false + } + const currentOauthAccount = this.readRuntimeOauthAccount() + if (currentOauthAccount === RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR) { + return false + } + return this.jsonValuesEqual(currentOauthAccount, managedOauthAccount) + } + + private writeRuntimeOauthAccount(oauthAccount: unknown): boolean { const configPath = this.pathResolver.getRuntimePaths().configPath const existing = this.readJsonObject(configPath) + if (existing === null) { + return false + } if (oauthAccount === null || oauthAccount === undefined) { delete existing.oauthAccount } else { existing.oauthAccount = oauthAccount } this.writeJson(configPath, existing) + return true + } + + private jsonValuesEqual(left: unknown, right: unknown): boolean { + return ( + JSON.stringify(this.sortJsonValue(left ?? null)) === + JSON.stringify(this.sortJsonValue(right ?? null)) + ) + } + + private sortJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.sortJsonValue(item)) + } + const record = this.asRecord(value) + if (!record) { + return value + } + return Object.fromEntries( + Object.entries(record) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, nestedValue]) => [key, this.sortJsonValue(nestedValue)]) + ) + } + + private isSystemDefaultSnapshot(value: unknown): value is ClaudeSystemDefaultSnapshot { + const snapshot = this.asRecord(value) + return ( + snapshot !== null && + Object.hasOwn(snapshot, 'credentialsJson') && + this.isOptionalNullableString(snapshot.credentialsJson) && + this.isOptionalNullableString(snapshot.keychainCredentialsJson) && + this.isOptionalNullableString(snapshot.scopedKeychainCredentialsJson) && + this.isOptionalNullableString(snapshot.legacyKeychainCredentialsJson) && + this.isOptionalBoolean(snapshot.scopedKeychainCredentialsCaptured) && + this.isOptionalBoolean(snapshot.legacyKeychainCredentialsCaptured) && + this.hasValidKeychainSnapshotValue(snapshot, 'scoped') && + this.hasValidKeychainSnapshotValue(snapshot, 'legacy') && + (snapshot.capturedAt === undefined || typeof snapshot.capturedAt === 'number') + ) + } + + private isOptionalNullableString(value: unknown): boolean { + return value === undefined || value === null || typeof value === 'string' + } + + private isOptionalBoolean(value: unknown): boolean { + return value === undefined || typeof value === 'boolean' + } + + private snapshotKeychainCredentials( + credentialsJson: string | null, + previousSnapshot: ClaudeSystemDefaultSnapshot | null | undefined, + service: 'scoped' | 'legacy', + managedCredentialsJson: string | undefined + ): string | null { + if (managedCredentialsJson && credentialsJson === managedCredentialsJson && previousSnapshot) { + const previousValue = this.readKeychainSnapshotValue(previousSnapshot, service) + if (previousValue.status === 'captured') { + return previousValue.credentialsJson + } + } + return credentialsJson + } + + private hasValidKeychainSnapshotValue( + snapshot: Record, + service: 'scoped' | 'legacy' + ): boolean { + const capturedKey = + service === 'scoped' + ? 'scopedKeychainCredentialsCaptured' + : 'legacyKeychainCredentialsCaptured' + if (snapshot[capturedKey] === false) { + return true + } + const credentialsKey = + service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' + return ( + Object.hasOwn(snapshot, credentialsKey) || Object.hasOwn(snapshot, 'keychainCredentialsJson') + ) + } + + private readKeychainSnapshotValue( + snapshot: ClaudeSystemDefaultSnapshot | null, + service: 'scoped' | 'legacy' + ): ClaudeKeychainSnapshotValue { + if (!snapshot) { + return { status: 'captured', credentialsJson: null } + } + const capturedKey = + service === 'scoped' + ? 'scopedKeychainCredentialsCaptured' + : 'legacyKeychainCredentialsCaptured' + if (snapshot[capturedKey] === false) { + return { status: 'unknown' } + } + const credentialsKey = + service === 'scoped' ? 'scopedKeychainCredentialsJson' : 'legacyKeychainCredentialsJson' + if (Object.hasOwn(snapshot, credentialsKey)) { + return { + status: 'captured', + credentialsJson: snapshot[credentialsKey] ?? null + } + } + return { status: 'captured', credentialsJson: snapshot.keychainCredentialsJson } + } + + private async readAggregateClaudeKeychainCredentialsBestEffort( + configDir: string + ): Promise { + try { + return await readActiveClaudeKeychainCredentials(configDir) + } catch (error) { + console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) + return null + } + } + + private async readActiveClaudeKeychainCredentialsBestEffort( + configDir?: string + ): Promise { + try { + return await readActiveClaudeKeychainCredentialsStrict(configDir) + } catch (error) { + console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) + return null + } + } + + private async readActiveClaudeKeychainCredentialsForSnapshot( + configDir?: string + ): Promise { + try { + return { + status: 'captured', + credentialsJson: await readActiveClaudeKeychainCredentialsStrict(configDir) + } + } catch (error) { + console.warn('[claude-runtime-auth] Failed to read Claude Keychain credentials:', error) + return { status: 'failed' } + } } private writeRuntimeCredentials(contents: string): void { @@ -667,7 +1323,7 @@ export class ClaudeRuntimeAuthService { } } - private readJsonObject(targetPath: string): Record { + private readJsonObject(targetPath: string): Record | null { if (!existsSync(targetPath)) { return {} } @@ -677,9 +1333,11 @@ export class ClaudeRuntimeAuthService { return parsed as Record } } catch { - /* Preserve no invalid JSON; Claude can recreate unsupported config files. */ + // Why: invalid config is an unknown external state. Replacing it with a + // fresh object could silently erase user or Claude-owned settings. + return null } - return {} + return null } private getRuntimeMetadataDir(): string { diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts new file mode 100644 index 00000000000..aa449677843 --- /dev/null +++ b/src/main/claude-accounts/service.test.ts @@ -0,0 +1,582 @@ +/* eslint-disable max-lines -- test suite covers Claude capture and rollback edge cases */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict, + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials +} from './keychain' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-claude-service-test' + } +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: () => 'claude' +})) + +vi.mock('./keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), + deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), + writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) +})) + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +function createService(): unknown { + return {} +} + +async function readCapturedCredentials( + configDir: string, + previousLegacyKeychain: string | null +): Promise { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + return ( + service as unknown as { + readCapturedCredentials( + configDir: string, + previousLegacyKeychain: string | null + ): Promise + } + ).readCapturedCredentials(configDir, previousLegacyKeychain) +} + +describe('ClaudeAccountService credential capture', () => { + let tempDir: string | null = null + + beforeEach(() => { + setPlatform('darwin') + tempDir = null + vi.mocked(readActiveClaudeKeychainCredentials).mockReset() + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() + vi.mocked(readManagedClaudeKeychainCredentials).mockReset() + vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockClear() + vi.mocked(writeActiveClaudeKeychainCredentials).mockReset() + vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() + vi.mocked(writeManagedClaudeKeychainCredentials).mockReset() + vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('accepts scoped Keychain capture even when it matches the previous legacy item', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce('same-account') + .mockResolvedValueOnce('same-account') + + await expect(readCapturedCredentials('/tmp/claude-config', 'same-account')).resolves.toBe( + 'same-account' + ) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith('/tmp/claude-config') + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() + }) + + it('rejects unchanged legacy fallback when scoped capture is missing', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('previous') + + await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBeNull() + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith( + 1, + '/tmp/claude-config' + ) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2) + }) + + it('accepts changed legacy fallback for old Claude Code builds', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('new-legacy') + + await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBe( + 'new-legacy' + ) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith( + 1, + '/tmp/claude-config' + ) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2) + }) + + it('falls back to captured credentials file on macOS', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-capture-')) + writeFileSync(join(tempDir, '.credentials.json'), '{"token":"file"}\n', 'utf-8') + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('previous') + + await expect(readCapturedCredentials(tempDir, 'previous')).resolves.toBe('{"token":"file"}\n') + }) + + it('fails login capture when legacy Keychain cleanup fails', async () => { + vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue('previous-legacy') + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue('captured-scoped') + vi.mocked(writeActiveClaudeKeychainCredentials).mockRejectedValue(new Error('restore failed')) + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + const testService = service as unknown as { + runClaudeCommand: () => Promise + runClaudeLoginAndCapture(): Promise<{ credentialsJson: string }> + } + testService.runClaudeCommand = vi.fn(async () => '{"account":{"email":"user@example.com"}}') + + await expect(testService.runClaudeLoginAndCapture()).rejects.toThrow('restore failed') + }) + + it('restores previous managed auth when reauth materialization fails', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1' + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), + syncForCurrentSelection: vi.fn(async () => { + throw new Error('materialize failed') + }) + } + const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') + + expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n') + expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe( + '{"oldOauth":true}\n' + ) + expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + }) + + it('restores settings without rematerializing when managed-auth rollback write fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n') + vi.mocked(writeManagedClaudeKeychainCredentials) + .mockResolvedValueOnce() + .mockRejectedValueOnce(new Error('managed restore failed')) + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1' + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), + syncForCurrentSelection: vi.fn(async () => { + throw new Error('materialize failed') + }) + } + const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') + + expect(store.getSettings().claudeManagedAccounts[0].email).toBe('new@example.com') + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[claude-accounts] Failed to restore managed credentials during rollback:', + expect.any(Error) + ) + warn.mockRestore() + }) + + it('restores oauth metadata when new credential write and credential rollback fail', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n') + vi.mocked(writeManagedClaudeKeychainCredentials) + .mockRejectedValueOnce(new Error('new credentials failed')) + .mockRejectedValueOnce(new Error('credential rollback failed')) + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1' + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), + syncForCurrentSelection: vi.fn() + } + const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await expect(service.reauthenticateAccount('account-1')).rejects.toThrow( + 'new credentials failed' + ) + + expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe( + '{"oldOauth":true}\n' + ) + expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[claude-accounts] Failed to restore managed credentials during rollback:', + expect.any(Error) + ) + warn.mockRestore() + }) + + it('restores old metadata when rollback restores credentials but oauth restore fails', async () => { + setPlatform('linux') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const oauthPath = join(managedAuthPath, 'oauth-account.json') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') + writeFileSync(oauthPath, '{"oldOauth":true}\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1' + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), + syncForCurrentSelection: vi.fn(async () => { + rmSync(oauthPath, { force: true }) + mkdirSync(oauthPath) + throw new Error('materialize failed') + }) + } + const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed') + + expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n') + expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com') + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('refreshes rate limits without recaching a removed active account', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'account-1' + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + + await service.removeAccount('account-1') + + expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith() + expect(settings).toMatchObject({ + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null + }) + }) + + it('evicts inactive rate-limit cache after successful reauth', async () => { + setPlatform('linux') + tempDir = '/tmp/orca-claude-service-test' + rmSync(tempDir, { recursive: true, force: true }) + const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'old@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: null + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + + await service.reauthenticateAccount('account-1') + + expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1') + expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith() + expect(settings.claudeManagedAccounts[0].email).toBe('new@example.com') + }) +}) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index b2a8dd90130..688af804f23 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -2,18 +2,9 @@ for login, credential capture, Keychain storage, selection, and rate-limit refresh. */ import { randomUUID } from 'node:crypto' import { spawn } from 'node:child_process' -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative, resolve, sep } from 'node:path' -import { app } from 'electron' import type { ClaudeManagedAccount, ClaudeManagedAccountSummary, @@ -21,13 +12,20 @@ import type { } from '../../shared/types' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' -import { writeFileAtomically } from '../codex-accounts/fs-utils' import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' +import { + getClaudeManagedAccountsRoot, + readClaudeManagedAuthFile, + resolveOwnedClaudeManagedAuthPath, + writeClaudeManagedAuthFile +} from './managed-auth-path' import { deleteActiveClaudeKeychainCredentialsStrict, deleteManagedClaudeKeychainCredentials, readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict, + readManagedClaudeKeychainCredentials, writeActiveClaudeKeychainCredentials, writeManagedClaudeKeychainCredentials } from './keychain' @@ -49,6 +47,11 @@ type CapturedClaudeAuth = { identity: ClaudeIdentity } +type ManagedClaudeAuthSnapshot = { + credentialsJson: string | null + oauthAccountJson: string | null +} + export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() @@ -129,38 +132,72 @@ export class ClaudeAccountService { private async doReauthenticateAccount(accountId: string): Promise { const account = this.requireAccount(accountId) - const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath) + const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath, accountId) const previousSettings = this.store.getSettings() + const previousManagedAuth = await this.readManagedAuthSnapshot(accountId, managedAuthPath) const captured = await this.runClaudeLoginAndCapture() if (!captured.identity.email) { throw new Error('Claude login completed, but Orca could not resolve the account email.') } - await this.writeManagedAuth(accountId, managedAuthPath, captured) const settings = this.store.getSettings() const now = Date.now() - this.store.updateSettings({ - claudeManagedAccounts: settings.claudeManagedAccounts.map((entry) => - entry.id === accountId - ? { - ...entry, - email: captured.identity.email!, - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - updatedAt: now, - lastAuthenticatedAt: now - } - : entry - ) - }) + const reauthenticatedAccounts = settings.claudeManagedAccounts.map((entry) => + entry.id === accountId + ? { + ...entry, + email: captured.identity.email!, + organizationUuid: captured.identity.organizationUuid, + organizationName: captured.identity.organizationName, + updatedAt: now, + lastAuthenticatedAt: now + } + : entry + ) + let wroteManagedCredentials = false try { + await this.writeManagedOauthAccount(accountId, managedAuthPath, captured.oauthAccount) + await this.writeManagedCredentials(accountId, managedAuthPath, captured.credentialsJson) + wroteManagedCredentials = true + this.store.updateSettings({ claudeManagedAccounts: reauthenticatedAccounts }) this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) + this.rateLimits.evictInactiveClaudeCache(accountId) await this.syncRuntimeAuthWithLivePtyGate() await this.rateLimits.refreshForClaudeAccountChange() return this.getSnapshot() } catch (error) { - this.restoreClaudeSettings(previousSettings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + let restoredManagedCredentials = false + try { + await this.restoreManagedCredentialsSnapshot( + accountId, + managedAuthPath, + previousManagedAuth + ) + restoredManagedCredentials = true + } catch (rollbackError) { + console.warn( + '[claude-accounts] Failed to restore managed credentials during rollback:', + rollbackError + ) + } + if (restoredManagedCredentials || !wroteManagedCredentials) { + try { + this.restoreManagedOauthSnapshot(accountId, managedAuthPath, previousManagedAuth) + } catch (rollbackError) { + console.warn( + '[claude-accounts] Failed to restore managed oauth metadata during rollback:', + rollbackError + ) + } + } + if (restoredManagedCredentials) { + this.restoreClaudeSettings(previousSettings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + } else if (wroteManagedCredentials) { + this.store.updateSettings({ claudeManagedAccounts: reauthenticatedAccounts }) + } else { + this.restoreClaudeSettings(previousSettings) + } throw error } } @@ -174,19 +211,21 @@ export class ClaudeAccountService { ? null : settings.activeClaudeManagedAccountId - this.store.updateSettings({ - claudeManagedAccounts: nextAccounts, - activeClaudeManagedAccountId: nextActiveId - }) try { - await this.syncRuntimeAuthWithLivePtyGate() + if (settings.activeClaudeManagedAccountId === accountId) { + this.store.updateSettings({ activeClaudeManagedAccountId: null }) + await this.syncRuntimeAuthWithLivePtyGate() + this.store.updateSettings({ claudeManagedAccounts: nextAccounts }) + } else { + this.store.updateSettings({ + claudeManagedAccounts: nextAccounts, + activeClaudeManagedAccountId: nextActiveId + }) + await this.syncRuntimeAuthWithLivePtyGate() + } await this.safeRemoveManagedAuth(accountId, account.managedAuthPath) this.rateLimits.evictInactiveClaudeCache(accountId) - await this.rateLimits.refreshForClaudeAccountChange( - settings.activeClaudeManagedAccountId === accountId - ? settings.activeClaudeManagedAccountId - : undefined - ) + await this.rateLimits.refreshForClaudeAccountChange() return this.getSnapshot() } catch (error) { this.restoreClaudeSettings(settings) @@ -277,7 +316,10 @@ export class ClaudeAccountService { private async runClaudeLoginAndCapture(): Promise { const tempConfigDir = mkdtempSync(join(tmpdir(), 'orca-claude-login-')) - const previousActiveKeychain = await readActiveClaudeKeychainCredentials() + const previousLegacyKeychain = await readActiveClaudeKeychainCredentials() + let captured: CapturedClaudeAuth | null = null + let captureError: unknown = null + let cleanupError: unknown = null try { await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfigDir, LOGIN_TIMEOUT_MS) const status = await this.runClaudeCommand( @@ -286,25 +328,45 @@ export class ClaudeAccountService { STATUS_TIMEOUT_MS, { allowFailure: true } ) - return await this.captureAuthFromConfigDir(tempConfigDir, status) + captured = await this.captureAuthFromConfigDir(tempConfigDir, status, previousLegacyKeychain) + } catch (error) { + captureError = error } finally { - if (process.platform === 'darwin' && previousActiveKeychain) { - // Why: Claude login writes the global active Keychain item even when - // CLAUDE_CONFIG_DIR points elsewhere. Restore it so adding an account - // does not switch the user's external Claude CLI out from under them. - await writeActiveClaudeKeychainCredentials(previousActiveKeychain) - } else if (process.platform === 'darwin') { - await deleteActiveClaudeKeychainCredentialsStrict() + if (process.platform === 'darwin') { + try { + await deleteActiveClaudeKeychainCredentialsStrict(tempConfigDir) + } catch (error) { + console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error) + } + } + if (process.platform === 'darwin') { + try { + // Why: older Claude versions ignored CLAUDE_CONFIG_DIR and wrote the + // legacy active Keychain item. Preserve that external CLI state. + await (previousLegacyKeychain + ? writeActiveClaudeKeychainCredentials(previousLegacyKeychain) + : deleteActiveClaudeKeychainCredentialsStrict()) + } catch (error) { + cleanupError = error + } } rmSync(tempConfigDir, { recursive: true, force: true }) } + if (captureError) { + throw captureError + } + if (cleanupError) { + throw cleanupError + } + return captured! } private async captureAuthFromConfigDir( configDir: string, - statusOutput: string + statusOutput: string, + previousLegacyKeychain: string | null ): Promise { - const credentialsJson = await this.readCapturedCredentials(configDir) + const credentialsJson = await this.readCapturedCredentials(configDir, previousLegacyKeychain) if (!credentialsJson) { throw new Error('Claude login completed, but no OAuth credentials were captured.') } @@ -313,9 +375,19 @@ export class ClaudeAccountService { return { credentialsJson, oauthAccount, identity } } - private async readCapturedCredentials(configDir: string): Promise { + private async readCapturedCredentials( + configDir: string, + previousLegacyKeychain: string | null + ): Promise { if (process.platform === 'darwin') { - return readActiveClaudeKeychainCredentials() + const scopedCredentialsJson = await readActiveClaudeKeychainCredentialsStrict(configDir) + if (scopedCredentialsJson) { + return scopedCredentialsJson + } + const legacyCredentialsJson = await readActiveClaudeKeychainCredentialsStrict() + if (legacyCredentialsJson && legacyCredentialsJson !== previousLegacyKeychain) { + return legacyCredentialsJson + } } const credentialsPath = join(configDir, '.credentials.json') return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null @@ -372,63 +444,120 @@ export class ClaudeAccountService { managedAuthPath: string, captured: CapturedClaudeAuth ): Promise { - const trustedPath = this.assertManagedAuthPath(managedAuthPath) + await this.writeManagedCredentials(accountId, managedAuthPath, captured.credentialsJson) + await this.writeManagedOauthAccount(accountId, managedAuthPath, captured.oauthAccount) + } + + private async writeManagedCredentials( + accountId: string, + managedAuthPath: string, + credentialsJson: string + ): Promise { + const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId) if (process.platform === 'darwin') { - await writeManagedClaudeKeychainCredentials(accountId, captured.credentialsJson) + await writeManagedClaudeKeychainCredentials(accountId, credentialsJson) } else { - writeFileAtomically(join(trustedPath, '.credentials.json'), captured.credentialsJson, { - mode: 0o600 - }) + writeClaudeManagedAuthFile(trustedPath, '.credentials.json', credentialsJson) } - writeFileAtomically( - join(trustedPath, 'oauth-account.json'), - `${JSON.stringify(captured.oauthAccount, null, 2)}\n`, - { mode: 0o600 } + } + + private async writeManagedOauthAccount( + accountId: string, + managedAuthPath: string, + oauthAccount: unknown + ): Promise { + const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId) + writeClaudeManagedAuthFile( + trustedPath, + 'oauth-account.json', + `${JSON.stringify(oauthAccount, null, 2)}\n` ) } + private async readManagedAuthSnapshot( + accountId: string, + managedAuthPath: string + ): Promise { + const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId) + return { + credentialsJson: + process.platform === 'darwin' + ? await readManagedClaudeKeychainCredentials(accountId) + : readClaudeManagedAuthFile(trustedPath, '.credentials.json'), + oauthAccountJson: readClaudeManagedAuthFile(trustedPath, 'oauth-account.json') + } + } + + private async restoreManagedCredentialsSnapshot( + accountId: string, + managedAuthPath: string, + snapshot: ManagedClaudeAuthSnapshot + ): Promise { + const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId) + const credentialsPath = join(trustedPath, '.credentials.json') + if (process.platform === 'darwin') { + await (snapshot.credentialsJson !== null + ? writeManagedClaudeKeychainCredentials(accountId, snapshot.credentialsJson) + : deleteManagedClaudeKeychainCredentials(accountId)) + } else if (snapshot.credentialsJson !== null) { + writeClaudeManagedAuthFile(trustedPath, '.credentials.json', snapshot.credentialsJson) + } else { + rmSync(credentialsPath, { force: true }) + } + } + + private restoreManagedOauthSnapshot( + accountId: string, + managedAuthPath: string, + snapshot: ManagedClaudeAuthSnapshot + ): void { + const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId) + const oauthPath = join(trustedPath, 'oauth-account.json') + if (snapshot.oauthAccountJson !== null) { + writeClaudeManagedAuthFile(trustedPath, 'oauth-account.json', snapshot.oauthAccountJson) + } else { + rmSync(oauthPath, { force: true }) + } + } + private createManagedAuthDir(accountId: string): string { const managedAuthPath = join(this.getManagedAccountsRoot(), accountId, 'auth') mkdirSync(managedAuthPath, { recursive: true }) writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8') - return this.assertManagedAuthPath(managedAuthPath) + return this.assertManagedAuthPath(managedAuthPath, accountId) } private getManagedAccountsRoot(): string { - const root = join(app.getPath('userData'), 'claude-accounts') + const root = getClaudeManagedAccountsRoot() mkdirSync(root, { recursive: true }) return root } - private assertManagedAuthPath(candidatePath: string): string { - const rootPath = this.getManagedAccountsRoot() - const resolvedCandidate = resolve(candidatePath) - const resolvedRoot = resolve(rootPath) - if (!existsSync(resolvedCandidate)) { + private assertManagedAuthPath(candidatePath: string, expectedAccountId?: string): string { + this.getManagedAccountsRoot() + const accountId = expectedAccountId ?? this.readManagedAuthAccountIdFromPath(candidatePath) + if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) { throw new Error('Managed Claude auth directory does not exist on disk.') } - const canonicalCandidate = realpathSync(resolvedCandidate) - const canonicalRoot = realpathSync(resolvedRoot) - if ( - canonicalCandidate !== canonicalRoot && - !canonicalCandidate.startsWith(canonicalRoot + sep) - ) { - throw new Error( - `Managed Claude auth is outside current storage root (expected under ${canonicalRoot}).` - ) - } - const relativePath = relative(canonicalRoot, canonicalCandidate) - const escaped = - relativePath === '' || relativePath.startsWith('..') || relativePath.includes(`..${sep}`) - if (escaped || !existsSync(join(canonicalCandidate, '.orca-managed-claude-auth'))) { + const trustedPath = resolveOwnedClaudeManagedAuthPath(accountId, candidatePath, { + adoptLegacyMarker: true + }) + if (!trustedPath) { throw new Error('Managed Claude auth storage is not owned by Orca.') } - return canonicalCandidate + return trustedPath + } + + private readManagedAuthAccountIdFromPath(candidatePath: string): string | null { + const rootPath = this.getManagedAccountsRoot() + const relativePath = relative(resolve(rootPath), resolve(candidatePath)) + const parts = relativePath.split(sep) + return parts.length === 2 && parts[1] === 'auth' ? parts[0] : null } private async safeRemoveManagedAuth(accountId: string, candidatePath: string): Promise { try { - const managedAuthPath = this.assertManagedAuthPath(candidatePath) + const managedAuthPath = this.assertManagedAuthPath(candidatePath, accountId) rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true }) } catch (error) { console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error) diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts new file mode 100644 index 00000000000..7b86f4765d7 --- /dev/null +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -0,0 +1,274 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' +import { fetchViaPty } from './claude-pty' +import { + readActiveClaudeKeychainCredentialsStrict, + readManagedClaudeKeychainCredentials +} from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() +})) + +vi.mock('../claude-accounts/keychain', () => ({ + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn() +})) + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +describe('fetchClaudeRateLimits', () => { + let tempDir: string | null = null + + beforeEach(() => { + setPlatform('darwin') + tempDir = null + vi.clearAllMocks() + readFileMock.mockRejectedValue(new Error('missing file')) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null) + appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test') + resolveProxyMock.mockResolvedValue('DIRECT') + netFetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + five_hour: { utilization: 12 }, + seven_day: { utilization: 34 } + }), + { status: 200 } + ) + ) + vi.mocked(fetchViaPty).mockResolvedValue({ + provider: 'claude', + session: { usedPercent: 56, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt: 1, + error: null, + status: 'ok' + }) + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('reads scoped default-config Keychain credentials for OAuth usage fetches', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + readFileMock.mockResolvedValue( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'file-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 } + }) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer oauth-token' + }) + }) + ) + }) + + it('falls back to the credentials file when Keychain access fails', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockRejectedValue( + new Error('Keychain locked') + ) + readFileMock.mockResolvedValue( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'file-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok' + }) + + expect(readFileMock).toHaveBeenCalledWith('/Users/test/.claude/.credentials.json', 'utf-8') + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer file-oauth-token' + }) + }) + ) + }) + + it('falls back to legacy Keychain when scoped credentials are unusable', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce('{not-json') + .mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'legacy-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok' + }) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer legacy-oauth-token' + }) + }) + ) + }) + + it('tries PTY usage when OAuth credentials are expired but refreshable', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'expired-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 56 } + }) + + expect(netFetchMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() + expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) + }) + + it('does not read inactive managed credentials from unowned auth paths', async () => { + setPlatform('linux') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const unownedAuthPath = join(tempDir, 'unowned', 'auth') + mkdirSync(unownedAuthPath, { recursive: true }) + writeFileSync(join(unownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + join(unownedAuthPath, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'unowned-token', + expiresAt: Date.now() + 60_000 + } + }), + 'utf-8' + ) + + await expect( + fetchManagedAccountUsage({ id: 'account-1', managedAuthPath: unownedAuthPath }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'No credentials' + }) + + expect(netFetchMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 312c65a8cdc..8622d420564 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,12 +1,19 @@ import { readFile } from 'node:fs/promises' -import { execFile } from 'node:child_process' import { homedir } from 'node:os' import path from 'node:path' import { net, session } from 'electron' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { fetchViaPty } from './claude-pty' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import { readManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' +import { + readActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentialsStrict, + readManagedClaudeKeychainCredentials +} from '../claude-accounts/keychain' +import { + readClaudeManagedAuthFile, + resolveOwnedClaudeManagedAuthPath +} from '../claude-accounts/managed-auth-path' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' @@ -61,72 +68,93 @@ async function ensureProxyFromEnv(): Promise { // Credential reading — tries multiple sources for an OAuth bearer token // --------------------------------------------------------------------------- -type ClaudeCredentials = { - claudeAiOauth?: { - accessToken?: string - refreshToken?: string - expiresAt?: number // unix ms - } -} - type KeychainCredentials = { claudeAiOauth?: { accessToken?: string + refreshToken?: string expiresAt?: number } } +type OAuthCredentialReadResult = { + token: string | null + hasRefreshableCredentials: boolean +} + // Why: factored out so both the active-account Keychain reader and the // managed-account reader share the same JSON parsing + expiry check. -function parseOAuthTokenFromCredentialsJson(raw: string): string | null { +function parseOAuthCredentialsJson(raw: string): OAuthCredentialReadResult { try { const parsed = JSON.parse(raw) as KeychainCredentials - const token = parsed?.claudeAiOauth?.accessToken + const oauth = parsed?.claudeAiOauth + const token = oauth?.accessToken if (!token || typeof token !== 'string') { - return null + return { token: null, hasRefreshableCredentials: false } } - const expiresAt = parsed.claudeAiOauth?.expiresAt + const refreshToken = oauth?.refreshToken + const expiresAt = oauth?.expiresAt if (typeof expiresAt === 'number' && expiresAt < Date.now()) { - return null + return { + token: null, + hasRefreshableCredentials: typeof refreshToken === 'string' && refreshToken.trim() !== '' + } } - return token + return { token, hasRefreshableCredentials: true } } catch { - return null + return { token: null, hasRefreshableCredentials: false } } } /** * Read OAuth token from macOS Keychain. - * Why: Claude Code v2.x+ stores OAuth credentials in the macOS Keychain - * under service "Claude Code-credentials". This is the standard location - * for Claude Max/Pro OAuth tokens. Only returns a token if the keychain - * entry has a `claudeAiOauth.accessToken` — API key users won't have this. + * Why: Claude Code 2.1+ scopes OAuth Keychain services by CLAUDE_CONFIG_DIR; + * older builds used the legacy unsuffixed service. The shared reader handles both. */ -async function readFromKeychain(): Promise { +async function readFromKeychain(configDir?: string): Promise { if (process.platform !== 'darwin') { - return null + return { token: null, hasRefreshableCredentials: false } } - return new Promise((resolve) => { - const user = process.env.USER ?? '' - if (!user) { - resolve(null) - return + if (configDir) { + const scopedCredentials = await readCredentialsFromStrictKeychain(configDir) + if (scopedCredentials.token) { + return scopedCredentials } + if (scopedCredentials.hasRefreshableCredentials) { + return scopedCredentials + } + const legacyCredentials = await readCredentialsFromStrictKeychain() + if (legacyCredentials.token) { + return legacyCredentials + } + return { + token: null, + hasRefreshableCredentials: + scopedCredentials.hasRefreshableCredentials || legacyCredentials.hasRefreshableCredentials + } + } - execFile( - 'security', - ['find-generic-password', '-s', 'Claude Code-credentials', '-a', user, '-w'], - { timeout: 3_000 }, - (err, stdout) => { - if (err || !stdout.trim()) { - resolve(null) - return - } - resolve(parseOAuthTokenFromCredentialsJson(stdout.trim())) - } - ) - }) + try { + const credentials = await readActiveClaudeKeychainCredentials(configDir) + return credentials + ? parseOAuthCredentialsJson(credentials) + : { token: null, hasRefreshableCredentials: false } + } catch { + return { token: null, hasRefreshableCredentials: false } + } +} + +async function readCredentialsFromStrictKeychain( + configDir?: string +): Promise { + try { + const credentials = await readActiveClaudeKeychainCredentialsStrict(configDir) + return credentials + ? parseOAuthCredentialsJson(credentials) + : { token: null, hasRefreshableCredentials: false } + } catch { + return { token: null, hasRefreshableCredentials: false } + } } /** @@ -134,24 +162,13 @@ async function readFromKeychain(): Promise { * Why: older Claude CLI versions store credentials in this plain JSON * file. We keep it as a fallback for compatibility. */ -async function readFromCredentialsFile(configDir?: string): Promise { +async function readFromCredentialsFile(configDir?: string): Promise { const credPath = path.join(configDir ?? path.join(homedir(), '.claude'), '.credentials.json') try { const raw = await readFile(credPath, 'utf-8') - const parsed = JSON.parse(raw) as ClaudeCredentials - const token = parsed?.claudeAiOauth?.accessToken - if (!token || typeof token !== 'string') { - return null - } - - const expiresAt = parsed.claudeAiOauth?.expiresAt - if (typeof expiresAt === 'number' && expiresAt < Date.now()) { - return null - } - - return token + return parseOAuthCredentialsJson(raw) } catch { - return null + return { token: null, hasRefreshableCredentials: false } } } @@ -161,20 +178,27 @@ async function readFromCredentialsFile(configDir?: string): Promise { +async function readOAuthCredentials(configDir?: string): Promise { // 1. macOS Keychain (Claude Max/Pro OAuth) - const fromKeychain = await readFromKeychain() - if (fromKeychain) { + const fromKeychain = await readFromKeychain(configDir) + if (fromKeychain.token) { + return fromKeychain + } + if (fromKeychain.hasRefreshableCredentials) { return fromKeychain } // 2. Legacy credentials file const fromFile = await readFromCredentialsFile(configDir) - if (fromFile) { + if (fromFile.token) { return fromFile } - return null + return { + token: null, + hasRefreshableCredentials: + fromKeychain.hasRefreshableCredentials || fromFile.hasRefreshableCredentials + } } // --------------------------------------------------------------------------- @@ -274,21 +298,20 @@ export async function fetchClaudeRateLimits(options?: { authPreparation?: ClaudeRuntimeAuthPreparation }): Promise { // Path A: try OAuth API if we have a genuine OAuth token - const oauthToken = await readOAuthCredentials( - options?.authPreparation?.envPatch.CLAUDE_CONFIG_DIR - ) - if (oauthToken) { + const oauthCredentials = await readOAuthCredentials(options?.authPreparation?.configDir) + if (oauthCredentials.token) { try { - return await fetchViaOAuth(oauthToken) + return await fetchViaOAuth(oauthCredentials.token) } catch { // OAuth API failed — fall through to PTY scraping as a backup // for subscription users whose token may still be valid for the CLI. } + } - // Path B: PTY fallback — only for subscription plan users (Max/Pro) - // whose OAuth token we found but the API call failed. The CLI's - // `/usage` command is subscription-only, so there's no point - // attempting PTY for API key users. + // Path B: PTY fallback — only for subscription plan users (Max/Pro) + // whose OAuth credentials exist. The CLI can refresh expired OAuth tokens, + // so an expired access token should not be treated like API-key billing. + if (oauthCredentials.token || oauthCredentials.hasRefreshableCredentials) { try { return await fetchViaPty({ authPreparation: options?.authPreparation }) } catch (err) { @@ -332,14 +355,21 @@ export type InactiveClaudeAccountInfo = { // Using ClaudeRuntimeAuthService would overwrite the active account's auth. async function readManagedOAuthToken(account: InactiveClaudeAccountInfo): Promise { try { + const managedAuthPath = resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, { + adoptLegacyMarker: true + }) + if (!managedAuthPath) { + return null + } if (process.platform === 'darwin') { const raw = await readManagedClaudeKeychainCredentials(account.id) if (raw) { - return parseOAuthTokenFromCredentialsJson(raw) + return parseOAuthCredentialsJson(raw).token } return null } - return await readFromCredentialsFile(account.managedAuthPath) + const raw = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') + return raw ? parseOAuthCredentialsJson(raw).token : null } catch { return null } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 0146ea88b41..3c0310569e5 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -5,13 +5,14 @@ Keeping them in one file makes the ordering contract reviewable as a unit. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { RateLimitService } from './service' -import { fetchClaudeRateLimits } from './claude-fetcher' +import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' vi.mock('./claude-fetcher', () => ({ - fetchClaudeRateLimits: vi.fn() + fetchClaudeRateLimits: vi.fn(), + fetchManagedAccountUsage: vi.fn() })) vi.mock('./codex-fetcher', () => ({ @@ -371,4 +372,73 @@ describe('RateLimitService', () => { expect(state.opencodeGo?.session).toBeNull() expect(state.opencodeGo?.error).toBe('No workspace ID found') }) + + it('does not recache an inactive Claude account removed during fetch-on-open', async () => { + const service = new RateLimitService() + const accountFetch = deferred() + let inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] + service.setInactiveClaudeAccountsResolver(() => inactiveAccounts) + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/tmp/.claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })) + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + await service.refresh() + vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise) + + const fetchOnOpen = service.fetchInactiveClaudeAccountsOnOpen() + await Promise.resolve() + expect(service.getState().inactiveClaudeAccounts).toEqual([ + { accountId: 'account-1', claude: null, updatedAt: 0, isFetching: true } + ]) + + service.evictInactiveClaudeCache('account-1') + inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }] + await service.refreshForClaudeAccountChange('account-1') + expect(service.getState().inactiveClaudeAccounts[0]?.accountId).toBe('account-1') + + inactiveAccounts = [] + service.evictInactiveClaudeCache('account-1') + accountFetch.resolve(okProvider('claude', 42)) + await fetchOnOpen + + expect(service.getState().inactiveClaudeAccounts).toEqual([]) + }) + + it('does not overwrite inactive Claude cache from a stale same-id fetch', async () => { + const service = new RateLimitService() + const accountFetch = deferred() + service.setInactiveClaudeAccountsResolver(() => [ + { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + ]) + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/tmp/.claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })) + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + await service.refresh() + vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise) + + const fetchOnOpen = service.fetchInactiveClaudeAccountsOnOpen() + await Promise.resolve() + + await service.refreshForClaudeAccountChange('account-1') + accountFetch.resolve(okProvider('claude', 42)) + await fetchOnOpen + + expect(service.getState().inactiveClaudeAccounts).toEqual([ + { + accountId: 'account-1', + claude: expect.objectContaining({ + session: expect.objectContaining({ usedPercent: 7 }) + }), + updatedAt: expect.any(Number), + isFetching: false + } + ]) + }) }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index b90568567ff..06eecc255da 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -73,6 +73,7 @@ export class RateLimitService { private inactiveClaudeFetching = new Set() private inactiveCodexFetching = new Set() private lastInactiveClaudeFetchAt = 0 + private inactiveClaudeAccountsGeneration = 0 private lastInactiveCodexFetchAt = 0 private stateListeners = new Set<(state: RateLimitState) => void>() @@ -105,6 +106,7 @@ export class RateLimitService { setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { this.inactiveClaudeAccountsResolver = resolver + this.inactiveClaudeAccountsGeneration += 1 } setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { @@ -193,6 +195,8 @@ export class RateLimitService { if (outgoingAccountId && this.state.claude?.session) { this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) } + this.inactiveClaudeAccountsGeneration += 1 + this.pruneInactiveClaudeState() this.claudeFetchGeneration += 1 this.lastInactiveClaudeFetchAt = 0 this.updateState({ @@ -207,10 +211,12 @@ export class RateLimitService { if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { return } + this.pruneInactiveClaudeState() const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] if (accounts.length === 0) { return } + const fetchGeneration = this.inactiveClaudeAccountsGeneration for (const account of accounts) { this.inactiveClaudeFetching.add(account.id) @@ -218,19 +224,49 @@ export class RateLimitService { this.pushToRenderer() for (const account of accounts) { + if ( + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } try { const fresh = await fetchManagedAccountUsage(account) + if ( + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } const cached = this.inactiveClaudeCache.get(account.id) ?? null this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) } catch { // Why: per-account try/catch prevents one Keychain rejection or // network error from aborting the remaining accounts in the batch. + if ( + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeCache.delete(account.id) + } } this.inactiveClaudeFetching.delete(account.id) this.pushToRenderer() } - this.lastInactiveClaudeFetchAt = Date.now() + if (fetchGeneration === this.inactiveClaudeAccountsGeneration) { + this.lastInactiveClaudeFetchAt = Date.now() + } } async fetchInactiveCodexAccountsOnOpen(): Promise { @@ -266,11 +302,34 @@ export class RateLimitService { } evictInactiveClaudeCache(accountId: string): void { + this.inactiveClaudeAccountsGeneration += 1 this.inactiveClaudeCache.delete(accountId) this.inactiveClaudeFetching.delete(accountId) this.pushToRenderer() } + private isCurrentInactiveClaudeAccount(accountId: string): boolean { + return (this.inactiveClaudeAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + + private pruneInactiveClaudeState(): void { + const currentIds = new Set( + (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveClaudeCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveClaudeCache.delete(accountId) + } + } + for (const accountId of this.inactiveClaudeFetching) { + if (!currentIds.has(accountId)) { + this.inactiveClaudeFetching.delete(accountId) + } + } + } + evictInactiveCodexCache(accountId: string): void { this.inactiveCodexCache.delete(accountId) this.inactiveCodexFetching.delete(accountId) @@ -735,6 +794,7 @@ export class RateLimitService { cache: Map, fetching: Set ): InactiveAccountUsage[] { + this.pruneInactiveClaudeState() const result: InactiveAccountUsage[] = [] for (const [accountId, limits] of cache) { result.push({ diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index b503998e3b3..0437beb2b6a 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -332,15 +332,8 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R const isBusy = claudeAction !== 'idle' return ( -
- + ) }) )} @@ -514,15 +516,8 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R const isBusy = codexAction !== 'idle' return ( -
{/* Why: selecting an account is the primary action in this row. @@ -597,7 +601,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
- + ) })}