Fix Claude scoped Keychain auth (#1735)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-14 10:48:46 -07:00
committed by GitHub
co-authored by Orca
parent 133c8788a5
commit 5b24ae4198
13 changed files with 4751 additions and 381 deletions
+36
View File
@@ -0,0 +1,36 @@
# Claude Runtime Auth Switching
Orca switches Claude Code accounts by materializing a selected managed account into Claude's shared runtime auth surfaces. The runtime surfaces are:
- `.claude/.credentials.json`
- macOS scoped Keychain credentials for the active `CLAUDE_CONFIG_DIR`
- macOS legacy `Claude Code-credentials`
- `.claude.json` `oauthAccount`
## Core Invariants
1. Never write a user/runtime surface unless Orca can prove it owns the current value on that surface.
2. Treat each credential surface independently. File, scoped Keychain, and legacy Keychain can each be owned, external, missing, or unknown.
3. `oauthAccount` metadata is restored or cleared only when metadata itself matches Orca's last managed value, or when a credential surface has already proved the current runtime state belongs to the managed account being cleaned up.
4. Invalid or unparsable runtime config is unknown, not null. Unknown config must be preserved.
5. Missing managed account records are unknown. Orca clears the active selection but does not mutate runtime auth without account identity proof.
6. Missing managed credentials for an existing account can be cleaned up using the account record identity. Only surfaces whose current credentials match that account are restored or cleared.
7. Read-back of refreshed tokens must evaluate all runtime credential candidates and persist only a single unambiguous managed-account match.
## Snapshot Policy
Before entering managed mode from system mode, Orca captures the system-default runtime state. On restore, the snapshot is only applied to surfaces whose current value still equals Orca's managed value, except for missing-managed-credential recovery where account identity is the proof.
Snapshots are schema-validated before use. Invalid snapshots are deleted and treated as absent.
When recapturing while the credentials file still equals the managed account, Orca preserves any previous snapshot value for Keychain surfaces that still equal the managed credentials. This prevents a failed restore followed by restart from recapturing managed Keychain values as system defaults.
## Read-Back Policy
Claude can refresh OAuth tokens in any runtime credential surface. Orca reads all available candidates, filters out stale or ambiguous matches, then chooses the freshest accepted candidate. Cold-start read-back is conservative: credentials must be newer than the matched managed account. Warm read-back rejects only metadata-proven older credentials, allowing equal-expiry token rotation.
## Failure Policy
Keychain reads during snapshot capture must succeed for both active services on macOS; otherwise Orca aborts managed entry. Best-effort Keychain reads are acceptable for token read-back because another surface may still contain a fresh candidate.
Add-account cleanup must restore/delete the legacy active Keychain item before reporting success. If cleanup fails, the login is treated as failed so Orca does not silently leave the user's legacy Claude state pointing at the captured account.
+205
View File
@@ -0,0 +1,205 @@
import { createHash } from 'node:crypto'
import { execFile } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
deleteActiveClaudeKeychainCredentials,
readActiveClaudeKeychainCredentials,
readActiveClaudeKeychainCredentialsStrict,
writeActiveClaudeKeychainCredentials,
writeActiveClaudeKeychainCredentialsForRuntime
} from './keychain'
vi.mock('node:child_process', () => ({
execFile: vi.fn()
}))
const execFileMock = vi.mocked(execFile)
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform
})
}
function serviceForConfigDir(configDir: string): string {
const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8)
return `Claude Code-credentials-${suffix}`
}
function invokeExecFileCallback(
callback: unknown,
error: Error | null,
stdout: string,
stderr: string
): void {
const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void
execCallback(error, stdout, stderr)
}
describe('Claude Keychain credentials', () => {
beforeEach(() => {
setPlatform('darwin')
execFileMock.mockReset()
})
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
it('reads config-scoped Claude Code 2.1 credentials before legacy credentials', async () => {
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementationOnce((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"scoped"}}\n', '')
return null as never
})
await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe(
'{"claudeAiOauth":{"accessToken":"scoped"}}'
)
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileMock.mock.calls[0][1]).toEqual([
'find-generic-password',
'-s',
scopedService,
'-a',
process.env.USER || process.env.USERNAME || 'user',
'-w'
])
})
it('falls back to the legacy unsuffixed Claude Code credentials service', async () => {
const configDir = '/tmp/orca-claude-login-test'
const notFound = Object.assign(new Error('not found'), { code: 44 })
execFileMock
.mockImplementationOnce((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, notFound, '', 'could not be found')
return null as never
})
.mockImplementationOnce((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, 'legacy\n', '')
return null as never
})
await expect(readActiveClaudeKeychainCredentials(configDir)).resolves.toBe('legacy')
expect(execFileMock.mock.calls[1][1]).toEqual([
'find-generic-password',
'-s',
'Claude Code-credentials',
'-a',
process.env.USER || process.env.USERNAME || 'user',
'-w'
])
})
it('writes active credentials to the config-scoped Claude Code service', async () => {
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementationOnce((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, '', '')
return null as never
})
await writeActiveClaudeKeychainCredentials('credentials-json', configDir)
expect(execFileMock.mock.calls[0][1]).toEqual([
'add-generic-password',
'-U',
'-s',
scopedService,
'-a',
process.env.USER || process.env.USERNAME || 'user',
'-w',
'credentials-json'
])
})
it('writes runtime credentials to scoped and legacy services for old Claude Code compatibility', async () => {
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementation((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, '', '')
return null as never
})
await writeActiveClaudeKeychainCredentialsForRuntime('credentials-json', configDir)
expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([
[
'add-generic-password',
'-U',
'-s',
scopedService,
'-a',
process.env.USER || process.env.USERNAME || 'user',
'-w',
'credentials-json'
],
[
'add-generic-password',
'-U',
'-s',
'Claude Code-credentials',
'-a',
process.env.USER || process.env.USERNAME || 'user',
'-w',
'credentials-json'
]
])
})
it('strictly reads only the requested active credentials service', async () => {
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementationOnce((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, 'scoped\n', '')
return null as never
})
await expect(readActiveClaudeKeychainCredentialsStrict(configDir)).resolves.toBe('scoped')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileMock.mock.calls[0][1]).toEqual([
'find-generic-password',
'-s',
scopedService,
'-a',
process.env.USER || process.env.USERNAME || 'user',
'-w'
])
})
it('deletes both scoped and legacy active credentials for config-dir cleanup', async () => {
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementation((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, '', '')
return null as never
})
await deleteActiveClaudeKeychainCredentials(configDir)
expect(execFileMock.mock.calls.map((call) => call[1])).toEqual([
[
'delete-generic-password',
'-s',
scopedService,
'-a',
process.env.USER || process.env.USERNAME || 'user'
],
[
'delete-generic-password',
'-s',
'Claude Code-credentials',
'-a',
process.env.USER || process.env.USERNAME || 'user'
]
])
})
})
+59 -8
View File
@@ -1,22 +1,56 @@
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials'
const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials'
export async function readActiveClaudeKeychainCredentials(): Promise<string | null> {
return readKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser())
export async function readActiveClaudeKeychainCredentials(
configDir?: string
): Promise<string | null> {
for (const service of getActiveClaudeServices(configDir)) {
const credentials = await readKeychainPassword(service, getKeychainUser())
if (credentials) {
return credentials
}
}
return null
}
export async function writeActiveClaudeKeychainCredentials(contents: string): Promise<void> {
await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser(), contents)
export async function readActiveClaudeKeychainCredentialsStrict(
configDir?: string
): Promise<string | null> {
return readKeychainPassword(getActiveClaudeService(configDir), getKeychainUser())
}
export async function deleteActiveClaudeKeychainCredentials(): Promise<void> {
await deleteKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser())
export async function writeActiveClaudeKeychainCredentials(
contents: string,
configDir?: string
): Promise<void> {
await writeKeychainPassword(getActiveClaudeService(configDir), getKeychainUser(), contents)
}
export async function deleteActiveClaudeKeychainCredentialsStrict(): Promise<void> {
await deleteKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser(), {
export async function writeActiveClaudeKeychainCredentialsForRuntime(
contents: string,
configDir: string
): Promise<void> {
const user = getKeychainUser()
const scopedService = getActiveClaudeService(configDir)
await writeKeychainPassword(scopedService, user, contents)
if (scopedService !== ACTIVE_CLAUDE_SERVICE) {
await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, user, contents)
}
}
export async function deleteActiveClaudeKeychainCredentials(configDir?: string): Promise<void> {
for (const service of getActiveClaudeServices(configDir)) {
await deleteKeychainPassword(service, getKeychainUser())
}
}
export async function deleteActiveClaudeKeychainCredentialsStrict(
configDir?: string
): Promise<void> {
await deleteKeychainPassword(getActiveClaudeService(configDir), getKeychainUser(), {
failOnAccessError: true
})
}
@@ -42,6 +76,23 @@ function getKeychainUser(): string {
return process.env.USER || process.env.USERNAME || 'user'
}
function getActiveClaudeService(configDir?: string): string {
if (!configDir) {
return ACTIVE_CLAUDE_SERVICE
}
// Why: Claude Code 2.1+ scopes macOS Keychain credentials by config dir
// using the first 8 hex chars of sha256(CLAUDE_CONFIG_DIR).
const suffix = createHash('sha256').update(configDir).digest('hex').slice(0, 8)
return `${ACTIVE_CLAUDE_SERVICE}-${suffix}`
}
function getActiveClaudeServices(configDir?: string): string[] {
const scopedService = getActiveClaudeService(configDir)
return scopedService === ACTIVE_CLAUDE_SERVICE
? [ACTIVE_CLAUDE_SERVICE]
: [scopedService, ACTIVE_CLAUDE_SERVICE]
}
async function readKeychainPassword(service: string, account: string): Promise<string | null> {
if (process.platform !== 'darwin') {
return null
@@ -0,0 +1,112 @@
import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { join, relative, resolve, sep } from 'node:path'
import { app } from 'electron'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
const MANAGED_AUTH_MARKER = '.orca-managed-claude-auth'
export function getClaudeManagedAccountsRoot(): string {
return join(app.getPath('userData'), 'claude-accounts')
}
export function resolveOwnedClaudeManagedAuthPath(
accountId: string,
candidatePath: string,
options: { adoptLegacyMarker?: boolean } = {}
): string | null {
const rootPath = getClaudeManagedAccountsRoot()
const resolvedCandidate = resolve(candidatePath)
if (!existsSync(resolvedCandidate) || !existsSync(rootPath)) {
return null
}
try {
if (lstatSync(resolvedCandidate).isSymbolicLink()) {
return null
}
const canonicalCandidate = realpathSync(resolvedCandidate)
const canonicalRoot = realpathSync(rootPath)
if (
canonicalCandidate === canonicalRoot ||
!canonicalCandidate.startsWith(canonicalRoot + sep)
) {
return null
}
const relativePath = relative(canonicalRoot, canonicalCandidate)
const relativeParts = relativePath.split(sep)
const escaped = relativePath.startsWith('..') || relativePath.includes(`..${sep}`)
if (
escaped ||
relativeParts.length !== 2 ||
relativeParts[0] !== accountId ||
relativeParts[1] !== 'auth'
) {
return null
}
const markerPath = join(canonicalCandidate, MANAGED_AUTH_MARKER)
const markerValid = isManagedAuthMarkerValid(markerPath, accountId)
if (!markerValid && options.adoptLegacyMarker) {
writeFileSync(markerPath, `${accountId}\n`, { encoding: 'utf-8', mode: 0o600, flag: 'wx' })
}
if (!markerValid && !isManagedAuthMarkerValid(markerPath, accountId)) {
return null
}
return canonicalCandidate
} catch {
return null
}
}
export function readClaudeManagedAuthFile(
managedAuthPath: string,
filename: '.credentials.json' | 'oauth-account.json'
): string | null {
const filePath = resolve(managedAuthPath, filename)
try {
if (!isOwnedChildFile(managedAuthPath, filePath)) {
return null
}
return readFileSync(filePath, 'utf-8')
} catch {
return null
}
}
export function writeClaudeManagedAuthFile(
managedAuthPath: string,
filename: '.credentials.json' | 'oauth-account.json',
contents: string
): void {
const filePath = resolve(managedAuthPath, filename)
if (existsSync(filePath) && !isOwnedChildFile(managedAuthPath, filePath)) {
throw new Error('Managed Claude auth child file is not owned by Orca.')
}
writeFileAtomically(filePath, contents, { mode: 0o600 })
}
function isManagedAuthMarkerValid(markerPath: string, accountId: string): boolean {
try {
if (
!existsSync(markerPath) ||
lstatSync(markerPath).isSymbolicLink() ||
!lstatSync(markerPath).isFile()
) {
return false
}
return readFileSync(markerPath, 'utf-8').trim() === accountId
} catch {
return false
}
}
function isOwnedChildFile(managedAuthPath: string, filePath: string): boolean {
if (
!existsSync(filePath) ||
lstatSync(filePath).isSymbolicLink() ||
!lstatSync(filePath).isFile()
) {
return false
}
const canonicalAuthPath = realpathSync(managedAuthPath)
const canonicalFilePath = realpathSync(filePath)
return canonicalFilePath.startsWith(canonicalAuthPath + sep)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+582
View File
@@ -0,0 +1,582 @@
/* eslint-disable max-lines -- test suite covers Claude capture and rollback edge cases */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
deleteActiveClaudeKeychainCredentialsStrict,
readActiveClaudeKeychainCredentials,
readActiveClaudeKeychainCredentialsStrict,
readManagedClaudeKeychainCredentials,
writeActiveClaudeKeychainCredentials,
writeManagedClaudeKeychainCredentials
} from './keychain'
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-claude-service-test'
}
}))
vi.mock('../codex-cli/command', () => ({
resolveClaudeCommand: () => 'claude'
}))
vi.mock('./keychain', () => ({
deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}),
deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}),
readActiveClaudeKeychainCredentials: vi.fn(),
readActiveClaudeKeychainCredentialsStrict: vi.fn(),
readManagedClaudeKeychainCredentials: vi.fn(),
writeActiveClaudeKeychainCredentials: vi.fn(async () => {}),
writeManagedClaudeKeychainCredentials: vi.fn(async () => {})
}))
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform
})
}
function createService(): unknown {
return {}
}
async function readCapturedCredentials(
configDir: string,
previousLegacyKeychain: string | null
): Promise<string | null> {
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
createService() as never,
createService() as never,
createService() as never
)
return (
service as unknown as {
readCapturedCredentials(
configDir: string,
previousLegacyKeychain: string | null
): Promise<string | null>
}
).readCapturedCredentials(configDir, previousLegacyKeychain)
}
describe('ClaudeAccountService credential capture', () => {
let tempDir: string | null = null
beforeEach(() => {
setPlatform('darwin')
tempDir = null
vi.mocked(readActiveClaudeKeychainCredentials).mockReset()
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset()
vi.mocked(readManagedClaudeKeychainCredentials).mockReset()
vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockClear()
vi.mocked(writeActiveClaudeKeychainCredentials).mockReset()
vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue()
vi.mocked(writeManagedClaudeKeychainCredentials).mockReset()
vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue()
})
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
}
})
it('accepts scoped Keychain capture even when it matches the previous legacy item', async () => {
vi.mocked(readActiveClaudeKeychainCredentialsStrict)
.mockResolvedValueOnce('same-account')
.mockResolvedValueOnce('same-account')
await expect(readCapturedCredentials('/tmp/claude-config', 'same-account')).resolves.toBe(
'same-account'
)
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith('/tmp/claude-config')
expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled()
})
it('rejects unchanged legacy fallback when scoped capture is missing', async () => {
vi.mocked(readActiveClaudeKeychainCredentialsStrict)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce('previous')
await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBeNull()
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(
1,
'/tmp/claude-config'
)
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2)
})
it('accepts changed legacy fallback for old Claude Code builds', async () => {
vi.mocked(readActiveClaudeKeychainCredentialsStrict)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce('new-legacy')
await expect(readCapturedCredentials('/tmp/claude-config', 'previous')).resolves.toBe(
'new-legacy'
)
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(
1,
'/tmp/claude-config'
)
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2)
})
it('falls back to captured credentials file on macOS', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-capture-'))
writeFileSync(join(tempDir, '.credentials.json'), '{"token":"file"}\n', 'utf-8')
vi.mocked(readActiveClaudeKeychainCredentialsStrict)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce('previous')
await expect(readCapturedCredentials(tempDir, 'previous')).resolves.toBe('{"token":"file"}\n')
})
it('fails login capture when legacy Keychain cleanup fails', async () => {
vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue('previous-legacy')
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue('captured-scoped')
vi.mocked(writeActiveClaudeKeychainCredentials).mockRejectedValue(new Error('restore failed'))
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
createService() as never,
createService() as never,
createService() as never
)
const testService = service as unknown as {
runClaudeCommand: () => Promise<string>
runClaudeLoginAndCapture(): Promise<{ credentialsJson: string }>
}
testService.runClaudeCommand = vi.fn(async () => '{"account":{"email":"user@example.com"}}')
await expect(testService.runClaudeLoginAndCapture()).rejects.toThrow('restore failed')
})
it('restores previous managed auth when reauth materialization fails', async () => {
setPlatform('linux')
tempDir = '/tmp/orca-claude-service-test'
rmSync(tempDir, { recursive: true, force: true })
const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
mkdirSync(managedAuthPath, { recursive: true })
writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8')
writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8')
let settings = {
claudeManagedAccounts: [
{
id: 'account-1',
email: 'old@example.com',
managedAuthPath,
authMethod: 'subscription-oauth',
organizationUuid: null,
organizationName: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeClaudeManagedAccountId: 'account-1'
}
const store = {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
}
const runtimeAuth = {
clearLastWrittenCredentialsJson: vi.fn(),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}),
syncForCurrentSelection: vi.fn(async () => {
throw new Error('materialize failed')
})
}
const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() }
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
store as never,
rateLimits as never,
runtimeAuth as never
)
;(
service as unknown as {
runClaudeLoginAndCapture(): Promise<{
credentialsJson: string
oauthAccount: unknown
identity: { email: string; organizationUuid: null; organizationName: null }
}>
}
).runClaudeLoginAndCapture = vi.fn(async () => ({
credentialsJson: '{"new":true}\n',
oauthAccount: { newOauth: true },
identity: { email: 'new@example.com', organizationUuid: null, organizationName: null }
}))
await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed')
expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n')
expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe(
'{"oldOauth":true}\n'
)
expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com')
expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled()
})
it('restores settings without rematerializing when managed-auth rollback write fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
tempDir = '/tmp/orca-claude-service-test'
rmSync(tempDir, { recursive: true, force: true })
const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
mkdirSync(managedAuthPath, { recursive: true })
writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8')
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n')
vi.mocked(writeManagedClaudeKeychainCredentials)
.mockResolvedValueOnce()
.mockRejectedValueOnce(new Error('managed restore failed'))
let settings = {
claudeManagedAccounts: [
{
id: 'account-1',
email: 'old@example.com',
managedAuthPath,
authMethod: 'subscription-oauth',
organizationUuid: null,
organizationName: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeClaudeManagedAccountId: 'account-1'
}
const store = {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
}
const runtimeAuth = {
clearLastWrittenCredentialsJson: vi.fn(),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}),
syncForCurrentSelection: vi.fn(async () => {
throw new Error('materialize failed')
})
}
const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() }
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
store as never,
rateLimits as never,
runtimeAuth as never
)
;(
service as unknown as {
runClaudeLoginAndCapture(): Promise<{
credentialsJson: string
oauthAccount: unknown
identity: { email: string; organizationUuid: null; organizationName: null }
}>
}
).runClaudeLoginAndCapture = vi.fn(async () => ({
credentialsJson: '{"new":true}\n',
oauthAccount: { newOauth: true },
identity: { email: 'new@example.com', organizationUuid: null, organizationName: null }
}))
await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed')
expect(store.getSettings().claudeManagedAccounts[0].email).toBe('new@example.com')
expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith(
'[claude-accounts] Failed to restore managed credentials during rollback:',
expect.any(Error)
)
warn.mockRestore()
})
it('restores oauth metadata when new credential write and credential rollback fail', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
tempDir = '/tmp/orca-claude-service-test'
rmSync(tempDir, { recursive: true, force: true })
const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
mkdirSync(managedAuthPath, { recursive: true })
writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8')
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue('{"old":true}\n')
vi.mocked(writeManagedClaudeKeychainCredentials)
.mockRejectedValueOnce(new Error('new credentials failed'))
.mockRejectedValueOnce(new Error('credential rollback failed'))
let settings = {
claudeManagedAccounts: [
{
id: 'account-1',
email: 'old@example.com',
managedAuthPath,
authMethod: 'subscription-oauth',
organizationUuid: null,
organizationName: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeClaudeManagedAccountId: 'account-1'
}
const store = {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
}
const runtimeAuth = {
clearLastWrittenCredentialsJson: vi.fn(),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}),
syncForCurrentSelection: vi.fn()
}
const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() }
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
store as never,
rateLimits as never,
runtimeAuth as never
)
;(
service as unknown as {
runClaudeLoginAndCapture(): Promise<{
credentialsJson: string
oauthAccount: unknown
identity: { email: string; organizationUuid: null; organizationName: null }
}>
}
).runClaudeLoginAndCapture = vi.fn(async () => ({
credentialsJson: '{"new":true}\n',
oauthAccount: { newOauth: true },
identity: { email: 'new@example.com', organizationUuid: null, organizationName: null }
}))
await expect(service.reauthenticateAccount('account-1')).rejects.toThrow(
'new credentials failed'
)
expect(readFileSync(join(managedAuthPath, 'oauth-account.json'), 'utf-8')).toBe(
'{"oldOauth":true}\n'
)
expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com')
expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith(
'[claude-accounts] Failed to restore managed credentials during rollback:',
expect.any(Error)
)
warn.mockRestore()
})
it('restores old metadata when rollback restores credentials but oauth restore fails', async () => {
setPlatform('linux')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
tempDir = '/tmp/orca-claude-service-test'
rmSync(tempDir, { recursive: true, force: true })
const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
const oauthPath = join(managedAuthPath, 'oauth-account.json')
mkdirSync(managedAuthPath, { recursive: true })
writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8')
writeFileSync(oauthPath, '{"oldOauth":true}\n', 'utf-8')
let settings = {
claudeManagedAccounts: [
{
id: 'account-1',
email: 'old@example.com',
managedAuthPath,
authMethod: 'subscription-oauth',
organizationUuid: null,
organizationName: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeClaudeManagedAccountId: 'account-1'
}
const store = {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
}
const runtimeAuth = {
clearLastWrittenCredentialsJson: vi.fn(),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}),
syncForCurrentSelection: vi.fn(async () => {
rmSync(oauthPath, { force: true })
mkdirSync(oauthPath)
throw new Error('materialize failed')
})
}
const rateLimits = { evictInactiveClaudeCache: vi.fn(), refreshForClaudeAccountChange: vi.fn() }
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
store as never,
rateLimits as never,
runtimeAuth as never
)
;(
service as unknown as {
runClaudeLoginAndCapture(): Promise<{
credentialsJson: string
oauthAccount: unknown
identity: { email: string; organizationUuid: null; organizationName: null }
}>
}
).runClaudeLoginAndCapture = vi.fn(async () => ({
credentialsJson: '{"new":true}\n',
oauthAccount: { newOauth: true },
identity: { email: 'new@example.com', organizationUuid: null, organizationName: null }
}))
await expect(service.reauthenticateAccount('account-1')).rejects.toThrow('materialize failed')
expect(readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')).toBe('{"old":true}\n')
expect(store.getSettings().claudeManagedAccounts[0].email).toBe('old@example.com')
expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled()
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
it('refreshes rate limits without recaching a removed active account', async () => {
setPlatform('linux')
tempDir = '/tmp/orca-claude-service-test'
rmSync(tempDir, { recursive: true, force: true })
const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
mkdirSync(managedAuthPath, { recursive: true })
writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8')
let settings = {
claudeManagedAccounts: [
{
id: 'account-1',
email: 'old@example.com',
managedAuthPath,
authMethod: 'subscription-oauth',
organizationUuid: null,
organizationName: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeClaudeManagedAccountId: 'account-1'
}
const store = {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
}
const runtimeAuth = {
syncForCurrentSelection: vi.fn(async () => {}),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
}
const rateLimits = {
evictInactiveClaudeCache: vi.fn(),
refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null }))
}
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
store as never,
rateLimits as never,
runtimeAuth as never
)
await service.removeAccount('account-1')
expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1')
expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith()
expect(settings).toMatchObject({
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null
})
})
it('evicts inactive rate-limit cache after successful reauth', async () => {
setPlatform('linux')
tempDir = '/tmp/orca-claude-service-test'
rmSync(tempDir, { recursive: true, force: true })
const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
mkdirSync(managedAuthPath, { recursive: true })
writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8')
writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8')
let settings = {
claudeManagedAccounts: [
{
id: 'account-1',
email: 'old@example.com',
managedAuthPath,
authMethod: 'subscription-oauth',
organizationUuid: null,
organizationName: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeClaudeManagedAccountId: null
}
const store = {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
}
const runtimeAuth = {
clearLastWrittenCredentialsJson: vi.fn(),
syncForCurrentSelection: vi.fn(async () => {}),
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
}
const rateLimits = {
evictInactiveClaudeCache: vi.fn(),
refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null }))
}
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
store as never,
rateLimits as never,
runtimeAuth as never
)
;(
service as unknown as {
runClaudeLoginAndCapture(): Promise<{
credentialsJson: string
oauthAccount: unknown
identity: { email: string; organizationUuid: null; organizationName: null }
}>
}
).runClaudeLoginAndCapture = vi.fn(async () => ({
credentialsJson: '{"new":true}\n',
oauthAccount: { newOauth: true },
identity: { email: 'new@example.com', organizationUuid: null, organizationName: null }
}))
await service.reauthenticateAccount('account-1')
expect(rateLimits.evictInactiveClaudeCache).toHaveBeenCalledWith('account-1')
expect(rateLimits.refreshForClaudeAccountChange).toHaveBeenCalledWith()
expect(settings.claudeManagedAccounts[0].email).toBe('new@example.com')
})
})
+213 -84
View File
@@ -2,18 +2,9 @@
for login, credential capture, Keychain storage, selection, and rate-limit refresh. */
import { randomUUID } from 'node:crypto'
import { spawn } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync
} from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, relative, resolve, sep } from 'node:path'
import { app } from 'electron'
import type {
ClaudeManagedAccount,
ClaudeManagedAccountSummary,
@@ -21,13 +12,20 @@ import type {
} from '../../shared/types'
import type { Store } from '../persistence'
import type { RateLimitService } from '../rate-limits/service'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { resolveClaudeCommand } from '../codex-cli/command'
import type { ClaudeRuntimeAuthService } from './runtime-auth-service'
import {
getClaudeManagedAccountsRoot,
readClaudeManagedAuthFile,
resolveOwnedClaudeManagedAuthPath,
writeClaudeManagedAuthFile
} from './managed-auth-path'
import {
deleteActiveClaudeKeychainCredentialsStrict,
deleteManagedClaudeKeychainCredentials,
readActiveClaudeKeychainCredentials,
readActiveClaudeKeychainCredentialsStrict,
readManagedClaudeKeychainCredentials,
writeActiveClaudeKeychainCredentials,
writeManagedClaudeKeychainCredentials
} from './keychain'
@@ -49,6 +47,11 @@ type CapturedClaudeAuth = {
identity: ClaudeIdentity
}
type ManagedClaudeAuthSnapshot = {
credentialsJson: string | null
oauthAccountJson: string | null
}
export class ClaudeAccountService {
private mutationQueue: Promise<unknown> = Promise.resolve()
@@ -129,38 +132,72 @@ export class ClaudeAccountService {
private async doReauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
const account = this.requireAccount(accountId)
const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath)
const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath, accountId)
const previousSettings = this.store.getSettings()
const previousManagedAuth = await this.readManagedAuthSnapshot(accountId, managedAuthPath)
const captured = await this.runClaudeLoginAndCapture()
if (!captured.identity.email) {
throw new Error('Claude login completed, but Orca could not resolve the account email.')
}
await this.writeManagedAuth(accountId, managedAuthPath, captured)
const settings = this.store.getSettings()
const now = Date.now()
this.store.updateSettings({
claudeManagedAccounts: settings.claudeManagedAccounts.map((entry) =>
entry.id === accountId
? {
...entry,
email: captured.identity.email!,
organizationUuid: captured.identity.organizationUuid,
organizationName: captured.identity.organizationName,
updatedAt: now,
lastAuthenticatedAt: now
}
: entry
)
})
const reauthenticatedAccounts = settings.claudeManagedAccounts.map((entry) =>
entry.id === accountId
? {
...entry,
email: captured.identity.email!,
organizationUuid: captured.identity.organizationUuid,
organizationName: captured.identity.organizationName,
updatedAt: now,
lastAuthenticatedAt: now
}
: entry
)
let wroteManagedCredentials = false
try {
await this.writeManagedOauthAccount(accountId, managedAuthPath, captured.oauthAccount)
await this.writeManagedCredentials(accountId, managedAuthPath, captured.credentialsJson)
wroteManagedCredentials = true
this.store.updateSettings({ claudeManagedAccounts: reauthenticatedAccounts })
this.runtimeAuth.clearLastWrittenCredentialsJson(accountId)
this.rateLimits.evictInactiveClaudeCache(accountId)
await this.syncRuntimeAuthWithLivePtyGate()
await this.rateLimits.refreshForClaudeAccountChange()
return this.getSnapshot()
} catch (error) {
this.restoreClaudeSettings(previousSettings)
await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback()
let restoredManagedCredentials = false
try {
await this.restoreManagedCredentialsSnapshot(
accountId,
managedAuthPath,
previousManagedAuth
)
restoredManagedCredentials = true
} catch (rollbackError) {
console.warn(
'[claude-accounts] Failed to restore managed credentials during rollback:',
rollbackError
)
}
if (restoredManagedCredentials || !wroteManagedCredentials) {
try {
this.restoreManagedOauthSnapshot(accountId, managedAuthPath, previousManagedAuth)
} catch (rollbackError) {
console.warn(
'[claude-accounts] Failed to restore managed oauth metadata during rollback:',
rollbackError
)
}
}
if (restoredManagedCredentials) {
this.restoreClaudeSettings(previousSettings)
await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback()
} else if (wroteManagedCredentials) {
this.store.updateSettings({ claudeManagedAccounts: reauthenticatedAccounts })
} else {
this.restoreClaudeSettings(previousSettings)
}
throw error
}
}
@@ -174,19 +211,21 @@ export class ClaudeAccountService {
? null
: settings.activeClaudeManagedAccountId
this.store.updateSettings({
claudeManagedAccounts: nextAccounts,
activeClaudeManagedAccountId: nextActiveId
})
try {
await this.syncRuntimeAuthWithLivePtyGate()
if (settings.activeClaudeManagedAccountId === accountId) {
this.store.updateSettings({ activeClaudeManagedAccountId: null })
await this.syncRuntimeAuthWithLivePtyGate()
this.store.updateSettings({ claudeManagedAccounts: nextAccounts })
} else {
this.store.updateSettings({
claudeManagedAccounts: nextAccounts,
activeClaudeManagedAccountId: nextActiveId
})
await this.syncRuntimeAuthWithLivePtyGate()
}
await this.safeRemoveManagedAuth(accountId, account.managedAuthPath)
this.rateLimits.evictInactiveClaudeCache(accountId)
await this.rateLimits.refreshForClaudeAccountChange(
settings.activeClaudeManagedAccountId === accountId
? settings.activeClaudeManagedAccountId
: undefined
)
await this.rateLimits.refreshForClaudeAccountChange()
return this.getSnapshot()
} catch (error) {
this.restoreClaudeSettings(settings)
@@ -277,7 +316,10 @@ export class ClaudeAccountService {
private async runClaudeLoginAndCapture(): Promise<CapturedClaudeAuth> {
const tempConfigDir = mkdtempSync(join(tmpdir(), 'orca-claude-login-'))
const previousActiveKeychain = await readActiveClaudeKeychainCredentials()
const previousLegacyKeychain = await readActiveClaudeKeychainCredentials()
let captured: CapturedClaudeAuth | null = null
let captureError: unknown = null
let cleanupError: unknown = null
try {
await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfigDir, LOGIN_TIMEOUT_MS)
const status = await this.runClaudeCommand(
@@ -286,25 +328,45 @@ export class ClaudeAccountService {
STATUS_TIMEOUT_MS,
{ allowFailure: true }
)
return await this.captureAuthFromConfigDir(tempConfigDir, status)
captured = await this.captureAuthFromConfigDir(tempConfigDir, status, previousLegacyKeychain)
} catch (error) {
captureError = error
} finally {
if (process.platform === 'darwin' && previousActiveKeychain) {
// Why: Claude login writes the global active Keychain item even when
// CLAUDE_CONFIG_DIR points elsewhere. Restore it so adding an account
// does not switch the user's external Claude CLI out from under them.
await writeActiveClaudeKeychainCredentials(previousActiveKeychain)
} else if (process.platform === 'darwin') {
await deleteActiveClaudeKeychainCredentialsStrict()
if (process.platform === 'darwin') {
try {
await deleteActiveClaudeKeychainCredentialsStrict(tempConfigDir)
} catch (error) {
console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error)
}
}
if (process.platform === 'darwin') {
try {
// Why: older Claude versions ignored CLAUDE_CONFIG_DIR and wrote the
// legacy active Keychain item. Preserve that external CLI state.
await (previousLegacyKeychain
? writeActiveClaudeKeychainCredentials(previousLegacyKeychain)
: deleteActiveClaudeKeychainCredentialsStrict())
} catch (error) {
cleanupError = error
}
}
rmSync(tempConfigDir, { recursive: true, force: true })
}
if (captureError) {
throw captureError
}
if (cleanupError) {
throw cleanupError
}
return captured!
}
private async captureAuthFromConfigDir(
configDir: string,
statusOutput: string
statusOutput: string,
previousLegacyKeychain: string | null
): Promise<CapturedClaudeAuth> {
const credentialsJson = await this.readCapturedCredentials(configDir)
const credentialsJson = await this.readCapturedCredentials(configDir, previousLegacyKeychain)
if (!credentialsJson) {
throw new Error('Claude login completed, but no OAuth credentials were captured.')
}
@@ -313,9 +375,19 @@ export class ClaudeAccountService {
return { credentialsJson, oauthAccount, identity }
}
private async readCapturedCredentials(configDir: string): Promise<string | null> {
private async readCapturedCredentials(
configDir: string,
previousLegacyKeychain: string | null
): Promise<string | null> {
if (process.platform === 'darwin') {
return readActiveClaudeKeychainCredentials()
const scopedCredentialsJson = await readActiveClaudeKeychainCredentialsStrict(configDir)
if (scopedCredentialsJson) {
return scopedCredentialsJson
}
const legacyCredentialsJson = await readActiveClaudeKeychainCredentialsStrict()
if (legacyCredentialsJson && legacyCredentialsJson !== previousLegacyKeychain) {
return legacyCredentialsJson
}
}
const credentialsPath = join(configDir, '.credentials.json')
return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null
@@ -372,63 +444,120 @@ export class ClaudeAccountService {
managedAuthPath: string,
captured: CapturedClaudeAuth
): Promise<void> {
const trustedPath = this.assertManagedAuthPath(managedAuthPath)
await this.writeManagedCredentials(accountId, managedAuthPath, captured.credentialsJson)
await this.writeManagedOauthAccount(accountId, managedAuthPath, captured.oauthAccount)
}
private async writeManagedCredentials(
accountId: string,
managedAuthPath: string,
credentialsJson: string
): Promise<void> {
const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId)
if (process.platform === 'darwin') {
await writeManagedClaudeKeychainCredentials(accountId, captured.credentialsJson)
await writeManagedClaudeKeychainCredentials(accountId, credentialsJson)
} else {
writeFileAtomically(join(trustedPath, '.credentials.json'), captured.credentialsJson, {
mode: 0o600
})
writeClaudeManagedAuthFile(trustedPath, '.credentials.json', credentialsJson)
}
writeFileAtomically(
join(trustedPath, 'oauth-account.json'),
`${JSON.stringify(captured.oauthAccount, null, 2)}\n`,
{ mode: 0o600 }
}
private async writeManagedOauthAccount(
accountId: string,
managedAuthPath: string,
oauthAccount: unknown
): Promise<void> {
const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId)
writeClaudeManagedAuthFile(
trustedPath,
'oauth-account.json',
`${JSON.stringify(oauthAccount, null, 2)}\n`
)
}
private async readManagedAuthSnapshot(
accountId: string,
managedAuthPath: string
): Promise<ManagedClaudeAuthSnapshot> {
const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId)
return {
credentialsJson:
process.platform === 'darwin'
? await readManagedClaudeKeychainCredentials(accountId)
: readClaudeManagedAuthFile(trustedPath, '.credentials.json'),
oauthAccountJson: readClaudeManagedAuthFile(trustedPath, 'oauth-account.json')
}
}
private async restoreManagedCredentialsSnapshot(
accountId: string,
managedAuthPath: string,
snapshot: ManagedClaudeAuthSnapshot
): Promise<void> {
const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId)
const credentialsPath = join(trustedPath, '.credentials.json')
if (process.platform === 'darwin') {
await (snapshot.credentialsJson !== null
? writeManagedClaudeKeychainCredentials(accountId, snapshot.credentialsJson)
: deleteManagedClaudeKeychainCredentials(accountId))
} else if (snapshot.credentialsJson !== null) {
writeClaudeManagedAuthFile(trustedPath, '.credentials.json', snapshot.credentialsJson)
} else {
rmSync(credentialsPath, { force: true })
}
}
private restoreManagedOauthSnapshot(
accountId: string,
managedAuthPath: string,
snapshot: ManagedClaudeAuthSnapshot
): void {
const trustedPath = this.assertManagedAuthPath(managedAuthPath, accountId)
const oauthPath = join(trustedPath, 'oauth-account.json')
if (snapshot.oauthAccountJson !== null) {
writeClaudeManagedAuthFile(trustedPath, 'oauth-account.json', snapshot.oauthAccountJson)
} else {
rmSync(oauthPath, { force: true })
}
}
private createManagedAuthDir(accountId: string): string {
const managedAuthPath = join(this.getManagedAccountsRoot(), accountId, 'auth')
mkdirSync(managedAuthPath, { recursive: true })
writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8')
return this.assertManagedAuthPath(managedAuthPath)
return this.assertManagedAuthPath(managedAuthPath, accountId)
}
private getManagedAccountsRoot(): string {
const root = join(app.getPath('userData'), 'claude-accounts')
const root = getClaudeManagedAccountsRoot()
mkdirSync(root, { recursive: true })
return root
}
private assertManagedAuthPath(candidatePath: string): string {
const rootPath = this.getManagedAccountsRoot()
const resolvedCandidate = resolve(candidatePath)
const resolvedRoot = resolve(rootPath)
if (!existsSync(resolvedCandidate)) {
private assertManagedAuthPath(candidatePath: string, expectedAccountId?: string): string {
this.getManagedAccountsRoot()
const accountId = expectedAccountId ?? this.readManagedAuthAccountIdFromPath(candidatePath)
if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) {
throw new Error('Managed Claude auth directory does not exist on disk.')
}
const canonicalCandidate = realpathSync(resolvedCandidate)
const canonicalRoot = realpathSync(resolvedRoot)
if (
canonicalCandidate !== canonicalRoot &&
!canonicalCandidate.startsWith(canonicalRoot + sep)
) {
throw new Error(
`Managed Claude auth is outside current storage root (expected under ${canonicalRoot}).`
)
}
const relativePath = relative(canonicalRoot, canonicalCandidate)
const escaped =
relativePath === '' || relativePath.startsWith('..') || relativePath.includes(`..${sep}`)
if (escaped || !existsSync(join(canonicalCandidate, '.orca-managed-claude-auth'))) {
const trustedPath = resolveOwnedClaudeManagedAuthPath(accountId, candidatePath, {
adoptLegacyMarker: true
})
if (!trustedPath) {
throw new Error('Managed Claude auth storage is not owned by Orca.')
}
return canonicalCandidate
return trustedPath
}
private readManagedAuthAccountIdFromPath(candidatePath: string): string | null {
const rootPath = this.getManagedAccountsRoot()
const relativePath = relative(resolve(rootPath), resolve(candidatePath))
const parts = relativePath.split(sep)
return parts.length === 2 && parts[1] === 'auth' ? parts[0] : null
}
private async safeRemoveManagedAuth(accountId: string, candidatePath: string): Promise<void> {
try {
const managedAuthPath = this.assertManagedAuthPath(candidatePath)
const managedAuthPath = this.assertManagedAuthPath(candidatePath, accountId)
rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true })
} catch (error) {
console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error)
+274
View File
@@ -0,0 +1,274 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
import { fetchViaPty } from './claude-pty'
import {
readActiveClaudeKeychainCredentialsStrict,
readManagedClaudeKeychainCredentials
} from '../claude-accounts/keychain'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted(
() => ({
netFetchMock: vi.fn(),
readFileMock: vi.fn(),
resolveProxyMock: vi.fn(),
setProxyMock: vi.fn(),
appGetPathMock: vi.fn()
})
)
vi.mock('node:fs/promises', () => ({
readFile: readFileMock
}))
vi.mock('electron', () => ({
app: {
getPath: appGetPathMock
},
net: {
fetch: netFetchMock
},
session: {
defaultSession: {
resolveProxy: resolveProxyMock,
setProxy: setProxyMock
}
}
}))
vi.mock('./claude-pty', () => ({
fetchViaPty: vi.fn()
}))
vi.mock('../claude-accounts/keychain', () => ({
readActiveClaudeKeychainCredentials: vi.fn(),
readActiveClaudeKeychainCredentialsStrict: vi.fn(),
readManagedClaudeKeychainCredentials: vi.fn()
}))
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform
})
}
describe('fetchClaudeRateLimits', () => {
let tempDir: string | null = null
beforeEach(() => {
setPlatform('darwin')
tempDir = null
vi.clearAllMocks()
readFileMock.mockRejectedValue(new Error('missing file'))
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null)
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null)
appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test')
resolveProxyMock.mockResolvedValue('DIRECT')
netFetchMock.mockResolvedValue(
new Response(
JSON.stringify({
five_hour: { utilization: 12 },
seven_day: { utilization: 34 }
}),
{ status: 200 }
)
)
vi.mocked(fetchViaPty).mockResolvedValue({
provider: 'claude',
session: { usedPercent: 56, windowMinutes: 300, resetsAt: null, resetDescription: null },
weekly: null,
updatedAt: 1,
error: null,
status: 'ok'
})
})
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
}
})
it('reads scoped default-config Keychain credentials for OAuth usage fetches', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-token',
expiresAt: Date.now() + 60_000
}
})
)
readFileMock.mockResolvedValue(
JSON.stringify({
claudeAiOauth: {
accessToken: 'file-oauth-token',
expiresAt: Date.now() + 60_000
}
})
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 12 },
weekly: { usedPercent: 34 }
})
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir)
expect(netFetchMock).toHaveBeenCalledWith(
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer oauth-token'
})
})
)
})
it('falls back to the credentials file when Keychain access fails', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockRejectedValue(
new Error('Keychain locked')
)
readFileMock.mockResolvedValue(
JSON.stringify({
claudeAiOauth: {
accessToken: 'file-oauth-token',
expiresAt: Date.now() + 60_000
}
})
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
status: 'ok'
})
expect(readFileMock).toHaveBeenCalledWith('/Users/test/.claude/.credentials.json', 'utf-8')
expect(netFetchMock).toHaveBeenCalledWith(
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer file-oauth-token'
})
})
)
})
it('falls back to legacy Keychain when scoped credentials are unusable', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict)
.mockResolvedValueOnce('{not-json')
.mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
accessToken: 'legacy-oauth-token',
expiresAt: Date.now() + 60_000
}
})
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
status: 'ok'
})
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir)
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined)
expect(netFetchMock).toHaveBeenCalledWith(
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer legacy-oauth-token'
})
})
)
})
it('tries PTY usage when OAuth credentials are expired but refreshable', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
accessToken: 'expired-oauth-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() - 60_000
}
})
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 56 }
})
expect(netFetchMock).not.toHaveBeenCalled()
expect(readFileMock).not.toHaveBeenCalled()
expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation })
})
it('does not read inactive managed credentials from unowned auth paths', async () => {
setPlatform('linux')
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
appGetPathMock.mockReturnValue(tempDir)
const unownedAuthPath = join(tempDir, 'unowned', 'auth')
mkdirSync(unownedAuthPath, { recursive: true })
writeFileSync(join(unownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
writeFileSync(
join(unownedAuthPath, '.credentials.json'),
JSON.stringify({
claudeAiOauth: {
accessToken: 'unowned-token',
expiresAt: Date.now() + 60_000
}
}),
'utf-8'
)
await expect(
fetchManagedAccountUsage({ id: 'account-1', managedAuthPath: unownedAuthPath })
).resolves.toMatchObject({
provider: 'claude',
status: 'error',
error: 'No credentials'
})
expect(netFetchMock).not.toHaveBeenCalled()
expect(readFileMock).not.toHaveBeenCalled()
})
})
+101 -71
View File
@@ -1,12 +1,19 @@
import { readFile } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { homedir } from 'node:os'
import path from 'node:path'
import { net, session } from 'electron'
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import { fetchViaPty } from './claude-pty'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import { readManagedClaudeKeychainCredentials } from '../claude-accounts/keychain'
import {
readActiveClaudeKeychainCredentials,
readActiveClaudeKeychainCredentialsStrict,
readManagedClaudeKeychainCredentials
} from '../claude-accounts/keychain'
import {
readClaudeManagedAuthFile,
resolveOwnedClaudeManagedAuthPath
} from '../claude-accounts/managed-auth-path'
const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
const OAUTH_BETA_HEADER = 'oauth-2025-04-20'
@@ -61,72 +68,93 @@ async function ensureProxyFromEnv(): Promise<void> {
// Credential reading — tries multiple sources for an OAuth bearer token
// ---------------------------------------------------------------------------
type ClaudeCredentials = {
claudeAiOauth?: {
accessToken?: string
refreshToken?: string
expiresAt?: number // unix ms
}
}
type KeychainCredentials = {
claudeAiOauth?: {
accessToken?: string
refreshToken?: string
expiresAt?: number
}
}
type OAuthCredentialReadResult = {
token: string | null
hasRefreshableCredentials: boolean
}
// Why: factored out so both the active-account Keychain reader and the
// managed-account reader share the same JSON parsing + expiry check.
function parseOAuthTokenFromCredentialsJson(raw: string): string | null {
function parseOAuthCredentialsJson(raw: string): OAuthCredentialReadResult {
try {
const parsed = JSON.parse(raw) as KeychainCredentials
const token = parsed?.claudeAiOauth?.accessToken
const oauth = parsed?.claudeAiOauth
const token = oauth?.accessToken
if (!token || typeof token !== 'string') {
return null
return { token: null, hasRefreshableCredentials: false }
}
const expiresAt = parsed.claudeAiOauth?.expiresAt
const refreshToken = oauth?.refreshToken
const expiresAt = oauth?.expiresAt
if (typeof expiresAt === 'number' && expiresAt < Date.now()) {
return null
return {
token: null,
hasRefreshableCredentials: typeof refreshToken === 'string' && refreshToken.trim() !== ''
}
}
return token
return { token, hasRefreshableCredentials: true }
} catch {
return null
return { token: null, hasRefreshableCredentials: false }
}
}
/**
* Read OAuth token from macOS Keychain.
* Why: Claude Code v2.x+ stores OAuth credentials in the macOS Keychain
* under service "Claude Code-credentials". This is the standard location
* for Claude Max/Pro OAuth tokens. Only returns a token if the keychain
* entry has a `claudeAiOauth.accessToken` — API key users won't have this.
* Why: Claude Code 2.1+ scopes OAuth Keychain services by CLAUDE_CONFIG_DIR;
* older builds used the legacy unsuffixed service. The shared reader handles both.
*/
async function readFromKeychain(): Promise<string | null> {
async function readFromKeychain(configDir?: string): Promise<OAuthCredentialReadResult> {
if (process.platform !== 'darwin') {
return null
return { token: null, hasRefreshableCredentials: false }
}
return new Promise<string | null>((resolve) => {
const user = process.env.USER ?? ''
if (!user) {
resolve(null)
return
if (configDir) {
const scopedCredentials = await readCredentialsFromStrictKeychain(configDir)
if (scopedCredentials.token) {
return scopedCredentials
}
if (scopedCredentials.hasRefreshableCredentials) {
return scopedCredentials
}
const legacyCredentials = await readCredentialsFromStrictKeychain()
if (legacyCredentials.token) {
return legacyCredentials
}
return {
token: null,
hasRefreshableCredentials:
scopedCredentials.hasRefreshableCredentials || legacyCredentials.hasRefreshableCredentials
}
}
execFile(
'security',
['find-generic-password', '-s', 'Claude Code-credentials', '-a', user, '-w'],
{ timeout: 3_000 },
(err, stdout) => {
if (err || !stdout.trim()) {
resolve(null)
return
}
resolve(parseOAuthTokenFromCredentialsJson(stdout.trim()))
}
)
})
try {
const credentials = await readActiveClaudeKeychainCredentials(configDir)
return credentials
? parseOAuthCredentialsJson(credentials)
: { token: null, hasRefreshableCredentials: false }
} catch {
return { token: null, hasRefreshableCredentials: false }
}
}
async function readCredentialsFromStrictKeychain(
configDir?: string
): Promise<OAuthCredentialReadResult> {
try {
const credentials = await readActiveClaudeKeychainCredentialsStrict(configDir)
return credentials
? parseOAuthCredentialsJson(credentials)
: { token: null, hasRefreshableCredentials: false }
} catch {
return { token: null, hasRefreshableCredentials: false }
}
}
/**
@@ -134,24 +162,13 @@ async function readFromKeychain(): Promise<string | null> {
* Why: older Claude CLI versions store credentials in this plain JSON
* file. We keep it as a fallback for compatibility.
*/
async function readFromCredentialsFile(configDir?: string): Promise<string | null> {
async function readFromCredentialsFile(configDir?: string): Promise<OAuthCredentialReadResult> {
const credPath = path.join(configDir ?? path.join(homedir(), '.claude'), '.credentials.json')
try {
const raw = await readFile(credPath, 'utf-8')
const parsed = JSON.parse(raw) as ClaudeCredentials
const token = parsed?.claudeAiOauth?.accessToken
if (!token || typeof token !== 'string') {
return null
}
const expiresAt = parsed.claudeAiOauth?.expiresAt
if (typeof expiresAt === 'number' && expiresAt < Date.now()) {
return null
}
return token
return parseOAuthCredentialsJson(raw)
} catch {
return null
return { token: null, hasRefreshableCredentials: false }
}
}
@@ -161,20 +178,27 @@ async function readFromCredentialsFile(configDir?: string): Promise<string | nul
* here — those are API keys which return 401 on the OAuth usage endpoint.
* API-key users are served by the PTY fallback instead.
*/
async function readOAuthCredentials(configDir?: string): Promise<string | null> {
async function readOAuthCredentials(configDir?: string): Promise<OAuthCredentialReadResult> {
// 1. macOS Keychain (Claude Max/Pro OAuth)
const fromKeychain = await readFromKeychain()
if (fromKeychain) {
const fromKeychain = await readFromKeychain(configDir)
if (fromKeychain.token) {
return fromKeychain
}
if (fromKeychain.hasRefreshableCredentials) {
return fromKeychain
}
// 2. Legacy credentials file
const fromFile = await readFromCredentialsFile(configDir)
if (fromFile) {
if (fromFile.token) {
return fromFile
}
return null
return {
token: null,
hasRefreshableCredentials:
fromKeychain.hasRefreshableCredentials || fromFile.hasRefreshableCredentials
}
}
// ---------------------------------------------------------------------------
@@ -274,21 +298,20 @@ export async function fetchClaudeRateLimits(options?: {
authPreparation?: ClaudeRuntimeAuthPreparation
}): Promise<ProviderRateLimits> {
// Path A: try OAuth API if we have a genuine OAuth token
const oauthToken = await readOAuthCredentials(
options?.authPreparation?.envPatch.CLAUDE_CONFIG_DIR
)
if (oauthToken) {
const oauthCredentials = await readOAuthCredentials(options?.authPreparation?.configDir)
if (oauthCredentials.token) {
try {
return await fetchViaOAuth(oauthToken)
return await fetchViaOAuth(oauthCredentials.token)
} catch {
// OAuth API failed — fall through to PTY scraping as a backup
// for subscription users whose token may still be valid for the CLI.
}
}
// Path B: PTY fallback — only for subscription plan users (Max/Pro)
// whose OAuth token we found but the API call failed. The CLI's
// `/usage` command is subscription-only, so there's no point
// attempting PTY for API key users.
// Path B: PTY fallback — only for subscription plan users (Max/Pro)
// whose OAuth credentials exist. The CLI can refresh expired OAuth tokens,
// so an expired access token should not be treated like API-key billing.
if (oauthCredentials.token || oauthCredentials.hasRefreshableCredentials) {
try {
return await fetchViaPty({ authPreparation: options?.authPreparation })
} catch (err) {
@@ -332,14 +355,21 @@ export type InactiveClaudeAccountInfo = {
// Using ClaudeRuntimeAuthService would overwrite the active account's auth.
async function readManagedOAuthToken(account: InactiveClaudeAccountInfo): Promise<string | null> {
try {
const managedAuthPath = resolveOwnedClaudeManagedAuthPath(account.id, account.managedAuthPath, {
adoptLegacyMarker: true
})
if (!managedAuthPath) {
return null
}
if (process.platform === 'darwin') {
const raw = await readManagedClaudeKeychainCredentials(account.id)
if (raw) {
return parseOAuthTokenFromCredentialsJson(raw)
return parseOAuthCredentialsJson(raw).token
}
return null
}
return await readFromCredentialsFile(account.managedAuthPath)
const raw = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json')
return raw ? parseOAuthCredentialsJson(raw).token : null
} catch {
return null
}
+72 -2
View File
@@ -5,13 +5,14 @@ Keeping them in one file makes the ordering contract reviewable as a unit. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
import { RateLimitService } from './service'
import { fetchClaudeRateLimits } from './claude-fetcher'
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
vi.mock('./claude-fetcher', () => ({
fetchClaudeRateLimits: vi.fn()
fetchClaudeRateLimits: vi.fn(),
fetchManagedAccountUsage: vi.fn()
}))
vi.mock('./codex-fetcher', () => ({
@@ -371,4 +372,73 @@ describe('RateLimitService', () => {
expect(state.opencodeGo?.session).toBeNull()
expect(state.opencodeGo?.error).toBe('No workspace ID found')
})
it('does not recache an inactive Claude account removed during fetch-on-open', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
let inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }]
service.setInactiveClaudeAccountsResolver(() => inactiveAccounts)
service.setClaudeAuthPreparationResolver(async () => ({
configDir: '/tmp/.claude',
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}))
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7))
await service.refresh()
vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise)
const fetchOnOpen = service.fetchInactiveClaudeAccountsOnOpen()
await Promise.resolve()
expect(service.getState().inactiveClaudeAccounts).toEqual([
{ accountId: 'account-1', claude: null, updatedAt: 0, isFetching: true }
])
service.evictInactiveClaudeCache('account-1')
inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }]
await service.refreshForClaudeAccountChange('account-1')
expect(service.getState().inactiveClaudeAccounts[0]?.accountId).toBe('account-1')
inactiveAccounts = []
service.evictInactiveClaudeCache('account-1')
accountFetch.resolve(okProvider('claude', 42))
await fetchOnOpen
expect(service.getState().inactiveClaudeAccounts).toEqual([])
})
it('does not overwrite inactive Claude cache from a stale same-id fetch', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
service.setInactiveClaudeAccountsResolver(() => [
{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
])
service.setClaudeAuthPreparationResolver(async () => ({
configDir: '/tmp/.claude',
envPatch: {},
stripAuthEnv: false,
provenance: 'system'
}))
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7))
await service.refresh()
vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise)
const fetchOnOpen = service.fetchInactiveClaudeAccountsOnOpen()
await Promise.resolve()
await service.refreshForClaudeAccountChange('account-1')
accountFetch.resolve(okProvider('claude', 42))
await fetchOnOpen
expect(service.getState().inactiveClaudeAccounts).toEqual([
{
accountId: 'account-1',
claude: expect.objectContaining({
session: expect.objectContaining({ usedPercent: 7 })
}),
updatedAt: expect.any(Number),
isFetching: false
}
])
})
})
+61 -1
View File
@@ -73,6 +73,7 @@ export class RateLimitService {
private inactiveClaudeFetching = new Set<string>()
private inactiveCodexFetching = new Set<string>()
private lastInactiveClaudeFetchAt = 0
private inactiveClaudeAccountsGeneration = 0
private lastInactiveCodexFetchAt = 0
private stateListeners = new Set<(state: RateLimitState) => void>()
@@ -105,6 +106,7 @@ export class RateLimitService {
setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void {
this.inactiveClaudeAccountsResolver = resolver
this.inactiveClaudeAccountsGeneration += 1
}
setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void {
@@ -193,6 +195,8 @@ export class RateLimitService {
if (outgoingAccountId && this.state.claude?.session) {
this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude)
}
this.inactiveClaudeAccountsGeneration += 1
this.pruneInactiveClaudeState()
this.claudeFetchGeneration += 1
this.lastInactiveClaudeFetchAt = 0
this.updateState({
@@ -207,10 +211,12 @@ export class RateLimitService {
if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) {
return
}
this.pruneInactiveClaudeState()
const accounts = this.inactiveClaudeAccountsResolver?.() ?? []
if (accounts.length === 0) {
return
}
const fetchGeneration = this.inactiveClaudeAccountsGeneration
for (const account of accounts) {
this.inactiveClaudeFetching.add(account.id)
@@ -218,19 +224,49 @@ export class RateLimitService {
this.pushToRenderer()
for (const account of accounts) {
if (
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
!this.isCurrentInactiveClaudeAccount(account.id)
) {
this.inactiveClaudeFetching.delete(account.id)
if (!this.isCurrentInactiveClaudeAccount(account.id)) {
this.inactiveClaudeCache.delete(account.id)
}
this.pushToRenderer()
continue
}
try {
const fresh = await fetchManagedAccountUsage(account)
if (
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
!this.isCurrentInactiveClaudeAccount(account.id)
) {
this.inactiveClaudeFetching.delete(account.id)
if (!this.isCurrentInactiveClaudeAccount(account.id)) {
this.inactiveClaudeCache.delete(account.id)
}
this.pushToRenderer()
continue
}
const cached = this.inactiveClaudeCache.get(account.id) ?? null
this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached))
} catch {
// Why: per-account try/catch prevents one Keychain rejection or
// network error from aborting the remaining accounts in the batch.
if (
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
!this.isCurrentInactiveClaudeAccount(account.id)
) {
this.inactiveClaudeCache.delete(account.id)
}
}
this.inactiveClaudeFetching.delete(account.id)
this.pushToRenderer()
}
this.lastInactiveClaudeFetchAt = Date.now()
if (fetchGeneration === this.inactiveClaudeAccountsGeneration) {
this.lastInactiveClaudeFetchAt = Date.now()
}
}
async fetchInactiveCodexAccountsOnOpen(): Promise<void> {
@@ -266,11 +302,34 @@ export class RateLimitService {
}
evictInactiveClaudeCache(accountId: string): void {
this.inactiveClaudeAccountsGeneration += 1
this.inactiveClaudeCache.delete(accountId)
this.inactiveClaudeFetching.delete(accountId)
this.pushToRenderer()
}
private isCurrentInactiveClaudeAccount(accountId: string): boolean {
return (this.inactiveClaudeAccountsResolver?.() ?? []).some(
(account) => account.id === accountId
)
}
private pruneInactiveClaudeState(): void {
const currentIds = new Set(
(this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id)
)
for (const accountId of this.inactiveClaudeCache.keys()) {
if (!currentIds.has(accountId)) {
this.inactiveClaudeCache.delete(accountId)
}
}
for (const accountId of this.inactiveClaudeFetching) {
if (!currentIds.has(accountId)) {
this.inactiveClaudeFetching.delete(accountId)
}
}
}
evictInactiveCodexCache(accountId: string): void {
this.inactiveCodexCache.delete(accountId)
this.inactiveCodexFetching.delete(accountId)
@@ -735,6 +794,7 @@ export class RateLimitService {
cache: Map<string, ProviderRateLimits>,
fetching: Set<string>
): InactiveAccountUsage[] {
this.pruneInactiveClaudeState()
const result: InactiveAccountUsage[] = []
for (const [accountId, limits] of cache) {
result.push({
@@ -332,15 +332,8 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
const isBusy = claudeAction !== 'idle'
return (
<button
<div
key={account.id}
type="button"
onClick={() =>
void runClaudeAccountAction(`select:${account.id}`, () =>
window.api.claudeAccounts.select({ accountId: account.id })
)
}
disabled={isBusy}
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
isActive
? 'border-foreground/20 bg-accent/15'
@@ -348,7 +341,16 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
}`}
>
<div className="flex w-full items-center justify-between gap-3 max-md:flex-col max-md:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<button
type="button"
onClick={() =>
void runClaudeAccountAction(`select:${account.id}`, () =>
window.api.claudeAccounts.select({ accountId: account.id })
)
}
disabled={isBusy}
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left disabled:cursor-default"
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{account.email}</span>
{isActive ? (
@@ -365,7 +367,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
? `${account.organizationName} · ${formatAccountTimestamp(account.lastAuthenticatedAt)}`
: formatAccountTimestamp(account.lastAuthenticatedAt)}
</span>
</div>
</button>
<div className="flex shrink-0 items-center justify-end gap-1 max-md:w-full max-md:flex-wrap">
<Button
variant="ghost"
@@ -401,7 +403,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
</Button>
</div>
</div>
</button>
</div>
)
})
)}
@@ -514,15 +516,8 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
const isBusy = codexAction !== 'idle'
return (
<button
<div
key={account.id}
type="button"
onClick={() =>
void runCodexAccountAction(`select:${account.id}`, () =>
window.api.codexAccounts.select({ accountId: account.id })
)
}
disabled={isBusy}
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
isActive
? 'border-foreground/20 bg-accent/15'
@@ -530,7 +525,16 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
}`}
>
<div className="flex w-full items-center justify-between gap-3 max-md:flex-col max-md:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<button
type="button"
onClick={() =>
void runCodexAccountAction(`select:${account.id}`, () =>
window.api.codexAccounts.select({ accountId: account.id })
)
}
disabled={isBusy}
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left disabled:cursor-default"
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{account.email}</span>
{isActive ? (
@@ -553,7 +557,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
{formatAccountTimestamp(account.lastAuthenticatedAt)}
</span>
</div>
</div>
</button>
<div className="flex shrink-0 items-center justify-end gap-1 max-md:w-full max-md:flex-wrap">
{/* Why: selecting an account is the primary action in this row.
@@ -597,7 +601,7 @@ export function AccountsPane({ settings, updateSettings }: AccountsPaneProps): R
</Button>
</div>
</div>
</button>
</div>
)
})}
</div>