From 1cf0f46296b7fa0b4d2adbbad168127e528e426f Mon Sep 17 00:00:00 2001 From: Filipo Ardenghi Date: Thu, 24 Sep 2026 11:17:19 -0300 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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'), From c730a0784f0de2d5394f0406800d2919265e330d Mon Sep 17 00:00:00 2001 From: Ethan Rivas Date: Sat, 26 Sep 2026 10:19:23 -0600 Subject: [PATCH 17/41] feat(claude-accounts): add per-project Claude account preference Store an optional Claude account choice on each repo, next to the GitHub account: the default (active) account, a saved managed account, or "ask every time". It is normalized on load and update, and travels over repo.update as a new optional field. --- src/main/ipc/repos/repo-update-handler.ts | 15 +++++ src/main/persistence-update-repo.test.ts | 25 ++++++++ .../repo-lifecycle-operations.ts | 2 + .../tracking-repos/repo-hydration.ts | 4 ++ .../tracking-repos/repo-sanitization.ts | 11 ++++ .../tracking-repos/repo-update-operations.ts | 6 ++ src/main/runtime/rpc/methods/repo.test.ts | 48 +++++++++++++++ .../runtime-repository-settings-controller.ts | 3 + src/preload/api/repository-api.ts | 2 + .../components/settings/RepositoryPane.tsx | 4 +- src/renderer/src/store/repos/repo-state.ts | 2 + src/renderer/src/store/repos/repo-update.ts | 16 +++++ .../project-claude-account-preference.test.ts | 48 +++++++++++++++ .../project-claude-account-preference.ts | 59 +++++++++++++++++++ src/shared/repo-types.ts | 3 + src/shared/rpc-contract/repo-update-params.ts | 13 ++++ 16 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 src/shared/claude/project-claude-account-preference.test.ts create mode 100644 src/shared/claude/project-claude-account-preference.ts diff --git a/src/main/ipc/repos/repo-update-handler.ts b/src/main/ipc/repos/repo-update-handler.ts index 5af9e097121c..1cdb8df20b33 100644 --- a/src/main/ipc/repos/repo-update-handler.ts +++ b/src/main/ipc/repos/repo-update-handler.ts @@ -8,6 +8,8 @@ import { normalizeRepoBadgeColor } from '../../../shared/repo-badge-color' import { sanitizeRepoIcon } from '../../../shared/repo-icon' import { normalizeGhAccountBinding } from '../../../shared/github/account-binding' import type { GhAccountBinding } from '../../../shared/github/account-binding' +import { normalizeRepoAgentAccounts } from '../../../shared/claude/project-claude-account-preference' +import type { RepoAgentAccounts } from '../../../shared/claude/project-claude-account-preference' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { normalizeCustomWorktreeVisibilitySources, @@ -55,6 +57,7 @@ export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Stor | Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } } ) => { @@ -91,6 +94,18 @@ export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Stor } } } + if ('agentAccounts' in updates) { + if (updates.agentAccounts == null) { + updates.agentAccounts = null + } else { + const normalized = normalizeRepoAgentAccounts(updates.agentAccounts) + if (!normalized) { + delete updates.agentAccounts + } else { + updates.agentAccounts = normalized + } + } + } // Why: worktree materialization calls .trim() per entry, so strip non-string[] at the boundary to avoid a silent throw later. if ('symlinkPaths' in updates && updates.symlinkPaths !== undefined) { const v = updates.symlinkPaths as unknown diff --git a/src/main/persistence-update-repo.test.ts b/src/main/persistence-update-repo.test.ts index d24267296588..4aea7eb91a72 100644 --- a/src/main/persistence-update-repo.test.ts +++ b/src/main/persistence-update-repo.test.ts @@ -561,6 +561,31 @@ describe('Store', () => { expect(cleared!.ghAccount).toBeUndefined() }) + it('updateRepo persists, reloads, and clears agentAccounts', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const updated = store.updateRepo('r1', { + agentAccounts: { claude: { mode: 'account', accountId: ' acct-1 ' } } + }) + expect(updated!.agentAccounts).toEqual({ claude: { mode: 'account', accountId: 'acct-1' } }) + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')!.agentAccounts).toEqual({ + claude: { mode: 'account', accountId: 'acct-1' } + }) + + const malformed = reloaded.updateRepo('r1', { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exercising a malformed value on purpose; the persistence layer must drop it rather than coerce it. + agentAccounts: { claude: { mode: 'bogus' } } as never + }) + expect(malformed!.agentAccounts).toEqual({ claude: { mode: 'account', accountId: 'acct-1' } }) + + const cleared = reloaded.updateRepo('r1', { agentAccounts: null }) + expect(cleared!.agentAccounts).toBeUndefined() + }) + it('updateRepo persists fork sync mode across reloads', async () => { const store = await createStore() store.addRepo(makeRepo()) diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 1dde30761169..90bd5eada4d1 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -1,6 +1,7 @@ import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import type { GhAccountBinding } from '../../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../../shared/claude/project-claude-account-preference' import { removeRepoFromHostWorkspaceSessions, removeRepoFromWorkspaceSession @@ -192,6 +193,7 @@ export class RepoLifecycleOperations { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null }, hostId?: ExecutionHostId ): Repo | null { diff --git a/src/main/persistence/tracking-repos/repo-hydration.ts b/src/main/persistence/tracking-repos/repo-hydration.ts index e642ef50f53c..9c5ceb6259f5 100644 --- a/src/main/persistence/tracking-repos/repo-hydration.ts +++ b/src/main/persistence/tracking-repos/repo-hydration.ts @@ -4,6 +4,7 @@ import { getDefaultRepoHookSettings } from '../../../shared/constants' import { isFolderRepo } from '../../../shared/repo-kind' import { sanitizeRepoIcon } from '../../../shared/repo-icon' import { normalizeGhAccountBinding } from '../../../shared/github/account-binding' +import { normalizeRepoAgentAccounts } from '../../../shared/claude/project-claude-account-preference' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { sanitizeForkSyncMode, @@ -37,6 +38,7 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap > & { ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } >(updates: T): T { const sanitized = { ...updates } @@ -151,6 +154,14 @@ export function sanitizeRepoUpdatesForPersistence< sanitized.ghAccount = ghAccount } } + if ('agentAccounts' in sanitized && sanitized.agentAccounts != null) { + const agentAccounts = normalizeRepoAgentAccounts(sanitized.agentAccounts) + if (!agentAccounts) { + delete sanitized.agentAccounts + } else { + sanitized.agentAccounts = agentAccounts + } + } if ('customWorktreeVisibilitySources' in sanitized) { const sources = normalizeCustomWorktreeVisibilitySources( sanitized.customWorktreeVisibilitySources diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index 399d05ac653a..780b2bef916b 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -6,6 +6,7 @@ import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/exter import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { normalizeWorktreeVisibilitySourcePreferences } from '../../../shared/worktree/visibility-sources' import type { GhAccountBinding } from '../../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../../shared/claude/project-claude-account-preference' import { invalidateGhAccountTokenCache } from '../../github/gh-account-token' import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization' @@ -74,6 +75,7 @@ export class RepoUpdatePersistenceOperations { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null }, hostId?: ExecutionHostId ): Repo | null { @@ -146,6 +148,10 @@ export class RepoUpdatePersistenceOperations { delete repo.ghAccount delete sanitizedUpdates.ghAccount } + if ('agentAccounts' in sanitizedUpdates && sanitizedUpdates.agentAccounts == null) { + delete repo.agentAccounts + delete sanitizedUpdates.agentAccounts + } if ('worktreeBasePath' in sanitizedUpdates && sanitizedUpdates.worktreeBasePath === undefined) { delete repo.worktreeBasePath delete sanitizedUpdates.worktreeBasePath diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 3de0e8feae04..9f5196991c07 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -426,6 +426,54 @@ describe('repo RPC methods', () => { expect(runtime.updateRepo).toHaveBeenLastCalledWith('repo-1', { ghAccount: null }) }) + it('persists normalized agentAccounts and clear sentinels, and omits malformed input', async () => { + const runtime = new OrcaRuntimeService(null) + vi.spyOn(runtime, 'updateRepo').mockResolvedValue({ + id: 'repo-1', + path: '/srv/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + agentAccounts: { claude: { mode: 'account', accountId: 'acct-1' } } + }) + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { agentAccounts: { claude: { mode: 'account', accountId: ' acct-1 ' } } } + }), + { clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] } + ) + + expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', { + agentAccounts: { claude: { mode: 'account', accountId: 'acct-1' } } + }) + expect(response).toMatchObject({ + ok: true, + result: { + repo: { id: 'repo-1', agentAccounts: { claude: { mode: 'account', accountId: 'acct-1' } } } + } + }) + + await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { agentAccounts: null } + }) + ) + expect(runtime.updateRepo).toHaveBeenLastCalledWith('repo-1', { agentAccounts: null }) + + await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exercising malformed transport input on purpose; the schema must drop it rather than coerce it. + updates: { agentAccounts: { claude: { mode: 'bogus' } } as never } + }) + ) + expect(runtime.updateRepo).toHaveBeenLastCalledWith('repo-1', {}) + }) + it('persists agent worktree visibility updates', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index 27260d23744f..ae467b8b48d4 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -2,6 +2,7 @@ import { getRepoExecutionHostId } from '../../shared/execution-host' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import type { GhAccountBinding } from '../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../shared/claude/project-claude-account-preference' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import type { RuntimeStore } from './runtime-store-contract' @@ -41,6 +42,8 @@ type RepositoryUpdates = Partial< externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null /** Only `null` clears; `omitUndefined` drops a stripped (undefined) field so it never unbinds. */ ghAccount?: GhAccountBinding | null + /** Only null clears. */ + agentAccounts?: RepoAgentAccounts | null } function omitUndefined>(value: T): Partial { diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 409154f0c15d..9df7a2ffcc7a 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -1,5 +1,6 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { GhAccountBinding } from '../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../shared/claude/project-claude-account-preference' import type { HostRepoCatalogSnapshot, ListReposForExecutionHostArgs @@ -72,6 +73,7 @@ export type RepositoryApi = { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } }) => Promise pickFolder: () => Promise diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 71a4c2e9c249..aa08e6df4d43 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -1,4 +1,5 @@ import type { GhAccountBinding } from '../../../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../../../shared/claude/project-claude-account-preference' import { useCallback, useRef, useState } from 'react' import type { OrcaHooks, RepoHookSettings } from '../../../../shared/orca-yaml-hook-types' import type { Project, ProjectUpdateArgs } from '../../../../shared/project-types' @@ -37,11 +38,12 @@ export { matchesRepositoryIdentitySearch } from './repository-identity-search' type RepositoryPaneRepoUpdate = Omit< Partial, - 'sourceControlAi' | 'externalWorktreeVisibility' | 'ghAccount' + 'sourceControlAi' | 'externalWorktreeVisibility' | 'ghAccount' | 'agentAccounts' > & { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } const EMPTY_WSL_DISTROS: string[] = [] diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 2223626f44bb..00a030a864a9 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -1,4 +1,5 @@ import type { GhAccountBinding } from '../../../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../../../shared/claude/project-claude-account-preference' import type { SshRepoReadoption } from '../../../../shared/ssh-types' import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { @@ -57,6 +58,7 @@ export type RepoUpdate = Partial< sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } export type ProjectUpdate = ProjectUpdateArgs['updates'] diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts index 93da54840a43..d8ed25a8add1 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -4,6 +4,7 @@ import type { Repo } from '../../../../shared/repo-types' import { sanitizeRepoIcon } from '../../../../shared/repo-icon' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { normalizeGhAccountBinding } from '../../../../shared/github/account-binding' +import { normalizeRepoAgentAccounts } from '../../../../shared/claude/project-claude-account-preference' import { findRepoForHost, getRepoHostIdentityForParts, @@ -59,6 +60,14 @@ export function sanitizeRepoUpdate(updates: RepoUpdate): RepoUpdate { sanitized.ghAccount = normalized } } + if ('agentAccounts' in sanitized && sanitized.agentAccounts != null) { + const normalized = normalizeRepoAgentAccounts(sanitized.agentAccounts) + if (!normalized) { + delete sanitized.agentAccounts + } else { + sanitized.agentAccounts = normalized + } + } if ('customWorktreeVisibilitySources' in sanitized) { const sources = normalizeCustomWorktreeVisibilitySources( sanitized.customWorktreeVisibilitySources @@ -155,6 +164,7 @@ export function createRepoUpdateActions( sourceControlAi, externalWorktreeDiscoverySuppressedAt, ghAccount, + agentAccounts, externalWorktreeVisibility, agentWorktreeVisibility, ...updatesWithoutClearSentinels @@ -196,6 +206,12 @@ export function createRepoUpdateActions( } else if (ghAccount !== undefined) { mergedRepo = { ...mergedRepo, ghAccount } } + if (agentAccounts === null) { + const { agentAccounts: _agentAccounts, ...repoWithoutAgentAccounts } = mergedRepo + mergedRepo = repoWithoutAgentAccounts + } else if (agentAccounts !== undefined) { + mergedRepo = { ...mergedRepo, agentAccounts } + } return mergedRepo }) return { diff --git a/src/shared/claude/project-claude-account-preference.test.ts b/src/shared/claude/project-claude-account-preference.test.ts new file mode 100644 index 000000000000..2d019133dc43 --- /dev/null +++ b/src/shared/claude/project-claude-account-preference.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + ACTIVE_CLAUDE_ACCOUNT, + isPinnableClaudeAccountId, + normalizeRepoAgentAccounts, + repoAgentAccountsEqual +} from './project-claude-account-preference' + +describe('normalizeRepoAgentAccounts', () => { + it('keeps ask and account modes and trims ids', () => { + expect(normalizeRepoAgentAccounts({ claude: { mode: 'ask' } })).toEqual({ + claude: { mode: 'ask' } + }) + expect( + normalizeRepoAgentAccounts({ claude: { mode: 'account', accountId: ' acct-1 ' } }) + ).toEqual({ + claude: { mode: 'account', accountId: 'acct-1' } + }) + }) + + it('rejects malformed shapes', () => { + expect(normalizeRepoAgentAccounts(null)).toBeNull() + expect(normalizeRepoAgentAccounts({ claude: { mode: 'account' } })).toBeNull() + expect( + normalizeRepoAgentAccounts({ claude: { mode: 'account', accountId: ACTIVE_CLAUDE_ACCOUNT } }) + ).toBeNull() + expect(normalizeRepoAgentAccounts({ claude: { mode: 'nope' } })).toBeNull() + expect(normalizeRepoAgentAccounts({})).toBeNull() + }) + + it('compares structurally', () => { + expect(repoAgentAccountsEqual({ claude: { mode: 'ask' } }, { claude: { mode: 'ask' } })).toBe( + true + ) + expect( + repoAgentAccountsEqual( + { claude: { mode: 'ask' } }, + { claude: { mode: 'account', accountId: 'a' } } + ) + ).toBe(false) + }) + + it('treats the active sentinel as not pinnable', () => { + expect(isPinnableClaudeAccountId('acct-1')).toBe(true) + expect(isPinnableClaudeAccountId(ACTIVE_CLAUDE_ACCOUNT)).toBe(false) + expect(isPinnableClaudeAccountId('')).toBe(false) + }) +}) diff --git a/src/shared/claude/project-claude-account-preference.ts b/src/shared/claude/project-claude-account-preference.ts new file mode 100644 index 000000000000..d7662c8b8368 --- /dev/null +++ b/src/shared/claude/project-claude-account-preference.ts @@ -0,0 +1,59 @@ +export type ProjectClaudeAccountPreference = + | { mode: 'ask' } + | { mode: 'account'; accountId: string } + +export type RepoAgentAccounts = { claude?: ProjectClaudeAccountPreference } + +// Why: a launch that deliberately runs on the active account must stay unpinned on resume too. +export const ACTIVE_CLAUDE_ACCOUNT = '__active__' + +const MAX_ACCOUNT_ID_LENGTH = 256 + +export function isPinnableClaudeAccountId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_ACCOUNT_ID_LENGTH && + value !== ACTIVE_CLAUDE_ACCOUNT && + !value.includes('\u0000') + ) +} + +function normalizeClaudePreference(value: unknown): ProjectClaudeAccountPreference | null { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const mode = 'mode' in value ? value.mode : undefined + if (mode === 'ask') { + return { mode: 'ask' } + } + if (mode !== 'account') { + return null + } + const rawId = 'accountId' in value ? value.accountId : undefined + const accountId = typeof rawId === 'string' ? rawId.trim() : '' + return isPinnableClaudeAccountId(accountId) ? { mode: 'account', accountId } : null +} + +export function normalizeRepoAgentAccounts(value: unknown): RepoAgentAccounts | null { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const claude = 'claude' in value ? normalizeClaudePreference(value.claude) : null + return claude ? { claude } : null +} + +export function repoAgentAccountsEqual( + a: RepoAgentAccounts | null | undefined, + b: RepoAgentAccounts | null | undefined +): boolean { + const left = a?.claude + const right = b?.claude + if (!left || !right) { + return left === right + } + if (left.mode !== right.mode) { + return false + } + return left.mode === 'ask' || (right.mode === 'account' && left.accountId === right.accountId) +} diff --git a/src/shared/repo-types.ts b/src/shared/repo-types.ts index f537b14fa0b2..2036ee8bc4cd 100644 --- a/src/shared/repo-types.ts +++ b/src/shared/repo-types.ts @@ -6,6 +6,7 @@ import type { ForkSyncMode } from './git-fork-sync' import type { GitRemoteIdentity } from './git-remote-identity' import type { RepoSourceControlAiOverrides } from './source-control-ai-types' import type { RepoProjectHostSetupMethod } from './project-types' +import type { RepoAgentAccounts } from './claude/project-claude-account-preference' // ─── Repo ──────────────────────────────────────────────────────────── export type RepoKind = 'git' | 'folder' @@ -77,6 +78,8 @@ export type Repo = { * and injects it into that child env only. */ ghAccount?: GhAccountBinding + /** Per-agent account preference for this project; ids only, never credentials. */ + agentAccounts?: RepoAgentAccounts /** Controls Orca's fork-default-branch sync offer for repos with upstream metadata. */ forkSyncMode?: ForkSyncMode /** Canonical identity for the repo remote Orca should use for provider-level grouping. */ diff --git a/src/shared/rpc-contract/repo-update-params.ts b/src/shared/rpc-contract/repo-update-params.ts index d4bf49e79923..c0d1d0279998 100644 --- a/src/shared/rpc-contract/repo-update-params.ts +++ b/src/shared/rpc-contract/repo-update-params.ts @@ -3,6 +3,7 @@ import { normalizeRepoSourceControlAiOverrides } from '../source-control-ai' import { normalizeRepoBadgeColor } from '../repo-badge-color' import { sanitizeRepoIcon } from '../repo-icon' import { normalizeGhAccountBinding } from '../github/account-binding' +import { normalizeRepoAgentAccounts } from '../claude/project-claude-account-preference' import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences @@ -70,6 +71,18 @@ export function createRepoUpdateSchema { + if (value === undefined) { + return undefined + } + if (value === null) { + return null + } + return normalizeRepoAgentAccounts(value) ?? undefined + }), forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), From a8b458e6d113cbd657d36961e9d46657452ba56d Mon Sep 17 00:00:00 2001 From: Ethan Rivas Date: Sat, 26 Sep 2026 10:19:23 -0600 Subject: [PATCH 18/41] feat(claude-accounts): carry the pinned Claude account in launch config Add an optional claudeAccountId to the sleeping-agent launch config so a launch can record which Claude account it ran on. Recovery equivalence and sleeping records keep the field; nothing reads it yet. --- .../slices/agent-status-recovery-equivalence.ts | 3 ++- .../agent-status-sleeping-records.test.ts | 17 +++++++++++++++++ .../slices/agent-status-sleeping-records.ts | 3 ++- src/shared/agent-session-resume.ts | 2 ++ 4 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/store/slices/agent-status-sleeping-records.test.ts diff --git a/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts index 660d104abf6f..1d26be7cebc4 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts @@ -14,7 +14,8 @@ export function launchConfigsEqual( if ( a.agentCommand !== b.agentCommand || a.agentArgs !== b.agentArgs || - a.ompResumeFilePath !== b.ompResumeFilePath + a.ompResumeFilePath !== b.ompResumeFilePath || + a.claudeAccountId !== b.claudeAccountId ) { return false } diff --git a/src/renderer/src/store/slices/agent-status-sleeping-records.test.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.test.ts new file mode 100644 index 000000000000..b7d5acedfcd0 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { copyLaunchConfig } from './agent-status-sleeping-records' +import { launchConfigsEqual } from './agent-status-recovery-equivalence' + +describe('copyLaunchConfig and launchConfigsEqual', () => { + it('copies and compares claudeAccountId', () => { + const base = { agentArgs: '', agentEnv: {} } + expect(copyLaunchConfig({ ...base, claudeAccountId: 'acct-1' }).claudeAccountId).toBe('acct-1') + expect(copyLaunchConfig(base)).not.toHaveProperty('claudeAccountId') + expect( + launchConfigsEqual({ ...base, claudeAccountId: 'a' }, { ...base, claudeAccountId: 'b' }) + ).toBe(false) + expect( + launchConfigsEqual({ ...base, claudeAccountId: 'a' }, { ...base, claudeAccountId: 'a' }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-sleeping-records.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts index 6363fb9e2166..c65c0dcf3788 100644 --- a/src/renderer/src/store/slices/agent-status-sleeping-records.ts +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.ts @@ -14,7 +14,8 @@ export function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAge ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), agentArgs: config.agentArgs, agentEnv: { ...config.agentEnv }, - ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}) + ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}), + ...(config.claudeAccountId ? { claudeAccountId: config.claudeAccountId } : {}) } } diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 0a0ab7d443ad..cc6ad99388ae 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -42,6 +42,8 @@ export type SleepingAgentLaunchConfig = { agentArgs: string agentEnv: Record ompResumeFilePath?: string + /** Account this launch was pinned to, or ACTIVE_CLAUDE_ACCOUNT; replayed on resume. */ + claudeAccountId?: string } export type SleepingAgentSessionRecord = { From 7502ff64a7c19b6bc62eeaf6ce416d6151bb97c3 Mon Sep 17 00:00:00 2001 From: Ethan Rivas Date: Sat, 26 Sep 2026 10:19:23 -0600 Subject: [PATCH 19/41] refactor(pty): share pinned Claude spawn helpers across launch lanes Move the pinned-account preparation, reservation release and spawn registration out of the runtime spawn lane into claude-pinned-spawn so the IPC lane can reuse them. No behavior change. --- src/main/ipc/pty/claude-pinned-spawn.test.ts | 139 ++++++++++++++++++ src/main/ipc/pty/claude-pinned-spawn.ts | 79 ++++++++++ .../ipc/pty/runtime/spawn-claude-account.ts | 49 ++---- src/main/ipc/pty/runtime/spawn.ts | 6 +- 4 files changed, 235 insertions(+), 38 deletions(-) create mode 100644 src/main/ipc/pty/claude-pinned-spawn.test.ts create mode 100644 src/main/ipc/pty/claude-pinned-spawn.ts diff --git a/src/main/ipc/pty/claude-pinned-spawn.test.ts b/src/main/ipc/pty/claude-pinned-spawn.test.ts new file mode 100644 index 000000000000..d848dc258779 --- /dev/null +++ b/src/main/ipc/pty/claude-pinned-spawn.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' + +const mocks = vi.hoisted(() => ({ + markClaudePtySpawned: vi.fn(), + markPinnedClaudePtySpawned: vi.fn(), + releaseClaudePinnedAccountReservation: vi.fn() +})) + +vi.mock('../../claude-accounts/live-pty-gate', () => ({ + markClaudePtySpawned: mocks.markClaudePtySpawned +})) +vi.mock('../../claude-accounts/claude-pinned-pty-registry', () => ({ + markPinnedClaudePtySpawned: mocks.markPinnedClaudePtySpawned, + releaseClaudePinnedAccountReservation: mocks.releaseClaudePinnedAccountReservation +})) + +import { + isFreshClaudeLaunch, + preparePinnableClaudeAuth, + markClaudePtySpawnedForAuth, + releasePinnedClaudeReservation +} from './claude-pinned-spawn' + +beforeEach(() => { + vi.clearAllMocks() +}) + +function makeAuth( + overrides: Partial = {} +): ClaudeRuntimeAuthPreparation { + return { + configDir: '/tmp/claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'managed:acct-1', + ...overrides + } +} + +describe('isFreshClaudeLaunch', () => { + it('recognizes a launch by its command', () => { + expect(isFreshClaudeLaunch({ preAdoptedStablePane: false, command: 'claude' }, undefined)).toBe( + true + ) + }) + + it('rejects an adopted stable pane or SSH connection', () => { + expect(isFreshClaudeLaunch({ preAdoptedStablePane: true, command: 'claude' }, undefined)).toBe( + false + ) + expect( + isFreshClaudeLaunch( + { preAdoptedStablePane: false, connectionId: 'ssh-1', command: 'claude' }, + undefined + ) + ).toBe(false) + }) + + it('trusts the launch agent for a pinned launch without a matching command', () => { + expect( + isFreshClaudeLaunch({ preAdoptedStablePane: false, launchAgent: 'claude' }, 'acct-1') + ).toBe(true) + }) + + it('does not trust the launch agent without a pinned account or the trust option', () => { + expect( + isFreshClaudeLaunch({ preAdoptedStablePane: false, launchAgent: 'claude' }, undefined) + ).toBe(false) + }) + + it('trusts the launch agent when options.trustLaunchAgent is set, even without a pinned account', () => { + expect( + isFreshClaudeLaunch({ preAdoptedStablePane: false, launchAgent: 'claude' }, undefined, { + trustLaunchAgent: true + }) + ).toBe(true) + }) +}) + +describe('preparePinnableClaudeAuth', () => { + it('returns null when there is no prepare function and no pinned account', async () => { + await expect(preparePinnableClaudeAuth(undefined, {}, undefined)).resolves.toBeNull() + }) + + it('throws when a pinned launch has no prepare function', async () => { + await expect(preparePinnableClaudeAuth(undefined, {}, 'acct-1')).rejects.toThrow( + 'This Orca runtime cannot prepare Claude accounts for pinned launches.' + ) + }) + + it('calls prepare without options when there is no pinned account', async () => { + const auth = makeAuth() + const prepare = vi.fn().mockResolvedValue(auth) + await expect(preparePinnableClaudeAuth(prepare, {}, undefined)).resolves.toBe(auth) + expect(prepare).toHaveBeenCalledWith({}) + }) + + it('accepts a pinned-provenance result for the requested account', async () => { + const auth = makeAuth({ provenance: 'managed:acct-1:pinned', pinnedAccountId: 'acct-1' }) + const prepare = vi.fn().mockResolvedValue(auth) + await expect(preparePinnableClaudeAuth(prepare, {}, 'acct-1')).resolves.toBe(auth) + expect(prepare).toHaveBeenCalledWith({}, { accountId: 'acct-1' }) + }) + + it('rejects a result whose provenance is for a different account', async () => { + const prepare = vi.fn().mockResolvedValue(makeAuth({ provenance: 'managed:acct-2' })) + await expect(preparePinnableClaudeAuth(prepare, {}, 'acct-1')).rejects.toThrow( + 'Orca could not prepare the requested Claude account for this launch. Check `orca account list` and retry.' + ) + }) +}) + +describe('markClaudePtySpawnedForAuth', () => { + it('marks a pinned PTY against its pinned account', () => { + markClaudePtySpawnedForAuth('pty-1', makeAuth({ pinnedAccountId: 'acct-1' })) + expect(mocks.markPinnedClaudePtySpawned).toHaveBeenCalledWith('pty-1', 'acct-1') + expect(mocks.markClaudePtySpawned).not.toHaveBeenCalled() + }) + + it('marks a non-pinned PTY against the live gate with its provenance', () => { + markClaudePtySpawnedForAuth('pty-2', makeAuth({ provenance: 'managed:acct-1' })) + expect(mocks.markClaudePtySpawned).toHaveBeenCalledWith('pty-2', 'managed:acct-1') + expect(mocks.markPinnedClaudePtySpawned).not.toHaveBeenCalled() + }) +}) + +describe('releasePinnedClaudeReservation', () => { + it('releases a pinned reservation', () => { + releasePinnedClaudeReservation(makeAuth({ pinnedAccountId: 'acct-1' })) + expect(mocks.releaseClaudePinnedAccountReservation).toHaveBeenCalledWith('acct-1') + }) + + it('does nothing for an unpinned or missing auth', () => { + releasePinnedClaudeReservation(makeAuth()) + releasePinnedClaudeReservation(null) + expect(mocks.releaseClaudePinnedAccountReservation).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/claude-pinned-spawn.ts b/src/main/ipc/pty/claude-pinned-spawn.ts new file mode 100644 index 000000000000..2e4d8aa97f08 --- /dev/null +++ b/src/main/ipc/pty/claude-pinned-spawn.ts @@ -0,0 +1,79 @@ +import { isClaudeLaunchCommand } from './host-env/fresh-spawn-routing' +import { markClaudePtySpawned } from '../../claude-accounts/live-pty-gate' +import { + markPinnedClaudePtySpawned, + releaseClaudePinnedAccountReservation +} from '../../claude-accounts/claude-pinned-pty-registry' +import type { PrepareClaudeAuth } from './host-env/types' +import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' + +type ClaudeLaunchShape = { + preAdoptedStablePane: boolean + connectionId?: string | null + command?: string + launchAgent?: string +} + +export function isFreshClaudeLaunch( + launch: ClaudeLaunchShape, + pinnedAccountId: string | undefined, + options?: { trustLaunchAgent?: boolean } +): boolean { + if (launch.preAdoptedStablePane || launch.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. + return ( + isClaudeLaunchCommand(launch.command) || + ((Boolean(pinnedAccountId) || Boolean(options?.trustLaunchAgent)) && + launch.launchAgent === 'claude') + ) +} + +export async function preparePinnableClaudeAuth( + prepare: PrepareClaudeAuth | undefined, + target: ClaudeAccountSelectionTarget, + pinnedAccountId: string | undefined +): Promise { + if (!prepare) { + if (pinnedAccountId) { + throw new Error('This Orca runtime cannot prepare Claude accounts for pinned launches.') + } + return null + } + if (!pinnedAccountId) { + return prepare(target) + } + const claudeAuth = await prepare(target, { 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 markClaudePtySpawnedForAuth( + ptyId: string, + claudeAuth: ClaudeRuntimeAuthPreparation | null +): 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, claudeAuth?.provenance) +} + +export function releasePinnedClaudeReservation( + claudeAuth: ClaudeRuntimeAuthPreparation | null +): void { + if (claudeAuth?.pinnedAccountId) { + releaseClaudePinnedAccountReservation(claudeAuth.pinnedAccountId) + } +} diff --git a/src/main/ipc/pty/runtime/spawn-claude-account.ts b/src/main/ipc/pty/runtime/spawn-claude-account.ts index e5c8ad49bf0a..4f8855dc25ec 100644 --- a/src/main/ipc/pty/runtime/spawn-claude-account.ts +++ b/src/main/ipc/pty/runtime/spawn-claude-account.ts @@ -1,6 +1,9 @@ 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 { + isFreshClaudeLaunch, + preparePinnableClaudeAuth, + markClaudePtySpawnedForAuth +} from '../claude-pinned-spawn' import type { RuntimePtySpawnState } from './spawn-state' /** The `--account` a fresh runtime spawn must run on, refused where a pinned launch cannot run. */ @@ -22,15 +25,9 @@ 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') + return isFreshClaudeLaunch( + { ...ctx.args, preAdoptedStablePane: Boolean(ctx.preAdoptedStablePane) }, + pinnedAccountId ) } @@ -38,38 +35,22 @@ export async function prepareRuntimeSpawnClaudeAuth( ctx: RuntimePtySpawnState, pinnedAccountId: string | undefined ): Promise { - if (!ctx.isClaudeLaunch || !ctx.deps.prepareClaudeAuth) { + if (!ctx.isClaudeLaunch) { 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 + return preparePinnableClaudeAuth( + ctx.deps.prepareClaudeAuth, + ctx.codexSelectionTarget, + pinnedAccountId + ) } 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, claudeAuth?.provenance) + markClaudePtySpawnedForAuth(ptyId, claudeAuth) } diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 06c42e248b42..1f3feb67cd23 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -20,7 +20,7 @@ import { commitRuntimeSpawnHiddenDelivery, releaseRuntimeSpawnPreSpawnHiddenMark } from './spawn-hidden-delivery' -import { releaseClaudePinnedAccountReservation } from '../../../claude-accounts/claude-pinned-pty-registry' +import { releasePinnedClaudeReservation } from '../claude-pinned-spawn' function toRuntimeSpawnReply(result: { id: string @@ -124,8 +124,6 @@ export async function spawnPtyFromRuntimeController( 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) - } + releasePinnedClaudeReservation(ctx.claudeAuth) } } From 2565f4fbcfc13e21cfc1292e1cd11f5918dde0c7 Mon Sep 17 00:00:00 2001 From: Ethan Rivas Date: Sat, 26 Sep 2026 10:19:23 -0600 Subject: [PATCH 20/41] feat(claude-accounts): tag pinned launch refusals with a stable code Pinned launch refusals now end with a [claude_pinned:] token. Errors cross IPC as plain strings, so the token lets the renderer map a refusal to localized copy without parsing the English message. --- .../runtime-auth-pinned-launch.ts | 28 ++++++++++++++----- src/main/ipc/pty/claude-pinned-spawn.test.ts | 13 +++++++++ src/main/ipc/pty/claude-pinned-spawn.ts | 6 +++- .../claude/claude-pinned-launch-error.test.ts | 22 +++++++++++++++ .../claude/claude-pinned-launch-error.ts | 24 ++++++++++++++++ 5 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 src/shared/claude/claude-pinned-launch-error.test.ts create mode 100644 src/shared/claude/claude-pinned-launch-error.ts 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 cc19bdf01f19..3353d47c26dc 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 @@ -1,4 +1,5 @@ import { join } from 'node:path' +import { claudePinnedLaunchError } from '../../../shared/claude/claude-pinned-launch-error' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import { countClaudePinnedAccountUsers, @@ -54,7 +55,10 @@ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationS ) { // 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.') + throw claudePinnedLaunchError( + 'became-active', + 'That Claude account just became the active account. Retry the launch.' + ) } if (account.managedAuthRuntime === 'wsl') { throw new Error( @@ -63,7 +67,8 @@ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationS } const configDir = await this.getOwnedManagedAuthPath(account) if (!configDir) { - throw new Error( + throw claudePinnedLaunchError( + 'credentials', `Orca cannot verify the saved sign-in for Claude account ${account.email}. Re-authenticate it in Settings > Accounts, then retry.` ) } @@ -72,7 +77,8 @@ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationS } const credentialsJson = await this.readManagedCredentials(account) if (!credentialsJson || !this.isValidCredentialsJsonObject(credentialsJson)) { - throw new Error( + throw claudePinnedLaunchError( + 'credentials', `Claude account ${account.email} has no valid saved sign-in. Re-authenticate it in Settings > Accounts, then retry.` ) } @@ -109,7 +115,10 @@ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationS 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.') + throw claudePinnedLaunchError( + 'account-missing', + 'That Claude account no longer exists. Run `orca account list` and retry.' + ) } return account } @@ -131,18 +140,23 @@ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationS if (conflict === 'host-sessions') { const account = this.requireClaudeAccountForLaunch(accountId) const sessions = countHostClaudePtysForAccount(accountId) - throw new Error( + throw claudePinnedLaunchError( + 'host-sessions', `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( + throw claudePinnedLaunchError( + 'host-mutation', '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.') + throw claudePinnedLaunchError( + 'usage-fetch', + 'Orca is still checking usage for that Claude account. Retry in a moment.' + ) } } } diff --git a/src/main/ipc/pty/claude-pinned-spawn.test.ts b/src/main/ipc/pty/claude-pinned-spawn.test.ts index d848dc258779..10d59bdab4dd 100644 --- a/src/main/ipc/pty/claude-pinned-spawn.test.ts +++ b/src/main/ipc/pty/claude-pinned-spawn.test.ts @@ -108,6 +108,19 @@ describe('preparePinnableClaudeAuth', () => { await expect(preparePinnableClaudeAuth(prepare, {}, 'acct-1')).rejects.toThrow( 'Orca could not prepare the requested Claude account for this launch. Check `orca account list` and retry.' ) + expect(mocks.releaseClaudePinnedAccountReservation).not.toHaveBeenCalled() + }) + + it('releases the reservation of a mismatched pinned preparation before refusing', async () => { + const prepare = vi + .fn() + .mockResolvedValue( + makeAuth({ provenance: 'managed:acct-2:pinned', pinnedAccountId: 'acct-2' }) + ) + await expect(preparePinnableClaudeAuth(prepare, {}, 'acct-1')).rejects.toThrow( + '[claude_pinned:provenance]' + ) + expect(mocks.releaseClaudePinnedAccountReservation).toHaveBeenCalledWith('acct-2') }) }) diff --git a/src/main/ipc/pty/claude-pinned-spawn.ts b/src/main/ipc/pty/claude-pinned-spawn.ts index 2e4d8aa97f08..e04b21ed36ed 100644 --- a/src/main/ipc/pty/claude-pinned-spawn.ts +++ b/src/main/ipc/pty/claude-pinned-spawn.ts @@ -1,4 +1,5 @@ import { isClaudeLaunchCommand } from './host-env/fresh-spawn-routing' +import { claudePinnedLaunchError } from '../../../shared/claude/claude-pinned-launch-error' import { markClaudePtySpawned } from '../../claude-accounts/live-pty-gate' import { markPinnedClaudePtySpawned, @@ -51,7 +52,10 @@ export async function preparePinnableClaudeAuth( claudeAuth.provenance !== `managed:${pinnedAccountId}:pinned` && claudeAuth.provenance !== `managed:${pinnedAccountId}` ) { - throw new Error( + // Why: the caller never sees this preparation, so its spawn finally cannot release the hold. + releasePinnedClaudeReservation(claudeAuth) + throw claudePinnedLaunchError( + 'provenance', 'Orca could not prepare the requested Claude account for this launch. Check `orca account list` and retry.' ) } diff --git a/src/shared/claude/claude-pinned-launch-error.test.ts b/src/shared/claude/claude-pinned-launch-error.test.ts new file mode 100644 index 000000000000..d2179cfd9b1c --- /dev/null +++ b/src/shared/claude/claude-pinned-launch-error.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + claudePinnedLaunchError, + readClaudePinnedLaunchErrorCode +} from './claude-pinned-launch-error' + +describe('claude pinned launch error marker', () => { + it('round-trips the code through an IPC-wrapped message', () => { + const error = claudePinnedLaunchError( + 'host-sessions', + 'Account a@b.c still has 2 Claude terminals.' + ) + const wrapped = `Error invoking remote method 'pty:spawn': Error: ${error.message}` + expect(readClaudePinnedLaunchErrorCode(wrapped)).toBe('host-sessions') + expect(error.message).toContain('Account a@b.c still has 2 Claude terminals.') + }) + + it('ignores unrelated errors and unknown codes', () => { + expect(readClaudePinnedLaunchErrorCode('ENOENT')).toBeNull() + expect(readClaudePinnedLaunchErrorCode('[claude_pinned:bogus]')).toBeNull() + }) +}) diff --git a/src/shared/claude/claude-pinned-launch-error.ts b/src/shared/claude/claude-pinned-launch-error.ts new file mode 100644 index 000000000000..38cf758feed1 --- /dev/null +++ b/src/shared/claude/claude-pinned-launch-error.ts @@ -0,0 +1,24 @@ +const CODES = [ + 'host-sessions', + 'host-mutation', + 'usage-fetch', + 'account-missing', + 'became-active', + 'credentials', + 'provenance' +] as const + +export type ClaudePinnedLaunchErrorCode = (typeof CODES)[number] + +const MARKER_RE = /\[claude_pinned:([a-z-]+)\]/ + +export function claudePinnedLaunchError(code: ClaudePinnedLaunchErrorCode, message: string): Error { + // Why: errors cross IPC as strings; the trailing token lets the renderer localize without parsing prose. + return new Error(`${message} [claude_pinned:${code}]`) +} + +export function readClaudePinnedLaunchErrorCode(text: string): ClaudePinnedLaunchErrorCode | null { + const match = MARKER_RE.exec(text) + const code = match?.[1] + return CODES.find((candidate) => candidate === code) ?? null +} From 814b9f95cfb422faf76d3d04965ec832dda31714 Mon Sep 17 00:00:00 2001 From: Ethan Rivas Date: Sat, 26 Sep 2026 10:19:24 -0600 Subject: [PATCH 21/41] feat(pty): pin Claude launches to the project account on both spawn lanes Resolve the account a fresh Claude launch must run on (explicit --account, then the launch config, then the project's saved account) and pin it on both the renderer IPC lane and the runtime lane. SSH and pre-adopted panes never pin, and host-side agent.launch falls back to a terminal instead of a structured session when the project pins one. --- .../agent-launch-executor.test.ts | 42 +++++- .../agent-launch/agent-launch-executor.ts | 6 +- src/main/agent-launch/agent-launch-mode.ts | 46 ++++++- .../project-claude-account-resolution.test.ts | 45 +++++++ .../project-claude-account-resolution.ts | 30 +++++ .../pty-runtime-claude-account-spawn.test.ts | 55 +++++++- src/main/ipc/pty/ipc/spawn-commit.ts | 10 +- src/main/ipc/pty/ipc/spawn-env.ts | 3 +- .../spawn-preflight-claude-account.test.ts | 127 ++++++++++++++++++ src/main/ipc/pty/ipc/spawn-preflight.ts | 33 +++-- .../spawn-run-claude-pinned-release.test.ts | 82 +++++++++++ src/main/ipc/pty/ipc/spawn-run.ts | 3 + .../ipc/pty/runtime/spawn-claude-account.ts | 35 +++-- src/main/ipc/pty/runtime/spawn-commit.ts | 8 +- ...ca-runtime-create-terminal-dependencies.ts | 3 +- .../runtime/orca-runtime-create-terminal.ts | 2 +- .../orchestration-worker-start-mode.ts | 3 +- .../runtime-agent-launch-resolution.ts | 14 ++ 18 files changed, 511 insertions(+), 36 deletions(-) create mode 100644 src/main/claude-accounts/project-claude-account-resolution.test.ts create mode 100644 src/main/claude-accounts/project-claude-account-resolution.ts create mode 100644 src/main/ipc/pty/ipc/spawn-preflight-claude-account.test.ts create mode 100644 src/main/ipc/pty/ipc/spawn-run-claude-pinned-release.test.ts diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index c0451a4f6b23..d8f8e29aa76f 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -27,6 +27,7 @@ function harness(options: { structuredCreateError?: Error deliveredMessageId?: string | null terminalPromptDelivered?: boolean + repos?: { id: string; agentAccounts?: unknown }[] }) { const calls: string[] = [] const createWorktree = vi.fn( @@ -71,7 +72,8 @@ function harness(options: { const runtime = { getClientSettings: () => options.settings === undefined ? STRUCTURED_PREFERENCE : options.settings, - getStructuredAgentSessionCreateSupport + getStructuredAgentSessionCreateSupport, + listRepos: () => options.repos ?? [] } return { calls, @@ -82,7 +84,7 @@ function harness(options: { deliverTerminalPrompt, run: (intent: AgentLaunchIntent) => executeAgentLaunch({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the two runtime methods the executor reaches, and each test asserts the calls made, so an omitted method throws rather than reading a wrong value. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the runtime methods the executor reaches, and each test asserts the calls made, so an omitted method throws rather than reading a wrong value. runtime: runtime as unknown as AgentLaunchExecution['runtime'], intent, surfaces: { @@ -241,6 +243,42 @@ describe('a launch into a workspace that already exists', () => { }) }) +describe('a launch into a project with a saved Claude account', () => { + const PINNED_REPO = { + id: 'repo-1', + agentAccounts: { claude: { mode: 'account', accountId: 'acct-b' } } + } + const EXISTING = { kind: 'existing', worktree: 'repo-1::/repo-1' } as const + + it('downgrades a structured Claude launch to a terminal the spawn pins', async () => { + const h = harness({ repos: [PINNED_REPO] }) + const result = await h.run({ agent: 'claude', target: EXISTING }) + expect(h.calls).toEqual(['createSupport', 'createTerminalAgent']) + expect(result.receipt).toMatchObject({ + mode: 'terminal', + preferred: 'structured', + reason: 'pinned_claude_account' + }) + expect(result.receipt.detail).toContain('saved account') + }) + + it("keeps the host's own reason when the workspace could never pin", async () => { + const h = harness({ + repos: [PINNED_REPO], + createSupport: { supported: false, reason: 'remote' } + }) + const result = await h.run({ agent: 'claude', target: EXISTING }) + expect(result.receipt).toMatchObject({ reason: 'remote_execution_host' }) + }) + + it('leaves Codex and an unpinned project structured', async () => { + const codex = harness({ repos: [PINNED_REPO] }) + expect((await codex.run({ agent: 'codex', target: EXISTING })).receipt.mode).toBe('structured') + const ask = harness({ repos: [{ id: 'repo-1', agentAccounts: { claude: { mode: 'ask' } } }] }) + expect((await ask.run({ agent: 'claude', target: EXISTING })).receipt.mode).toBe('structured') + }) +}) + describe('an agent with no structured session', () => { it('stays a terminal without asking the host', async () => { const h = harness({}) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 4d477c6d0327..518f9f4e7cb0 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -62,7 +62,11 @@ import { } from './agent-launch-surface-factories' export type AgentLaunchExecution = { - runtime: Pick + runtime: Pick< + OrcaRuntimeService, + 'getStructuredAgentSessionCreateSupport' | 'getClientSettings' + > & + Partial> intent: AgentLaunchIntent surfaces: AgentLaunchSurfaceFactory workspaces?: AgentLaunchWorkspaceFactory diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index dae3de8ed6aa..abe1cdf6e23a 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -34,6 +34,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' import type { WorkspaceLaunchKind } from '../../shared/workspace-launch-kind' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { resolveProjectClaudeAccount } from '../claude-accounts/project-claude-account-resolution' // The receipt is part of the launch contract, so it is declared with the rest of it; re-exported // here because this module is where the decision that fills it lives. @@ -174,7 +175,8 @@ export function decideAgentLaunchMode(args: { * becomes a terminal agent rather than a failed launch. */ export async function resolveAgentLaunchModeOnHost( - runtime: Pick, + runtime: Pick & + Partial>, receipt: AgentLaunchModeReceipt, worktreeId: string | undefined, agent: TuiAgent | undefined, @@ -183,13 +185,48 @@ export async function resolveAgentLaunchModeOnHost( if (receipt.mode !== 'structured' || !worktreeId) { return receipt } - return downgradeAgentLaunchModeForHost( + const settled = downgradeAgentLaunchModeForHost( receipt, await readStructuredCreateSupport(runtime, worktreeId, agent), vocabulary ) + return downgradeAgentLaunchModeForProjectClaudeAccount( + runtime, + settled, + worktreeId, + agent, + vocabulary + ) +} + +/** + * The project's saved Claude account pins the terminal spawn, and a structured session has no way + * to run on it, so a host-side launch downgrades the way the renderer's own plan does. Read after + * the host verdict so an SSH or WSL workspace, which never pins, keeps its own reason. + */ +function downgradeAgentLaunchModeForProjectClaudeAccount( + runtime: Partial>, + receipt: AgentLaunchModeReceipt, + worktreeId: string, + agent: TuiAgent | undefined, + vocabulary: AgentLaunchModeVocabulary +): AgentLaunchModeReceipt { + if (receipt.mode !== 'structured' || agent !== 'claude') { + return receipt + } + const repos = runtime.listRepos?.() ?? [] + const accountId = resolveProjectClaudeAccount({ + getRepo: (repoId) => repos.find((repo) => repo.id === repoId), + worktreeId + }) + return accountId + ? downgraded('pinned_claude_account', vocabulary, PROJECT_CLAUDE_ACCOUNT_DETAIL) + : receipt } +const PROJECT_CLAUDE_ACCOUNT_DETAIL = + 'this project runs Claude on its saved account, which only a terminal can do' + /** A host that cannot answer has not proved it can create one, so the launch stays a PTY agent. */ async function readStructuredCreateSupport( runtime: Pick, @@ -229,9 +266,10 @@ export function downgradeAgentLaunchModeForHost( function downgraded( reason: Exclude, - vocabulary: AgentLaunchModeVocabulary + vocabulary: AgentLaunchModeVocabulary, + detail?: string ): AgentLaunchModeReceipt { - const why = vocabulary.detailOverrides?.[reason] ?? DOWNGRADE_DETAIL[reason] + const why = detail ?? vocabulary.detailOverrides?.[reason] ?? DOWNGRADE_DETAIL[reason] return { mode: 'terminal', preferred: 'structured', diff --git a/src/main/claude-accounts/project-claude-account-resolution.test.ts b/src/main/claude-accounts/project-claude-account-resolution.test.ts new file mode 100644 index 000000000000..4789b2445203 --- /dev/null +++ b/src/main/claude-accounts/project-claude-account-resolution.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { resolveProjectClaudeAccount } from './project-claude-account-resolution' +import { ACTIVE_CLAUDE_ACCOUNT } from '../../shared/claude/project-claude-account-preference' +import { getRepoMainWorktreeId } from '../../shared/worktree/id' +import type { Repo } from '../../shared/repo-types' + +const worktreeId = getRepoMainWorktreeId({ id: 'repo-1', path: '/w/app' }) +const repo = (agentAccounts?: Repo['agentAccounts']): Repo | undefined => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolver only reads agentAccounts. + ({ id: 'repo-1', agentAccounts }) as Repo + +describe('resolveProjectClaudeAccount', () => { + it('prefers explicit, then launch config, then the saved project account', () => { + const getRepo = () => repo({ claude: { mode: 'account', accountId: 'saved' } }) + expect(resolveProjectClaudeAccount({ getRepo, worktreeId, explicitAccountId: 'cli' })).toBe( + 'cli' + ) + expect( + resolveProjectClaudeAccount({ getRepo, worktreeId, launchConfigAccountId: 'resume' }) + ).toBe('resume') + expect(resolveProjectClaudeAccount({ getRepo, worktreeId })).toBe('saved') + }) + + it('keeps the active sentinel unpinned even when the project has an account', () => { + const getRepo = () => repo({ claude: { mode: 'account', accountId: 'saved' } }) + expect( + resolveProjectClaudeAccount({ + getRepo, + worktreeId, + launchConfigAccountId: ACTIVE_CLAUDE_ACCOUNT + }) + ).toBeUndefined() + }) + + it('returns nothing for ask, no preference, or unknown repos', () => { + expect( + resolveProjectClaudeAccount({ getRepo: () => repo({ claude: { mode: 'ask' } }), worktreeId }) + ).toBeUndefined() + expect(resolveProjectClaudeAccount({ getRepo: () => repo(), worktreeId })).toBeUndefined() + expect(resolveProjectClaudeAccount({ getRepo: () => undefined, worktreeId })).toBeUndefined() + expect( + resolveProjectClaudeAccount({ getRepo: () => repo(), worktreeId: undefined }) + ).toBeUndefined() + }) +}) diff --git a/src/main/claude-accounts/project-claude-account-resolution.ts b/src/main/claude-accounts/project-claude-account-resolution.ts new file mode 100644 index 000000000000..dc70bdc10c75 --- /dev/null +++ b/src/main/claude-accounts/project-claude-account-resolution.ts @@ -0,0 +1,30 @@ +import type { Repo } from '../../shared/repo-types' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { + ACTIVE_CLAUDE_ACCOUNT, + isPinnableClaudeAccountId +} from '../../shared/claude/project-claude-account-preference' + +export function resolveProjectClaudeAccount(input: { + getRepo: (repoId: string) => Repo | undefined + worktreeId?: string + launchConfigAccountId?: string + explicitAccountId?: string +}): string | undefined { + if (isPinnableClaudeAccountId(input.explicitAccountId)) { + return input.explicitAccountId + } + // Why: a launch config records the launch's own choice (incl. "active this time"); it outranks the project default on resume. + if (input.launchConfigAccountId === ACTIVE_CLAUDE_ACCOUNT) { + return undefined + } + if (isPinnableClaudeAccountId(input.launchConfigAccountId)) { + return input.launchConfigAccountId + } + if (!input.worktreeId) { + return undefined + } + const repoId = getRepoIdFromWorktreeId(input.worktreeId) + const preference = repoId ? input.getRepo(repoId)?.agentAccounts?.claude : undefined + return preference?.mode === 'account' ? preference.accountId : undefined +} 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 2c2a13230f60..28820e05a54e 100644 --- a/src/main/ipc/pty-runtime-claude-account-spawn.test.ts +++ b/src/main/ipc/pty-runtime-claude-account-spawn.test.ts @@ -9,6 +9,11 @@ import { hasLivePinnedClaudePtys } from '../claude-accounts/claude-pinned-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import type { Store } from '../persistence' +import type { ProjectClaudeAccountPreference } from '../../shared/claude/project-claude-account-preference' +import { resolveRuntimeSpawnClaudeAccount } from './pty/runtime/spawn-claude-account' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from './pty/runtime/controller-deps' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -88,11 +93,27 @@ const PINNED_PREPARATION: ClaudeRuntimeAuthPreparation = { provenance: 'managed:acct-b:pinned' } +function storeWithRepo(claude: ProjectClaudeAccountPreference): Store { + const repo = { id: 'wt-1', agentAccounts: { claude } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveRuntimeSpawnClaudeAccount only reads getRepo; the rest of Store belongs to unrelated persistence callers this test never exercises. + return { getRepo: (repoId: string) => (repoId === 'wt-1' ? repo : undefined) } as unknown as Store +} + +function runtimeCtx(input: { + args: Partial + deps?: { store?: Store } +}): ReturnType { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveRuntimeSpawnClaudeAccount only reads deps.store; the rest of PtyRuntimeControllerDeps belongs to later spawn stages this test never runs. + const deps = { store: input.deps?.store } as unknown as PtyRuntimeControllerDeps + return createRuntimePtySpawnState(deps, { cols: 80, rows: 24, ...input.args }) +} + describe('runtime PTY spawn pinned to a Claude account', () => { const { mainWindow } = setupPtyIpcSuite() function registerRuntimeController( - prepareClaudeAuth: (...args: unknown[]) => Promise + prepareClaudeAuth: (...args: unknown[]) => Promise, + store?: Store ): RuntimeSpawnController { let controller: RuntimeSpawnController | null = null const runtime = { @@ -113,7 +134,8 @@ describe('runtime PTY spawn pinned to a Claude account', () => { runtime as never, undefined, undefined, - prepareClaudeAuth + prepareClaudeAuth, + store ) if (!controller) { throw new Error('runtime PTY controller was not registered') @@ -281,3 +303,32 @@ describe('runtime PTY spawn pinned to a Claude account', () => { } }) }) + +describe('resolveRuntimeSpawnClaudeAccount: project account fallback', () => { + const worktreeId = 'wt-1' + + it('falls back to the project account when no --account is given', () => { + const ctx = runtimeCtx({ + args: { command: 'claude', launchAgent: 'claude', worktreeId }, + deps: { store: storeWithRepo({ mode: 'account', accountId: 'acct-1' }) } + }) + expect(resolveRuntimeSpawnClaudeAccount(ctx)).toBe('acct-1') + }) + + it('keeps SSH and non-Claude runtime spawns unpinned by project preference', () => { + const store = storeWithRepo({ mode: 'account', accountId: 'acct-1' }) + const sshCtx = runtimeCtx({ + args: { command: 'claude', launchAgent: 'claude', worktreeId, connectionId: 'ssh-1' }, + deps: { store } + }) + const nonClaudeCtx = runtimeCtx({ + args: { command: 'zsh', worktreeId }, + deps: { store } + }) + + expect(() => resolveRuntimeSpawnClaudeAccount(sshCtx)).not.toThrow() + expect(resolveRuntimeSpawnClaudeAccount(sshCtx)).toBeUndefined() + expect(() => resolveRuntimeSpawnClaudeAccount(nonClaudeCtx)).not.toThrow() + expect(resolveRuntimeSpawnClaudeAccount(nonClaudeCtx)).toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 7a9fe0326bb9..f8e3fb5febe9 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,6 +1,6 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { agentHookServer } from '../../../agent-hooks/server' -import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' +import { markClaudePtySpawnedForAuth } from '../claude-pinned-spawn' import { registerPty } from '../../../memory/pty-registry' import type { PtySpawnResult } from '../../../providers/types' import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' @@ -28,6 +28,11 @@ import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty- export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args + // Why first: the run's finally releases a pinned reservation, so a later commit step that throws + // must not leave this live PTY unregistered; a failed persist's cleanup marks it exited again. + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markClaudePtySpawnedForAuth(ctx.result.id, ctx.claudeAuth) + } const { rendererPreSignaled, rendererAlreadyRegistered, committedSize } = await persistPtyIpcSpawnCommit(ctx) @@ -142,9 +147,6 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + // Why exempt pinned: mirrors preflight and the runtime lane; a switch never touches a pinned account. + 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/ipc/spawn-preflight-claude-account.test.ts b/src/main/ipc/pty/ipc/spawn-preflight-claude-account.test.ts new file mode 100644 index 000000000000..0e975ab0f632 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-preflight-claude-account.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ACTIVE_CLAUDE_ACCOUNT } from '../../../../shared/claude/project-claude-account-preference' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../../../claude-accounts/environment' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../../../claude-accounts/live-pty-gate' +import { registerSshPtyProvider, unregisterSshPtyProvider } from '../provider/registry' +import { preparePtyIpcSpawnPreflight } from './spawn-preflight' +import { assemblePtyIpcSpawnEnv } from './spawn-env' +import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' +import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp', isPackaged: false } })) + +const WORKTREE_ID = 'repo-1::/work/repo-1' + +const PINNED_PREPARATION: ClaudeRuntimeAuthPreparation = { + configDir: '/managed/acct-1', + envPatch: { CLAUDE_CONFIG_DIR: '/managed/acct-1' }, + stripAuthEnv: true, + provenance: 'managed:acct-1:pinned', + pinnedAccountId: 'acct-1' +} + +function buildPreflightCtx(input: { + args: Partial + prepareClaudeAuth: PtySpawnIpcDeps['prepareClaudeAuth'] + adoptedStablePane?: AdoptStablePaneResult +}): PtyIpcSpawnState { + const repo = { id: 'repo-1', agentAccounts: { claude: { mode: 'account', accountId: 'acct-1' } } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight and env assembly only read the members stubbed here; the rest belong to later spawn stages this test never runs. + const deps = { + prepareClaudeAuth: input.prepareClaudeAuth, + store: { getRepo: (repoId: string) => (repoId === 'repo-1' ? repo : undefined) }, + transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(), + assertFolderWorkspacePtyPathUsable: vi.fn(), + adoptStablePane: vi.fn(async () => input.adoptedStablePane ?? null), + resolvePtySpawnStartupCwd: (_worktreeId: string | undefined, cwd: string | undefined) => cwd, + localStartupCwdDirectoryExists: () => true, + prepareCodexResumeHome: () => null, + noCodexResumeLaunch: (command: string | undefined) => ({ command }), + stripSequencedStartupResumeArgv: (env: Record | undefined) => env + } as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { + cols: 80, + rows: 24, + worktreeId: WORKTREE_ID, + ...input.args + }) + if (input.adoptedStablePane) { + ctx.earlyStablePaneOwner = input.adoptedStablePane.owner + ctx.earlyWorktreeId = WORKTREE_ID + } + return ctx +} + +describe('renderer pty spawn preflight: project Claude account', () => { + afterEach(() => { + endClaudeAuthSwitch() + unregisterSshPtyProvider('ssh-1') + }) + + it('pins a fresh Claude launch to the project account', async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + const ctx = buildPreflightCtx({ + args: { command: 'claude', launchAgent: 'claude' }, + prepareClaudeAuth + }) + + await preparePtyIpcSpawnPreflight(ctx) + + expect(prepareClaudeAuth).toHaveBeenCalledWith(expect.anything(), { accountId: 'acct-1' }) + expect(ctx.claudeAuth?.pinnedAccountId).toBe('acct-1') + }) + + it('leaves non-Claude, SSH and reattach spawns unpinned', async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight only checks the provider's identity; this SSH spawn never reaches provider.spawn. + registerSshPtyProvider('ssh-1', { spawn: vi.fn() } as never) + const adoptedStablePane: AdoptStablePaneResult = { + result: { id: 'pty-adopted' }, + owner: { tabId: 'tab-1', leafId: 'leaf-1', ptyId: 'pty-adopted' } + } + const variants = [ + buildPreflightCtx({ args: { command: 'zsh' }, prepareClaudeAuth }), + buildPreflightCtx({ + args: { command: 'claude', launchAgent: 'claude', connectionId: 'ssh-1' }, + prepareClaudeAuth + }), + buildPreflightCtx({ + args: { command: 'claude', launchAgent: 'claude' }, + prepareClaudeAuth, + adoptedStablePane + }) + ] + + for (const ctx of variants) { + await preparePtyIpcSpawnPreflight(ctx) + expect(ctx.claudeAuth).toBeNull() + } + expect(prepareClaudeAuth).not.toHaveBeenCalled() + }) + + it('does not block a pinned launch on a global switch in progress through env assembly', async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + beginClaudeAuthSwitch() + const pinned = buildPreflightCtx({ + args: { command: 'claude', launchAgent: 'claude' }, + prepareClaudeAuth + }) + const unpinned = buildPreflightCtx({ + args: { + command: 'claude', + launchAgent: 'claude', + launchConfig: { agentArgs: '', agentEnv: {}, claudeAccountId: ACTIVE_CLAUDE_ACCOUNT } + }, + prepareClaudeAuth + }) + + await expect(preparePtyIpcSpawnPreflight(pinned)).resolves.toBeUndefined() + await expect(assemblePtyIpcSpawnEnv(pinned)).resolves.toBeUndefined() + await expect(preparePtyIpcSpawnPreflight(unpinned)).rejects.toThrow( + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE + ) + expect(pinned.claudeAuth?.pinnedAccountId).toBe('acct-1') + expect(pinned.baseEnv?.CLAUDE_CONFIG_DIR).toBe('/managed/acct-1') + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 07fbbf942b87..18ac4780a599 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -12,10 +12,11 @@ import { normalizeWindowsTerminalCwd } from '../../../providers/windows-shell-ar import { wslUncDirectoryExistsAsync } from '../../../wsl' import { getCodexSelectionTargetForPty } from '../host-env/codex-home' import { - isClaudeLaunchCommand, recoverFreshSpawnProviderRouting, routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' +import { resolveProjectClaudeAccount } from '../../../claude-accounts/project-claude-account-resolution' +import { isFreshClaudeLaunch, preparePinnableClaudeAuth } from '../claude-pinned-spawn' import { getAppPtyId, getProvider, getRelayPtyId } from '../provider/registry' import type { PtyIpcSpawnState } from './spawn-state' @@ -191,9 +192,22 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis } } } - ctx.isClaudeLaunch = - !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + const pinnedClaudeAccountId = + ctx.preAdoptedStablePane || args.connectionId + ? undefined + : resolveProjectClaudeAccount({ + // Why optional: as in the push-target hook, a partial Store must not fail every spawn. + getRepo: (repoId) => ctx.deps.store?.getRepo?.(repoId), + worktreeId: args.worktreeId, + // Why unchecked: launchConfig is untrusted IPC JSON; the resolver validates the id itself. + launchConfigAccountId: args.launchConfig?.claudeAccountId + }) + ctx.isClaudeLaunch = isFreshClaudeLaunch( + { ...args, preAdoptedStablePane: Boolean(ctx.preAdoptedStablePane) }, + 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) } ctx.terminalRuntimeOptions = @@ -227,9 +241,12 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.cwd, ctx.expectedWslDistro ) - ctx.claudeAuth = - ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth - ? await ctx.deps.prepareClaudeAuth(initialSelectionTarget) - : null + ctx.claudeAuth = ctx.isClaudeLaunch + ? await preparePinnableClaudeAuth( + ctx.deps.prepareClaudeAuth, + initialSelectionTarget, + pinnedClaudeAccountId + ) + : null ctx.spawnTiming.mark('auth') } diff --git a/src/main/ipc/pty/ipc/spawn-run-claude-pinned-release.test.ts b/src/main/ipc/pty/ipc/spawn-run-claude-pinned-release.test.ts new file mode 100644 index 000000000000..6beefdc5516c --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-run-claude-pinned-release.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + _internals as pinnedRegistryInternals, + countClaudePinnedAccountUsers, + hasLivePinnedClaudePtys, + reserveClaudePinnedAccount +} from '../../../claude-accounts/claude-pinned-pty-registry' +import { runPtyIpcSpawn } from './spawn-run' +import type { PtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcDeps } from './spawn-types' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp', isPackaged: false } })) +vi.mock('./spawn-begin', () => ({ beginPtyIpcSpawn: vi.fn(async () => undefined) })) +vi.mock('./spawn-preflight', () => ({ + preparePtyIpcSpawnPreflight: vi.fn(async (ctx: PtyIpcSpawnState) => { + // Mirrors auth preparation: a pinned preparation holds a reservation for the spawn to release. + reserveClaudePinnedAccount('acct-1') + ctx.isClaudeLaunch = true + ctx.claudeAuth = { + configDir: '/managed/acct-1', + envPatch: {}, + stripAuthEnv: true, + provenance: 'managed:acct-1:pinned', + pinnedAccountId: 'acct-1' + } + }) +})) +vi.mock('./spawn-env', () => ({ assemblePtyIpcSpawnEnv: vi.fn(async () => {}) })) +vi.mock('./spawn-options', () => ({ buildPtyIpcSpawnOptions: vi.fn(async () => undefined) })) +vi.mock('./spawn-execute', () => ({ + executePtyIpcSpawn: vi.fn(async (ctx: PtyIpcSpawnState) => { + if (ctx.args.command !== 'claude --spawns') { + throw new Error('provider spawn failed') + } + ctx.stablePaneOwner = null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: commit reads only id and optional result fields, all absent here. + ctx.result = { id: 'pty-pinned' } as PtyIpcSpawnState['result'] + }) +})) +vi.mock('./spawn-commit-persist', () => ({ + persistPtyIpcSpawnCommit: vi.fn(async () => ({ + rendererPreSignaled: true, + rendererAlreadyRegistered: false, + committedSize: undefined + })) +})) + +describe('renderer pty spawn run: pinned Claude reservation', () => { + afterEach(() => { + pinnedRegistryInternals.reset() + }) + + it('releases the pinned reservation when a later stage fails', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every stage that reads deps is mocked; the run's own catch/finally only touch the stubbed members. + const deps = { + transitionSpawnHiddenRendererPtyDeliveryState: vi.fn() + } as unknown as PtySpawnIpcDeps + + await expect(runPtyIpcSpawn(deps, { cols: 80, rows: 24, command: 'claude' })).rejects.toThrow( + 'provider spawn failed' + ) + expect(countClaudePinnedAccountUsers('acct-1')).toBe(0) + }) + + it('keeps a live pinned PTY registered when a post-spawn commit step throws', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: commit reaches only runtime.registerPty before the injected throw; every other stage is mocked. + const deps = { + transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(), + runtime: { + registerPty: vi.fn(() => { + throw new Error('registerPty failed') + }) + } + } as unknown as PtySpawnIpcDeps + + await expect( + runPtyIpcSpawn(deps, { cols: 80, rows: 24, command: 'claude --spawns', worktreeId: 'wt-1' }) + ).rejects.toThrow('registerPty failed') + expect(hasLivePinnedClaudePtys('acct-1')).toBe(true) + expect(countClaudePinnedAccountUsers('acct-1')).toBe(1) + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 82e2d33f3830..f85c69eb9f58 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -1,4 +1,5 @@ import { rejectPaneSpawnReservation } from '../pane/spawn-reservation' +import { releasePinnedClaudeReservation } from '../claude-pinned-spawn' import { ptySizes } from '../delivery/visibility-state' import { beginPtyIpcSpawn } from './spawn-begin' import { preparePtyIpcSpawnPreflight } from './spawn-preflight' @@ -77,5 +78,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } finally { ctx.releaseWorktreeSpawn?.() ctx.finishTerminalInstall() + // Why last: a committed pinned PTY is registered by now, so its account never reads as unused. + releasePinnedClaudeReservation(ctx.claudeAuth) } } diff --git a/src/main/ipc/pty/runtime/spawn-claude-account.ts b/src/main/ipc/pty/runtime/spawn-claude-account.ts index 4f8855dc25ec..cfe08b78af6d 100644 --- a/src/main/ipc/pty/runtime/spawn-claude-account.ts +++ b/src/main/ipc/pty/runtime/spawn-claude-account.ts @@ -4,21 +4,40 @@ import { preparePinnableClaudeAuth, markClaudePtySpawnedForAuth } from '../claude-pinned-spawn' +import { resolveProjectClaudeAccount } from '../../../claude-accounts/project-claude-account-resolution' import type { RuntimePtySpawnState } from './spawn-state' -/** The `--account` a fresh runtime spawn must run on, refused where a pinned launch cannot run. */ +/** + * The `--account` a fresh runtime spawn must run on, refused where a pinned launch cannot run. + * With no explicit `--account`, falls back to the project's saved Claude account default; that + * fallback never throws, since it never applies to SSH or non-Claude launches. + */ export function resolveRuntimeSpawnClaudeAccount(ctx: RuntimePtySpawnState): string | undefined { - const accountId = ctx.preAdoptedStablePane ? undefined : ctx.args.claudeAccountId - if (!accountId) { + if (ctx.preAdoptedStablePane) { return undefined } - if (ctx.args.connectionId) { - throw new Error('Claude --account launches are not supported for SSH workspaces.') + const explicit = ctx.args.claudeAccountId + if (explicit) { + 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 explicit } - if (!isClaudeLaunchCommand(ctx.args.command) && ctx.args.launchAgent !== 'claude') { - throw new Error('--account applies only to Claude launches.') + if ( + ctx.args.connectionId || + !isFreshClaudeLaunch({ ...ctx.args, preAdoptedStablePane: false }, undefined, { + trustLaunchAgent: true + }) + ) { + return undefined } - return accountId + return resolveProjectClaudeAccount({ + getRepo: (repoId) => ctx.deps.store?.getRepo?.(repoId), + worktreeId: ctx.args.worktreeId + }) } export function isRuntimeClaudeLaunch( diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 4a0adcf8a770..1839412d22ee 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -108,6 +108,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { agentSessionEnsure: ctx.result.agentSessionEnsure } } + // Why here: first step past the adopted branch, so a later throw cannot leave this live PTY + // unregistered after the run releases its pinned reservation. + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markRuntimeClaudePtySpawned(ctx.result.id, ctx.claudeAuth) + } ptyOwnership.set(ctx.result.id, args.connectionId ?? null) if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) @@ -227,9 +232,6 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (!ctx.stablePaneOwner) { ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) } - if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { - markRuntimeClaudePtySpawned(ctx.result.id, ctx.claudeAuth) - } if (args.telemetry && !ctx.stablePaneOwner) { const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 73669b5a0c34..225154c4b7af 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -12,7 +12,8 @@ export { admitStablePaneAdoption } from './runtime-terminal-pane-identity' export { copySleepingAgentLaunchConfig, inferCapturedClaudeAgentTeamsMode, - mergeTerminalEnvDeletionKeys + mergeTerminalEnvDeletionKeys, + terminalCreateClaudeAccountIdField } from './runtime-agent-launch-resolution' export { buildClaudeAgentTeamsLaunchPlan } from './claude-agent-teams-shim-env' export { diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 227dc20d58db..5ddda1f83563 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -138,7 +138,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate launchOpts.envToDelete, agentTeamsPlan?.envToDelete ), - ...(launchOpts.claudeAccountId ? { claudeAccountId: launchOpts.claudeAccountId } : {}), + ...dependencies.terminalCreateClaudeAccountIdField(launchOpts), resumeProviderSession: launchOpts.resumeProviderSession, telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, 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 39e11f1a59a2..3a18545feb12 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -65,7 +65,8 @@ export function decideWorkerStartMode(args: { } export async function resolveWorkerStartModeOnHost( - runtime: Pick, + runtime: Pick & + Partial>, mode: WorkerStartModeReceipt, worktreeId: string | undefined, agent: TuiAgent | undefined diff --git a/src/main/runtime/runtime-agent-launch-resolution.ts b/src/main/runtime/runtime-agent-launch-resolution.ts index 9f5ddd4bd0a6..0068f240a090 100644 --- a/src/main/runtime/runtime-agent-launch-resolution.ts +++ b/src/main/runtime/runtime-agent-launch-resolution.ts @@ -7,6 +7,20 @@ import type { ProjectExecutionRuntimeResolution } from '../../shared/project-exe import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' +import { isPinnableClaudeAccountId } from '../../shared/claude/project-claude-account-preference' + +/** The `--account` a background terminal create must carry: explicit, or the launch config's one-time pin. */ +export function terminalCreateClaudeAccountIdField(launchOpts: { + claudeAccountId?: string + launchConfig?: Pick +}): { claudeAccountId?: string } { + if (launchOpts.claudeAccountId) { + return { claudeAccountId: launchOpts.claudeAccountId } + } + return isPinnableClaudeAccountId(launchOpts.launchConfig?.claudeAccountId) + ? { claudeAccountId: launchOpts.launchConfig.claudeAccountId } + : {} +} export function mergeTerminalEnvDeletionKeys( first: readonly string[] | undefined, From fbea60139f4bf520946a60e9aa4afd87cffeaf82 Mon Sep 17 00:00:00 2001 From: Ethan Rivas Date: Sat, 26 Sep 2026 10:19:24 -0600 Subject: [PATCH 22/41] feat(claude-accounts): stamp the resolved Claude account on GUI launches New-tab and New Workspace launches resolve the project's saved account in the renderer and record it in the launch config. A launch that pins an account is routed to a terminal, since a structured session has no way to run on a pinned account. --- .../quick-creation-execution.ts | 10 +- .../composer-state/quick-startup-plan.ts | 9 ++ .../src/lib/agent-session-launch-plan.test.ts | 43 ++++++++ .../src/lib/agent-session-launch-plan.ts | 28 ++++- .../src/lib/claude-launch-account.test.ts | 32 ++++++ src/renderer/src/lib/claude-launch-account.ts | 38 +++++++ ...ch-agent-in-new-tab-claude-account.test.ts | 104 ++++++++++++++++++ .../src/lib/launch-agent-in-new-tab.ts | 21 +++- 8 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/lib/claude-launch-account.test.ts create mode 100644 src/renderer/src/lib/claude-launch-account.ts create mode 100644 src/renderer/src/lib/launch-agent-in-new-tab-claude-account.test.ts diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 324bf694025f..caf470e3294e 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -47,6 +47,7 @@ import { buildQuickComposerStartup } from './quick-startup-plan' import { buildQuickCreationRequest } from './quick-creation-request' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' +import { resolveLaunchClaudeAccountId } from '@/lib/claude-launch-account' export function useQuickCreationExecution(input: QuickCreationExecutionInput) { const { @@ -126,6 +127,9 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { const { prompt: quickPrompt, draftPrompt: quickDraftPrompt } = resolveQuickCreateLinkedWorkItemPrompt(promptLinkedWorkItem, trimmedNote) + // The composer's account prompt sets this in a later change. + const composerClaudeAccountId: string | undefined = undefined + const { startupPlan, backendStartup, @@ -139,7 +143,8 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, - telemetrySource + telemetrySource, + claudeAccountId: resolveLaunchClaudeAccountId(selectedRepo, composerClaudeAccountId) }) const startupPolicySettlement = await settleComposerSubmit( @@ -208,7 +213,8 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { }, prompt: quickDraftPrompt ?? quickPrompt, promptDelivery, - initialSessionOptions: startupPlan?.sessionOptions + initialSessionOptions: startupPlan?.sessionOptions, + claudeAccountId: composerClaudeAccountId }).route : 'terminal-tui' const structuredLaunch = agentLaunchRoute === 'structured-native-chat' diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index 486550197814..c7ac34661b34 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -12,6 +12,7 @@ import { import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { tuiAgentToAgentKind } from '@/lib/telemetry' +import { withClaudeLaunchAccount } from '@/lib/claude-launch-account' export type QuickComposerStartupInput = { agent: TuiAgent | null @@ -23,6 +24,7 @@ export type QuickComposerStartupInput = { shell: AgentStartupShell | null | undefined isRemote: boolean telemetrySource: WorktreeCreationRequest['telemetrySource'] + claudeAccountId?: string } export type QuickComposerStartup = { @@ -97,6 +99,13 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui startupPlan.draftPrompt = draftPrompt } } + // Why: stamped before `backendStartup` is derived so the backend and renderer lanes pin alike. + if (startupPlan && agent === 'claude') { + startupPlan = { + ...startupPlan, + launchConfig: withClaudeLaunchAccount(startupPlan.launchConfig, input.claudeAccountId) + } + } const telemetry: AgentStartedTelemetry | null = agent === null ? null diff --git a/src/renderer/src/lib/agent-session-launch-plan.test.ts b/src/renderer/src/lib/agent-session-launch-plan.test.ts index 538144a1ffdd..c584c5969b2f 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.test.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentLaunchRouteStore } from './agent-launch-route-input' +import { ACTIVE_CLAUDE_ACCOUNT } from '../../../shared/claude/project-claude-account-preference' const mocks = vi.hoisted(() => ({ buildAgentLaunchRouteInput: vi.fn(), @@ -160,6 +161,48 @@ describe('planAgentSessionLaunch', () => { }) }) +describe('planAgentSessionLaunch with a pinned Claude account', () => { + const pinnedStoreFixture = { + settings: {}, + repos: [{ id: 'repo-1', agentAccounts: { claude: { mode: 'account', accountId: 'acct-1' } } }] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the planner reads only settings and repos off the store, and routing inputs are mocked. + const pinnedStore = pinnedStoreFixture as unknown as AgentLaunchRouteStore + const workspace = { kind: 'git-worktree' as const, worktreeId: 'repo-1::/repo/wt' } + + beforeEach(() => { + vi.clearAllMocks() + mocks.buildAgentLaunchRouteInput.mockReturnValue(ROUTE_INPUT) + mocks.resolveAgentLaunchRoute.mockReturnValue('structured-native-chat') + }) + + it("downgrades Claude to the terminal on the project's saved account or an explicit one", () => { + expect(planAgentSessionLaunch(pinnedStore, { agent: 'claude', workspace }).route).toBe( + 'terminal-tui' + ) + expect( + planAgentSessionLaunch(store, { + agent: 'claude', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, + claudeAccountId: 'acct-2' + }).route + ).toBe('terminal-tui') + }) + + it('keeps the structured route for the active-account choice and for other agents', () => { + expect( + planAgentSessionLaunch(pinnedStore, { + agent: 'claude', + workspace, + claudeAccountId: ACTIVE_CLAUDE_ACCOUNT + }).route + ).toBe('structured-native-chat') + expect(planAgentSessionLaunch(pinnedStore, { agent: 'codex', workspace }).route).toBe( + 'structured-native-chat' + ) + }) +}) + describe('structuredAgentSessionLaunchFeasible', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 7e72412e4fb6..e7510491c35d 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -1,4 +1,5 @@ import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { isPinnableClaudeAccountId } from '../../../shared/claude/project-claude-account-preference' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { TuiAgent } from '../../../shared/tui-agent' import { @@ -12,6 +13,7 @@ import { type AgentLaunchRoute, type AgentLaunchRoutingInput } from '@/lib/agent-launch-routing' +import { findLaunchRepo, resolveLaunchClaudeAccountId } from '@/lib/claude-launch-account' import type { NativeChatLaunchPromptDelivery } from '@/lib/native-chat-initial-view-mode' import { beginStructuredAgentLaunchSettlement, @@ -24,6 +26,8 @@ import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-sessio export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void + /** A one-time account choice; the project's saved account applies when omitted. */ + claudeAccountId?: string } /** @@ -122,6 +126,28 @@ export function structuredAgentSessionLaunchFeasible( return structuredAgentLaunchSupported({ ...buildAgentLaunchRouteInput(store, args), settings }) } +// Why: a structured session has no account-pinning path, so a pinned Claude launch runs in a terminal. +function launchPinsClaudeAccount( + store: AgentLaunchRouteStore, + request: AgentSessionLaunchRequest +): boolean { + if (request.agent !== 'claude') { + return false + } + const repo = findLaunchRepo(store, request.workspace) + return isPinnableClaudeAccountId(resolveLaunchClaudeAccountId(repo, request.claudeAccountId)) +} + +function resolvePlannedRoute( + store: AgentLaunchRouteStore, + request: AgentSessionLaunchRequest +): AgentLaunchRoute { + const route = resolveAgentLaunchRoute(buildAgentLaunchRouteInput(store, request)) + return route === 'structured-native-chat' && launchPinsClaudeAccount(store, request) + ? 'terminal-tui' + : route +} + /** The one place a launch route is decided. Delivery mode is fixed here too, so the settle loop * later receives exactly the prompt and mode the route was decided on. */ export function planAgentSessionLaunch( @@ -129,7 +155,7 @@ export function planAgentSessionLaunch( request: AgentSessionLaunchRequest ): AgentSessionLaunchPlan { return adoptAgentSessionLaunchVerdict({ - route: resolveAgentLaunchRoute(buildAgentLaunchRouteInput(store, request)), + route: resolvePlannedRoute(store, request), agent: request.agent, ...(request.workspace.worktreeId ? { worktreeId: request.workspace.worktreeId } : {}), ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), diff --git a/src/renderer/src/lib/claude-launch-account.test.ts b/src/renderer/src/lib/claude-launch-account.test.ts new file mode 100644 index 000000000000..196b8c064a48 --- /dev/null +++ b/src/renderer/src/lib/claude-launch-account.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { resolveLaunchClaudeAccountId, withClaudeLaunchAccount } from './claude-launch-account' +import { ACTIVE_CLAUDE_ACCOUNT } from '../../../shared/claude/project-claude-account-preference' + +describe('resolveLaunchClaudeAccountId', () => { + const saved = { + agentAccounts: { + claude: { mode: 'account' as const, accountId: 'acct-1' } + } + } + it('prefers the explicit choice, including the active sentinel', () => { + expect(resolveLaunchClaudeAccountId(saved, 'acct-2')).toBe('acct-2') + expect(resolveLaunchClaudeAccountId(saved, ACTIVE_CLAUDE_ACCOUNT)).toBe(ACTIVE_CLAUDE_ACCOUNT) + }) + it('falls back to the saved account, else nothing', () => { + expect(resolveLaunchClaudeAccountId(saved)).toBe('acct-1') + expect( + resolveLaunchClaudeAccountId({ + agentAccounts: { claude: { mode: 'ask' } } + }) + ).toBeUndefined() + expect(resolveLaunchClaudeAccountId(undefined)).toBeUndefined() + }) + it('stamps the launch config without mutating it', () => { + const base = { agentArgs: '', agentEnv: {} } + expect(withClaudeLaunchAccount(base, 'acct-1')).toEqual({ + ...base, + claudeAccountId: 'acct-1' + }) + expect(withClaudeLaunchAccount(base, undefined)).toBe(base) + }) +}) diff --git a/src/renderer/src/lib/claude-launch-account.ts b/src/renderer/src/lib/claude-launch-account.ts new file mode 100644 index 000000000000..a92996053d9a --- /dev/null +++ b/src/renderer/src/lib/claude-launch-account.ts @@ -0,0 +1,38 @@ +import type { Repo } from '../../../shared/repo-types' +import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import type { AppState } from '@/store/types' +import { getIndexedRepoMap, getIndexedWorktreeById } from '@/store/worktree-repo-index' + +export function resolveLaunchClaudeAccountId( + repo: Pick | undefined, + explicit?: string +): string | undefined { + if (explicit) { + return explicit + } + const preference = repo?.agentAccounts?.claude + return preference?.mode === 'account' ? preference.accountId : undefined +} + +export function withClaudeLaunchAccount( + config: SleepingAgentLaunchConfig, + accountId: string | undefined +): SleepingAgentLaunchConfig { + return accountId ? { ...config, claudeAccountId: accountId } : config +} + +export function findLaunchRepo( + state: Partial>, + workspace: { repoId?: string; worktreeId?: string } +): Repo | undefined { + const { worktreeId } = workspace + const repoId = + workspace.repoId ?? + (worktreeId + ? ((state.worktreesByRepo && + getIndexedWorktreeById(state.worktreesByRepo, worktreeId)?.repoId) ?? + getRepoIdFromWorktreeId(worktreeId)) + : undefined) + return repoId && state.repos ? getIndexedRepoMap(state.repos).get(repoId) : undefined +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-claude-account.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-claude-account.test.ts new file mode 100644 index 000000000000..2dd5714dd03c --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-claude-account.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockQueueTabStartupCommand = vi.fn() + +const store = { + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null + }, + repos: [ + { + id: 'repo-1', + path: '/repo', + connectionId: null, + agentAccounts: { claude: { mode: 'account' as const, accountId: 'acct-1' } } + } + ], + allWorktrees: vi.fn(() => []), + tabsByWorktree: { 'repo-1::/repo/wt': [{ id: 'tab-1' }] }, + openFiles: [], + browserTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + createTab: vi.fn(() => ({ id: 'tab-1' })), + queueTabInitialCwd: vi.fn(), + queueTabStartupCommand: mockQueueTabStartupCommand, + setActiveTabType: vi.fn(), + setTabBarOrder: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => store } +})) + +vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' })) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdFromState: () => null +})) + +vi.mock('@/lib/native-chat-transcript-readability', () => ({ + isNativeChatTranscriptLocalReadable: () => true +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + isWebRuntimeSessionActive: () => false +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => 'local', + getRuntimeEnvironmentIdForWorktree: () => null +})) + +vi.mock('@/components/tab-bar/reconcile-order', () => ({ + reconcileTabOrder: (_stored: unknown, terminalIds: string[]) => terminalIds +})) + +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) + +vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ + seedNativeChatAppliedSessionOptions: vi.fn() +})) + +function queuedLaunchConfig(): unknown { + return mockQueueTabStartupCommand.mock.calls[0]?.[1]?.launchConfig +} + +describe('launchAgentInNewTab Claude account', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("stamps the project's saved account on a Claude launch", async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'claude', worktreeId: 'repo-1::/repo/wt' }) + + expect(queuedLaunchConfig()).toEqual(expect.objectContaining({ claudeAccountId: 'acct-1' })) + }) + + it('prefers a one-time account choice over the saved one', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'repo-1::/repo/wt', + claudeAccountId: 'acct-2' + }) + + expect(queuedLaunchConfig()).toEqual(expect.objectContaining({ claudeAccountId: 'acct-2' })) + }) + + it('leaves other agents unstamped', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'repo-1::/repo/wt' }) + + expect(queuedLaunchConfig()).not.toHaveProperty('claudeAccountId') + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index a91f5fef3337..83b22136df7b 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -27,6 +27,11 @@ import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/na import { launchAgentInStructuredNewTab } from '@/lib/launch-agent-in-new-tab-structured' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' +import { + findLaunchRepo, + resolveLaunchClaudeAccountId, + withClaudeLaunchAccount +} from '@/lib/claude-launch-account' import { planAgentSessionLaunch, type AgentSessionLaunchPlan @@ -65,6 +70,8 @@ export type LaunchAgentInNewTabArgs = { * activation. */ activate?: boolean + /** A one-time Claude account choice; the project's saved account applies when omitted. */ + claudeAccountId?: string /** Keeps a preflighted route authoritative across workspace creation. */ agentSessionLaunchPlan?: AgentSessionLaunchPlan /** Lets a workspace reveal itself before the selected surface opens. */ @@ -122,7 +129,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent onPromptDeliveryUnconfirmed, agentSessionLaunchPlan, beforeSurfaceOpen, - activate + activate, + claudeAccountId } = args const store = useAppStore.getState() const { worktreeSshConnectionId, resolvedLaunchPlatform, isRemote, queuedShell } = @@ -214,7 +222,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent promptDelivery: viewModePromptDelivery, tuiCustomization: { cwd: initialCwd }, initialSessionOptions: startupPlan.sessionOptions, - onPromptDelivered + onPromptDelivered, + ...(claudeAccountId ? { claudeAccountId } : {}) }) if (plan?.route === 'structured-native-chat') { const structured = launchAgentInStructuredNewTab({ @@ -264,7 +273,13 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, + launchConfig: + agent === 'claude' + ? withClaudeLaunchAccount( + startupPlan.launchConfig, + resolveLaunchClaudeAccountId(findLaunchRepo(store, { worktreeId }), claudeAccountId) + ) + : startupPlan.launchConfig, launchAgent: agent, ...(agentArgs !== undefined ? { agentArgsOverride: agentArgs } : {}), ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), From bee7622dc6ec6a9b952963388f2cc28b5facce02 Mon Sep 17 00:00:00 2001 From: Ethan Rivas Date: Sat, 26 Sep 2026 10:19:24 -0600 Subject: [PATCH 23/41] feat(settings): Claude account section and per-project ask setting Add a Claude account picker to repository settings (also for folder projects) and an opt-in "ask per project" toggle under Accounts, both reachable from settings search. The repository project sections move into their own component to keep RepositoryPane under the size limit. --- .../RepositoryClaudeAccountSection.test.tsx | 272 ++++++++++++++++++ .../RepositoryClaudeAccountSection.tsx | 237 +++++++++++++++ .../components/settings/RepositoryPane.tsx | 90 +++--- .../RepositoryProjectSettingsSections.tsx | 110 +++++++ .../accounts-pane-claude-section.test.tsx | 116 ++++++++ .../settings/accounts-pane-claude-section.tsx | 44 +++ .../settings/accounts-search.test.ts | 25 +- .../components/settings/accounts-search.ts | 47 ++- .../settings/repository-claude-account.ts | 62 ++++ .../repository-search-claude-account.test.ts | 22 ++ .../components/settings/repository-search.ts | 25 ++ src/renderer/src/i18n/locales/en.json | 27 +- src/shared/global-settings-types.ts | 2 + .../rpc-contract/client-settings-params.ts | 1 + 14 files changed, 1021 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/settings/RepositoryClaudeAccountSection.test.tsx create mode 100644 src/renderer/src/components/settings/RepositoryClaudeAccountSection.tsx create mode 100644 src/renderer/src/components/settings/RepositoryProjectSettingsSections.tsx create mode 100644 src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx create mode 100644 src/renderer/src/components/settings/repository-claude-account.ts create mode 100644 src/renderer/src/components/settings/repository-search-claude-account.test.ts diff --git a/src/renderer/src/components/settings/RepositoryClaudeAccountSection.test.tsx b/src/renderer/src/components/settings/RepositoryClaudeAccountSection.test.tsx new file mode 100644 index 000000000000..0a3900fbb923 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryClaudeAccountSection.test.tsx @@ -0,0 +1,272 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { RepoAgentAccounts } from '../../../../shared/claude/project-claude-account-preference' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import type { ProviderAccountsSnapshot } from '@/runtime/runtime-provider-accounts-client' +import { RepositoryClaudeAccountSection } from './RepositoryClaudeAccountSection' + +const { fetchSnapshotMock, storeState } = vi.hoisted(() => { + const repos: Repo[] = [] + return { + fetchSnapshotMock: vi.fn(), + storeState: { + settings: null, + repos, + settingsSearchQuery: '' + } + } +}) + +vi.mock('../../store', () => { + const useAppStore = (selector: (state: typeof storeState) => unknown) => selector(storeState) + useAppStore.getState = () => storeState + return { useAppStore } +}) + +vi.mock('@/lib/repo-runtime-owner', () => ({ + getRepoOwnerRoutedSettings: (settings: unknown) => settings +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: vi.fn() +})) + +vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ + fetchProviderAccountsSnapshot: fetchSnapshotMock +})) + +// Why: Radix Select needs pointer/layout APIs happy-dom lacks; a native select keeps value + disabled testable. +vi.mock('../ui/select', () => ({ + Select: ({ + value, + onValueChange, + disabled, + children + }: { + value: string + onValueChange: (value: string) => void + disabled?: boolean + children: React.ReactNode + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ + value, + disabled, + children + }: { + value: string + disabled?: boolean + children: React.ReactNode + }) => ( + + ) +})) + +const DEFAULT_VALUE = '__default__' +const ASK_VALUE = '__ask__' + +const BASE_REPO: Repo = { + id: 'repo-1', + path: '/home/user/project', + displayName: 'My Project', + badgeColor: '#000000', + addedAt: 0 +} + +const ACCOUNT_A: ClaudeManagedAccountSummary = { + id: 'account-a', + email: 'a@x.com', + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 +} + +const ACCOUNT_B: ClaudeManagedAccountSummary = { + id: 'account-b', + email: 'b@y.com', + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 +} + +function snapshot(accounts: ClaudeManagedAccountSummary[]): ProviderAccountsSnapshot { + return { + claude: { accounts, activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } }, + codex: { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + }, + rateLimits: null + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + fetchSnapshotMock.mockReset() + storeState.repos = [] +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +type UpdateRepo = (repoId: string, updates: { agentAccounts?: RepoAgentAccounts | null }) => unknown + +/** updateRepo that echoes the preference back through the store, like a current-version host. */ +function echoingUpdateRepo(): ReturnType> { + return vi.fn(async (repoId, updates) => { + storeState.repos = storeState.repos.map((entry) => + entry.id === repoId ? { ...entry, agentAccounts: updates.agentAccounts ?? undefined } : entry + ) + return true + }) +} + +async function render(repo: Repo, updateRepo: UpdateRepo = echoingUpdateRepo()): Promise { + storeState.repos = [repo] + await act(async () => { + root.render( + React.createElement(RepositoryClaudeAccountSection, { + repo, + updateRepo, + forceVisible: true + }) + ) + }) + // Flush the account-snapshot load kicked off by the mount effect. + await act(async () => {}) +} + +function getSelect(): HTMLSelectElement { + const select = container.querySelector('select') + if (!select) { + throw new Error('account select not found') + } + return select +} + +function findOption(text: string): HTMLOptionElement { + const option = Array.from(container.querySelectorAll('option')).find((entry) => + entry.textContent?.includes(text) + ) + if (!option) { + throw new Error(`option containing "${text}" not found`) + } + return option +} + +async function choose(value: string): Promise { + await act(async () => { + const select = getSelect() + select.value = value + select.dispatchEvent(new Event('change', { bubbles: true })) + }) +} + +describe('RepositoryClaudeAccountSection', () => { + it('starts on Default and lists host accounts', async () => { + fetchSnapshotMock.mockResolvedValue(snapshot([ACCOUNT_A, ACCOUNT_B])) + + await render(BASE_REPO) + + expect(getSelect().value).toBe(DEFAULT_VALUE) + expect(findOption('Default')).toBeTruthy() + expect(findOption('Ask every time')).toBeTruthy() + expect(findOption('a@x.com')).toBeTruthy() + expect(findOption('b@y.com')).toBeTruthy() + }) + + it('saves an account and clears back to Default', async () => { + fetchSnapshotMock.mockResolvedValue(snapshot([ACCOUNT_A, ACCOUNT_B])) + const updateRepo = echoingUpdateRepo() + + await render(BASE_REPO, updateRepo) + await choose('account-a') + + expect(updateRepo).toHaveBeenCalledWith('repo-1', { + agentAccounts: { claude: { mode: 'account', accountId: 'account-a' } } + }) + + // The store echoed the save back; re-render with the updated repo like the real parent would. + const afterSelect = storeState.repos.find((entry) => entry.id === 'repo-1') + if (!afterSelect) { + throw new Error('repo not found after select') + } + await act(async () => { + root.render( + React.createElement(RepositoryClaudeAccountSection, { + repo: afterSelect, + updateRepo, + forceVisible: true + }) + ) + }) + await choose(DEFAULT_VALUE) + + expect(updateRepo).toHaveBeenLastCalledWith('repo-1', { agentAccounts: null }) + }) + + it('saves Ask every time', async () => { + fetchSnapshotMock.mockResolvedValue(snapshot([ACCOUNT_A])) + const updateRepo = echoingUpdateRepo() + + await render(BASE_REPO, updateRepo) + await choose(ASK_VALUE) + + expect(updateRepo).toHaveBeenCalledWith('repo-1', { + agentAccounts: { claude: { mode: 'ask' } } + }) + }) + + it('shows a removed saved account as unavailable', async () => { + fetchSnapshotMock.mockResolvedValue(snapshot([ACCOUNT_A])) + + await render({ + ...BASE_REPO, + agentAccounts: { claude: { mode: 'account', accountId: 'account-missing' } } + }) + + expect(getSelect().value).toBe('account-missing') + const removed = findOption('(unavailable)') + expect(removed.textContent).toBe('Removed account (unavailable)') + expect(removed.disabled).toBe(true) + }) + + it('is disabled with a note for SSH projects', async () => { + fetchSnapshotMock.mockResolvedValue(snapshot([ACCOUNT_A])) + + await render({ ...BASE_REPO, connectionId: 'ssh-1' }) + + expect(getSelect().disabled).toBe(true) + expect(fetchSnapshotMock).not.toHaveBeenCalled() + expect(container.textContent).toContain("aren't supported for this project's host yet") + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryClaudeAccountSection.tsx b/src/renderer/src/components/settings/RepositoryClaudeAccountSection.tsx new file mode 100644 index 000000000000..a18ab67a111f --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryClaudeAccountSection.tsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import type { Repo } from '../../../../shared/repo-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import type { RepoAgentAccounts } from '../../../../shared/claude/project-claude-account-preference' +import { repoAgentAccountsEqual } from '../../../../shared/claude/project-claude-account-preference' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import { useAppStore } from '../../store' +import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { fetchProviderAccountsSnapshot } from '@/runtime/runtime-provider-accounts-client' +import { providerAccountMatchesView } from './provider-account-visibility' +import { translate } from '@/i18n/i18n' +import { searchKeywords } from './settings-search-keywords' +import { + ASK_VALUE, + DEFAULT_VALUE, + buildClaudeAccountOptions, + claudeAccountPinningUnsupportedReason +} from './repository-claude-account' + +type RepositoryClaudeAccountSectionProps = { + repo: Repo + updateRepo: (repoId: string, updates: { agentAccounts?: RepoAgentAccounts | null }) => unknown + forceVisible?: boolean +} + +export function RepositoryClaudeAccountSection({ + repo, + updateRepo, + forceVisible +}: RepositoryClaudeAccountSectionProps): React.JSX.Element | null { + const settings = useAppStore((state) => state.settings) + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [notEnforced, setNotEnforced] = useState(false) + const loadGenerationRef = useRef(0) + const selectLabelId = useId() + + const unsupportedReason = claudeAccountPinningUnsupportedReason(repo) + + // Why: getRepoOwnerRoutedSettings returns a fresh object each render, and the target + // depends on nothing else — key the memo on the id so it stays stable. + const activeRuntimeEnvironmentId = + getRepoOwnerRoutedSettings(settings, repo)?.activeRuntimeEnvironmentId ?? null + const runtimeTarget = useMemo( + () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), + [activeRuntimeEnvironmentId] + ) + const accountVisibilityOptions = useMemo( + () => ({ remoteOwner: runtimeTarget.kind === 'environment', ownerPlatform: null }), + [runtimeTarget] + ) + + const loadAccounts = useCallback(async () => { + if (unsupportedReason) { + return + } + const generation = ++loadGenerationRef.current + setLoading(true) + setError(null) + try { + const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId }) + if (generation !== loadGenerationRef.current) { + return + } + setAccounts( + snapshot.claude.accounts.filter((account) => + providerAccountMatchesView(account, { runtime: 'host' }, accountVisibilityOptions) + ) + ) + } catch (err) { + if (generation !== loadGenerationRef.current) { + return + } + setError( + err instanceof Error + ? err.message + : translate( + 'auto.components.settings.RepositoryClaudeAccountSection.loadFailed', + 'Could not load Claude accounts.' + ) + ) + setAccounts([]) + } finally { + if (generation === loadGenerationRef.current) { + setLoading(false) + } + } + }, [unsupportedReason, activeRuntimeEnvironmentId, accountVisibilityOptions]) + + useEffect(() => { + void loadAccounts() + }, [loadAccounts]) + + const saved = repo.agentAccounts?.claude + const options = buildClaudeAccountOptions({ accounts, saved }) + const selectedValue = !saved ? DEFAULT_VALUE : saved.mode === 'ask' ? ASK_VALUE : saved.accountId + + const applyPreference = async (requested: RepoAgentAccounts | null): Promise => { + if (saving) { + return + } + setSaving(true) + setError(null) + setNotEnforced(false) + try { + const result = await Promise.resolve(updateRepo(repo.id, { agentAccounts: requested })) + if (result === false) { + setError( + translate( + 'auto.components.settings.RepositoryClaudeAccountSection.saveFailed', + 'Could not save the Claude account preference.' + ) + ) + return + } + const echoed = useAppStore + .getState() + .repos.find((entry) => entry.id === repo.id)?.agentAccounts + if (!repoAgentAccountsEqual(requested, echoed ?? null)) { + setNotEnforced(true) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + } + + return ( + +
+
+ {translate( + 'auto.components.settings.RepositoryClaudeAccountSection.title', + 'Claude Account' + )} +
+

+ {translate( + 'auto.components.settings.RepositoryClaudeAccountSection.description', + 'Choose which Claude account starts in this project.' + )} +

+
+ +
+ + {translate( + 'auto.components.settings.RepositoryClaudeAccountSection.selectLabel', + 'Account' + )} + + +
+ + {unsupportedReason ? ( +

+ {translate( + 'auto.components.settings.RepositoryClaudeAccountSection.unsupported', + "Per-project Claude accounts aren't supported for this project's host yet." + )} +

+ ) : null} + {notEnforced ? ( +

+ {translate( + 'auto.components.settings.RepositoryClaudeAccountSection.notEnforced', + 'Saved, but not enforced by this runtime (mixed-version host).' + )} +

+ ) : null} + {error ?

{error}

: null} +
+ ) +} diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index aa08e6df4d43..59a7a8121ccd 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -23,20 +23,17 @@ import { useAppStore } from '../../store' import { getRepositoryIconSectionId } from './repository-settings-targets' import { RepositoryIconPicker } from './RepositoryIconPicker' import { getRepositoryPaneSearchEntries } from './repository-search' -import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput' -import { RepositoryForkSyncSection } from './RepositoryForkSyncSection' -import { RepositoryGitHubAccountSection } from './RepositoryGitHubAccountSection' +import { RepositoryProjectSettingsSections } from './RepositoryProjectSettingsSections' +import { RepositoryClaudeAccountSection } from './RepositoryClaudeAccountSection' import { translate } from '@/i18n/i18n' -import { RepositoryWindowsRuntimeSection } from './RepositoryWindowsRuntimeSection' import { matchesRepositoryIdentitySearch } from './repository-identity-search' -import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection' import { getProjectRuntimeSessionSummary } from './repository-runtime-session-summary' import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host' export { getRepositoryPaneSearchEntries } export { matchesRepositoryIdentitySearch } from './repository-identity-search' -type RepositoryPaneRepoUpdate = Omit< +export type RepositoryPaneRepoUpdate = Omit< Partial, 'sourceControlAi' | 'externalWorktreeVisibility' | 'ghAccount' | 'agentAccounts' > & { @@ -341,56 +338,37 @@ export function RepositoryPane({ {!isFolder ? ( - <> - - - - - - - - - - fetchWorktrees(repoId, { - executionHostId: selectedHostId, - requireAuthoritative: true - }) - } - forceVisible={forceFullPaneForRepoMatch} - /> - - ) : null} + + fetchWorktrees(repoId, { + executionHostId: selectedHostId, + requireAuthoritative: true + }) + } + /> + ) : ( + + )} ) : null, hooksSection, diff --git a/src/renderer/src/components/settings/RepositoryProjectSettingsSections.tsx b/src/renderer/src/components/settings/RepositoryProjectSettingsSections.tsx new file mode 100644 index 000000000000..8dbc35eeae7e --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryProjectSettingsSections.tsx @@ -0,0 +1,110 @@ +import type React from 'react' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Project, ProjectUpdateArgs } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { SettingsSearchEntry } from './settings-search' +import type { ProjectRuntimeSessionSummary } from './repository-runtime-session-summary' +import type { RepositoryPaneRepoUpdate } from './RepositoryPane' +import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' +import { RepositoryWindowsRuntimeSection } from './RepositoryWindowsRuntimeSection' +import { RepositoryForkSyncSection } from './RepositoryForkSyncSection' +import { RepositoryGitHubAccountSection } from './RepositoryGitHubAccountSection' +import { RepositoryClaudeAccountSection } from './RepositoryClaudeAccountSection' +import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection' + +type RepositoryProjectSettingsSectionsProps = { + repo: Repo + selectedProjectSetupId?: string + forceVisible: boolean + searchQuery: string + hostSetupEntries: SettingsSearchEntry[] + project: Project | null + settings: GlobalSettings | null + repoOwnerSettings: GlobalSettings | null + isLocalWindowsProject: boolean + wslAvailable: boolean + wslDistros: string[] + wslCapabilitiesLoading: boolean + runtimeSessionSummary?: ProjectRuntimeSessionSummary + updateProject?: ( + projectId: string, + updates: ProjectUpdateArgs['updates'] + ) => void | Promise + projectRuntimeEntries: SettingsSearchEntry[] + updateSelectedRepo: (repoId: string, updates: RepositoryPaneRepoUpdate) => void | Promise + refreshWorktrees: (repoId: string) => void | Promise +} + +/** The project-scoped settings sections shown for non-folder repos, below Identity. */ +export function RepositoryProjectSettingsSections({ + repo, + selectedProjectSetupId, + forceVisible, + searchQuery, + hostSetupEntries, + project, + settings, + repoOwnerSettings, + isLocalWindowsProject, + wslAvailable, + wslDistros, + wslCapabilitiesLoading, + runtimeSessionSummary, + updateProject, + projectRuntimeEntries, + updateSelectedRepo, + refreshWorktrees +}: RepositoryProjectSettingsSectionsProps): React.JSX.Element { + return ( + <> + + + + + + + + + + + + + ) +} diff --git a/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx new file mode 100644 index 000000000000..0c1bc45cf9f7 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { i18n } from '../../i18n/i18n' +import { useAppStore } from '../../store' +import { renderClaudeAccountsSection } from './accounts-pane-claude-section' +import type { AccountsPaneSectionModel } from './accounts-pane-types' + +function buildModel( + updateSettings: (updates: Partial>) => void +): AccountsPaneSectionModel { + const settings = getDefaultSettings('/tmp') + return { + settings, + updateSettings, + searchQuery: '', + recordFeatureInteraction: vi.fn(), + wslSupportedPlatform: false, + wslAvailable: false, + wslDistros: [], + wslCapabilitiesLoading: false, + localAccountRuntime: { runtime: 'host', label: 'This device' }, + localAccountRuntimeSentenceLabel: 'this device', + isRemoteAccountScope: false, + remoteServerName: null, + remoteAccountScopeNotice: null, + accountRuntime: { runtime: 'host', label: 'This device' }, + accountRuntimeSentenceLabel: 'this device', + accountRuntimeUnavailable: false, + accountVisibilityOptions: { remoteOwner: false, ownerPlatform: null }, + claudeAccounts: { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + }, + claudeAction: 'idle', + visibleClaudeAccounts: [], + systemClaudeActive: true, + setRemoveClaudeTarget: vi.fn(), + runClaudeAccountAction: vi.fn(), + codexAccounts: { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + }, + codexAction: 'idle', + visibleCodexAccounts: [], + systemCodexActive: true, + systemCodexNeedsSignIn: false, + systemCodexMissingSignIn: false, + systemCodexIdentity: undefined, + activeCodexAuthWarning: null, + activeCodexAccountId: null, + codexConfigSync: null, + codexConfigSyncWarning: null, + codexRateLimits: null, + codexRateLimitTarget: { runtime: 'host', wslDistro: null }, + setRemoveCodexTarget: vi.fn(), + runCodexAccountAction: vi.fn(), + recordOpenCodeSettingEdit: vi.fn(), + miniMaxRateLimits: null, + miniMaxApiKeyDraft: '', + setMiniMaxApiKeyDraft: vi.fn(), + miniMaxApiKeyConfigured: false, + saveMiniMaxApiKey: vi.fn(), + clearMiniMaxApiKey: vi.fn(), + miniMaxCookieDraft: '', + setMiniMaxCookieDraft: vi.fn(), + miniMaxConfigured: false, + miniMaxCredentialBusy: false, + saveMiniMaxCookie: vi.fn(), + clearMiniMaxCookie: vi.fn() + } +} + +async function renderSection( + updateSettings: (updates: Partial>) => void +): Promise<{ root: Root; container: HTMLDivElement }> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render(renderClaudeAccountsSection(buildModel(updateSettings))) + }) + return { root, container } +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('renderClaudeAccountsSection', () => { + it('toggles the ask-per-project setting through updateSettings', async () => { + await i18n.changeLanguage('en') + useAppStore.setState({ settingsSearchQuery: '', runtimeEnvironments: [] }) + const updateSettings = vi.fn() + const { root, container } = await renderSection(updateSettings) + + const switchButton = container.querySelector( + '#accounts-claude-ask-per-project button[role="switch"]' + ) + if (!switchButton) { + throw new Error('Ask-per-project switch was not rendered') + } + + await act(async () => { + switchButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ askClaudeAccountPerProject: true }) + root.unmount() + }) +}) diff --git a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx index e99758f0db54..0d00e991d406 100644 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx @@ -4,8 +4,10 @@ import { selectClaudeProviderAccount } from '@/runtime/runtime-provider-accounts import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { ClaudeIcon } from '../status-bar/icons' import { SearchableSetting } from './SearchableSetting' +import { getAskClaudeAccountPerProjectSearchKeywords } from './accounts-search' import { getProviderAccountRuntime, providerAccountIsActiveInView @@ -27,6 +29,7 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re setRemoveClaudeTarget, settings, systemClaudeActive, + updateSettings, visibleClaudeAccounts, wslCapabilitiesLoading } = model @@ -289,6 +292,47 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re )} + + +
+
+ +

