From 5315e87a81152fd0bd49e8ea68da905ab294c5a6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:05:26 -0400 Subject: [PATCH] feat: add Claude account switcher (#1050) --- src/main/claude-accounts/environment.ts | 52 ++ src/main/claude-accounts/keychain.ts | 126 +++++ src/main/claude-accounts/live-pty-gate.ts | 28 + .../claude-accounts/runtime-auth-service.ts | 303 ++++++++++ src/main/claude-accounts/runtime-paths.ts | 34 ++ src/main/claude-accounts/service.ts | 520 ++++++++++++++++++ .../runtime-home-service.test.ts | 6 +- .../codex-accounts/runtime-home-service.ts | 6 +- src/main/codex-accounts/service.test.ts | 2 + src/main/index.ts | 24 +- src/main/ipc/claude-accounts.ts | 16 + src/main/ipc/pty.ts | 67 ++- src/main/ipc/register-core-handlers.test.ts | 12 + src/main/ipc/register-core-handlers.ts | 4 + src/main/providers/local-pty-provider.ts | 3 + src/main/providers/types.ts | 1 + src/main/rate-limits/claude-fetcher.ts | 19 +- src/main/rate-limits/claude-pty.ts | 14 +- src/main/rate-limits/service.ts | 125 ++++- .../window/attach-main-window-services.ts | 12 +- src/preload/api-types.d.ts | 8 + src/preload/index.ts | 11 + .../src/components/settings/GeneralPane.tsx | 279 +++++++++- .../src/components/settings/general-search.ts | 21 +- .../src/components/status-bar/StatusBar.tsx | 158 +++++- src/shared/constants.ts | 2 + src/shared/types.ts | 33 ++ 27 files changed, 1844 insertions(+), 42 deletions(-) create mode 100644 src/main/claude-accounts/environment.ts create mode 100644 src/main/claude-accounts/keychain.ts create mode 100644 src/main/claude-accounts/live-pty-gate.ts create mode 100644 src/main/claude-accounts/runtime-auth-service.ts create mode 100644 src/main/claude-accounts/runtime-paths.ts create mode 100644 src/main/claude-accounts/service.ts create mode 100644 src/main/ipc/claude-accounts.ts diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts new file mode 100644 index 000000000..83fe3b402 --- /dev/null +++ b/src/main/claude-accounts/environment.ts @@ -0,0 +1,52 @@ +export const CLAUDE_AUTH_ENV_VARS = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'AWS_BEARER_TOKEN_BEDROCK' +] as const + +export type ClaudeEnvPatch = { + CLAUDE_CONFIG_DIR?: string + ANTHROPIC_CUSTOM_HEADERS?: string +} + +export function applyClaudeEnvPatch( + baseEnv: Record, + patch: ClaudeEnvPatch, + options?: { stripAuthEnv?: boolean } +): Record { + if (options?.stripAuthEnv) { + for (const key of CLAUDE_AUTH_ENV_VARS) { + delete baseEnv[key] + } + if (isAuthLikeCustomHeaders(baseEnv.ANTHROPIC_CUSTOM_HEADERS)) { + delete baseEnv.ANTHROPIC_CUSTOM_HEADERS + } + } + + if (patch.CLAUDE_CONFIG_DIR) { + baseEnv.CLAUDE_CONFIG_DIR = patch.CLAUDE_CONFIG_DIR + } + if (patch.ANTHROPIC_CUSTOM_HEADERS !== undefined) { + baseEnv.ANTHROPIC_CUSTOM_HEADERS = patch.ANTHROPIC_CUSTOM_HEADERS + } + + return baseEnv +} + +export function hasClaudeAuthEnvConflict(env: Record | undefined): boolean { + if (!env) { + return false + } + return ( + CLAUDE_AUTH_ENV_VARS.some((key) => Boolean(env[key])) || + isAuthLikeCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS) + ) +} + +function isAuthLikeCustomHeaders(value: string | undefined): boolean { + if (!value) { + return false + } + return /authorization|x-api-key|api-key|bearer/i.test(value) +} diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts new file mode 100644 index 000000000..647bf975d --- /dev/null +++ b/src/main/claude-accounts/keychain.ts @@ -0,0 +1,126 @@ +import { execFile } from 'node:child_process' + +const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' +const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' + +export async function readActiveClaudeKeychainCredentials(): Promise { + return readKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser()) +} + +export async function writeActiveClaudeKeychainCredentials(contents: string): Promise { + await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser(), contents) +} + +export async function deleteActiveClaudeKeychainCredentials(): Promise { + await deleteKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser()) +} + +export async function deleteActiveClaudeKeychainCredentialsStrict(): Promise { + await deleteKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser(), { + failOnAccessError: true + }) +} + +export async function readManagedClaudeKeychainCredentials( + accountId: string +): Promise { + return readKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) +} + +export async function writeManagedClaudeKeychainCredentials( + accountId: string, + contents: string +): Promise { + await writeKeychainPassword(ORCA_CLAUDE_SERVICE, accountId, contents) +} + +export async function deleteManagedClaudeKeychainCredentials(accountId: string): Promise { + await deleteKeychainPassword(ORCA_CLAUDE_SERVICE, accountId) +} + +function getKeychainUser(): string { + return process.env.USER || process.env.USERNAME || 'user' +} + +async function readKeychainPassword(service: string, account: string): Promise { + if (process.platform !== 'darwin') { + return null + } + return new Promise((resolve, reject) => { + execFile( + 'security', + ['find-generic-password', '-s', service, '-a', account, '-w'], + { timeout: 3_000 }, + (error, stdout, stderr) => { + if (!error && stdout.trim()) { + resolve(stdout.trim()) + return + } + const message = `${stderr} ${error?.message ?? ''}`.toLowerCase() + const code = (error as { code?: unknown } | null)?.code + if ( + code === 44 || + message.includes('could not be found') || + message.includes('not be found') + ) { + resolve(null) + return + } + reject(error ?? new Error(`Could not read macOS Keychain item ${service}/${account}.`)) + } + ) + }) +} + +async function writeKeychainPassword( + service: string, + account: string, + contents: string +): Promise { + if (process.platform !== 'darwin') { + return + } + await execSecurity(['add-generic-password', '-U', '-s', service, '-a', account, '-w', contents]) +} + +async function deleteKeychainPassword( + service: string, + account: string, + options?: { failOnAccessError?: boolean } +): Promise { + if (process.platform !== 'darwin') { + return + } + await execSecurity(['delete-generic-password', '-s', service, '-a', account], { + ignoreNotFound: true, + ignoreFailure: !options?.failOnAccessError + }) +} + +function execSecurity( + args: string[], + options?: { ignoreFailure?: boolean; ignoreNotFound?: boolean } +): Promise { + return new Promise((resolve, reject) => { + execFile('security', args, { timeout: 3_000 }, (error, _stdout, stderr) => { + if (!error) { + resolve() + return + } + const code = (error as { code?: unknown }).code + const message = `${stderr} ${error.message}`.toLowerCase() + if ( + options?.ignoreNotFound && + (code === 44 || message.includes('could not be found') || message.includes('not be found')) + ) { + resolve() + return + } + if (!options?.ignoreFailure) { + reject(error) + return + } + resolve() + }) + }) +} diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts new file mode 100644 index 000000000..b87756a35 --- /dev/null +++ b/src/main/claude-accounts/live-pty-gate.ts @@ -0,0 +1,28 @@ +const liveClaudePtyIds = new Set() +let switchInProgress = false + +export function markClaudePtySpawned(ptyId: string): void { + liveClaudePtyIds.add(ptyId) +} + +export function markClaudePtyExited(ptyId: string): void { + liveClaudePtyIds.delete(ptyId) +} + +export function beginClaudeAuthSwitch(): void { + if (switchInProgress) { + throw new Error('A Claude account switch is already in progress.') + } + if (liveClaudePtyIds.size > 0) { + throw new Error('Close or restart live Claude terminals before switching Claude accounts.') + } + switchInProgress = true +} + +export function endClaudeAuthSwitch(): void { + switchInProgress = false +} + +export function isClaudeAuthSwitchInProgress(): boolean { + return switchInProgress +} diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts new file mode 100644 index 000000000..64768f2ee --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -0,0 +1,303 @@ +/* eslint-disable max-lines -- Why: Claude account switching has one safety +boundary: runtime auth materialization. Keeping file, Keychain, snapshot, and +env-patch semantics together prevents PTY launch and quota fetch paths drifting. */ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { app } from 'electron' +import type { ClaudeManagedAccount } from '../../shared/types' +import type { Store } from '../persistence' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import type { ClaudeEnvPatch } from './environment' +import { ClaudeRuntimePathResolver } from './runtime-paths' +import { + deleteActiveClaudeKeychainCredentials, + readActiveClaudeKeychainCredentials, + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials +} from './keychain' + +export type ClaudeRuntimeAuthPreparation = { + envPatch: ClaudeEnvPatch + stripAuthEnv: boolean + provenance: string +} + +type ClaudeSystemDefaultSnapshot = { + credentialsJson: string | null + configOauthAccount: unknown + keychainCredentialsJson: string | null + capturedAt: number +} + +export class ClaudeRuntimeAuthService { + private readonly pathResolver = new ClaudeRuntimePathResolver() + private mutationQueue: Promise = Promise.resolve() + private lastSyncedAccountId: string | null = null + // Why: tracks the credentials Orca last wrote to the shared credentials file. + // On managed→system-default transition, if the file differs from this value, + // an external login (e.g. `claude auth login`) overwrote it — so Orca adopts + // the file as the new system default instead of restoring a stale snapshot. + private lastWrittenCredentialsJson: string | null = null + + constructor(private readonly store: Store) { + this.initializeLastSyncedState() + void this.safeSyncForCurrentSelection() + } + + async prepareForClaudeLaunch(): Promise { + await this.syncForCurrentSelection() + return this.getPreparation() + } + + async prepareForRateLimitFetch(): Promise { + await this.syncForCurrentSelection() + return this.getPreparation() + } + + async syncForCurrentSelection(): Promise { + await this.serializeMutation(() => this.doSyncForCurrentSelection()) + } + + async forceMaterializeCurrentSelectionForRollback(): Promise { + await this.serializeMutation(async () => { + const settings = this.store.getSettings() + if (!settings.activeClaudeManagedAccountId) { + await this.restoreSystemDefaultSnapshot() + this.lastSyncedAccountId = null + return + } + await this.doSyncForCurrentSelection() + }) + } + + getRuntimeConfigDir(): string { + return this.pathResolver.getRuntimePaths().configDir + } + + private initializeLastSyncedState(): void { + const settings = this.store.getSettings() + this.lastSyncedAccountId = settings.activeClaudeManagedAccountId + } + + private async safeSyncForCurrentSelection(): Promise { + try { + await this.syncForCurrentSelection() + } catch (error) { + console.warn('[claude-runtime-auth] Failed to sync runtime auth state:', error) + } + } + + private serializeMutation(fn: () => Promise): Promise { + const next = this.mutationQueue.then(fn, fn) + this.mutationQueue = next.catch(() => {}) + return next + } + + private async doSyncForCurrentSelection(): Promise { + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.claudeManagedAccounts, + settings.activeClaudeManagedAccountId + ) + if (!activeAccount) { + if (this.lastSyncedAccountId !== null) { + await this.restoreSystemDefaultSnapshot() + this.lastSyncedAccountId = null + } + return + } + + await this.captureSystemDefaultSnapshotIfNeeded() + + const credentialsJson = await this.readManagedCredentials(activeAccount) + if (!credentialsJson) { + console.warn( + '[claude-runtime-auth] Active managed account is missing credentials, restoring system default' + ) + this.store.updateSettings({ activeClaudeManagedAccountId: null }) + if (this.lastSyncedAccountId !== null) { + await this.restoreSystemDefaultSnapshot() + this.lastSyncedAccountId = null + } + return + } + + this.writeRuntimeCredentials(credentialsJson) + if (process.platform === 'darwin') { + await writeActiveClaudeKeychainCredentials(credentialsJson) + } + this.writeRuntimeOauthAccount(this.readManagedOauthAccount(activeAccount)) + this.lastSyncedAccountId = activeAccount.id + } + + private getPreparation(): ClaudeRuntimeAuthPreparation { + const settings = this.store.getSettings() + const paths = this.pathResolver.getRuntimePaths() + const activeAccountId = settings.activeClaudeManagedAccountId + return { + envPatch: paths.envPatch, + stripAuthEnv: Boolean(activeAccountId), + provenance: activeAccountId ? `managed:${activeAccountId}` : 'system' + } + } + + private getActiveAccount( + accounts: ClaudeManagedAccount[], + activeAccountId: string | null + ): ClaudeManagedAccount | null { + if (!activeAccountId) { + return null + } + return accounts.find((account) => account.id === activeAccountId) ?? null + } + + private async readManagedCredentials(account: ClaudeManagedAccount): Promise { + if (process.platform === 'darwin') { + return readManagedClaudeKeychainCredentials(account.id) + } + const credentialsPath = join(account.managedAuthPath, '.credentials.json') + if (!existsSync(credentialsPath)) { + return null + } + return readFileSync(credentialsPath, 'utf-8') + } + + private readManagedOauthAccount(account: ClaudeManagedAccount): unknown { + const oauthPath = join(account.managedAuthPath, 'oauth-account.json') + if (!existsSync(oauthPath)) { + return null + } + try { + return JSON.parse(readFileSync(oauthPath, 'utf-8')) as unknown + } catch { + return null + } + } + + private async captureSystemDefaultSnapshotIfNeeded(): Promise { + const snapshotPath = this.getSystemDefaultSnapshotPath() + if (existsSync(snapshotPath)) { + return + } + + const paths = this.pathResolver.getRuntimePaths() + const credentialsJson = existsSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') + : null + const keychainCredentialsJson = await readActiveClaudeKeychainCredentials() + const snapshot: ClaudeSystemDefaultSnapshot = { + credentialsJson, + configOauthAccount: this.readRuntimeOauthAccount(), + keychainCredentialsJson, + capturedAt: Date.now() + } + this.writeJson(snapshotPath, snapshot) + } + + private async restoreSystemDefaultSnapshot(): Promise { + if (this.detectExternalLoginAndUpdateSnapshot()) { + return + } + + const snapshotPath = this.getSystemDefaultSnapshotPath() + if (!existsSync(snapshotPath)) { + return + } + const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as ClaudeSystemDefaultSnapshot + if (snapshot.credentialsJson !== null) { + this.writeRuntimeCredentials(snapshot.credentialsJson) + } else { + rmSync(this.pathResolver.getRuntimePaths().credentialsPath, { force: true }) + } + this.writeRuntimeOauthAccount(snapshot.configOauthAccount) + if (process.platform === 'darwin') { + await (snapshot.keychainCredentialsJson !== null + ? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson) + : deleteActiveClaudeKeychainCredentials()) + } + } + + // Why: detects whether an external tool (e.g. `claude auth login`) overwrote + // the credentials file while a managed account was active. If the file + // differs from what Orca last wrote, that external login becomes the new + // system default — no manual "refresh" button needed. + private detectExternalLoginAndUpdateSnapshot(): boolean { + if (this.lastWrittenCredentialsJson === null) { + return false + } + const paths = this.pathResolver.getRuntimePaths() + if (!existsSync(paths.credentialsPath)) { + return false + } + const currentCredentials = readFileSync(paths.credentialsPath, 'utf-8') + if (currentCredentials === this.lastWrittenCredentialsJson) { + return false + } + // External login detected — adopt current state as the new system default + const snapshotPath = this.getSystemDefaultSnapshotPath() + rmSync(snapshotPath, { force: true }) + this.lastWrittenCredentialsJson = null + return true + } + + private readRuntimeOauthAccount(): unknown { + const configPath = this.pathResolver.getRuntimePaths().configPath + if (!existsSync(configPath)) { + return null + } + try { + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record + return parsed.oauthAccount ?? null + } catch { + return null + } + } + + private writeRuntimeOauthAccount(oauthAccount: unknown): void { + const configPath = this.pathResolver.getRuntimePaths().configPath + const existing = this.readJsonObject(configPath) + if (oauthAccount === null || oauthAccount === undefined) { + delete existing.oauthAccount + } else { + existing.oauthAccount = oauthAccount + } + this.writeJson(configPath, existing) + } + + private writeRuntimeCredentials(contents: string): void { + const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath + mkdirSync(dirname(credentialsPath), { recursive: true }) + writeFileAtomically(credentialsPath, contents, { mode: 0o600 }) + this.lastWrittenCredentialsJson = contents + } + + private writeJson(targetPath: string, value: unknown): void { + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileAtomically(targetPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }) + } + + private readJsonObject(targetPath: string): Record { + if (!existsSync(targetPath)) { + return {} + } + try { + const parsed = JSON.parse(readFileSync(targetPath, 'utf-8')) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + /* Preserve no invalid JSON; Claude can recreate unsupported config files. */ + } + return {} + } + + private getRuntimeMetadataDir(): string { + const metadataDir = join(app.getPath('userData'), 'claude-runtime-auth') + mkdirSync(metadataDir, { recursive: true }) + return metadataDir + } + + private getSystemDefaultSnapshotPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') + } +} diff --git a/src/main/claude-accounts/runtime-paths.ts b/src/main/claude-accounts/runtime-paths.ts new file mode 100644 index 000000000..350cdd083 --- /dev/null +++ b/src/main/claude-accounts/runtime-paths.ts @@ -0,0 +1,34 @@ +import { existsSync, mkdirSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { ClaudeEnvPatch } from './environment' + +export type ClaudeRuntimePaths = { + configDir: string + credentialsPath: string + configPath: string + envPatch: ClaudeEnvPatch +} + +export class ClaudeRuntimePathResolver { + getRuntimePaths(): ClaudeRuntimePaths { + const inheritedConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || null + const configDir = inheritedConfigDir || join(homedir(), '.claude') + mkdirSync(configDir, { recursive: true }) + + return { + configDir, + credentialsPath: join(configDir, '.credentials.json'), + configPath: this.resolveConfigPath(configDir, inheritedConfigDir), + envPatch: inheritedConfigDir ? { CLAUDE_CONFIG_DIR: configDir } : {} + } + } + + private resolveConfigPath(configDir: string, inheritedConfigDir: string | null): string { + const colocatedConfigPath = join(configDir, '.claude.json') + if (inheritedConfigDir || existsSync(colocatedConfigPath)) { + return colocatedConfigPath + } + return join(homedir(), '.claude.json') + } +} diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts new file mode 100644 index 000000000..0063e5a1a --- /dev/null +++ b/src/main/claude-accounts/service.ts @@ -0,0 +1,520 @@ +/* eslint-disable max-lines -- Why: Claude managed accounts need one audited owner +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 { tmpdir } from 'node:os' +import { join, relative, resolve, sep } from 'node:path' +import { app } from 'electron' +import type { + ClaudeManagedAccount, + ClaudeManagedAccountSummary, + ClaudeRateLimitAccountsState +} 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 { + deleteActiveClaudeKeychainCredentialsStrict, + deleteManagedClaudeKeychainCredentials, + readActiveClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials +} from './keychain' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' + +const LOGIN_TIMEOUT_MS = 180_000 +const STATUS_TIMEOUT_MS = 20_000 +const MAX_COMMAND_OUTPUT_CHARS = 4_000 + +type ClaudeIdentity = { + email: string | null + organizationUuid: string | null + organizationName: string | null +} + +type CapturedClaudeAuth = { + credentialsJson: string + oauthAccount: unknown + identity: ClaudeIdentity +} + +export class ClaudeAccountService { + private mutationQueue: Promise = Promise.resolve() + + constructor( + private readonly store: Store, + private readonly rateLimits: RateLimitService, + private readonly runtimeAuth: ClaudeRuntimeAuthService + ) {} + + listAccounts(): ClaudeRateLimitAccountsState { + this.normalizeActiveSelection() + return this.getSnapshot() + } + + async addAccount(): Promise { + return this.serializeMutation(() => this.doAddAccount()) + } + + async reauthenticateAccount(accountId: string): Promise { + return this.serializeMutation(() => this.doReauthenticateAccount(accountId)) + } + + async removeAccount(accountId: string): Promise { + return this.serializeMutation(() => this.doRemoveAccount(accountId)) + } + + async selectAccount(accountId: string | null): Promise { + return this.serializeMutation(() => this.doSelectAccount(accountId)) + } + + private serializeMutation(fn: () => Promise): Promise { + const next = this.mutationQueue.then(fn, fn) + this.mutationQueue = next.catch(() => {}) + return next + } + + private async doAddAccount(): Promise { + const accountId = randomUUID() + const managedAuthPath = this.createManagedAuthDir(accountId) + const previousSettings = this.store.getSettings() + + try { + 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 now = Date.now() + const account: ClaudeManagedAccount = { + id: accountId, + email: captured.identity.email, + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: captured.identity.organizationUuid, + organizationName: captured.identity.organizationName, + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now + } + + this.store.updateSettings({ + claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], + activeClaudeManagedAccountId: account.id + }) + await this.syncRuntimeAuthWithLivePtyGate() + await this.rateLimits.refreshForClaudeAccountChange() + return this.getSnapshot() + } catch (error) { + this.restoreClaudeSettings(previousSettings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + await this.safeRemoveManagedAuth(accountId, managedAuthPath) + throw error + } + } + + private async doReauthenticateAccount(accountId: string): Promise { + const account = this.requireAccount(accountId) + const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath) + const previousSettings = this.store.getSettings() + 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 + ) + }) + try { + await this.syncRuntimeAuthWithLivePtyGate() + await this.rateLimits.refreshForClaudeAccountChange() + return this.getSnapshot() + } catch (error) { + this.restoreClaudeSettings(previousSettings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + throw error + } + } + + private async doRemoveAccount(accountId: string): Promise { + const account = this.requireAccount(accountId) + const settings = this.store.getSettings() + const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) + const nextActiveId = + settings.activeClaudeManagedAccountId === accountId + ? null + : settings.activeClaudeManagedAccountId + + this.store.updateSettings({ + claudeManagedAccounts: nextAccounts, + activeClaudeManagedAccountId: nextActiveId + }) + try { + await this.syncRuntimeAuthWithLivePtyGate() + await this.safeRemoveManagedAuth(accountId, account.managedAuthPath) + await this.rateLimits.refreshForClaudeAccountChange() + return this.getSnapshot() + } catch (error) { + this.restoreClaudeSettings(settings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + throw error + } + } + + private async doSelectAccount(accountId: string | null): Promise { + if (accountId !== null) { + this.requireAccount(accountId) + } + const previousSettings = this.store.getSettings() + this.store.updateSettings({ activeClaudeManagedAccountId: accountId }) + try { + await this.syncRuntimeAuthWithLivePtyGate() + await this.rateLimits.refreshForClaudeAccountChange() + return this.getSnapshot() + } catch (error) { + this.restoreClaudeSettings(previousSettings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + throw error + } + } + + private getSnapshot(): ClaudeRateLimitAccountsState { + const settings = this.store.getSettings() + return { + accounts: settings.claudeManagedAccounts + .map((account) => this.toSummary(account)) + .sort((a, b) => b.updatedAt - a.updatedAt), + activeAccountId: settings.activeClaudeManagedAccountId + } + } + + private toSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { + return { + id: account.id, + email: account.email, + authMethod: account.authMethod ?? 'unknown', + organizationUuid: account.organizationUuid ?? null, + organizationName: account.organizationName ?? null, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + lastAuthenticatedAt: account.lastAuthenticatedAt + } + } + + private requireAccount(accountId: string): ClaudeManagedAccount { + const account = this.store + .getSettings() + .claudeManagedAccounts.find((entry) => entry.id === accountId) + if (!account) { + throw new Error('That Claude account no longer exists.') + } + return account + } + + private normalizeActiveSelection(): void { + const settings = this.store.getSettings() + if (!settings.activeClaudeManagedAccountId) { + return + } + const hasActiveAccount = settings.claudeManagedAccounts.some( + (entry) => entry.id === settings.activeClaudeManagedAccountId + ) + if (!hasActiveAccount) { + this.store.updateSettings({ activeClaudeManagedAccountId: null }) + } + } + + private restoreClaudeSettings(settings: ReturnType): void { + this.store.updateSettings({ + claudeManagedAccounts: settings.claudeManagedAccounts, + activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId + }) + } + + private async syncRuntimeAuthWithLivePtyGate(operation?: () => Promise): Promise { + beginClaudeAuthSwitch() + try { + await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection()) + } finally { + endClaudeAuthSwitch() + } + } + + private async runClaudeLoginAndCapture(): Promise { + const tempConfigDir = mkdtempSync(join(tmpdir(), 'orca-claude-login-')) + const previousActiveKeychain = await readActiveClaudeKeychainCredentials() + try { + await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfigDir, LOGIN_TIMEOUT_MS) + const status = await this.runClaudeCommand( + ['auth', 'status', '--json'], + tempConfigDir, + STATUS_TIMEOUT_MS, + { allowFailure: true } + ) + return await this.captureAuthFromConfigDir(tempConfigDir, status) + } 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() + } + rmSync(tempConfigDir, { recursive: true, force: true }) + } + } + + private async captureAuthFromConfigDir( + configDir: string, + statusOutput: string + ): Promise { + const credentialsJson = await this.readCapturedCredentials(configDir) + if (!credentialsJson) { + throw new Error('Claude login completed, but no OAuth credentials were captured.') + } + const oauthAccount = this.readOauthAccountFromConfigDir(configDir) + const identity = this.resolveIdentity(statusOutput, oauthAccount, credentialsJson) + return { credentialsJson, oauthAccount, identity } + } + + private async readCapturedCredentials(configDir: string): Promise { + if (process.platform === 'darwin') { + return readActiveClaudeKeychainCredentials() + } + const credentialsPath = join(configDir, '.credentials.json') + return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null + } + + private readOauthAccountFromConfigDir(configDir: string): unknown { + for (const configPath of [join(configDir, '.claude.json'), join(configDir, '.config.json')]) { + if (!existsSync(configPath)) { + continue + } + try { + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record + if (parsed.oauthAccount) { + return parsed.oauthAccount + } + } catch { + continue + } + } + return null + } + + private resolveIdentity( + statusOutput: string, + oauthAccount: unknown, + credentialsJson: string + ): ClaudeIdentity { + const status = this.parseJsonObject(statusOutput) + const oauth = this.asRecord(oauthAccount) + const credentials = this.parseJsonObject(credentialsJson) + const credentialOauth = this.asRecord(credentials?.claudeAiOauth) + + return { + email: this.normalizeField( + this.readString(status, 'email') ?? + this.readString(oauth, 'emailAddress') ?? + this.readString(oauth, 'email') ?? + this.readString(credentialOauth, 'email') + ), + organizationUuid: this.normalizeField( + this.readString(status, 'organizationUuid') ?? + this.readString(status, 'organizationId') ?? + this.readString(oauth, 'organizationUuid') ?? + this.readString(oauth, 'organizationId') + ), + organizationName: this.normalizeField( + this.readString(status, 'organizationName') ?? this.readString(oauth, 'organizationName') + ) + } + } + + private async writeManagedAuth( + accountId: string, + managedAuthPath: string, + captured: CapturedClaudeAuth + ): Promise { + const trustedPath = this.assertManagedAuthPath(managedAuthPath) + if (process.platform === 'darwin') { + await writeManagedClaudeKeychainCredentials(accountId, captured.credentialsJson) + } else { + writeFileAtomically(join(trustedPath, '.credentials.json'), captured.credentialsJson, { + mode: 0o600 + }) + } + writeFileAtomically( + join(trustedPath, 'oauth-account.json'), + `${JSON.stringify(captured.oauthAccount, null, 2)}\n`, + { mode: 0o600 } + ) + } + + 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) + } + + private getManagedAccountsRoot(): string { + const root = join(app.getPath('userData'), 'claude-accounts') + 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)) { + 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'))) { + throw new Error('Managed Claude auth storage is not owned by Orca.') + } + return canonicalCandidate + } + + private async safeRemoveManagedAuth(accountId: string, candidatePath: string): Promise { + try { + const managedAuthPath = this.assertManagedAuthPath(candidatePath) + rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true }) + } catch (error) { + console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error) + } + await deleteManagedClaudeKeychainCredentials(accountId) + } + + private runClaudeCommand( + args: string[], + configDir: string, + timeoutMs: number, + options?: { allowFailure?: boolean } + ): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const claudeCommand = resolveClaudeCommand() + const child = spawn(claudeCommand, args, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir + } + }) + + let settled = false + let output = '' + const appendOutput = (chunk: Buffer): void => { + output = `${output}${chunk.toString()}` + if (output.length > MAX_COMMAND_OUTPUT_CHARS) { + output = output.slice(-MAX_COMMAND_OUTPUT_CHARS) + } + } + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + callback() + } + const timeout = setTimeout(() => { + child.kill() + settle(() => rejectPromise(new Error('Claude sign-in took too long to finish.'))) + }, timeoutMs) + + child.stdout.on('data', appendOutput) + child.stderr.on('data', appendOutput) + child.on('error', (error) => { + settle(() => rejectPromise(error)) + }) + child.on('close', (code) => { + settle(() => { + if (code === 0 || options?.allowFailure) { + resolvePromise(output) + return + } + const trimmedOutput = output.trim() + rejectPromise( + new Error( + trimmedOutput + ? `Claude command failed: ${trimmedOutput}` + : `Claude command exited with code ${code ?? 'unknown'}.` + ) + ) + }) + }) + }) + } + + private parseJsonObject(value: string): Record | null { + try { + const parsed = JSON.parse(value) as unknown + return this.asRecord(parsed) + } catch { + return null + } + } + + private asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + return value as Record + } + + private readString(value: Record | null, key: string): string | null { + const field = value?.[key] + return typeof field === 'string' ? field : null + } + + private normalizeField(value: string | null | undefined): string | null { + if (!value) { + return null + } + const trimmed = value.trim() + return trimmed === '' ? null : trimmed + } +} diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 998f0891a..40447950f 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -74,6 +74,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings promptCacheTtlMs: 300_000, codexManagedAccounts: [], activeCodexManagedAccountId: null, + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, skipDeleteWorktreeConfirm: false, @@ -315,9 +317,9 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') // External tool changes auth — subsequent syncs must not overwrite - writeFileSync(runtimeAuthPath, '{"account":"cc-switch"}\n', 'utf-8') + writeFileSync(runtimeAuthPath, '{"account":"external-tool"}\n', 'utf-8') service.syncForCurrentSelection() - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"cc-switch"}\n') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-tool"}\n') }) it('restores system default on restart when persisted active account is invalid', async () => { diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index b298b100f..f0cf6fd84 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -21,7 +21,7 @@ import { writeFileAtomically } from './fs-utils' export class CodexRuntimeHomeService { // Why: tracks whether auth.json is currently managed by Orca. When null, // Orca does NOT own auth.json and must not overwrite external changes - // (e.g. user running `codex login` or using cc-switch). The snapshot + // (e.g. user running `codex login` or another auth tool). The snapshot // restore only fires on the managed→system-default transition. private lastSyncedAccountId: string | null = null @@ -58,8 +58,8 @@ export class CodexRuntimeHomeService { // Why: only restore the snapshot when transitioning FROM a managed // account back to system default. When no managed account was ever // active, auth.json belongs to the user and Orca must not touch it. - // This prevents overwriting external auth changes (codex login, - // cc-switch, or other tools) on every PTY launch / rate-limit fetch. + // This prevents overwriting external auth changes (codex login or other + // tools) on every PTY launch / rate-limit fetch. if (this.lastSyncedAccountId !== null) { this.restoreSystemDefaultSnapshot() this.lastSyncedAccountId = null diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index bff056a88..860d97eca 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -68,6 +68,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings promptCacheTtlMs: 300_000, codexManagedAccounts: [], activeCodexManagedAccountId: null, + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, skipDeleteWorktreeConfirm: false, diff --git a/src/main/index.ts b/src/main/index.ts index f6e011c02..495ce73f8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -33,6 +33,8 @@ import { attachMainWindowServices } from './window/attach-main-window-services' import { createMainWindow } from './window/createMainWindow' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' +import { ClaudeAccountService } from './claude-accounts/service' +import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { openCodeHookService } from './opencode/hook-service' import { StarNagService } from './star-nag/service' import { AgentBrowserBridge } from './browser/agent-browser-bridge' @@ -49,6 +51,8 @@ let claudeUsage: ClaudeUsageStore | null = null let codexUsage: CodexUsageStore | null = null let codexAccounts: CodexAccountService | null = null let codexRuntimeHome: CodexRuntimeHomeService | null = null +let claudeAccounts: ClaudeAccountService | null = null +let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null let runtime: OrcaRuntimeService | null = null let rateLimits: RateLimitService | null = null let runtimeRpc: OrcaRuntimeRpcServer | null = null @@ -135,6 +139,14 @@ function openMainWindow(): BrowserWindow { if (!codexRuntimeHome) { throw new Error('Codex runtime home service must be initialized before opening the main window') } + if (!claudeAccounts) { + throw new Error('Claude account service must be initialized before opening the main window') + } + if (!claudeRuntimeAuth) { + throw new Error( + 'Claude runtime auth service must be initialized before opening the main window' + ) + } const window = createMainWindow(store, { getIsQuitting: () => isQuitting, @@ -149,10 +161,17 @@ function openMainWindow(): BrowserWindow { claudeUsage, codexUsage, codexAccounts, + claudeAccounts, rateLimits, window.webContents.id ) - attachMainWindowServices(window, store, runtime, () => codexRuntimeHome!.prepareForCodexLaunch()) + attachMainWindowServices( + window, + store, + runtime, + () => codexRuntimeHome!.prepareForCodexLaunch(), + () => claudeRuntimeAuth!.prepareForClaudeLaunch() + ) rateLimits.attach(window) rateLimits.start() window.on('closed', () => { @@ -180,7 +199,10 @@ app.whenReady().then(async () => { rateLimits = new RateLimitService() codexRuntimeHome = new CodexRuntimeHomeService(store) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) + claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) + claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) rateLimits.setCodexHomePathResolver(() => codexRuntimeHome!.prepareForRateLimitFetch()) + rateLimits.setClaudeAuthPreparationResolver(() => claudeRuntimeAuth!.prepareForRateLimitFetch()) runtime = new OrcaRuntimeService(store, stats) starNag = new StarNagService(store, stats) starNag.start() diff --git a/src/main/ipc/claude-accounts.ts b/src/main/ipc/claude-accounts.ts new file mode 100644 index 000000000..8d62c8c06 --- /dev/null +++ b/src/main/ipc/claude-accounts.ts @@ -0,0 +1,16 @@ +import { ipcMain } from 'electron' +import type { ClaudeAccountService } from '../claude-accounts/service' + +export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountService): void { + ipcMain.handle('claudeAccounts:list', () => claudeAccounts.listAccounts()) + ipcMain.handle('claudeAccounts:add', () => claudeAccounts.addAccount()) + ipcMain.handle('claudeAccounts:reauthenticate', (_event, args: { accountId: string }) => + claudeAccounts.reauthenticateAccount(args.accountId) + ) + ipcMain.handle('claudeAccounts:remove', (_event, args: { accountId: string }) => + claudeAccounts.removeAccount(args.accountId) + ) + ipcMain.handle('claudeAccounts:select', (_event, args: { accountId: string | null }) => + claudeAccounts.selectAccount(args.accountId) + ) +} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 5acab8151..1c754d204 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -10,7 +10,14 @@ import type { GlobalSettings } from '../../shared/types' import { openCodeHookService } from '../opencode/hook-service' import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' import { LocalPtyProvider } from '../providers/local-pty-provider' -import type { IPtyProvider } from '../providers/types' +import type { IPtyProvider, PtySpawnOptions } from '../providers/types' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' +import { + isClaudeAuthSwitchInProgress, + markClaudePtyExited, + markClaudePtySpawned +} from '../claude-accounts/live-pty-gate' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -43,6 +50,15 @@ function getProviderForPty(ptyId: string): IPtyProvider { return getProvider(connectionId) } +function isClaudeLaunchCommand(command: string | undefined): boolean { + if (!command) { + return false + } + return /(^|[\s;&|('"`])(?:[^\s;&|('"`]*[\\/])?claude(?:\.cmd|\.exe)?($|[\s;&|)'"`])/i.test( + command + ) +} + /** Register an SSH PTY provider for a connection. */ export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { sshProviders.set(connectionId, provider) @@ -126,7 +142,8 @@ export function registerPtyHandlers( mainWindow: BrowserWindow, runtime?: OrcaRuntimeService, getSelectedCodexHomePath?: () => string | null, - getSettings?: () => GlobalSettings + getSettings?: () => GlobalSettings, + prepareClaudeAuth?: () => Promise ): void { // Remove any previously registered handlers so we can re-register them // (e.g. when macOS re-activates the app and creates a new window). @@ -182,6 +199,7 @@ export function registerPtyHandlers( onExit: (id, code) => { clearProviderPtyState(id) ptyOwnership.delete(id) + markClaudePtyExited(id) runtime?.onPtyExit(id, code) }, onData: (id, data, timestamp) => runtime?.onPtyData(id, data, timestamp) @@ -257,6 +275,7 @@ export function registerPtyHandlers( for (const { id } of killed) { clearProviderPtyState(id) ptyOwnership.delete(id) + markClaudePtyExited(id) runtime?.onPtyExit(id, -1) } } @@ -283,6 +302,7 @@ export function registerPtyHandlers( // if the remote SSH session is already gone. void provider.shutdown(ptyId, false).catch(() => {}) clearProviderPtyState(ptyId) + markClaudePtyExited(ptyId) runtime?.onPtyExit(ptyId, -1) return true } @@ -306,16 +326,46 @@ export function registerPtyHandlers( } ) => { const provider = getProvider(args.connectionId) - const result = await provider.spawn({ + const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) + if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null + if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { + throw new Error( + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + ) + } + const env = claudeAuth ? { ...args.env, ...claudeAuth.envPatch } : args.env + const envToDelete = claudeAuth?.stripAuthEnv + ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + : undefined + const spawnOptions: PtySpawnOptions = { cols: args.cols, rows: args.rows, cwd: args.cwd, - env: args.env, - command: args.command, - worktreeId: args.worktreeId, - sessionId: args.sessionId - }) + env + } + if (envToDelete) { + spawnOptions.envToDelete = envToDelete + } + if (args.command !== undefined) { + spawnOptions.command = args.command + } + if (args.worktreeId !== undefined) { + spawnOptions.worktreeId = args.worktreeId + } + if (args.sessionId !== undefined) { + spawnOptions.sessionId = args.sessionId + } + const result = await provider.spawn(spawnOptions) ptyOwnership.set(result.id, args.connectionId ?? null) + if (isClaudeLaunch) { + markClaudePtySpawned(result.id) + } return result } ) @@ -359,6 +409,7 @@ export function registerPtyHandlers( /* session already dead — cleanup below handles the rest */ } finally { ptyOwnership.delete(args.id) + markClaudePtyExited(args.id) } }) diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 462a6c61b..8217c7626 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -16,6 +16,7 @@ const { registerFilesystemHandlersMock, registerRuntimeHandlersMock, registerCodexAccountHandlersMock, + registerClaudeAccountHandlersMock, registerClipboardHandlersMock, registerUpdaterHandlersMock, registerRateLimitHandlersMock, @@ -42,6 +43,7 @@ const { registerFilesystemHandlersMock: vi.fn(), registerRuntimeHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), + registerClaudeAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), registerRateLimitHandlersMock: vi.fn(), @@ -126,6 +128,10 @@ vi.mock('./codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) +vi.mock('./claude-accounts', () => ({ + registerClaudeAccountHandlers: registerClaudeAccountHandlersMock +})) + vi.mock('../window/attach-main-window-services', () => ({ registerClipboardHandlers: registerClipboardHandlersMock, registerUpdaterHandlers: registerUpdaterHandlersMock @@ -164,6 +170,7 @@ describe('registerCoreHandlers', () => { registerFilesystemHandlersMock.mockReset() registerRuntimeHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() + registerClaudeAccountHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() registerUpdaterHandlersMock.mockReset() registerRateLimitHandlersMock.mockReset() @@ -183,6 +190,7 @@ describe('registerCoreHandlers', () => { const claudeUsage = { marker: 'claudeUsage' } const codexUsage = { marker: 'codexUsage' } const codexAccounts = { marker: 'codexAccounts' } + const claudeAccounts = { marker: 'claudeAccounts' } const rateLimits = { marker: 'rateLimits' } registerCoreHandlers( @@ -192,12 +200,14 @@ describe('registerCoreHandlers', () => { claudeUsage as never, codexUsage as never, codexAccounts as never, + claudeAccounts as never, rateLimits as never ) expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) + expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() @@ -228,6 +238,7 @@ describe('registerCoreHandlers', () => { const claudeUsage2 = { marker: 'claudeUsage2' } const codexUsage2 = { marker: 'codexUsage2' } const codexAccounts2 = { marker: 'codexAccounts2' } + const claudeAccounts2 = { marker: 'claudeAccounts2' } const rateLimits2 = { marker: 'rateLimits2' } registerCoreHandlers( @@ -237,6 +248,7 @@ describe('registerCoreHandlers', () => { claudeUsage2 as never, codexUsage2 as never, codexAccounts2 as never, + claudeAccounts2 as never, rateLimits2 as never, 42 ) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 9dbf905a2..fb341bf32 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -24,6 +24,7 @@ import { browserSessionRegistry } from '../browser/browser-session-registry' import { registerShellHandlers } from './shell' import { registerUIHandlers } from './ui' import { registerCodexAccountHandlers } from './codex-accounts' +import { registerClaudeAccountHandlers } from './claude-accounts' import { warmSystemFontFamilies } from '../system-fonts' import { registerClipboardHandlers, @@ -33,6 +34,7 @@ import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' import type { RateLimitService } from '../rate-limits/service' import type { CodexAccountService } from '../codex-accounts/service' +import type { ClaudeAccountService } from '../claude-accounts/service' let registered = false @@ -43,6 +45,7 @@ export function registerCoreHandlers( claudeUsage: ClaudeUsageStore, codexUsage: CodexUsageStore, codexAccounts: CodexAccountService, + claudeAccounts: ClaudeAccountService, rateLimits: RateLimitService, mainWindowWebContentsId: number | null = null ): void { @@ -63,6 +66,7 @@ export function registerCoreHandlers( registerClaudeUsageHandlers(claudeUsage) registerCodexUsageHandlers(codexUsage) registerCodexAccountHandlers(codexAccounts) + registerClaudeAccountHandlers(claudeAccounts) registerRateLimitHandlers(rateLimits) registerGitHubHandlers(store, stats) registerLinearHandlers() diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 78129628b..09c7b3d65 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -190,6 +190,9 @@ export class LocalPtyProvider implements IPtyProvider { // fallback keeps tests and non-Electron runs working. TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev' } as Record + for (const key of args.envToDelete ?? []) { + delete spawnEnv[key] + } // Why: FORCE_HYPERLINK=1 is read by oh-my-zsh's supports_hyperlinks(), the // Rust supports-hyperlinks crate, GNU coreutils, and other tooling. diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 57618fb1f..cb68f077e 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -17,6 +17,7 @@ export type PtySpawnOptions = { rows: number cwd?: string env?: Record + envToDelete?: string[] command?: string /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 53bf45d41..f85518f4c 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -5,6 +5,7 @@ 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' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' @@ -130,8 +131,8 @@ async function readFromKeychain(): Promise { * Why: older Claude CLI versions store credentials in this plain JSON * file. We keep it as a fallback for compatibility. */ -async function readFromCredentialsFile(): Promise { - const credPath = path.join(homedir(), '.claude', '.credentials.json') +async function readFromCredentialsFile(configDir?: string): Promise { + 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 @@ -157,7 +158,7 @@ async function readFromCredentialsFile(): Promise { * 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(): Promise { +async function readOAuthCredentials(configDir?: string): Promise { // 1. macOS Keychain (Claude Max/Pro OAuth) const fromKeychain = await readFromKeychain() if (fromKeychain) { @@ -165,7 +166,7 @@ async function readOAuthCredentials(): Promise { } // 2. Legacy credentials file - const fromFile = await readFromCredentialsFile() + const fromFile = await readFromCredentialsFile(configDir) if (fromFile) { return fromFile } @@ -266,9 +267,13 @@ async function fetchViaOAuth(token: string): Promise { // Public API // --------------------------------------------------------------------------- -export async function fetchClaudeRateLimits(): Promise { +export async function fetchClaudeRateLimits(options?: { + authPreparation?: ClaudeRuntimeAuthPreparation +}): Promise { // Path A: try OAuth API if we have a genuine OAuth token - const oauthToken = await readOAuthCredentials() + const oauthToken = await readOAuthCredentials( + options?.authPreparation?.envPatch.CLAUDE_CONFIG_DIR + ) if (oauthToken) { try { return await fetchViaOAuth(oauthToken) @@ -282,7 +287,7 @@ export async function fetchClaudeRateLimits(): Promise { // `/usage` command is subscription-only, so there's no point // attempting PTY for API key users. try { - return await fetchViaPty() + return await fetchViaPty({ authPreparation: options?.authPreparation }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' return { diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 553cc5b4a..1ae686ceb 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -1,5 +1,7 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { resolveClaudeCommand } from '../codex-cli/command' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' const PTY_TIMEOUT_MS = 25_000 @@ -125,7 +127,9 @@ function describeClaudeUsageFailure(output: string): string { return 'Claude usage is unavailable right now.' } -export async function fetchViaPty(): Promise { +export async function fetchViaPty(options?: { + authPreparation?: ClaudeRuntimeAuthPreparation +}): Promise { const pty = await import('node-pty') return new Promise((resolve) => { @@ -144,11 +148,17 @@ export async function fetchViaPty(): Promise { const spawnFile = isWin32 ? 'cmd.exe' : claudeCommand const spawnArgs = isWin32 ? ['/c', claudeCommand] : [] + const spawnEnv = applyClaudeEnvPatch( + { ...process.env, TERM: 'xterm-256color' } as Record, + options?.authPreparation?.envPatch ?? {}, + { stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false } + ) + const term = pty.spawn(spawnFile, spawnArgs, { name: 'xterm-256color', cols: 120, rows: 40, - env: { ...process.env, TERM: 'xterm-256color' } + env: spawnEnv }) const termDisposables: { dispose: () => void }[] = [] const disposeTermListeners = (): void => { diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 440735961..0810c41ac 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -5,6 +5,7 @@ import type { BrowserWindow } from 'electron' import type { RateLimitState, ProviderRateLimits } from '../../shared/rate-limit-types' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' // Why: quota state does not need near-real-time polling, and a less aggressive // default reduces avoidable Claude /usage pressure. We intentionally use a @@ -23,9 +24,12 @@ export class RateLimitService { private isFetching = false private fullFetchQueued = false private codexOnlyFetchQueued = false + private claudeOnlyFetchQueued = false private fetchIdleResolvers: (() => void)[] = [] private codexFetchGeneration = 0 + private claudeFetchGeneration = 0 private codexHomePathResolver: (() => string | null) | null = null + private claudeAuthPreparationResolver: (() => Promise) | null = null constructor() {} @@ -33,6 +37,10 @@ export class RateLimitService { this.codexHomePathResolver = resolver } + setClaudeAuthPreparationResolver(resolver: () => Promise): void { + this.claudeAuthPreparationResolver = resolver + } + attach(mainWindow: BrowserWindow): void { this.detachWindowListeners?.() this.mainWindow = mainWindow @@ -95,6 +103,16 @@ export class RateLimitService { return this.state } + async refreshForClaudeAccountChange(): Promise { + this.claudeFetchGeneration += 1 + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(null, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.state + } + setPollingInterval(ms: number): void { this.pollInterval = Math.max(30_000, ms) if (this.timer) { @@ -171,6 +189,10 @@ export class RateLimitService { this.codexOnlyFetchQueued = false await this.runFetchCodexOnlyCycle() } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + await this.runFetchClaudeOnlyCycle() + } } } finally { this.isFetching = false @@ -202,6 +224,45 @@ export class RateLimitService { this.codexOnlyFetchQueued = false shouldContinue = true } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + await this.runFetchClaudeOnlyCycle() + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + private async fetchClaudeOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.claudeOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + while (shouldContinue) { + await this.runFetchClaudeOnlyCycle() + shouldContinue = false + if (this.fullFetchQueued) { + this.fullFetchQueued = false + await this.runFetchAllCycle() + continue + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + shouldContinue = true + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + await this.runFetchCodexOnlyCycle() + } } } finally { this.isFetching = false @@ -210,7 +271,12 @@ export class RateLimitService { } private waitForFetchIdle(): Promise { - if (!this.isFetching && !this.fullFetchQueued && !this.codexOnlyFetchQueued) { + if ( + !this.isFetching && + !this.fullFetchQueued && + !this.codexOnlyFetchQueued && + !this.claudeOnlyFetchQueued + ) { return Promise.resolve() } // Why: explicit refresh callers need to await the queued follow-up cycle @@ -222,7 +288,12 @@ export class RateLimitService { } private resolveFetchIdleWaiters(): void { - if (this.isFetching || this.fullFetchQueued || this.codexOnlyFetchQueued) { + if ( + this.isFetching || + this.fullFetchQueued || + this.codexOnlyFetchQueued || + this.claudeOnlyFetchQueued + ) { return } const resolvers = this.fetchIdleResolvers @@ -250,6 +321,9 @@ export class RateLimitService { } private async runFetchAllCycle(): Promise { + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' + const claudeGeneration = this.claudeFetchGeneration const codexHomePath = this.codexHomePathResolver?.() ?? null const codexProvenance = codexHomePath ? `managed:${codexHomePath}` : 'system' const codexGeneration = this.codexFetchGeneration @@ -264,7 +338,7 @@ export class RateLimitService { }) const [claude, codex] = await Promise.all([ - fetchClaudeRateLimits().catch( + fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }).catch( (err): ProviderRateLimits => ({ provider: 'claude', session: null, @@ -287,16 +361,22 @@ export class RateLimitService { ]) const latestCodexHomePath = this.codexHomePathResolver?.() ?? null + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' const latestCodexProvenance = latestCodexHomePath ? `managed:${latestCodexHomePath}` : 'system' const shouldApplyCodex = codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance + const shouldApplyClaude = + claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance // Why: account switches can race in-flight Codex fetches. Only apply a // Codex result if both the selected-account provenance and the request // generation still match, otherwise an old account could overwrite the // newly selected account's quota state. this.updateState({ - claude: this.applyStalePolicy(claude, previousState.claude), + claude: shouldApplyClaude + ? this.applyStalePolicy(claude, previousState.claude) + : this.state.claude, codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex }) @@ -338,6 +418,43 @@ export class RateLimitService { this.lastFetchAt = Date.now() } + private async runFetchClaudeOnlyCycle(): Promise { + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' + const claudeGeneration = this.claudeFetchGeneration + const previousState = this.state + + this.updateState({ + ...previousState, + claude: this.withFetchingStatus(previousState.claude, 'claude') + }) + + const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }).catch( + (err): ProviderRateLimits => ({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + }) + ) + + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.() + const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' + const shouldApplyClaude = + claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance + + this.updateState({ + ...this.state, + claude: shouldApplyClaude + ? this.applyStalePolicy(claude, previousState.claude) + : this.state.claude + }) + + this.lastFetchAt = Date.now() + } + private applyStalePolicy( fresh: ProviderRateLimits, previous: ProviderRateLimits | null diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 3acc53691..b1e71587e 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -22,16 +22,24 @@ import { } from '../updater' import { scheduleHistoryGc } from '../terminal-history' import { listRepoWorktrees } from '../repo-worktrees' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' export function attachMainWindowServices( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: () => string | null + getSelectedCodexHomePath?: () => string | null, + prepareClaudeAuth?: () => Promise ): void { registerRepoHandlers(mainWindow, store) registerWorktreeHandlers(mainWindow, store) - registerPtyHandlers(mainWindow, runtime, getSelectedCodexHomePath, () => store.getSettings()) + registerPtyHandlers( + mainWindow, + runtime, + getSelectedCodexHomePath, + () => store.getSettings(), + prepareClaudeAuth + ) // Why: GC runs on a 10s delay so live worktree enumeration completes first. // Uses git worktree list (not store.getWorktreeMeta) because untouched // worktrees have no metadata entries — see design doc §7.6. diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 356c8d740..d56319ea3 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -6,6 +6,7 @@ import type { BrowserSessionProfile, BrowserSessionProfileScope, BrowserSessionProfileSource, + ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, CreateWorktreeResult, DirEntry, @@ -475,6 +476,13 @@ export type PreloadApi = { remove: (args: { accountId: string }) => Promise select: (args: { accountId: string | null }) => Promise } + claudeAccounts: { + list: () => Promise + add: () => Promise + reauthenticate: (args: { accountId: string }) => Promise + remove: (args: { accountId: string }) => Promise + select: (args: { accountId: string | null }) => Promise + } cli: { getInstallStatus: () => Promise install: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 7e591ae8a..9613b3c0c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -522,6 +522,17 @@ const api = { ipcRenderer.invoke('codexAccounts:select', args) }, + claudeAccounts: { + list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), + add: (): Promise => ipcRenderer.invoke('claudeAccounts:add'), + reauthenticate: (args: { accountId: string }): Promise => + ipcRenderer.invoke('claudeAccounts:reauthenticate', args), + remove: (args: { accountId: string }): Promise => + ipcRenderer.invoke('claudeAccounts:remove', args), + select: (args: { accountId: string | null }): Promise => + ipcRenderer.invoke('claudeAccounts:select', args) + }, + cli: { getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), install: (): Promise => ipcRenderer.invoke('cli:install'), diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 2c4dfa585..145b92ebc 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -2,7 +2,11 @@ splitting individual settings into separate files would scatter related controls without a meaningful abstraction boundary. */ import { useEffect, useState } from 'react' -import type { CodexRateLimitAccountsState, GlobalSettings } from '../../../../shared/types' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState, + GlobalSettings +} from '../../../../shared/types' import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Input } from '../ui/input' @@ -19,6 +23,7 @@ import { } from '../../../../shared/constants' import { clampNumber } from '@/lib/terminal-theme' import { + GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES, GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES, GENERAL_CACHE_TIMER_SEARCH_ENTRIES, GENERAL_CLI_SEARCH_ENTRIES, @@ -58,6 +63,16 @@ function getCodexAccountLabel( return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account' } +function getClaudeAccountLabel( + state: ClaudeRateLimitAccountsState, + accountId: string | null | undefined +): string { + if (accountId == null) { + return 'System default' + } + return state.accounts.find((account) => account.id === accountId)?.email ?? 'Claude account' +} + function getCodexAccountErrorDescription(error: unknown): string { const message = String((error as Error)?.message ?? error) .replace(/^Error occurred in handler for 'codexAccounts:[^']+':\s*/i, '') @@ -92,6 +107,16 @@ function getCodexAccountErrorDescription(error: unknown): string { return message || 'Codex sign-in failed. Please try again.' } +function getClaudeAccountErrorDescription(error: unknown): string { + return ( + String((error as Error)?.message ?? error) + .replace(/^Error occurred in handler for 'claudeAccounts:[^']+':\s*/i, '') + .replace(/^Error invoking remote method 'claudeAccounts:[^']+':\s*/i, '') + .replace(/^Error:\s*/i, '') + .trim() || 'Claude sign-in failed. Please try again.' + ) +} + export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) const updateStatus = useAppStore((s) => s.updateStatus) @@ -107,7 +132,15 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea const [codexAction, setCodexAction] = useState< 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` >('idle') + const [claudeAccounts, setClaudeAccounts] = useState({ + accounts: [], + activeAccountId: null + }) + const [claudeAction, setClaudeAction] = useState< + 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` + >('idle') const [removeAccountId, setRemoveAccountId] = useState(null) + const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState(null) // Why: the star state is derived from gh, not from settings, so it does not // live in the global settings store. 'hidden' covers the gh-unavailable and // already-starred-on-a-previous-session cases so the section drops out for @@ -167,9 +200,9 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea const loadCodexAccounts = async (): Promise => { try { - const next = await window.api.codexAccounts.list() + const nextCodex = await window.api.codexAccounts.list() if (!stale) { - setCodexAccounts(next) + setCodexAccounts(nextCodex) } } catch (error) { if (!stale) { @@ -180,7 +213,23 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea } } + const loadClaudeAccounts = async (): Promise => { + try { + const nextClaude = await window.api.claudeAccounts.list() + if (!stale) { + setClaudeAccounts(nextClaude) + } + } catch (error) { + if (!stale) { + toast.error('Could not load Claude accounts.', { + description: String((error as Error)?.message ?? error) + }) + } + } + } + void loadCodexAccounts() + void loadClaudeAccounts() return () => { stale = true @@ -229,6 +278,11 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea await fetchSettings() } + const syncClaudeAccounts = async (next: ClaudeRateLimitAccountsState): Promise => { + setClaudeAccounts(next) + await fetchSettings() + } + const formatAccountTimestamp = (timestamp: number): string => { return new Date(timestamp).toLocaleString(undefined, { month: 'short', @@ -269,6 +323,29 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea } } + const runClaudeAccountAction = async ( + action: typeof claudeAction, + operation: () => Promise + ): Promise => { + const previousActiveAccountId = claudeAccounts.activeAccountId + setClaudeAction(action) + try { + const next = await operation() + await syncClaudeAccounts(next) + if (previousActiveAccountId !== next.activeAccountId || action === 'adding') { + toast.info('Claude account updated.', { + description: `${getClaudeAccountLabel(claudeAccounts, previousActiveAccountId)} → ${getClaudeAccountLabel(next, next.activeAccountId)}. Restart live Claude terminals before continuing old sessions.` + }) + } + } catch (error) { + toast.error('Claude account update failed.', { + description: getClaudeAccountErrorDescription(error) + }) + } finally { + setClaudeAction('idle') + } + } + const visibleSections = [ matchesSettingsSearch(searchQuery, GENERAL_WORKSPACE_SEARCH_ENTRIES) ? (
@@ -567,6 +644,168 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea )}
) : null, + matchesSettingsSearch(searchQuery, GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES) ? ( +
+
+

Claude Accounts

+

+ Add and switch Claude Code accounts without moving chat sessions to account-specific + config directories. +

+
+ + +
+
+ +

+ Orca swaps Claude auth only; config and chat history stay in the shared Claude root. +

+
+ +
+ +
+ + {claudeAccounts.accounts.length === 0 ? ( +
+ No managed Claude accounts yet. Orca will use your system default Claude login until + you add one here. +
+ ) : ( + claudeAccounts.accounts.map((account) => { + const isActive = claudeAccounts.activeAccountId === account.id + const isReauthing = claudeAction === `reauth:${account.id}` + const isBusy = claudeAction !== 'idle' + + return ( + + +
+ + + ) + }) + )} + +
+
+ ) : null, matchesSettingsSearch(searchQuery, GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES) ? (
@@ -897,6 +1136,40 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea + !open && setRemoveClaudeAccountId(null)} + > + + + Remove Claude Account? + + Orca will delete the managed Claude auth for this saved account. If it is currently + active, Orca falls back to the system default Claude login. + + + + + + + + {visibleSections.map((section, index) => (
{index > 0 ? : null} diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index a2bf348a7..06a03927d 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -65,6 +65,14 @@ export const GENERAL_CACHE_TIMER_SEARCH_ENTRIES: SettingsSearchEntry[] = [ } ] +export const GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Claude Accounts', + description: 'Manage which Claude account Orca uses while preserving shared chat context.', + keywords: ['claude', 'account', 'switch', 'active', 'status bar', 'quota'] + } +] + export const GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Codex Accounts', @@ -82,7 +90,17 @@ export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Default Agent', description: 'Pre-select an AI coding agent in the new-workspace composer.', - keywords: ['agent', 'default', 'claude', 'codex', 'opencode', 'pi', 'gemini', 'aider', 'copilot'] + keywords: [ + 'agent', + 'default', + 'claude', + 'codex', + 'opencode', + 'pi', + 'gemini', + 'aider', + 'copilot' + ] } ] @@ -99,6 +117,7 @@ export const GENERAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ ...GENERAL_EDITOR_SEARCH_ENTRIES, ...GENERAL_CLI_SEARCH_ENTRIES, ...GENERAL_CACHE_TIMER_SEARCH_ENTRIES, + ...GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES, ...GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES, ...GENERAL_UPDATE_SEARCH_ENTRIES, ...GENERAL_SUPPORT_SEARCH_ENTRIES diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index dc61b2ad3..7506db033 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -14,7 +14,10 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useAppStore } from '../../store' -import type { CodexRateLimitAccountsState } from '../../../../shared/types' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState +} from '../../../../shared/types' import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' import { ProviderIcon, ProviderPanel } from './tooltip' import { ClaudeIcon, OpenAIIcon } from './icons' @@ -33,6 +36,150 @@ function getCodexAccountLabel( return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account' } +function ClaudeSwitcherMenu({ + claude, + compact, + iconOnly +}: { + claude: ProviderRateLimits + compact: boolean + iconOnly: boolean +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const [accountsExpanded, setAccountsExpanded] = useState(false) + const [accounts, setAccounts] = useState({ + accounts: [], + activeAccountId: null + }) + const [isSwitching, setIsSwitching] = useState(false) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const fetchSettings = useAppStore((s) => s.fetchSettings) + const claudeAccountSyncKey = useAppStore((s) => { + const settings = s.settings + if (!settings) { + return 'no-settings' + } + return `${settings.activeClaudeManagedAccountId ?? 'system'}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` + }) + + const loadAccounts = useCallback(async () => { + const next = await window.api.claudeAccounts.list() + setAccounts(next) + }, []) + + useEffect(() => { + void loadAccounts().catch((error) => { + console.error('Failed to load Claude accounts for status bar:', error) + }) + }, [loadAccounts, open, claudeAccountSyncKey]) + + useEffect(() => { + if (!open) { + setAccountsExpanded(false) + } + }, [open]) + + const handleSelectAccount = async (accountId: string | null): Promise => { + if (isSwitching) { + return + } + setIsSwitching(true) + try { + const next = await window.api.claudeAccounts.select({ accountId }) + setAccounts(next) + await fetchSettings() + setAccountsExpanded(false) + } catch (error) { + console.error('Failed to switch Claude account from status bar:', error) + } finally { + setIsSwitching(false) + } + } + + const activeAccountLabel = + accounts.activeAccountId === null + ? 'System default' + : (accounts.accounts.find((account) => account.id === accounts.activeAccountId)?.email ?? + 'Managed') + const availableSwitchTargets = [ + ...(accounts.activeAccountId === null + ? [] + : [{ id: null as string | null, label: 'System default' }]), + ...accounts.accounts + .filter((account) => account.id !== accounts.activeAccountId) + .map((account) => ({ id: account.id, label: account.email })) + ] + + return ( + + Claude Account + { + event.preventDefault() + setAccountsExpanded((prev) => !prev) + }} + > + + {activeAccountLabel} + + {accountsExpanded ? ( + + ) : ( + + )} + + {accountsExpanded ? ( +
+
+ Switch to +
+
+ {availableSwitchTargets.length === 0 ? ( +
No other accounts
+ ) : null} + {availableSwitchTargets.map((target) => ( + { + event.preventDefault() + void handleSelectAccount(target.id) + }} + > + {target.label} + + ))} +
+
+ Restart live Claude terminals before continuing old conversations after switching. +
+
+ ) : null} + + { + openSettingsTarget({ + pane: 'general', + repoId: null, + sectionId: 'general-claude-accounts' + }) + openSettingsPage() + }} + > + Manage Accounts… + +
+ ) +} + // --------------------------------------------------------------------------- // Mini progress bar (shows remaining capacity, grey) // --------------------------------------------------------------------------- @@ -487,14 +634,7 @@ function StatusBarInner(): React.JSX.Element | null { }} >
- {showClaude && ( - - )} + {showClaude && } {showCodex && } {anyVisible && ( diff --git a/src/shared/constants.ts b/src/shared/constants.ts index be2858497..547192cde 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -150,6 +150,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { promptCacheTtlMs: 300_000, codexManagedAccounts: [], activeCodexManagedAccountId: null, + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, skipDeleteWorktreeConfirm: false, diff --git a/src/shared/types.ts b/src/shared/types.ts index ef3815046..2e44e0a9f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -653,6 +653,34 @@ export type CodexRateLimitAccountsState = { activeAccountId: string | null } +export type ClaudeManagedAccount = { + id: string + email: string + managedAuthPath: string + authMethod: 'subscription-oauth' | 'unknown' + organizationUuid?: string | null + organizationName?: string | null + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +export type ClaudeManagedAccountSummary = { + id: string + email: string + authMethod: 'subscription-oauth' | 'unknown' + organizationUuid?: string | null + organizationName?: string | null + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +export type ClaudeRateLimitAccountsState = { + accounts: ClaudeManagedAccountSummary[] + activeAccountId: string | null +} + /** All AI coding agents Orca knows how to launch. Used for the agent picker in the new-workspace * flow and for the default-agent setting. Extend this union as new agents are added. */ export type TuiAgent = @@ -766,6 +794,11 @@ export type GlobalSettings = { * analytics and external terminal sessions. */ codexManagedAccounts: CodexManagedAccount[] activeCodexManagedAccountId: string | null + /** Why: Claude Code keeps conversations under one shared config root. Orca + * persists only per-account auth material here so switching accounts does + * not fork prior chat/session context the way CLAUDE_CONFIG_DIR swapping would. */ + claudeManagedAccounts: ClaudeManagedAccount[] + activeClaudeManagedAccountId: string | null /** When true, each worktree gets its own shell history file so ArrowUp * does not surface commands from other worktrees. Defaults to true. * Disable to revert to shared global shell history. */