Degrade the credential medium, not the account, when the Keychain fails

A managed pane that could not reach its config-scoped Keychain item was rerouted
at the user's personal config dir, so the session silently ran as a different
account and spent that account's quota. Write the managed credentials into the
isolated home's own credentials file instead — the medium the CLI already falls
back to — and keep the pane on its account. The system lane now applies only
when no managed credential can be read at all, where it is still surfaced.

Claude-Session: https://claude.ai/code/session_012E63B2jhajtUbArQHhZjVQ
This commit is contained in:
Merge Sim
2026-09-01 21:42:37 -07:00
parent 0cf48d64f9
commit dd36819903
4 changed files with 68 additions and 7 deletions
@@ -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
@@ -27,6 +27,7 @@ export const testState = {
throwRuntimeKeychainWrite: false,
throwLegacyRuntimeKeychainWrite: false,
throwScopedKeychainWrite: false,
throwManagedKeychainRead: false,
runtimeWriteConfigDir: null as string | null,
scopedKeychainCredentialsByConfigDir: new Map<string, string>(),
managedKeychainCredentials: new Map<string, string>()
@@ -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)
})
@@ -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',
@@ -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<boolean> {
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