+ {translate( + 'auto.components.settings.AccountsPane.askClaudeAccountPerProjectDescription', + 'When on, projects without a saved Claude account ask before starting Claude.' + )} +

+
+ { + updateSettings({ askClaudeAccountPerProject: checked }) + }} + /> +
+
) } diff --git a/src/renderer/src/components/settings/accounts-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts index 6e027fddbede..0eaa99a68030 100644 --- a/src/renderer/src/components/settings/accounts-search.test.ts +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -15,10 +15,13 @@ vi.mock('./settings-search-keywords', () => ({ translateSearchKeyword: (_key: string, fallback: string) => [fallback] })) +import { matchesSettingsSearch } from './settings-search' import { + getAccountsClaudeSearchEntries, getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, - getAccountsPaneSearchEntries + getAccountsPaneSearchEntries, + getAskClaudeAccountPerProjectSearchKeywords } from './accounts-search' describe('getAccountsMiniMaxSearchEntries', () => { @@ -47,6 +50,26 @@ describe('getAccountsMiniMaxSearchEntries', () => { }) }) +describe('getAccountsClaudeSearchEntries ask-per-project entry', () => { + it('stays visible under the same query that opens the Claude section', () => { + const toggleEntry = getAccountsClaudeSearchEntries().find( + (entry) => entry.title === 'Ask which Claude account to use for each project' + ) + expect(toggleEntry).toBeDefined() + + // Why: "Accounts" matches the sibling "Claude Accounts" entry and used to + // hide this toggle because its own keywords lacked the plural form. + expect(matchesSettingsSearch('Accounts', toggleEntry!)).toBe(true) + expect(matchesSettingsSearch('ask which', toggleEntry!)).toBe(true) + }) + + it('exposes the same keywords to the toggle SearchableSetting', () => { + expect(getAskClaudeAccountPerProjectSearchKeywords()).toEqual( + expect.arrayContaining(['accounts', 'ask which', 'per project', 'prompt']) + ) + }) +}) + describe('getAccountsOpencodeSearchEntries', () => { it('tells search to paste the full Cookie header including the console session', () => { const cookieEntry = getAccountsOpencodeSearchEntries().find( diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 856e22213caf..a45c501eda89 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -22,6 +22,50 @@ export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [ } ]) +const getAskClaudeAccountPerProjectSearchEntry = createLocalizedCatalog( + (): SettingsSearchEntry => ({ + title: translate( + 'auto.components.settings.AccountsPane.askClaudeAccountPerProjectTitle', + 'Ask which Claude account to use for each project' + ), + description: translate( + 'auto.components.settings.AccountsPane.askClaudeAccountPerProjectDescription', + 'When on, projects without a saved Claude account ask before starting Claude.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'), + ...translateSearchKeyword('auto.components.settings.accounts.search.06662af91e', 'account'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.askPerProjectAccounts', + 'accounts' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.askPerProjectProject', + 'project' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.askPerProjectPerProject', + 'per project' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.askPerProjectAsk', 'ask'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.askPerProjectAskWhich', + 'ask which' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.askPerProjectPrompt', + 'prompt' + ) + ] + }) +) + +// Why: the toggle's own SearchableSetting needs the identical keyword set so a +// query that opens this pane section also keeps the toggle itself visible. +export function getAskClaudeAccountPerProjectSearchKeywords(): string[] { + return getAskClaudeAccountPerProjectSearchEntry().keywords ?? [] +} + export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.accounts.search.75682e1b62', 'Claude Accounts'), @@ -41,7 +85,8 @@ export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.accounts.search.c759741d77', 'quota'), ...translateSearchKeyword('auto.components.settings.accounts.search.f2d666a886', 'optional') ] - } + }, + getAskClaudeAccountPerProjectSearchEntry() ]) export const getAccountsCodexSearchEntries = createLocalizedCatalog(() => [ diff --git a/src/renderer/src/components/settings/repository-claude-account.ts b/src/renderer/src/components/settings/repository-claude-account.ts new file mode 100644 index 000000000000..c160f897a821 --- /dev/null +++ b/src/renderer/src/components/settings/repository-claude-account.ts @@ -0,0 +1,62 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import type { ProjectClaudeAccountPreference } from '../../../../shared/claude/project-claude-account-preference' +import { translate } from '@/i18n/i18n' + +export const DEFAULT_VALUE = '__default__' +export const ASK_VALUE = '__ask__' + +export type ClaudeAccountOption = { + value: string + label: string + disabled?: boolean +} + +function claudeAccountLabel(account: ClaudeManagedAccountSummary): string { + return account.organizationName ? `${account.email} · ${account.organizationName}` : account.email +} + +export function buildClaudeAccountOptions(input: { + accounts: ClaudeManagedAccountSummary[] + saved?: ProjectClaudeAccountPreference +}): ClaudeAccountOption[] { + const options: ClaudeAccountOption[] = [ + { + value: DEFAULT_VALUE, + label: translate('auto.components.settings.RepositoryClaudeAccountSection.default', 'Default') + }, + { + value: ASK_VALUE, + label: translate( + 'auto.components.settings.RepositoryClaudeAccountSection.ask', + 'Ask every time' + ) + }, + ...input.accounts.map((account) => ({ + value: account.id, + label: claudeAccountLabel(account) + })) + ] + + const saved = input.saved + if ( + saved?.mode === 'account' && + !input.accounts.some((account) => account.id === saved.accountId) + ) { + options.push({ + value: saved.accountId, + label: translate( + 'auto.components.settings.RepositoryClaudeAccountSection.removedAccountOption', + 'Removed account (unavailable)' + ), + disabled: true + }) + } + + return options +} + +// Why: SSH repos spawn Claude on a remote host that never sees the local managed-account store. +export function claudeAccountPinningUnsupportedReason(repo: Repo): 'ssh' | null { + return repo.connectionId ? 'ssh' : null +} diff --git a/src/renderer/src/components/settings/repository-search-claude-account.test.ts b/src/renderer/src/components/settings/repository-search-claude-account.test.ts new file mode 100644 index 000000000000..01a1fb21b27a --- /dev/null +++ b/src/renderer/src/components/settings/repository-search-claude-account.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { getRepositoryPaneSearchEntries } from './repository-search' + +const baseRepo: Repo = { + id: 'repo-1', + path: '/work/notes', + displayName: 'Notes', + badgeColor: '#000', + addedAt: 0 +} + +function hasClaudeAccountEntry(repo: Repo): boolean { + return getRepositoryPaneSearchEntries(repo).some((entry) => entry.title === 'Claude Account') +} + +describe('repository search: Claude Account', () => { + it('lists the section for folder projects as well as git projects', () => { + expect(hasClaudeAccountEntry(baseRepo)).toBe(true) + expect(hasClaudeAccountEntry({ ...baseRepo, kind: 'folder' })).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index cb0b90973d89..89944d37dac9 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -110,6 +110,31 @@ export function getRepositoryPaneSearchEntries( } ] : []), + { + title: translate( + 'auto.components.settings.repository.search.claudeAccount', + 'Claude Account' + ), + description: translate( + 'auto.components.settings.repository.search.claudeAccountDescription', + 'Choose which Claude account starts in this project.' + ), + keywords: [ + repo.displayName, + ...translateSearchKeyword( + 'auto.components.settings.repository.search.claudeAccountKeyword', + 'claude account' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.claudeAccountsKeyword', + 'claude accounts' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.accountsKeyword', + 'accounts' + ) + ] + }, ...(repo.upstream && !isFolder ? [ { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7284c8b41e8f..bbb7e7103e37 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6616,6 +6616,8 @@ "remoteServerFallback": "the remote server", "loadAccountsFailed": "Could not load provider accounts.", "remoteScopeAccounts": "Showing accounts managed by {{value0}}. Add or re-authenticate accounts on that server.", + "askClaudeAccountPerProjectTitle": "Ask which Claude account to use for each project", + "askClaudeAccountPerProjectDescription": "When on, projects without a saved Claude account ask before starting Claude.", "accountScopePrefix": "Account scope", "accountScopeRemoteServerUnnamed": "Remote server", "remoteScopeLocalAccountsKept": "Accounts managed on this desktop are unchanged. Switch the default runtime back to Local desktop to view them.", @@ -9066,7 +9068,13 @@ "b2c4e7f1a8": "endpoint", "3a9b6d2c4e": "api key", "5d8f1a3b7c": "china", - "7e2a4b8c1d": "overseas" + "7e2a4b8c1d": "overseas", + "askPerProjectAccounts": "accounts", + "askPerProjectProject": "project", + "askPerProjectPerProject": "per project", + "askPerProjectAsk": "ask", + "askPerProjectAskWhich": "ask which", + "askPerProjectPrompt": "prompt" } }, "advanced": { @@ -10395,6 +10403,11 @@ "ghAccount": "gh account", "login": "login", "token": "token", + "claudeAccount": "Claude Account", + "claudeAccountDescription": "Choose which Claude account starts in this project.", + "claudeAccountKeyword": "claude account", + "claudeAccountsKeyword": "claude accounts", + "accountsKeyword": "accounts", "externalWorktrees": "External worktrees", "externalWorktreesDescription": "Override whether worktrees created outside Orca appear for this project.", "waitForSetupBeforeAgent": "wait for setup before starting agent", @@ -12003,6 +12016,18 @@ "notEnforced": "Saved, but not enforced by this runtime (mixed-version host).", "refreshAccounts": "Refresh GitHub accounts" }, + "RepositoryClaudeAccountSection": { + "title": "Claude Account", + "description": "Choose which Claude account starts in this project.", + "selectLabel": "Account", + "default": "Default", + "ask": "Ask every time", + "unsupported": "Per-project Claude accounts aren't supported for this project's host yet.", + "saveFailed": "Could not save the Claude account preference.", + "notEnforced": "Saved, but not enforced by this runtime (mixed-version host).", + "loadFailed": "Could not load Claude accounts.", + "removedAccountOption": "Removed account (unavailable)" + }, "shareSkills": { "title": "Share Skills", "description": "Share your skills with an unlisted link. Anyone who has it can install them.", diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 1e0410f4c227..6dd98c4b880f 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -417,6 +417,8 @@ export type GlobalSettings = { agentYoloDefaultsMigrated?: boolean /** Why: disabling must persist so startup doesn't reinstall global agent hook entries the user just removed. */ agentStatusHooksEnabled: boolean + /** When true, projects without a saved Claude account prompt for one before launching Claude. */ + askClaudeAccountPerProject?: boolean /** Dismissed freshness tuples: no write authority, just suppress re-nudging the same official placement/revision. */ dismissedSkillFreshnessNudges?: string[] /** Why: generated tab titles are subjective, so they stay opt-in and manual renames win. */ diff --git a/src/shared/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts index fd0c2f986a93..3cc3f5840a02 100644 --- a/src/shared/rpc-contract/client-settings-params.ts +++ b/src/shared/rpc-contract/client-settings-params.ts @@ -109,6 +109,7 @@ export const SettingsUpdate = z .optional(), experimentalNewWorktreeCardStyle: z.boolean().optional(), agentStatusHooksEnabled: z.boolean().optional(), + askClaudeAccountPerProject: z.boolean().optional(), defaultRepoSelection: z.array(z.string()).nullable().optional(), defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), compactWorktreeCards: z.boolean().optional(), From 1e466f9c469296085a786cbc9e6bb00a644ac7ba Mon Sep 17 00:00:00 2001 From: Ethan Rivas Date: Sat, 26 Sep 2026 10:19:24 -0600 Subject: [PATCH 24/41] feat(claude-accounts): ask which account to use per project When the ask setting is on and a project has no saved account, Claude launches from the tab bar, quick launch and New Workspace first show a prompt to pick an account, with an option to remember the choice for the project. --- src/renderer/src/App.tsx | 2 + .../ClaudeAccountPromptDialog.test.tsx | 202 ++++++++++++++++++ .../ClaudeAccountPromptDialog.tsx | 157 ++++++++++++++ .../choose-claude-launch-account.test.ts | 75 +++++++ .../choose-claude-launch-account.ts | 158 ++++++++++++++ .../claude-account-prompt-state.ts | 55 +++++ .../settings/repository-claude-account.ts | 2 +- .../components/tab-bar/QuickLaunchButton.tsx | 25 ++- .../use-tab-bar-create-menu-controller.ts | 49 +++-- .../components/use-terminal-create-actions.ts | 32 +-- .../quick-creation-execution.ts | 8 +- src/renderer/src/i18n/locales/en.json | 9 + 12 files changed, 730 insertions(+), 44 deletions(-) create mode 100644 src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.test.tsx create mode 100644 src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.tsx create mode 100644 src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.test.ts create mode 100644 src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.ts create mode 100644 src/renderer/src/components/claude-account-prompt/claude-account-prompt-state.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 903c8b81447f..ce3face71b6c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' import RunningTerminalCloseDialog from './components/terminal-pane/RunningTerminalCloseDialog' import WorktreeBaseFallbackDialog from './components/WorktreeBaseFallbackDialog' +import ClaudeAccountPromptDialog from './components/claude-account-prompt/ClaudeAccountPromptDialog' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { AppBackgroundServices } from './app-shell/AppBackgroundServices' import { AppRootSurfaces } from './app-shell/AppRootSurfaces' @@ -106,6 +107,7 @@ function App(): React.JSX.Element { + {/* Why: Electron's drag-region hit-test is DOM-order-based (ignores z-index); render last so WindowControls stay clickable. */} diff --git a/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.test.tsx b/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.test.tsx new file mode 100644 index 000000000000..d16b557c1b5f --- /dev/null +++ b/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.test.tsx @@ -0,0 +1,202 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import { useAppStore } from '@/store' +import ClaudeAccountPromptDialog from './ClaudeAccountPromptDialog' +import { resetClaudeAccountPromptForTests } from './claude-account-prompt-state' +import { chooseClaudeLaunchAccount } from './choose-claude-launch-account' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' + +// Why: Radix Select needs pointer/layout APIs happy-dom lacks; a native select keeps the value testable. +vi.mock('@/components/ui/select', () => ({ + Select: ({ + value, + onValueChange, + children + }: { + value: string + onValueChange: (value: string) => void + children: React.ReactNode + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => ( + + ) +})) + +const initialState = useAppStore.getInitialState() +let root: Root | null = null + +function account(id: string, organizationName?: string): ClaudeManagedAccountSummary { + return { + id, + email: `${id}@example.com`, + organizationName, + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Orca', + badgeColor: '#000', + addedAt: 0, + agentAccounts: { claude: { mode: 'ask' } } +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prompt only reads askClaudeAccountPerProject. +const settings = {} as GlobalSettings + +async function startPrompt(updateRepo = vi.fn(), surface: React.ReactNode = null) { + const choice = chooseClaudeLaunchAccount({ + repo, + settings, + accounts: [account('work', 'Acme'), account('personal')], + activeAccountId: 'personal', + updateRepo + }) + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render( + <> + {surface} + + + ) + }) + return { choice, updateRepo } +} + +function getButton(label: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!button) { + throw new Error(`Button not found: ${label}`) + } + return button +} + +function getSelect(): HTMLSelectElement { + const select = document.body.querySelector('select') + if (!select) { + throw new Error('Select not found') + } + return select +} + +describe('ClaudeAccountPromptDialog', () => { + beforeEach(() => { + useAppStore.setState( + { ...initialState, activeModal: 'none', setContextualToursBlockingSurfaceVisible: vi.fn() }, + true + ) + }) + + afterEach(async () => { + await act(async () => { + root?.unmount() + }) + root = null + document.body.innerHTML = '' + resetClaudeAccountPromptForTests() + useAppStore.setState(initialState, true) + }) + + it('pre-selects the active account and cancels', async () => { + const { choice, updateRepo } = await startPrompt() + + expect(document.body.textContent).toContain('Choose a Claude account') + expect(document.body.textContent).toContain('Orca') + expect(document.body.textContent).toContain('work@example.com · Acme') + expect(getSelect().value).toBe('personal') + + await act(async () => { + getButton('Cancel').click() + }) + + await expect(choice).resolves.toEqual({ kind: 'cancelled' }) + expect(updateRepo).not.toHaveBeenCalled() + }) + + it('remembers the pick for the project when Remember is checked', async () => { + const { choice, updateRepo } = await startPrompt() + + await act(async () => { + const select = getSelect() + select.value = 'work' + select.dispatchEvent(new Event('change', { bubbles: true })) + }) + await act(async () => { + getButton('Start').click() + }) + + await expect(choice).resolves.toEqual({ kind: 'default' }) + expect(updateRepo).toHaveBeenCalledWith('repo-1', { + agentAccounts: { claude: { mode: 'account', accountId: 'work' } } + }) + }) + + it('uses the pick for this launch only when Remember is unchecked', async () => { + const { choice, updateRepo } = await startPrompt() + + await act(async () => { + document.body.querySelector('[role="checkbox"]')?.click() + }) + await act(async () => { + getButton('Start').click() + }) + + await expect(choice).resolves.toEqual({ kind: 'account', accountId: 'personal' }) + expect(updateRepo).not.toHaveBeenCalled() + }) + + it('opens over the New Workspace composer that is awaiting it', async () => { + useAppStore.setState({ activeModal: 'new-workspace-composer' }) + const onComposerOpenChange = vi.fn() + const composer = ( + + + New workspace + + + ) + const { choice } = await startPrompt(vi.fn(), composer) + + expect(document.body.textContent).toContain('Choose a Claude account') + await act(async () => { + document.body.querySelector('[role="checkbox"]')?.click() + }) + await act(async () => { + getButton('Start').click() + }) + + await expect(choice).resolves.toEqual({ kind: 'account', accountId: 'personal' }) + expect(onComposerOpenChange).not.toHaveBeenCalled() + expect(useAppStore.getState().activeModal).toBe('new-workspace-composer') + }) + + it('stays hidden behind an unrelated modal', async () => { + useAppStore.setState({ activeModal: 'confirm-orca-yaml-hooks' }) + await startPrompt() + + expect(document.body.textContent).not.toContain('Choose a Claude account') + }) +}) diff --git a/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.tsx b/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.tsx new file mode 100644 index 000000000000..b3fa30283641 --- /dev/null +++ b/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.tsx @@ -0,0 +1,157 @@ +import { useEffect, useId, useState, useSyncExternalStore } from 'react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { claudeAccountLabel } from '../settings/repository-claude-account' +import { + answerClaudeAccountPrompt, + getClaudeAccountPromptRequest, + subscribeClaudeAccountPrompt, + type ClaudeAccountPromptRequest +} from './claude-account-prompt-state' + +function initialAccountId(request: ClaudeAccountPromptRequest | null): string { + if (!request) { + return '' + } + const active = request.accounts.find((account) => account.id === request.activeAccountId) + return (active ?? request.accounts[0])?.id ?? '' +} + +export default function ClaudeAccountPromptDialog(): React.JSX.Element { + const request = useSyncExternalStore( + subscribeClaudeAccountPrompt, + getClaudeAccountPromptRequest, + getClaudeAccountPromptRequest + ) + const activeModal = useAppStore((state) => state.activeModal) + const setContextualToursBlockingSurfaceVisible = useAppStore( + (state) => state.setContextualToursBlockingSurfaceVisible + ) + const [accountId, setAccountId] = useState(() => initialAccountId(request)) + const [remember, setRemember] = useState(true) + // Why: keeps the last request rendered while the dialog animates closed. + const [displayed, setDisplayed] = useState(request) + const selectLabelId = useId() + const rememberId = useId() + // Why: New Workspace awaits this prompt while its composer modal stays open, so it layers on top + // like the composer's other nested dialogs instead of waiting for a modal that never closes. + const open = + request !== null && (activeModal === 'none' || activeModal === 'new-workspace-composer') + + if (request && request !== displayed) { + setDisplayed(request) + setAccountId(initialAccountId(request)) + setRemember(true) + } + + useEffect(() => { + setContextualToursBlockingSurfaceVisible(open) + return () => setContextualToursBlockingSurfaceVisible(false) + }, [open, setContextualToursBlockingSurfaceVisible]) + + return ( + { + if (!isOpen) { + answerClaudeAccountPrompt({ kind: 'cancelled' }) + } + }} + > + + + + {translate( + 'auto.components.ClaudeAccountPromptDialog.title', + 'Choose a Claude account' + )} + + + {translate( + 'auto.components.ClaudeAccountPromptDialog.description', + 'Pick the Claude account to start in {{value0}}.', + { value0: displayed?.projectName ?? '' } + )} + + +
+
+ + {translate('auto.components.ClaudeAccountPromptDialog.accountLabel', 'Account')} + + +
+
+
+ setRemember(checked === true)} + /> + +
+

+ {translate( + 'auto.components.ClaudeAccountPromptDialog.rememberHint', + 'You can change this in Settings → Repository' + )} +

+
+
+ + + + +
+
+ ) +} diff --git a/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.test.ts b/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.test.ts new file mode 100644 index 000000000000..e96dad1c237f --- /dev/null +++ b/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import { shouldPromptForClaudeAccount } from './choose-claude-launch-account' + +function account(id: string): ClaudeManagedAccountSummary { + return { + id, + email: `${id}@example.com`, + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function repo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0, + ...overrides + } +} + +const twoAccounts = [account('a'), account('b')] + +describe('shouldPromptForClaudeAccount', () => { + it('does not prompt without a preference while the setting is off', () => { + expect( + shouldPromptForClaudeAccount({ + repo: repo(), + settings: { askClaudeAccountPerProject: false }, + accounts: twoAccounts + }) + ).toBe(false) + expect( + shouldPromptForClaudeAccount({ + repo: repo(), + settings: { askClaudeAccountPerProject: true }, + accounts: twoAccounts + }) + ).toBe(true) + }) + + it('prompts for an ask preference with two or more accounts', () => { + const askRepo = repo({ agentAccounts: { claude: { mode: 'ask' } } }) + expect( + shouldPromptForClaudeAccount({ repo: askRepo, settings: {}, accounts: twoAccounts }) + ).toBe(true) + expect( + shouldPromptForClaudeAccount({ repo: askRepo, settings: {}, accounts: [account('a')] }) + ).toBe(false) + }) + + it('never prompts for SSH repos or a saved account', () => { + const settings = { askClaudeAccountPerProject: true } + expect( + shouldPromptForClaudeAccount({ + repo: repo({ connectionId: 'ssh-1', agentAccounts: { claude: { mode: 'ask' } } }), + settings, + accounts: twoAccounts + }) + ).toBe(false) + expect( + shouldPromptForClaudeAccount({ + repo: repo({ agentAccounts: { claude: { mode: 'account', accountId: 'a' } } }), + settings, + accounts: twoAccounts + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.ts b/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.ts new file mode 100644 index 000000000000..9b3be54b664a --- /dev/null +++ b/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.ts @@ -0,0 +1,158 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import type { RepoAgentAccounts } from '../../../../shared/claude/project-claude-account-preference' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { useAppStore } from '@/store' +import { findLaunchRepo, resolveLaunchClaudeAccountId } from '@/lib/claude-launch-account' +import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' +import { + fetchProviderAccountsSnapshot, + hasRemoteProviderAccountOwner +} from '@/runtime/runtime-provider-accounts-client' +import { providerAccountMatchesView } from '../settings/provider-account-visibility' +import { requestClaudeAccountPrompt } from './claude-account-prompt-state' + +export type ClaudeLaunchAccountChoice = + | { kind: 'default' } + | { kind: 'account'; accountId: string } + | { kind: 'cancelled' } + +type PromptEligibilityInput = { + repo: Repo | undefined + settings: Pick | null | undefined +} + +function claudeAccountPromptMayApply( + repo: Repo | undefined, + settings: PromptEligibilityInput['settings'] +): repo is Repo { + if (!repo || repo.connectionId) { + return false + } + const preference = repo.agentAccounts?.claude + if (preference) { + return preference.mode === 'ask' + } + return settings?.askClaudeAccountPerProject === true +} + +export function shouldPromptForClaudeAccount( + input: PromptEligibilityInput & { accounts: ClaudeManagedAccountSummary[] } +): boolean { + return claudeAccountPromptMayApply(input.repo, input.settings) && input.accounts.length >= 2 +} + +export async function chooseClaudeLaunchAccount(input: { + repo: Repo | undefined + settings: GlobalSettings + accounts: ClaudeManagedAccountSummary[] + activeAccountId: string | null + updateRepo: (repoId: string, u: { agentAccounts: RepoAgentAccounts }) => unknown +}): Promise { + const { repo } = input + if (!repo || !shouldPromptForClaudeAccount(input)) { + return { kind: 'default' } + } + const answer = await requestClaudeAccountPrompt({ + projectName: repo.displayName, + accounts: input.accounts, + activeAccountId: input.activeAccountId + }) + if (answer.kind === 'cancelled') { + return answer + } + if (!answer.remember) { + return { kind: 'account', accountId: answer.accountId } + } + try { + const saved = await Promise.resolve( + input.updateRepo(repo.id, { + agentAccounts: { claude: { mode: 'account', accountId: answer.accountId } } + }) + ) + // Why: if saving failed, still honor the pick for this launch. + return saved === false ? { kind: 'account', accountId: answer.accountId } : { kind: 'default' } + } catch { + return { kind: 'account', accountId: answer.accountId } + } +} + +export async function chooseClaudeLaunchAccountForWorkspace(workspace: { + repoId?: string + worktreeId?: string +}): Promise { + const state = useAppStore.getState() + const repo = findLaunchRepo(state, workspace) + const { settings } = state + if (!settings || !claudeAccountPromptMayApply(repo, settings)) { + return { kind: 'default' } + } + const routedSettings = getRepoOwnerRoutedSettings(settings, repo) + let accounts: ClaudeManagedAccountSummary[] + let activeAccountId: string | null + try { + const snapshot = await fetchProviderAccountsSnapshot(routedSettings) + // Why: fail open to the unprompted launch; main still enforces any saved account. + if (snapshot.failedProviders?.includes('claude')) { + return { kind: 'default' } + } + const visibility = { + remoteOwner: hasRemoteProviderAccountOwner(routedSettings), + ownerPlatform: null + } + accounts = snapshot.claude.accounts.filter((account) => + providerAccountMatchesView(account, { runtime: 'host' }, visibility) + ) + activeAccountId = + snapshot.claude.activeAccountIdsByRuntime?.host ?? snapshot.claude.activeAccountId + } catch { + return { kind: 'default' } + } + const hostId = getRepoExecutionHostId(repo) + return chooseClaudeLaunchAccount({ + repo, + settings, + accounts, + activeAccountId, + updateRepo: (repoId, updates) => useAppStore.getState().updateRepo(repoId, updates, { hostId }) + }) +} + +// Why: a remembered pick lands in the store, so resolve it there rather than from a caller's repo snapshot. +export async function chooseClaudeLaunchAccountIdForRepo( + repoId: string +): Promise { + const choice = await chooseClaudeLaunchAccountForWorkspace({ repoId }) + if (choice.kind === 'cancelled') { + return null + } + return choice.kind === 'account' + ? choice.accountId + : resolveLaunchClaudeAccountId(findLaunchRepo(useAppStore.getState(), { repoId })) +} + +// Why: launch synchronously when no prompt can apply so menu-close focus handoffs keep today's timing. +export function launchWithClaudeAccountChoice( + agent: TuiAgent, + target: { repoId?: string; worktreeId?: string; afterDeferredLaunch?: () => void }, + launch: (claudeAccountId: string | undefined, deferred: boolean) => void +): void { + const state = useAppStore.getState() + if ( + agent !== 'claude' || + !claudeAccountPromptMayApply(findLaunchRepo(state, target), state.settings) + ) { + launch(undefined, false) + return + } + void (async () => { + const choice = await chooseClaudeLaunchAccountForWorkspace(target) + if (choice.kind === 'cancelled') { + return + } + launch(choice.kind === 'account' ? choice.accountId : undefined, true) + target.afterDeferredLaunch?.() + })() +} diff --git a/src/renderer/src/components/claude-account-prompt/claude-account-prompt-state.ts b/src/renderer/src/components/claude-account-prompt/claude-account-prompt-state.ts new file mode 100644 index 000000000000..2a7e3e6ac215 --- /dev/null +++ b/src/renderer/src/components/claude-account-prompt/claude-account-prompt-state.ts @@ -0,0 +1,55 @@ +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' + +export type ClaudeAccountPromptAnswer = + | { kind: 'start'; accountId: string; remember: boolean } + | { kind: 'cancelled' } + +export type ClaudeAccountPromptRequest = { + projectName: string + accounts: ClaudeManagedAccountSummary[] + activeAccountId: string | null + resolve: (answer: ClaudeAccountPromptAnswer) => void +} + +const requests: ClaudeAccountPromptRequest[] = [] +const listeners = new Set<() => void>() + +function notifyListeners(): void { + for (const listener of listeners) { + listener() + } +} + +export function requestClaudeAccountPrompt( + request: Omit +): Promise { + return new Promise((resolve) => { + requests.push({ ...request, resolve }) + notifyListeners() + }) +} + +export function answerClaudeAccountPrompt(answer: ClaudeAccountPromptAnswer): void { + const request = requests.shift() + if (!request) { + return + } + request.resolve(answer) + notifyListeners() +} + +export function getClaudeAccountPromptRequest(): ClaudeAccountPromptRequest | null { + return requests[0] ?? null +} + +export function subscribeClaudeAccountPrompt(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function resetClaudeAccountPromptForTests(): void { + for (const request of requests.splice(0)) { + request.resolve({ kind: 'cancelled' }) + } + notifyListeners() +} diff --git a/src/renderer/src/components/settings/repository-claude-account.ts b/src/renderer/src/components/settings/repository-claude-account.ts index c160f897a821..fb75c76b706f 100644 --- a/src/renderer/src/components/settings/repository-claude-account.ts +++ b/src/renderer/src/components/settings/repository-claude-account.ts @@ -12,7 +12,7 @@ export type ClaudeAccountOption = { disabled?: boolean } -function claudeAccountLabel(account: ClaudeManagedAccountSummary): string { +export function claudeAccountLabel(account: ClaudeManagedAccountSummary): string { return account.organizationName ? `${account.email} · ${account.organizationName}` : account.email } diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 026944575c83..91997eff3788 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -8,6 +8,8 @@ import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTar import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { launchWithClaudeAccountChoice } from '../claude-account-prompt/choose-claude-launch-account' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -130,8 +132,8 @@ function QuickLaunchAgentMenuItemsInner({ openSettingsPage() }, [openSettingsPage, openSettingsTarget]) - const runLaunch = useCallback( - (agent: TuiAgent) => { + const launchAndWatch = useCallback( + (agent: TuiAgent, claudeAccountId: string | undefined, deferred: boolean) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent const result = launchAgentInNewTab({ @@ -141,7 +143,8 @@ function QuickLaunchAgentMenuItemsInner({ ...(prompt !== undefined ? { prompt } : {}), ...(promptDelivery !== undefined ? { promptDelivery } : {}), ...(launchSource !== undefined ? { launchSource } : {}), - ...(onPromptDelivered !== undefined ? { onPromptDelivered } : {}) + ...(onPromptDelivered !== undefined ? { onPromptDelivered } : {}), + ...(claudeAccountId ? { claudeAccountId } : {}) }) if (!result) { toast.error( @@ -156,7 +159,12 @@ function QuickLaunchAgentMenuItemsInner({ if (result.surface.kind !== 'local-terminal') { return } - onFocusTerminal(result.surface.tabId) + // Why: after the account prompt the menu has already closed, so a queued menu-close focus would never run. + if (deferred) { + focusTerminalTabSurface(result.surface.tabId) + } else { + onFocusTerminal(result.surface.tabId) + } // Why: launch success means the terminal session exists. Agent readiness // can lag behind on slow machines, and prompt paste flows already own @@ -182,6 +190,15 @@ function QuickLaunchAgentMenuItemsInner({ [worktreeId, groupId, onFocusTerminal, prompt, promptDelivery, launchSource, onPromptDelivered] ) + const runLaunch = useCallback( + (agent: TuiAgent) => { + launchWithClaudeAccountChoice(agent, { worktreeId }, (claudeAccountId, deferred) => + launchAndWatch(agent, claudeAccountId, deferred) + ) + }, + [worktreeId, launchAndWatch] + ) + const enabledDetectedIds = detectedIds ? filterEnabledTuiAgents(detectedIds, disabledAgents) : [] const agents = detectedIds ? orderAgents(defaultAgent, enabledDetectedIds) : [] diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index 9b688008b993..b1165cd5447f 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -9,6 +9,7 @@ import { } from '@/lib/launch-agent-in-new-tab' import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { useAppStore } from '../../store' +import { launchWithClaudeAccountChoice } from '../claude-account-prompt/choose-claude-launch-account' import type { TabAgentLaunchOption } from './tab-agent-launch-options' import { buildTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options' import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' @@ -221,30 +222,32 @@ export function useTabBarCreateMenuController({ } } const launchAgentFromNewTabEntry = (agent: TuiAgent): void => { - const option = agentLaunchOptions.find((candidate) => candidate.agent === agent) - const result = launchAgentInNewTab({ - agent, - worktreeId, - groupId: resolvedGroupId, - launchSource: 'tab_bar_quick_launch' - }) - if (!result) { - toast.error( - translate( - 'auto.components.tab.bar.TabBar.ab589350e5', - 'Could not build launch command for {{value0}}.', - { value0: option?.label ?? agent } + // Why: after the account prompt the menu has already closed, so the queued focus must run now. + const afterDeferredLaunch = runPendingNewTabMenuFocusAfterClose + launchWithClaudeAccountChoice(agent, { worktreeId, afterDeferredLaunch }, (claudeAccountId) => { + const result = launchAgentInNewTab({ + agent, + worktreeId, + groupId: resolvedGroupId, + launchSource: 'tab_bar_quick_launch', + claudeAccountId + }) + if (!result) { + toast.error( + translate( + 'auto.components.tab.bar.TabBar.ab589350e5', + 'Could not build launch command for {{value0}}.', + { value0: agentLaunchOptions.find((entry) => entry.agent === agent)?.label ?? agent } + ) ) - ) - return - } - if (result.surface.kind === 'local-terminal') { - queueTerminalTabFocusAfterNewTabMenuClose(result.surface.tabId) - return - } - if (shouldQueueTerminalFocusAfterMenuClose(result)) { - queueNewActiveTerminalFocusAfterNewTabMenuClose() - } + return + } + if (result.surface.kind === 'local-terminal') { + queueTerminalTabFocusAfterNewTabMenuClose(result.surface.tabId) + } else if (shouldQueueTerminalFocusAfterMenuClose(result)) { + queueNewActiveTerminalFocusAfterNewTabMenuClose() + } + }) } const runPendingNewTabMenuFocusAfterClose = (): void => { const pendingFocus = pendingNewTabMenuFocusRef.current diff --git a/src/renderer/src/components/use-terminal-create-actions.ts b/src/renderer/src/components/use-terminal-create-actions.ts index 927e8e52b043..e92c30f44764 100644 --- a/src/renderer/src/components/use-terminal-create-actions.ts +++ b/src/renderer/src/components/use-terminal-create-actions.ts @@ -10,6 +10,7 @@ import { } from '@/runtime/web-runtime-session' import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { launchWithClaudeAccountChoice } from './claude-account-prompt/choose-claude-launch-account' import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' @@ -95,21 +96,24 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr const targetGroupId = state.activeGroupIdByWorktree[activeWorktreeId] ?? state.groupsByWorktree[activeWorktreeId]?.[0]?.id - const result = launchAgentInNewTab({ - agent, - worktreeId: activeWorktreeId, - groupId: targetGroupId, - launchSource: 'shortcut' - }) - if (!result) { - toast.error( - translate( - 'auto.components.Terminal.e57db40c11', - 'Could not build launch command for {{value0}}.', - { value0: agent } + launchWithClaudeAccountChoice(agent, { worktreeId: activeWorktreeId }, (claudeAccountId) => { + const result = launchAgentInNewTab({ + agent, + worktreeId: activeWorktreeId, + groupId: targetGroupId, + launchSource: 'shortcut', + ...(claudeAccountId ? { claudeAccountId } : {}) + }) + if (!result) { + toast.error( + translate( + 'auto.components.Terminal.e57db40c11', + 'Could not build launch command for {{value0}}.', + { value0: agent } + ) ) - ) - } + } + }) }, [activeWorktreeId] ) diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index caf470e3294e..09e5322ec030 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -48,6 +48,7 @@ import { buildQuickCreationRequest } from './quick-creation-request' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { resolveLaunchClaudeAccountId } from '@/lib/claude-launch-account' +import { chooseClaudeLaunchAccountIdForRepo } from '@/components/claude-account-prompt/choose-claude-launch-account' export function useQuickCreationExecution(input: QuickCreationExecutionInput) { const { @@ -127,8 +128,11 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { const { prompt: quickPrompt, draftPrompt: quickDraftPrompt } = resolveQuickCreateLinkedWorkItemPrompt(promptLinkedWorkItem, trimmedNote) - // The composer's account prompt sets this in a later change. - const composerClaudeAccountId: string | undefined = undefined + const composerClaudeAccountId = + agent === 'claude' ? await chooseClaudeLaunchAccountIdForRepo(repoId) : undefined + if (composerClaudeAccountId === null) { + return + } const { startupPlan, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bbb7e7103e37..8610b3c172be 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1196,6 +1196,15 @@ } }, "components": { + "ClaudeAccountPromptDialog": { + "title": "Choose a Claude account", + "description": "Pick the Claude account to start in {{value0}}.", + "accountLabel": "Account", + "remember": "Remember for this project", + "rememberHint": "You can change this in Settings → Repository", + "cancel": "Cancel", + "start": "Start" + }, "BrowserCookieImportDisclosure": { "title": "Google logins aren't imported", "description": "Sign in to Google directly in Orca." From 7b6ca7d47510dcc590b1862cff397a1e812c6556 Mon Sep 17 00:00:00 2001 From: Ethan Rivas Date: Sat, 26 Sep 2026 10:19:24 -0600 Subject: [PATCH 25/41] feat(terminal): explain refused pinned launches and restart on the active account Map pinned-launch refusal codes to localized copy in the terminal error toast, naming the account and terminal count when an account is still in use. Offer "Start on active account" to restart only the refused pane, or Retry for refusals that clear on their own. CLI and mobile error text strips the refusal tag; --json output keeps it. --- .../transport/rpc-acceptance-policies.test.ts | 16 +++ .../src/transport/rpc-acceptance-policies.ts | 3 +- src/cli/cli-error-claude-pinned.test.ts | 19 ++++ src/cli/cli-error.ts | 5 +- .../runtime-auth-pinned-launch.ts | 3 +- .../terminal-pane/TerminalErrorToast.test.ts | 20 ++++ .../terminal-pane/TerminalErrorToast.tsx | 33 +++++- .../terminal-pane/TerminalPaneSurface.tsx | 34 ++++++ .../claude-pinned-launch-error-copy.test.ts | 58 ++++++++++ .../claude-pinned-launch-error-copy.ts | 88 +++++++++++++++ .../terminal-pane/pty-connection-types.ts | 5 +- .../pty-connection/run-deferred-connect.ts | 9 +- .../transport-output-callbacks.ts | 4 +- .../refused-claude-launch-recovery.test.ts | 100 ++++++++++++++++++ .../refused-claude-launch-recovery.ts | 91 ++++++++++++++++ .../terminal-pane-lifecycle-types.ts | 5 +- .../terminal-pane-manager-options.ts | 13 ++- .../use-terminal-pane-process-exit-actions.ts | 6 +- .../use-terminal-pane-title-state.ts | 13 ++- .../src/i18n/en-runtime-required.json | 11 ++ src/renderer/src/i18n/locales/en.json | 14 ++- ...agent-launch-caller-profile-census.test.ts | 2 +- ...ent-launch-caller-profiles-test-harness.ts | 17 +++ ...agent-launch-routing-caller-census.test.ts | 3 + .../claude/claude-pinned-launch-error.test.ts | 35 +++++- .../claude/claude-pinned-launch-error.ts | 58 +++++++++- 26 files changed, 647 insertions(+), 18 deletions(-) create mode 100644 src/cli/cli-error-claude-pinned.test.ts create mode 100644 src/renderer/src/components/terminal-pane/claude-pinned-launch-error-copy.test.ts create mode 100644 src/renderer/src/components/terminal-pane/claude-pinned-launch-error-copy.ts create mode 100644 src/renderer/src/components/terminal-pane/refused-claude-launch-recovery.test.ts create mode 100644 src/renderer/src/components/terminal-pane/refused-claude-launch-recovery.ts diff --git a/mobile/src/transport/rpc-acceptance-policies.test.ts b/mobile/src/transport/rpc-acceptance-policies.test.ts index 2f2abc90e166..0e0dea501822 100644 --- a/mobile/src/transport/rpc-acceptance-policies.test.ts +++ b/mobile/src/transport/rpc-acceptance-policies.test.ts @@ -4,6 +4,7 @@ import { isMethodNotFoundRefusal, isStreamingOpenerReply, requireRpcResultOrThrowCodedError, + requireRpcResultOrThrowMessage, rpcObjectResultOrNull } from './rpc-acceptance-policies' @@ -166,3 +167,18 @@ describe('isStreamingOpenerReply', () => { expect(isStreamingOpenerReply(response)).toBe(false) }) }) + +describe('requireRpcResultOrThrowMessage', () => { + it('shows a pinned Claude refusal without its renderer marker', () => { + const refusal: RpcResponse = { + id: 'rpc-1', + ok: false, + error: { + code: 'runtime_error', + message: 'Account a@b.c is in use. [claude_pinned:host-sessions email=a%40b.c terminals=1]' + }, + _meta: meta + } + expect(() => requireRpcResultOrThrowMessage(refusal)).toThrow(/^Account a@b\.c is in use\.$/) + }) +}) diff --git a/mobile/src/transport/rpc-acceptance-policies.ts b/mobile/src/transport/rpc-acceptance-policies.ts index 89e72f45468c..9631f91ee5c9 100644 --- a/mobile/src/transport/rpc-acceptance-policies.ts +++ b/mobile/src/transport/rpc-acceptance-policies.ts @@ -1,4 +1,5 @@ import type { RpcResponse, RpcSuccess } from './types' +import { stripClaudePinnedLaunchMarker } from '../../../src/shared/claude/claude-pinned-launch-error' // Named acceptance policies for RPC replies. Call sites used to hand-roll these // predicates and did not agree with each other; each policy here preserves one @@ -35,7 +36,7 @@ export function isStreamingOpenerReply( /** New-tab errors historically show the host message without its diagnostic code. */ export function requireRpcResultOrThrowMessage(response: RpcResponse): unknown { if (!response.ok) { - throw new Error(response.error.message) + throw new Error(stripClaudePinnedLaunchMarker(response.error.message)) } return response.result } diff --git a/src/cli/cli-error-claude-pinned.test.ts b/src/cli/cli-error-claude-pinned.test.ts new file mode 100644 index 000000000000..2707b53ec6bf --- /dev/null +++ b/src/cli/cli-error-claude-pinned.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest' +import { formatCliError } from './cli-error' +import { RuntimeRpcFailureError } from './runtime-client' + +it('prints a pinned Claude refusal without the renderer marker', () => { + const error = new RuntimeRpcFailureError({ + id: 'request-1', + ok: false, + error: { + code: 'runtime_error', + message: + 'Claude account a@b.c is still in use. [claude_pinned:host-sessions email=a%40b.c terminals=1]' + }, + _meta: { runtimeId: 'runtime-1' } + }) + const output = formatCliError(error) + expect(output).toContain('Claude account a@b.c is still in use.') + expect(output).not.toContain('claude_pinned') +}) diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts index aa6f2d562e21..2d8bbfe5e423 100644 --- a/src/cli/cli-error.ts +++ b/src/cli/cli-error.ts @@ -4,6 +4,7 @@ import { stripAutomationOwnerConflictCode } from '../shared/automation-owner-conflict' import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery' +import { stripClaudePinnedLaunchMarker } from '../shared/claude/claude-pinned-launch-error' import { worktreeSelectorRecovery } from './worktree-selector-recovery' import type { RuntimeRpcFailure } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types' @@ -35,7 +36,9 @@ function errorCode(error: unknown): string | undefined { } export function formatCliError(error: unknown, context: CliErrorContext = {}): string { - const message = error instanceof Error ? error.message : String(error) + const message = stripClaudePinnedLaunchMarker( + error instanceof Error ? error.message : String(error) + ) const selector = selectorRecovery(errorCode(error), context) if (selector) { return formatMessageWithNextSteps( 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 3353d47c26dc..89f86098f816 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 @@ -142,7 +142,8 @@ export class ClaudeRuntimeAuthPinnedLaunch extends ClaudeRuntimeAuthPreparationS const sessions = countHostClaudePtysForAccount(accountId) throw claudePinnedLaunchError( 'host-sessions', - `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.` + `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.`, + { email: account.email, terminalCount: sessions } ) } if (conflict === 'host-mutation') { diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index 31febbd6c21a..50bc3db22b5d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -21,6 +21,7 @@ import { shouldOfferDaemonRestart, stripSshReconnectOwnedErrorLines } from './TerminalErrorToast' +import { claudePinnedLaunchError } from '../../../../shared/claude/claude-pinned-launch-error' beforeEach(() => { environmentMocks.resolveFooter.mockReset() @@ -415,4 +416,23 @@ describe('TerminalErrorToast environment footer', () => { expect(view.container.textContent).toContain('Retry could not reconnect yet') ) }) + + it('shows the friendly message and a "Start on active account" button for a tagged host-sessions refusal', () => { + const onStartOnActiveAccount = vi.fn() + const error = claudePinnedLaunchError('host-sessions', 'refused').message + const view = render( + React.createElement(TerminalErrorToast, { + error, + onDismiss: vi.fn(), + onStartOnActiveAccount + }) + ) + + expect(view.container.textContent).toContain( + 'This Claude account is still used by terminals started while it was the active account.' + ) + expect(view.container.querySelector('a')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'Start on active account' })) + expect(onStartOnActiveAccount).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index 8b627a9651f8..971813b65df6 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -3,6 +3,7 @@ import { translate } from '@/i18n/i18n' import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info' import { Button } from '@/components/ui/button' import { hasClientEnvironmentFooter } from '../../../../shared/client-environment-info' +import { describeClaudePinnedLaunchError } from './claude-pinned-launch-error-copy' const SSH_PREFIX = 'SSH connection is not active' // Produced by pty-connection.ts reportError() when a PTY reattach can't reach its SSH host. @@ -82,6 +83,9 @@ export function shouldOfferDaemonRestart(error: string): boolean { } export function isExplainedTerminalError(error: string): boolean { + if (describeClaudePinnedLaunchError(error)) { + return true + } return error .split('\n') .some( @@ -112,6 +116,10 @@ function humanizeUnreattachableSession(error: string): string { /** Swaps raw daemon-boundary codes for copy a user can act on. */ export function humanizeTerminalError(error: string): string { + const pinnedLaunch = describeClaudePinnedLaunchError(error) + if (pinnedLaunch) { + return pinnedLaunch.message + } let humanized = error if (humanized.includes(PANE_OWNER_UNVERIFIED_MARKER)) { const explanation = isPaneOwnerUnverifiedError(humanized) @@ -165,15 +173,25 @@ export function TerminalErrorToast({ error, onDismiss, onRestartDaemon, - onRetry + onRetry, + onRetryLaunch, + onStartOnActiveAccount }: { error: string onDismiss: () => void onRestartDaemon?: () => void onRetry?: () => Promise + /** Relaunches a refused pinned Claude spawn as-is, for refusals that clear on their own. */ + onRetryLaunch?: () => void + onStartOnActiveAccount?: () => void }): React.JSX.Element { const ssh = isSshError(error) const paneOwnerUnverified = isPaneOwnerUnverifiedError(error) + const pinnedLaunch = describeClaudePinnedLaunchError(error) + const showStartOnActiveAccount = Boolean( + pinnedLaunch?.offerActiveAccount && onStartOnActiveAccount + ) + const showRetryLaunch = Boolean(pinnedLaunch?.offerRetry && onRetryLaunch) const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error) // Restart cannot recover a session after its owning daemon exits. const showIssueLink = @@ -323,6 +341,19 @@ export function TerminalErrorToast({ : translate('auto.components.terminal.pane.TerminalErrorToast.retry', 'Retry')} ) : null} + {showRetryLaunch ? ( + + ) : null} + {showStartOnActiveAccount ? ( + + ) : null}