From 1cf0f46296b7fa0b4d2adbbad168127e528e426f Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 11:17:19 -0300 Subject: [PATCH 01/16] feat(claude-accounts): resolve --account selectors to managed Claude accounts --- .../claude-launch-account.test.ts | 68 +++++++++++++++++++ .../claude-accounts/claude-launch-account.ts | 59 ++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/main/claude-accounts/claude-launch-account.test.ts create mode 100644 src/main/claude-accounts/claude-launch-account.ts diff --git a/src/main/claude-accounts/claude-launch-account.test.ts b/src/main/claude-accounts/claude-launch-account.test.ts new file mode 100644 index 000000000000..b5c268c3ff0d --- /dev/null +++ b/src/main/claude-accounts/claude-launch-account.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeManagedAccountSummary } from '../../shared/managed-account-types' +import { ClaudeLaunchAccountError, resolveClaudeLaunchAccount } from './claude-launch-account' + +function summary( + id: string, + email: string, + overrides: Partial = {} +): ClaudeManagedAccountSummary { + return { + id, + email, + managedAuthRuntime: 'host', + wslDistro: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...overrides + } +} + +describe('resolveClaudeLaunchAccount', () => { + const state = { + accounts: [ + summary('acct-a', 'Alice@Example.com'), + summary('acct-b', 'bob@example.com'), + summary('acct-b2', 'bob@example.com'), + summary('acct-wsl', 'wsl@example.com', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }) + ], + activeAccountId: 'acct-a' + } + + it('resolves an exact id and reports whether it is the host account', () => { + expect(resolveClaudeLaunchAccount(state, 'acct-a')).toEqual({ + accountId: 'acct-a', + email: 'Alice@Example.com', + isActiveOnHost: true + }) + expect(resolveClaudeLaunchAccount(state, 'acct-b2').isActiveOnHost).toBe(false) + }) + + it('matches emails case-insensitively', () => { + expect(resolveClaudeLaunchAccount(state, ' alice@example.COM ').accountId).toBe('acct-a') + }) + + it('lists the candidate ids when an email is ambiguous', () => { + expect(() => resolveClaudeLaunchAccount(state, 'bob@example.com')).toThrow( + /acct-b, acct-b2.*--account / + ) + }) + + it('points unknown selectors at orca account list', () => { + expect(() => resolveClaudeLaunchAccount(state, 'nobody@example.com')).toThrow( + ClaudeLaunchAccountError + ) + expect(() => resolveClaudeLaunchAccount(state, 'nobody@example.com')).toThrow( + 'orca account list' + ) + }) + + it('refuses WSL accounts and empty selectors', () => { + expect(() => resolveClaudeLaunchAccount(state, 'acct-wsl')).toThrow(/host accounts only/) + expect(() => resolveClaudeLaunchAccount(state, ' ')).toThrow(/requires a Claude account/) + }) +}) diff --git a/src/main/claude-accounts/claude-launch-account.ts b/src/main/claude-accounts/claude-launch-account.ts new file mode 100644 index 000000000000..26a42021d25d --- /dev/null +++ b/src/main/claude-accounts/claude-launch-account.ts @@ -0,0 +1,59 @@ +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' + +export type ClaudeLaunchAccountResolution = { + accountId: string + email: string + /** The host's selected account; a launch on it takes the normal (unpinned) path. */ + isActiveOnHost: boolean +} + +export class ClaudeLaunchAccountError extends Error { + constructor(message: string) { + super(message) + this.name = 'ClaudeLaunchAccountError' + } +} + +/** + * Resolves `--account ` against this host's managed Claude accounts. + * + * An exact id wins over an email so an id can always disambiguate; emails compare + * case-insensitively because providers normalize them and users type them either way. + */ +export function resolveClaudeLaunchAccount( + state: Pick, + selector: string +): ClaudeLaunchAccountResolution { + const requested = selector.trim() + if (!requested) { + throw new ClaudeLaunchAccountError('--account requires a Claude account id or email.') + } + const byId = state.accounts.find((account) => account.id === requested) + const normalizedEmail = requested.toLowerCase() + const matches = byId + ? [byId] + : state.accounts.filter((account) => account.email.trim().toLowerCase() === normalizedEmail) + if (matches.length === 0) { + throw new ClaudeLaunchAccountError( + `No managed Claude account matches "${requested}". Run \`orca account list\` to see the account ids and emails on this host.` + ) + } + if (matches.length > 1) { + throw new ClaudeLaunchAccountError( + `More than one managed Claude account uses ${requested} (${matches.map((account) => account.id).join(', ')}). Pass --account instead.` + ) + } + const account = matches[0] + if (account.managedAuthRuntime === 'wsl') { + // Why: a WSL account's credentials live inside the distro; the pinned launch only knows how + // to hand a host config dir to a host Claude. + throw new ClaudeLaunchAccountError( + `Claude account ${account.email} (${account.id}) is a WSL account; --account supports host accounts only.` + ) + } + return { + accountId: account.id, + email: account.email, + isActiveOnHost: state.activeAccountId === account.id + } +} From 8a7e4fd259f704e06cf3bd3325f7f08de195d0ef Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 11:18:58 -0300 Subject: [PATCH 02/16] feat(claude-accounts): track Claude PTYs pinned to a non-active account --- .../claude-pinned-pty-registry.test.ts | 104 ++++++++++ .../claude-pinned-pty-registry.ts | 182 ++++++++++++++++++ .../claude-accounts/live-pty-gate.test.ts | 27 +++ src/main/claude-accounts/live-pty-gate.ts | 4 + 4 files changed, 317 insertions(+) create mode 100644 src/main/claude-accounts/claude-pinned-pty-registry.test.ts create mode 100644 src/main/claude-accounts/claude-pinned-pty-registry.ts diff --git a/src/main/claude-accounts/claude-pinned-pty-registry.test.ts b/src/main/claude-accounts/claude-pinned-pty-registry.test.ts new file mode 100644 index 000000000000..74c454a9f853 --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-pty-registry.test.ts @@ -0,0 +1,104 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + _internals, + attachClaudePinnedPtyPersistence, + confirmSeededPinnedClaudePtys, + countClaudePinnedAccountUsers, + createClaudePinnedPtyFilePersistence, + hasLivePinnedClaudePtys, + hasSeededUnconfirmedPinnedClaudePtys, + markPinnedClaudePtyExited, + markPinnedClaudePtySpawned, + onClaudePinnedAccountDrained, + readClaudePinnedPtyRegistryFile, + reserveClaudePinnedAccount, + seedPinnedClaudePtysFromPersistence +} from './claude-pinned-pty-registry' + +describe('Claude pinned PTY registry', () => { + afterEach(() => { + _internals.reset() + }) + + it('counts reservations and live PTYs per account and drains once both are gone', () => { + const drained = vi.fn() + onClaudePinnedAccountDrained(drained) + const release = reserveClaudePinnedAccount('acct-b') + markPinnedClaudePtySpawned('pty-1', 'acct-b') + expect(countClaudePinnedAccountUsers('acct-b')).toBe(2) + expect(countClaudePinnedAccountUsers('acct-c')).toBe(0) + + release() + release() + expect(countClaudePinnedAccountUsers('acct-b')).toBe(1) + expect(drained).not.toHaveBeenCalled() + + markPinnedClaudePtyExited('pty-1') + expect(hasLivePinnedClaudePtys('acct-b')).toBe(false) + expect(drained).toHaveBeenCalledTimes(1) + expect(drained).toHaveBeenCalledWith('acct-b') + }) + + it('ignores exits of PTYs it never pinned', () => { + const drained = vi.fn() + onClaudePinnedAccountDrained(drained) + markPinnedClaudePtyExited('unrelated') + expect(drained).not.toHaveBeenCalled() + }) + + it('keeps seeded PTYs guarding until the daemon proves them dead', () => { + const drained = vi.fn() + onClaudePinnedAccountDrained(drained) + seedPinnedClaudePtysFromPersistence({ alive: 'acct-b', dead: 'acct-c' }) + expect(hasSeededUnconfirmedPinnedClaudePtys()).toBe(true) + expect(hasLivePinnedClaudePtys('acct-c')).toBe(true) + + confirmSeededPinnedClaudePtys(['alive']) + + expect(hasSeededUnconfirmedPinnedClaudePtys()).toBe(false) + expect(hasLivePinnedClaudePtys('acct-b')).toBe(true) + expect(hasLivePinnedClaudePtys('acct-c')).toBe(false) + expect(drained).toHaveBeenCalledExactlyOnceWith('acct-c') + }) + + it('persists the live map to disk and reads it back', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-claude-pinned-')) + try { + const filePath = join(dir, 'claude-pinned-pane-accounts.json') + attachClaudePinnedPtyPersistence(createClaudePinnedPtyFilePersistence(filePath)) + markPinnedClaudePtySpawned('pty-1', 'acct-b') + markPinnedClaudePtySpawned('pty-2', 'acct-c') + markPinnedClaudePtyExited('pty-2') + + expect(JSON.parse(readFileSync(filePath, 'utf-8'))).toEqual({ + version: 1, + panes: { 'pty-1': 'acct-b' } + }) + expect(readClaudePinnedPtyRegistryFile(filePath)).toEqual({ 'pty-1': 'acct-b' }) + + writeFileSync(filePath, '{"panes":{"x":42,"y":"acct-d"}}') + expect(readClaudePinnedPtyRegistryFile(filePath)).toEqual({ y: 'acct-d' }) + writeFileSync(filePath, 'not json') + expect(readClaudePinnedPtyRegistryFile(filePath)).toEqual({}) + expect(readClaudePinnedPtyRegistryFile(join(dir, 'missing.json'))).toEqual({}) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('never lets a persistence failure break a spawn or an exit', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + attachClaudePinnedPtyPersistence({ + write: () => { + throw new Error('disk full') + } + }) + expect(() => markPinnedClaudePtySpawned('pty-1', 'acct-b')).not.toThrow() + expect(() => markPinnedClaudePtyExited('pty-1')).not.toThrow() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/main/claude-accounts/claude-pinned-pty-registry.ts b/src/main/claude-accounts/claude-pinned-pty-registry.ts new file mode 100644 index 000000000000..dfe9670c69a3 --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-pty-registry.ts @@ -0,0 +1,182 @@ +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' + +/** + * Claude PTYs launched with `--account` on a managed account that is not the host's active one. + * + * Kept apart from live-pty-gate's global set on purpose: that set defers the ACTIVE account's + * refresh and read-back, which a pinned PTY never touches. A pinned PTY guards its own account + * instead — no host switch to it, no removal, no Orca-side token refresh — for as long as a + * Claude it launched may still hold that account's single-use refresh token. + */ +const accountIdByPtyId = new Map() +// Why: restored at startup but not yet confirmed against the daemon; they keep the account +// guarded so a restart cannot refresh a token a surviving pinned Claude still owns. +const seededUnconfirmedPtyIds = new Set() +// Why: a launch holds its account from auth preparation until the PTY is registered, so a host +// switch cannot land between the credential seed and the spawn. +const reservationsByAccountId = new Map() +const drainListeners = new Set<(accountId: string) => void>() + +export type ClaudePinnedPtyPersistence = { + write(entries: Record): void +} + +let persistence: ClaudePinnedPtyPersistence | null = null + +export function attachClaudePinnedPtyPersistence(target: ClaudePinnedPtyPersistence | null): void { + persistence = target +} + +/** Fires when an account's last pinned PTY exits and no launch still holds it. */ +export function onClaudePinnedAccountDrained(listener: (accountId: string) => void): () => void { + drainListeners.add(listener) + return () => drainListeners.delete(listener) +} + +export function countClaudePinnedAccountUsers(accountId: string): number { + let count = reservationsByAccountId.get(accountId) ?? 0 + for (const pinnedAccountId of accountIdByPtyId.values()) { + if (pinnedAccountId === accountId) { + count += 1 + } + } + return count +} + +export function hasLivePinnedClaudePtys(accountId: string): boolean { + for (const pinnedAccountId of accountIdByPtyId.values()) { + if (pinnedAccountId === accountId) { + return true + } + } + return false +} + +/** Returns an idempotent release; call it once the launch registered its PTY or failed. */ +export function reserveClaudePinnedAccount(accountId: string): () => void { + reservationsByAccountId.set(accountId, (reservationsByAccountId.get(accountId) ?? 0) + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (reservationsByAccountId.get(accountId) ?? 1) - 1 + if (remaining > 0) { + reservationsByAccountId.set(accountId, remaining) + } else { + reservationsByAccountId.delete(accountId) + } + notifyIfDrained(accountId) + } +} + +export function markPinnedClaudePtySpawned(ptyId: string, accountId: string): void { + accountIdByPtyId.set(ptyId, accountId) + seededUnconfirmedPtyIds.delete(ptyId) + persist() +} + +/** Called from live-pty-gate's exit choke point for every PTY; a no-op for unpinned ones. */ +export function markPinnedClaudePtyExited(ptyId: string): void { + const accountId = accountIdByPtyId.get(ptyId) + seededUnconfirmedPtyIds.delete(ptyId) + if (accountId === undefined) { + return + } + accountIdByPtyId.delete(ptyId) + persist() + notifyIfDrained(accountId) +} + +export function seedPinnedClaudePtysFromPersistence(entries: Record): void { + for (const [ptyId, accountId] of Object.entries(entries)) { + accountIdByPtyId.set(ptyId, accountId) + seededUnconfirmedPtyIds.add(ptyId) + } +} + +export function hasSeededUnconfirmedPinnedClaudePtys(): boolean { + return seededUnconfirmedPtyIds.size > 0 +} + +/** Releases seeded ids the daemon no longer knows; live ones keep guarding their account. */ +export function confirmSeededPinnedClaudePtys(aliveSessionIds: readonly string[]): void { + const alive = new Set(aliveSessionIds) + const drainedCandidates = new Set() + for (const ptyId of seededUnconfirmedPtyIds) { + const accountId = accountIdByPtyId.get(ptyId) + if (!alive.has(ptyId) && accountId !== undefined) { + accountIdByPtyId.delete(ptyId) + drainedCandidates.add(accountId) + } + } + seededUnconfirmedPtyIds.clear() + if (drainedCandidates.size > 0) { + persist() + } + for (const accountId of drainedCandidates) { + notifyIfDrained(accountId) + } +} + +function notifyIfDrained(accountId: string): void { + if (countClaudePinnedAccountUsers(accountId) > 0) { + return + } + for (const listener of drainListeners) { + listener(accountId) + } +} + +function persist(): void { + try { + persistence?.write(Object.fromEntries(accountIdByPtyId)) + } catch (error) { + // Why: the record only protects a restart; losing it must never fail a spawn or an exit. + console.warn('[claude-pinned-pty] Failed to persist pinned Claude PTYs:', error) + } +} + +export function readClaudePinnedPtyRegistryFile(filePath: string): Record { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(filePath, 'utf-8')) + } catch { + return {} + } + const panes = parsed && typeof parsed === 'object' && 'panes' in parsed ? parsed.panes : undefined + if (!panes || typeof panes !== 'object' || Array.isArray(panes)) { + return {} + } + const entries: Record = {} + for (const [ptyId, accountId] of Object.entries(panes)) { + if (typeof accountId === 'string' && accountId) { + entries[ptyId] = accountId + } + } + return entries +} + +export function createClaudePinnedPtyFilePersistence(filePath: string): ClaudePinnedPtyPersistence { + return { + write(entries) { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileAtomically(filePath, `${JSON.stringify({ version: 1, panes: entries })}\n`, { + mode: 0o600 + }) + } + } +} + +export const _internals = { + reset(): void { + accountIdByPtyId.clear() + seededUnconfirmedPtyIds.clear() + reservationsByAccountId.clear() + drainListeners.clear() + persistence = null + } +} diff --git a/src/main/claude-accounts/live-pty-gate.test.ts b/src/main/claude-accounts/live-pty-gate.test.ts index 7359bbd98055..792d09dfd0d9 100644 --- a/src/main/claude-accounts/live-pty-gate.test.ts +++ b/src/main/claude-accounts/live-pty-gate.test.ts @@ -11,6 +11,12 @@ import { onLiveClaudePtysDrained, seedLiveClaudePtysFromPersistence } from './live-pty-gate' +import { + _internals as pinnedRegistryInternals, + hasLivePinnedClaudePtys, + markPinnedClaudePtySpawned, + onClaudePinnedAccountDrained +} from './claude-pinned-pty-registry' describe('Claude live PTY gate', () => { afterEach(() => { @@ -20,6 +26,7 @@ describe('Claude live PTY gate', () => { confirmSeededClaudeLivePtys([]) attachClaudeLivePtyPersistence(null) endClaudeAuthSwitch() + pinnedRegistryInternals.reset() }) it('allows switching while Claude PTYs are live', () => { @@ -139,4 +146,24 @@ describe('Claude live PTY gate', () => { markClaudePtyExited('live-claude-pty') expect(removeClaudeLivePtySessionId).toHaveBeenCalledWith('live-claude-pty') }) + + it('keeps pinned PTYs out of the global gate but releases them through the same exit', () => { + const onGlobalDrained = vi.fn() + const onPinnedDrained = vi.fn() + const unsubscribe = onLiveClaudePtysDrained(onGlobalDrained) + onClaudePinnedAccountDrained(onPinnedDrained) + try { + markPinnedClaudePtySpawned('pinned-pty', 'acct-b') + + expect(hasLiveClaudePtys()).toBe(false) + + markClaudePtyExited('pinned-pty') + + expect(hasLivePinnedClaudePtys('acct-b')).toBe(false) + expect(onPinnedDrained).toHaveBeenCalledExactlyOnceWith('acct-b') + expect(onGlobalDrained).not.toHaveBeenCalled() + } finally { + unsubscribe() + } + }) }) diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts index caab66c34308..2614929e10d0 100644 --- a/src/main/claude-accounts/live-pty-gate.ts +++ b/src/main/claude-accounts/live-pty-gate.ts @@ -1,3 +1,5 @@ +import { markPinnedClaudePtyExited } from './claude-pinned-pty-registry' + const liveClaudePtyIds = new Set() // Why: ids restored from persistence at startup, not yet confirmed against the // daemon. They keep the OAuth refresh gate closed so an early managed refresh @@ -85,6 +87,8 @@ export function markClaudePtyExited(ptyId: string): void { liveClaudePtyIds.delete(ptyId) seededUnconfirmedPtyIds.delete(ptyId) persistence?.removeClaudeLivePtySessionId(ptyId) + // Why: this is the one exit path every provider reports through, so pinned PTYs release here too. + markPinnedClaudePtyExited(ptyId) notifyDrainedOnTransition(hadLivePtys) } From 8f000ae83ce48f31974ee2cfa4cb59a5747196b8 Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 11:38:18 -0300 Subject: [PATCH 03/16] feat(claude-accounts): prepare pinned Claude launches on a non-active host account --- .../claude-pinned-config-dir.test.ts | 141 +++++++ .../claude-pinned-config-dir.ts | 183 +++++++++ .../claude-pinned-credentials.ts | 97 +++++ src/main/claude-accounts/environment.ts | 5 + ...runtime-auth-service-pinned-launch.test.ts | 351 ++++++++++++++++++ .../claude-accounts/runtime-auth-service.ts | 65 +++- .../runtime-auth-pinned-launch.ts | 170 +++++++++ .../runtime-auth/runtime-auth-readback.ts | 10 + .../runtime-auth/runtime-auth-sync.ts | 18 +- .../runtime-auth/runtime-auth-types.ts | 7 + 10 files changed, 1042 insertions(+), 5 deletions(-) create mode 100644 src/main/claude-accounts/claude-pinned-config-dir.test.ts create mode 100644 src/main/claude-accounts/claude-pinned-config-dir.ts create mode 100644 src/main/claude-accounts/claude-pinned-credentials.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts create mode 100644 src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts diff --git a/src/main/claude-accounts/claude-pinned-config-dir.test.ts b/src/main/claude-accounts/claude-pinned-config-dir.test.ts new file mode 100644 index 000000000000..732140ad4b85 --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-config-dir.test.ts @@ -0,0 +1,141 @@ +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const fsMock = vi.hoisted(() => ({ symlinkTypes: [] as (string | null | undefined)[] })) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + symlinkSync: (target: string, path: string, type?: 'dir' | 'file' | 'junction' | null) => { + fsMock.symlinkTypes.push(type) + // Why: junctions only exist on Windows; the call shape is what this suite asserts. + actual.symlinkSync(target, path, type === 'junction' ? 'dir' : type) + } + } +}) + +const { prepareClaudePinnedConfigDir } = await import('./claude-pinned-config-dir') + +describe('prepareClaudePinnedConfigDir', () => { + let root: string + let hostConfigDir: string + let hostConfigPath: string + let pinnedDir: string + + beforeEach(() => { + fsMock.symlinkTypes = [] + root = mkdtempSync(join(tmpdir(), 'orca-claude-pinned-config-')) + hostConfigDir = join(root, 'home', '.claude') + hostConfigPath = join(root, 'home', '.claude.json') + pinnedDir = join(root, 'claude-accounts', 'acct-b', 'auth') + mkdirSync(hostConfigDir, { recursive: true }) + mkdirSync(pinnedDir, { recursive: true }) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + function prepare(platform: NodeJS.Platform = 'darwin', oauthAccount: unknown = null): void { + prepareClaudePinnedConfigDir({ + configDir: pinnedDir, + source: { hostConfigDir, hostConfigPath }, + oauthAccount, + platform + }) + } + + function readPinnedConfig(): Record { + return JSON.parse(readFileSync(join(pinnedDir, '.claude.json'), 'utf-8')) + } + + it('links projects to the host transcripts, as a junction on Windows', () => { + prepare('win32') + const linkPath = join(pinnedDir, 'projects') + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + expect(resolve(pinnedDir, readlinkSync(linkPath))).toBe(join(hostConfigDir, 'projects')) + expect(fsMock.symlinkTypes).toEqual(['junction']) + + prepare('linux') + expect(fsMock.symlinkTypes).toEqual(['junction']) + }) + + it('never deletes a non-empty real projects dir', () => { + mkdirSync(join(pinnedDir, 'projects', 'session'), { recursive: true }) + prepare() + expect(lstatSync(join(pinnedDir, 'projects')).isDirectory()).toBe(true) + expect(fsMock.symlinkTypes).toEqual([]) + }) + + it('repoints a link that targets a stale host dir', () => { + const stale = join(root, 'stale-projects') + mkdirSync(stale) + symlinkSync(stale, join(pinnedDir, 'projects'), 'dir') + prepare('linux') + expect(resolve(pinnedDir, readlinkSync(join(pinnedDir, 'projects')))).toBe( + join(hostConfigDir, 'projects') + ) + }) + + it('mirrors the host settings.json so Orca hooks and the statusline load', () => { + writeFileSync(join(hostConfigDir, 'settings.json'), '{"hooks":{"Stop":[]}}\n') + prepare() + expect(readFileSync(join(pinnedDir, 'settings.json'), 'utf-8')).toBe('{"hooks":{"Stop":[]}}\n') + + writeFileSync(join(hostConfigDir, 'settings.json'), '{"hooks":{}}\n') + prepare() + expect(readFileSync(join(pinnedDir, 'settings.json'), 'utf-8')).toBe('{"hooks":{}}\n') + }) + + it('merges first-run keys, trusted projects, and the account without clobbering', () => { + writeFileSync( + hostConfigPath, + JSON.stringify({ + hasCompletedOnboarding: true, + lastOnboardingVersion: '2.1.0', + theme: 'dark', + numStartups: 99, + oauthAccount: { emailAddress: 'active@example.com' }, + projects: { + '/work/trusted': { hasTrustDialogAccepted: true, history: ['secret prompt'] }, + '/work/untrusted': { hasTrustDialogAccepted: false } + } + }) + ) + writeFileSync( + join(pinnedDir, '.claude.json'), + JSON.stringify({ theme: 'light', projects: { '/work/trusted': { allowedTools: ['x'] } } }) + ) + + prepare('darwin', { emailAddress: 'pinned@example.com' }) + + expect(readPinnedConfig()).toEqual({ + theme: 'light', + hasCompletedOnboarding: true, + lastOnboardingVersion: '2.1.0', + projects: { + '/work/trusted': { allowedTools: ['x'], hasTrustDialogAccepted: true } + }, + oauthAccount: { emailAddress: 'pinned@example.com' } + }) + }) + + it('leaves an unparseable pinned .claude.json alone', () => { + writeFileSync(hostConfigPath, JSON.stringify({ hasCompletedOnboarding: true })) + writeFileSync(join(pinnedDir, '.claude.json'), '{not json') + prepare('darwin', { emailAddress: 'pinned@example.com' }) + expect(readFileSync(join(pinnedDir, '.claude.json'), 'utf-8')).toBe('{not json') + }) +}) diff --git a/src/main/claude-accounts/claude-pinned-config-dir.ts b/src/main/claude-accounts/claude-pinned-config-dir.ts new file mode 100644 index 000000000000..828e74ab4a61 --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-config-dir.ts @@ -0,0 +1,183 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + readlinkSync, + rmdirSync, + rmSync, + symlinkSync, + type Stats +} from 'node:fs' +import { join, resolve } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' + +/** + * Makes a managed account's auth dir usable as a pinned launch's CLAUDE_CONFIG_DIR. + * + * A bare managed dir holds only credentials, so Claude would start without Orca's hooks (the + * worker never reports a turn), replay onboarding and trust prompts, and write transcripts no + * Orca scanner looks at. Each launch mirrors just enough of the host config dir to avoid that; + * everything else (CLAUDE.md, agents, plugins, MCP servers) is deliberately not inherited. + */ + +// Keys Claude reads to decide whether to replay first-run screens; copied only when absent. +const FIRST_RUN_CONFIG_KEYS = [ + 'hasCompletedOnboarding', + 'lastOnboardingVersion', + 'theme', + 'bypassPermissionsModeAccepted' +] as const + +export type ClaudePinnedConfigDirSource = { + /** The host config dir a normal launch uses. */ + hostConfigDir: string + /** The host `.claude.json` a normal launch uses (colocated or `~/.claude.json`). */ + hostConfigPath: string +} + +export function prepareClaudePinnedConfigDir(args: { + configDir: string + source: ClaudePinnedConfigDirSource + oauthAccount: unknown + platform?: NodeJS.Platform +}): void { + const platform = args.platform ?? process.platform + linkSharedProjectsDir(args.configDir, args.source.hostConfigDir, platform) + mirrorHostSettings(args.configDir, args.source.hostConfigDir) + mergeFirstRunConfig(args.configDir, args.source.hostConfigPath, args.oauthAccount) +} + +/** Shares transcripts so resume and every session scanner find pinned sessions too. */ +function linkSharedProjectsDir( + configDir: string, + hostConfigDir: string, + platform: NodeJS.Platform +): void { + const target = resolve(hostConfigDir, 'projects') + const linkPath = join(configDir, 'projects') + mkdirSync(target, { recursive: true }) + const existing = lstatOrNull(linkPath) + if (existing?.isSymbolicLink()) { + if (resolve(configDir, readlinkSync(linkPath)) === target) { + return + } + rmSync(linkPath, { force: true }) + } else if (existing?.isDirectory()) { + // Why: a non-empty real dir holds sessions written before linking existed; never delete them. + if (readdirSync(linkPath).length > 0) { + return + } + rmdirSync(linkPath) + } else if (existing) { + return + } + // Why: a junction needs no Developer Mode or admin rights on Windows, unlike a dir symlink. + symlinkSync(target, linkPath, platform === 'win32' ? 'junction' : 'dir') +} + +/** Hooks and the statusline live in settings.json; mirror it so the pinned Claude reports status. */ +function mirrorHostSettings(configDir: string, hostConfigDir: string): void { + const hostSettingsPath = join(hostConfigDir, 'settings.json') + let contents: string + try { + if (!existsSync(hostSettingsPath)) { + return + } + contents = readFileSync(hostSettingsPath, 'utf-8') + } catch { + return + } + const pinnedSettingsPath = join(configDir, 'settings.json') + if (readFileOrNull(pinnedSettingsPath) === contents) { + return + } + writeFileAtomically(pinnedSettingsPath, contents, { mode: 0o600 }) +} + +/** Add-only, so anything the pinned Claude wrote for itself is never overwritten. */ +function mergeFirstRunConfig( + configDir: string, + hostConfigPath: string, + oauthAccount: unknown +): void { + const pinnedConfigPath = join(configDir, '.claude.json') + const pinnedExists = existsSync(pinnedConfigPath) + const pinnedRaw = pinnedExists ? readFileOrNull(pinnedConfigPath) : null + const pinned = pinnedExists ? (pinnedRaw === null ? null : parseJsonObject(pinnedRaw)) : {} + if (!pinned) { + // Why: an unparseable file is Claude's own state; rewriting it could erase its history. + return + } + const hostRaw = readFileOrNull(hostConfigPath) + const host = (hostRaw === null ? null : parseJsonObject(hostRaw)) ?? {} + let changed = false + for (const key of FIRST_RUN_CONFIG_KEYS) { + if (!(key in pinned) && key in host) { + pinned[key] = host[key] + changed = true + } + } + const hostProjects = asRecord(host.projects) + if (hostProjects) { + const pinnedProjects = asRecord(pinned.projects) ?? {} + let projectsChanged = false + for (const [projectPath, entry] of Object.entries(hostProjects)) { + if (asRecord(entry)?.hasTrustDialogAccepted !== true) { + continue + } + const pinnedEntry = asRecord(pinnedProjects[projectPath]) + if (pinnedEntry?.hasTrustDialogAccepted === true) { + continue + } + pinnedProjects[projectPath] = { ...pinnedEntry, hasTrustDialogAccepted: true } + projectsChanged = true + } + if (projectsChanged) { + pinned.projects = pinnedProjects + changed = true + } + } + if (!('oauthAccount' in pinned) && oauthAccount !== null && oauthAccount !== undefined) { + pinned.oauthAccount = oauthAccount + changed = true + } + if (changed) { + writeFileAtomically(pinnedConfigPath, `${JSON.stringify(pinned, null, 2)}\n`, { + mode: 0o600 + }) + } +} + +function lstatOrNull(targetPath: string): Stats | null { + try { + return lstatSync(targetPath) + } catch { + return null + } +} + +function readFileOrNull(targetPath: string): string | null { + try { + return readFileSync(targetPath, 'utf-8') + } catch { + return null + } +} + +function parseJsonObject(raw: string): Record | null { + try { + return asRecord(JSON.parse(raw)) + } catch { + return null + } +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: narrowed to a non-null, non-array object above. + return value as Record +} diff --git a/src/main/claude-accounts/claude-pinned-credentials.ts b/src/main/claude-accounts/claude-pinned-credentials.ts new file mode 100644 index 000000000000..46f20c57d381 --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-credentials.ts @@ -0,0 +1,97 @@ +import { createHash } from 'node:crypto' +import { existsSync, lstatSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentialsStrict, + writeActiveClaudeKeychainCredentials +} from './keychain' + +/** + * macOS credential staging for a Claude launched on a non-active managed account. + * + * Claude reads the Keychain item scoped to its CLAUDE_CONFIG_DIR, never Orca's managed item, so a + * pinned launch copies the managed blob into that scoped item and takes it back when the last + * pinned PTY exits. Refresh tokens are single-use, so while the scoped copy is live it is the only + * store allowed to refresh; the marker records what was seeded so the read-back can tell a Claude + * refresh (adopt) from a re-auth that happened in between (the managed store wins). + * + * Only the scoped item is touched here. The legacy unscoped `Claude Code-credentials` item belongs + * to the host's active account and every call passes a config dir, so none can reach it. + */ +const PINNED_KEYCHAIN_SEED_MARKER = '.orca-pinned-keychain-seed' + +// Why: a hot-path cache for the usage fetcher; the marker file is the source of truth. +const pendingSeedAccountIds = new Set() + +export function hashClaudeCredentialsJson(credentialsJson: string): string { + return createHash('sha256').update(credentialsJson).digest('hex') +} + +export function readPinnedClaudeSeedMarker(configDir: string): string | null { + const markerPath = join(configDir, PINNED_KEYCHAIN_SEED_MARKER) + try { + if (!existsSync(markerPath) || !lstatSync(markerPath).isFile()) { + return null + } + return readFileSync(markerPath, 'utf-8').trim() || null + } catch { + return null + } +} + +/** True while a seeded scoped item may hold a newer token than the managed store. */ +export function hasPendingPinnedClaudeSeed(accountId: string): boolean { + return pendingSeedAccountIds.has(accountId) +} + +/** Loads markers left by a previous run so the crash recovery happens before any refresh. */ +export function notePinnedClaudeSeedMarker(accountId: string, configDir: string): void { + if (readPinnedClaudeSeedMarker(configDir) !== null) { + pendingSeedAccountIds.add(accountId) + } +} + +export async function seedPinnedClaudeKeychainCredentials(args: { + accountId: string + configDir: string + credentialsJson: string +}): Promise { + await writeActiveClaudeKeychainCredentials(args.credentialsJson, args.configDir) + try { + // A hash, never the blob: the marker sits in a plain file next to the config. + writeFileAtomically( + join(args.configDir, PINNED_KEYCHAIN_SEED_MARKER), + `${hashClaudeCredentialsJson(args.credentialsJson)}\n`, + { mode: 0o600 } + ) + } catch (error) { + // Why: an unmarked scoped copy could never be read back, so do not leave one live. + await deleteActiveClaudeKeychainCredentialsStrict(args.configDir).catch(() => {}) + throw error + } + pendingSeedAccountIds.add(args.accountId) +} + +export async function readPinnedClaudeKeychainCredentials( + configDir: string +): Promise { + return readActiveClaudeKeychainCredentialsStrict(configDir) +} + +/** Deletes the scoped copy first so a failed delete keeps the marker and is retried. */ +export async function clearPinnedClaudeKeychainCredentials( + accountId: string, + configDir: string +): Promise { + await deleteActiveClaudeKeychainCredentialsStrict(configDir) + rmSync(join(configDir, PINNED_KEYCHAIN_SEED_MARKER), { force: true }) + pendingSeedAccountIds.delete(accountId) +} + +export const _internals = { + reset(): void { + pendingSeedAccountIds.clear() + } +} diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index b85dd60a8542..8d56eb76494c 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -9,6 +9,8 @@ export const CLAUDE_AUTH_ENV_VARS = [ export type ClaudeEnvPatch = { CLAUDE_CONFIG_DIR?: string + /** Claude Code 2.1.220+ hashes this, not CLAUDE_CONFIG_DIR, for its scoped Keychain item. */ + CLAUDE_SECURESTORAGE_CONFIG_DIR?: string ANTHROPIC_CUSTOM_HEADERS?: string } @@ -36,6 +38,9 @@ export function applyClaudeEnvPatch( if (patch.CLAUDE_CONFIG_DIR) { baseEnv.CLAUDE_CONFIG_DIR = patch.CLAUDE_CONFIG_DIR } + if (patch.CLAUDE_SECURESTORAGE_CONFIG_DIR) { + baseEnv.CLAUDE_SECURESTORAGE_CONFIG_DIR = patch.CLAUDE_SECURESTORAGE_CONFIG_DIR + } if (patch.ANTHROPIC_CUSTOM_HEADERS !== undefined) { baseEnv.ANTHROPIC_CUSTOM_HEADERS = patch.ANTHROPIC_CUSTOM_HEADERS } diff --git a/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts new file mode 100644 index 000000000000..f21b519ccd7d --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts @@ -0,0 +1,351 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeAccount, + createClaudeCredentialsJson, + createElectronMock, + createManagedClaudeAuth, + createOauthRefreshMock, + createSettings, + createStore, + readManagedCredentialsForTest, + resetRuntimeAuthTestState, + setPlatform, + testState +} from './runtime-auth-service-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +vi.mock('electron', () => createElectronMock()) + +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +// Why a local mock: the shared harness only models the host's own scoped item, and a pinned +// launch's whole point is a second scoped item keyed by the managed dir. +const keychain = vi.hoisted(() => ({ + scoped: new Map(), + legacy: null as string | null, + throwScopedRead: false +})) + +vi.mock('./keychain', () => ({ + readActiveClaudeKeychainCredentials: vi.fn(async (configDir?: string) => + configDir ? (keychain.scoped.get(configDir) ?? keychain.legacy) : keychain.legacy + ), + readActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => { + if (!configDir) { + return keychain.legacy + } + if (keychain.throwScopedRead) { + throw new Error('keychain locked') + } + return keychain.scoped.get(configDir) ?? null + }), + writeActiveClaudeKeychainCredentials: vi.fn(async (contents: string, configDir?: string) => { + if (!configDir) { + keychain.legacy = contents + return + } + keychain.scoped.set(configDir, contents) + }), + writeActiveClaudeKeychainCredentialsForRuntime: vi.fn( + async (contents: string, configDir: string) => { + keychain.scoped.set(configDir, contents) + keychain.legacy = contents + } + ), + deleteActiveClaudeKeychainCredentials: vi.fn(async () => {}), + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async (configDir?: string) => { + if (!configDir) { + keychain.legacy = null + return + } + keychain.scoped.delete(configDir) + }), + readManagedClaudeKeychainCredentials: vi.fn( + async (accountId: string) => testState.managedKeychainCredentials.get(accountId) ?? null + ), + writeManagedClaudeKeychainCredentials: vi.fn(async (accountId: string, contents: string) => { + testState.managedKeychainCredentials.set(accountId, contents) + }) +})) + +type Fixture = Awaited> + +async function setUpTwoAccounts() { + const activeCredentials = createClaudeCredentialsJson('active@example.com', 'active') + const pinnedCredentials = createClaudeCredentialsJson('pinned@example.com', 'pinned') + const activePath = createManagedClaudeAuth(testState.userDataDir, 'acct-a', activeCredentials) + const pinnedPath = createManagedClaudeAuth( + testState.userDataDir, + 'acct-b', + pinnedCredentials, + `${JSON.stringify({ accountUuid: 'uuid-b', emailAddress: 'pinned@example.com' })}\n` + ) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('acct-a', activePath, { email: 'active@example.com' }), + createClaudeAccount('acct-b', pinnedPath, { email: 'pinned@example.com' }) + ], + activeClaudeManagedAccountId: 'acct-a' + }) + const store = createStore(settings) + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const registry = await import('./claude-pinned-pty-registry') + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + return { + service, + store, + registry, + activeCredentials, + pinnedCredentials, + pinnedPath, + pinnedDir: realpathSync(pinnedPath), + runtimeCredentialsPath: join(testState.fakeHomeDir, '.claude', '.credentials.json') + } +} + +function markerPath(fixture: Fixture): string { + return join(fixture.pinnedDir, '.orca-pinned-keychain-seed') +} + +describe('ClaudeRuntimeAuthService pinned --account launches', () => { + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR + + beforeEach(() => { + resetRuntimeAuthTestState() + delete process.env.CLAUDE_CONFIG_DIR + keychain.scoped.clear() + keychain.legacy = null + keychain.throwScopedRead = false + }) + + afterEach(() => { + cleanupRuntimeAuthTestState() + if (originalConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + }) + + it.each(['linux', 'win32'] as const)( + 'on %s points Claude at the managed dir without touching the host sync', + async (platform) => { + setPlatform(platform) + const fixture = await setUpTwoAccounts() + const hostCredentialsBefore = readFileSync(fixture.runtimeCredentialsPath, 'utf-8') + fixture.store.updateSettings.mockClear() + + const prepared = await fixture.service.prepareForClaudeLaunch( + { runtime: 'host' }, + { accountId: 'acct-b' } + ) + + expect(prepared).toEqual({ + configDir: fixture.pinnedDir, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { + CLAUDE_CONFIG_DIR: fixture.pinnedDir, + CLAUDE_SECURESTORAGE_CONFIG_DIR: fixture.pinnedDir + }, + stripAuthEnv: true, + pinnedAccountId: 'acct-b', + provenance: 'managed:acct-b:pinned' + }) + expect(readFileSync(fixture.runtimeCredentialsPath, 'utf-8')).toBe(hostCredentialsBefore) + expect(fixture.store.updateSettings).not.toHaveBeenCalled() + expect(keychain.scoped.size).toBe(0) + expect(existsSync(markerPath(fixture))).toBe(false) + // The spawn owns the reservation from here and releases it once the PTY is registered. + expect(fixture.registry.countClaudePinnedAccountUsers('acct-b')).toBe(1) + } + ) + + it('takes the normal launch path when --account names the active host account', async () => { + setPlatform('linux') + const fixture = await setUpTwoAccounts() + + const prepared = await fixture.service.prepareForClaudeLaunch( + { runtime: 'host' }, + { accountId: 'acct-a' } + ) + + expect(prepared.provenance).toBe('managed:acct-a') + expect(prepared.pinnedAccountId).toBeUndefined() + expect(prepared.envPatch).toEqual({}) + expect(fixture.registry.countClaudePinnedAccountUsers('acct-a')).toBe(0) + expect(readFileSync(fixture.runtimeCredentialsPath, 'utf-8')).toBe(fixture.activeCredentials) + }) + + it('refuses WSL targets, unknown accounts, and accounts without a saved sign-in', async () => { + setPlatform('linux') + const fixture = await setUpTwoAccounts() + + await expect( + fixture.service.prepareForClaudeLaunch( + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + { accountId: 'acct-b' } + ) + ).rejects.toThrow(/not supported for WSL/) + await expect( + fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-zzz' }) + ).rejects.toThrow(/no longer exists/) + writeFileSync(join(fixture.pinnedPath, '.credentials.json'), '{"claudeAiOauth":{}}') + await expect( + fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + ).rejects.toThrow(/no valid saved sign-in/) + expect(fixture.registry.countClaudePinnedAccountUsers('acct-b')).toBe(0) + }) + + it('on macOS seeds only the scoped Keychain item of the managed dir', async () => { + const fixture = await setUpTwoAccounts() + const legacyBefore = keychain.legacy + const hostScopedBefore = keychain.scoped.get(join(testState.fakeHomeDir, '.claude')) + + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + + expect(keychain.scoped.get(fixture.pinnedDir)).toBe(fixture.pinnedCredentials) + expect(keychain.legacy).toBe(legacyBefore) + expect(keychain.scoped.get(join(testState.fakeHomeDir, '.claude'))).toBe(hostScopedBefore) + const { hashClaudeCredentialsJson, hasPendingPinnedClaudeSeed } = + await import('./claude-pinned-credentials') + expect(readFileSync(markerPath(fixture), 'utf-8').trim()).toBe( + hashClaudeCredentialsJson(fixture.pinnedCredentials) + ) + // The marker stores a hash, never the credential. + expect(readFileSync(markerPath(fixture), 'utf-8')).not.toContain('pinned-refresh') + expect(hasPendingPinnedClaudeSeed('acct-b')).toBe(true) + }) + + it('does not reseed an item a live pinned Claude already refreshed', async () => { + const fixture = await setUpTwoAccounts() + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + fixture.registry.markPinnedClaudePtySpawned('pty-1', 'acct-b') + const refreshed = createClaudeCredentialsJson('pinned@example.com', 'pinned-rotated') + keychain.scoped.set(fixture.pinnedDir, refreshed) + + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + + expect(keychain.scoped.get(fixture.pinnedDir)).toBe(refreshed) + }) + + it('adopts a refreshed Keychain copy into the managed store once the account drains', async () => { + const fixture = await setUpTwoAccounts() + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + const refreshed = createClaudeCredentialsJson('pinned@example.com', 'pinned-rotated') + keychain.scoped.set(fixture.pinnedDir, refreshed) + fixture.registry.markPinnedClaudePtySpawned('pty-1', 'acct-b') + fixture.registry._internals.reset() + + await fixture.service.reconcilePinnedAccountCredentials('acct-b') + + expect(readManagedCredentialsForTest('acct-b', fixture.pinnedPath)).toBe(refreshed) + expect(keychain.scoped.has(fixture.pinnedDir)).toBe(false) + expect(existsSync(markerPath(fixture))).toBe(false) + // The host account never moved. + expect(readFileSync(fixture.runtimeCredentialsPath, 'utf-8')).toBe(fixture.activeCredentials) + }) + + it('lets a re-auth that happened during the pinned session win the read-back', async () => { + const fixture = await setUpTwoAccounts() + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + fixture.registry._internals.reset() + keychain.scoped.set( + fixture.pinnedDir, + createClaudeCredentialsJson('pinned@example.com', 'pinned-rotated') + ) + const reauthed = createClaudeCredentialsJson('pinned@example.com', 'pinned-reauthed') + testState.managedKeychainCredentials.set('acct-b', reauthed) + + await fixture.service.reconcilePinnedAccountCredentials('acct-b') + + expect(readManagedCredentialsForTest('acct-b', fixture.pinnedPath)).toBe(reauthed) + expect(keychain.scoped.has(fixture.pinnedDir)).toBe(false) + expect(existsSync(markerPath(fixture))).toBe(false) + }) + + it('rejects a read-back that proves a different identity', async () => { + const fixture = await setUpTwoAccounts() + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + fixture.registry._internals.reset() + keychain.scoped.set( + fixture.pinnedDir, + createClaudeCredentialsJson('someone-else@example.com', 'other') + ) + + await fixture.service.reconcilePinnedAccountCredentials('acct-b') + + expect(readManagedCredentialsForTest('acct-b', fixture.pinnedPath)).toBe( + fixture.pinnedCredentials + ) + expect(keychain.scoped.has(fixture.pinnedDir)).toBe(false) + }) + + it('does not read back while the account still has pinned users', async () => { + const fixture = await setUpTwoAccounts() + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + const refreshed = createClaudeCredentialsJson('pinned@example.com', 'pinned-rotated') + keychain.scoped.set(fixture.pinnedDir, refreshed) + + await fixture.service.reconcilePinnedAccountCredentials('acct-b') + + expect(keychain.scoped.get(fixture.pinnedDir)).toBe(refreshed) + expect(existsSync(markerPath(fixture))).toBe(true) + }) + + it('recovers a crashed pinned session before the next pinned launch reseeds', async () => { + const fixture = await setUpTwoAccounts() + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + // A crash: the PTY is gone and no drain ran, so the marker and the refreshed item remain. + fixture.registry._internals.reset() + const refreshed = createClaudeCredentialsJson('pinned@example.com', 'pinned-rotated') + keychain.scoped.set(fixture.pinnedDir, refreshed) + + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + + expect(readManagedCredentialsForTest('acct-b', fixture.pinnedPath)).toBe(refreshed) + expect(keychain.scoped.get(fixture.pinnedDir)).toBe(refreshed) + const { hashClaudeCredentialsJson } = await import('./claude-pinned-credentials') + expect(readFileSync(markerPath(fixture), 'utf-8').trim()).toBe( + hashClaudeCredentialsJson(refreshed) + ) + }) + + it('refuses to reseed over an unread Keychain copy it cannot read', async () => { + const fixture = await setUpTwoAccounts() + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + fixture.registry._internals.reset() + keychain.throwScopedRead = true + + await expect( + fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + ).rejects.toThrow(/Unlock the Keychain/) + expect(existsSync(markerPath(fixture))).toBe(true) + expect(fixture.registry.countClaudePinnedAccountUsers('acct-b')).toBe(0) + }) + + it('reads a crashed pinned session back before the account becomes the host account', async () => { + const fixture = await setUpTwoAccounts() + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + fixture.registry._internals.reset() + const refreshed = createClaudeCredentialsJson('pinned@example.com', 'pinned-rotated') + keychain.scoped.set(fixture.pinnedDir, refreshed) + + fixture.store.updateSettings({ activeClaudeManagedAccountId: 'acct-b' }) + await fixture.service.syncForCurrentSelection() + + expect(readManagedCredentialsForTest('acct-b', fixture.pinnedPath)).toBe(refreshed) + expect(readFileSync(fixture.runtimeCredentialsPath, 'utf-8')).toBe(refreshed) + expect(keychain.scoped.has(fixture.pinnedDir)).toBe(false) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index dead38a7780f..3d5a935c460f 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,12 +1,20 @@ import type { Store } from '../persistence' import { getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, type ClaudeAccountSelectionTarget } from './runtime-selection' +import { countClaudePinnedAccountUsers } from './claude-pinned-pty-registry' import { ClaudeRuntimeAuthSync } from './runtime-auth/runtime-auth-sync' -import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +import type { + ClaudeLaunchAuthOptions, + ClaudeRuntimeAuthPreparation +} from './runtime-auth/runtime-auth-types' -export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +export type { + ClaudeLaunchAuthOptions, + ClaudeRuntimeAuthPreparation +} from './runtime-auth/runtime-auth-types' export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { constructor(store: Store) { @@ -16,13 +24,41 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { } async prepareForClaudeLaunch( - target?: ClaudeAccountSelectionTarget + target?: ClaudeAccountSelectionTarget, + options?: ClaudeLaunchAuthOptions ): Promise { const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + const accountId = options?.accountId + if (accountId) { + return this.serializeMutation(() => + this.prepareRequestedAccountLaunch(accountId, effectiveTarget) + ) + } await this.syncForCurrentSelection(effectiveTarget) return this.getPreparation(effectiveTarget) } + /** Reads back a drained pinned account's Keychain copy; a no-op off macOS or without a seed. */ + async reconcilePinnedAccountCredentials(accountId: string): Promise { + await this.serializeMutation(async () => { + const account = this.getActiveAccount( + this.store.getSettings().claudeManagedAccounts, + accountId + ) + if ( + process.platform !== 'darwin' || + !account || + countClaudePinnedAccountUsers(accountId) > 0 + ) { + return + } + const configDir = await this.getOwnedManagedAuthPath(account) + if (configDir) { + await this.reconcilePinnedKeychainCredentials(account, configDir, { strict: false }) + } + }) + } + async prepareForRateLimitFetch( target?: ClaudeAccountSelectionTarget ): Promise { @@ -60,6 +96,29 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { return this.getPreparation(target).configDir } + // Why one mutation: selection updates settings before it queues its sync, so reading the active + // account here, in the queue, can never pin an account a concurrent switch just activated. + private async prepareRequestedAccountLaunch( + accountId: string, + target: ClaudeAccountSelectionTarget + ): Promise { + const settings = this.store.getSettings() + const account = this.getActiveAccount(settings.claudeManagedAccounts, accountId) + if (!account) { + throw new Error('That Claude account no longer exists. Run `orca account list` and retry.') + } + if ( + normalizeClaudeAccountSelectionTarget(target).runtime === 'host' && + getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) === accountId + ) { + // Why: the active account already owns ~/.claude; pinning it too would put one refresh + // token in two stores. + await this.doSyncForCurrentSelection(target) + return this.getPreparation(target) + } + return this.preparePinnedClaudeLaunch(account, target) + } + private initializeLastSyncedState(): void { const settings = this.store.getSettings() this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts new file mode 100644 index 000000000000..e557c19b1073 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts @@ -0,0 +1,170 @@ +import { join } from 'node:path' +import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' +import { + countClaudePinnedAccountUsers, + reserveClaudePinnedAccount +} from '../claude-pinned-pty-registry' +import { + clearPinnedClaudeKeychainCredentials, + hashClaudeCredentialsJson, + readPinnedClaudeKeychainCredentials, + readPinnedClaudeSeedMarker, + seedPinnedClaudeKeychainCredentials +} from '../claude-pinned-credentials' +import { prepareClaudePinnedConfigDir } from '../claude-pinned-config-dir' +import { + normalizeClaudeAccountSelectionTarget, + type ClaudeAccountSelectionTarget +} from '../runtime-selection' +import { ClaudeRuntimeAuthPreparationService } from './runtime-auth-preparation' +import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' + +/** + * A launch pinned to a managed host account that is NOT the host's active one. + * + * It never goes through the global sync: the host's `~/.claude` keeps the active account, and the + * pinned Claude gets the managed auth dir itself as CLAUDE_CONFIG_DIR — the same shape the WSL + * path and the inactive usage preview already use. On Linux and Windows that dir's + * `.credentials.json` IS the managed store, so Claude refreshes it in place. On macOS Claude only + * reads the Keychain item scoped to that dir, so the managed blob is seeded into it and read back + * once the account's last pinned PTY exits. + * + * Refresh tokens are single-use: replaying a spent one revokes the whole token family. So an + * account is only ever refreshed through one store at a time — never materialized as the host + * account while pinned (selection refuses), never refreshed by Orca while a pinned Claude may hold + * it (the usage fetcher checks the registry), and read back only with identity proof. + */ +export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationService { + /** Caller holds the mutation queue. Reserves the account; the spawn releases it. */ + protected async preparePinnedClaudeLaunch( + account: ClaudeManagedAccount, + target: ClaudeAccountSelectionTarget + ): Promise { + if (normalizeClaudeAccountSelectionTarget(target).runtime !== 'host') { + throw new Error('Claude --account launches are not supported for WSL terminals yet.') + } + if (account.managedAuthRuntime === 'wsl') { + throw new Error( + `Claude account ${account.email} is a WSL account; --account supports host accounts only.` + ) + } + const configDir = await this.getOwnedManagedAuthPath(account) + if (!configDir) { + throw new Error( + `Orca cannot verify the saved sign-in for Claude account ${account.email}. Re-authenticate it in Settings > Accounts, then retry.` + ) + } + // Why before reserving: any other holder means a pinned Claude may already own the scoped + // item, and reseeding it would roll that session back to a spent refresh token. + const sharedWithLiveSession = countClaudePinnedAccountUsers(account.id) > 0 + const release = reserveClaudePinnedAccount(account.id) + try { + if (process.platform === 'darwin' && !sharedWithLiveSession) { + await this.reconcilePinnedKeychainCredentials(account, configDir, { strict: true }) + } + const credentialsJson = await this.readManagedCredentials(account) + if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { + throw new Error( + `Claude account ${account.email} has no valid saved sign-in. Re-authenticate it in Settings > Accounts, then retry.` + ) + } + if (process.platform === 'darwin' && !sharedWithLiveSession) { + await seedPinnedClaudeKeychainCredentials({ + accountId: account.id, + configDir, + credentialsJson + }) + } + const hostPaths = this.pathResolver.getRuntimePaths() + try { + prepareClaudePinnedConfigDir({ + configDir, + source: { hostConfigDir: hostPaths.configDir, hostConfigPath: hostPaths.configPath }, + oauthAccount: await this.readManagedOauthAccount(account) + }) + } catch (error) { + // Why: the mirror only spares prompts; a launch that still authenticates is better than none. + console.warn( + '[claude-runtime-auth] Could not mirror host config for a pinned launch:', + error + ) + } + return { + configDir, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { CLAUDE_CONFIG_DIR: configDir, CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir }, + stripAuthEnv: true, + pinnedAccountId: account.id, + provenance: `managed:${account.id}:pinned` + } + } catch (error) { + release() + throw error + } + } + + /** + * Takes a seeded scoped Keychain item back into the managed store (macOS only). + * + * Adopts only when the managed store still holds exactly what was seeded — a re-auth in between + * wins — and the scoped blob is a valid, not-older credential for this same account. Either way + * the scoped copy and its marker are then removed; a read or delete that fails keeps the marker + * so the next launch or startup retries rather than seeding over an unread refresh. + */ + protected async reconcilePinnedKeychainCredentials( + account: ClaudeManagedAccount, + configDir: string, + options: { strict: boolean } + ): Promise { + const seededHash = readPinnedClaudeSeedMarker(configDir) + if (seededHash === null) { + return + } + let scopedCredentialsJson: string | null + try { + scopedCredentialsJson = await readPinnedClaudeKeychainCredentials(configDir) + } catch (error) { + if (options.strict) { + throw new Error( + 'Could not read the Keychain item of the last Claude session pinned to this account. Unlock the Keychain and retry.', + { cause: error } + ) + } + console.warn('[claude-runtime-auth] Deferring pinned Claude read-back:', error) + return + } + const managedCredentialsJson = await this.readManagedCredentials(account) + if ( + scopedCredentialsJson && + managedCredentialsJson && + scopedCredentialsJson !== managedCredentialsJson && + hashClaudeCredentialsJson(managedCredentialsJson) === seededHash && + this.isValidCredentialsJsonObject(scopedCredentialsJson) && + !this.runtimeCredentialsAreOlder(scopedCredentialsJson, managedCredentialsJson) && + this.runtimeCredentialsMatchAccount( + scopedCredentialsJson, + this.readPinnedConfigOauthAccount(configDir), + account, + managedCredentialsJson, + await this.readManagedOauthAccount(account) + ) === 'match' + ) { + await this.writeManagedCredentials(account, scopedCredentialsJson) + } + try { + await clearPinnedClaudeKeychainCredentials(account.id, configDir) + } catch (error) { + if (options.strict) { + throw error + } + console.warn('[claude-runtime-auth] Could not remove a pinned Claude Keychain copy:', error) + } + } + + private readPinnedConfigOauthAccount(configDir: string): unknown { + const config = this.readJsonObject(join(configDir, '.claude.json')) + return config?.oauthAccount ?? null + } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts index 77dfea271841..91feffad4e8d 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts @@ -1,6 +1,8 @@ import { existsSync, readFileSync } from 'node:fs' import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' import { ClaudeRuntimeAuthCredentialMatching } from './runtime-auth-credential-matching' +import { countClaudePinnedAccountUsers } from '../claude-pinned-pty-registry' +import { hasPendingPinnedClaudeSeed } from '../claude-pinned-credentials' import type { ClaudeReadBackMatch, ClaudeReadBackResult, @@ -52,6 +54,14 @@ export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatchi if (match.kind !== 'matched') { continue } + // Why: a pinned account refreshes through its own session's store; overwriting it from the + // host copy would leave two stores replaying one single-use refresh token. + if ( + countClaudePinnedAccountUsers(match.account.id) > 0 || + hasPendingPinnedClaudeSeed(match.account.id) + ) { + continue + } // Why: on cold start we can't tell a fresh CLI refresh from stale runtime creds; adopt only when expiry or a rotated refresh token proves runtime is newer than managed. if (this.lastWrittenCredentialsJson === null) { const fresher = this.runtimeCredentialsAreFresher( diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts index f191689a4466..acff40726099 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts @@ -9,9 +9,11 @@ import { import { hasLiveClaudePtys } from '../live-pty-gate' import { isOauthTokenExpiring } from '../oauth-refresh' import { writeActiveClaudeKeychainCredentialsForRuntime } from '../keychain' -import { ClaudeRuntimeAuthPreparationService } from './runtime-auth-preparation' +import { ClaudeRuntimeAuthPinnedLaunch } from './runtime-auth-pinned-launch' +import { countClaudePinnedAccountUsers } from '../claude-pinned-pty-registry' +import { hasPendingPinnedClaudeSeed } from '../claude-pinned-credentials' -export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService { +export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPinnedLaunch { protected async doSyncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { const settings = this.store.getSettings() const effectiveTarget = this.resolveWslDefaultTarget(target) @@ -156,6 +158,18 @@ export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService { return } + if ( + process.platform === 'darwin' && + hasPendingPinnedClaudeSeed(activeAccount.id) && + countClaudePinnedAccountUsers(activeAccount.id) === 0 + ) { + // Why: a pinned session that ended without a read-back (crash) may hold the only unspent + // refresh token; take it back before materializing this account for the host. + const ownedPath = await this.getOwnedManagedAuthPath(activeAccount) + if (ownedPath) { + await this.reconcilePinnedKeychainCredentials(activeAccount, ownedPath, { strict: false }) + } + } let credentialsJson = await this.readManagedCredentials(activeAccount) if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { console.warn( diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts index 77f0394e5719..d41a486f8691 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts @@ -9,9 +9,16 @@ export type ClaudeRuntimeAuthPreparation = { envPatch: ClaudeEnvPatch stripAuthEnv: boolean managedRefreshDeferredByLivePty?: boolean + /** Set only by a `--account` launch on a non-active host account; see runtime-auth-pinned-launch. */ + pinnedAccountId?: string provenance: string } +export type ClaudeLaunchAuthOptions = { + /** A managed host account the launch must run on, whether or not it is the active one. */ + accountId?: string +} + export type ClaudeSystemDefaultSnapshot = { credentialsJson: string | null configOauthAccount: unknown From 51fa681c9bcba69183564786a322466e19433f07 Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 11:38:23 -0300 Subject: [PATCH 04/16] feat(claude-accounts): guard pinned accounts from host switches, removal and Orca refreshes --- .../claude-account-selection.ts | 19 ++++++ ...-account-service-account-selection.test.ts | 64 ++++++++++++++++++ ...aude-fetcher-managed-account-usage.test.ts | 66 +++++++++++++++++++ .../claude-managed-account-credentials.ts | 12 +++- .../claude-managed-account-usage.ts | 25 ++++++- .../rate-limits/claude-managed-usage-panel.ts | 7 +- 6 files changed, 189 insertions(+), 4 deletions(-) diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index d81f2346eb70..198a5215c6cf 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -6,6 +6,7 @@ import type { import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' +import { countClaudePinnedAccountUsers } from './claude-pinned-pty-registry' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, @@ -33,6 +34,7 @@ export class ClaudeAccountSelection { async remove(accountId: string): Promise { const account = this.requireAccount(accountId) + assertNotPinnedByLiveTerminals(account, 'remove') const settings = this.store.getSettings() const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) const nextSelection = removeClaudeAccountIdFromSelection( @@ -80,6 +82,7 @@ export class ClaudeAccountSelection { let effectiveTarget = target if (accountId !== null) { const account = this.requireAccount(accountId) + assertNotPinnedByLiveTerminals(account, 'select') const accountTarget = getClaudeSelectionTargetForAccount(account) const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) @@ -175,6 +178,22 @@ export class ClaudeAccountSelection { } } +// Why: selecting would materialize the account into ~/.claude and removing would delete the dir a +// pinned Claude runs from; either way one refresh token would end up in two places or none. +function assertNotPinnedByLiveTerminals( + account: ClaudeManagedAccount, + action: 'select' | 'remove' +): void { + const users = countClaudePinnedAccountUsers(account.id) + if (users === 0) { + return + } + const terminals = users === 1 ? '1 terminal' : `${users} terminals` + throw new Error( + `Claude account ${account.email} is in use by ${terminals} launched with --account. Close ${users === 1 ? 'it' : 'them'} before you ${action === 'select' ? 'switch to' : 'remove'} this account.` + ) +} + function toClaudeAccountSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { return { id: account.id, diff --git a/src/main/claude-accounts/claude-account-service-account-selection.test.ts b/src/main/claude-accounts/claude-account-service-account-selection.test.ts index f055475506bc..f166f2b0c43c 100644 --- a/src/main/claude-accounts/claude-account-service-account-selection.test.ts +++ b/src/main/claude-accounts/claude-account-service-account-selection.test.ts @@ -485,4 +485,68 @@ describe('ClaudeAccountService credential capture', () => { wslDistro: 'Ubuntu' }) }) + + it('refuses to switch to or remove an account pinned by --account terminals', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const pinnedAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') + mkdirSync(pinnedAuthPath, { recursive: true }) + let settings = { + claudeManagedAccounts: [ + { + id: 'account-2', + email: 'pinned@example.com', + managedAuthPath: pinnedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: null + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const registry = await import('./claude-pinned-pty-registry') + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + registry.markPinnedClaudePtySpawned('pinned-pty-1', 'account-2') + registry.markPinnedClaudePtySpawned('pinned-pty-2', 'account-2') + try { + await expect(service.selectAccount('account-2')).rejects.toThrow( + 'in use by 2 terminals launched with --account' + ) + await expect(service.removeAccount('account-2')).rejects.toThrow(/before you remove/) + expect(runtimeAuth.syncForCurrentSelection).not.toHaveBeenCalled() + expect(settings.activeClaudeManagedAccountId).toBeNull() + expect(settings.claudeManagedAccounts).toHaveLength(1) + + registry.markPinnedClaudePtyExited('pinned-pty-1') + registry.markPinnedClaudePtyExited('pinned-pty-2') + await service.selectAccount('account-2') + expect(settings.activeClaudeManagedAccountId).toBe('account-2') + } finally { + registry._internals.reset() + } + }) }) diff --git a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts index c01bcb00f6eb..7e5db5fc2685 100644 --- a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts +++ b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts @@ -11,10 +11,15 @@ import { import { fetchViaPty } from './claude-pty' import { deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentialsStrict, readManagedClaudeKeychainCredentials, writeActiveClaudeKeychainCredentials, writeManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' +import { + _internals as pinnedRegistryInternals, + markPinnedClaudePtySpawned +} from '../claude-accounts/claude-pinned-pty-registry' const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( () => ({ @@ -74,6 +79,7 @@ describe('fetchClaudeRateLimits', () => { afterEach(() => { restorePlatform() + pinnedRegistryInternals.reset() if (tempDir) { rmSync(tempDir, { recursive: true, force: true }) } @@ -422,4 +428,64 @@ describe('fetchClaudeRateLimits', () => { ) expect(usageCall?.[1]?.headers?.Authorization).toBe('Bearer fresh-access') }) + + it('never refreshes or previews an account a --account terminal is running on', async () => { + setPlatform('linux') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const credentialsPath = join(ownedAuthPath, '.credentials.json') + const expiring = JSON.stringify({ + claudeAiOauth: { + accessToken: 'live-access', + refreshToken: 'live-refresh', + expiresAt: Date.now() - 60_000 + } + }) + writeFileSync(credentialsPath, expiring, 'utf-8') + markPinnedClaudePtySpawned('pinned-pty', 'account-1') + + await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(netFetchMock.mock.calls.map(([url]) => String(url))).toEqual([ + expect.stringContaining('/api/oauth/usage') + ]) + expect(netFetchMock.mock.calls[0]?.[1]?.headers?.Authorization).toBe('Bearer live-access') + expect(readFileSync(credentialsPath, 'utf-8')).toBe(expiring) + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('reads a pinned macOS account from its session Keychain copy without staging it', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const canonicalAuthPath = realpathSync(ownedAuthPath) + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue( + JSON.stringify({ claudeAiOauth: { accessToken: 'spent', expiresAt: Date.now() - 1 } }) + ) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( + JSON.stringify({ claudeAiOauth: { accessToken: 'session', expiresAt: Date.now() + 60_000 } }) + ) + markPinnedClaudePtySpawned('pinned-pty', 'account-1') + + await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) + expect(netFetchMock.mock.calls[0]?.[1]?.headers?.Authorization).toBe('Bearer session') + expect(writeActiveClaudeKeychainCredentials).not.toHaveBeenCalled() + expect(deleteActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() + expect(writeManagedClaudeKeychainCredentials).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + }) }) diff --git a/src/main/rate-limits/claude-managed-account-credentials.ts b/src/main/rate-limits/claude-managed-account-credentials.ts index 71d505510cf1..867fdde3273b 100644 --- a/src/main/rate-limits/claude-managed-account-credentials.ts +++ b/src/main/rate-limits/claude-managed-account-credentials.ts @@ -13,6 +13,8 @@ import { resolveOwnedClaudeManagedAuthPath, writeClaudeManagedAuthFile } from '../claude-accounts/managed-auth-path' +import { countClaudePinnedAccountUsers } from '../claude-accounts/claude-pinned-pty-registry' +import { hasPendingPinnedClaudeSeed } from '../claude-accounts/claude-pinned-credentials' export type InactiveClaudeAccount = { id: string @@ -97,6 +99,11 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccount): s } } +/** True while a `--account` launch owns this account's credentials (live, starting, or unread). */ +export function isClaudeAccountHeldByPinnedLaunch(accountId: string): boolean { + return countClaudePinnedAccountUsers(accountId) > 0 || hasPendingPinnedClaudeSeed(accountId) +} + export async function withClaudeManagedPreviewKeychainCredentials( location: ClaudeManagedCredentialsLocation, credentialsJson: string, @@ -109,7 +116,10 @@ export async function withClaudeManagedPreviewKeychainCredentials( try { return await operation() } finally { - await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) + // Why: a pinned launch that started mid-preview now runs from this same scoped item. + if (!isClaudeAccountHeldByPinnedLaunch(location.accountId)) { + await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) + } } } diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index ddf7b3df7508..d3eaaa552c71 100644 --- a/src/main/rate-limits/claude-managed-account-usage.ts +++ b/src/main/rate-limits/claude-managed-account-usage.ts @@ -4,9 +4,12 @@ import { refreshClaudeOauthCredentials } from '../claude-accounts/oauth-refresh' import { + isClaudeAccountHeldByPinnedLaunch, readClaudeManagedCredentialsJson, + readStagedClaudeManagedPreviewCredentials, resolveClaudeManagedCredentialsLocation, writeClaudeManagedCredentialsJson, + type ClaudeManagedCredentialsLocation, type InactiveClaudeAccount } from './claude-managed-account-credentials' import { fetchClaudeManagedUsagePanelSupplement } from './claude-managed-usage-panel' @@ -39,7 +42,14 @@ export async function fetchInactiveClaudeAccountUsage( return abortedClaudeRateLimitResult() } const location = resolveClaudeManagedCredentialsLocation(account) - let credentialsJson = location ? await readClaudeManagedCredentialsJson(location) : null + // Why: a `--account` Claude may be refreshing this account right now; a second refresh from + // here would replay its single-use refresh token and revoke the session. + const heldByPinnedLaunch = isClaudeAccountHeldByPinnedLaunch(account.id) + let credentialsJson = location + ? heldByPinnedLaunch + ? await readPinnedLaunchCredentialsJson(location) + : await readClaudeManagedCredentialsJson(location) + : null if (options.signal?.aborted) { return abortedClaudeRateLimitResult() } @@ -48,7 +58,7 @@ export async function fetchInactiveClaudeAccountUsage( } let token = parseClaudeOAuthCredentialsJson(credentialsJson, 'credentials-file').token - if (isOauthTokenExpiring(credentialsJson)) { + if (!heldByPinnedLaunch && isOauthTokenExpiring(credentialsJson)) { const refreshed = await refreshClaudeOauthCredentials(credentialsJson) if (options.signal?.aborted) { return abortedClaudeRateLimitResult() @@ -72,6 +82,7 @@ export async function fetchInactiveClaudeAccountUsage( return abortedClaudeRateLimitResult() } if ( + heldByPinnedLaunch || !canSupplementClaudeOAuthUsage({ oauthLimits, authPreparation: undefined, @@ -102,3 +113,13 @@ export async function fetchInactiveClaudeAccountUsage( return oauthLimits } } + +/** Read-only: the pinned session's own Keychain copy is the fresher store on macOS. */ +async function readPinnedLaunchCredentialsJson( + location: ClaudeManagedCredentialsLocation +): Promise { + return ( + (await readStagedClaudeManagedPreviewCredentials(location)) ?? + (await readClaudeManagedCredentialsJson(location)) + ) +} diff --git a/src/main/rate-limits/claude-managed-usage-panel.ts b/src/main/rate-limits/claude-managed-usage-panel.ts index 15c1e90169c7..25ba90707e42 100644 --- a/src/main/rate-limits/claude-managed-usage-panel.ts +++ b/src/main/rate-limits/claude-managed-usage-panel.ts @@ -3,6 +3,7 @@ import type { NetworkProxySettings } from '../../shared/network-proxy' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { fetchViaPty } from './claude-pty' import { + isClaudeAccountHeldByPinnedLaunch, readStagedClaudeManagedPreviewCredentials, withClaudeManagedPreviewKeychainCredentials, writeClaudeManagedCredentialsJson, @@ -104,7 +105,11 @@ export async function fetchClaudeManagedUsagePanelSupplement(input: { return null } const refreshed = await readStagedClaudeManagedPreviewCredentials(input.location) - if (refreshed && refreshed !== input.credentialsJson) { + if ( + refreshed && + refreshed !== input.credentialsJson && + !isClaudeAccountHeldByPinnedLaunch(input.account.id) + ) { await writeClaudeManagedCredentialsJson(input.location, refreshed) } return cliLimits From 7f4f1e91fb86b6b7d898b7b5bf649fdcf34bf167 Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 12:03:53 -0300 Subject: [PATCH 05/16] feat(pty): spawn runtime Claude PTYs pinned to a requested account --- .../claude-pinned-pty-registry.test.ts | 6 +- .../claude-pinned-pty-registry.ts | 26 +- .../runtime-auth-pinned-launch.ts | 7 +- .../pty-runtime-claude-account-spawn.test.ts | 283 ++++++++++++++++++ src/main/ipc/pty/host-env/types.ts | 8 +- .../ipc/pty/runtime/spawn-claude-account.ts | 75 +++++ src/main/ipc/pty/runtime/spawn-commit.ts | 4 +- src/main/ipc/pty/runtime/spawn-preflight.ts | 20 +- src/main/ipc/pty/runtime/spawn-state.ts | 2 + src/main/ipc/pty/runtime/spawn.ts | 5 + .../runtime-pty-controller-contract.ts | 2 + .../startup/main-process-runtime-launch.ts | 2 +- .../startup/main-process-runtime-service.ts | 3 +- src/main/startup/main-window-core-services.ts | 2 +- .../window/attach-main-window-services.ts | 7 +- 15 files changed, 410 insertions(+), 42 deletions(-) create mode 100644 src/main/ipc/pty-runtime-claude-account-spawn.test.ts create mode 100644 src/main/ipc/pty/runtime/spawn-claude-account.ts diff --git a/src/main/claude-accounts/claude-pinned-pty-registry.test.ts b/src/main/claude-accounts/claude-pinned-pty-registry.test.ts index 74c454a9f853..2ed6687290e5 100644 --- a/src/main/claude-accounts/claude-pinned-pty-registry.test.ts +++ b/src/main/claude-accounts/claude-pinned-pty-registry.test.ts @@ -14,6 +14,7 @@ import { markPinnedClaudePtySpawned, onClaudePinnedAccountDrained, readClaudePinnedPtyRegistryFile, + releaseClaudePinnedAccountReservation, reserveClaudePinnedAccount, seedPinnedClaudePtysFromPersistence } from './claude-pinned-pty-registry' @@ -26,13 +27,12 @@ describe('Claude pinned PTY registry', () => { it('counts reservations and live PTYs per account and drains once both are gone', () => { const drained = vi.fn() onClaudePinnedAccountDrained(drained) - const release = reserveClaudePinnedAccount('acct-b') + reserveClaudePinnedAccount('acct-b') markPinnedClaudePtySpawned('pty-1', 'acct-b') expect(countClaudePinnedAccountUsers('acct-b')).toBe(2) expect(countClaudePinnedAccountUsers('acct-c')).toBe(0) - release() - release() + releaseClaudePinnedAccountReservation('acct-b') expect(countClaudePinnedAccountUsers('acct-b')).toBe(1) expect(drained).not.toHaveBeenCalled() diff --git a/src/main/claude-accounts/claude-pinned-pty-registry.ts b/src/main/claude-accounts/claude-pinned-pty-registry.ts index dfe9670c69a3..e3daed0b230a 100644 --- a/src/main/claude-accounts/claude-pinned-pty-registry.ts +++ b/src/main/claude-accounts/claude-pinned-pty-registry.ts @@ -54,23 +54,19 @@ export function hasLivePinnedClaudePtys(accountId: string): boolean { return false } -/** Returns an idempotent release; call it once the launch registered its PTY or failed. */ -export function reserveClaudePinnedAccount(accountId: string): () => void { +/** Held from auth preparation until the spawn settles; each reserve needs exactly one release. */ +export function reserveClaudePinnedAccount(accountId: string): void { reservationsByAccountId.set(accountId, (reservationsByAccountId.get(accountId) ?? 0) + 1) - let released = false - return () => { - if (released) { - return - } - released = true - const remaining = (reservationsByAccountId.get(accountId) ?? 1) - 1 - if (remaining > 0) { - reservationsByAccountId.set(accountId, remaining) - } else { - reservationsByAccountId.delete(accountId) - } - notifyIfDrained(accountId) +} + +export function releaseClaudePinnedAccountReservation(accountId: string): void { + const remaining = (reservationsByAccountId.get(accountId) ?? 0) - 1 + if (remaining > 0) { + reservationsByAccountId.set(accountId, remaining) + } else { + reservationsByAccountId.delete(accountId) } + notifyIfDrained(accountId) } export function markPinnedClaudePtySpawned(ptyId: string, accountId: string): void { diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts index e557c19b1073..d016ba2e4a8f 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import { countClaudePinnedAccountUsers, + releaseClaudePinnedAccountReservation, reserveClaudePinnedAccount } from '../claude-pinned-pty-registry' import { @@ -35,7 +36,7 @@ import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' * it (the usage fetcher checks the registry), and read back only with identity proof. */ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationService { - /** Caller holds the mutation queue. Reserves the account; the spawn releases it. */ + /** Caller holds the mutation queue. Reserves the account; the spawn releases the reservation. */ protected async preparePinnedClaudeLaunch( account: ClaudeManagedAccount, target: ClaudeAccountSelectionTarget @@ -57,7 +58,7 @@ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationS // Why before reserving: any other holder means a pinned Claude may already own the scoped // item, and reseeding it would roll that session back to a spent refresh token. const sharedWithLiveSession = countClaudePinnedAccountUsers(account.id) > 0 - const release = reserveClaudePinnedAccount(account.id) + reserveClaudePinnedAccount(account.id) try { if (process.platform === 'darwin' && !sharedWithLiveSession) { await this.reconcilePinnedKeychainCredentials(account, configDir, { strict: true }) @@ -100,7 +101,7 @@ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationS provenance: `managed:${account.id}:pinned` } } catch (error) { - release() + releaseClaudePinnedAccountReservation(account.id) throw error } } diff --git a/src/main/ipc/pty-runtime-claude-account-spawn.test.ts b/src/main/ipc/pty-runtime-claude-account-spawn.test.ts new file mode 100644 index 000000000000..9dac472339a1 --- /dev/null +++ b/src/main/ipc/pty-runtime-claude-account-spawn.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it, vi } from 'vitest' +import { spawnMock } from './pty-ipc-mock-registry' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers, registerSshPtyProvider } from './pty' +import { hasLiveClaudePtys, markClaudePtyExited } from '../claude-accounts/live-pty-gate' +import { + _internals as pinnedRegistryInternals, + countClaudePinnedAccountUsers, + hasLivePinnedClaudePtys +} from '../claude-accounts/claude-pinned-pty-registry' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +type RuntimeSpawnArgs = { + cols: number + rows: number + worktreeId?: string + command?: string + launchAgent?: 'claude' | 'codex' + connectionId?: string | null + env?: Record + claudeAccountId?: string +} +type RuntimeSpawnController = { spawn(args: RuntimeSpawnArgs): Promise<{ id: string }> } + +const ACTIVE_PREPARATION: ClaudeRuntimeAuthPreparation = { + configDir: '/home/user/.claude', + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: true, + provenance: 'managed:acct-a' +} + +const PINNED_DIR = '/userData/claude-accounts/acct-b/auth' +const PINNED_PREPARATION: ClaudeRuntimeAuthPreparation = { + configDir: PINNED_DIR, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { CLAUDE_CONFIG_DIR: PINNED_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR: PINNED_DIR }, + stripAuthEnv: true, + pinnedAccountId: 'acct-b', + provenance: 'managed:acct-b:pinned' +} + +describe('runtime PTY spawn pinned to a Claude account', () => { + const { mainWindow } = setupPtyIpcSuite() + + function registerRuntimeController( + prepareClaudeAuth: (...args: unknown[]) => Promise + ): RuntimeSpawnController { + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value: RuntimeSpawnController) => { + controller = value + }), + preAllocateHandleForPty: vi.fn(() => 'term_pinned'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + prepareClaudeAuth + ) + if (!controller) { + throw new Error('runtime PTY controller was not registered') + } + return controller + } + + function lastSpawnEnv(): Record { + return spawnMock.mock.calls.at(-1)![2].env as Record + } + + function cleanup(ids: string[]): void { + for (const id of ids) { + markClaudePtyExited(id) + } + pinnedRegistryInternals.reset() + } + + it('leaves a launch without --account byte-for-byte unchanged', async () => { + const prepareClaudeAuth = vi.fn(async () => ACTIVE_PREPARATION) + const controller = registerRuntimeController(prepareClaudeAuth) + const args = { cols: 80, rows: 24, worktreeId: 'wt-1', command: 'claude', env: { KEEP: '1' } } + + const plain = await controller.spawn(args) + const plainEnv = lastSpawnEnv() + const plainSpawnOptions = { ...spawnMock.mock.calls.at(-1)![2], env: undefined } + // Why: `--account` naming the host's active account resolves to the same preparation, so any + // difference between these two spawns would be the new branch leaking into the old path. + const activePinned = await controller.spawn({ ...args, claudeAccountId: 'acct-a' }) + const activePinnedEnv = lastSpawnEnv() + try { + expect(prepareClaudeAuth.mock.calls[0]).toHaveLength(1) + expect(prepareClaudeAuth.mock.calls[1]?.[1]).toEqual({ accountId: 'acct-a' }) + expect(plainEnv).not.toHaveProperty('CLAUDE_CONFIG_DIR') + expect(plainEnv).not.toHaveProperty('CLAUDE_SECURESTORAGE_CONFIG_DIR') + expect(plainEnv.KEEP).toBe('1') + const withoutPerSpawnIds = (env: Record) => + Object.fromEntries( + Object.entries(env).filter( + ([key]) => !/^ORCA_(PANE_KEY|TERMINAL_HANDLE|PTY_ID)/.test(key) + ) + ) + expect(withoutPerSpawnIds(activePinnedEnv)).toEqual(withoutPerSpawnIds(plainEnv)) + expect({ ...spawnMock.mock.calls.at(-1)![2], env: undefined }).toEqual(plainSpawnOptions) + expect(hasLiveClaudePtys()).toBe(true) + expect(countClaudePinnedAccountUsers('acct-a')).toBe(0) + } finally { + cleanup([plain.id, activePinned.id]) + } + }) + + it('spawns a pinned Claude on the managed dir and tracks it per account', async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + const controller = registerRuntimeController(prepareClaudeAuth) + + const spawned = await controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: 'claude', + claudeAccountId: 'acct-b' + }) + try { + const env = lastSpawnEnv() + expect(env.CLAUDE_CONFIG_DIR).toBe(PINNED_DIR) + expect(env.CLAUDE_SECURESTORAGE_CONFIG_DIR).toBe(PINNED_DIR) + expect(prepareClaudeAuth).toHaveBeenCalledWith({ runtime: 'host' }, { accountId: 'acct-b' }) + expect(hasLiveClaudePtys()).toBe(false) + expect(hasLivePinnedClaudePtys('acct-b')).toBe(true) + markClaudePtyExited(spawned.id) + expect(hasLivePinnedClaudePtys('acct-b')).toBe(false) + } finally { + cleanup([spawned.id]) + } + }) + + it('treats a gated setup launch as Claude when the agent id says so', async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + const controller = registerRuntimeController(prepareClaudeAuth) + const spawned = await controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: `bash -lc 'eval "$ORCA_SETUP_GATE"'`, + launchAgent: 'claude', + claudeAccountId: 'acct-b' + }) + try { + expect(prepareClaudeAuth).toHaveBeenCalledTimes(1) + expect(lastSpawnEnv().CLAUDE_CONFIG_DIR).toBe(PINNED_DIR) + } finally { + cleanup([spawned.id]) + } + }) + + it('refuses non-Claude and SSH launches before preparing any credentials', async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + const controller = registerRuntimeController(prepareClaudeAuth) + registerSshPtyProvider('ssh-1', { spawn: vi.fn() } as never) + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: 'codex', + claudeAccountId: 'acct-b' + }) + ).rejects.toThrow('--account applies only to Claude launches.') + await expect( + controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: 'claude', + connectionId: 'ssh-1', + claudeAccountId: 'acct-b' + }) + ).rejects.toThrow('not supported for SSH workspaces') + expect(prepareClaudeAuth).not.toHaveBeenCalled() + }) + + it('refuses to launch when the prepared account is not the requested one', async () => { + const prepareClaudeAuth = vi.fn(async () => ({ + ...ACTIVE_PREPARATION, + provenance: 'managed:acct-c' + })) + const controller = registerRuntimeController(prepareClaudeAuth) + const spawnsBefore = spawnMock.mock.calls.length + await expect( + controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: 'claude', + claudeAccountId: 'acct-b' + }) + ).rejects.toThrow(/could not prepare the requested Claude account/) + expect(spawnMock.mock.calls.length).toBe(spawnsBefore) + }) + + it('keeps the env-conflict refusal and releases the reservation of a failed spawn', async () => { + const prepareClaudeAuth = vi.fn(async () => { + // Mirrors the service: preparation reserves the account for the spawn to release. + const { reserveClaudePinnedAccount } = + await import('../claude-accounts/claude-pinned-pty-registry') + reserveClaudePinnedAccount('acct-b') + return PINNED_PREPARATION + }) + const controller = registerRuntimeController(prepareClaudeAuth) + try { + await expect( + controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: 'claude', + env: { ANTHROPIC_API_KEY: 'sk-test' }, + claudeAccountId: 'acct-b' + }) + ).rejects.toThrow(/explicit Anthropic auth environment variables/) + expect(countClaudePinnedAccountUsers('acct-b')).toBe(0) + } finally { + cleanup([]) + } + }) +}) diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 37ef0614a504..5190d86d16b1 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -1,7 +1,10 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import type { NetworkProxySettings } from '../../../../shared/network-proxy' -import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { + ClaudeLaunchAuthOptions, + ClaudeRuntimeAuthPreparation +} from '../../../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../../../claude-accounts/runtime-selection' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' @@ -76,5 +79,6 @@ export function allocatePtyLifecycleSequence(): number { } export type PrepareClaudeAuth = ( - target?: ClaudeAccountSelectionTarget + target?: ClaudeAccountSelectionTarget, + options?: ClaudeLaunchAuthOptions ) => Promise diff --git a/src/main/ipc/pty/runtime/spawn-claude-account.ts b/src/main/ipc/pty/runtime/spawn-claude-account.ts new file mode 100644 index 000000000000..f1c248f0bfd2 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-claude-account.ts @@ -0,0 +1,75 @@ +import { isClaudeLaunchCommand } from '../host-env/fresh-spawn-routing' +import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' +import { markPinnedClaudePtySpawned } from '../../../claude-accounts/claude-pinned-pty-registry' +import type { RuntimePtySpawnState } from './spawn-state' + +/** The `--account` a fresh runtime spawn must run on, refused where a pinned launch cannot run. */ +export function resolveRuntimeSpawnClaudeAccount(ctx: RuntimePtySpawnState): string | undefined { + const accountId = ctx.preAdoptedStablePane ? undefined : ctx.args.claudeAccountId + if (!accountId) { + return undefined + } + if (ctx.args.connectionId) { + throw new Error('Claude --account launches are not supported for SSH workspaces.') + } + if (!isClaudeLaunchCommand(ctx.args.command) && ctx.args.launchAgent !== 'claude') { + throw new Error('--account applies only to Claude launches.') + } + return accountId +} + +export function isRuntimeClaudeLaunch( + ctx: RuntimePtySpawnState, + pinnedAccountId: string | undefined +): boolean { + const { args } = ctx + if (ctx.preAdoptedStablePane || args.connectionId) { + return false + } + // Why: a wait-for-setup launch types a setup gate, not `claude`; for a pinned launch the agent + // id is the proof, and its credentials must still be prepared. + return ( + isClaudeLaunchCommand(args.command) || + (Boolean(pinnedAccountId) && args.launchAgent === 'claude') + ) +} + +export async function prepareRuntimeSpawnClaudeAuth( + ctx: RuntimePtySpawnState, + pinnedAccountId: string | undefined +): Promise { + if (!ctx.isClaudeLaunch || !ctx.deps.prepareClaudeAuth) { + if (pinnedAccountId) { + throw new Error('This Orca runtime cannot prepare Claude accounts for --account launches.') + } + return null + } + if (!pinnedAccountId) { + return ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) + } + const claudeAuth = await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget, { + accountId: pinnedAccountId + }) + // Why: the active path (`managed:`) also honours the request; any other account must not run. + if ( + claudeAuth.provenance !== `managed:${pinnedAccountId}:pinned` && + claudeAuth.provenance !== `managed:${pinnedAccountId}` + ) { + throw new Error( + 'Orca could not prepare the requested Claude account for this launch. Check `orca account list` and retry.' + ) + } + return claudeAuth +} + +export function markRuntimeClaudePtySpawned( + ptyId: string, + claudeAuth: RuntimePtySpawnState['claudeAuth'] +): void { + if (claudeAuth?.pinnedAccountId) { + // Why: a pinned PTY guards its own account, not the active one the global gate defers. + markPinnedClaudePtySpawned(ptyId, claudeAuth.pinnedAccountId) + return + } + markClaudePtySpawned(ptyId) +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 09d414602636..4a0adcf8a770 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -7,7 +7,7 @@ import { recordCodexPaneAccountForSpawn, codexReattachedHomeRouteField } from '../host-env/codex-home' -import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' +import { markRuntimeClaudePtySpawned } from './spawn-claude-account' import { registerPty } from '../../../memory/pty-registry' import { rememberPaneKeyForPty } from '../pane/key-state' import { @@ -228,7 +228,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) } if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { - markClaudePtySpawned(ctx.result.id) + markRuntimeClaudePtySpawned(ctx.result.id, ctx.claudeAuth) } if (args.telemetry && !ctx.stablePaneOwner) { const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 2bd10feaab63..37e0ac5d013f 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -17,7 +17,6 @@ import { } from '../host-env/codex-home' import { promoteAgentTeamsShimPath } from '../host-env/path' import { - isClaudeLaunchCommand, recoverFreshSpawnProviderRouting, routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' @@ -44,6 +43,11 @@ import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex- import { clearProviderPtyState } from '../provider/state-cleanup' import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness' import type { RuntimePtySpawnState } from './spawn-state' +import { + isRuntimeClaudeLaunch, + prepareRuntimeSpawnClaudeAuth, + resolveRuntimeSpawnClaudeAccount +} from './spawn-claude-account' export async function prepareRuntimePtySpawn( ctx: RuntimePtySpawnState @@ -68,9 +72,10 @@ export async function prepareRuntimePtySpawn( if (freshSpawnRecovery) { await freshSpawnRecovery } - ctx.isClaudeLaunch = - !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + const pinnedClaudeAccountId = resolveRuntimeSpawnClaudeAccount(ctx) + ctx.isClaudeLaunch = isRuntimeClaudeLaunch(ctx, pinnedClaudeAccountId) + // Why exempt: a switch never touches a pinned account; preparation re-reads it in the switch's queue. + if (ctx.isClaudeLaunch && !pinnedClaudeAccountId && isClaudeAuthSwitchInProgress()) { throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. @@ -147,11 +152,8 @@ export async function prepareRuntimePtySpawn( // Why: the drop still applies here, but this controller's result has no field for // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. ctx.launchCommand = codexResumeLaunch.command - ctx.claudeAuth = - ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth - ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) - : null - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + ctx.claudeAuth = await prepareRuntimeSpawnClaudeAuth(ctx, pinnedClaudeAccountId) + if (ctx.isClaudeLaunch && !ctx.claudeAuth?.pinnedAccountId && isClaudeAuthSwitchInProgress()) { throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 581d335ecdbc..ce71adee62a6 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -96,6 +96,8 @@ export type RuntimePtySpawnArgs = { } env?: Record envToDelete?: string[] + /** `--account`: the managed host Claude account this launch must run on. */ + claudeAccountId?: string resumeProviderSession?: AgentProviderSessionMetadata connectionId?: string | null worktreeId?: string diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 5897cb1d9afa..06c42e248b42 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -20,6 +20,7 @@ import { commitRuntimeSpawnHiddenDelivery, releaseRuntimeSpawnPreSpawnHiddenMark } from './spawn-hidden-delivery' +import { releaseClaudePinnedAccountReservation } from '../../../claude-accounts/claude-pinned-pty-registry' function toRuntimeSpawnReply(result: { id: string @@ -122,5 +123,9 @@ export async function spawnPtyFromRuntimeController( } finally { ctx.releaseWorktreeSpawn?.() ctx.finishTerminalInstall() + // Why last: a committed pinned PTY is registered by now, so its account never reads as unused. + if (ctx.claudeAuth?.pinnedAccountId) { + releaseClaudePinnedAccountReservation(ctx.claudeAuth.pinnedAccountId) + } } } diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 12b1466cacf1..5c5b0c463bf5 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -50,6 +50,8 @@ export type RuntimePtyController = { startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] + /** `--account`: the managed host Claude account this launch must run on. */ + claudeAccountId?: string resumeProviderSession?: AgentProviderSessionMetadata telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 858c6b757867..b70243292444 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -141,7 +141,7 @@ async function launchServeMode( runtime, prepareCodexRuntimeHomeForLaunch, () => state.store!.getSettings(), - (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + (target, options) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target, options), state.store!, prepareCodexSessionResumeForLaunch, { onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 5fe914de2a2a..9327b2a8785e 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -74,7 +74,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // `orca serve`, which never opens one, and the fleet path runs there too. const observedPaneIdentities = new AgentStatusObservedPaneIdentities() const runtime = new OrcaRuntimeService(store, stats, { - prepareClaudeAuth: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + prepareClaudeAuth: (target, options) => + state.claudeRuntimeAuth!.prepareForClaudeLaunch(target, options), agentSessionClaimSigner: loadAgentSessionClaimSigner( getProfileUserDataPath(), getProfileUserDataPath() diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 8f9ef2118dfc..cfe29d6f7e2a 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -111,7 +111,7 @@ export function attachMainWindowCoreServices( store, runtime, prepareCodexRuntimeHomeForLaunch, - (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target), + (target, options) => claudeRuntimeAuth.prepareForClaudeLaunch(target, options), { prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, awaitLocalPtyStartup: () => state.localPtyStartupReady, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 78496abc1fc9..e94f54910e2b 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -27,10 +27,9 @@ import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runti import type { UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import type { PrepareClaudeAuth } from '../ipc/pty/host-env/types' import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop' -import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { scheduleWorktreeBaseDirectoryWatcherSync, setWorktreeBaseDirectoryWatcherSyncContext @@ -51,9 +50,7 @@ export function attachMainWindowServices( store: Store, runtime: OrcaRuntimeService, getSelectedCodexHomePath?: GetSelectedCodexHomePath, - prepareClaudeAuth?: ( - target?: ClaudeAccountSelectionTarget - ) => Promise, + prepareClaudeAuth?: PrepareClaudeAuth, options?: { prepareCodexSessionResume?: PrepareCodexSessionResume awaitLocalPtyStartup?: () => Promise From 6ea647bac096b59cfeb3ee28147fc366a362f9fc Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 12:03:56 -0300 Subject: [PATCH 06/16] feat(runtime): carry --account from terminal create to the PTY and restore pinned PTYs at startup --- src/main/daemon/daemon-provider-init.ts | 14 +++++++---- ...ca-runtime-agent-teams-launch-plan.test.ts | 23 +++++++++++++++++++ .../orca-runtime-agent-teams-launch-plan.ts | 16 +++++++++---- .../orca-runtime-create-terminal-desktop.ts | 4 ++++ .../runtime/orca-runtime-create-terminal.ts | 8 +++---- .../runtime/runtime-terminal-contracts.ts | 5 ++++ .../startup/main-process-account-services.ts | 17 ++++++++++++++ .../startup/main-process-ready-foundation.ts | 16 +++++++++++++ 8 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 src/main/runtime/orca-runtime-agent-teams-launch-plan.test.ts diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index c1d73d95a277..e9295f64226c 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -3,6 +3,10 @@ import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys } from '../claude-accounts/live-pty-gate' +import { + confirmSeededPinnedClaudePtys, + hasSeededUnconfirmedPinnedClaudePtys +} from '../claude-accounts/claude-pinned-pty-registry' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' import { checkDaemonHealth } from './daemon-health' import { collectPinnedDaemonVersions, pruneOldDaemonHosts } from './daemon-host-relocation' @@ -192,7 +196,7 @@ async function reportDaemonAdoption( // Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token. async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise { - if (!hasSeededUnconfirmedClaudePtys()) { + if (!hasSeededUnconfirmedClaudePtys() && !hasSeededUnconfirmedPinnedClaudePtys()) { return } try { @@ -205,11 +209,11 @@ async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise< console.warn('[daemon] Keeping seeded Claude live-PTY gate — session listing failed') return } - confirmSeededClaudeLivePtys( - results.flatMap((result) => - result.status === 'fulfilled' ? result.value.map((session) => session.sessionId) : [] - ) + const aliveSessionIds = results.flatMap((result) => + result.status === 'fulfilled' ? result.value.map((session) => session.sessionId) : [] ) + confirmSeededClaudeLivePtys(aliveSessionIds) + confirmSeededPinnedClaudePtys(aliveSessionIds) } catch (error) { // Why: gate bookkeeping must never fail daemon init; stale seeds only defer a usage refresh until next restart. console.warn('[daemon] Failed to reconcile seeded Claude live-PTY gate:', error) diff --git a/src/main/runtime/orca-runtime-agent-teams-launch-plan.test.ts b/src/main/runtime/orca-runtime-agent-teams-launch-plan.test.ts new file mode 100644 index 000000000000..bffe0813beaa --- /dev/null +++ b/src/main/runtime/orca-runtime-agent-teams-launch-plan.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-agent-teams-plan-test' } })) + +const { buildRuntimeAgentTeamsLaunchPlan } = await import('./orca-runtime-agent-teams-launch-plan') + +describe('buildRuntimeAgentTeamsLaunchPlan', () => { + it('keeps an --account leader on in-process teammates instead of host-account panes', async () => { + const createTeamEnv = vi.fn(() => ({ ORCA_AGENT_TEAMS_TEAM_ID: 'team-1' })) + + const result = await buildRuntimeAgentTeamsLaunchPlan({ + launch: { command: 'claude', claudeAccountId: 'acct-b' }, + claudeAgentTeamsMode: 'native-panes-shim', + baseEnv: { ORCA_AGENT_TEAMS_SHIM_BIN: '/usr/local/bin/orca' }, + adoptedBeforeLaunch: false, + createTeamEnv + }) + + expect(createTeamEnv).not.toHaveBeenCalled() + expect(result.plan?.command).toContain('--teammate-mode in-process') + expect(result.plan?.env).toEqual({ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }) + }) +}) diff --git a/src/main/runtime/orca-runtime-agent-teams-launch-plan.ts b/src/main/runtime/orca-runtime-agent-teams-launch-plan.ts index 0d5808970072..f34e48645cab 100644 --- a/src/main/runtime/orca-runtime-agent-teams-launch-plan.ts +++ b/src/main/runtime/orca-runtime-agent-teams-launch-plan.ts @@ -8,10 +8,11 @@ import { inferCapturedClaudeAgentTeamsMode } from './orca-runtime-create-terminal-dependencies' -export async function buildRuntimeAgentTeamsLaunchPlan(args: { - launchConfig: TerminalCreateOptions['launchConfig'] - command?: string - claudeAgentTeamsSourceCommand?: string +export async function buildRuntimeAgentTeamsLaunchPlan(input: { + launch: Pick< + TerminalCreateOptions, + 'launchConfig' | 'command' | 'claudeAgentTeamsSourceCommand' | 'claudeAccountId' + > claudeAgentTeamsMode?: ClaudeAgentTeamsMode baseEnv: Record adoptedBeforeLaunch: boolean @@ -21,13 +22,18 @@ export async function buildRuntimeAgentTeamsLaunchPlan(args: { sequencedStartupCommand?: string effectiveLaunchConfig: TerminalCreateOptions['launchConfig'] }> { + const args = { ...input, ...input.launch } const sourceCommand = args.claudeAgentTeamsSourceCommand?.trim() || args.command?.trim() || undefined - const mode = inferCapturedClaudeAgentTeamsMode( + const capturedMode = inferCapturedClaudeAgentTeamsMode( args.launchConfig, sourceCommand, args.claudeAgentTeamsMode ) + // Why: native-pane teammates are separate terminals launched on the host's active account; an + // `--account` leader keeps its teammates in-process, on its own pinned credentials. + const mode = + args.claudeAccountId && capturedMode === 'native-panes-shim' ? 'in-process' : capturedMode const plan = args.adoptedBeforeLaunch ? undefined : await buildClaudeAgentTeamsLaunchPlan({ diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 9cd955428eef..abc5ef6d2f43 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -10,6 +10,10 @@ export async function createDesktopTerminal( presentation: RuntimeTerminalPresentation | undefined, rendererWindow: Electron.BrowserWindow | null ): Promise { + if (opts.claudeAccountId) { + // Why: only the background lane reaches the PTY spawn that honours the pinned account. + throw new Error('A Claude --account launch requires a workspace selector.') + } runtime.assertGraphReady() const win = rendererWindow ?? runtime.getAuthoritativeWindow() const workspace = worktreeSelector diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 81d4dc94dbb1..227dc20d58db 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -20,7 +20,8 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null const shouldCreateInBackground = worktreeSelector !== undefined && - (Boolean(opts.agentSessionClaim) || + // Why: only the background lane carries a pinned Claude account to the PTY spawn. + (Boolean(opts.agentSessionClaim || opts.claudeAccountId) || (!requiresRendererFocus && opts.rendererBacked !== true) || availableAuthoritativeWindow === null) if (shouldCreateInBackground) { @@ -83,9 +84,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate let effectiveLaunchConfig = launchOpts.launchConfig try { const agentTeams = await buildRuntimeAgentTeamsLaunchPlan({ - launchConfig: launchOpts.launchConfig, - command: launchOpts.command, - claudeAgentTeamsSourceCommand: launchOpts.claudeAgentTeamsSourceCommand, + launch: launchOpts, claudeAgentTeamsMode: this.store?.getSettings?.().claudeAgentTeamsMode, baseEnv: { ...process.env, ...baseEnv }, adoptedBeforeLaunch, @@ -139,6 +138,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate launchOpts.envToDelete, agentTeamsPlan?.envToDelete ), + ...(launchOpts.claudeAccountId ? { claudeAccountId: launchOpts.claudeAccountId } : {}), resumeProviderSession: launchOpts.resumeProviderSession, telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 36e917ebc7d0..5d6b5a3a3b1b 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -56,6 +56,11 @@ export type TerminalCreateOptions = { */ agentArgs?: string | null launchPreferences?: AgentLaunchPreferences + /** + * `--account`: a managed host Claude account id this launch must run on. Background-lane only, + * and deliberately not part of `callerSuppliedLaunch`: it picks credentials, not the launch. + */ + claudeAccountId?: string terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 7a677ed760ef..75296147114e 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -4,6 +4,8 @@ import { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' import { CodexAccountService } from '../codex-accounts/service' import { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' import { ClaudeAccountService } from '../claude-accounts/service' +import { onClaudePinnedAccountDrained } from '../claude-accounts/claude-pinned-pty-registry' +import { notePinnedClaudeSeedMarker } from '../claude-accounts/claude-pinned-credentials' import { KeybindingService } from '../keybindings/keybinding-service' import { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' import { startCodexSessionBackfillInBackground } from '../codex/codex-session-backfill' @@ -71,7 +73,22 @@ export function initializeMainProcessAccountServices(): void { // Why: migrate historical shared-home sessions after startup; compatibility // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). state.codexSessionMigration.scheduleInitialRun() + // Why before the service: its constructor sync must already see seeds a crashed pinned session left. + if (process.platform === 'darwin') { + for (const account of store.getSettings().claudeManagedAccounts) { + if (account.managedAuthRuntime !== 'wsl') { + notePinnedClaudeSeedMarker(account.id, account.managedAuthPath) + } + } + } state.claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) + onClaudePinnedAccountDrained((accountId) => { + void state.claudeRuntimeAuth + ?.reconcilePinnedAccountCredentials(accountId) + .catch((error) => + console.warn('[claude-runtime-auth] Pinned Claude read-back failed:', error) + ) + }) state.claudeAccounts = new ClaudeAccountService(store, state.rateLimits, state.claudeRuntimeAuth) state.rateLimits.setCodexHomePathResolver((target) => state.codexRuntimeHome!.prepareForRateLimitFetch(target) diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 3c0e01fe09eb..933fd4fa1794 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -1,3 +1,4 @@ +import { join } from 'node:path' import { app, session } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' @@ -27,6 +28,12 @@ import { onLiveClaudePtysDrained, seedLiveClaudePtysFromPersistence } from '../claude-accounts/live-pty-gate' +import { + attachClaudePinnedPtyPersistence, + createClaudePinnedPtyFilePersistence, + readClaudePinnedPtyRegistryFile, + seedPinnedClaudePtysFromPersistence +} from '../claude-accounts/claude-pinned-pty-registry' import { applyAppIcon } from '../app-icon' import { shouldSuppressDevEducation, @@ -253,6 +260,15 @@ export async function initializeReadyFoundation(): Promise { }) const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) + // Why: same restart hazard for `--account` PTYs, whose accounts the global gate never covers. + const pinnedClaudePtyRegistryPath = join( + app.getPath('userData'), + 'claude-pinned-pane-accounts.json' + ) + seedPinnedClaudePtysFromPersistence(readClaudePinnedPtyRegistryFile(pinnedClaudePtyRegistryPath)) + attachClaudePinnedPtyPersistence( + createClaudePinnedPtyFilePersistence(pinnedClaudePtyRegistryPath) + ) if (persistedClaudePtyIds.length > 0) { console.log( `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` From 100386eba8327269ee92e033c28ddad7d3b93928 Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 12:11:36 -0300 Subject: [PATCH 07/16] feat(worktree): start a created worktree's Claude agent on a requested account --- .../pty-runtime-claude-account-spawn.test.ts | 2 +- .../orca-runtime-create-managed-worktree.ts | 9 +++ .../rpc/methods/worktree-create-args.ts | 3 + .../runtime/runtime-folder-worktree-create.ts | 6 +- ...me-local-worktree-terminal-startup.test.ts | 35 ++++++++++++ ...runtime-local-worktree-terminal-startup.ts | 6 +- .../runtime-managed-worktree-create-types.ts | 2 + ...me-worktree-startup-claude-account.test.ts | 56 +++++++++++++++++++ ...runtime-worktree-startup-claude-account.ts | 38 +++++++++++++ src/shared/protocol-version.ts | 3 + ...rktree-create-claude-account-param.test.ts | 31 ++++++++++ .../rpc-contract/worktree-create-params.ts | 8 +++ 12 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/runtime-worktree-startup-claude-account.test.ts create mode 100644 src/main/runtime/runtime-worktree-startup-claude-account.ts create mode 100644 src/shared/rpc-contract/worktree-create-claude-account-param.test.ts diff --git a/src/main/ipc/pty-runtime-claude-account-spawn.test.ts b/src/main/ipc/pty-runtime-claude-account-spawn.test.ts index 9dac472339a1..2c2a13230f60 100644 --- a/src/main/ipc/pty-runtime-claude-account-spawn.test.ts +++ b/src/main/ipc/pty-runtime-claude-account-spawn.test.ts @@ -133,7 +133,7 @@ describe('runtime PTY spawn pinned to a Claude account', () => { } it('leaves a launch without --account byte-for-byte unchanged', async () => { - const prepareClaudeAuth = vi.fn(async () => ACTIVE_PREPARATION) + const prepareClaudeAuth = vi.fn(async (..._args: unknown[]) => ACTIVE_PREPARATION) const controller = registerRuntimeController(prepareClaudeAuth) const args = { cols: 80, rows: 24, worktreeId: 'wt-1', command: 'claude', env: { KEEP: '1' } } diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 79c0b612c051..772266bfa7f9 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -12,6 +12,7 @@ import type { PreparationRearmHolder } from '../worktree-create-preparation' import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' +import { resolveWorktreeStartupClaudeAccount } from './runtime-worktree-startup-claude-account' export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost { async createManagedWorktree( @@ -86,11 +87,18 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork // `null` on a `runtime:` host is deliberate: its nested target is addressable only inside that // environment, so the trust write must not go to a same-named target in this client's table. const sshConnectionId = createRoute.kind === 'ssh' ? createRoute.connectionId : null + const startupClaudeAccountId = resolveWorktreeStartupClaudeAccount({ + request: args, + createRouteKind: createRoute.kind, + canSpawn: Boolean(this.ptyController?.spawn), + listClaudeAccounts: () => this.getAccountsSnapshot().claude + }) if (isFolderRepo(repo)) { // A folder workspace is a registration, not a filesystem create, so it is host-agnostic — // except for the agent trust write, which must land on the host that will run the agent. return createRuntimeFolderWorktree({ request: args, + startupClaudeAccountId, repo, startup: effectiveStartup, startupFollowup: effectiveStartupFollowup, @@ -227,6 +235,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork startupTerminalPtyId } = await startRuntimeLocalWorktreeTerminals({ request: args, + startupClaudeAccountId, repo, worktree, setup, diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index 932659758bb5..7ce3b1664f6b 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -78,6 +78,9 @@ export function buildManagedWorktreeCreateArgs( : undefined, ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), + ...(params.startupClaudeAccount !== undefined + ? { startupClaudeAccount: params.startupClaudeAccount } + : {}), startupDraft: params.startupDraft, lineage: { parentWorkspace: params.parentWorkspace, diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index fd13253c488b..b101630a9b2f 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -47,6 +47,8 @@ type RuntimeFolderWorktreeCreateDeps = { export async function createRuntimeFolderWorktree(args: { request: RuntimeManagedWorktreeCreateArgs + /** Resolved `--account`; only the startup agent terminal runs on it. */ + startupClaudeAccountId?: string repo: Repo startup?: WorktreeStartupLaunch startupFollowup?: WorktreeStartupFollowup @@ -142,6 +144,7 @@ export async function createRuntimeFolderWorktree(args: { env: args.startup.env, ...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), + ...(args.startupClaudeAccountId ? { claudeAccountId: args.startupClaudeAccountId } : {}), ...(args.startup.viewMode ? { viewMode: args.startup.viewMode } : {}), startupCommandDelivery: args.startup.startupCommandDelivery, telemetry: args.startup.telemetry, @@ -173,7 +176,8 @@ export async function createRuntimeFolderWorktree(args: { repo.id, worktree.id, undefined, - args.startup && !didSpawnStartup ? args.startup : undefined + // Why: the renderer's fallback launch cannot honour --account; it would use the active one. + args.startup && !didSpawnStartup && !args.startupClaudeAccountId ? args.startup : undefined ) } else if (deps.ptySpawnAvailable && !didSpawnStartup && !args.createdWithAgent) { try { diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts index 1e590f229737..599736d0abaa 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -109,3 +109,38 @@ describe('startRuntimeLocalWorktreeTerminals default shell seeding', () => { } }) }) + +describe('startRuntimeLocalWorktreeTerminals --account', () => { + it('pins only the startup terminal and never hands a failed launch to the renderer', async () => { + const { createTerminal, ports } = createPorts() + createTerminal.mockRejectedValueOnce(new Error('keychain locked')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = await startRuntimeLocalWorktreeTerminals({ + request: { repoSelector: `id:${repo.id}`, name: worktree.displayName, activate: true }, + startupClaudeAccountId: 'acct-b', + repo, + worktree, + createdWithAgent: 'claude', + startup: { command: 'claude' }, + ports + }) + warn.mockRestore() + + expect(createTerminal.mock.calls[0]?.[1]).toMatchObject({ + claudeAccountId: 'acct-b', + launchAgent: 'claude' + }) + expect(result.warning).toContain('keychain locked') + expect(ports.activate).toHaveBeenCalledWith( + repo.id, + worktree.id, + undefined, + undefined, + undefined + ) + }) + + it('does not add an account to launches without one', async () => { + expect(await startupTerminalOptions()).not.toHaveProperty('claudeAccountId') + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index b3cb5e718508..c9a80e68c33a 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -53,6 +53,8 @@ export type RuntimeLocalWorktreeTerminalStartupResult = { export async function startRuntimeLocalWorktreeTerminals(args: { request: RuntimeManagedWorktreeCreateArgs + /** Resolved `--account`; only the startup agent terminal runs on it. */ + startupClaudeAccountId?: string repo: Repo worktree: Worktree setup?: CreateWorktreeResult['setup'] @@ -106,6 +108,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: { env: sequencedStartup.env, ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), + ...(args.startupClaudeAccountId ? { claudeAccountId: args.startupClaudeAccountId } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, telemetry: sequencedStartup.telemetry, @@ -148,7 +151,8 @@ export async function startRuntimeLocalWorktreeTerminals(args: { repo.id, worktree.id, activationSetup, - startup && !didSpawnStartup ? startup : undefined, + // Why: the renderer's fallback launch cannot honour --account; it would use the active one. + startup && !didSpawnStartup && !args.startupClaudeAccountId ? startup : undefined, runtimeWillProvision ? undefined : defaultTabs ) } else if (ports.canSpawn && (setup || defaultTabs || didSpawnStartup)) { diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index bdc8c2a0a127..4495550837aa 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -50,6 +50,8 @@ export type RuntimeManagedWorktreeCreateArgs = { createdWithAgent?: TuiAgent startupAgent?: TuiAgent startupLaunchPreferences?: AgentLaunchPreferences + /** `--account ` for a `startupAgent: 'claude'` launch; resolved before any create. */ + startupClaudeAccount?: string startupPrompt?: string /** Per-launch inputs used when `startupAgent` is the created terminal surface. */ startupAgentArgs?: string | null diff --git a/src/main/runtime/runtime-worktree-startup-claude-account.test.ts b/src/main/runtime/runtime-worktree-startup-claude-account.test.ts new file mode 100644 index 000000000000..1e9f92130ce4 --- /dev/null +++ b/src/main/runtime/runtime-worktree-startup-claude-account.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveWorktreeStartupClaudeAccount } from './runtime-worktree-startup-claude-account' + +const accounts = { + accounts: [ + { + id: 'acct-b', + email: 'pinned@example.com', + managedAuthRuntime: 'host' as const, + wslDistro: null, + authMethod: 'subscription-oauth' as const, + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeAccountId: null +} + +function resolve(overrides: Partial[0]>) { + return resolveWorktreeStartupClaudeAccount({ + request: { startupAgent: 'claude', startupClaudeAccount: 'pinned@example.com' }, + createRouteKind: 'local', + canSpawn: true, + listClaudeAccounts: () => accounts, + ...overrides + }) +} + +describe('resolveWorktreeStartupClaudeAccount', () => { + it('resolves the selector for a local Claude startup agent', () => { + expect(resolve({})).toBe('acct-b') + }) + + it('is a no-op without --account and never lists accounts', () => { + const listClaudeAccounts = vi.fn(() => accounts) + expect( + resolve({ request: { startupAgent: 'claude' }, createRouteKind: 'ssh', listClaudeAccounts }) + ).toBeUndefined() + expect(listClaudeAccounts).not.toHaveBeenCalled() + }) + + it('refuses every case the startup terminal could not honour', () => { + expect(() => + resolve({ request: { startupAgent: 'codex', startupClaudeAccount: 'acct-b' } }) + ).toThrow('--account requires --agent claude.') + expect(() => resolve({ createRouteKind: 'ssh' })).toThrow(/not supported for SSH/) + expect(() => resolve({ createRouteKind: 'runtime' })).toThrow(/connected-server/) + expect(() => resolve({ canSpawn: false })).toThrow(/cannot start Claude --account/) + expect(() => + resolve({ request: { startupAgent: 'claude', startupClaudeAccount: 'nobody@example.com' } }) + ).toThrow(/orca account list/) + }) +}) diff --git a/src/main/runtime/runtime-worktree-startup-claude-account.ts b/src/main/runtime/runtime-worktree-startup-claude-account.ts new file mode 100644 index 000000000000..5d315ae0aea7 --- /dev/null +++ b/src/main/runtime/runtime-worktree-startup-claude-account.ts @@ -0,0 +1,38 @@ +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' +import { resolveClaudeLaunchAccount } from '../claude-accounts/claude-launch-account' +import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' + +/** + * Resolves and validates a create's `--account` before anything is created on disk. + * + * Every refusal happens here because the startup terminal is spawned after the checkout exists, + * and a failure there is only a warning on an otherwise successful create — a worktree whose + * agent silently never started, or worse, started on the host's active account. + */ +export function resolveWorktreeStartupClaudeAccount(args: { + request: Pick< + RuntimeManagedWorktreeCreateArgs, + 'startupClaudeAccount' | 'startupAgent' | 'startup' + > + createRouteKind: 'local' | 'ssh' | 'runtime' + canSpawn: boolean + listClaudeAccounts: () => ClaudeRateLimitAccountsState +}): string | undefined { + const selector = args.request.startupClaudeAccount + if (selector === undefined) { + return undefined + } + if (args.request.startupAgent !== 'claude' || args.request.startup) { + throw new Error('--account requires --agent claude.') + } + if (args.createRouteKind !== 'local') { + throw new Error( + 'Claude --account launches run on this Orca host only; they are not supported for SSH or connected-server workspaces.' + ) + } + if (!args.canSpawn) { + // Why: without a runtime PTY lane the renderer would launch the agent on the active account. + throw new Error('This Orca runtime cannot start Claude --account terminals.') + } + return resolveClaudeLaunchAccount(args.listClaudeAccounts(), selector).accountId +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 9645b8d3fba6..dc30af19f594 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -52,6 +52,8 @@ export const ORCHESTRATION_WORKER_STOP_VERDICT_RUNTIME_CAPABILITY = 'orchestration.worker-stop-verdict.v1' as const export const ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY = 'orchestration.worker-launch-preferences.v1' as const +/** Host resolves `--account` for Claude worker-start and worktree create launches. */ +export const CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY = 'claude.launch-account.v1' as const export const ORCHESTRATION_FEDERATION_STRUCTURED_READ_RUNTIME_CAPABILITY = 'orchestration.federation-structured-read.v1' as const export const ORCHESTRATION_FEDERATION_FLEET_SNAPSHOT_RUNTIME_CAPABILITY = @@ -316,6 +318,7 @@ export const RUNTIME_CAPABILITIES = [ ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_RUNTIME_CAPABILITY, ORCHESTRATION_WORKER_STOP_VERDICT_RUNTIME_CAPABILITY, ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY, + CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY, ORCHESTRATION_FEDERATION_STRUCTURED_READ_RUNTIME_CAPABILITY, ORCHESTRATION_FEDERATION_FLEET_SNAPSHOT_RUNTIME_CAPABILITY, ORCHESTRATION_FEDERATION_RELEASE_ARCHIVE_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/worktree-create-claude-account-param.test.ts b/src/shared/rpc-contract/worktree-create-claude-account-param.test.ts new file mode 100644 index 000000000000..8d7c038b7b39 --- /dev/null +++ b/src/shared/rpc-contract/worktree-create-claude-account-param.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { WorktreeCreate } from './worktree-create-params' +import { buildManagedWorktreeCreateArgs } from '../../main/runtime/rpc/methods/worktree-create-args' + +describe('worktree.create startupClaudeAccount', () => { + it('requires a Claude startup agent', () => { + const parsed = WorktreeCreate.safeParse({ + repo: 'id:repo-1', + startupAgent: 'codex', + startupClaudeAccount: 'acct-b' + }) + expect(parsed.success).toBe(false) + expect(parsed.error?.issues.map((issue) => issue.message)).toContain( + 'startupClaudeAccount requires startupAgent claude' + ) + }) + + it('maps the selector to the runtime create only when present', () => { + const pinned = WorktreeCreate.parse({ + repo: 'id:repo-1', + startupAgent: 'claude', + startupClaudeAccount: 'pinned@example.com' + }) + expect(buildManagedWorktreeCreateArgs(pinned, {})).toMatchObject({ + startupAgent: 'claude', + startupClaudeAccount: 'pinned@example.com' + }) + const plain = WorktreeCreate.parse({ repo: 'id:repo-1', startupAgent: 'claude' }) + expect(buildManagedWorktreeCreateArgs(plain, {})).not.toHaveProperty('startupClaudeAccount') + }) +}) diff --git a/src/shared/rpc-contract/worktree-create-params.ts b/src/shared/rpc-contract/worktree-create-params.ts index 7c0b1f55fadf..3f9985fe13ac 100644 --- a/src/shared/rpc-contract/worktree-create-params.ts +++ b/src/shared/rpc-contract/worktree-create-params.ts @@ -110,6 +110,8 @@ export const WorktreeCreate = z // workspaces execute in a different shell than the client process. startupAgent: OptionalTuiAgent, startupPrompt: OptionalString, + // Why: `--account ` resolves on the host that owns the Claude accounts, never here. + startupClaudeAccount: OptionalString, // Why: task-driven mobile creates need desktop parity: the host chooses // the same default/detected agent and drafts the linked issue/PR URL into it. startupDraft: OptionalString, @@ -137,6 +139,12 @@ export const WorktreeCreate = z message: 'Choose either one parent selector or --no-parent.' }) } + if (params.startupClaudeAccount !== undefined && params.startupAgent !== 'claude') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'startupClaudeAccount requires startupAgent claude' + }) + } if (params.startupPrompt !== undefined && params.startupAgent === undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, From d739f798f3414a5f08238c9dfd47fa7f7e07bc40 Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 12:11:46 -0300 Subject: [PATCH 08/16] feat(orchestration): run worker-start --account workers on a pinned Claude account --- src/main/agent-launch/agent-launch-mode.ts | 9 +- .../orchestration-worker-start-mode.ts | 7 +- .../worker/local-worker-start.ts | 11 +- .../worker/worker-claude-account-placement.ts | 16 +++ .../worker/worker-claude-account.test.ts | 128 ++++++++++++++++++ .../worker/worker-launch-preferences.ts | 21 +++ .../worker/worker-start-agent-placement.ts | 4 + .../worker/worker-start-validation.ts | 50 ++++++- .../orchestration/worker/worker-topology.ts | 2 + .../worker/worker-worktree-creation.ts | 5 +- .../methods/orchestration/worker/workers.ts | 6 + src/shared/agent-launch-intent.ts | 1 + .../orchestration-worker-start-params.ts | 9 ++ 13 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account-placement.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account.test.ts diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 99711cc12939..dae3de8ed6aa 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -72,6 +72,8 @@ export type AgentLaunchModePlacement = { * resolved — never accepted from a caller, which would let one route around this decision. * Absent means the kind was never established, and is not read as any particular kind. */ workspaceKind?: WorkspaceLaunchKind + /** `--account`: a structured session has no way to run on a non-active account yet. */ + claudeAccount?: string /** A start directory other than the workspace root. It belongs here, unlike `model` or `effort`, * because a structured session has no way to apply one — it runs in its workspace — so honouring * it and honouring the chat preference are mutually exclusive rather than merely awkward. */ @@ -87,7 +89,9 @@ const DOWNGRADE_DETAIL: Record, s structured_support_unknown: 'the execution host has not established structured session support', wsl_execution_runtime: 'this workspace runs under WSL', codex_on_windows: 'Codex has no structured session on Windows', - structured_unsupported_on_host: 'the execution host cannot create one here' + structured_unsupported_on_host: 'the execution host cannot create one here', + pinned_claude_account: + '--account runs Claude on a non-active account, which only a terminal can do' } const BLOCKER_REASON: Record< @@ -133,6 +137,9 @@ export function decideAgentLaunchMode(args: { detail: `Started ${vocabulary.terminal}, the default for new agent tabs in your settings.` } } + if (placement.claudeAccount) { + return downgraded('pinned_claude_account', vocabulary) + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; isAgentSessionHandleProvider rejects it and the launch downgrades to a terminal. const agent = placement.agent as TuiAgent const support = resolveStructuredNativeChatSupport({ diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index 9cd9f51e0ac6..39e11f1a59a2 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -46,6 +46,8 @@ type WorkerStartModePlacement = { worktree?: string model?: string effort?: string + /** Read: `--account` is only honoured by a terminal worker. */ + account?: string } export function decideWorkerStartMode(args: { @@ -53,7 +55,10 @@ export function decideWorkerStartMode(args: { settings: AgentLaunchModeSettings | null | undefined }): WorkerStartModeReceipt { return decideAgentLaunchMode({ - placement: args.params, + placement: { + ...args.params, + ...(args.params.account ? { claudeAccount: args.params.account } : {}) + }, settings: args.settings, vocabulary: WORKER_START_VOCABULARY }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index f8cd1033c97c..9a9ee2f50f95 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -24,6 +24,7 @@ import { recordCreatedWorkerTerminalCustody } from './created-worker-terminal-cu import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' import { requireWorkerAuthority, type WorkerEffect } from './worker-topology' import { prepareLocalWorkerStart } from './worker-start-validation' +import { assertClaudeAccountWorktreeIsLocal } from './worker-claude-account-placement' import { deliverAndSettleWorkerStartReadiness } from './worker-start-readiness-settlement' type WorkerStartMutation = { @@ -47,7 +48,11 @@ export async function startLocalWorker(args: { const { params, runtime, db, run, coordinatorPane, existingTask, orchestrationMutation } = args const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' - const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) + const { agent, launch, claudeAccount } = prepareLocalWorkerStart({ + params, + createsWorktree, + runtime + }) const coordinatorWorktreeId = await resolveDispatchCallerWorktreeId(runtime, params.from) const creationWorktree = createsWorktree @@ -74,6 +79,9 @@ export async function startLocalWorker(args: { resolvedWorktreeId: resolvedWorktree?.id }) } + if (claudeAccount && resolvedWorktree) { + await assertClaudeAccountWorktreeIsLocal(runtime, resolvedWorktree.id) + } let mode = await resolveWorkerStartModeOnHost(runtime, args.mode, resolvedWorktree?.id, agent) const startOptions = { @@ -137,6 +145,7 @@ export async function startLocalWorker(args: { mode, agent, launchPreferences: launch.preferences, + ...(claudeAccount ? { claudeAccountId: claudeAccount.accountId } : {}), effects, onStage: (stage) => { failedStage = stage diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account-placement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account-placement.ts new file mode 100644 index 000000000000..b69b8a948272 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account-placement.ts @@ -0,0 +1,16 @@ +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { OrchestrationError } from '../../../../orchestration/orchestration-error' + +/** Refused before the dispatch row exists, so a remote placement never shows up as a failed start. */ +export async function assertClaudeAccountWorktreeIsLocal( + runtime: Pick, + worktreeId: string +): Promise { + const scope = await runtime.showTerminalWorkspaceLaunchScope(`id:${worktreeId}`) + if (scope.connectionId) { + throw new OrchestrationError( + 'invalid_argument', + '--account runs Claude on this host only and cannot start a worker in an SSH workspace.' + ) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account.test.ts new file mode 100644 index 000000000000..fb7cd60cb98b --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ClaudeManagedAccountSummary } from '../../../../../../shared/managed-account-types' +import { WorkerStartParams } from './worker-start-schema' +import { createWorkerLaunchReceipt, withWorkerLaunchAccount } from './worker-launch-preferences' +import { prepareLocalWorkerStart } from './worker-start-validation' +import { decideWorkerStartMode } from '../../orchestration-worker-start-mode' +import { assertClaudeAccountWorktreeIsLocal } from './worker-claude-account-placement' + +function account(id: string, email: string): ClaudeManagedAccountSummary { + return { + id, + email, + managedAuthRuntime: 'host', + wslDistro: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +function createRuntime() { + return { + validateOrchestrationAgentLauncher: vi.fn(), + getAccountsSnapshot: vi.fn(() => ({ + claude: { + accounts: [ + account('acct-a', 'active@example.com'), + account('acct-b', 'pinned@example.com') + ], + activeAccountId: 'acct-a' + } + })) + } +} + +function prepare(params: Record) { + return prepareLocalWorkerStart({ + params: WorkerStartParams.parse({ task: 'task_1', from: 'term_coord', ...params }), + createsWorktree: false, + runtime: createRuntime() as never + }) +} + +describe('worker-start --account', () => { + it('rejects --account beside --terminal at the schema', () => { + const parsed = WorkerStartParams.safeParse({ + task: 'task_1', + from: 'term_coord', + terminal: 'term_worker', + account: 'acct-b' + }) + expect(parsed.success).toBe(false) + expect(parsed.error?.issues.map((issue) => issue.message)).toContain( + '--account applies to a new Claude terminal and cannot combine with --terminal.' + ) + }) + + it('resolves an email to the pinned account and records it in the launch receipt', () => { + const { claudeAccount, launch } = prepare({ agent: 'claude', account: 'PINNED@example.com' }) + + expect(claudeAccount).toEqual({ + accountId: 'acct-b', + email: 'pinned@example.com', + isActiveOnHost: false + }) + const expected = { id: 'acct-b', email: 'pinned@example.com', mode: 'pinned' } + expect(launch.receipt.requested.account).toEqual(expected) + expect(launch.receipt.effective?.account).toEqual(expected) + }) + + it('reports the active host account as an unpinned launch', () => { + expect( + prepare({ agent: 'claude', account: 'acct-a' }).launch.receipt.requested.account?.mode + ).toBe('active') + }) + + it('rejects non-Claude agents and unknown accounts before any dispatch exists', () => { + expect(() => prepare({ agent: 'codex', account: 'acct-b' })).toThrow( + '--account applies only to --agent claude.' + ) + expect(() => prepare({ agent: 'claude', account: 'nobody@example.com' })).toThrow( + /orca account list/ + ) + }) + + it('leaves receipts without --account exactly as before', () => { + const receipt = createWorkerLaunchReceipt({ agent: 'claude' }) + expect(withWorkerLaunchAccount(receipt, undefined)).toBe(receipt) + expect(prepare({ agent: 'claude' }).launch.receipt).toEqual(receipt) + expect(prepare({ agent: 'claude' }).claudeAccount).toBeUndefined() + }) + + it('downgrades a structured default to a terminal worker', () => { + const settings = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true + } + expect(decideWorkerStartMode({ params: { agent: 'claude' }, settings }).mode).toBe('structured') + const mode = decideWorkerStartMode({ params: { agent: 'claude', account: 'acct-b' }, settings }) + expect(mode).toMatchObject({ + mode: 'terminal', + preferred: 'structured', + reason: 'pinned_claude_account' + }) + expect(mode.detail).toContain('--account') + }) + + it('refuses an SSH workspace placement', async () => { + await expect( + assertClaudeAccountWorktreeIsLocal( + { + showTerminalWorkspaceLaunchScope: vi.fn(async () => ({ connectionId: 'ssh-1' })) + } as never, + 'wt-1' + ) + ).rejects.toThrow(/SSH workspace/) + await expect( + assertClaudeAccountWorktreeIsLocal( + { showTerminalWorkspaceLaunchScope: vi.fn(async () => ({ connectionId: null })) } as never, + 'wt-1' + ) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts index d998d8e5e715..04e028c4006a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts @@ -9,10 +9,18 @@ import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../. import type { TuiAgent } from '../../../../../../shared/tui-agent' import { OrchestrationError } from '../../../../orchestration/orchestration-error' +/** `--account`: which managed Claude account ran, and whether that needed a pinned launch. */ +export type OrchestrationWorkerLaunchAccount = { + id: string + email: string + mode: 'pinned' | 'active' +} + export type OrchestrationWorkerLaunchSelection = { agent: TuiAgent | null model: string | null effort: string | null + account?: OrchestrationWorkerLaunchAccount } export type OrchestrationWorkerLaunchReceipt = { @@ -33,6 +41,19 @@ export function createWorkerLaunchReceipt(args: { return { requested: selection, effective: { ...selection } } } +export function withWorkerLaunchAccount( + receipt: OrchestrationWorkerLaunchReceipt, + account: OrchestrationWorkerLaunchAccount | undefined +): OrchestrationWorkerLaunchReceipt { + if (!account) { + return receipt + } + return { + requested: { ...receipt.requested, account }, + effective: receipt.effective ? { ...receipt.effective, account } : null + } +} + export function createPendingWorkerLaunchReceipt(args: { agent: TuiAgent | null model?: string diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts index 8d87ea894c80..c0cf40c7087f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts @@ -59,6 +59,8 @@ type WorkerAgentPlacementArgs = { mode: WorkerStartModeReceipt agent: TuiAgent | undefined launchPreferences: AgentLaunchPreferences | undefined + /** `--account`: the resolved managed Claude account the agent terminal runs on. */ + claudeAccountId?: string effects: WorkerEffect[] /** Attributes a throw to the step that was running, the way the caller's own stages do. */ onStage: (stage: string) => void @@ -120,6 +122,7 @@ async function placeInCreatedWorktree( agent: args.agent as TuiAgent, withAgentTerminal: args.mode.mode !== 'structured', ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + ...(args.claudeAccountId ? { claudeAccountId: args.claudeAccountId } : {}), effects: args.effects }) const worktree = requireWorktree(created.worktree) @@ -170,6 +173,7 @@ async function createWorkerAgentSurface( worktreeId, agent: args.agent as TuiAgent, ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + ...(args.claudeAccountId ? { claudeAccountId: args.claudeAccountId } : {}), taskId: args.taskId, effects: args.effects }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts index 1ecce8e557f8..20617ac8c1b2 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts @@ -6,8 +6,13 @@ import type { FederationAttachStartInput } from '../federation/federation-start- import { assertWorkerLaunchPreferencesCreateTerminal, createWorkerLaunchReceipt, - resolveWorkerLaunchPreferences + resolveWorkerLaunchPreferences, + withWorkerLaunchAccount } from './worker-launch-preferences' +import { + resolveClaudeLaunchAccount, + type ClaudeLaunchAccountResolution +} from '../../../../../claude-accounts/claude-launch-account' import type { WorkerStartInput } from './worker-start-schema' type WorkerStartLaunch = ReturnType @@ -52,7 +57,11 @@ export function prepareLocalWorkerStart(args: { params: WorkerStartInput createsWorktree: boolean runtime: OrcaRuntimeService -}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { +}): { + agent: TuiAgent | undefined + launch: WorkerStartLaunch + claudeAccount: ClaudeLaunchAccountResolution | undefined +} { const { params, createsWorktree, runtime } = args assertWorkerLaunchPreferencesCreateTerminal(params) if (params.terminal && params.agent) { @@ -67,6 +76,9 @@ export function prepareLocalWorkerStart(args: { '--terminal cannot combine with new-worktree creation.' ) } + if (params.account && params.agent !== 'claude') { + throw new OrchestrationError('invalid_argument', '--account applies only to --agent claude.') + } if (createsWorktree && !params.name) { throw new OrchestrationError('invalid_argument', 'New worktrees require --name.') } @@ -76,7 +88,7 @@ export function prepareLocalWorkerStart(args: { 'Creation and setup options apply only to new-child or new-top-level worktrees.' ) } - return resolveWorkerStartAgent({ + const started = resolveWorkerStartAgent({ runtime, terminal: params.terminal, agent: params.agent, @@ -84,6 +96,38 @@ export function prepareLocalWorkerStart(args: { effort: params.effort, missingAgentMessage: 'A configured --agent is required when worker-start creates a terminal.' }) + const claudeAccount = params.account + ? resolveWorkerClaudeAccount(runtime, params.account) + : undefined + return { + ...started, + launch: { + ...started.launch, + receipt: withWorkerLaunchAccount( + started.launch.receipt, + claudeAccount && { + id: claudeAccount.accountId, + email: claudeAccount.email, + mode: claudeAccount.isActiveOnHost ? 'active' : 'pinned' + } + ) + }, + claudeAccount + } +} + +function resolveWorkerClaudeAccount( + runtime: OrcaRuntimeService, + selector: string +): ClaudeLaunchAccountResolution { + try { + return resolveClaudeLaunchAccount(runtime.getAccountsSnapshot().claude, selector) + } catch (error) { + throw new OrchestrationError( + 'invalid_argument', + error instanceof Error ? error.message : String(error) + ) + } } export function prepareFederationAttachmentWorkerStart(args: { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts index b18eb9ee6c25..843bf1905abc 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts @@ -61,6 +61,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { worktreeId: string agent: TuiAgent launchPreferences?: AgentLaunchPreferences + claudeAccountId?: string taskId: string effects: WorkerEffect[] }): Promise<{ handle: string; warning?: string }> { @@ -70,6 +71,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { // configured launcher instead of executing the raw id. startupAgent: args.agent, ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + ...(args.claudeAccountId ? { claudeAccountId: args.claudeAccountId } : {}), title: `worker-${args.taskId}`, // Why: dispatching a worker is background work; it must not pull the sidebar // to the worker's workspace while the user is reading somewhere else. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts index fe0c11e374d8..44786db447eb 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts @@ -36,6 +36,8 @@ export async function createWorkerWorktree(args: { * bracketed paste. The renderer's own structured worktree create skips both the same way. */ withAgentTerminal: boolean launchPreferences?: AgentLaunchPreferences + /** `--account`: resolved on this host; the created worktree's agent terminal runs on it. */ + claudeAccountId?: string effects: WorkerEffect[] }): Promise<{ worktree: Awaited> @@ -61,7 +63,8 @@ export async function createWorkerWorktree(args: { ...(args.withAgentTerminal ? { startupAgent: args.agent, - ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}) + ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}), + ...(args.claudeAccountId ? { startupClaudeAccount: args.claudeAccountId } : {}) } : {}), activate: false, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index b1a1f40d45ae..446f9f51c992 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -53,6 +53,12 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ params, settings: readWorkerStartModeSettings(runtime) }) + if (params.on && params.account) { + throw new OrchestrationError( + 'invalid_argument', + "--account runs Claude on this host's accounts and cannot combine with --on." + ) + } if (params.on) { // A remote worker is always a terminal agent; the mode receipt rides along so the // coordinator still learns why its structured default did not apply. diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index 2309bb8eb4cc..3ef4377bd43a 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -181,6 +181,7 @@ export type AgentLaunchModeReason = | 'wsl_execution_runtime' | 'codex_on_windows' | 'structured_unsupported_on_host' + | 'pinned_claude_account' /** Restates `WorkerStartModeReceipt` in surface-neutral terms so orchestration's receipt and a * mobile or renderer launch report the same vocabulary. */ diff --git a/src/shared/rpc-contract/orchestration-worker-start-params.ts b/src/shared/rpc-contract/orchestration-worker-start-params.ts index dc6432aa47a9..a08046ec9643 100644 --- a/src/shared/rpc-contract/orchestration-worker-start-params.ts +++ b/src/shared/rpc-contract/orchestration-worker-start-params.ts @@ -29,6 +29,8 @@ export const WorkerStartParams = z agent: OptionalString, model: OptionalWorkerLaunchPreference, effort: OptionalWorkerLaunchPreference, + // Why: an id or email resolved by the worker host, which is the one that owns the accounts. + account: OptionalWorkerLaunchPreference, retryOf: OptionalString, timeoutMs: OptionalFiniteNumber, devMode: z.boolean().optional() @@ -48,6 +50,13 @@ export const WorkerStartParams = z message: '--task and --spec are mutually exclusive' }) } + if (params.account && params.terminal) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['account'], + message: '--account applies to a new Claude terminal and cannot combine with --terminal.' + }) + } // Why: --spec creates a new Task, so a retry link to a prior Dispatch could never resolve and // the refusal named a Task id the caller never supplied. if (params.retryOf && params.spec) { From fdb0edce8d8d121017d6aa8c5fc17ce4088dae8f Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 12:12:17 -0300 Subject: [PATCH 09/16] feat(cli): add --account to worker-start and worktree create for Claude --- .../handlers/claude-launch-account-flag.ts | 38 +++++++ .../orchestration-worker-account-cli.test.ts | 84 ++++++++++++++ .../orchestration/worker-launch-handler.ts | 5 + src/cli/handlers/worktree.ts | 5 +- ...dex-worktree-create-claude-account.test.ts | 103 ++++++++++++++++++ src/cli/specs/core.ts | 4 +- src/cli/specs/orchestration-worker-specs.ts | 4 +- 7 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 src/cli/handlers/claude-launch-account-flag.ts create mode 100644 src/cli/handlers/orchestration-worker-account-cli.test.ts create mode 100644 src/cli/index-worktree-create-claude-account.test.ts diff --git a/src/cli/handlers/claude-launch-account-flag.ts b/src/cli/handlers/claude-launch-account-flag.ts new file mode 100644 index 000000000000..92a07829ef45 --- /dev/null +++ b/src/cli/handlers/claude-launch-account-flag.ts @@ -0,0 +1,38 @@ +import { getOptionalStringFlag } from '../flags' +import { RuntimeClientError, type RuntimeClient } from '../runtime-client' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' + +/** + * `--account `: the managed Claude account a launch runs on, resolved by the host. + * + * Why the capability check: an older host strips the unknown field and would quietly start the + * agent on its active account — exactly what the caller asked not to happen. + */ +export async function readClaudeLaunchAccountFlag( + flags: Map, + client: RuntimeClient, + agent: string | undefined +): Promise { + if (!flags.has('account')) { + return undefined + } + const account = getOptionalStringFlag(flags, 'account')?.trim() + if (!account) { + throw new RuntimeClientError( + 'invalid_argument', + '--account requires a Claude account id or email.' + ) + } + if (agent !== 'claude') { + throw new RuntimeClientError('invalid_argument', '--account requires --agent claude.') + } + const status = await client.call('status.get') + if (!status.result.capabilities?.includes(CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY)) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The connected Orca runtime does not support --account for Claude launches. Update or restart Orca and try again.' + ) + } + return account +} diff --git a/src/cli/handlers/orchestration-worker-account-cli.test.ts b/src/cli/handlers/orchestration-worker-account-cli.test.ts new file mode 100644 index 000000000000..43be9df099d9 --- /dev/null +++ b/src/cli/handlers/orchestration-worker-account-cli.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) + +import { ORCHESTRATION_HANDLERS } from './orchestration' +import { CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' + +describe('orchestration worker-start --account', () => { + beforeEach(() => { + callMock.mockReset() + process.exitCode = undefined + }) + + const invokeWorkerStart = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration worker-start']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + it('forwards --account to a host that resolves Claude launch accounts', async () => { + callMock + .mockResolvedValueOnce({ + result: { capabilities: [CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY] } + }) + .mockResolvedValueOnce({ + result: { runId: 'run_1', taskId: 'task_1', dispatchId: 'ctx_1', state: 'ready' } + }) + + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'claude'], + ['account', 'pinned@example.com'], + ['from', 'term_coord'] + ]) + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'status.get') + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'orchestration.workerStart', + expect.objectContaining({ agent: 'claude', account: 'pinned@example.com' }) + ) + }) + + it('fails before worker-start when --account would be stripped or misapplied', async () => { + callMock.mockResolvedValueOnce({ result: { capabilities: [] } }) + const flags = (agent: string) => + new Map([ + ['task', 'task_1'], + ['agent', agent], + ['account', 'acct-b'], + ['from', 'term_coord'] + ]) + + await expect(invokeWorkerStart(flags('claude'))).rejects.toMatchObject({ + code: 'incompatible_runtime' + }) + await expect(invokeWorkerStart(flags('codex'))).rejects.toThrow( + '--account requires --agent claude.' + ) + expect(callMock).toHaveBeenCalledTimes(1) + }) + + it('never sends an account field when --account is absent', async () => { + callMock.mockResolvedValue({ + result: { runId: 'run_1', taskId: 'task_1', dispatchId: 'ctx_1', state: 'ready' } + }) + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'claude'], + ['from', 'term_coord'] + ]) + ) + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock.mock.calls[0]?.[1]).not.toHaveProperty('account') + }) +}) diff --git a/src/cli/handlers/orchestration/worker-launch-handler.ts b/src/cli/handlers/orchestration/worker-launch-handler.ts index b6e4d92799c2..a8542e33f8d9 100644 --- a/src/cli/handlers/orchestration/worker-launch-handler.ts +++ b/src/cli/handlers/orchestration/worker-launch-handler.ts @@ -10,6 +10,7 @@ import { isDevCliInvocation } from './runtime-compatibility' import { resolveCoordinatorTerminalHandle } from './terminal-identity' import { formatWorkerStart } from './worker-output' import { renderResolvedOrchestrationCommand } from '../../orchestration-mutation-recovery' +import { readClaudeLaunchAccountFlag } from '../claude-launch-account-flag' export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record = { 'orchestration worker-start': async ({ flags, client, cwd, json }) => { @@ -28,6 +29,9 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record ) } } + const account = flags.has('terminal') + ? getOptionalStringFlag(flags, 'account') + : await readClaudeLaunchAccountFlag(flags, client, getOptionalStringFlag(flags, 'agent')) const task = getOptionalStringFlag(flags, 'task') const spec = getOptionalStringFlag(flags, 'spec') const taskTitle = getOptionalStringFlag(flags, 'task-title') @@ -62,6 +66,7 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record agent: getOptionalStringFlag(flags, 'agent'), model, effort, + ...(account ? { account } : {}), terminal: getOptionalStringFlag(flags, 'terminal'), retryOf: getOptionalStringFlag(flags, 'retry-of'), timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 62ddd27155a0..ba98b0a49699 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -28,6 +28,7 @@ import { import { isTuiAgent } from '../../shared/tui-agent-config' import { isWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' import { printLineageSummary } from './worktree-lineage-summary' +import { readClaudeLaunchAccountFlag } from './claude-launch-account-flag' import { assertWorkspaceTargetFlagsCompatible, hasWorkspaceProjectTarget, @@ -194,6 +195,7 @@ export const WORKTREE_HANDLERS: Record = { const explicitParentWorktree = explicitParent.parentWorktree const explicitParentWorkspace = explicitParent.parentWorkspace const startupAgent = getOptionalStartupAgent(flags) + const claudeAccount = await readClaudeLaunchAccountFlag(flags, client, startupAgent) const setupDecision = getOptionalSetupDecision(flags) const noParent = flags.get('no-parent') === true const envParentWorkspace = @@ -245,7 +247,8 @@ export const WORKTREE_HANDLERS: Record = { ...(startupAgent ? { startupAgent, - startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '' + startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '', + ...(claudeAccount ? { startupClaudeAccount: claudeAccount } : {}) } : {}) }) diff --git a/src/cli/index-worktree-create-claude-account.test.ts b/src/cli/index-worktree-create-claude-account.test.ts new file mode 100644 index 000000000000..ae5a6d166b65 --- /dev/null +++ b/src/cli/index-worktree-create-claude-account.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest' +import { CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY } from '../shared/protocol-version' + +const { + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) + +vi.mock('child_process', async () => { + const { createChildProcessModuleMock } = await import('./index-test-harness.js') + return createChildProcessModuleMock(spawnMock) +}) + +import { main } from './index' +import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' +import { useWorktreeAwarenessEnvironment } from './index-test-harness' + +describe('orca worktree create --account', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + const createArgs = ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'pinned', '--json'] + + it('sends the account selector to a host that advertises support', async () => { + queueFixtures( + callMock, + okFixture('req_status', { capabilities: [CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY] }), + worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]), + okFixture('req_create', { + worktree: buildWorktree('/tmp/repo/pinned', 'pinned', 'abc', 'repo-1') + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main([...createArgs, '--agent', 'claude', '--account', 'pinned@example.com'], '/tmp/repo') + + expect(callMock).toHaveBeenNthCalledWith(1, 'status.get') + expect(callMock).toHaveBeenNthCalledWith( + 3, + 'worktree.create', + expect.objectContaining({ + startupAgent: 'claude', + startupPrompt: '', + startupClaudeAccount: 'pinned@example.com' + }) + ) + }) + + it('refuses an older host instead of launching on its active account', async () => { + queueFixtures(callMock, okFixture('req_status', { capabilities: [] })) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main([...createArgs, '--agent', 'claude', '--account', 'acct-b'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(callMock).not.toHaveBeenCalledWith('worktree.create', expect.anything()) + }) + + it('requires --agent claude', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main([...createArgs, '--agent', 'codex', '--account', 'acct-b'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(callMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 98301b36e537..350bccafd241 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -90,7 +90,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', + 'orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--account ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'repo', @@ -99,6 +99,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'project-host-setup', 'name', 'agent', + 'account', 'prompt', 'base-branch', 'issue', @@ -121,6 +122,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ '--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.', 'By default this creates the worktree and its first terminal without switching the active Orca view.', 'Pass --agent to launch an agent in the first terminal; --prompt sends initial work to that agent.', + '--account (with --agent claude) starts Claude on that managed account from `orca account list` without switching the host account. Local host workspaces only.', 'With --agent --json, read the new agent handle from result.agentTerminalHandle; older runtimes return only result.startupTerminal.handle, and may return neither for folder-based repos.', 'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.', 'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.', diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 8c8e117f63b6..bb17417837e5 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -5,7 +5,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'worker-start'], summary: 'Start one supervised worker on the Run home or a connected Orca server', usage: - 'orca orchestration worker-start (--task | --spec ) [--on ] [--worktree ] (--agent | --terminal ) [--task-title ] [--deps ] [--parent ] [--model ] [--effort ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', + 'orca orchestration worker-start (--task | --spec ) [--on ] [--worktree ] (--agent | --terminal ) [--task-title ] [--deps ] [--parent ] [--model ] [--effort ] [--account ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'task', @@ -24,6 +24,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'agent', 'model', 'effort', + 'account', 'terminal', 'retry-of', 'timeout-ms', @@ -36,6 +37,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.', '--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, opencode, or opencode2.', '--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. Neither can combine with --terminal. Other agents, including opencode, launch with the model from their own config.', + '--account starts a Claude worker on that managed account from `orca account list` without switching the host account. Claude only, this host only (no --on), always a terminal worker, and not with --terminal.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', "How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.", From 36a8b8b29caf6b77b399ace5f213d841336daa3d Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 12:25:21 -0300 Subject: [PATCH 10/16] test(worktree): keep the --account mapping test out of the shared project --- .../rpc/methods/worktree-create-claude-account-args.test.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/{shared/rpc-contract/worktree-create-claude-account-param.test.ts => main/runtime/rpc/methods/worktree-create-claude-account-args.test.ts} (86%) diff --git a/src/shared/rpc-contract/worktree-create-claude-account-param.test.ts b/src/main/runtime/rpc/methods/worktree-create-claude-account-args.test.ts similarity index 86% rename from src/shared/rpc-contract/worktree-create-claude-account-param.test.ts rename to src/main/runtime/rpc/methods/worktree-create-claude-account-args.test.ts index 8d7c038b7b39..80dd74852f4a 100644 --- a/src/shared/rpc-contract/worktree-create-claude-account-param.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-claude-account-args.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { WorktreeCreate } from './worktree-create-params' -import { buildManagedWorktreeCreateArgs } from '../../main/runtime/rpc/methods/worktree-create-args' +import { WorktreeCreate } from '../../../../shared/rpc-contract/worktree-create-params' +import { buildManagedWorktreeCreateArgs } from './worktree-create-args' describe('worktree.create startupClaudeAccount', () => { it('requires a Claude startup agent', () => { From 05058eb1c33633b778d5c6b1797cbc2b910dd649 Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 13:48:28 -0300 Subject: [PATCH 11/16] fix(claude-accounts): claim accounts atomically against pinned launches Selection and removal checked for pinned terminals before the pinned launch's reservation existed, and a usage fetch could stage or refresh an account while a pinned launch seeded the same Keychain item. Host mutations, usage fetches and pinned reservations now claim an account with one synchronous check-and-set and hold it until they finish; a pinned launch waits out a usage fetch and refuses a switch or removal in progress. --- .../claude-account-selection.ts | 51 ++++++++-- ...-account-service-account-selection.test.ts | 66 +++++++++++++ .../claude-pinned-pty-registry.test.ts | 38 +++++++- .../claude-pinned-pty-registry.ts | 94 ++++++++++++++++++- ...runtime-auth-service-pinned-launch.test.ts | 32 +++++++ .../claude-accounts/runtime-auth-service.ts | 2 +- .../runtime-auth-pinned-launch.ts | 81 ++++++++++++---- ...aude-fetcher-managed-account-usage.test.ts | 39 +++++++- .../claude-managed-account-credentials.ts | 17 ++-- .../claude-managed-account-usage.ts | 21 ++++- .../rate-limits/claude-managed-usage-panel.ts | 7 +- 11 files changed, 397 insertions(+), 51 deletions(-) diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index 198a5215c6cf..8abf2a677044 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -6,7 +6,10 @@ import type { import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' -import { countClaudePinnedAccountUsers } from './claude-pinned-pty-registry' +import { + beginClaudeAccountHostMutation, + countClaudePinnedAccountUsers +} from './claude-pinned-pty-registry' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, @@ -34,7 +37,20 @@ export class ClaudeAccountSelection { async remove(accountId: string): Promise { const account = this.requireAccount(accountId) - assertNotPinnedByLiveTerminals(account, 'remove') + // Why held to the end: the managed dir and Keychain item are deleted last, outside the auth + // queue, and a pinned launch must not seed from them in between. + const endHostMutation = claimAccountForHostMutation(account, 'remove') + try { + return await this.removeClaimed(account) + } finally { + endHostMutation() + } + } + + private async removeClaimed( + account: ClaudeManagedAccount + ): Promise { + const accountId = account.id const settings = this.store.getSettings() const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) const nextSelection = removeClaudeAccountIdFromSelection( @@ -78,11 +94,25 @@ export class ClaudeAccountSelection { async select( accountId: string | null, target?: ClaudeAccountSelectionTarget + ): Promise { + const endHostMutation = + accountId === null + ? null + : claimAccountForHostMutation(this.requireAccount(accountId), 'select') + try { + return await this.selectClaimed(accountId, target) + } finally { + endHostMutation?.() + } + } + + private async selectClaimed( + accountId: string | null, + target?: ClaudeAccountSelectionTarget ): Promise { let effectiveTarget = target if (accountId !== null) { const account = this.requireAccount(accountId) - assertNotPinnedByLiveTerminals(account, 'select') const accountTarget = getClaudeSelectionTargetForAccount(account) const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) @@ -179,15 +209,18 @@ export class ClaudeAccountSelection { } // Why: selecting would materialize the account into ~/.claude and removing would delete the dir a -// pinned Claude runs from; either way one refresh token would end up in two places or none. -function assertNotPinnedByLiveTerminals( +// pinned Claude runs from; either way one refresh token would end up in two places or none. The +// claim is one synchronous check-and-set against pinned reservations, so a launch reserving the +// account concurrently is refused instead of racing past a check that already passed. +function claimAccountForHostMutation( account: ClaudeManagedAccount, action: 'select' | 'remove' -): void { - const users = countClaudePinnedAccountUsers(account.id) - if (users === 0) { - return +): () => void { + const release = beginClaudeAccountHostMutation(account.id) + if (release) { + return release } + const users = countClaudePinnedAccountUsers(account.id) const terminals = users === 1 ? '1 terminal' : `${users} terminals` throw new Error( `Claude account ${account.email} is in use by ${terminals} launched with --account. Close ${users === 1 ? 'it' : 'them'} before you ${action === 'select' ? 'switch to' : 'remove'} this account.` diff --git a/src/main/claude-accounts/claude-account-service-account-selection.test.ts b/src/main/claude-accounts/claude-account-service-account-selection.test.ts index f166f2b0c43c..74d8a658a2f7 100644 --- a/src/main/claude-accounts/claude-account-service-account-selection.test.ts +++ b/src/main/claude-accounts/claude-account-service-account-selection.test.ts @@ -549,4 +549,70 @@ describe('ClaudeAccountService credential capture', () => { registry._internals.reset() } }) + + it('holds the account against pinned launches for the whole switch and removal', async () => { + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const authPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') + mkdirSync(authPath, { recursive: true }) + writeFileSync(join(authPath, '.orca-managed-claude-auth'), 'account-2\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'account-2', + email: 'pinned@example.com', + managedAuthPath: authPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: null as string | null + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const registry = await import('./claude-pinned-pty-registry') + const reservationsDuringSync: (string | null)[] = [] + const runtimeAuth = { + // Why: this is where a queued pinned launch would run; it must find the account claimed. + syncForCurrentSelection: vi.fn(async () => { + const conflict = registry.reserveClaudePinnedAccount('account-2') + reservationsDuringSync.push(conflict) + if (conflict === null) { + registry.releaseClaudePinnedAccountReservation('account-2') + } + }), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + try { + await service.selectAccount('account-2') + await service.selectAccount(null) + await service.removeAccount('account-2') + + // A deselect never claims the account it is not switching to; select and remove do. + expect(reservationsDuringSync).toEqual(['host-mutation', null, 'host-mutation']) + expect(registry.reserveClaudePinnedAccount('account-2')).toBeNull() + } finally { + registry._internals.reset() + } + }) }) diff --git a/src/main/claude-accounts/claude-pinned-pty-registry.test.ts b/src/main/claude-accounts/claude-pinned-pty-registry.test.ts index 2ed6687290e5..7b836035bc05 100644 --- a/src/main/claude-accounts/claude-pinned-pty-registry.test.ts +++ b/src/main/claude-accounts/claude-pinned-pty-registry.test.ts @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { _internals, attachClaudePinnedPtyPersistence, + beginClaudeAccountHostMutation, + beginClaudeAccountUsageFetch, confirmSeededPinnedClaudePtys, countClaudePinnedAccountUsers, createClaudePinnedPtyFilePersistence, @@ -16,7 +18,8 @@ import { readClaudePinnedPtyRegistryFile, releaseClaudePinnedAccountReservation, reserveClaudePinnedAccount, - seedPinnedClaudePtysFromPersistence + seedPinnedClaudePtysFromPersistence, + whenClaudeAccountUsageFetchSettles } from './claude-pinned-pty-registry' describe('Claude pinned PTY registry', () => { @@ -27,7 +30,7 @@ describe('Claude pinned PTY registry', () => { it('counts reservations and live PTYs per account and drains once both are gone', () => { const drained = vi.fn() onClaudePinnedAccountDrained(drained) - reserveClaudePinnedAccount('acct-b') + expect(reserveClaudePinnedAccount('acct-b')).toBeNull() markPinnedClaudePtySpawned('pty-1', 'acct-b') expect(countClaudePinnedAccountUsers('acct-b')).toBe(2) expect(countClaudePinnedAccountUsers('acct-c')).toBe(0) @@ -101,4 +104,35 @@ describe('Claude pinned PTY registry', () => { expect(warn).toHaveBeenCalled() warn.mockRestore() }) + + it('makes a host mutation and a pinned reservation mutually exclusive', () => { + const endMutation = beginClaudeAccountHostMutation('acct-b') + expect(endMutation).not.toBeNull() + expect(reserveClaudePinnedAccount('acct-b')).toBe('host-mutation') + expect(countClaudePinnedAccountUsers('acct-b')).toBe(0) + endMutation?.() + endMutation?.() + + expect(reserveClaudePinnedAccount('acct-b')).toBeNull() + expect(beginClaudeAccountHostMutation('acct-b')).toBeNull() + expect(beginClaudeAccountHostMutation('acct-c')).not.toBeNull() + }) + + it('makes a usage fetch and a pinned reservation mutually exclusive and wakes waiters', async () => { + const endFetch = beginClaudeAccountUsageFetch('acct-b') + expect(endFetch).not.toBeNull() + expect(reserveClaudePinnedAccount('acct-b')).toBe('usage-fetch') + const settled = whenClaudeAccountUsageFetchSettles('acct-b', 5_000) + endFetch?.() + await expect(settled).resolves.toBe(true) + await expect(whenClaudeAccountUsageFetchSettles('acct-b', 1)).resolves.toBe(true) + + expect(reserveClaudePinnedAccount('acct-b')).toBeNull() + expect(beginClaudeAccountUsageFetch('acct-b')).toBeNull() + }) + + it('stops waiting for a usage fetch at the deadline', async () => { + beginClaudeAccountUsageFetch('acct-b') + await expect(whenClaudeAccountUsageFetchSettles('acct-b', 5)).resolves.toBe(false) + }) }) diff --git a/src/main/claude-accounts/claude-pinned-pty-registry.ts b/src/main/claude-accounts/claude-pinned-pty-registry.ts index e3daed0b230a..e5abb52135b4 100644 --- a/src/main/claude-accounts/claude-pinned-pty-registry.ts +++ b/src/main/claude-accounts/claude-pinned-pty-registry.ts @@ -18,6 +18,14 @@ const seededUnconfirmedPtyIds = new Set() // switch cannot land between the credential seed and the spawn. const reservationsByAccountId = new Map() const drainListeners = new Set<(accountId: string) => void>() +// Why: a host switch/removal and an Orca usage fetch (refresh or `claude` preview) each touch an +// account's credentials outside the pinned store; every claim below is a synchronous +// check-and-set against the reservations, so the two sides can never both believe they own it. +const hostMutationsByAccountId = new Map() +const usageFetchesByAccountId = new Map() +const usageFetchSettledListeners = new Map void>>() + +export type ClaudePinnedReservationConflict = 'host-mutation' | 'usage-fetch' export type ClaudePinnedPtyPersistence = { write(entries: Record): void @@ -54,9 +62,88 @@ export function hasLivePinnedClaudePtys(accountId: string): boolean { return false } -/** Held from auth preparation until the spawn settles; each reserve needs exactly one release. */ -export function reserveClaudePinnedAccount(accountId: string): void { +/** + * Held from auth preparation until the spawn settles; each successful reserve needs exactly one + * release. Refused, rather than counted, while a host mutation or usage fetch holds the account. + */ +export function reserveClaudePinnedAccount( + accountId: string +): ClaudePinnedReservationConflict | null { + if (hostMutationsByAccountId.has(accountId)) { + return 'host-mutation' + } + if (usageFetchesByAccountId.has(accountId)) { + return 'usage-fetch' + } reservationsByAccountId.set(accountId, (reservationsByAccountId.get(accountId) ?? 0) + 1) + return null +} + +/** Claims an account for a host switch or removal; null while pinned launches hold it. */ +export function beginClaudeAccountHostMutation(accountId: string): (() => void) | null { + return claimUnlessPinned(hostMutationsByAccountId, accountId) +} + +/** Claims an account for an Orca-side usage fetch; null while pinned launches hold it. */ +export function beginClaudeAccountUsageFetch(accountId: string): (() => void) | null { + const release = claimUnlessPinned(usageFetchesByAccountId, accountId) + if (!release) { + return null + } + return () => { + release() + if (!usageFetchesByAccountId.has(accountId)) { + for (const listener of usageFetchSettledListeners.get(accountId) ?? []) { + listener() + } + } + } +} + +/** Resolves true once no usage fetch holds the account, false at the deadline. */ +export function whenClaudeAccountUsageFetchSettles( + accountId: string, + timeoutMs: number +): Promise { + if (!usageFetchesByAccountId.has(accountId)) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + const listeners = usageFetchSettledListeners.get(accountId) ?? new Set<() => void>() + usageFetchSettledListeners.set(accountId, listeners) + const settle = (settled: boolean): void => { + listeners.delete(listener) + if (listeners.size === 0) { + usageFetchSettledListeners.delete(accountId) + } + clearTimeout(timer) + resolve(settled) + } + const listener = (): void => settle(true) + listeners.add(listener) + const timer = setTimeout(() => settle(false), timeoutMs) + timer.unref?.() + }) +} + +function claimUnlessPinned(claims: Map, accountId: string): (() => void) | null { + if (countClaudePinnedAccountUsers(accountId) > 0) { + return null + } + claims.set(accountId, (claims.get(accountId) ?? 0) + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (claims.get(accountId) ?? 1) - 1 + if (remaining > 0) { + claims.set(accountId, remaining) + } else { + claims.delete(accountId) + } + } } export function releaseClaudePinnedAccountReservation(accountId: string): void { @@ -172,6 +259,9 @@ export const _internals = { accountIdByPtyId.clear() seededUnconfirmedPtyIds.clear() reservationsByAccountId.clear() + hostMutationsByAccountId.clear() + usageFetchesByAccountId.clear() + usageFetchSettledListeners.clear() drainListeners.clear() persistence = null } diff --git a/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts index f21b519ccd7d..3b23283fcc91 100644 --- a/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts @@ -348,4 +348,36 @@ describe('ClaudeRuntimeAuthService pinned --account launches', () => { expect(readFileSync(fixture.runtimeCredentialsPath, 'utf-8')).toBe(refreshed) expect(keychain.scoped.has(fixture.pinnedDir)).toBe(false) }) + + it('refuses a pinned launch while the account is being switched to or removed', async () => { + const fixture = await setUpTwoAccounts() + const endMutation = fixture.registry.beginClaudeAccountHostMutation('acct-b') + + await expect( + fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + ).rejects.toThrow(/being switched to or removed/) + + expect(keychain.scoped.has(fixture.pinnedDir)).toBe(false) + expect(fixture.registry.countClaudePinnedAccountUsers('acct-b')).toBe(0) + endMutation?.() + }) + + it('waits out an in-flight usage fetch before seeding the pinned Keychain item', async () => { + const fixture = await setUpTwoAccounts() + const endFetch = fixture.registry.beginClaudeAccountUsageFetch('acct-b') + let settled = false + const prepared = fixture.service + .prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + .finally(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(settled).toBe(false) + expect(keychain.scoped.has(fixture.pinnedDir)).toBe(false) + + endFetch?.() + await expect(prepared).resolves.toMatchObject({ pinnedAccountId: 'acct-b' }) + expect(keychain.scoped.get(fixture.pinnedDir)).toBe(fixture.pinnedCredentials) + }) }) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 3d5a935c460f..5fb3e47750ff 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -116,7 +116,7 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { await this.doSyncForCurrentSelection(target) return this.getPreparation(target) } - return this.preparePinnedClaudeLaunch(account, target) + return this.preparePinnedClaudeLaunch(account.id, target) } private initializeLastSyncedState(): void { diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts index d016ba2e4a8f..bdcb57420ee1 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts @@ -3,7 +3,8 @@ import type { ClaudeManagedAccount } from '../../../shared/managed-account-types import { countClaudePinnedAccountUsers, releaseClaudePinnedAccountReservation, - reserveClaudePinnedAccount + reserveClaudePinnedAccount, + whenClaudeAccountUsageFetchSettles } from '../claude-pinned-pty-registry' import { clearPinnedClaudeKeychainCredentials, @@ -14,6 +15,7 @@ import { } from '../claude-pinned-credentials' import { prepareClaudePinnedConfigDir } from '../claude-pinned-config-dir' import { + getSelectedClaudeAccountIdForTarget, normalizeClaudeAccountSelectionTarget, type ClaudeAccountSelectionTarget } from '../runtime-selection' @@ -35,31 +37,48 @@ import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' * account while pinned (selection refuses), never refreshed by Orca while a pinned Claude may hold * it (the usage fetcher checks the registry), and read back only with identity proof. */ +// Why: a usage fetch is bounded by the hidden `claude` usage PTY's own 25s timeout; waiting a bit +// longer beats refusing a launch over background polling. It holds the auth queue meanwhile. +const PINNED_LAUNCH_USAGE_FETCH_WAIT_MS = 40_000 + export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationService { /** Caller holds the mutation queue. Reserves the account; the spawn releases the reservation. */ protected async preparePinnedClaudeLaunch( - account: ClaudeManagedAccount, + accountId: string, target: ClaudeAccountSelectionTarget ): Promise { if (normalizeClaudeAccountSelectionTarget(target).runtime !== 'host') { throw new Error('Claude --account launches are not supported for WSL terminals yet.') } - if (account.managedAuthRuntime === 'wsl') { - throw new Error( - `Claude account ${account.email} is a WSL account; --account supports host accounts only.` - ) - } - const configDir = await this.getOwnedManagedAuthPath(account) - if (!configDir) { - throw new Error( - `Orca cannot verify the saved sign-in for Claude account ${account.email}. Re-authenticate it in Settings > Accounts, then retry.` - ) - } - // Why before reserving: any other holder means a pinned Claude may already own the scoped - // item, and reseeding it would roll that session back to a spent refresh token. - const sharedWithLiveSession = countClaudePinnedAccountUsers(account.id) > 0 - reserveClaudePinnedAccount(account.id) + const sharedWithLiveSession = await this.reservePinnedClaudeAccount(accountId) try { + // Why re-read: a removal may have finished while this launch waited for a usage fetch. + const account = this.getActiveAccount( + this.store.getSettings().claudeManagedAccounts, + accountId + ) + if (!account) { + throw new Error('That Claude account no longer exists. Run `orca account list` and retry.') + } + if ( + getSelectedClaudeAccountIdForTarget(this.store.getSettings(), { runtime: 'host' }) === + accountId + ) { + // Why: a switch to it finished during the wait; pinning it now would put one refresh + // token in two stores, and the retry takes the normal path. + throw new Error('That Claude account just became the active account. Retry the launch.') + } + if (account.managedAuthRuntime === 'wsl') { + throw new Error( + `Claude account ${account.email} is a WSL account; --account supports host accounts only.` + ) + } + const configDir = await this.getOwnedManagedAuthPath(account) + if (!configDir) { + throw new Error( + `Orca cannot verify the saved sign-in for Claude account ${account.email}. Re-authenticate it in Settings > Accounts, then retry.` + ) + } if (process.platform === 'darwin' && !sharedWithLiveSession) { await this.reconcilePinnedKeychainCredentials(account, configDir, { strict: true }) } @@ -101,11 +120,37 @@ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationS provenance: `managed:${account.id}:pinned` } } catch (error) { - releaseClaudePinnedAccountReservation(account.id) + releaseClaudePinnedAccountReservation(accountId) throw error } } + /** + * Reserves the account, waiting out an Orca usage fetch that already holds it. Returns whether + * another pinned launch already held the account, i.e. whether its scoped item is live. + */ + private async reservePinnedClaudeAccount(accountId: string): Promise { + const deadline = Date.now() + PINNED_LAUNCH_USAGE_FETCH_WAIT_MS + while (true) { + // Why: any other holder may already own the scoped item, and reseeding it would roll that + // session back to a spent refresh token. + const sharedWithLiveSession = countClaudePinnedAccountUsers(accountId) > 0 + const conflict = reserveClaudePinnedAccount(accountId) + if (conflict === null) { + return sharedWithLiveSession + } + if (conflict === 'host-mutation') { + throw new Error( + 'That Claude account is being switched to or removed. Retry once that finishes.' + ) + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0 || !(await whenClaudeAccountUsageFetchSettles(accountId, remainingMs))) { + throw new Error('Orca is still checking usage for that Claude account. Retry in a moment.') + } + } + } + /** * Takes a seeded scoped Keychain item back into the managed store (macOS only). * diff --git a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts index 7e5db5fc2685..e3c9de64576e 100644 --- a/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts +++ b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts @@ -18,7 +18,9 @@ import { } from '../claude-accounts/keychain' import { _internals as pinnedRegistryInternals, - markPinnedClaudePtySpawned + markPinnedClaudePtySpawned, + releaseClaudePinnedAccountReservation, + reserveClaudePinnedAccount } from '../claude-accounts/claude-pinned-pty-registry' const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( @@ -488,4 +490,39 @@ describe('fetchClaudeRateLimits', () => { expect(writeManagedClaudeKeychainCredentials).not.toHaveBeenCalled() expect(fetchViaPty).not.toHaveBeenCalled() }) + + it('holds the account against pinned launches until its preview has finished', async () => { + setPlatform('linux') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync( + join(ownedAuthPath, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'token', expiresAt: Date.now() + 60_000 } }), + 'utf-8' + ) + let reservationDuringPreview: string | null = 'not-called' + vi.mocked(fetchViaPty).mockImplementationOnce(async () => { + reservationDuringPreview = reserveClaudePinnedAccount('account-1') + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: 1, + error: null, + status: 'ok' + } + }) + + await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(reservationDuringPreview).toBe('usage-fetch') + expect(reserveClaudePinnedAccount('account-1')).toBeNull() + releaseClaudePinnedAccountReservation('account-1') + }) }) diff --git a/src/main/rate-limits/claude-managed-account-credentials.ts b/src/main/rate-limits/claude-managed-account-credentials.ts index 867fdde3273b..47d20b3a001d 100644 --- a/src/main/rate-limits/claude-managed-account-credentials.ts +++ b/src/main/rate-limits/claude-managed-account-credentials.ts @@ -13,7 +13,7 @@ import { resolveOwnedClaudeManagedAuthPath, writeClaudeManagedAuthFile } from '../claude-accounts/managed-auth-path' -import { countClaudePinnedAccountUsers } from '../claude-accounts/claude-pinned-pty-registry' +import { beginClaudeAccountUsageFetch } from '../claude-accounts/claude-pinned-pty-registry' import { hasPendingPinnedClaudeSeed } from '../claude-accounts/claude-pinned-credentials' export type InactiveClaudeAccount = { @@ -99,9 +99,13 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccount): s } } -/** True while a `--account` launch owns this account's credentials (live, starting, or unread). */ -export function isClaudeAccountHeldByPinnedLaunch(accountId: string): boolean { - return countClaudePinnedAccountUsers(accountId) > 0 || hasPendingPinnedClaudeSeed(accountId) +/** + * Claims the account for one Orca usage fetch (token refresh and the `claude` usage preview), or + * returns null while a `--account` launch owns its credentials (live, starting, or unread). While + * the claim is held a pinned launch waits, so the two never refresh one account at once. + */ +export function beginClaudeManagedUsageFetch(accountId: string): (() => void) | null { + return hasPendingPinnedClaudeSeed(accountId) ? null : beginClaudeAccountUsageFetch(accountId) } export async function withClaudeManagedPreviewKeychainCredentials( @@ -116,10 +120,7 @@ export async function withClaudeManagedPreviewKeychainCredentials( try { return await operation() } finally { - // Why: a pinned launch that started mid-preview now runs from this same scoped item. - if (!isClaudeAccountHeldByPinnedLaunch(location.accountId)) { - await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) - } + await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) } } diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index d3eaaa552c71..699b805ff39e 100644 --- a/src/main/rate-limits/claude-managed-account-usage.ts +++ b/src/main/rate-limits/claude-managed-account-usage.ts @@ -4,7 +4,7 @@ import { refreshClaudeOauthCredentials } from '../claude-accounts/oauth-refresh' import { - isClaudeAccountHeldByPinnedLaunch, + beginClaudeManagedUsageFetch, readClaudeManagedCredentialsJson, readStagedClaudeManagedPreviewCredentials, resolveClaudeManagedCredentialsLocation, @@ -41,10 +41,23 @@ export async function fetchInactiveClaudeAccountUsage( if (options.signal?.aborted) { return abortedClaudeRateLimitResult() } - const location = resolveClaudeManagedCredentialsLocation(account) // Why: a `--account` Claude may be refreshing this account right now; a second refresh from - // here would replay its single-use refresh token and revoke the session. - const heldByPinnedLaunch = isClaudeAccountHeldByPinnedLaunch(account.id) + // here would replay its single-use refresh token and revoke the session. Held to the end so a + // pinned launch cannot start between this fetch's refresh or preview and its write-back. + const endUsageFetch = beginClaudeManagedUsageFetch(account.id) + try { + return await fetchClaimedInactiveClaudeAccountUsage(account, options, endUsageFetch === null) + } finally { + endUsageFetch?.() + } +} + +async function fetchClaimedInactiveClaudeAccountUsage( + account: InactiveClaudeAccount, + options: ClaudeManagedAccountUsageOptions, + heldByPinnedLaunch: boolean +): Promise { + const location = resolveClaudeManagedCredentialsLocation(account) let credentialsJson = location ? heldByPinnedLaunch ? await readPinnedLaunchCredentialsJson(location) diff --git a/src/main/rate-limits/claude-managed-usage-panel.ts b/src/main/rate-limits/claude-managed-usage-panel.ts index 25ba90707e42..15c1e90169c7 100644 --- a/src/main/rate-limits/claude-managed-usage-panel.ts +++ b/src/main/rate-limits/claude-managed-usage-panel.ts @@ -3,7 +3,6 @@ import type { NetworkProxySettings } from '../../shared/network-proxy' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { fetchViaPty } from './claude-pty' import { - isClaudeAccountHeldByPinnedLaunch, readStagedClaudeManagedPreviewCredentials, withClaudeManagedPreviewKeychainCredentials, writeClaudeManagedCredentialsJson, @@ -105,11 +104,7 @@ export async function fetchClaudeManagedUsagePanelSupplement(input: { return null } const refreshed = await readStagedClaudeManagedPreviewCredentials(input.location) - if ( - refreshed && - refreshed !== input.credentialsJson && - !isClaudeAccountHeldByPinnedLaunch(input.account.id) - ) { + if (refreshed && refreshed !== input.credentialsJson) { await writeClaudeManagedCredentialsJson(input.location, refreshed) } return cliLimits From b9de81a2730dd2593dec89d752c5fee932f27cbe Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 13:48:41 -0300 Subject: [PATCH 12/16] fix(cli): refuse worker-start --account with --terminal before calling the host --- .../orchestration-worker-account-cli.test.ts | 18 ++++++++++++++++++ .../orchestration/worker-launch-handler.ts | 14 +++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/cli/handlers/orchestration-worker-account-cli.test.ts b/src/cli/handlers/orchestration-worker-account-cli.test.ts index 43be9df099d9..faa1ae105b1e 100644 --- a/src/cli/handlers/orchestration-worker-account-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-account-cli.test.ts @@ -81,4 +81,22 @@ describe('orchestration worker-start --account', () => { expect(callMock).toHaveBeenCalledTimes(1) expect(callMock.mock.calls[0]?.[1]).not.toHaveProperty('account') }) + + it('refuses --account with --terminal before calling any host', async () => { + await expect( + invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['terminal', 'term_worker'], + ['account', 'acct-b'], + ['from', 'term_coord'] + ]) + ) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: + '--account cannot be combined with --terminal; the reused terminal keeps its own account.' + }) + expect(callMock).not.toHaveBeenCalled() + }) }) diff --git a/src/cli/handlers/orchestration/worker-launch-handler.ts b/src/cli/handlers/orchestration/worker-launch-handler.ts index a8542e33f8d9..0f59975afa45 100644 --- a/src/cli/handlers/orchestration/worker-launch-handler.ts +++ b/src/cli/handlers/orchestration/worker-launch-handler.ts @@ -29,9 +29,17 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record ) } } - const account = flags.has('terminal') - ? getOptionalStringFlag(flags, 'account') - : await readClaudeLaunchAccountFlag(flags, client, getOptionalStringFlag(flags, 'agent')) + if (flags.has('account') && flags.has('terminal')) { + throw new RuntimeClientError( + 'invalid_argument', + '--account cannot be combined with --terminal; the reused terminal keeps its own account.' + ) + } + const account = await readClaudeLaunchAccountFlag( + flags, + client, + getOptionalStringFlag(flags, 'agent') + ) const task = getOptionalStringFlag(flags, 'task') const spec = getOptionalStringFlag(flags, 'spec') const taskTitle = getOptionalStringFlag(flags, 'task-title') From 36f1060c3e3ee4dc835fcab83ed6a7b0343082f9 Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 13:48:45 -0300 Subject: [PATCH 13/16] fix(claude-accounts): revalidate startup pinned seed markers against owned paths --- .../claude-pinned-credentials.ts | 8 ++++++ ...runtime-auth-service-pinned-launch.test.ts | 24 +++++++++++++++- .../claude-accounts/runtime-auth-service.ts | 28 +++++++++++++++++++ .../startup/main-process-account-services.ts | 6 ++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/main/claude-accounts/claude-pinned-credentials.ts b/src/main/claude-accounts/claude-pinned-credentials.ts index 46f20c57d381..07eaf7267c98 100644 --- a/src/main/claude-accounts/claude-pinned-credentials.ts +++ b/src/main/claude-accounts/claude-pinned-credentials.ts @@ -53,6 +53,14 @@ export function notePinnedClaudeSeedMarker(accountId: string, configDir: string) } } +export function listPendingPinnedClaudeSeedAccountIds(): string[] { + return [...pendingSeedAccountIds] +} + +export function clearPendingPinnedClaudeSeed(accountId: string): void { + pendingSeedAccountIds.delete(accountId) +} + export async function seedPinnedClaudeKeychainCredentials(args: { accountId: string configDir: string diff --git a/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts index 3b23283fcc91..15d436ebbeca 100644 --- a/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts @@ -13,7 +13,7 @@ import { testState } from './runtime-auth-service-test-harness' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { join } from 'node:path' vi.mock('electron', () => createElectronMock()) @@ -380,4 +380,26 @@ describe('ClaudeRuntimeAuthService pinned --account launches', () => { await expect(prepared).resolves.toMatchObject({ pinnedAccountId: 'acct-b' }) expect(keychain.scoped.get(fixture.pinnedDir)).toBe(fixture.pinnedCredentials) }) + + it('drops startup seed flags it cannot prove and reads back provable ones', async () => { + const fixture = await setUpTwoAccounts() + await fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + fixture.registry._internals.reset() + const credentials = await import('./claude-pinned-credentials') + const refreshed = createClaudeCredentialsJson('pinned@example.com', 'pinned-rotated') + keychain.scoped.set(fixture.pinnedDir, refreshed) + // A flag the raw-path scan set for an account whose dir Orca does not own any more. + const strayDir = join(testState.userDataDir, 'stray') + mkdirSync(strayDir, { recursive: true }) + writeFileSync(join(strayDir, '.orca-pinned-keychain-seed'), 'hash\n') + credentials.notePinnedClaudeSeedMarker('acct-a', strayDir) + expect(credentials.hasPendingPinnedClaudeSeed('acct-a')).toBe(true) + + await fixture.service.revalidatePinnedSeedMarkers() + + expect(credentials.hasPendingPinnedClaudeSeed('acct-a')).toBe(false) + expect(credentials.hasPendingPinnedClaudeSeed('acct-b')).toBe(false) + expect(readManagedCredentialsForTest('acct-b', fixture.pinnedPath)).toBe(refreshed) + expect(existsSync(markerPath(fixture))).toBe(false) + }) }) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 5fb3e47750ff..5f0177656c16 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -5,6 +5,11 @@ import { type ClaudeAccountSelectionTarget } from './runtime-selection' import { countClaudePinnedAccountUsers } from './claude-pinned-pty-registry' +import { + clearPendingPinnedClaudeSeed, + listPendingPinnedClaudeSeedAccountIds, + readPinnedClaudeSeedMarker +} from './claude-pinned-credentials' import { ClaudeRuntimeAuthSync } from './runtime-auth/runtime-auth-sync' import type { ClaudeLaunchAuthOptions, @@ -119,6 +124,29 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { return this.preparePinnedClaudeLaunch(account.id, target) } + /** + * Startup pass over the seed markers found by the raw-path scan: drops flags whose dir is not + * Orca-owned or has no marker (they would suppress refresh until restart), and reads back + * crashed pinned sessions no surviving PTY still holds. + */ + async revalidatePinnedSeedMarkers(): Promise { + await this.serializeMutation(async () => { + const accounts = this.store.getSettings().claudeManagedAccounts + for (const accountId of listPendingPinnedClaudeSeedAccountIds()) { + const account = this.getActiveAccount(accounts, accountId) + const configDir = + account && account.managedAuthRuntime !== 'wsl' + ? await this.getOwnedManagedAuthPath(account) + : null + if (!account || !configDir || readPinnedClaudeSeedMarker(configDir) === null) { + clearPendingPinnedClaudeSeed(accountId) + } else if (countClaudePinnedAccountUsers(accountId) === 0) { + await this.reconcilePinnedKeychainCredentials(account, configDir, { strict: false }) + } + } + }) + } + private initializeLastSyncedState(): void { const settings = this.store.getSettings() this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 75296147114e..ddaa72635cd3 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -82,6 +82,12 @@ export function initializeMainProcessAccountServices(): void { } } state.claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) + // Why: the scan above reads raw paths synchronously; ownership is only provable asynchronously. + void state.claudeRuntimeAuth + .revalidatePinnedSeedMarkers() + .catch((error) => + console.warn('[claude-runtime-auth] Could not revalidate pinned Claude seed markers:', error) + ) onClaudePinnedAccountDrained((accountId) => { void state.claudeRuntimeAuth ?.reconcilePinnedAccountCredentials(accountId) From 9295b63702da2515807a38c0c48a40ecaaa3444f Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 14:02:04 -0300 Subject: [PATCH 14/16] fix(claude-accounts): wait for a pinned reservation outside the auth queue A pinned launch that met an in-flight usage fetch waited up to 40s inside the process-wide auth mutation queue, stalling every Claude launch and sync. The reservation is now taken (and waited for) before entering the queue; the queued part re-validates the account and the reservation is released on every failure. --- ...runtime-auth-service-pinned-launch.test.ts | 62 ++++++++ .../claude-accounts/runtime-auth-service.ts | 65 +++++--- .../runtime-auth-pinned-launch.ts | 150 ++++++++---------- 3 files changed, 177 insertions(+), 100 deletions(-) diff --git a/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts index 15d436ebbeca..b25da9fa48d2 100644 --- a/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts @@ -402,4 +402,66 @@ describe('ClaudeRuntimeAuthService pinned --account launches', () => { expect(readManagedCredentialsForTest('acct-b', fixture.pinnedPath)).toBe(refreshed) expect(existsSync(markerPath(fixture))).toBe(false) }) + + it('never holds the auth queue while a pinned launch waits out a usage fetch', async () => { + const fixture = await setUpTwoAccounts() + const endFetch = fixture.registry.beginClaudeAccountUsageFetch('acct-b') + const pinned = fixture.service.prepareForClaudeLaunch( + { runtime: 'host' }, + { accountId: 'acct-b' } + ) + const stalled = (ms: number) => + new Promise<'stalled'>((resolve) => setTimeout(() => resolve('stalled'), ms)) + + await expect( + Promise.race([fixture.service.prepareForClaudeLaunch({ runtime: 'host' }), stalled(2_000)]) + ).resolves.toMatchObject({ provenance: 'managed:acct-a' }) + await expect( + Promise.race([ + fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-a' }), + stalled(2_000) + ]) + ).resolves.toMatchObject({ provenance: 'managed:acct-a' }) + await expect( + Promise.race([fixture.service.syncForCurrentSelection(), stalled(2_000)]) + ).resolves.toBeUndefined() + + endFetch?.() + await expect(pinned).resolves.toMatchObject({ pinnedAccountId: 'acct-b' }) + }) + + it('releases the reservation when the wait for a usage fetch times out', async () => { + const fixture = await setUpTwoAccounts() + const endFetch = fixture.registry.beginClaudeAccountUsageFetch('acct-b') + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + try { + const pinned = fixture.service.prepareForClaudeLaunch( + { runtime: 'host' }, + { accountId: 'acct-b' } + ) + const rejection = expect(pinned).rejects.toThrow(/still checking usage/) + await vi.advanceTimersByTimeAsync(40_001) + await rejection + } finally { + vi.useRealTimers() + } + expect(fixture.registry.countClaudePinnedAccountUsers('acct-b')).toBe(0) + expect(keychain.scoped.has(fixture.pinnedDir)).toBe(false) + endFetch?.() + }) + + it('releases the reservation when the queued part refuses the launch', async () => { + const fixture = await setUpTwoAccounts() + const endFetch = fixture.registry.beginClaudeAccountUsageFetch('acct-b') + const pinned = fixture.service.prepareForClaudeLaunch( + { runtime: 'host' }, + { accountId: 'acct-b' } + ) + // A switch to the account completes while the launch waits. + fixture.store.updateSettings({ activeClaudeManagedAccountId: 'acct-b' }) + endFetch?.() + + await expect(pinned).rejects.toThrow(/just became the active account/) + expect(fixture.registry.countClaudePinnedAccountUsers('acct-b')).toBe(0) + }) }) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 5f0177656c16..31ca1acfd88b 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -4,7 +4,10 @@ import { normalizeClaudeAccountSelectionTarget, type ClaudeAccountSelectionTarget } from './runtime-selection' -import { countClaudePinnedAccountUsers } from './claude-pinned-pty-registry' +import { + countClaudePinnedAccountUsers, + releaseClaudePinnedAccountReservation +} from './claude-pinned-pty-registry' import { clearPendingPinnedClaudeSeed, listPendingPinnedClaudeSeedAccountIds, @@ -35,9 +38,7 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() const accountId = options?.accountId if (accountId) { - return this.serializeMutation(() => - this.prepareRequestedAccountLaunch(accountId, effectiveTarget) - ) + return this.prepareRequestedAccountLaunch(accountId, effectiveTarget) } await this.syncForCurrentSelection(effectiveTarget) return this.getPreparation(effectiveTarget) @@ -101,27 +102,53 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { return this.getPreparation(target).configDir } - // Why one mutation: selection updates settings before it queues its sync, so reading the active - // account here, in the queue, can never pin an account a concurrent switch just activated. + /** + * An `--account` launch. The pinned reservation (and any wait for a usage fetch holding the + * account) happens before the auth queue, so a background fetch never stalls every other launch + * and sync; the queued part then re-validates against settings that no switch can race, since + * the claims make a host mutation and a reservation mutually exclusive. + */ private async prepareRequestedAccountLaunch( accountId: string, target: ClaudeAccountSelectionTarget ): Promise { - const settings = this.store.getSettings() - const account = this.getActiveAccount(settings.claudeManagedAccounts, accountId) - if (!account) { - throw new Error('That Claude account no longer exists. Run `orca account list` and retry.') + this.requireClaudeAccountForLaunch(accountId) + if (this.isActiveHostAccountLaunch(accountId, target)) { + return this.serializeMutation(async () => { + if (!this.isActiveHostAccountLaunch(accountId, target)) { + throw new Error('The active Claude account changed during this launch. Retry the launch.') + } + // Why: the active account already owns ~/.claude; pinning it too would put one refresh + // token in two stores. + await this.doSyncForCurrentSelection(target) + return this.getPreparation(target) + }) } - if ( - normalizeClaudeAccountSelectionTarget(target).runtime === 'host' && - getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) === accountId - ) { - // Why: the active account already owns ~/.claude; pinning it too would put one refresh - // token in two stores. - await this.doSyncForCurrentSelection(target) - return this.getPreparation(target) + if (normalizeClaudeAccountSelectionTarget(target).runtime !== 'host') { + throw new Error('Claude --account launches are not supported for WSL terminals yet.') + } + const sharedWithLiveSession = await this.reservePinnedClaudeAccount(accountId) + // Why no host-mutation re-check in the queue: while the reservation is held, no switch or + // removal can claim the account, so only settings changes made during the wait remain. + try { + return await this.serializeMutation(() => + this.preparePinnedClaudeLaunch(accountId, sharedWithLiveSession) + ) + } catch (error) { + releaseClaudePinnedAccountReservation(accountId) + throw error } - return this.preparePinnedClaudeLaunch(account.id, target) + } + + private isActiveHostAccountLaunch( + accountId: string, + target: ClaudeAccountSelectionTarget + ): boolean { + return ( + normalizeClaudeAccountSelectionTarget(target).runtime === 'host' && + getSelectedClaudeAccountIdForTarget(this.store.getSettings(), { runtime: 'host' }) === + accountId + ) } /** diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts index bdcb57420ee1..c3b5cccb6d28 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts @@ -2,7 +2,6 @@ import { join } from 'node:path' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import { countClaudePinnedAccountUsers, - releaseClaudePinnedAccountReservation, reserveClaudePinnedAccount, whenClaudeAccountUsageFetchSettles } from '../claude-pinned-pty-registry' @@ -14,11 +13,7 @@ import { seedPinnedClaudeKeychainCredentials } from '../claude-pinned-credentials' import { prepareClaudePinnedConfigDir } from '../claude-pinned-config-dir' -import { - getSelectedClaudeAccountIdForTarget, - normalizeClaudeAccountSelectionTarget, - type ClaudeAccountSelectionTarget -} from '../runtime-selection' +import { getSelectedClaudeAccountIdForTarget } from '../runtime-selection' import { ClaudeRuntimeAuthPreparationService } from './runtime-auth-preparation' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' @@ -38,98 +33,91 @@ import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' * it (the usage fetcher checks the registry), and read back only with identity proof. */ // Why: a usage fetch is bounded by the hidden `claude` usage PTY's own 25s timeout; waiting a bit -// longer beats refusing a launch over background polling. It holds the auth queue meanwhile. +// longer beats refusing a launch over background polling. The wait happens outside the auth queue. const PINNED_LAUNCH_USAGE_FETCH_WAIT_MS = 40_000 export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationService { - /** Caller holds the mutation queue. Reserves the account; the spawn releases the reservation. */ + /** + * Caller holds the mutation queue AND a pinned reservation, which the spawn releases (the caller + * releases it if this throws). Re-validates what only the queue can see consistently. + */ protected async preparePinnedClaudeLaunch( accountId: string, - target: ClaudeAccountSelectionTarget + sharedWithLiveSession: boolean ): Promise { - if (normalizeClaudeAccountSelectionTarget(target).runtime !== 'host') { - throw new Error('Claude --account launches are not supported for WSL terminals yet.') + // Why re-read: a removal may have finished while this launch waited for a usage fetch. + const account = this.requireClaudeAccountForLaunch(accountId) + if ( + getSelectedClaudeAccountIdForTarget(this.store.getSettings(), { runtime: 'host' }) === + accountId + ) { + // Why: a switch to it finished during the wait; pinning it now would put one refresh + // token in two stores, and the retry takes the normal path. + throw new Error('That Claude account just became the active account. Retry the launch.') } - const sharedWithLiveSession = await this.reservePinnedClaudeAccount(accountId) - try { - // Why re-read: a removal may have finished while this launch waited for a usage fetch. - const account = this.getActiveAccount( - this.store.getSettings().claudeManagedAccounts, - accountId + if (account.managedAuthRuntime === 'wsl') { + throw new Error( + `Claude account ${account.email} is a WSL account; --account supports host accounts only.` ) - if (!account) { - throw new Error('That Claude account no longer exists. Run `orca account list` and retry.') - } - if ( - getSelectedClaudeAccountIdForTarget(this.store.getSettings(), { runtime: 'host' }) === - accountId - ) { - // Why: a switch to it finished during the wait; pinning it now would put one refresh - // token in two stores, and the retry takes the normal path. - throw new Error('That Claude account just became the active account. Retry the launch.') - } - if (account.managedAuthRuntime === 'wsl') { - throw new Error( - `Claude account ${account.email} is a WSL account; --account supports host accounts only.` - ) - } - const configDir = await this.getOwnedManagedAuthPath(account) - if (!configDir) { - throw new Error( - `Orca cannot verify the saved sign-in for Claude account ${account.email}. Re-authenticate it in Settings > Accounts, then retry.` - ) - } - if (process.platform === 'darwin' && !sharedWithLiveSession) { - await this.reconcilePinnedKeychainCredentials(account, configDir, { strict: true }) - } - const credentialsJson = await this.readManagedCredentials(account) - if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { - throw new Error( - `Claude account ${account.email} has no valid saved sign-in. Re-authenticate it in Settings > Accounts, then retry.` - ) - } - if (process.platform === 'darwin' && !sharedWithLiveSession) { - await seedPinnedClaudeKeychainCredentials({ - accountId: account.id, - configDir, - credentialsJson - }) - } - const hostPaths = this.pathResolver.getRuntimePaths() - try { - prepareClaudePinnedConfigDir({ - configDir, - source: { hostConfigDir: hostPaths.configDir, hostConfigPath: hostPaths.configPath }, - oauthAccount: await this.readManagedOauthAccount(account) - }) - } catch (error) { - // Why: the mirror only spares prompts; a launch that still authenticates is better than none. - console.warn( - '[claude-runtime-auth] Could not mirror host config for a pinned launch:', - error - ) - } - return { + } + const configDir = await this.getOwnedManagedAuthPath(account) + if (!configDir) { + throw new Error( + `Orca cannot verify the saved sign-in for Claude account ${account.email}. Re-authenticate it in Settings > Accounts, then retry.` + ) + } + if (process.platform === 'darwin' && !sharedWithLiveSession) { + await this.reconcilePinnedKeychainCredentials(account, configDir, { strict: true }) + } + const credentialsJson = await this.readManagedCredentials(account) + if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { + throw new Error( + `Claude account ${account.email} has no valid saved sign-in. Re-authenticate it in Settings > Accounts, then retry.` + ) + } + if (process.platform === 'darwin' && !sharedWithLiveSession) { + await seedPinnedClaudeKeychainCredentials({ + accountId: account.id, configDir, - runtime: 'host', - wslDistro: null, - wslLinuxConfigDir: null, - envPatch: { CLAUDE_CONFIG_DIR: configDir, CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir }, - stripAuthEnv: true, - pinnedAccountId: account.id, - provenance: `managed:${account.id}:pinned` - } + credentialsJson + }) + } + const hostPaths = this.pathResolver.getRuntimePaths() + try { + prepareClaudePinnedConfigDir({ + configDir, + source: { hostConfigDir: hostPaths.configDir, hostConfigPath: hostPaths.configPath }, + oauthAccount: await this.readManagedOauthAccount(account) + }) } catch (error) { - releaseClaudePinnedAccountReservation(accountId) - throw error + // Why: the mirror only spares prompts; a launch that still authenticates is better than none. + console.warn('[claude-runtime-auth] Could not mirror host config for a pinned launch:', error) + } + return { + configDir, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { CLAUDE_CONFIG_DIR: configDir, CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir }, + stripAuthEnv: true, + pinnedAccountId: account.id, + provenance: `managed:${account.id}:pinned` + } + } + + protected requireClaudeAccountForLaunch(accountId: string): ClaudeManagedAccount { + const account = this.getActiveAccount(this.store.getSettings().claudeManagedAccounts, accountId) + if (!account) { + throw new Error('That Claude account no longer exists. Run `orca account list` and retry.') } + return account } /** * Reserves the account, waiting out an Orca usage fetch that already holds it. Returns whether * another pinned launch already held the account, i.e. whether its scoped item is live. */ - private async reservePinnedClaudeAccount(accountId: string): Promise { + protected async reservePinnedClaudeAccount(accountId: string): Promise { const deadline = Date.now() + PINNED_LAUNCH_USAGE_FETCH_WAIT_MS while (true) { // Why: any other holder may already own the scoped item, and reseeding it would roll that From 629da63483adc86b8b4a7a623c71afc8d7459f1c Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 14:02:09 -0300 Subject: [PATCH 15/16] fix(claude-accounts): claim a pinned account before re-authentication writes Re-auth replaced an account's managed credentials without the host-mutation claim, so it could overwrite the store a pinned --account Claude refreshes. It now refuses up front while pinned terminals hold the account and claims the account from the credential write through rollback. --- .../claude-account-host-mutation.ts | 54 ++++++ .../claude-account-registration.ts | 11 ++ .../claude-account-selection.ts | 28 +--- ...aude-account-service-reauth-pinned.test.ts | 156 ++++++++++++++++++ 4 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 src/main/claude-accounts/claude-account-host-mutation.ts create mode 100644 src/main/claude-accounts/claude-account-service-reauth-pinned.test.ts diff --git a/src/main/claude-accounts/claude-account-host-mutation.ts b/src/main/claude-accounts/claude-account-host-mutation.ts new file mode 100644 index 000000000000..7a4e409e75ae --- /dev/null +++ b/src/main/claude-accounts/claude-account-host-mutation.ts @@ -0,0 +1,54 @@ +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { + beginClaudeAccountHostMutation, + countClaudePinnedAccountUsers +} from './claude-pinned-pty-registry' + +export type ClaudeAccountHostMutation = 'select' | 'remove' | 'reauthenticate' + +const ACTION_PHRASES: Record = { + select: 'switch to', + remove: 'remove', + reauthenticate: 're-authenticate' +} + +/** + * Claims a managed account for a change to its host-side credentials or auth dir. + * + * Selecting materializes it into ~/.claude, removing deletes the dir a pinned Claude runs from, + * and re-authenticating replaces the credential a pinned Claude refreshes; each would leave one + * single-use refresh token in two places or none. The claim is one synchronous check-and-set + * against pinned reservations, so a launch reserving concurrently is refused instead of racing + * past a check that already passed. Hold it until every write is done. + */ +export function claimClaudeAccountForHostMutation( + account: ClaudeManagedAccount, + action: ClaudeAccountHostMutation +): () => void { + const release = beginClaudeAccountHostMutation(account.id) + if (!release) { + throwPinnedAccountInUse(account, action) + } + return release +} + +/** A non-claiming early check, for failing before a slow step such as a browser login. */ +export function assertClaudeAccountNotPinned( + account: ClaudeManagedAccount, + action: ClaudeAccountHostMutation +): void { + if (countClaudePinnedAccountUsers(account.id) > 0) { + throwPinnedAccountInUse(account, action) + } +} + +function throwPinnedAccountInUse( + account: ClaudeManagedAccount, + action: ClaudeAccountHostMutation +): never { + const users = countClaudePinnedAccountUsers(account.id) + const terminals = users === 1 ? '1 terminal' : `${users} terminals` + throw new Error( + `Claude account ${account.email} is in use by ${terminals} launched with --account. Close ${users === 1 ? 'it' : 'them'} before you ${ACTION_PHRASES[action]} this account.` + ) +} diff --git a/src/main/claude-accounts/claude-account-registration.ts b/src/main/claude-accounts/claude-account-registration.ts index bebc26be6804..0bd88f7452d8 100644 --- a/src/main/claude-accounts/claude-account-registration.ts +++ b/src/main/claude-accounts/claude-account-registration.ts @@ -18,6 +18,10 @@ import { normalizeClaudeRuntimeSelection } from './runtime-selection' import type { ClaudeAccountSelection } from './claude-account-selection' +import { + assertClaudeAccountNotPinned, + claimClaudeAccountForHostMutation +} from './claude-account-host-mutation' type ClaudeAccountRegistrationDependencies = { store: Store @@ -90,6 +94,8 @@ export class ClaudeAccountRegistration { async reauthenticate(accountId: string): Promise { const account = this.dependencies.selection.requireAccount(accountId) + // Why: fail before opening a browser login the write could never be allowed to apply. + assertClaudeAccountNotPinned(account, 'reauthenticate') const managedAuthPath = await this.dependencies.assertManagedAuth( account.managedAuthPath, accountId @@ -121,6 +127,9 @@ export class ClaudeAccountRegistration { : entry ) let wroteCredentials = false + // Why claimed only now: the login ran in a temp dir; from here until the rollback finishes, + // a pinned launch must not seed from, or run on, the credential being replaced. + const endHostMutation = claimClaudeAccountForHostMutation(account, 'reauthenticate') try { await this.dependencies.writeOauth(accountId, managedAuthPath, captured.oauthAccount) await this.dependencies.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) @@ -142,6 +151,8 @@ export class ClaudeAccountRegistration { wroteCredentials ) throw error + } finally { + endHostMutation() } } diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index 8abf2a677044..6f6baccd345f 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -6,10 +6,7 @@ import type { import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' -import { - beginClaudeAccountHostMutation, - countClaudePinnedAccountUsers -} from './claude-pinned-pty-registry' +import { claimClaudeAccountForHostMutation } from './claude-account-host-mutation' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, @@ -39,7 +36,7 @@ export class ClaudeAccountSelection { const account = this.requireAccount(accountId) // Why held to the end: the managed dir and Keychain item are deleted last, outside the auth // queue, and a pinned launch must not seed from them in between. - const endHostMutation = claimAccountForHostMutation(account, 'remove') + const endHostMutation = claimClaudeAccountForHostMutation(account, 'remove') try { return await this.removeClaimed(account) } finally { @@ -98,7 +95,7 @@ export class ClaudeAccountSelection { const endHostMutation = accountId === null ? null - : claimAccountForHostMutation(this.requireAccount(accountId), 'select') + : claimClaudeAccountForHostMutation(this.requireAccount(accountId), 'select') try { return await this.selectClaimed(accountId, target) } finally { @@ -208,25 +205,6 @@ export class ClaudeAccountSelection { } } -// Why: selecting would materialize the account into ~/.claude and removing would delete the dir a -// pinned Claude runs from; either way one refresh token would end up in two places or none. The -// claim is one synchronous check-and-set against pinned reservations, so a launch reserving the -// account concurrently is refused instead of racing past a check that already passed. -function claimAccountForHostMutation( - account: ClaudeManagedAccount, - action: 'select' | 'remove' -): () => void { - const release = beginClaudeAccountHostMutation(account.id) - if (release) { - return release - } - const users = countClaudePinnedAccountUsers(account.id) - const terminals = users === 1 ? '1 terminal' : `${users} terminals` - throw new Error( - `Claude account ${account.email} is in use by ${terminals} launched with --account. Close ${users === 1 ? 'it' : 'them'} before you ${action === 'select' ? 'switch to' : 'remove'} this account.` - ) -} - function toClaudeAccountSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { return { id: account.id, diff --git a/src/main/claude-accounts/claude-account-service-reauth-pinned.test.ts b/src/main/claude-accounts/claude-account-service-reauth-pinned.test.ts new file mode 100644 index 000000000000..702e734dfdeb --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-reauth-pinned.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + resetClaudeKeychainMocks, + restorePlatform, + setPlatform +} from './claude-account-service-test-harness' + +const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-reauth-pinned-test') + +vi.mock('electron', () => ({ + app: { + getPath: () => CLAUDE_SERVICE_TEST_ROOT + } +})) + +const commandMocks = vi.hoisted(() => ({ + resolveClaudeCommand: vi.fn(() => 'claude') +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: commandMocks.resolveClaudeCommand +})) + +vi.mock('./keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(async () => {}), + deleteManagedClaudeKeychainCredentials: vi.fn(async () => {}), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(async () => {}), + writeManagedClaudeKeychainCredentials: vi.fn(async () => {}) +})) + +import * as registry from './claude-pinned-pty-registry' + +type LoginCapture = { + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } +} + +async function setUp(syncForCurrentSelection: () => Promise = async () => {}) { + rmSync(CLAUDE_SERVICE_TEST_ROOT, { recursive: true, force: true }) + const managedAuthPath = join(CLAUDE_SERVICE_TEST_ROOT, 'claude-accounts', 'account-1', 'auth') + mkdirSync(managedAuthPath, { recursive: true }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + writeFileSync(join(managedAuthPath, '.credentials.json'), '{"old":true}\n', 'utf-8') + writeFileSync(join(managedAuthPath, 'oauth-account.json'), '{"oldOauth":true}\n', 'utf-8') + let settings = { + claudeManagedAccounts: [ + { + id: 'account-1', + email: 'pinned@example.com', + managedAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: null + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}), + syncForCurrentSelection: vi.fn(syncForCurrentSelection) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => {}) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + const login = vi.fn(async (): Promise => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'pinned@example.com', organizationUuid: null, organizationName: null } + })) + ;(service as unknown as { runClaudeLoginAndCapture: typeof login }).runClaudeLoginAndCapture = + login + const readCredentials = (): string => + readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8') + return { service, login, readCredentials } +} + +describe('ClaudeAccountService re-auth of a pinned account', () => { + beforeEach(() => { + setPlatform('linux') + resetClaudeKeychainMocks() + }) + + afterEach(() => { + registry._internals.reset() + restorePlatform() + rmSync(CLAUDE_SERVICE_TEST_ROOT, { recursive: true, force: true }) + }) + + it('refuses before opening a login while an --account terminal holds the account', async () => { + const { service, login, readCredentials } = await setUp() + registry.markPinnedClaudePtySpawned('pinned-pty', 'account-1') + + await expect(service.reauthenticateAccount('account-1')).rejects.toThrow( + 'in use by 1 terminal launched with --account. Close it before you re-authenticate' + ) + + expect(login).not.toHaveBeenCalled() + expect(readCredentials()).toBe('{"old":true}\n') + }) + + it('discards the login when a pinned launch reserved the account meanwhile', async () => { + const { service, login, readCredentials } = await setUp() + login.mockImplementationOnce(async () => { + expect(registry.reserveClaudePinnedAccount('account-1')).toBeNull() + return { + credentialsJson: '{"new":true}\n', + oauthAccount: { newOauth: true }, + identity: { email: 'pinned@example.com', organizationUuid: null, organizationName: null } + } + }) + + await expect(service.reauthenticateAccount('account-1')).rejects.toThrow( + /in use by 1 terminal launched with --account/ + ) + + expect(readCredentials()).toBe('{"old":true}\n') + }) + + it('holds the account against pinned launches from the write until it finishes', async () => { + const reservationsDuringSync: (string | null)[] = [] + const { service, readCredentials } = await setUp(async () => { + reservationsDuringSync.push(registry.reserveClaudePinnedAccount('account-1')) + }) + + await service.reauthenticateAccount('account-1') + + expect(readCredentials()).toBe('{"new":true}\n') + expect(reservationsDuringSync).toEqual(['host-mutation']) + expect(registry.reserveClaudePinnedAccount('account-1')).toBeNull() + }) +}) From 87c9fe4acbedd52dbe4bcbd64f293dbe67a2f42e Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 14:18:13 -0300 Subject: [PATCH 16/16] fix(claude-accounts): refuse to pin an account old host Claude terminals still run on A host Claude keeps the account it started on and refreshes it through ~/.claude after the host switches away, so pinning that account with --account gave one single-use refresh chain two owners. Unpinned host Claude PTYs and structured children now record the account they started on (from the prepared auth provenance, else the host selection), restored across restarts, and a pinned reservation refuses while any of them is still live. --- .../claude-host-pty-accounts.test.ts | 115 ++++++++++++++ .../claude-host-pty-accounts.ts | 148 ++++++++++++++++++ .../claude-pinned-pty-registry.ts | 9 +- src/main/claude-accounts/live-pty-gate.ts | 20 ++- ...runtime-auth-service-pinned-launch.test.ts | 30 ++++ .../runtime-auth-pinned-launch.ts | 8 + .../ipc/pty-spawn-env-terminal-basics.test.ts | 3 + src/main/ipc/pty/ipc/spawn-commit.ts | 2 +- .../ipc/pty/runtime/spawn-claude-account.ts | 2 +- .../startup/main-process-ready-foundation.ts | 20 +++ 10 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 src/main/claude-accounts/claude-host-pty-accounts.test.ts create mode 100644 src/main/claude-accounts/claude-host-pty-accounts.ts diff --git a/src/main/claude-accounts/claude-host-pty-accounts.test.ts b/src/main/claude-accounts/claude-host-pty-accounts.test.ts new file mode 100644 index 000000000000..cb3cbf93f153 --- /dev/null +++ b/src/main/claude-accounts/claude-host-pty-accounts.test.ts @@ -0,0 +1,115 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + _internals, + attachClaudeHostPtyAccountPersistence, + countHostClaudePtysForAccount, + createClaudeHostPtyAccountFilePersistence, + forgetHostClaudePtyAccount, + hostClaudeAccountIdFromProvenance, + readClaudeHostPtyAccountsFile, + recordHostClaudePtyAccount, + seedHostClaudePtyAccounts, + setClaudeActiveHostAccountResolver +} from './claude-host-pty-accounts' +import { + hasLiveClaudePtys, + markClaudePtyExited, + markClaudePtySpawned, + markClaudeStructuredChildExited, + markClaudeStructuredChildSpawned, + seedLiveClaudePtysFromPersistence, + confirmSeededClaudeLivePtys +} from './live-pty-gate' +import { + _internals as pinnedRegistryReset, + releaseClaudePinnedAccountReservation, + reserveClaudePinnedAccount +} from './claude-pinned-pty-registry' + +describe('host Claude PTY account attribution', () => { + afterEach(() => { + _internals.reset() + pinnedRegistryReset.reset() + }) + + it('reads the host account only from an unsuffixed managed provenance', () => { + expect(hostClaudeAccountIdFromProvenance('managed:acct-x')).toBe('acct-x') + expect(hostClaudeAccountIdFromProvenance('managed:acct-x:pinned')).toBeNull() + expect(hostClaudeAccountIdFromProvenance('managed:acct-x:wsl:Ubuntu')).toBeNull() + expect(hostClaudeAccountIdFromProvenance('system')).toBeNull() + }) + + it('blocks a pinned launch of X while a host Claude started on X runs, and only X', () => { + markClaudePtySpawned('old-x', 'managed:acct-x') + markClaudePtySpawned('other-y', 'managed:acct-y') + markClaudePtySpawned('system', 'system') + + expect(hasLiveClaudePtys()).toBe(true) + expect(reserveClaudePinnedAccount('acct-x')).toBe('host-sessions') + expect(reserveClaudePinnedAccount('acct-z')).toBeNull() + releaseClaudePinnedAccountReservation('acct-z') + + markClaudePtyExited('old-x') + expect(countHostClaudePtysForAccount('acct-x')).toBe(0) + expect(reserveClaudePinnedAccount('acct-x')).toBeNull() + releaseClaudePinnedAccountReservation('acct-x') + markClaudePtyExited('other-y') + markClaudePtyExited('system') + }) + + it('attributes launches without provenance and structured children to the selected account', () => { + setClaudeActiveHostAccountResolver(() => 'acct-x') + markClaudePtySpawned('no-provenance') + markClaudeStructuredChildSpawned('child-1') + expect(countHostClaudePtysForAccount('acct-x')).toBe(2) + + markClaudeStructuredChildExited('child-1') + markClaudePtyExited('no-provenance') + expect(countHostClaudePtysForAccount('acct-x')).toBe(0) + }) + + it('restores recorded accounts, falls back to the selected one, and forgets dead seeds', () => { + setClaudeActiveHostAccountResolver(() => 'acct-now') + seedLiveClaudePtysFromPersistence(['recorded', 'legacy', 'dead']) + seedHostClaudePtyAccounts(['recorded', 'legacy', 'dead'], { + recorded: 'acct-x', + dead: 'acct-x' + }) + expect(countHostClaudePtysForAccount('acct-x')).toBe(2) + expect(countHostClaudePtysForAccount('acct-now')).toBe(1) + + confirmSeededClaudeLivePtys(['recorded', 'legacy']) + + expect(countHostClaudePtysForAccount('acct-x')).toBe(1) + markClaudePtyExited('recorded') + markClaudePtyExited('legacy') + }) + + it('persists terminal PTYs but never structured children', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-claude-host-pty-')) + try { + const filePath = join(dir, 'claude-host-pane-accounts.json') + attachClaudeHostPtyAccountPersistence(createClaudeHostPtyAccountFilePersistence(filePath)) + recordHostClaudePtyAccount('pty-1', 'acct-x', { persist: true }) + recordHostClaudePtyAccount('pty-2', null, { persist: true }) + recordHostClaudePtyAccount('claude-structured:1', 'acct-x', { persist: false }) + + expect(readClaudeHostPtyAccountsFile(filePath)).toEqual({ 'pty-1': 'acct-x', 'pty-2': null }) + + forgetHostClaudePtyAccount('pty-1') + expect(JSON.parse(readFileSync(filePath, 'utf-8'))).toEqual({ + version: 1, + panes: { 'pty-2': null } + }) + writeFileSync(filePath, '{"panes":{"a":7,"b":"acct-y"}}') + expect(readClaudeHostPtyAccountsFile(filePath)).toEqual({ b: 'acct-y' }) + writeFileSync(filePath, 'garbage') + expect(readClaudeHostPtyAccountsFile(filePath)).toEqual({}) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/claude-accounts/claude-host-pty-accounts.ts b/src/main/claude-accounts/claude-host-pty-accounts.ts new file mode 100644 index 000000000000..07b64f28e8a8 --- /dev/null +++ b/src/main/claude-accounts/claude-host-pty-accounts.ts @@ -0,0 +1,148 @@ +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' + +/** + * Which managed account each ordinary (unpinned) host Claude started on. + * + * Such a Claude keeps that account's token in memory and refreshes it through ~/.claude even + * after the host switches to another account. Pinning the old account with `--account` while it + * runs would give one single-use refresh chain two owners, so pinned reservations consult this. + * `null` means the system default (no managed account), which a pinned launch never shares. + */ +const hostAccountIdByPtyId = new Map() +// Why: structured children die with this process, so persisting them would only leave stale ids. +const unpersistedPtyIds = new Set() +let resolveActiveHostAccountId: () => string | null = () => null + +export type ClaudeHostPtyAccountPersistence = { + write(entries: Record): void +} + +let persistence: ClaudeHostPtyAccountPersistence | null = null + +export function attachClaudeHostPtyAccountPersistence( + target: ClaudeHostPtyAccountPersistence | null +): void { + persistence = target +} + +/** The host's selected account; used when a launch did not report which account it prepared. */ +export function setClaudeActiveHostAccountResolver(resolver: () => string | null): void { + resolveActiveHostAccountId = resolver +} + +/** `managed:` is a host-managed launch; `system`, WSL, and suffixed provenances are not. */ +export function hostClaudeAccountIdFromProvenance(provenance: string): string | null { + const match = /^managed:([^:]+)$/.exec(provenance) + return match ? match[1] : null +} + +export function recordHostClaudePtyAccount( + ptyId: string, + accountId: string | null | undefined, + options: { persist: boolean } +): void { + hostAccountIdByPtyId.set( + ptyId, + accountId === undefined ? resolveActiveHostAccountId() : accountId + ) + if (options.persist) { + unpersistedPtyIds.delete(ptyId) + persist() + } else { + unpersistedPtyIds.add(ptyId) + } +} + +export function forgetHostClaudePtyAccount(ptyId: string): void { + const wasPersisted = !unpersistedPtyIds.delete(ptyId) + if (hostAccountIdByPtyId.delete(ptyId) && wasPersisted) { + persist() + } +} + +export function countHostClaudePtysForAccount(accountId: string): number { + let count = 0 + for (const hostAccountId of hostAccountIdByPtyId.values()) { + if (hostAccountId === accountId) { + count += 1 + } + } + return count +} + +/** + * Restores attribution for Claude PTYs the daemon kept alive across a restart. A PTY with no + * record (one that started before this record existed) is attributed to the account selected + * now: the selection persists across restarts, so that is the account it most plausibly used. + */ +export function seedHostClaudePtyAccounts( + sessionIds: readonly string[], + recorded: Record +): void { + for (const sessionId of sessionIds) { + hostAccountIdByPtyId.set( + sessionId, + Object.hasOwn(recorded, sessionId) ? recorded[sessionId] : resolveActiveHostAccountId() + ) + } + persist() +} + +function persist(): void { + const entries: Record = {} + for (const [ptyId, accountId] of hostAccountIdByPtyId) { + if (!unpersistedPtyIds.has(ptyId)) { + entries[ptyId] = accountId + } + } + try { + persistence?.write(entries) + } catch (error) { + // Why: the record only protects a restart; losing it must never fail a spawn or an exit. + console.warn('[claude-host-pty] Failed to persist host Claude PTY accounts:', error) + } +} + +export function readClaudeHostPtyAccountsFile(filePath: string): Record { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(filePath, 'utf-8')) + } catch { + return {} + } + const panes = parsed && typeof parsed === 'object' && 'panes' in parsed ? parsed.panes : undefined + if (!panes || typeof panes !== 'object' || Array.isArray(panes)) { + return {} + } + const entries: Record = {} + for (const [ptyId, accountId] of Object.entries(panes)) { + if (accountId === null || (typeof accountId === 'string' && accountId)) { + entries[ptyId] = accountId + } + } + return entries +} + +export function createClaudeHostPtyAccountFilePersistence( + filePath: string +): ClaudeHostPtyAccountPersistence { + return { + write(entries) { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileAtomically(filePath, `${JSON.stringify({ version: 1, panes: entries })}\n`, { + mode: 0o600 + }) + } + } +} + +export const _internals = { + reset(): void { + hostAccountIdByPtyId.clear() + unpersistedPtyIds.clear() + resolveActiveHostAccountId = () => null + persistence = null + } +} diff --git a/src/main/claude-accounts/claude-pinned-pty-registry.ts b/src/main/claude-accounts/claude-pinned-pty-registry.ts index e5abb52135b4..d450f1a50b4e 100644 --- a/src/main/claude-accounts/claude-pinned-pty-registry.ts +++ b/src/main/claude-accounts/claude-pinned-pty-registry.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync } from 'node:fs' import { dirname } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { countHostClaudePtysForAccount } from './claude-host-pty-accounts' /** * Claude PTYs launched with `--account` on a managed account that is not the host's active one. @@ -25,7 +26,7 @@ const hostMutationsByAccountId = new Map() const usageFetchesByAccountId = new Map() const usageFetchSettledListeners = new Map void>>() -export type ClaudePinnedReservationConflict = 'host-mutation' | 'usage-fetch' +export type ClaudePinnedReservationConflict = 'host-sessions' | 'host-mutation' | 'usage-fetch' export type ClaudePinnedPtyPersistence = { write(entries: Record): void @@ -64,11 +65,15 @@ export function hasLivePinnedClaudePtys(accountId: string): boolean { /** * Held from auth preparation until the spawn settles; each successful reserve needs exactly one - * release. Refused, rather than counted, while a host mutation or usage fetch holds the account. + * release. Refused, rather than counted, while an ordinary host Claude still runs on the account + * or a host mutation or usage fetch holds it. */ export function reserveClaudePinnedAccount( accountId: string ): ClaudePinnedReservationConflict | null { + if (countHostClaudePtysForAccount(accountId) > 0) { + return 'host-sessions' + } if (hostMutationsByAccountId.has(accountId)) { return 'host-mutation' } diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts index 2614929e10d0..def11f3cd562 100644 --- a/src/main/claude-accounts/live-pty-gate.ts +++ b/src/main/claude-accounts/live-pty-gate.ts @@ -1,4 +1,9 @@ import { markPinnedClaudePtyExited } from './claude-pinned-pty-registry' +import { + forgetHostClaudePtyAccount, + hostClaudeAccountIdFromProvenance, + recordHostClaudePtyAccount +} from './claude-host-pty-accounts' const liveClaudePtyIds = new Set() // Why: ids restored from persistence at startup, not yet confirmed against the @@ -70,16 +75,25 @@ export function confirmSeededClaudeLivePtys(aliveSessionIds: readonly string[]): if (!alive.has(sessionId)) { liveClaudePtyIds.delete(sessionId) persistence?.removeClaudeLivePtySessionId(sessionId) + forgetHostClaudePtyAccount(sessionId) } } seededUnconfirmedPtyIds.clear() notifyDrainedOnTransition(hadLivePtys) } -export function markClaudePtySpawned(ptyId: string): void { +/** `launchProvenance` is the prepared auth's provenance; absent, the selected host account is used. */ +export function markClaudePtySpawned(ptyId: string, launchProvenance?: string): void { liveClaudePtyIds.add(ptyId) seededUnconfirmedPtyIds.delete(ptyId) persistence?.addClaudeLivePtySessionId(ptyId) + recordHostClaudePtyAccount( + ptyId, + launchProvenance === undefined + ? undefined + : hostClaudeAccountIdFromProvenance(launchProvenance), + { persist: true } + ) } export function markClaudePtyExited(ptyId: string): void { @@ -89,6 +103,7 @@ export function markClaudePtyExited(ptyId: string): void { persistence?.removeClaudeLivePtySessionId(ptyId) // Why: this is the one exit path every provider reports through, so pinned PTYs release here too. markPinnedClaudePtyExited(ptyId) + forgetHostClaudePtyAccount(ptyId) notifyDrainedOnTransition(hadLivePtys) } @@ -107,11 +122,14 @@ export function markClaudePtyExited(ptyId: string): void { */ export function markClaudeStructuredChildSpawned(childKey: string): void { liveClaudePtyIds.add(structuredChildGateId(childKey)) + // Why: a structured child runs on the host selection too (claude-structured-auth-policy). + recordHostClaudePtyAccount(structuredChildGateId(childKey), undefined, { persist: false }) } export function markClaudeStructuredChildExited(childKey: string): void { const hadLivePtys = liveClaudePtyIds.size > 0 liveClaudePtyIds.delete(structuredChildGateId(childKey)) + forgetHostClaudePtyAccount(structuredChildGateId(childKey)) notifyDrainedOnTransition(hadLivePtys) } diff --git a/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts index b25da9fa48d2..67bf8eab43b0 100644 --- a/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts @@ -464,4 +464,34 @@ describe('ClaudeRuntimeAuthService pinned --account launches', () => { await expect(pinned).rejects.toThrow(/just became the active account/) expect(fixture.registry.countClaudePinnedAccountUsers('acct-b')).toBe(0) }) + + it('refuses to pin an account old host terminals still run on, until they exit', async () => { + const fixture = await setUpTwoAccounts() + const gate = await import('./live-pty-gate') + // Started while acct-b was the host account; the host has since moved to acct-a. + gate.markClaudePtySpawned('old-host-b', 'managed:acct-b') + gate.markClaudePtySpawned('host-a', 'managed:acct-a') + try { + await expect( + fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + ).rejects.toThrow( + 'Account pinned@example.com still has 1 Claude terminal started while it was the active account; close it before launching it with --account.' + ) + expect(fixture.registry.countClaudePinnedAccountUsers('acct-b')).toBe(0) + expect(keychain.scoped.has(fixture.pinnedDir)).toBe(false) + // The normal launch path does not consult the attribution at all. + await expect( + fixture.service.prepareForClaudeLaunch({ runtime: 'host' }) + ).resolves.toMatchObject({ provenance: 'managed:acct-a' }) + + gate.markClaudePtyExited('old-host-b') + + await expect( + fixture.service.prepareForClaudeLaunch({ runtime: 'host' }, { accountId: 'acct-b' }) + ).resolves.toMatchObject({ pinnedAccountId: 'acct-b' }) + } finally { + gate.markClaudePtyExited('old-host-b') + gate.markClaudePtyExited('host-a') + } + }) }) diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts index c3b5cccb6d28..cc19bdf01f19 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts @@ -13,6 +13,7 @@ import { seedPinnedClaudeKeychainCredentials } from '../claude-pinned-credentials' import { prepareClaudePinnedConfigDir } from '../claude-pinned-config-dir' +import { countHostClaudePtysForAccount } from '../claude-host-pty-accounts' import { getSelectedClaudeAccountIdForTarget } from '../runtime-selection' import { ClaudeRuntimeAuthPreparationService } from './runtime-auth-preparation' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth-types' @@ -127,6 +128,13 @@ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationS if (conflict === null) { return sharedWithLiveSession } + if (conflict === 'host-sessions') { + const account = this.requireClaudeAccountForLaunch(accountId) + const sessions = countHostClaudePtysForAccount(accountId) + throw new Error( + `Account ${account.email} still has ${sessions === 1 ? '1 Claude terminal' : `${sessions} Claude terminals`} started while it was the active account; close ${sessions === 1 ? 'it' : 'them'} before launching it with --account.` + ) + } if (conflict === 'host-mutation') { throw new Error( 'That Claude account is being switched to or removed. Retry once that finishes.' diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index c36fa3e40f5e..dce35dc0bdfd 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -8,6 +8,7 @@ import { LocalPtyProvider } from '../providers/local-pty-provider' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' +import { countHostClaudePtysForAccount } from '../claude-accounts/claude-host-pty-accounts' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' @@ -313,10 +314,12 @@ describe('registerPtyHandlers', () => { expect(prepareClaudeAuth).toHaveBeenCalledTimes(1) expect(hasLiveClaudePtys()).toBe(true) + expect(countHostClaudePtysForAccount('account-1')).toBe(1) await handlers.get('pty:kill')!(null, { id: spawnResult.id }) expect(hasLiveClaudePtys()).toBe(false) + expect(countHostClaudePtysForAccount('account-1')).toBe(0) }) it('clears Claude live-PTY tracking from shared provider teardown', () => { markClaudePtySpawned('ssh-claude-pty') diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 90b700f24b2e..7a9fe0326bb9 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -143,7 +143,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { }) const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) + // Why: `--account` must not pin an account a surviving host Claude still refreshes. + setClaudeActiveHostAccountResolver(() => + getSelectedClaudeAccountIdForTarget(store.getSettings(), { runtime: 'host' }) + ) + const hostClaudePtyAccountsPath = join(app.getPath('userData'), 'claude-host-pane-accounts.json') + attachClaudeHostPtyAccountPersistence( + createClaudeHostPtyAccountFilePersistence(hostClaudePtyAccountsPath) + ) + seedHostClaudePtyAccounts( + persistedClaudePtyIds, + readClaudeHostPtyAccountsFile(hostClaudePtyAccountsPath) + ) // Why: same restart hazard for `--account` PTYs, whose accounts the global gate never covers. const pinnedClaudePtyRegistryPath = join( app.getPath('userData'),