mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 08:02:09 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user