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..faa1ae105b1e --- /dev/null +++ b/src/cli/handlers/orchestration-worker-account-cli.test.ts @@ -0,0 +1,102 @@ +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') + }) + + 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 b6e4d92799c2..0f59975afa45 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,17 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record ) } } + 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') @@ -62,6 +74,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.", 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/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 d81f2346eb70..6f6baccd345f 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 { claimClaudeAccountForHostMutation } from './claude-account-host-mutation' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { getClaudeSelectionTargetForAccount, @@ -33,6 +34,20 @@ export class ClaudeAccountSelection { async remove(accountId: string): Promise { 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 = claimClaudeAccountForHostMutation(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( @@ -76,6 +91,21 @@ export class ClaudeAccountSelection { async select( accountId: string | null, target?: ClaudeAccountSelectionTarget + ): Promise { + const endHostMutation = + accountId === null + ? null + : claimClaudeAccountForHostMutation(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) { 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..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 @@ -485,4 +485,134 @@ 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() + } + }) + + 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-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() + }) +}) 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-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 + } +} 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..07eaf7267c98 --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-credentials.ts @@ -0,0 +1,105 @@ +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 function listPendingPinnedClaudeSeedAccountIds(): string[] { + return [...pendingSeedAccountIds] +} + +export function clearPendingPinnedClaudeSeed(accountId: string): void { + pendingSeedAccountIds.delete(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/claude-pinned-pty-registry.test.ts b/src/main/claude-accounts/claude-pinned-pty-registry.test.ts new file mode 100644 index 000000000000..7b836035bc05 --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-pty-registry.test.ts @@ -0,0 +1,138 @@ +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, + beginClaudeAccountHostMutation, + beginClaudeAccountUsageFetch, + confirmSeededPinnedClaudePtys, + countClaudePinnedAccountUsers, + createClaudePinnedPtyFilePersistence, + hasLivePinnedClaudePtys, + hasSeededUnconfirmedPinnedClaudePtys, + markPinnedClaudePtyExited, + markPinnedClaudePtySpawned, + onClaudePinnedAccountDrained, + readClaudePinnedPtyRegistryFile, + releaseClaudePinnedAccountReservation, + reserveClaudePinnedAccount, + seedPinnedClaudePtysFromPersistence, + whenClaudeAccountUsageFetchSettles +} 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) + expect(reserveClaudePinnedAccount('acct-b')).toBeNull() + markPinnedClaudePtySpawned('pty-1', 'acct-b') + expect(countClaudePinnedAccountUsers('acct-b')).toBe(2) + expect(countClaudePinnedAccountUsers('acct-c')).toBe(0) + + releaseClaudePinnedAccountReservation('acct-b') + 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() + }) + + 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 new file mode 100644 index 000000000000..d450f1a50b4e --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-pty-registry.ts @@ -0,0 +1,273 @@ +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. + * + * 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>() +// 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-sessions' | 'host-mutation' | 'usage-fetch' + +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 +} + +/** + * Held from auth preparation until the spawn settles; each successful reserve needs exactly one + * 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' + } + 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 { + 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 { + 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() + hostMutationsByAccountId.clear() + usageFetchesByAccountId.clear() + usageFetchSettledListeners.clear() + drainListeners.clear() + persistence = null + } +} 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/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..def11f3cd562 100644 --- a/src/main/claude-accounts/live-pty-gate.ts +++ b/src/main/claude-accounts/live-pty-gate.ts @@ -1,3 +1,10 @@ +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 // daemon. They keep the OAuth refresh gate closed so an early managed refresh @@ -68,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 { @@ -85,6 +101,9 @@ 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) + forgetHostClaudePtyAccount(ptyId) notifyDrainedOnTransition(hadLivePtys) } @@ -103,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 new file mode 100644 index 000000000000..67bf8eab43b0 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-pinned-launch.test.ts @@ -0,0 +1,497 @@ +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, mkdirSync, 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) + }) + + 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) + }) + + 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) + }) + + 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) + }) + + 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-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index dead38a7780f..31ca1acfd88b 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,12 +1,28 @@ import type { Store } from '../persistence' import { getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, type ClaudeAccountSelectionTarget } from './runtime-selection' +import { + countClaudePinnedAccountUsers, + releaseClaudePinnedAccountReservation +} from './claude-pinned-pty-registry' +import { + clearPendingPinnedClaudeSeed, + listPendingPinnedClaudeSeedAccountIds, + readPinnedClaudeSeedMarker +} from './claude-pinned-credentials' 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 +32,39 @@ 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.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 +102,78 @@ export class ClaudeRuntimeAuthService extends ClaudeRuntimeAuthSync { return this.getPreparation(target).configDir } + /** + * 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 { + 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') { + 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 + } + } + + private isActiveHostAccountLaunch( + accountId: string, + target: ClaudeAccountSelectionTarget + ): boolean { + return ( + normalizeClaudeAccountSelectionTarget(target).runtime === 'host' && + getSelectedClaudeAccountIdForTarget(this.store.getSettings(), { runtime: 'host' }) === + accountId + ) + } + + /** + * 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/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..cc19bdf01f19 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-pinned-launch.ts @@ -0,0 +1,212 @@ +import { join } from 'node:path' +import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' +import { + countClaudePinnedAccountUsers, + reserveClaudePinnedAccount, + whenClaudeAccountUsageFetchSettles +} from '../claude-pinned-pty-registry' +import { + clearPinnedClaudeKeychainCredentials, + hashClaudeCredentialsJson, + readPinnedClaudeKeychainCredentials, + readPinnedClaudeSeedMarker, + 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' + +/** + * 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. + */ +// 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. 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 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, + sharedWithLiveSession: boolean + ): Promise { + // 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.') + } + 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 { + 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. + */ + 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 + // session back to a spent refresh token. + const sharedWithLiveSession = countClaudePinnedAccountUsers(accountId) > 0 + const conflict = reserveClaudePinnedAccount(accountId) + 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.' + ) + } + 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). + * + * 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 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/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..2c2a13230f60 --- /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 (..._args: unknown[]) => 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-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/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/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 { + 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, claudeAuth?.provenance) +} 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/rate-limits/claude-fetcher-managed-account-usage.test.ts b/src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts index c01bcb00f6eb..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 @@ -11,10 +11,17 @@ import { import { fetchViaPty } from './claude-pty' import { deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentialsStrict, readManagedClaudeKeychainCredentials, writeActiveClaudeKeychainCredentials, writeManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' +import { + _internals as pinnedRegistryInternals, + markPinnedClaudePtySpawned, + releaseClaudePinnedAccountReservation, + reserveClaudePinnedAccount +} from '../claude-accounts/claude-pinned-pty-registry' const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( () => ({ @@ -74,6 +81,7 @@ describe('fetchClaudeRateLimits', () => { afterEach(() => { restorePlatform() + pinnedRegistryInternals.reset() if (tempDir) { rmSync(tempDir, { recursive: true, force: true }) } @@ -422,4 +430,99 @@ 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() + }) + + 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 71d505510cf1..47d20b3a001d 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 { beginClaudeAccountUsageFetch } from '../claude-accounts/claude-pinned-pty-registry' +import { hasPendingPinnedClaudeSeed } from '../claude-accounts/claude-pinned-credentials' export type InactiveClaudeAccount = { id: string @@ -97,6 +99,15 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccount): s } } +/** + * 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( location: ClaudeManagedCredentialsLocation, credentialsJson: string, diff --git a/src/main/rate-limits/claude-managed-account-usage.ts b/src/main/rate-limits/claude-managed-account-usage.ts index ddf7b3df7508..699b805ff39e 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 { + beginClaudeManagedUsageFetch, readClaudeManagedCredentialsJson, + readStagedClaudeManagedPreviewCredentials, resolveClaudeManagedCredentialsLocation, writeClaudeManagedCredentialsJson, + type ClaudeManagedCredentialsLocation, type InactiveClaudeAccount } from './claude-managed-account-credentials' import { fetchClaudeManagedUsagePanelSupplement } from './claude-managed-usage-panel' @@ -38,8 +41,28 @@ export async function fetchInactiveClaudeAccountUsage( if (options.signal?.aborted) { return abortedClaudeRateLimitResult() } + // 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. 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 ? await readClaudeManagedCredentialsJson(location) : null + let credentialsJson = location + ? heldByPinnedLaunch + ? await readPinnedLaunchCredentialsJson(location) + : await readClaudeManagedCredentialsJson(location) + : null if (options.signal?.aborted) { return abortedClaudeRateLimitResult() } @@ -48,7 +71,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 +95,7 @@ export async function fetchInactiveClaudeAccountUsage( return abortedClaudeRateLimitResult() } if ( + heldByPinnedLaunch || !canSupplementClaudeOAuthUsage({ oauthLimits, authPreparation: undefined, @@ -102,3 +126,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/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-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/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/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/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/rpc/methods/worktree-create-claude-account-args.test.ts b/src/main/runtime/rpc/methods/worktree-create-claude-account-args.test.ts new file mode 100644 index 000000000000..80dd74852f4a --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-create-claude-account-args.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +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', () => { + 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/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-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/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/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/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 7a677ed760ef..ddaa72635cd3 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,28 @@ 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) + // 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) + .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..adf375617669 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,20 @@ import { onLiveClaudePtysDrained, seedLiveClaudePtysFromPersistence } from '../claude-accounts/live-pty-gate' +import { + attachClaudePinnedPtyPersistence, + createClaudePinnedPtyFilePersistence, + readClaudePinnedPtyRegistryFile, + seedPinnedClaudePtysFromPersistence +} from '../claude-accounts/claude-pinned-pty-registry' +import { + attachClaudeHostPtyAccountPersistence, + createClaudeHostPtyAccountFilePersistence, + readClaudeHostPtyAccountsFile, + seedHostClaudePtyAccounts, + setClaudeActiveHostAccountResolver +} from '../claude-accounts/claude-host-pty-accounts' +import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' import { applyAppIcon } from '../app-icon' import { shouldSuppressDevEducation, @@ -253,6 +268,27 @@ export async function initializeReadyFoundation(): 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'), + '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` 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 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/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/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) { 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,