diff --git a/src/main/claude-accounts/runtime-auth-service-managed-keychain-bridge.test.ts b/src/main/claude-accounts/runtime-auth-service-managed-keychain-bridge.test.ts index 6d986af2ace..cb85a4e83b1 100644 --- a/src/main/claude-accounts/runtime-auth-service-managed-keychain-bridge.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-managed-keychain-bridge.test.ts @@ -14,7 +14,7 @@ import { testState } from './runtime-auth-service-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { writeFileSync } from 'node:fs' +import { readFileSync, realpathSync, writeFileSync } from 'node:fs' import { join } from 'node:path' vi.mock('electron', () => createElectronMock()) @@ -266,7 +266,8 @@ describe('ClaudeRuntimeAuthService', () => { expect(testState.managedKeychainCredentials.get('account-1')).toBe(managedCredentials) }) - it('retains a visible degraded provenance after scoped Keychain bridge failure', async () => { + // Why: precedent is to degrade the storage medium, never the account identity. + it('keeps the managed home and writes its credentials file when the scoped Keychain fails', async () => { if (process.platform !== 'darwin') { return } @@ -284,10 +285,38 @@ describe('ClaudeRuntimeAuthService', () => { const service = new ClaudeRuntimeAuthService(store as never) testState.throwScopedKeychainWrite = true + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation.provenance).toBe('managed:account-1') + expect(preparation.stripAuthEnv).toBe(true) + expect(preparation.configDir).toBe(realpathSync(managedAuthPath)) + expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe(credentials) + }) + + it('retains a visible degraded provenance after scoped Keychain bridge failure', async () => { + if (process.platform !== 'darwin') { + return + } + const credentials = createClaudeCredentialsJson('user@example.com', 'managed') + const managedAuthPath = createManagedClaudeAuth(testState.userDataDir, 'account-1', credentials) + const store = createStore( + createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('account-1', managedAuthPath, { managedAuthRuntime: 'host' }) + ], + activeClaudeManagedAccountId: 'account-1' + }) + ) + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + testState.throwScopedKeychainWrite = true + testState.throwManagedKeychainRead = true + const launchPreparation = await service.prepareForClaudeLaunch() expect(launchPreparation.provenance).toBe('system:managed-keychain-unavailable') testState.throwScopedKeychainWrite = false + testState.throwManagedKeychainRead = false await expect(service.prepareForRateLimitFetch()).resolves.toMatchObject({ provenance: 'system:managed-keychain-unavailable', stripAuthEnv: false diff --git a/src/main/claude-accounts/runtime-auth-service-test-harness.ts b/src/main/claude-accounts/runtime-auth-service-test-harness.ts index a4c3abf3897..3963c96b31f 100644 --- a/src/main/claude-accounts/runtime-auth-service-test-harness.ts +++ b/src/main/claude-accounts/runtime-auth-service-test-harness.ts @@ -27,6 +27,7 @@ export const testState = { throwRuntimeKeychainWrite: false, throwLegacyRuntimeKeychainWrite: false, throwScopedKeychainWrite: false, + throwManagedKeychainRead: false, runtimeWriteConfigDir: null as string | null, scopedKeychainCredentialsByConfigDir: new Map(), managedKeychainCredentials: new Map() @@ -130,9 +131,12 @@ export function createKeychainMock() { testState.activeKeychainCredentials = contents } ), - readManagedClaudeKeychainCredentials: vi.fn( - async (accountId: string) => testState.managedKeychainCredentials.get(accountId) ?? null - ), + readManagedClaudeKeychainCredentials: vi.fn(async (accountId: string) => { + if (testState.throwManagedKeychainRead) { + throw new Error('managed keychain read failed') + } + return testState.managedKeychainCredentials.get(accountId) ?? null + }), writeManagedClaudeKeychainCredentials: vi.fn(async (accountId: string, contents: string) => { testState.managedKeychainCredentials.set(accountId, contents) }) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 57d0af55554..ee382a8eef7 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -109,8 +109,19 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { } } catch { console.warn('[claude-runtime-auth] Failed to bridge macOS managed Claude Keychain') - // Never route a pane at a home whose credential surface could not be - // synchronized; fall back to the system lane instead. + // Degrade the medium before the identity: the CLI reads the config dir's own + // credentials file when the Keychain is unusable, so keep the pane on its account. + try { + if (await this.materializeManagedCredentialsFile(selected)) { + return preparation + } + } catch { + console.warn( + '[claude-runtime-auth] Failed to materialize managed Claude credentials file' + ) + } + // Only with no readable managed credential is there nothing to route at; fall + // back to the system lane and surface it. const fallbackPreparation: ClaudeRuntimeAuthPreparation = { configDir: this.pathResolver.getRuntimePaths().configDir, runtime: 'host', diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts index 1a7f9d5546e..a5f331d04c2 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts @@ -35,6 +35,23 @@ export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCreden return readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') } + // Why: mirrors the CLI's own keychain-primary/file-fallback contract — when the scoped Keychain + // is unusable, degrade the storage medium inside the isolated home, never the account identity. + protected async materializeManagedCredentialsFile( + account: ClaudeManagedAccount + ): Promise { + const managedAuthPath = await this.getOwnedManagedAuthPath(account) + if (!managedAuthPath) { + return false + } + const credentialsJson = await this.readManagedCredentials(account) + if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { + return false + } + writeClaudeManagedAuthFile(managedAuthPath, '.credentials.json', credentialsJson) + return true + } + protected async writeManagedCredentials( account: ClaudeManagedAccount, credentialsJson: string