feat: add Claude account switcher (#1050)
This commit is contained in:
parent
861f21fc81
commit
5315e87a81
|
|
@ -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<string, string>,
|
||||
patch: ClaudeEnvPatch,
|
||||
options?: { stripAuthEnv?: boolean }
|
||||
): Record<string, string> {
|
||||
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<string, string> | 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)
|
||||
}
|
||||
|
|
@ -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<string | null> {
|
||||
return readKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser())
|
||||
}
|
||||
|
||||
export async function writeActiveClaudeKeychainCredentials(contents: string): Promise<void> {
|
||||
await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser(), contents)
|
||||
}
|
||||
|
||||
export async function deleteActiveClaudeKeychainCredentials(): Promise<void> {
|
||||
await deleteKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser())
|
||||
}
|
||||
|
||||
export async function deleteActiveClaudeKeychainCredentialsStrict(): Promise<void> {
|
||||
await deleteKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser(), {
|
||||
failOnAccessError: true
|
||||
})
|
||||
}
|
||||
|
||||
export async function readManagedClaudeKeychainCredentials(
|
||||
accountId: string
|
||||
): Promise<string | null> {
|
||||
return readKeychainPassword(ORCA_CLAUDE_SERVICE, accountId)
|
||||
}
|
||||
|
||||
export async function writeManagedClaudeKeychainCredentials(
|
||||
accountId: string,
|
||||
contents: string
|
||||
): Promise<void> {
|
||||
await writeKeychainPassword(ORCA_CLAUDE_SERVICE, accountId, contents)
|
||||
}
|
||||
|
||||
export async function deleteManagedClaudeKeychainCredentials(accountId: string): Promise<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
const liveClaudePtyIds = new Set<string>()
|
||||
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
|
||||
}
|
||||
|
|
@ -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<unknown> = 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<ClaudeRuntimeAuthPreparation> {
|
||||
await this.syncForCurrentSelection()
|
||||
return this.getPreparation()
|
||||
}
|
||||
|
||||
async prepareForRateLimitFetch(): Promise<ClaudeRuntimeAuthPreparation> {
|
||||
await this.syncForCurrentSelection()
|
||||
return this.getPreparation()
|
||||
}
|
||||
|
||||
async syncForCurrentSelection(): Promise<void> {
|
||||
await this.serializeMutation(() => this.doSyncForCurrentSelection())
|
||||
}
|
||||
|
||||
async forceMaterializeCurrentSelectionForRollback(): Promise<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await this.syncForCurrentSelection()
|
||||
} catch (error) {
|
||||
console.warn('[claude-runtime-auth] Failed to sync runtime auth state:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private serializeMutation<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.mutationQueue.then(fn, fn)
|
||||
this.mutationQueue = next.catch(() => {})
|
||||
return next
|
||||
}
|
||||
|
||||
private async doSyncForCurrentSelection(): Promise<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>
|
||||
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<string, unknown> {
|
||||
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<string, unknown>
|
||||
}
|
||||
} 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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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<unknown> = 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<ClaudeRateLimitAccountsState> {
|
||||
return this.serializeMutation(() => this.doAddAccount())
|
||||
}
|
||||
|
||||
async reauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||||
return this.serializeMutation(() => this.doReauthenticateAccount(accountId))
|
||||
}
|
||||
|
||||
async removeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||||
return this.serializeMutation(() => this.doRemoveAccount(accountId))
|
||||
}
|
||||
|
||||
async selectAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
|
||||
return this.serializeMutation(() => this.doSelectAccount(accountId))
|
||||
}
|
||||
|
||||
private serializeMutation<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.mutationQueue.then(fn, fn)
|
||||
this.mutationQueue = next.catch(() => {})
|
||||
return next
|
||||
}
|
||||
|
||||
private async doAddAccount(): Promise<ClaudeRateLimitAccountsState> {
|
||||
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<ClaudeRateLimitAccountsState> {
|
||||
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<ClaudeRateLimitAccountsState> {
|
||||
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<ClaudeRateLimitAccountsState> {
|
||||
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<Store['getSettings']>): void {
|
||||
this.store.updateSettings({
|
||||
claudeManagedAccounts: settings.claudeManagedAccounts,
|
||||
activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId
|
||||
})
|
||||
}
|
||||
|
||||
private async syncRuntimeAuthWithLivePtyGate(operation?: () => Promise<void>): Promise<void> {
|
||||
beginClaudeAuthSwitch()
|
||||
try {
|
||||
await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection())
|
||||
} finally {
|
||||
endClaudeAuthSwitch()
|
||||
}
|
||||
}
|
||||
|
||||
private async runClaudeLoginAndCapture(): Promise<CapturedClaudeAuth> {
|
||||
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<CapturedClaudeAuth> {
|
||||
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<string | null> {
|
||||
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<string, unknown>
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
return this.asRecord(parsed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
private readString(value: Record<string, unknown> | 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
|
||||
}
|
||||
}
|
||||
|
|
@ -74,6 +74,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): 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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
promptCacheTtlMs: 300_000,
|
||||
codexManagedAccounts: [],
|
||||
activeCodexManagedAccountId: null,
|
||||
claudeManagedAccounts: [],
|
||||
activeClaudeManagedAccountId: null,
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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<ClaudeRuntimeAuthPreparation>
|
||||
): 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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type PtySpawnOptions = {
|
|||
rows: number
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
command?: string
|
||||
/** Orca worktree identity. When present, the local provider scopes shell
|
||||
* history to this worktree so ArrowUp only surfaces local commands. */
|
||||
|
|
|
|||
|
|
@ -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<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(): Promise<string | null> {
|
||||
const credPath = path.join(homedir(), '.claude', '.credentials.json')
|
||||
async function readFromCredentialsFile(configDir?: string): Promise<string | null> {
|
||||
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<string | null> {
|
|||
* 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<string | null> {
|
||||
async function readOAuthCredentials(configDir?: string): Promise<string | null> {
|
||||
// 1. macOS Keychain (Claude Max/Pro OAuth)
|
||||
const fromKeychain = await readFromKeychain()
|
||||
if (fromKeychain) {
|
||||
|
|
@ -165,7 +166,7 @@ async function readOAuthCredentials(): Promise<string | null> {
|
|||
}
|
||||
|
||||
// 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<ProviderRateLimits> {
|
|||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchClaudeRateLimits(): Promise<ProviderRateLimits> {
|
||||
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()
|
||||
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<ProviderRateLimits> {
|
|||
// `/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 {
|
||||
|
|
|
|||
|
|
@ -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<ProviderRateLimits> {
|
||||
export async function fetchViaPty(options?: {
|
||||
authPreparation?: ClaudeRuntimeAuthPreparation
|
||||
}): Promise<ProviderRateLimits> {
|
||||
const pty = await import('node-pty')
|
||||
|
||||
return new Promise<ProviderRateLimits>((resolve) => {
|
||||
|
|
@ -144,11 +148,17 @@ export async function fetchViaPty(): Promise<ProviderRateLimits> {
|
|||
const spawnFile = isWin32 ? 'cmd.exe' : claudeCommand
|
||||
const spawnArgs = isWin32 ? ['/c', claudeCommand] : []
|
||||
|
||||
const spawnEnv = applyClaudeEnvPatch(
|
||||
{ ...process.env, TERM: 'xterm-256color' } as Record<string, string>,
|
||||
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 => {
|
||||
|
|
|
|||
|
|
@ -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<ClaudeRuntimeAuthPreparation>) | null = null
|
||||
|
||||
constructor() {}
|
||||
|
||||
|
|
@ -33,6 +37,10 @@ export class RateLimitService {
|
|||
this.codexHomePathResolver = resolver
|
||||
}
|
||||
|
||||
setClaudeAuthPreparationResolver(resolver: () => Promise<ClaudeRuntimeAuthPreparation>): 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<RateLimitState> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<ClaudeRuntimeAuthPreparation>
|
||||
): 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.
|
||||
|
|
|
|||
|
|
@ -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<CodexRateLimitAccountsState>
|
||||
select: (args: { accountId: string | null }) => Promise<CodexRateLimitAccountsState>
|
||||
}
|
||||
claudeAccounts: {
|
||||
list: () => Promise<ClaudeRateLimitAccountsState>
|
||||
add: () => Promise<ClaudeRateLimitAccountsState>
|
||||
reauthenticate: (args: { accountId: string }) => Promise<ClaudeRateLimitAccountsState>
|
||||
remove: (args: { accountId: string }) => Promise<ClaudeRateLimitAccountsState>
|
||||
select: (args: { accountId: string | null }) => Promise<ClaudeRateLimitAccountsState>
|
||||
}
|
||||
cli: {
|
||||
getInstallStatus: () => Promise<CliInstallStatus>
|
||||
install: () => Promise<CliInstallStatus>
|
||||
|
|
|
|||
|
|
@ -522,6 +522,17 @@ const api = {
|
|||
ipcRenderer.invoke('codexAccounts:select', args)
|
||||
},
|
||||
|
||||
claudeAccounts: {
|
||||
list: (): Promise<unknown> => ipcRenderer.invoke('claudeAccounts:list'),
|
||||
add: (): Promise<unknown> => ipcRenderer.invoke('claudeAccounts:add'),
|
||||
reauthenticate: (args: { accountId: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('claudeAccounts:reauthenticate', args),
|
||||
remove: (args: { accountId: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('claudeAccounts:remove', args),
|
||||
select: (args: { accountId: string | null }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('claudeAccounts:select', args)
|
||||
},
|
||||
|
||||
cli: {
|
||||
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
|
||||
install: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:install'),
|
||||
|
|
|
|||
|
|
@ -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<ClaudeRateLimitAccountsState>({
|
||||
accounts: [],
|
||||
activeAccountId: null
|
||||
})
|
||||
const [claudeAction, setClaudeAction] = useState<
|
||||
'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}`
|
||||
>('idle')
|
||||
const [removeAccountId, setRemoveAccountId] = useState<string | null>(null)
|
||||
const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState<string | null>(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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<ClaudeRateLimitAccountsState>
|
||||
): Promise<void> => {
|
||||
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) ? (
|
||||
<section key="workspace" className="space-y-4">
|
||||
|
|
@ -567,6 +644,168 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
|
|||
)}
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES) ? (
|
||||
<section key="claude-accounts" id="general-claude-accounts" className="space-y-4 scroll-mt-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Claude Accounts</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add and switch Claude Code accounts without moving chat sessions to account-specific
|
||||
config directories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title="Claude Accounts"
|
||||
description="Manage which Claude account Orca materializes into the shared Claude auth files."
|
||||
keywords={['claude', 'account', 'rate limit', 'status bar', 'quota']}
|
||||
className="space-y-3 px-1 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Accounts</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Orca swaps Claude auth only; config and chat history stay in the shared Claude root.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
void runClaudeAccountAction('adding', () => window.api.claudeAccounts.add())
|
||||
}
|
||||
disabled={claudeAction !== 'idle'}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{claudeAction === 'adding' ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-3" />
|
||||
)}
|
||||
Add Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void runClaudeAccountAction('select:system', () =>
|
||||
window.api.claudeAccounts.select({ accountId: null })
|
||||
)
|
||||
}
|
||||
disabled={claudeAction !== 'idle'}
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
|
||||
claudeAccounts.activeAccountId === null
|
||||
? 'border-foreground/20 bg-accent/15'
|
||||
: 'border-border/70 hover:border-border hover:bg-accent/8'
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">System default</span>
|
||||
{claudeAccounts.activeAccountId === null ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/80"
|
||||
>
|
||||
Active
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
Use your current system Claude login.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{claudeAccounts.accounts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/70 px-3 py-4 text-xs text-muted-foreground">
|
||||
No managed Claude accounts yet. Orca will use your system default Claude login until
|
||||
you add one here.
|
||||
</div>
|
||||
) : (
|
||||
claudeAccounts.accounts.map((account) => {
|
||||
const isActive = claudeAccounts.activeAccountId === account.id
|
||||
const isReauthing = claudeAction === `reauth:${account.id}`
|
||||
const isBusy = claudeAction !== 'idle'
|
||||
|
||||
return (
|
||||
<button
|
||||
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'
|
||||
: 'border-border/70 hover:border-border hover:bg-accent/8'
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{account.email}</span>
|
||||
{isActive ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/80"
|
||||
>
|
||||
Active
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{account.organizationName
|
||||
? `${account.organizationName} · ${formatAccountTimestamp(account.lastAuthenticatedAt)}`
|
||||
: formatAccountTimestamp(account.lastAuthenticatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-1 max-md:w-full max-md:flex-wrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void runClaudeAccountAction(`reauth:${account.id}`, () =>
|
||||
window.api.claudeAccounts.reauthenticate({ accountId: account.id })
|
||||
)
|
||||
}}
|
||||
disabled={isBusy}
|
||||
className="h-6 px-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isReauthing ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
Re-authenticate
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setRemoveClaudeAccountId(account.id)
|
||||
}}
|
||||
disabled={isBusy}
|
||||
className="h-6 px-2 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES) ? (
|
||||
<section key="codex-accounts" id="general-codex-accounts" className="space-y-4 scroll-mt-6">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -897,6 +1136,40 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={removeClaudeAccountId !== null}
|
||||
onOpenChange={(open) => !open && setRemoveClaudeAccountId(null)}
|
||||
>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Claude Account?</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRemoveClaudeAccountId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
const accountId = removeClaudeAccountId
|
||||
if (!accountId) {
|
||||
return
|
||||
}
|
||||
setRemoveClaudeAccountId(null)
|
||||
void runClaudeAccountAction(`remove:${accountId}`, () =>
|
||||
window.api.claudeAccounts.remove({ accountId })
|
||||
)
|
||||
}}
|
||||
>
|
||||
Remove Account
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{visibleSections.map((section, index) => (
|
||||
<div key={index} className="space-y-8">
|
||||
{index > 0 ? <Separator /> : null}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ClaudeRateLimitAccountsState>({
|
||||
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<void> => {
|
||||
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 (
|
||||
<ProviderDetailsMenu
|
||||
provider={claude}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
ariaLabel="Open Claude details and account switcher"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuLabel>Claude Account</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault()
|
||||
setAccountsExpanded((prev) => !prev)
|
||||
}}
|
||||
>
|
||||
<span className="max-w-[180px] truncate text-[12px] text-foreground">
|
||||
{activeAccountLabel}
|
||||
</span>
|
||||
{accountsExpanded ? (
|
||||
<ChevronDown className="ml-auto size-3.5 text-muted-foreground/85" />
|
||||
) : (
|
||||
<ChevronRight className="ml-auto size-3.5 text-muted-foreground/85" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{accountsExpanded ? (
|
||||
<div className="px-1 pb-1">
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
Switch to
|
||||
</div>
|
||||
<div className="max-h-[220px] overflow-y-auto rounded-md border border-border/60 bg-accent/5 p-1">
|
||||
{availableSwitchTargets.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">No other accounts</div>
|
||||
) : null}
|
||||
{availableSwitchTargets.map((target) => (
|
||||
<DropdownMenuItem
|
||||
key={target.id ?? 'system'}
|
||||
disabled={isSwitching}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault()
|
||||
void handleSelectAccount(target.id)
|
||||
}}
|
||||
>
|
||||
<span className="max-w-[220px] truncate">{target.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-2 py-1.5 text-[10px] leading-4 text-muted-foreground">
|
||||
Restart live Claude terminals before continuing old conversations after switching.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
openSettingsTarget({
|
||||
pane: 'general',
|
||||
repoId: null,
|
||||
sectionId: 'general-claude-accounts'
|
||||
})
|
||||
openSettingsPage()
|
||||
}}
|
||||
>
|
||||
Manage Accounts…
|
||||
</DropdownMenuItem>
|
||||
</ProviderDetailsMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mini progress bar (shows remaining capacity, grey)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -487,14 +634,7 @@ function StatusBarInner(): React.JSX.Element | null {
|
|||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{showClaude && (
|
||||
<ProviderDetailsMenu
|
||||
provider={claude}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
ariaLabel="Open Claude usage details"
|
||||
/>
|
||||
)}
|
||||
{showClaude && <ClaudeSwitcherMenu claude={claude} compact={compact} iconOnly={iconOnly} />}
|
||||
{showCodex && <CodexSwitcherMenu codex={codex} compact={compact} iconOnly={iconOnly} />}
|
||||
{anyVisible && (
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue