From 0cf48d64f9ffa95cbd6589813345fff07255ef7b Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Tue, 1 Sep 2026 21:19:10 -0700 Subject: [PATCH] Read the scoped Keychain channel the managed bridge actually uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scoped Keychain mock keys managed config dirs by path, so tests seeding the shared field left the bridge re-reading whatever the previous launch wrote — the foreign credential never reached the code under test and the identity assertions passed without exercising the guard. Splits the macOS bridge cases into their own file; the combined file crossed the 800-line ceiling. Claude-Session: https://claude.ai/code/session_012E63B2jhajtUbArQHhZjVQ --- ...me-auth-service-keychain-snapshots.test.ts | 165 ---------- ...th-service-managed-keychain-bridge.test.ts | 302 ++++++++++++++++++ .../runtime-auth-service-test-harness.ts | 19 +- 3 files changed, 320 insertions(+), 166 deletions(-) create mode 100644 src/main/claude-accounts/runtime-auth-service-managed-keychain-bridge.test.ts diff --git a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts b/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts index 6d13de5194d..6b36876148e 100644 --- a/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts @@ -41,171 +41,6 @@ describe('ClaudeRuntimeAuthService', () => { cleanupRuntimeAuthTestState() }) - it('bridges host managed credentials into the macOS config-scoped Keychain item', 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) - - await service.prepareForClaudeLaunch() - - expect(testState.scopedKeychainCredentials).toBe(credentials) - expect(testState.legacyKeychainCredentials).toBeNull() - }) - - it('repairs a stale scoped Keychain item after re-authentication', async () => { - if (process.platform !== 'darwin') { - return - } - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale') - const freshCredentials = createClaudeCredentialsJson('user@example.com', 'fresh') - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - staleCredentials - ) - 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) - - await service.prepareForClaudeLaunch() - testState.managedKeychainCredentials.set('account-1', freshCredentials) - testState.scopedKeychainCredentials = staleCredentials - service.clearLastWrittenCredentialsJson('account-1') - - await service.prepareForClaudeLaunch() - - expect(testState.managedKeychainCredentials.get('account-1')).toBe(freshCredentials) - expect(testState.scopedKeychainCredentials).toBe(freshCredentials) - }) - - it('does not import a different identity from the scoped Keychain item', async () => { - if (process.platform !== 'darwin') { - return - } - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed', - null, - 2_000 - ) - const foreignCredentials = createClaudeCredentialsJson( - 'other@example.com', - 'foreign', - null, - 3_000 - ) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - 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) - - await service.prepareForClaudeLaunch() - testState.scopedKeychainCredentials = foreignCredentials - await service.prepareForClaudeLaunch() - - expect(testState.managedKeychainCredentials.get('account-1')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - }) - - it('does not import an older same-identity scoped Keychain credential', async () => { - if (process.platform !== 'darwin') { - return - } - const managedCredentials = createClaudeCredentialsJson( - 'user@example.com', - 'managed', - null, - 2_000 - ) - const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) - const managedAuthPath = createManagedClaudeAuth( - testState.userDataDir, - 'account-1', - managedCredentials - ) - 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) - - await service.prepareForClaudeLaunch() - testState.scopedKeychainCredentials = staleCredentials - await service.prepareForClaudeLaunch() - - expect(testState.managedKeychainCredentials.get('account-1')).toBe(managedCredentials) - expect(testState.scopedKeychainCredentials).toBe(managedCredentials) - }) - - 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 - - const launchPreparation = await service.prepareForClaudeLaunch() - expect(launchPreparation.provenance).toBe('system:managed-keychain-unavailable') - - testState.throwScopedKeychainWrite = false - await expect(service.prepareForRateLimitFetch()).resolves.toMatchObject({ - provenance: 'system:managed-keychain-unavailable', - stripAuthEnv: false - }) - - await service.prepareForClaudeLaunch() - await expect(service.prepareForRateLimitFetch()).resolves.toMatchObject({ - provenance: 'managed:account-1', - stripAuthEnv: true - }) - }) - it('reads back refreshed active keychain credentials on macOS', async () => { if (process.platform !== 'darwin') { return 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 new file mode 100644 index 00000000000..6d986af2ace --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-managed-keychain-bridge.test.ts @@ -0,0 +1,302 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createClaudeCredentialsWithoutEmail, + createElectronMock, + createKeychainMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + resetRuntimeAuthTestState, + setScopedKeychainCredentialsForManagedPath, + testState +} from './runtime-auth-service-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +vi.mock('electron', () => createElectronMock()) + +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +vi.mock('./keychain', () => createKeychainMock()) + +describe('ClaudeRuntimeAuthService', () => { + beforeEach(() => { + resetRuntimeAuthTestState() + }) + + afterEach(() => { + cleanupRuntimeAuthTestState() + }) + + it('bridges host managed credentials into the macOS config-scoped Keychain item', 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) + + await service.prepareForClaudeLaunch() + + expect(testState.scopedKeychainCredentials).toBe(credentials) + expect(testState.legacyKeychainCredentials).toBeNull() + }) + + it('repairs a stale scoped Keychain item after re-authentication', async () => { + if (process.platform !== 'darwin') { + return + } + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale') + const freshCredentials = createClaudeCredentialsJson('user@example.com', 'fresh') + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + staleCredentials + ) + 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) + + await service.prepareForClaudeLaunch() + testState.managedKeychainCredentials.set('account-1', freshCredentials) + testState.scopedKeychainCredentials = staleCredentials + service.clearLastWrittenCredentialsJson('account-1') + + await service.prepareForClaudeLaunch() + + expect(testState.managedKeychainCredentials.get('account-1')).toBe(freshCredentials) + expect(testState.scopedKeychainCredentials).toBe(freshCredentials) + }) + + it('does not import a different identity from the scoped Keychain item', async () => { + if (process.platform !== 'darwin') { + return + } + const managedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'managed', + null, + 2_000 + ) + const foreignCredentials = createClaudeCredentialsJson( + 'other@example.com', + 'foreign', + null, + 3_000 + ) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + 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) + + await service.prepareForClaudeLaunch() + setScopedKeychainCredentialsForManagedPath(managedAuthPath, foreignCredentials) + await service.prepareForClaudeLaunch() + + expect(testState.managedKeychainCredentials.get('account-1')).toBe(managedCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + }) + + it('does not import an older same-identity scoped Keychain credential', async () => { + if (process.platform !== 'darwin') { + return + } + const managedCredentials = createClaudeCredentialsJson( + 'user@example.com', + 'managed', + null, + 2_000 + ) + const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', null, 1_000) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + 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) + + await service.prepareForClaudeLaunch() + setScopedKeychainCredentialsForManagedPath(managedAuthPath, staleCredentials) + await service.prepareForClaudeLaunch() + + expect(testState.managedKeychainCredentials.get('account-1')).toBe(managedCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) + }) + + // Why: real credential blobs carry no identity fields, so identity can only come from the + // .claude.json the CLI keeps inside the config dir those credentials belong to. + it('adopts a rotated scoped credential proven by the managed config dir identity', async () => { + if (process.platform !== 'darwin') { + return + } + const managedCredentials = createClaudeCredentialsWithoutEmail('managed', null, { + expiresAt: 2_000, + refreshToken: 'managed-refresh' + }) + const rotatedCredentials = createClaudeCredentialsWithoutEmail('rotated', null, { + expiresAt: 3_000, + refreshToken: 'rotated-refresh' + }) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + writeFileSync( + join(managedAuthPath, '.claude.json'), + JSON.stringify({ + oauthAccount: { accountUuid: 'account-1', emailAddress: 'user@example.com' } + }), + 'utf-8' + ) + 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) + + await service.prepareForClaudeLaunch() + setScopedKeychainCredentialsForManagedPath(managedAuthPath, rotatedCredentials) + await service.prepareForClaudeLaunch() + + expect(testState.managedKeychainCredentials.get('account-1')).toBe(rotatedCredentials) + }) + + // Why: a stale shared .claude.json left by migration must not vouch for a different login that + // happened inside the managed pane. + it('rejects a scoped credential whose managed config dir identity is a different account', async () => { + if (process.platform !== 'darwin') { + return + } + const managedCredentials = createClaudeCredentialsWithoutEmail('managed', null, { + expiresAt: 2_000, + refreshToken: 'managed-refresh' + }) + const foreignCredentials = createClaudeCredentialsWithoutEmail('foreign', null, { + expiresAt: 3_000, + refreshToken: 'foreign-refresh' + }) + const managedAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'account-1', + managedCredentials + ) + writeFileSync( + join(testState.fakeHomeDir, '.claude.json'), + JSON.stringify({ + oauthAccount: { accountUuid: 'account-1', emailAddress: 'user@example.com' } + }), + 'utf-8' + ) + writeFileSync( + join(managedAuthPath, '.claude.json'), + JSON.stringify({ + oauthAccount: { accountUuid: 'account-2', emailAddress: 'other@example.com' } + }), + 'utf-8' + ) + 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) + + await service.prepareForClaudeLaunch() + setScopedKeychainCredentialsForManagedPath(managedAuthPath, foreignCredentials) + await service.prepareForClaudeLaunch() + + expect(testState.managedKeychainCredentials.get('account-1')).toBe(managedCredentials) + }) + + 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 + + const launchPreparation = await service.prepareForClaudeLaunch() + expect(launchPreparation.provenance).toBe('system:managed-keychain-unavailable') + + testState.throwScopedKeychainWrite = false + await expect(service.prepareForRateLimitFetch()).resolves.toMatchObject({ + provenance: 'system:managed-keychain-unavailable', + stripAuthEnv: false + }) + + await service.prepareForClaudeLaunch() + await expect(service.prepareForRateLimitFetch()).resolves.toMatchObject({ + provenance: 'managed:account-1', + stripAuthEnv: true + }) + }) +}) 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 caccb8657b4..a4c3abf3897 100644 --- a/src/main/claude-accounts/runtime-auth-service-test-harness.ts +++ b/src/main/claude-accounts/runtime-auth-service-test-harness.ts @@ -1,5 +1,13 @@ import { vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getDefaultSettings } from '../../shared/constants' @@ -207,6 +215,15 @@ export function createManagedClaudeAuth( return managedAuthPath } +// Why: the scoped Keychain mock keys managed config dirs by path, so setting the shared field +// leaves the bridge reading whatever the previous launch wrote. +export function setScopedKeychainCredentialsForManagedPath( + managedAuthPath: string, + credentialsJson: string +): void { + testState.scopedKeychainCredentialsByConfigDir.set(realpathSync(managedAuthPath), credentialsJson) +} + export function createClaudeAccount( id: string, managedAuthPath: string,