Read the scoped Keychain channel the managed bridge actually uses

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
This commit is contained in:
Merge Sim
2026-09-01 21:19:10 -07:00
parent 4b8f97c192
commit 0cf48d64f9
3 changed files with 320 additions and 166 deletions
@@ -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
@@ -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<typeof import('node:os')>('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
})
})
})
@@ -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,