diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 401c2291db18..e15bf4f5cdde 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -48,6 +48,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts', 'src/renderer/src/store/github/cache-persistence.test.ts', 'src/main/native-chat/agent-session-journal/**/*.test.ts', + // Account receipt replay exercises the host's durable Node SQLite ledger. + 'src/main/runtime/rpc/methods/agent-launch-account-replay.test.ts', 'src/main/runtime/rpc/ws-transport.test.ts', 'src/shared/remote-runtime-client.test.ts', 'src/shared/remote-runtime-connect-bound.test.ts', diff --git a/docs/reference/codex-launch-account-pinning.md b/docs/reference/codex-launch-account-pinning.md new file mode 100644 index 000000000000..65343683c114 --- /dev/null +++ b/docs/reference/codex-launch-account-pinning.md @@ -0,0 +1,32 @@ +# Explicit Codex launch accounts + +A coordinator can launch a new Codex worker on an existing managed account without changing the global account selection or restarting other sessions: + +```sh +orca orchestration worker-start --task task_id --agent codex --account account_id --worktree current +orca worktree create --repo id:repo_id --name worker-b --agent codex --account account_id +``` + +Selectors accept an exact managed account id, a case-insensitive email that is unique among native host accounts, or `system` / `"System default"`. Exact ids take precedence. Unknown, ambiguous, untrusted, and unsupported account homes are refused. WSL accounts do not participate in native email matching. + +Both commands require `codex.launch-account.v1` in the connected runtime's advertised capabilities. Missing or older capabilities fail before a launch mutation is sent. Connect directly to the execution runtime to use its accounts; `worker-start --on` federation, SSH-forwarded workspaces, paired-runtime repository forwarding, and WSL execution are refused. Native git worktrees and folder workspaces are supported on Windows, macOS, and Linux. Other providers, terminal reuse, session restoration, renderer-driven PTY creation, and custom Codex launch commands cannot guarantee this pin and are refused. + +An explicit account uses a native terminal. If the user's default was a structured chat session, the mode receipt records `pinned_codex_account` as the downgrade reason. Omitting `--account` retains the existing launch mode, active-account routing, and account-home callback signature. + +The account service resolves selectors and verifies home ownership. It prepares the requested managed home using the existing resource, config, hook, and session-history infrastructure, without invoking account switching or shared-auth transition bookkeeping. Explicit system selection uses the canonical system Codex home even when Orca inherited another pane's `CODEX_HOME`. The PTY receives that home only in its new launch environment. Existing credential readiness checks must succeed for that same home; they cannot fall back to another account. The returned home is checked against the existing account attribution resolver before spawning. + +Pinned panes retain their effective account in the durable pane registry and are excluded from global-selection stale-account notices. Existing unpinned panes retain their normal account-switch behavior. No new credential store is introduced, and launch receipts contain no credentials or home paths. + +`worktree.create` and `agent.launch` return an optional `account` receipt. Worker receipts contain the same value at `launch.account`, persisted in the Dispatch start options: + +```json +{ + "provider": "codex", + "requested": "b@example.com", + "effective": { "id": "account-b", "email": "b@example.com" } +} +``` + +System receipts use `requested: "system"` and null effective id/email. Email requests are normalized; id requests retain the exact id. `agent.launch` accepts `account` only at the launch level and refuses a nested `target.create.startupAccount`. It includes the selector in its operation fingerprint and validates the account receipt during authoritative replay. Retrying the same operation returns its recorded effective account without resolving or launching again; changing its account conflicts. Explicit pins refuse `worktree.create`'s legacy `clientMutationId` cache, which does not compare account intent; use `agent.launch` with `operationId` for replay. + +The capability is additive and has no new stream opcode. Hosts and clients update independently, so callers must not forward the selector to an unadvertised host. This implementation refuses forwarding entirely. Automatic quota failover is separate work tracked in [#20512](https://github.com/stablyai/orca/issues/20512); the concurrency use case is described in [#9496](https://github.com/stablyai/orca/issues/9496). diff --git a/src/cli/handlers/codex-launch-account-cli.test.ts b/src/cli/handlers/codex-launch-account-cli.test.ts new file mode 100644 index 000000000000..67812ce62d7a --- /dev/null +++ b/src/cli/handlers/codex-launch-account-cli.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CommandHandler, HandlerContext } from '../dispatch' +import { CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/agent-launch-account' +import { getCodexLaunchAccountFlag } from './codex-launch-account-flag' +import { ORCHESTRATION_HANDLERS } from './orchestration' +import { WORKTREE_HANDLERS } from './worktree' + +vi.mock('../format', () => ({ printResult: vi.fn(), formatWorktreeShow: vi.fn() })) + +const call = vi.fn() +const receipt = { + provider: 'codex', + requested: 'account-b', + effective: { id: 'account-b', email: 'b@example.com' } +} + +function context(entries: Record): HandlerContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers use only client.call; the mock rejects any unexpected RPC in the assertions. + return { + flags: new Map(Object.entries(entries)), + client: { call }, + cwd: '/fixture', + json: true + } as unknown as HandlerContext +} + +beforeEach(() => call.mockReset()) + +describe('explicit Codex launch CLI capability boundary', () => { + const commands: { + handler: CommandHandler + base: Record + method: string + field: string + }[] = [ + { + handler: ORCHESTRATION_HANDLERS['orchestration worker-start'], + base: { task: 'task_1', from: 'term_coord' }, + method: 'orchestration.workerStart', + field: 'account' + }, + { + handler: WORKTREE_HANDLERS['worktree create'], + base: { name: 'pinned', repo: 'id:repo_1', 'no-parent': true }, + method: 'worktree.create', + field: 'startupAccount' + } + ] + + it.each(commands)( + 'gates and forwards the selector through $method', + async ({ handler, base, method, field }) => { + call + .mockResolvedValueOnce({ + result: { capabilities: [CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY] } + }) + .mockResolvedValue({ + result: { + state: 'ready', + worktree: { id: 'wt_1' }, + warnings: [], + launch: { account: receipt }, + account: receipt + } + }) + await handler(context({ ...base, agent: 'codex', account: 'account-b' })) + expect(call.mock.calls[0]).toEqual(['status.get']) + const mutation = call.mock.calls.find(([name]) => name === method) + expect(mutation?.[1]).toHaveProperty(field, 'account-b') + } + ) + + it.each(commands)( + 'keeps the omitted-flag RPC shape and skips status for $method', + async ({ handler, base, method, field }) => { + call.mockResolvedValue({ result: { state: 'ready', worktree: { id: 'wt_1' }, warnings: [] } }) + await handler(context({ ...base, agent: 'codex' })) + expect(call).not.toHaveBeenCalledWith('status.get') + expect(call.mock.calls.find(([name]) => name === method)?.[1]).not.toHaveProperty(field) + } + ) + + it.each([undefined, [], ['agent.launch.v2'], null])( + 'refuses missing/old host capabilities %j before mutation', + async (capabilities) => { + call.mockResolvedValue({ result: { capabilities } }) + const ctx = context({ account: 'account-a', agent: 'codex' }) + await expect(getCodexLaunchAccountFlag(ctx.flags, ctx.client)).rejects.toMatchObject({ + code: 'incompatible_runtime' + }) + expect(call).toHaveBeenCalledExactlyOnceWith('status.get') + } + ) + + const unsupportedRequests: Record[] = [ + { agent: 'claude' }, + { agent: 'cursor' }, + { agent: 'codex', terminal: 'term_existing' }, + { agent: 'codex', on: 'server' }, + {} + ] + it.each(unsupportedRequests)( + 'refuses unsupported provider or placement %j without contacting a host', + async (flags) => { + const ctx = context({ account: 'account-a', ...flags }) + await expect(getCodexLaunchAccountFlag(ctx.flags, ctx.client)).rejects.toMatchObject({ + code: 'invalid_argument' + }) + expect(call).not.toHaveBeenCalled() + } + ) + + it.each([true, false, '', ' ', 'id\nsecret'])( + 'refuses malformed account flag %j without dropping the pin', + async (account) => { + const ctx = context({ account, agent: 'codex' }) + await expect(getCodexLaunchAccountFlag(ctx.flags, ctx.client)).rejects.toMatchObject({ + code: 'invalid_argument' + }) + expect(call).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/cli/handlers/codex-launch-account-flag.ts b/src/cli/handlers/codex-launch-account-flag.ts new file mode 100644 index 000000000000..f19424c3f8e9 --- /dev/null +++ b/src/cli/handlers/codex-launch-account-flag.ts @@ -0,0 +1,49 @@ +import { + assertCodexAccountLaunchRequest, + CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY +} from '../../shared/agent-launch-account' +import { LaunchAccountParam } from '../../shared/rpc-contract/launch-account-param' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { RuntimeClientError, type RuntimeClient } from '../runtime-client' + +export async function getCodexLaunchAccountFlag( + flags: Map, + client: RuntimeClient +): Promise { + if (!flags.has('account')) { + return undefined + } + const account = getRequiredStringFlag(flags, 'account') + const parsed = LaunchAccountParam.safeParse(account) + if (!parsed.success) { + throw new RuntimeClientError( + 'invalid_argument', + '--account requires an account id, unique email, or system.' + ) + } + try { + assertCodexAccountLaunchRequest({ + account: parsed.data, + agent: getOptionalStringFlag(flags, 'agent'), + terminal: getOptionalStringFlag(flags, 'terminal'), + on: getOptionalStringFlag(flags, 'on') + }) + } catch (error) { + throw new RuntimeClientError( + 'invalid_argument', + error instanceof Error ? error.message : String(error) + ) + } + const status = await client.call('status.get') + if ( + !Array.isArray(status.result?.capabilities) || + !status.result.capabilities.includes(CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY) + ) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The connected Orca runtime does not support explicit Codex accounts. No launch was requested.' + ) + } + return parsed.data +} diff --git a/src/cli/handlers/orchestration/worker-launch-handler.ts b/src/cli/handlers/orchestration/worker-launch-handler.ts index b6e4d92799c2..0fb6ddb45721 100644 --- a/src/cli/handlers/orchestration/worker-launch-handler.ts +++ b/src/cli/handlers/orchestration/worker-launch-handler.ts @@ -1,6 +1,7 @@ import type { CommandHandler } from '../../dispatch' import { printResult } from '../../format' import { getOptionalStringFlag } from '../../flags' +import { getCodexLaunchAccountFlag } from '../codex-launch-account-flag' import { RuntimeClientError } from '../../runtime-client' import type { RuntimeStatus } from '../../../shared/runtime-types' import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' @@ -13,6 +14,7 @@ import { renderResolvedOrchestrationCommand } from '../../orchestration-mutation export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record = { 'orchestration worker-start': async ({ flags, client, cwd, json }) => { + const account = await getCodexLaunchAccountFlag(flags, client) const model = getOptionalStringFlag(flags, 'model') const effort = getOptionalStringFlag(flags, 'effort') if (model || effort) { @@ -60,6 +62,7 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record comment: getOptionalStringFlag(flags, 'comment'), setup: getOptionalStringFlag(flags, 'setup'), agent: getOptionalStringFlag(flags, 'agent'), + ...(account !== undefined ? { account } : {}), model, effort, terminal: getOptionalStringFlag(flags, 'terminal'), diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index e1b2fd904c74..21ca6fb1aa22 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -13,6 +13,7 @@ import { type WithAnnotatedHostScope } from '../omitted-host-scope-selectors' import { RuntimeClientError } from '../runtime-client' +import { getCodexLaunchAccountFlag } from './codex-launch-account-flag' import { getOptionalPositiveIntegerFlag, getOptionalStringFlag, @@ -168,6 +169,7 @@ export const WORKTREE_HANDLERS: Record = { printResult(result, json, formatWorktreeShow) }, 'worktree create': async ({ flags, client, cwd, json }) => { + const account = await getCodexLaunchAccountFlag(flags, client) assertWorktreeParentFlagsCompatible(flags) assertWorkspaceTargetFlagsCompatible(flags) const reviewLinks = getReviewTargetLinkFlags(flags) @@ -232,6 +234,7 @@ export const WORKTREE_HANDLERS: Record = { ...(startupAgent ? { startupAgent, + ...(account !== undefined ? { startupAccount: account } : {}), startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '', launchSource: 'cli' } diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index ef098ba194b8..627f81932591 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -66,7 +66,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 ] [--pr ] [--linear-issue ] [--gitlab-issue ] [--gitlab-mr ] [--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 ] [--pr ] [--linear-issue ] [--gitlab-issue ] [--gitlab-mr ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'repo', @@ -75,6 +75,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'project-host-setup', 'name', 'agent', + 'account', 'prompt', 'base-branch', 'issue', @@ -100,6 +101,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 requires --agent codex and a runtime advertising codex.launch-account.v1. It pins an existing native host account by exact id or unique case-insensitive email; system uses the canonical system home. Global selection and existing sessions stay unchanged. SSH, WSL, forwarded hosts, and custom Codex launch commands are refused.', '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 43674b9d7578..06292b3cfa4c 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 ) [--account ] [--task-title ] [--deps ] [--parent ] [--model ] [--effort ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'task', @@ -22,6 +22,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'comment', 'setup', 'agent', + 'account', 'model', 'effort', 'terminal', @@ -36,6 +37,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', '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, zcode, opencode, or opencode2.', + '--account requires --agent codex and codex.launch-account.v1 on the connected runtime. Exact managed id or unique case-insensitive native host email pins only the new launch; system uses the canonical system home. It uses terminal mode with an explicit receipt, leaves global selection and existing sessions unchanged, and rejects --terminal, --on, SSH, WSL, and custom Codex launch commands.', '--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. OMP accepts --model only; --effort is unsupported. Neither can combine with --terminal. OpenCode model selection requires an existing worktree, a verified execution-host CLI, and an available model; effort is unsupported. Other agents, including zcode, launch with the model from their own config.', '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.', diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 755bd55e2767..8ede4ee35d1b 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -27,6 +27,7 @@ import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan' import { parsePaneKey } from '../../shared/stable-pane-id' +import { assertCodexAccountLaunchRequest } from '../../shared/agent-launch-account' import type { AgentLaunchIntent, AgentLaunchResult, @@ -81,6 +82,13 @@ export async function executeAgentLaunch( execution: AgentLaunchExecution ): Promise { const { intent, runtime } = execution + if (intent.account) { + assertCodexAccountLaunchRequest({ + account: intent.account.requested, + agent: intent.agent, + terminal: intent.reuseTerminal?.handle + }) + } if (intent.reuseTerminal || intent.target.kind === 'create-worktree') { assertOpenCodeModelLaunchPreferencesAbsent(intent.agent, intent.sessionOptions) } @@ -89,6 +97,7 @@ export async function executeAgentLaunch( const preflight = decideAgentLaunchMode({ placement: { agent: intent.agent, + ...(intent.account ? { account: intent.account.requested } : {}), workspaceKind: launchWorkspaceKind(intent.target), ...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}), ...(intent.cwd ? { cwd: intent.cwd } : {}), @@ -110,6 +119,7 @@ export async function executeAgentLaunch( return { ...reused, receipt: preflight, + ...(intent.account ? { account: intent.account } : {}), ...promptReceipt( intent, await deliverTerminalLaunchPrompt(execution, intent.reuseTerminal.handle, { @@ -133,6 +143,7 @@ export async function executeAgentLaunch( return { ...startup, receipt: preflight, + ...(intent.account ? { account: intent.account } : {}), ...(placed.warning ? { warning: placed.warning } : {}), ...promptReceipt( intent, @@ -187,6 +198,7 @@ export async function executeAgentLaunch( return { ...surface, receipt: settled, + ...(intent.account ? { account: intent.account } : {}), ...(warning ? { warning } : {}), ...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created)) } @@ -318,6 +330,7 @@ function ignoredStructuredAgentArgsWarning( * workspace and the terminal of an existing one start the same agent. */ function terminalLaunchInputs(intent: AgentLaunchIntent) { return { + ...(intent.account ? { codexAccountId: intent.account.effective.id } : {}), ...(intent.sessionOptions ? { options: intent.sessionOptions } : {}), // `null` is a value the caller meant, so this tests for absence rather than falsiness. ...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}), diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index f9a9eef7846b..7e3d75c26798 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -60,6 +60,7 @@ export type AgentLaunchModeSettings = Partial /** The placement facts the decision reads. `worktree`, `model` and `effort` are deliberately not * here: a structured launch honours all three, and a placement flag must never imply a mode. */ export type AgentLaunchModePlacement = { + account?: string agent?: string /** A connected execution server; absent means local. */ on?: string @@ -88,7 +89,8 @@ 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_codex_account: 'an explicit Codex account requires a native terminal launch' } const BLOCKER_REASON: Record< @@ -140,6 +142,9 @@ export function decideAgentLaunchMode(args: { if (placement.on) { return downgraded('remote_execution_host', vocabulary) } + if (placement.account !== undefined) { + return downgraded('pinned_codex_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/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index e7d36f05ea1c..6408197b25ae 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -22,6 +22,7 @@ export type AgentLaunchSurfaceFactory = { tabId?: string }): Promise createTerminalAgent(args: { + codexAccountId?: string | null worktreeId: string agent: TuiAgent options?: Readonly> @@ -98,6 +99,7 @@ export class AgentLaunchStructuredSessionRefusedError extends Error { * its own worktree stages and residual-resource effects around the same call. */ export type AgentLaunchWorkspaceFactory = { createWorktree(args: { + codexAccountId?: string | null create: Readonly> /** Set only when the settled mode is a terminal agent: agent-first creation sequences the * agent's startup command behind the setup runner, which is how a PTY launch gets its diff --git a/src/main/codex-accounts/codex-launch-account.test.ts b/src/main/codex-accounts/codex-launch-account.test.ts new file mode 100644 index 000000000000..fc87cc347215 --- /dev/null +++ b/src/main/codex-accounts/codex-launch-account.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { resolveCodexLaunchAccount } from './codex-launch-account' + +const accounts = [ + { id: 'account-a', email: 'Same@Example.com' }, + { id: 'account-b', email: 'same@example.com' }, + { id: 'account-c', email: 'Unique@Example.com' }, + { id: 'account-wsl', email: 'unique@example.com', managedHomeRuntime: 'wsl' as const } +] + +describe('native Codex launch account selectors', () => { + it('resolves exact ids even when their email is ambiguous', () => { + expect(resolveCodexLaunchAccount(accounts, 'account-b')).toEqual({ + provider: 'codex', + requested: 'account-b', + effective: { id: 'account-b', email: 'same@example.com' } + }) + }) + + it('resolves case-insensitive emails only within the supported host lane', () => { + expect(resolveCodexLaunchAccount(accounts, ' UNIQUE@EXAMPLE.COM ')).toEqual({ + provider: 'codex', + requested: 'unique@example.com', + effective: { id: 'account-c', email: 'Unique@Example.com' } + }) + }) + + it.each(['system', 'SYSTEM', 'System default'])( + 'resolves %s explicitly to the system account', + (selector) => { + expect(resolveCodexLaunchAccount(accounts, selector)).toEqual({ + provider: 'codex', + requested: 'system', + effective: { id: null, email: null } + }) + } + ) + + it('gives exact ids precedence over the reserved system selector', () => { + expect( + resolveCodexLaunchAccount([{ id: 'system', email: 'id@example.com' }], 'system').effective.id + ).toBe('system') + }) + + it('refuses ambiguous email with exact ids as the remedy', () => { + expect(() => resolveCodexLaunchAccount(accounts, 'SAME@example.com')).toThrow( + /Ambiguous.*account-a, account-b/ + ) + }) + + it.each(['missing', 'ACCOUNT-C'])('refuses unknown or inexact ids: %s', (selector) => { + expect(() => resolveCodexLaunchAccount(accounts, selector)).toThrow( + 'No managed Codex account matches' + ) + }) + + it('refuses an exact WSL account id', () => { + expect(() => resolveCodexLaunchAccount(accounts, 'account-wsl')).toThrow( + 'WSL accounts are unsupported' + ) + }) + + it.each(['', ' ', 'id\nsecret', 'id\u0000secret', 'x'.repeat(513)])( + 'refuses malformed selectors', + (selector) => { + expect(() => resolveCodexLaunchAccount(accounts, selector)).toThrow('--account requires') + } + ) +}) diff --git a/src/main/codex-accounts/codex-launch-account.ts b/src/main/codex-accounts/codex-launch-account.ts new file mode 100644 index 000000000000..acc7c5fefb35 --- /dev/null +++ b/src/main/codex-accounts/codex-launch-account.ts @@ -0,0 +1,46 @@ +import type { CodexManagedAccountSummary } from '../../shared/managed-account-types' +import type { AgentLaunchAccountReceipt } from '../../shared/agent-launch-account' +import { LaunchAccountParam } from '../../shared/rpc-contract/launch-account-param' + +export function resolveCodexLaunchAccount( + accounts: readonly Pick[], + selector: string +): AgentLaunchAccountReceipt { + const parsed = LaunchAccountParam.safeParse(selector) + if (!parsed.success) { + throw new Error('--account requires a Codex account id, unique email, or system.') + } + const requested = parsed.data + const byId = accounts.find((account) => account.id === requested) + if (!byId && /^(system|system default)$/i.test(requested)) { + return { provider: 'codex', requested: 'system', effective: { id: null, email: null } } + } + const matches = byId + ? [byId] + : accounts.filter( + (account) => + account.managedHomeRuntime !== 'wsl' && + account.email.trim().toLowerCase() === requested.toLowerCase() + ) + if (matches.length === 0) { + throw new Error( + 'No managed Codex account matches --account on this host. Use an existing account id or email.' + ) + } + if (matches.length > 1) { + throw new Error( + `Ambiguous Codex account email; pass an exact id: ${matches.map((account) => account.id).join(', ')}.` + ) + } + const account = matches[0] + if (account.managedHomeRuntime === 'wsl') { + throw new Error( + '--account supports native host Codex accounts only; WSL accounts are unsupported.' + ) + } + return { + provider: 'codex', + requested: byId ? account.id : account.email.trim().toLowerCase(), + effective: { id: account.id, email: account.email } + } +} diff --git a/src/main/codex-accounts/runtime-home-pinned-launch.test.ts b/src/main/codex-accounts/runtime-home-pinned-launch.test.ts new file mode 100644 index 000000000000..1d3b90f4a3a5 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-pinned-launch.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type * as NodeOs from 'node:os' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAccountRecord, + createCodexAuthJson, + createManagedAuth, + createStore, + getRuntimeCodexAuthPath, + getSystemCodexAuthPath, + getSystemCodexHomePath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +vi.mock('electron', () => ({ app: { getPath: () => testState.userDataDir } })) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => testState.fakeHomeDir +})) + +describe('Codex launch pins do not transition the active account', () => { + beforeEach(() => setupRuntimeHomeTest()) + afterEach(() => teardownRuntimeHomeTest()) + + async function fixture() { + const authA = createCodexAuthJson('a@example.com', 'provider-a', 'fixture-refresh-a') + const authB = createCodexAuthJson('b@example.com', 'provider-b', 'fixture-refresh-b') + const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) + const homeB = createManagedAuth(testState.userDataDir, 'account-b', authB) + writeFileSync(getSystemCodexAuthPath(), '{"fixture":"system"}\n') + const settings = createSettings({ + realHomeRoutable: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-a', 'a@example.com', 'provider-a', homeA), + createCodexAccountRecord('account-b', 'b@example.com', 'provider-b', homeB) + ], + activeCodexManagedAccountId: 'account-a', + activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} } + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this home-service fixture exercises only settings access; any omitted store member fails on use. + const service = new CodexRuntimeHomeService(store as never) + return { service, store, settings, homeA, homeB, authA, authB } + } + + it('prepares simultaneous A and B launches without touching either credential or global routing', async () => { + const { service, store, settings, homeA, homeB, authA, authB } = await fixture() + const selection = structuredClone(settings) + store.updateSettings.mockClear() + const launches = await Promise.all( + ['account-a', 'B@EXAMPLE.COM'].map(async (selector) => { + const receipt = service.resolveLaunchAccount(selector) + return { receipt, home: service.prepareForPinnedCodexLaunch(receipt.effective.id) } + }) + ) + expect(launches.map((launch) => launch.home)).toEqual([homeA, homeB]) + expect(launches.map((launch) => launch.receipt.effective.id)).toEqual([ + 'account-a', + 'account-b' + ]) + expect(settings).toEqual(selection) + expect(store.updateSettings).not.toHaveBeenCalled() + expect(service.resolveHostCodexHomePathForLaunchReadOnly()).toBe(homeA) + expect(readFileSync(join(homeA, 'auth.json'), 'utf8')).toBe(authA) + expect(readFileSync(join(homeB, 'auth.json'), 'utf8')).toBe(authB) + expect(readFileSync(getSystemCodexAuthPath(), 'utf8')).toBe('{"fixture":"system"}\n') + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + }) + + it('pins system to the canonical home despite managed selection and inherited CODEX_HOME', async () => { + const { service, store, homeA } = await fixture() + vi.stubEnv('CODEX_HOME', homeA) + vi.stubEnv('ORCA_CODEX_HOME', homeA) + try { + expect( + service.prepareForPinnedCodexLaunch( + service.resolveLaunchAccount('System default').effective.id + ) + ).toBe(getSystemCodexHomePath()) + expect(service.resolveHostCodexHomePathForLaunchReadOnly()).toBe(homeA) + expect(store.updateSettings).not.toHaveBeenCalled() + } finally { + vi.unstubAllEnvs() + } + }) + + it('refuses an untrusted requested home without clearing the globally active account', async () => { + const { service, store, settings, homeB, homeA } = await fixture() + writeFileSync(join(homeB, '.orca-managed-home'), 'wrong-owner\n') + expect(() => service.resolveLaunchAccount('account-b')).toThrow('untrusted') + expect(settings.activeCodexManagedAccountId).toBe('account-a') + expect(store.updateSettings).not.toHaveBeenCalled() + expect(service.resolveHostCodexHomePathForLaunchReadOnly()).toBe(homeA) + }) +}) diff --git a/src/main/codex-accounts/runtime-home-service-launch.ts b/src/main/codex-accounts/runtime-home-service-launch.ts index 3930d7accba4..e1d47bf69927 100644 --- a/src/main/codex-accounts/runtime-home-service-launch.ts +++ b/src/main/codex-accounts/runtime-home-service-launch.ts @@ -1,6 +1,7 @@ import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' import { + getSystemCodexHomePath, resolveOrcaManagedCodexHomePath, syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' @@ -17,8 +18,47 @@ import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfil import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' import { CodexRuntimeHomeRouting } from './runtime-home-service-home-routing' +import { resolveCodexLaunchAccount } from './codex-launch-account' +import type { AgentLaunchAccountReceipt } from '../../shared/agent-launch-account' export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { + resolveLaunchAccount(selector: string): AgentLaunchAccountReceipt { + const receipt = resolveCodexLaunchAccount( + this.store.getSettings().codexManagedAccounts, + selector + ) + this.resolvePinnedLaunchHome(receipt.effective.id) + return receipt + } + + prepareForPinnedCodexLaunch(accountId: string | null): string { + const homePath = this.resolvePinnedLaunchHome(accountId) + if (accountId !== null) { + this.prepareManagedHomeResourcesForLaunch(homePath) + } + return homePath + } + + private resolvePinnedLaunchHome(accountId: string | null): string { + if (accountId === null) { + return getSystemCodexHomePath() + } + const account = this.store + .getSettings() + .codexManagedAccounts.find((entry) => entry.id === accountId) + if (!account || account.managedHomeRuntime === 'wsl') { + throw new Error('The pinned Codex account is unavailable on this native host.') + } + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind !== 'owned') { + throw new Error('The pinned Codex account home is untrusted; no agent was launched.') + } + return resolved.homePath + } + protected initializeLastSyncedState(): void { const settings = this.store.getSettings() const activeAccount = this.getActiveAccount( diff --git a/src/main/codex-accounts/runtime-home-service-managed-home.ts b/src/main/codex-accounts/runtime-home-service-managed-home.ts index 817b2f7adc41..a6e47ecab876 100644 --- a/src/main/codex-accounts/runtime-home-service-managed-home.ts +++ b/src/main/codex-accounts/runtime-home-service-managed-home.ts @@ -112,13 +112,17 @@ export abstract class CodexRuntimeHomeManagedHome extends CodexRuntimeHomeSync { this.lastHostAccountUsedSelfContainedHome = true this.sharedAuthRefreshBlockedByManagedTransition = true this.markSharedRuntimeAuthManaged(account.id) + this.prepareManagedHomeResourcesForLaunch(perAccountHome) + return perAccountHome + } + + protected prepareManagedHomeResourcesForLaunch(perAccountHome: string): void { syncSystemCodexResourcesIntoManagedHome(perAccountHome) syncSystemConfigIntoManagedCodexHome({ runtimeHomePath: perAccountHome, systemHomePath: getSystemCodexHomePath() }) this.startSelfContainedSessionBridgeForLaunch(perAccountHome) - return perAccountHome } // Why: Codex's own `/resume` picker only lists rollouts under the launch diff --git a/src/main/codex/codex-pane-account-registry-mutations.ts b/src/main/codex/codex-pane-account-registry-mutations.ts index 61de741d45f6..86dbcae85cf1 100644 --- a/src/main/codex/codex-pane-account-registry-mutations.ts +++ b/src/main/codex/codex-pane-account-registry-mutations.ts @@ -137,7 +137,8 @@ function recordsEqual( return ( left?.selectionKey === right.selectionKey && left.accountId === right.accountId && - left.homeRoute === right.homeRoute + left.homeRoute === right.homeRoute && + left.pinned === right.pinned ) } diff --git a/src/main/codex/codex-pane-account-registry-types.ts b/src/main/codex/codex-pane-account-registry-types.ts index 65666532a878..285ea0e4416c 100644 --- a/src/main/codex/codex-pane-account-registry-types.ts +++ b/src/main/codex/codex-pane-account-registry-types.ts @@ -7,6 +7,8 @@ export type CodexPaneAccountRecord = { accountId: string | null /** Absent only on records written before route provenance was introduced. */ homeRoute?: CodexPaneHomeRoute + /** Explicit per-launch selection, independent of the global active account. */ + pinned?: true } export type CodexPaneAccountRegistryFile = { diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 55eac65e21cd..96d3c86fb019 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -129,6 +129,7 @@ function parseRegistry(parsed: unknown): CodexPaneAccountRegistryFile { empty.panes[ptyId] = { selectionKey: record.selectionKey, accountId: record.accountId, + ...(record.pinned === true ? { pinned: true } : {}), ...(homeRoute ? { homeRoute } : {}) } } diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index bef05d044a15..c8fbf511652a 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -28,20 +28,25 @@ type CodexPaneLaunchAccountSettings = Pick< */ export function resolveCodexPaneLaunchAccount(args: { pinnedByResume: boolean + pinnedAccountId?: string | null launchCodexHomePath: string | null systemCodexHomePath: string settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneAccountRecord | null { - const accountId = args.pinnedByResume - ? resolveCodexHomeOwnerAccountId(args) - : getSelectedCodexAccountIdForTarget(args.settings, args.target) + const accountId = + args.pinnedAccountId !== undefined + ? args.pinnedAccountId + : args.pinnedByResume + ? resolveCodexHomeOwnerAccountId(args) + : getSelectedCodexAccountIdForTarget(args.settings, args.target) if (accountId === undefined) { return null } return { selectionKey: getCodexSelectionLaneKey(args.target), accountId, + ...(args.pinnedAccountId !== undefined ? { pinned: true as const } : {}), homeRoute: resolveCodexPaneHomeRoute(args) } } diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index ac21c3d56046..5173115b38ef 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -48,6 +48,44 @@ afterEach(() => { }) describe('codex pane account registry', () => { + it('persists explicit pins and excludes them from global-selection restart notices', () => { + recordCodexPaneAccount('existing-a', { + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) + recordCodexPaneAccount('pinned-b', { + selectionKey: 'host', + accountId: 'account-b', + homeRoute: 'account-home', + pinned: true + }) + recordCodexPaneAccount('pinned-system', { + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home', + pinned: true + }) + const existing = getCodexPaneAccount('existing-a') + _internals.resetCache() + expect(getCodexPaneAccount('pinned-b')).toEqual({ + selectionKey: 'host', + accountId: 'account-b', + homeRoute: 'account-home', + pinned: true + }) + expect(getCodexPaneAccount('existing-a')).toEqual(existing) + const ptyIds = ['existing-a', 'pinned-b', 'pinned-system'] + expect(listStaleCodexPanes({ ptyIds, settings: settingsWithSelection('account-a') })).toEqual( + [] + ) + expect( + listStaleCodexPanes({ ptyIds, settings: settingsWithSelection('account-c') }).map( + (pane) => pane.ptyId + ) + ).toEqual(['existing-a']) + }) + it.each([ ['real-home', true], ['account-home', true], diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index 99a9cdf422ab..cf92e2af3abd 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -24,7 +24,7 @@ export function listStaleCodexPanes(args: { const records = listRecordedCodexPaneAccounts(args.ptyIds) for (const ptyId of args.ptyIds) { const record = records.get(ptyId) - if (!record) { + if (!record || record.pinned) { continue } const activeAccountId = getSelectedCodexAccountIdForTarget( diff --git a/src/main/ipc/pty-runtime-codex-account-pin.test.ts b/src/main/ipc/pty-runtime-codex-account-pin.test.ts new file mode 100644 index 000000000000..1cdb8066943a --- /dev/null +++ b/src/main/ipc/pty-runtime-codex-account-pin.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { readFileSyncMock, recordCodexPaneAccountMock } from './pty-ipc-mock-registry' +import { TEST_CODEX_AUTH_JSON, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' +import { registerPtyHandlers } from './pty' +import type { RuntimePtyController } from '../runtime/runtime-pty-controller-contract' +import type { GetSelectedCodexHomePath } from './pty/host-env/types' +import type { PtySpawnOptions } from '../providers/types' +import { createSettings } from '../codex-accounts/runtime-home-settings-test-fixtures' +import { createCodexAccountRecord } from '../codex-accounts/runtime-home-service-test-harness' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' + +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()) +) + +const homeA = join(TEST_MANAGED_ROOT, 'account-a', 'home') +const homeB = join(TEST_MANAGED_ROOT, 'account-b', 'home') +const systemHome = getSystemCodexHomePath() + +describe('native runtime Codex account pins', () => { + const { mainWindow, handlers, installDaemonTestProvider } = setupPtyIpcSuite() + + function fixture(resolveHome?: GetSelectedCodexHomePath) { + readFileSyncMock.mockImplementation((path: string) => + path.endsWith('auth.json') ? TEST_CODEX_AUTH_JSON : '' + ) + const settings = createSettings({ + activeCodexManagedAccountId: 'account-a', + activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} }, + codexManagedAccounts: [ + createCodexAccountRecord('account-a', 'a@example.com', 'provider-a', homeA), + createCodexAccountRecord('account-b', 'b@example.com', 'provider-b', homeB) + ] + }) + const prepareHome = vi.fn( + resolveHome ?? + ((_target, _env, context) => { + return context?.pinnedAccountId === null + ? systemHome + : context?.pinnedAccountId === 'account-b' + ? homeB + : homeA + }) + ) + let controller: RuntimePtyController | undefined + const runtime = { + setPtyController: (next: RuntimePtyController) => { + controller = next + }, + registerPty: vi.fn() + } + const spawn = installDaemonTestProvider() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies only the runtime/window members the native controller reads; no Electron window is created. + registerPtyHandlers(mainWindow as never, runtime as never, prepareHome, () => settings) + if (!controller?.spawn) { + throw new Error('native spawn controller missing') + } + return { spawn: controller.spawn, providerSpawn: spawn, prepareHome, settings } + } + + it('launches A and B concurrently with isolated environments and attribution while preserving another session', async () => { + const { spawn, providerSpawn, prepareHome, settings } = fixture() + const environments = new Map>() + providerSpawn.mockImplementation(async (options: PtySpawnOptions) => { + const id = options.sessionId ?? 'fixture-pty' + environments.set(id, { ...options.env }) + return { id } + }) + const existing = await spawn({ cols: 80, rows: 24, launchAgent: 'codex', command: 'codex' }) + const existingEnv = structuredClone(environments.get(existing.id)) + const initialSettings = structuredClone(settings) + prepareHome.mockClear() + const [a, b] = await Promise.all([ + spawn({ + cols: 80, + rows: 24, + launchAgent: 'codex', + command: 'codex', + codexAccountId: 'account-a' + }), + spawn({ + cols: 80, + rows: 24, + launchAgent: 'codex', + command: 'codex', + codexAccountId: 'account-b' + }) + ]) + expect(environments.get(a.id)).toMatchObject({ CODEX_HOME: homeA, ORCA_CODEX_HOME: homeA }) + expect(environments.get(b.id)).toMatchObject({ CODEX_HOME: homeB, ORCA_CODEX_HOME: homeB }) + expect(environments.get(existing.id)).toEqual(existingEnv) + expect(settings).toEqual(initialSettings) + expect(prepareHome).toHaveBeenCalledTimes(2) + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith(a.id, { + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home', + pinned: true + }) + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith(b.id, { + selectionKey: 'host', + accountId: 'account-b', + homeRoute: 'account-home', + pinned: true + }) + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith(existing.id, { + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) + }) + + it('overrides inherited home for explicit system despite the global managed account', async () => { + const { spawn, providerSpawn, settings } = fixture() + await spawn({ + cols: 80, + rows: 24, + launchAgent: 'codex', + codexAccountId: null, + env: { CODEX_HOME: homeA, ORCA_CODEX_HOME: homeA }, + envToDelete: ['CODEX_HOME'] + }) + expect(providerSpawn.mock.calls[0][0].env).toMatchObject({ + CODEX_HOME: systemHome, + ORCA_CODEX_HOME: systemHome + }) + expect(providerSpawn.mock.calls[0][0].envToDelete ?? []).not.toContain('CODEX_HOME') + expect(settings.activeCodexManagedAccountId).toBe('account-a') + expect(recordCodexPaneAccountMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ accountId: null, pinned: true }) + ) + }) + + it('leaves the resolver call signature unchanged when omitted', async () => { + const { spawn, prepareHome } = fixture() + await spawn({ cols: 80, rows: 24, launchAgent: 'codex' }) + expect(prepareHome.mock.calls[0]).toHaveLength(2) + }) + + it.each([ + { sessionId: 'existing' }, + { connectionId: 'ssh-host' }, + { launchAgent: 'claude' as const }, + { shellOverride: 'wsl.exe', cwd: '\\\\wsl$\\Ubuntu\\home\\fixture' } + ])( + 'refuses unsupported low-level placements %j before home preparation or spawn', + async (placement) => { + const { spawn, providerSpawn, prepareHome } = fixture() + await expect( + spawn({ + cols: 80, + rows: 24, + launchAgent: 'codex', + codexAccountId: 'account-b', + ...placement + }) + ).rejects.toThrow('--account') + expect(providerSpawn).not.toHaveBeenCalled() + expect(prepareHome).not.toHaveBeenCalled() + } + ) + + it('refuses missing pin support rather than launching the active account', async () => { + const { spawn, providerSpawn } = fixture(() => null) + await expect( + spawn({ cols: 80, rows: 24, launchAgent: 'codex', codexAccountId: 'account-b' }) + ).rejects.toThrow('could not prepare') + expect(providerSpawn).not.toHaveBeenCalled() + }) + + it('refuses a legacy callback that ignores the pin and returns the global active home', async () => { + const { spawn, providerSpawn } = fixture(() => homeA) + await expect( + spawn({ cols: 80, rows: 24, launchAgent: 'codex', codexAccountId: 'account-b' }) + ).rejects.toThrow('could not prepare') + await expect( + spawn({ cols: 80, rows: 24, launchAgent: 'codex', codexAccountId: null }) + ).rejects.toThrow('could not prepare') + expect(providerSpawn).not.toHaveBeenCalled() + }) + + it('refuses unavailable pinned credentials without resolving the global selection or falling back', async () => { + vi.useFakeTimers() + const { spawn, providerSpawn, prepareHome, settings } = fixture() + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('fixture missing auth'), { code: 'ENOENT' }) + }) + const attempt = spawn({ cols: 80, rows: 24, launchAgent: 'codex', codexAccountId: 'account-b' }) + const refused = expect(attempt).rejects.toThrow('credentials for this session') + await vi.runAllTimersAsync() + await refused + expect(prepareHome).toHaveBeenCalledTimes(1) + expect(prepareHome.mock.calls[0][2]).toEqual({ pinnedAccountId: 'account-b' }) + expect(settings.activeCodexManagedAccountId).toBe('account-a') + expect(providerSpawn).not.toHaveBeenCalled() + vi.useRealTimers() + }) + + it('refuses renderer-driven attempts to pass an account pin', async () => { + const { providerSpawn } = fixture() + await expect( + handlers.get('pty:spawn')?.(null, { + cols: 80, + rows: 24, + launchAgent: 'codex', + codexAccountId: 'account-b' + }) + ).rejects.toThrow('native runtime terminal') + expect(providerSpawn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts index 3b79c0e7876e..155b2f98a0ee 100644 --- a/src/main/ipc/pty/host-env/codex-home.ts +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -211,16 +211,22 @@ export function recordCodexPaneAccountForSpawn(args: { isDaemonHostSpawn: boolean isReattach: boolean pinnedByResume: boolean + pinnedAccountId?: string | null launchCodexHomePath: string | null target: CodexAccountSelectionTarget settings: GlobalSettings | undefined }): void { - if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { + if ( + !args.ptyId || + (!args.isDaemonHostSpawn && args.pinnedAccountId === undefined) || + args.isReattach + ) { return } const record = args.settings ? resolveCodexPaneLaunchAccount({ pinnedByResume: args.pinnedByResume, + ...(args.pinnedAccountId !== undefined ? { pinnedAccountId: args.pinnedAccountId } : {}), launchCodexHomePath: args.launchCodexHomePath, systemCodexHomePath: getSystemCodexHomePath(), settings: args.settings, diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index c3dfe1ae6e97..3a27abbd0c96 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -40,6 +40,7 @@ export type BuildPtyHostEnvOptions = { export type CodexHomeLaunchContext = { unavailableManagedHomePath?: string + pinnedAccountId?: string | null } // Why (#16441): Codex launch prep grants hook trust through a codex app-server diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 07fbbf942b87..3e6bbdfd705b 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -21,6 +21,11 @@ import type { PtyIpcSpawnState } from './spawn-state' export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise { const args = ctx.args + if (args.codexAccountId !== undefined) { + throw new Error( + '--account requires a native runtime terminal; renderer PTY launches are unsupported.' + ) + } // Establish daemon identity before the first await so hidden delivery is gated before byte zero. ctx.provider = getProvider(args.connectionId) ctx.isDaemonHostSpawn = diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 8e91799c4296..8484114eadd1 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -35,6 +35,8 @@ export type PtySpawnIpcArgs = { resumeProviderSession?: AgentProviderSessionMetadata launchToken?: unknown launchAgent?: TuiAgent + /** Unsupported on the renderer lane; accepted here only to refuse it explicitly. */ + codexAccountId?: string | null startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null worktreeId?: string diff --git a/src/main/ipc/pty/runtime/spawn-codex-home-selection.ts b/src/main/ipc/pty/runtime/spawn-codex-home-selection.ts new file mode 100644 index 000000000000..815b576ff906 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-codex-home-selection.ts @@ -0,0 +1,32 @@ +import type { RuntimePtySpawnState } from './spawn-state' +import { resolveCodexPaneLaunchAccount } from '../../../codex/codex-pane-launch-account' +import { getSystemCodexHomePath } from '../../../codex/codex-home-paths' + +export async function selectRuntimeLaunchCodexHome( + ctx: RuntimePtySpawnState +): Promise { + const { args, deps, codexSelectionTarget, env } = ctx + const home = + (args.codexAccountId !== undefined + ? await deps.getSelectedCodexHomePath?.(codexSelectionTarget, env, { + pinnedAccountId: args.codexAccountId + }) + : await deps.getSelectedCodexHomePath?.(codexSelectionTarget, env)) ?? null + if (args.codexAccountId !== undefined) { + const settings = deps.getSettings?.() + const owner = + home && settings + ? resolveCodexPaneLaunchAccount({ + pinnedByResume: true, + launchCodexHomePath: home, + systemCodexHomePath: getSystemCodexHomePath(), + settings, + target: codexSelectionTarget + }) + : null + if (!owner || owner.accountId !== args.codexAccountId) { + throw new Error('The native host could not prepare the pinned Codex account home.') + } + } + return home +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index e7a6de5e7bcb..e338180705ec 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -228,6 +228,7 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { isDaemonHostSpawn: ctx.isDaemonHostSpawn, isReattach: ctx.result.isReattach === true, pinnedByResume: ctx.codexResumeHomeSelected, + ...(args.codexAccountId !== undefined ? { pinnedAccountId: args.codexAccountId } : {}), launchCodexHomePath: ctx.selectedCodexHomePath, target: ctx.codexSelectionTarget, settings: ctx.deps.getSettings?.() diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 26c3856b45ee..1ea156bf482f 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -33,6 +33,7 @@ import { paneSpawnReservationsByOwnerKey } from '../pane/spawn-reservation' import type { RuntimePtySpawnState } from './spawn-state' +import { assertNewCodexAccountPane } from '../../../../shared/agent-launch-account' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native-account-launch' import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' @@ -92,7 +93,7 @@ export async function buildRuntimePtySpawnOptions( // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. ctx.stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] ) - if (ctx.codexResumeHomeSelected) { + if (ctx.codexResumeHomeSelected || args.codexAccountId !== undefined) { ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) @@ -259,6 +260,7 @@ export async function buildRuntimePtySpawnOptions( ? paneSpawnReservationsByOwnerKey.get(resolvedPaneSpawnReservationKey!) : undefined if (existingPaneSpawn) { + assertNewCodexAccountPane(args.codexAccountId, existingPaneSpawn) const concurrentResult = await existingPaneSpawn.promise const concurrentOwner = resolveStablePaneOwner( ctx.deps.runtime, diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 5effb7e682e8..c33dc28e0155 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -43,6 +43,7 @@ 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 { selectRuntimeLaunchCodexHome } from './spawn-codex-home-selection' export async function prepareRuntimePtySpawn( ctx: RuntimePtySpawnState @@ -129,6 +130,9 @@ export async function prepareRuntimePtySpawn( ctx.cwd, ctx.expectedWslDistro ) + if (args.codexAccountId !== undefined && ctx.codexSelectionTarget.runtime !== 'host') { + throw new Error('--account supports native host execution only; WSL launch was refused.') + } const codexResumePreparation = ctx.preAdoptedStablePane ? null : ctx.deps.prepareCodexResumeHome({ @@ -185,8 +189,7 @@ export async function prepareRuntimePtySpawn( if (args.preAllocatedHandle) { ctx.env = { ...ctx.env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } } - const selectLaunchCodexHome = async (): Promise => - (await ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env)) ?? null + const selectLaunchCodexHome = () => selectRuntimeLaunchCodexHome(ctx) ctx.selectedCodexHomePath = !ctx.preAdoptedStablePane && !args.connectionId ? getCompatibleSelectedCodexHomePath( @@ -201,6 +204,9 @@ export async function prepareRuntimePtySpawn( : await selectLaunchCodexHome() ) : null + if (args.codexAccountId !== undefined && !ctx.selectedCodexHomePath) { + throw new Error('The native host could not prepare the pinned Codex account home.') + } if ( !ctx.preAdoptedStablePane && args.launchAgent === 'codex' && @@ -209,7 +215,10 @@ export async function prepareRuntimePtySpawn( const resolution = resolveCodexHomeAfterManagedAuthReadiness({ selectedCodexHomePath: ctx.selectedCodexHomePath, getSettings: () => ctx.deps.getSettings?.(), - requiredCodexHomePath: codexResumeHome?.codexHomePath, + requiredCodexHomePath: + args.codexAccountId !== undefined + ? (ctx.selectedCodexHomePath ?? undefined) + : codexResumeHome?.codexHomePath, target: ctx.codexSelectionTarget, resolveCurrent: async () => getCompatibleSelectedCodexHomePath( diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 581d335ecdbc..8996b90ebfa3 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -87,6 +87,7 @@ export type RuntimePtySpawnArgs = { cwd?: string command?: string launchAgent?: TuiAgent + codexAccountId?: string | null commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery telemetry?: { diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 5897cb1d9afa..22b507934c85 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -53,6 +53,17 @@ export async function spawnPtyFromRuntimeController( deps: PtyRuntimeControllerDeps, args: RuntimePtySpawnArgs ) { + if ( + args.codexAccountId !== undefined && + (args.launchAgent !== 'codex' || + args.connectionId || + args.resumeProviderSession || + args.sessionId || + args.adoptedStablePane || + args.agentSessionEnsure) + ) { + throw new Error('--account requires a fresh native Codex terminal.') + } const ctx = createRuntimePtySpawnState(deps, args) if (!args.adoptedStablePane) { const leafId = @@ -75,6 +86,12 @@ export async function spawnPtyFromRuntimeController( args.connectionId ) : null + if ( + args.codexAccountId !== undefined && + (existingOwner || (ownerKey && paneSpawnReservationsByOwnerKey.has(ownerKey))) + ) { + throw new Error('--account cannot reuse a live or pending terminal pane.') + } if (ownerKey && !existingOwner && !paneSpawnReservationsByOwnerKey.has(ownerKey)) { ctx.paneSpawnReservationKey = ownerKey ctx.paneSpawnReservation = reservePaneSpawn(ownerKey) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 8e55f10700d6..71d6fdf6b768 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -23,10 +23,12 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork ): Promise { // Re-arm a consumed checkout after terminal startup, including failed creates. const rearm: PreparationRearmHolder = { fire: () => {} } + const { request, account } = await this.prepareWorktreeLaunchAccount(args) try { - return await trackRuntimeWorkspaceCreate(args, (events) => - this.performManagedWorktreeCreate(args, rearm, events) + const result = await trackRuntimeWorkspaceCreate(request, (events) => + this.performManagedWorktreeCreate(request, rearm, events) ) + return account ? { ...result, account } : result } finally { rearm.fire() } diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 9175fa8dec57..6438c42914e2 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -1,4 +1,6 @@ export type { TerminalCreateOptions } from './runtime-terminal-contracts' +export { codexAccountSpawnOptions } from '../../shared/agent-launch-account' +export { resolveTerminalCreateRouting } from './runtime-terminal-create-routing' export type { Worktree } from '../../shared/worktree/types' export type { RuntimeTerminalCreate } from '../../shared/runtime-types' export { diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 6a56bff56b0c..c12b4efa4ef1 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -12,6 +12,11 @@ export async function createDesktopTerminal( rendererWindow: Electron.BrowserWindow | null, createdWorktree?: Worktree ): Promise { + if (opts.codexAccountId !== undefined) { + throw new Error( + '--account requires a native runtime terminal; renderer-backed launch is unsupported.' + ) + } 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 eba6d03a29a7..1817ebab7820 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -21,15 +21,12 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate if (callerColors) { dependencies.setPairedViewerColors(callerColors) } - const presentation = dependencies.resolveTerminalPresentation(opts) - const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true - const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow() - const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null - const shouldCreateInBackground = - worktreeSelector !== undefined && - (Boolean(opts.agentSessionClaim) || - (!requiresRendererFocus && opts.rendererBacked !== true) || - availableAuthoritativeWindow === null) + const { presentation, rendererWindow, shouldCreateInBackground } = + dependencies.resolveTerminalCreateRouting( + worktreeSelector, + opts, + this.getAvailableAuthoritativeWindow() + ) if (shouldCreateInBackground) { if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') @@ -60,16 +57,19 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate if (launchOpts.signal?.aborted) { throw new Error('client_disconnected') } - const adoptedBeforeLaunch = await this.ptyController.adoptStablePane?.({ - cols: 120, - rows: 40, - cwd, - connectionId: workspace.connectionId, - worktreeId: workspace.id, - preAllocatedHandle, - tabId, - leafId - }) + const adoptedBeforeLaunch = + launchOpts.codexAccountId !== undefined + ? null + : await this.ptyController.adoptStablePane?.({ + cols: 120, + rows: 40, + cwd, + connectionId: workspace.connectionId, + worktreeId: workspace.id, + preAllocatedHandle, + tabId, + leafId + }) const launchToken = launchOpts.launchConfig ? (launchOpts.launchToken ?? dependencies.randomUUID()) : undefined @@ -131,6 +131,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ? launchOpts.command : (agentTeamsPlan?.command ?? launchOpts.command), launchAgent: launchOpts.launchAgent, + ...dependencies.codexAccountSpawnOptions(launchOpts.codexAccountId), commandDelivery: 'provider', startupCommandDelivery: launchOpts.startupCommandDelivery, env, diff --git a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts index 370ed4da51ba..80793de7550d 100644 --- a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts +++ b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts @@ -92,14 +92,19 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend protected async resolveTerminalWorkspaceLaunchScope( selector: string, - createdWorktree?: Worktree + createdWorktree?: Worktree, + options?: { preparePushTarget: false } ): Promise { - return (await this.resolveTerminalWorkspaceLaunchTarget(selector, createdWorktree)).scope + const target = options + ? this.resolveTerminalWorkspaceLaunchTarget(selector, createdWorktree, options) + : this.resolveTerminalWorkspaceLaunchTarget(selector, createdWorktree) + return (await target).scope } protected async resolveTerminalWorkspaceLaunchTarget( selector: string, - createdWorktree?: Worktree + createdWorktree?: Worktree, + options?: { preparePushTarget: false } ): Promise { const floatingTerminalSelector = selector === FLOATING_TERMINAL_WORKTREE_ID || @@ -142,20 +147,25 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend } // Metadata only (display name, hook settings); the routing decision is `resolution.connectionId`. const repo = resolution.repo ?? this.store?.getRepo(worktree.repoId) ?? null - triggerTerminalSpawnPushTargetMaterialization( - worktree.path, - worktree.pushTarget, - repo, - this.store, - worktree.repoId, - worktree.id - ) + if (options?.preparePushTarget !== false) { + triggerTerminalSpawnPushTargetMaterialization( + worktree.path, + worktree.pushTarget, + repo, + this.store, + worktree.repoId, + worktree.id + ) + } return { scope: { id: worktree.id, path: worktree.path, connectionId: resolution.connectionId, repo, + ...(options + ? { executionHostId: getWorktreeExecutionHostId(worktree, repo ?? undefined) } + : {}), folderWorkspace: null }, managedWorktree: worktree diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index f66240f57390..89b2605b466d 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -1,5 +1,11 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithRemoveManagedWorktree } from './orca-runtime-remove-managed-worktree' +import { + assertPinnedCodexTerminalOptions, + prepareRuntimeWorktreeLaunchAccount, + resolveRuntimeAgentLaunchAccount, + type RuntimeAgentLaunchAccountArgs +} from './runtime-agent-launch-account' import type { ExecutionHostId } from '../../shared/execution-host' import type { RemoveManagedWorktreeOptions, @@ -35,6 +41,7 @@ import { retryFailedRemovalUnlessRegistered } from '../worktree-removal-table' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { resolveQoderTerminalCommandForWorkspace } from './qoder-terminal-command-resolution' import { buildRuntimeAgentTerminalStartupOptions } from './runtime-agent-terminal-startup' +import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { protected async resolveWorktreeRemovalTarget( @@ -243,6 +250,9 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith workspace: TerminalWorkspaceLaunchScope, opts: TerminalCreateOptions ): Promise { + if (opts.codexAccountId !== undefined) { + assertPinnedCodexTerminalOptions(workspace, opts, this.requireStore()) + } const launch = await this.buildAgentTerminalCreateOptions(workspace, opts) return resolveQoderTerminalCommandForWorkspace( launch, @@ -252,6 +262,25 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith ) } + async resolveAgentLaunchAccount(args: RuntimeAgentLaunchAccountArgs) { + return resolveRuntimeAgentLaunchAccount(args, { + resolveAccount: (selector) => this.resolveCodexLaunchAccount(selector), + resolveWorkspace: (selector) => + this.resolveTerminalWorkspaceLaunchScope(selector, undefined, { preparePushTarget: false }), + showRepo: (selector) => this.showRepo(selector), + getStore: () => this.requireStore(), + canSpawn: () => Boolean(this.ptyController?.spawn) + }) + } + + protected prepareWorktreeLaunchAccount(request: RuntimeManagedWorktreeCreateArgs) { + return prepareRuntimeWorktreeLaunchAccount( + request, + (args) => this.resolveAgentLaunchAccount(args), + Boolean(this.ptyController?.spawn) + ) + } + protected async buildAgentTerminalCreateOptions( workspace: TerminalWorkspaceLaunchScope, opts: TerminalCreateOptions diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index fa9106dc2875..b7f2f3142cd9 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -21,6 +21,7 @@ import { refuseUnclassified } from '../../../shared/agent-session-wire-refusals' import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure' +import { WorktreeStartupError } from '../../../shared/worktree/worktree-startup-error' class LineageError extends Error { code = 'LINEAGE_PARENT_NOT_FOUND' @@ -30,6 +31,48 @@ class LineageError extends Error { } describe('mapRuntimeError', () => { + it.each(['runtime_error', 'operation_unknown'])( + 'reports the retained startup worktree without changing the cause code %s', + (code) => { + const cause = + code === 'runtime_error' + ? new Error('pinned auth unavailable') + : Object.assign(new Error('startup acceptance uncertain'), { + code, + data: { + requestId: 'request-original', + effects: [{ kind: 'terminal', id: 'term-uncertain' }], + residualResources: [{ kind: 'terminal', id: 'term-uncertain' }] + } + }) + const failure = mapRuntimeError( + 'req-startup', + { runtimeId: 'runtime-1' }, + new WorktreeStartupError('repo::created', cause) + ) + const effect = { kind: 'worktree', action: 'created', id: 'repo::created' } + expect(failure).toMatchObject({ + ok: false, + error: { + code, + message: cause.message, + data: { + worktreeId: 'repo::created', + effects: expect.arrayContaining([effect]), + residualResources: expect.arrayContaining([effect]) + } + } + }) + if (code === 'operation_unknown') { + expect(failure.error.data).toMatchObject({ + requestId: 'request-original', + effects: expect.arrayContaining([{ kind: 'terminal', id: 'term-uncertain' }]), + residualResources: expect.arrayContaining([{ kind: 'terminal', id: 'term-uncertain' }]) + }) + } + } + ) + it('preserves the stable skill failure category and retryability across RPC', () => { expect( mapRuntimeError( diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 6d122c02d453..e217ce3aa4c2 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -33,6 +33,7 @@ import { NESTED_WORKER_DEPTH_EXCEEDED_CODE } from '../../../shared/nested-worker import { WORKTREE_CREATE_COLLISION_CODE } from '../../../shared/new-workspace/worktree-create-collision' import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from '../../../shared/agent-launch-pane-already-live' import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../shared/agent-launch-session-already-exists' +import { WorktreeStartupError } from '../../../shared/worktree/worktree-startup-error' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -164,6 +165,26 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ ]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { + if (error instanceof WorktreeStartupError) { + const failure = mapRuntimeError(id, meta, error.cause) + const data = failure.error.data + const details = data !== null && typeof data === 'object' ? data : {} + const worktree = { kind: 'worktree', action: 'created', id: error.worktreeId } + return errorResponse(id, meta, failure.error.code, failure.error.message, { + ...details, + worktreeId: error.worktreeId, + effects: [ + worktree, + ...('effects' in details && Array.isArray(details.effects) ? details.effects : []) + ], + residualResources: [ + worktree, + ...('residualResources' in details && Array.isArray(details.residualResources) + ? details.residualResources + : []) + ] + }) + } const message = error instanceof Error ? error.message : String(error) if (isAgentSessionRefusalError(error)) { return agentSessionRefusalErrorResponse(id, meta, error) diff --git a/src/main/runtime/rpc/methods/agent-launch-account-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-account-replay.test.ts new file mode 100644 index 000000000000..a4fd7955deac --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-account-replay.test.ts @@ -0,0 +1,145 @@ +import '../unused-default-rpc-methods.test-fixture' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { closeTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { CAPABLE_CLIENT, methodNamed, rpcContext, runtimeStub } from './agent-launch.test-fixture' +import { AGENT_LAUNCH_METHODS } from './agent-launch' +import { RpcDispatcher } from '../dispatcher' + +const method = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') +let directory: string +let store: AgentSessionRecordStore +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-account-pin-replay-')) + store = await openTestAgentSessionRecordStore(directory) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the terminal launch ledger reads only deps.store from this host double. + setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) +}) +afterEach(async () => { + setStructuredAgentSessionHost(null) + closeTestJournalHostDatabase(directory) + await rm(directory, { recursive: true, force: true }) +}) + +describe('Codex account launch authoritative receipt replay', () => { + it.each(['agent.launch', 'agent.launchReplay'] as const)( + 'refuses a nested account that %s would strip instead of pinning', + (name) => { + const boundary = methodNamed(AGENT_LAUNCH_METHODS, name) + expect(() => + boundary.params.parse({ + agent: 'codex', + operationId: `${Date.now()}-000000000000000000000000000000ab`, + target: { + kind: 'create-worktree', + create: { repo: 'id:repo-1', name: 'pinned', startupAccount: 'account-b' } + } + }) + ).toThrow('requires account at the launch level') + } + ) + + it('refuses a pin before returning a warm legacy cache from an unpinned launch', async () => { + const runtime = { ...runtimeStub({ settings: {} }), getRuntimeId: () => 'test-runtime' } + const params = { + agent: 'codex', + target: { + kind: 'create-worktree', + create: { repo: 'id:repo-1', name: 'pinned', clientMutationId: 'same-create' } + } + } + const previous = await method.handler( + method.params.parse(params), + rpcContext(runtime, CAPABLE_CLIENT) + ) + expect(previous.account).toBeUndefined() + expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1) + runtime.resolveAgentLaunchAccount.mockClear() + runtime.createManagedWorktree.mockClear() + runtime.createTerminal.mockClear() + runtime.dedupeWorktreeCreate.mockClear() + const dispatcher = new RpcDispatcher({ + runtime: rpcContext(runtime, {}).runtime, + methods: AGENT_LAUNCH_METHODS + }) + const response = await dispatcher.dispatch( + { + id: 'pin-b', + authToken: 'test-token', + method: 'agent.launch', + params: { ...params, account: 'account-b' } + }, + CAPABLE_CLIENT + ) + expect(response).toMatchObject({ + ok: false, + error: { code: 'invalid_argument', message: expect.stringContaining('operationId') } + }) + expect(runtime.dedupeWorktreeCreate).not.toHaveBeenCalled() + expect(runtime.resolveAgentLaunchAccount).not.toHaveBeenCalled() + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + expect(runtime.createTerminal).not.toHaveBeenCalled() + }) + + it.each(['existing', 'create-worktree'] as const)( + 'records %s launches and replays the pin without resolving or spawning again', + async (kind) => { + const operationId = `${Date.now()}-000000000000000000000000000000bb` + const target = + kind === 'existing' + ? { kind, worktree: 'id:wt-7' } + : { + kind, + create: { repo: 'id:repo-1', name: 'pinned', clientMutationId: 'legacy-wrapper' } + } + const params = method.params.parse({ + operationId, + agent: 'codex', + account: 'B@EXAMPLE.COM', + target + }) + const runtime = runtimeStub() + const result = await method.handler(params, rpcContext(runtime, CAPABLE_CLIENT)) + expect(result).toMatchObject({ + outcome: { kind: 'terminal' }, + receipt: { mode: 'terminal', reason: 'pinned_codex_account' }, + account: { + provider: 'codex', + requested: 'b@example.com', + effective: { id: 'account-b', email: 'b@example.com' } + } + }) + if (kind === 'existing') { + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:wt-7', + expect.objectContaining({ codexAccountId: 'account-b' }) + ) + } else { + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ startupAgent: 'codex', startupAccount: 'account-b' }) + ) + } + const replayRuntime = runtimeStub() + replayRuntime.resolveAgentLaunchAccount.mockRejectedValue( + new Error('account removed after launch') + ) + const replay = await method.handler(params, rpcContext(replayRuntime, CAPABLE_CLIENT)) + expect(replay).toEqual(result) + expect(replayRuntime.resolveAgentLaunchAccount).not.toHaveBeenCalled() + expect(replayRuntime.createTerminal).not.toHaveBeenCalled() + expect(replayRuntime.createManagedWorktree).not.toHaveBeenCalled() + expect( + store.listOperationRows().find((row) => row.operationId === operationId)?.outcome.status + ).toBe('succeeded') + await expect( + method.handler({ ...params, account: 'account-a' }, rpcContext(runtime, CAPABLE_CLIENT)) + ).rejects.toThrow('agent_session_operation_conflict') + } + ) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 7be7fe8034b9..96c3d0de8abb 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -111,6 +111,7 @@ export function agentLaunchSurfaceFactory( text: prompt.text }), createTerminalAgent: async ({ + codexAccountId, worktreeId, agent, startupPrompt, @@ -126,6 +127,7 @@ export function agentLaunchSurfaceFactory( // The agent id is not a shell command — `cursor` is the desktop app, its CLI is // `cursor-agent` — so the runtime builds the configured launcher. startupAgent: agent, + ...(codexAccountId !== undefined ? { codexAccountId } : {}), // Offered to that launcher's startup plan; it rides only when the typed line can carry it, // and the runtime reports which so an uncarried prompt is pasted once the agent is ready. ...(startupPrompt diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index d9c9c40e4696..aa97d9b39322 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -37,6 +37,7 @@ export function agentLaunchWorkspaceFactory( ): AgentLaunchWorkspaceFactory { return { createWorktree: async ({ + codexAccountId, create, startupAgent, startupPrompt, @@ -65,6 +66,9 @@ export function agentLaunchWorkspaceFactory( { ...params, ...(startupAgent ? { startupAgent } : {}), + ...(codexAccountId !== undefined + ? { startupAccount: codexAccountId ?? 'system' } + : {}), // Only ever set alongside `startupAgent`, which is what the create requires; the // executor offers it only to an agent that takes its prompt on argv, and it rides only // when the typed line can carry it. diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index 909118c883fc..39b2c90cc3be 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -9,6 +9,7 @@ import { vi } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import { AgentLaunchPaneAlreadyLiveError } from '../../../../shared/agent-launch-pane-already-live' import type { RpcContext } from '../core' +import { resolveCodexLaunchAccount } from '../../../codex-accounts/codex-launch-account' export const STRUCTURED_PREFERENCE = { experimentalNativeChat: true, @@ -57,6 +58,15 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { }) ) return { + resolveAgentLaunchAccount: vi.fn(async (args: { account: string }) => + resolveCodexLaunchAccount( + [ + { id: 'account-a', email: 'a@example.com' }, + { id: 'account-b', email: 'b@example.com' } + ], + args.account + ) + ), getClientSettings: vi.fn(() => options.settings ?? STRUCTURED_PREFERENCE), getStructuredAgentSessionCreateSupport: vi.fn( async () => options.createSupport ?? { supported: true } diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index d0d3a905b780..a758149d6dba 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -95,8 +95,21 @@ async function agentLaunchIntent( params: AgentLaunchParams, runtime: OrcaRuntimeService ): Promise { + const account = + params.account !== undefined + ? await runtime.resolveAgentLaunchAccount({ + account: params.account, + agent: params.agent, + terminal: params.reuseTerminal?.handle, + cwd: params.cwd, + ...(params.target.kind === 'create-worktree' + ? { repo: params.target.create.repo } + : { worktree: params.target.worktree }) + }) + : undefined return { agent: params.agent, + ...(account ? { account } : {}), target: await agentLaunchTarget(params, runtime), ...(params.prompt ? { prompt: params.prompt } : {}), ...(params.sessionOptions ? { sessionOptions: params.sessionOptions } : {}), 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..353a73e5ba5d 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -40,6 +40,7 @@ export const WORKER_START_VOCABULARY: AgentLaunchModeVocabulary = { * listed but no longer read: a structured worker honours all three, and naming them here keeps * the set of options this decision has considered visible. */ type WorkerStartModePlacement = { + account?: string agent?: string on?: string terminal?: string 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 7a6168dbcd20..7e850f04188f 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 @@ -1,5 +1,6 @@ import { probeWorkerOpenCodeModelLaunchSupport } from './worker-opencode-model-preflight' import { resolveWorkerConfiguredAgentParams } from './worker-configured-agent-preflight' +import { resolveWorkerLaunchAccount } from './worker-launch-preferences' import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' @@ -123,6 +124,17 @@ export async function startLocalWorker(args: { }) } let mode = await resolveWorkerStartModeOnHost(runtime, args.mode, resolvedWorktree?.id, agent) + const account = await resolveWorkerLaunchAccount( + runtime, + params.account, + agent, + creationWorktree + ? { repo: params.repo ?? creationWorktree.repoId } + : { worktree: `id:${resolvedWorktree!.id}` } + ) + if (account) { + launch.receipt.account = account + } const startOptions = { worktree: requestedWorktree, @@ -187,6 +199,7 @@ export async function startLocalWorker(args: { mode, agent, launchPreferences: launch.preferences, + ...(account ? { codexAccountId: account.effective.id } : {}), effects, onStage: (stage) => { failedStage = stage 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 a5b12d7d46ad..4a0d37105fe8 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,6 +9,8 @@ import { } from '../../../../../../shared/agent-session-option-launch' import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' import type { TuiAgent } from '../../../../../../shared/tui-agent' +import type { AgentLaunchAccountReceipt } from '../../../../../../shared/agent-launch-account' +import type { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationError } from '../../../../orchestration/orchestration-error' export type OrchestrationWorkerLaunchSelection = { @@ -18,10 +20,23 @@ export type OrchestrationWorkerLaunchSelection = { } export type OrchestrationWorkerLaunchReceipt = { + account?: AgentLaunchAccountReceipt requested: OrchestrationWorkerLaunchSelection effective: OrchestrationWorkerLaunchSelection | null } +export async function resolveWorkerLaunchAccount( + runtime: Pick, + account: string | undefined, + agent: TuiAgent | undefined, + placement: { repo: string } | { worktree: string } +) { + if (account === undefined) { + return undefined + } + return runtime.resolveAgentLaunchAccount({ account, agent, ...placement }) +} + export function createWorkerLaunchReceipt(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..04c4c9f84721 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,7 @@ type WorkerAgentPlacementArgs = { mode: WorkerStartModeReceipt agent: TuiAgent | undefined launchPreferences: AgentLaunchPreferences | undefined + codexAccountId?: string | null 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 +121,7 @@ async function placeInCreatedWorktree( agent: args.agent as TuiAgent, withAgentTerminal: args.mode.mode !== 'structured', ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + ...(args.codexAccountId !== undefined ? { codexAccountId: args.codexAccountId } : {}), effects: args.effects }) const worktree = requireWorktree(created.worktree) @@ -170,6 +172,7 @@ async function createWorkerAgentSurface( worktreeId, agent: args.agent as TuiAgent, ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + ...(args.codexAccountId !== undefined ? { codexAccountId: args.codexAccountId } : {}), taskId: args.taskId, effects: args.effects }) 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 3e86dea3e28d..4291fb791fc6 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 + codexAccountId?: string | null taskId: string effects: WorkerEffect[] }): Promise<{ handle: string; warning?: string }> { @@ -69,6 +70,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { // desktop app while its CLI is `cursor-agent`. Let the runtime build the // configured launcher instead of executing the raw id. startupAgent: args.agent, + ...(args.codexAccountId !== undefined ? { codexAccountId: args.codexAccountId } : {}), launchSource: 'orchestration', ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), title: `worker-${args.taskId}`, 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 cbdab8d1f940..3cdb7dd95fca 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 @@ -8,6 +8,7 @@ */ import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority' +import { WorktreeStartupError } from '../../../../../../shared/worktree/worktree-startup-error' import type { TuiAgent } from '../../../../../../shared/tui-agent' import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' @@ -36,6 +37,7 @@ export async function createWorkerWorktree(args: { * bracketed paste. The renderer's own structured worktree create skips both the same way. */ withAgentTerminal: boolean launchPreferences?: AgentLaunchPreferences + codexAccountId?: string | null effects: WorkerEffect[] }): Promise<{ worktree: Awaited> @@ -45,46 +47,60 @@ export async function createWorkerWorktree(args: { const { runtime, db, dispatchId, requestedWorktree, coordinatorWorktree, params, effects } = args const setupDecision = params.setup ?? 'run' db.recordWorkerStage({ dispatchId, stage: 'worktree_creating', effects }) - const created = await runtime.createManagedWorktree({ - repoSelector: params.repo ?? coordinatorWorktree.repoId, - name: params.name as string, - baseBranch: params.baseBranch, - displayName: params.displayName, - ...(params.displayName !== undefined ? { displayNameKind: 'user' as const } : {}), - comment: params.comment, - // setupDecision runs setup without the legacy runHooks activation side effect. - runHooks: false, - setupDecision, - awaitTerminalProvisioning: true, - observeSetupCompletion: true, - createdWithAgent: args.agent, - ...(args.withAgentTerminal - ? { - startupAgent: args.agent, - startupLaunchSource: 'orchestration', - ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}) - } - : {}), - activate: false, - lineage: { - parentWorktree: requestedWorktree === 'new-child' ? coordinatorWorktree.id : undefined, - noParent: requestedWorktree === 'new-top-level', - callerTerminalHandle: params.from - } - }) + const recordCreatedWorktree = (worktreeId: string): void => { + effects.push({ + kind: 'worktree', + action: requestedWorktree === 'new-child' ? 'created_child' : 'created_top_level', + id: worktreeId + }) + db.recordWorkerStage({ + dispatchId, + stage: 'worktree_created', + worktreeId, + effects, + residualResources: effects + }) + } + const created = await runtime + .createManagedWorktree({ + repoSelector: params.repo ?? coordinatorWorktree.repoId, + name: params.name as string, + baseBranch: params.baseBranch, + displayName: params.displayName, + ...(params.displayName !== undefined ? { displayNameKind: 'user' as const } : {}), + comment: params.comment, + // setupDecision runs setup without the legacy runHooks activation side effect. + runHooks: false, + setupDecision, + awaitTerminalProvisioning: true, + observeSetupCompletion: true, + createdWithAgent: args.agent, + ...(args.withAgentTerminal + ? { + startupAgent: args.agent, + ...(args.codexAccountId !== undefined + ? { startupAccount: args.codexAccountId ?? 'system' } + : {}), + startupLaunchSource: 'orchestration', + ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}) + } + : {}), + activate: false, + lineage: { + parentWorktree: requestedWorktree === 'new-child' ? coordinatorWorktree.id : undefined, + noParent: requestedWorktree === 'new-top-level', + callerTerminalHandle: params.from + } + }) + .catch((error: unknown) => { + if (error instanceof WorktreeStartupError) { + recordCreatedWorktree(error.worktreeId) + throw error.cause + } + throw error + }) const terminalHandle = created.startupTerminal?.handle - effects.push({ - kind: 'worktree', - action: requestedWorktree === 'new-child' ? 'created_child' : 'created_top_level', - id: created.worktree.id - }) - db.recordWorkerStage({ - dispatchId, - stage: 'worktree_created', - worktreeId: created.worktree.id, - effects, - residualResources: effects - }) + recordCreatedWorktree(created.worktree.id) const setupReceipt = { requested: setupDecision, effective: setupDecision, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-codex-account.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-codex-account.test.ts new file mode 100644 index 000000000000..e63780ed60ec --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-codex-account.test.ts @@ -0,0 +1,256 @@ +import '../../../unused-default-rpc-methods.test-fixture' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createOrchestrationRpcHarness } from '../rpc-test-harness' +import type { OrchestrationRpcState } from '../rpc-test-harness' +import { resolveCodexLaunchAccount } from '../../../../../codex-accounts/codex-launch-account' +import type { Worktree } from '../../../../../../shared/worktree/types' +import { startRuntimeLocalWorktreeTerminals } from '../../../../runtime-local-worktree-terminal-startup' +import { WorktreeStartupError } from '../../../../../../shared/worktree/worktree-startup-error' + +const h = createOrchestrationRpcHarness() +let state: OrchestrationRpcState +const accounts = [ + { id: 'account-a', email: 'a@example.com' }, + { id: 'account-b', email: 'b@example.com' } +] + +beforeEach(() => { + state = h.setup() + const { runtime } = state + vi.spyOn(runtime, 'showRepo').mockResolvedValue({ + id: 'repo', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' + }) + vi.spyOn(state.db, 'createStartingWorkerDispatch') + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_coord' + ? h.coordinatorPaneKey + : handle === 'term_worker' + ? 'tab_worker:leaf_worker' + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockImplementation( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worker placement reads only id/repoId and the terminal handle/status from these fixtures. + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these worker paths read only workspace id and repoId. + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + id: 'repo::worktree', + repoId: 'repo' + } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: existing-workspace worker placement reads only this workspace id. + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: 'repo::worktree' + } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: null + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'resolveAgentLaunchAccount').mockImplementation(async (args) => { + if (args.account === undefined) { + return undefined + } + return resolveCodexLaunchAccount(accounts, args.account) + }) +}) +afterEach(() => h.cleanup()) + +function start(params: Record) { + return h.call( + 'orchestration.workerStart', + { from: 'term_coord', spec: 'isolated fixture worker', agent: 'codex', ...params }, + state.ctx + ) +} + +describe('worker-start account plumbing and durable receipts', () => { + it.each(['new-child', 'new-top-level'] as const)( + 'preserves the created %s when pinned terminal startup fails', + async (requestedWorktree) => { + const { db, runtime } = state + const createdWorktree: Worktree = { + id: 'repo::created', + repoId: 'repo', + path: '/created', + head: 'abc', + branch: 'pinned-worker', + isBare: false, + isMainWorktree: false, + displayName: 'pinned-worker', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } + vi.spyOn(runtime, 'resolveAgentLaunchAccount').mockResolvedValue({ + provider: 'codex', + requested: 'account-b', + effective: { id: 'account-b', email: 'b@example.com' } + }) + vi.mocked(runtime.createTerminal).mockRejectedValue(new Error('pinned auth unavailable')) + const activate = vi.fn() + const provision = vi.fn() + vi.spyOn(runtime, 'removeManagedWorktree') + vi.spyOn(runtime, 'createManagedWorktree').mockImplementation(async (request) => { + await startRuntimeLocalWorktreeTerminals({ + request: { ...request, startupCodexAccountId: 'account-b' }, + repo: { id: 'repo', path: '/repo', displayName: 'repo', badgeColor: 'blue', addedAt: 1 }, + worktree: createdWorktree, + createdWithAgent: 'codex', + startup: { command: 'codex' }, + ports: { + canSpawn: true, + createTerminal: (selector, options) => runtime.createTerminal(selector, options), + pasteDraft: vi.fn(), + sendFollowup: vi.fn(), + provision, + activate + } + }) + throw new Error('Pinned startup must reject before this point') + }) + + const result = await start({ + worktree: requestedWorktree, + account: 'account-b', + name: 'pinned-worker' + }) + const effect = { + kind: 'worktree', + action: requestedWorktree === 'new-child' ? 'created_child' : 'created_top_level', + id: createdWorktree.id + } + expect(result).toMatchObject({ + state: 'failed', + failedStage: 'worktree_create', + lastError: 'pinned auth unavailable', + effects: [effect], + residualResources: [effect], + launch: { account: { effective: { id: 'account-b' } } } + }) + if ( + !result || + typeof result !== 'object' || + !('dispatchId' in result) || + typeof result.dispatchId !== 'string' + ) { + throw new Error('Missing failed Dispatch receipt') + } + const worker = db.getWorkerDispatch(result.dispatchId)! + expect(worker.worktree_id).toBe(createdWorktree.id) + expect(JSON.parse(worker.effects)).toEqual([effect]) + expect(JSON.parse(worker.residual_resources)).toEqual([effect]) + expect(worker.agent_terminal_handle).toBeNull() + expect(db.getWorkerTerminalResourceByOwner(result.dispatchId)).toBeUndefined() + expect(db.listTasks()[0]?.status).toBe('failed') + expect(result).not.toHaveProperty('recovery') + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + expect(runtime.createTerminal).toHaveBeenCalledWith( + `id:${createdWorktree.id}`, + expect.objectContaining({ codexAccountId: 'account-b' }) + ) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + expect(runtime.removeManagedWorktree).not.toHaveBeenCalled() + expect(activate).not.toHaveBeenCalled() + expect(provision).not.toHaveBeenCalled() + } + ) + + it('preserves the known worktree while pinned terminal acceptance remains unknown', async () => { + const { runtime } = state + vi.spyOn(runtime, 'createManagedWorktree').mockRejectedValue( + new WorktreeStartupError( + 'repo::created', + Object.assign(new Error('terminal acceptance uncertain'), { code: 'operation_unknown' }) + ) + ) + const result = await start({ + worktree: 'new-child', + name: 'pinned-worker', + account: 'account-b' + }) + expect(result).toMatchObject({ + state: 'outcome_unknown', + failedStage: 'worktree_create', + lastError: 'terminal acceptance uncertain', + effects: [expect.objectContaining({ kind: 'worktree', id: 'repo::created' })], + residualResources: [expect.objectContaining({ kind: 'worktree', id: 'repo::created' })] + }) + expect(result).not.toHaveProperty('recovery') + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + it.each(['account-b', 'B@EXAMPLE.COM', 'system'])( + 'pins %s through current-workspace launch and records its receipt', + async (account) => { + const receipt = resolveCodexLaunchAccount(accounts, account) + const result = await start({ account }) + expect(result).toMatchObject({ state: 'ready', launch: { account: receipt } }) + expect(state.runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ startupAgent: 'codex', codexAccountId: receipt.effective.id }) + ) + if ( + !result || + typeof result !== 'object' || + !('dispatchId' in result) || + typeof result.dispatchId !== 'string' + ) { + throw new Error('missing dispatch receipt') + } + const dispatch = state.db.getWorkerDispatch(result.dispatchId) + expect(JSON.parse(dispatch?.start_options ?? 'null')).toMatchObject({ + launch: { account: receipt } + }) + } + ) + + it('omits the account argument and receipt for legacy launches', async () => { + const result = await start({}) + expect(result).not.toHaveProperty('launch.account') + expect(state.runtime.resolveAgentLaunchAccount).not.toHaveBeenCalled() + expect(vi.mocked(state.runtime.createTerminal).mock.calls[0][1]).not.toHaveProperty( + 'codexAccountId' + ) + }) + + it.each([ + { agent: 'claude' }, + { terminal: 'term_existing' }, + { on: 'old-server' }, + { account: 'unknown' } + ])( + 'refuses an unsupported request %j before any Task, Dispatch or terminal is created', + async (params) => { + await expect(start({ account: 'account-b', ...params })).rejects.toThrow() + expect(state.db.listTasks()).toEqual([]) + expect(state.db.createStartingWorkerDispatch).not.toHaveBeenCalled() + expect(state.runtime.createTerminal).not.toHaveBeenCalled() + expect(state.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index 7709ae043aba..c0c0748d0ab7 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -192,6 +192,25 @@ describe('orchestration new-worktree workers', () => { }) }) + it.each(['account-b', 'system'])( + 'pins %s through the shared agent-first worktree creation', + async (selector) => { + mockCreatedWorktree() + const id = selector === 'system' ? null : selector + const account = { + provider: 'codex' as const, + requested: selector, + effective: { id, email: id ? 'b@example.com' : null } + } + vi.spyOn(runtime, 'resolveAgentLaunchAccount').mockResolvedValue(account) + const { result } = await startWorker({ account: selector }) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ startupAgent: 'codex', startupAccount: selector }) + ) + expect(result).toMatchObject({ state: 'ready', launch: { account } }) + } + ) + it.each(['new-child', 'new-top-level'])( 'refuses an OpenCode model for %s without probing', async (worktree) => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index be4e40ab2b64..8e8baf4eeca8 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -1,4 +1,5 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-error' +import { assertCodexAccountLaunchRequest } from '../../../../../../shared/agent-launch-account' import { defineMethod } from '../../../core' import { startFederatedWorker } from '../federation/federated-worker-start' import { startLocalWorker } from './local-worker-start' @@ -22,6 +23,7 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ params, { runtime, orchestrationMutation, orchestrationCompatibilityEvidence, orchestrationCaller } ) => { + assertCodexAccountLaunchRequest(params) if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { throw new OrchestrationError( 'invalid_argument', diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index ac4c349d7562..a1280286bcfb 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -77,6 +77,7 @@ export function buildManagedWorktreeCreateArgs( } : undefined, ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), + ...(params.startupAccount !== undefined ? { startupAccount: params.startupAccount } : {}), ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), ...(params.launchSource ? { startupLaunchSource: params.launchSource } : {}), startupDraft: params.startupDraft, diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index c824301e5a5f..8afdad108ad1 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -64,6 +64,10 @@ export class RuntimeAccountController { return this.services?.claudeAccounts.getRuntimeConfigDir(target) ?? null } + resolveCodexLaunchAccount(selector: string) { + return this.requireServices().codexAccounts.runtimeHomeService.resolveLaunchAccount(selector) + } + getSnapshot(): AccountsSnapshot { const { claudeAccounts, codexAccounts, rateLimits } = this.requireServices() return { diff --git a/src/main/runtime/runtime-agent-launch-account.ts b/src/main/runtime/runtime-agent-launch-account.ts new file mode 100644 index 000000000000..42995aeb4613 --- /dev/null +++ b/src/main/runtime/runtime-agent-launch-account.ts @@ -0,0 +1,117 @@ +import { + assertCodexAccountLaunchRequest, + type AgentLaunchAccountReceipt +} from '../../shared/agent-launch-account' +import type { Repo } from '../../shared/repo-types' +import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' +import { assertNativeCodexAccountPlacement } from './runtime-codex-account-placement' +import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' +import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' +import type { RuntimeStore } from './runtime-store-contract' +import type { TerminalCreateOptions } from './runtime-terminal-contracts' + +export type RuntimeAgentLaunchAccountArgs = { + account?: string + agent?: string + terminal?: string + on?: string + worktree?: string + repo?: string + cwd?: string +} +type AccountResolver = ( + args: RuntimeAgentLaunchAccountArgs +) => Promise + +export async function resolveRuntimeAgentLaunchAccount( + args: RuntimeAgentLaunchAccountArgs, + ports: { + resolveAccount: (selector: string) => AgentLaunchAccountReceipt + resolveWorkspace: (selector: string) => Promise + showRepo: (selector: string) => Promise + getStore: () => RuntimeStore + canSpawn: () => boolean + } +): Promise { + if (args.account === undefined) { + return undefined + } + assertCodexAccountLaunchRequest(args) + if (!ports.canSpawn()) { + throw new Error('--account requires an available native terminal launcher.') + } + const account = ports.resolveAccount(args.account) + const workspace = args.worktree ? await ports.resolveWorkspace(args.worktree) : null + const repo = workspace?.repo ?? (args.repo ? await ports.showRepo(args.repo) : null) + const cwd = args.cwd ?? workspace?.path ?? repo?.path + if (!cwd) { + throw new Error('--account requires an execution workspace or repository.') + } + assertNativeCodexAccountPlacement({ + store: ports.getStore(), + repo, + executionHostId: workspace?.executionHostId ?? workspace?.folderWorkspace?.executionHostId, + connectionId: workspace?.connectionId, + worktreeId: workspace?.id, + cwd + }) + return account +} + +export function assertPinnedCodexTerminalOptions( + workspace: TerminalWorkspaceLaunchScope, + opts: TerminalCreateOptions, + store: RuntimeStore +): void { + assertCodexAccountLaunchRequest({ + account: 'resolved', + agent: opts.startupAgent ?? opts.launchAgent + }) + assertNativeCodexAccountPlacement({ + store, + repo: workspace.repo, + executionHostId: workspace.executionHostId ?? workspace.folderWorkspace?.executionHostId, + connectionId: workspace.connectionId, + worktreeId: workspace.id, + cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path, + shellOverride: opts.shellOverride + }) + if ( + opts.resumeProviderSession || + opts.sessionId || + opts.agentSessionClaim || + opts.rendererBacked + ) { + throw new Error( + '--account requires a fresh native terminal; resume, adoption and renderer-backed launch are unsupported.' + ) + } +} + +export async function prepareRuntimeWorktreeLaunchAccount( + request: RuntimeManagedWorktreeCreateArgs, + resolveAccount: AccountResolver, + canSpawn: boolean +): Promise<{ + request: RuntimeManagedWorktreeCreateArgs + account?: AgentLaunchAccountReceipt +}> { + if (request.startupAccount === undefined) { + return { request } + } + if (request.startup || request.startupDraft || !canSpawn) { + throw new Error( + '--account requires a fresh native startup agent; explicit startup commands and drafts are unsupported.' + ) + } + const account = await resolveAccount({ + account: request.startupAccount, + agent: request.startupAgent, + repo: request.repoSelector, + cwd: request.startupCwd + }) + if (!account) { + throw new Error('The runtime could not resolve the pinned Codex account.') + } + return { request: { ...request, startupCodexAccountId: account.effective.id }, account } +} diff --git a/src/main/runtime/runtime-codex-account-placement.test.ts b/src/main/runtime/runtime-codex-account-placement.test.ts new file mode 100644 index 000000000000..878ed724fc06 --- /dev/null +++ b/src/main/runtime/runtime-codex-account-placement.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { Project } from '../../shared/project-types' +import { createSettings } from '../codex-accounts/runtime-home-settings-test-fixtures' +import { assertNativeCodexAccountPlacement } from './runtime-codex-account-placement' +import { decideAgentLaunchMode } from '../agent-launch/agent-launch-mode' + +vi.mock('../wsl', () => ({ + getCachedWslAvailability: () => true, + hasCachedWslAvailability: () => true, + getCachedWslDistros: () => ['Ubuntu'], + hasCachedWslDistros: () => true +})) + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') +afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } +}) + +const repo: Repo = { + id: 'repo-1', + path: 'C:\\fixture', + displayName: 'fixture', + badgeColor: 'blue', + addedAt: 1 +} +function fixture() { + const settings = createSettings() + const projects: Project[] = [] + const store = { getSettings: () => settings, getRepo: () => repo, getProjects: () => projects } + return { args: { store, repo, cwd: repo.path }, settings, projects } +} + +describe('Codex account native placement preflight', () => { + it.each(['account-a', 'account-b', 'system'])( + 'keeps %s pinned to a terminal while an unpinned custom command follows the chat default', + (account) => { + const settings = { + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true + } + expect( + decideAgentLaunchMode({ placement: { agent: 'codex', account }, settings }) + ).toMatchObject({ + mode: 'terminal', + preferred: 'structured', + reason: 'pinned_codex_account' + }) + const f = fixture() + Object.assign(f.settings, settings, { agentCmdOverrides: { codex: 'custom-wrapper' } }) + expect( + decideAgentLaunchMode({ placement: { agent: 'codex' }, settings: f.settings }) + ).toMatchObject({ + mode: 'structured', + reason: 'user_default' + }) + expect(() => assertNativeCodexAccountPlacement(f.args)).toThrow('custom Codex launch command') + } + ) + + it.each(['darwin', 'linux', 'win32'])( + 'accepts a native git or folder workspace on %s', + (platform) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + const { args } = fixture() + expect(() => assertNativeCodexAccountPlacement(args)).not.toThrow() + expect(() => assertNativeCodexAccountPlacement({ ...args, repo: null })).not.toThrow() + } + ) + + it.each(['ssh:target', 'runtime:server'] as const)( + 'rejects %s for both repositories and folder workspaces', + (executionHostId) => { + const { args } = fixture() + expect(() => + assertNativeCodexAccountPlacement({ ...args, repo: { ...repo, executionHostId } }) + ).toThrow('native host only') + expect(() => + assertNativeCodexAccountPlacement({ ...args, repo: null, executionHostId }) + ).toThrow('native host only') + } + ) + + it('rejects SSH routing independently of repo metadata', () => { + expect(() => + assertNativeCodexAccountPlacement({ ...fixture().args, connectionId: 'target' }) + ).toThrow('native host only') + }) + + it('rejects custom commands that can replace the Codex launch', () => { + const { args, settings } = fixture() + settings.agentCmdOverrides = { codex: 'custom-codex-wrapper' } + expect(() => assertNativeCodexAccountPlacement(args)).toThrow('custom Codex launch command') + }) + + it('rejects a WSL cwd on every host', () => { + expect(() => + assertNativeCodexAccountPlacement({ + ...fixture().args, + cwd: '\\\\wsl$\\Ubuntu\\home\\fixture' + }) + ).toThrow('WSL') + }) + + it('rejects Windows WSL shell choice and project placement before a checkout is created', () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + const { args, projects } = fixture() + expect(() => assertNativeCodexAccountPlacement({ ...args, shellOverride: 'wsl.exe' })).toThrow( + 'WSL' + ) + projects.push({ + id: 'project-1', + displayName: 'fixture', + badgeColor: 'blue', + sourceRepoIds: [repo.id], + createdAt: 1, + updatedAt: 1, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + expect(() => assertNativeCodexAccountPlacement(args)).toThrow('WSL') + }) +}) diff --git a/src/main/runtime/runtime-codex-account-placement.ts b/src/main/runtime/runtime-codex-account-placement.ts new file mode 100644 index 000000000000..da521a239a8e --- /dev/null +++ b/src/main/runtime/runtime-codex-account-placement.ts @@ -0,0 +1,56 @@ +import type { Repo } from '../../shared/repo-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import { + isWslShellName, + resolveLocalWindowsTerminalRuntimeOptions +} from '../../shared/local-windows-terminal-runtime' +import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' +import { isWslUncPath } from '../../shared/wsl-paths' +import { + resolveLocalProjectRuntimeForRepo, + resolveLocalProjectRuntimeForWorktreeId +} from '../local-project-runtime-resolution' +import type { RuntimeStore } from './runtime-store-contract' + +export function assertNativeCodexAccountPlacement(args: { + store: Pick + repo: Repo | null + connectionId?: string | null + executionHostId?: ExecutionHostId | null + worktreeId?: string + cwd: string + shellOverride?: string +}): void { + if ( + args.connectionId || + (args.executionHostId && args.executionHostId !== 'local') || + (args.repo && getRepoExecutionHostId(args.repo) !== 'local') + ) { + throw new Error( + '--account supports the connected runtime native host only; SSH and forwarded workspaces are unsupported.' + ) + } + const settings = args.store.getSettings() + if (hasExplicitTuiLaunchCommand(settings, 'codex')) { + throw new Error('--account cannot guarantee the account with a custom Codex launch command.') + } + if (isWslUncPath(args.cwd)) { + throw new Error('--account does not support WSL workspaces.') + } + if (process.platform === 'win32') { + const projectRuntime = args.worktreeId + ? resolveLocalProjectRuntimeForWorktreeId(args.store, args.worktreeId) + : args.repo + ? resolveLocalProjectRuntimeForRepo(args.store, args.repo) + : undefined + const options = resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings, + projectRuntime + }) + if (isWslShellName(options.shellOverride)) { + throw new Error('--account does not support WSL execution; select a native host workspace.') + } + } +} diff --git a/src/main/runtime/runtime-codex-account-pre-create.test.ts b/src/main/runtime/runtime-codex-account-pre-create.test.ts new file mode 100644 index 000000000000..eb3457b991ae --- /dev/null +++ b/src/main/runtime/runtime-codex-account-pre-create.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { createSettings } from '../codex-accounts/runtime-home-settings-test-fixtures' +import { resolveCodexLaunchAccount } from '../codex-accounts/codex-launch-account' +import { OrcaRuntimeService } from './orca-runtime' +import { RpcDispatcher } from './rpc/dispatcher' +import { WORKTREE_METHODS } from './rpc/methods/worktree' + +const { createLocal, createFolder } = vi.hoisted(() => ({ + createLocal: vi.fn(), + createFolder: vi.fn() +})) +vi.mock('./runtime-local-worktree-create', () => ({ + createRuntimeLocalManagedWorktree: createLocal +})) +vi.mock('./runtime-folder-worktree-create', () => ({ createRuntimeFolderWorktree: createFolder })) +beforeEach(() => { + createLocal.mockReset() + createFolder.mockReset() +}) + +function fixture(repoUpdates: Partial = {}) { + const settings = createSettings() + const store = { getSettings: () => settings, updateSettings: vi.fn(), getProjects: () => [] } + const repo: Repo = { + id: 'repo-1', + path: '/fixture', + displayName: 'fixture', + badgeColor: 'blue', + addedAt: 1, + ...repoUpdates + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: account preflight reads only settings/projects; reaching creation or any omitted store method is a test failure. + const runtime = new OrcaRuntimeService(store as never) + const spawn = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these refusal tests only check that native spawn exists and never call the controller. + runtime.setPtyController({ spawn } as never) + vi.spyOn(runtime, 'resolveCodexLaunchAccount').mockImplementation((selector) => + resolveCodexLaunchAccount( + [ + { id: 'account-a', email: 'same@example.com' }, + { id: 'account-b', email: 'SAME@example.com' } + ], + selector + ) + ) + vi.spyOn(runtime, 'showRepo').mockResolvedValue(repo) + const showSettings = vi.spyOn(store, 'getSettings') + const request = { + repoSelector: 'id:repo-1', + name: 'pinned', + startupAgent: 'codex' as const, + startupAccount: 'account-b' + } + return { runtime, request, store, settings, spawn, showSettings } +} + +function expectNoCreate(f: ReturnType) { + expect(createLocal).not.toHaveBeenCalled() + expect(createFolder).not.toHaveBeenCalled() + expect(f.spawn).not.toHaveBeenCalled() + expect(f.store.updateSettings).not.toHaveBeenCalled() +} + +describe('worktree account refusals precede native/forwarded create', () => { + it.each(['unknown', 'same@example.com'])( + 'refuses %s before workspace lookup or preparation', + async (startupAccount) => { + const f = fixture() + await expect( + f.runtime.createManagedWorktree({ ...f.request, startupAccount }) + ).rejects.toThrow() + expect(f.runtime.showRepo).not.toHaveBeenCalled() + expect(f.showSettings).not.toHaveBeenCalled() + expectNoCreate(f) + } + ) + + it.each(['ssh:target', 'runtime:server'] as const)( + 'refuses %s for git and folder repositories before any create', + async (executionHostId) => { + for (const kind of ['git', 'folder'] as const) { + const f = fixture({ executionHostId, kind }) + await expect(f.runtime.createManagedWorktree(f.request)).rejects.toThrow('native host only') + expectNoCreate(f) + } + } + ) + + it('refuses custom launch commands before a worktree is created', async () => { + const f = fixture() + f.settings.agentCmdOverrides = { codex: 'custom-wrapper' } + await expect(f.runtime.createManagedWorktree(f.request)).rejects.toThrow( + 'custom Codex launch command' + ) + expectNoCreate(f) + }) + + it('refuses prebuilt startup commands and an unavailable native PTY before lookup', async () => { + const f = fixture() + await expect( + f.runtime.createManagedWorktree({ ...f.request, startup: { command: 'codex' } }) + ).rejects.toThrow('fresh native startup agent') + f.runtime.setPtyController(null) + await expect(f.runtime.createManagedWorktree(f.request)).rejects.toThrow( + 'fresh native startup agent' + ) + expect(f.runtime.showRepo).not.toHaveBeenCalled() + expectNoCreate(f) + }) + + it('refuses pins before a warm legacy cache while preserving unpinned replay', async () => { + const f = fixture() + const request = { + repo: 'id:repo-1', + name: 'pinned', + startupAgent: 'codex', + clientMutationId: 'same-create' + } + const previous = { worktree: { id: 'previous-unpinned' } } + await f.runtime.dedupeWorktreeCreate( + request.repo, + request.clientMutationId, + async () => previous + ) + const dedupe = vi.spyOn(f.runtime, 'dedupeWorktreeCreate') + const dispatcher = new RpcDispatcher({ runtime: f.runtime, methods: WORKTREE_METHODS }) + const replay = await dispatcher.dispatch({ + id: 'legacy-replay', + authToken: 'test-token', + method: 'worktree.create', + params: request + }) + expect(replay).toMatchObject({ ok: true, result: previous }) + dedupe.mockClear() + const pin = await dispatcher.dispatch({ + id: 'pin-b', + authToken: 'test-token', + method: 'worktree.create', + params: { ...request, startupAccount: 'account-b' } + }) + expect(pin).toMatchObject({ + ok: false, + error: { code: 'invalid_argument', message: expect.stringContaining('operationId') } + }) + expect(dedupe).not.toHaveBeenCalled() + expect(f.runtime.showRepo).not.toHaveBeenCalled() + expectNoCreate(f) + }) +}) diff --git a/src/main/runtime/runtime-folder-worktree-create.test.ts b/src/main/runtime/runtime-folder-worktree-create.test.ts index c72c226e4526..d1a5080ffc04 100644 --- a/src/main/runtime/runtime-folder-worktree-create.test.ts +++ b/src/main/runtime/runtime-folder-worktree-create.test.ts @@ -58,6 +58,46 @@ async function startupTerminalOptions(startupPaneKey?: string): Promise { + it.each([ + { selector: 'account-b', accountId: 'account-b' }, + { selector: 'system', accountId: null } + ])( + 'retains folder registration after pinned startup fails for $selector', + async ({ selector, accountId }) => { + const { createTerminal, deps } = createDeps() + deps.provisionInBackground = () => true + const registration = vi.spyOn(deps.store, 'setWorktreeMeta') + const failure = new Error('pinned auth unavailable') + createTerminal.mockRejectedValue(failure) + const creation = createRuntimeFolderWorktree({ + request: { + repoSelector: `id:${repo.id}`, + name: 'pinned', + startupAccount: selector, + startupCodexAccountId: accountId, + activate: true + }, + repo, + createdWithAgent: 'codex', + startup: { command: 'codex' }, + deps + }) + await expect(creation).rejects.toMatchObject({ + worktreeId: expect.any(String), + cause: failure + }) + const worktreeId = registration.mock.calls[0][0] + await expect(creation).rejects.toMatchObject({ worktreeId }) + expect(registration).toHaveBeenCalledTimes(1) + expect(deps.emitCreated).toHaveBeenCalledWith(expect.objectContaining({ worktreeId })) + expect(createTerminal).toHaveBeenCalledTimes(1) + expect(createTerminal).toHaveBeenCalledWith( + `id:${worktreeId}`, + expect.objectContaining({ codexAccountId: accountId }) + ) + expect(deps.activate).not.toHaveBeenCalled() + } + ) it('seeds a headless activated folder workspace without a paired viewer', async () => { const { deps, createTerminal } = createDeps() deps.provisionInBackground = () => true diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index 34c8d91432d1..91633f715778 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -1,4 +1,5 @@ import { paneIdentity } from './runtime-terminal-pane-identity' +import { WorktreeStartupError } from '../../shared/worktree/worktree-startup-error' import { randomUUID } from 'node:crypto' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-lookup' import { resolveWorktreeCreateDisplayNameRequest } from '../ipc/worktree-logic' @@ -133,6 +134,9 @@ export async function createRuntimeFolderWorktree(args: { try { const terminal = await deps.createTerminal(`id:${worktree.id}`, { command: args.startup.command, + ...(request.startupCodexAccountId !== undefined + ? { codexAccountId: request.startupCodexAccountId } + : {}), ...(request.startupCwd ? { cwd: request.startupCwd } : {}), ...paneIdentity(request.startupPaneKey), env: args.startup.env, @@ -159,6 +163,9 @@ export async function createRuntimeFolderWorktree(args: { surface: 'background' } } catch (error) { + if (request.startupAccount !== undefined) { + throw new WorktreeStartupError(worktree.id, error) + } const message = error instanceof Error ? error.message : String(error) warning = `Failed to create the startup terminal for ${worktree.path}: ${message}` console.warn(`[worktree-create] ${warning}`) diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts index 22fc805ab0c0..b31594678c93 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts @@ -28,6 +28,7 @@ export type LegacyWorkerRecoveryResolution = { } export type TerminalWorkspaceLaunchScope = { + executionHostId?: ExecutionHostId id: string path: string connectionId: string | null 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 42962a8c3d53..d3affef54e5d 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -71,6 +71,36 @@ async function startupTerminalOptions(startupPaneKey?: string) { } describe('startRuntimeLocalWorktreeTerminals reserved startup pane', () => { + it('propagates the pin and refuses an unpinned fallback when startup fails', async () => { + const { createTerminal, ports } = createPorts() + ports.provisionInBackground = () => true + const failure = new Error('pinned auth unavailable') + createTerminal.mockRejectedValue(failure) + await expect( + startRuntimeLocalWorktreeTerminals({ + request: { + repoSelector: `id:${repo.id}`, + name: 'pinned', + startupAccount: 'account-b', + startupCodexAccountId: 'account-b', + activate: true + }, + repo, + worktree, + createdWithAgent: 'codex', + startup: { command: 'codex' }, + ports + }) + ).rejects.toMatchObject({ worktreeId: worktree.id, cause: failure }) + expect(createTerminal).toHaveBeenCalledWith( + `id:${worktree.id}`, + expect.objectContaining({ codexAccountId: 'account-b', launchAgent: 'codex' }) + ) + expect(ports.activate).not.toHaveBeenCalled() + expect(ports.provision).not.toHaveBeenCalled() + expect(createTerminal).toHaveBeenCalledTimes(1) + }) + // The local, folder and remote creates each forward this separately, so nothing above them // catches the one that stops. it('creates the startup terminal under the pane the caller reserved', async () => { diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index 9034228b37df..dcccac53a85d 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -1,4 +1,5 @@ import { paneIdentity } from './runtime-terminal-pane-identity' +import { WorktreeStartupError } from '../../shared/worktree/worktree-startup-error' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' @@ -96,6 +97,9 @@ export async function startRuntimeLocalWorktreeTerminals(args: { try { const terminal = await ports.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, + ...(request.startupCodexAccountId !== undefined + ? { codexAccountId: request.startupCodexAccountId } + : {}), ...(request.startupCwd ? { cwd: request.startupCwd } : {}), ...paneIdentity(request.startupPaneKey), ...(setup && startup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), @@ -119,6 +123,9 @@ export async function startRuntimeLocalWorktreeTerminals(args: { startupTerminalPaneKey = terminal.paneKey ?? null startupTerminalPtyId = terminal.ptyId ?? null } catch (error) { + if (request.startupAccount !== undefined) { + throw new WorktreeStartupError(worktree.id, error) + } warning = appendFailure(warning, worktree.path, 'startup', error) } } diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index e92b8beba928..048ccb5b039c 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -49,6 +49,9 @@ export type RuntimeManagedWorktreeCreateArgs = { observeSetupCompletion?: boolean createdWithAgent?: TuiAgent startupAgent?: TuiAgent + startupAccount?: string + /** Host-resolved launch input; never accepted over RPC. */ + startupCodexAccountId?: string | null startupLaunchPreferences?: AgentLaunchPreferences startupPrompt?: string /** Main-internal: set by a caller that delivers an uncarried `startupPrompt` itself, so the text diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 306fe4f053c9..832ac41c890f 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -52,6 +52,7 @@ export type RuntimePtyController = { cwd?: string command?: string launchAgent?: TuiAgent + codexAccountId?: string | null commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index fb993e625cd8..03bc7f71e8d8 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -45,6 +45,7 @@ export type RuntimeServiceCommandSurface = { setCommitMessageAgentEnvironmentResolvers: RuntimeAccountController['setCommitMessageAgentEnvironment'] getCommitMessageAgentEnvironmentResolvers: RuntimeAccountController['getCommitMessageAgentEnvironment'] getAccountsSnapshot: RuntimeAccountController['getSnapshot'] + resolveCodexLaunchAccount: RuntimeAccountController['resolveCodexLaunchAccount'] refreshAccountsForMobile: RuntimeAccountController['refreshForMobile'] refreshAccountsForMobileSubscriber: RuntimeAccountController['refreshForMobileSubscriber'] selectClaudeAccount: RuntimeAccountController['selectClaude'] @@ -142,6 +143,7 @@ export function installRuntimeServiceCommandSurface( getCommitMessageAgentEnvironmentResolvers: accounts.getCommitMessageAgentEnvironment.bind(accounts), getAccountsSnapshot: accounts.getSnapshot.bind(accounts), + resolveCodexLaunchAccount: accounts.resolveCodexLaunchAccount.bind(accounts), refreshAccountsForMobile: accounts.refreshForMobile.bind(accounts), refreshAccountsForMobileSubscriber: accounts.refreshForMobileSubscriber.bind(accounts), selectClaudeAccount: accounts.selectClaude.bind(accounts), diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 43b487db3c94..ea04abc6566e 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -44,6 +44,8 @@ export type TerminalCreateOptions = { launchToken?: string launchAgent?: TuiAgent startupAgent?: TuiAgent + /** Main-owned account id; null pins the canonical system home. */ + codexAccountId?: string | null /** * Initial text offered to `startupAgent`'s launch command, for an agent whose CLI takes a prompt * argument. Not a general prompt channel: an agent that takes its text only after start has no diff --git a/src/main/runtime/runtime-terminal-create-routing.ts b/src/main/runtime/runtime-terminal-create-routing.ts new file mode 100644 index 000000000000..fda74fe433d3 --- /dev/null +++ b/src/main/runtime/runtime-terminal-create-routing.ts @@ -0,0 +1,20 @@ +import { resolveTerminalPresentation } from './orca-runtime-core' +import type { TerminalCreateOptions } from './runtime-terminal-contracts' + +export function resolveTerminalCreateRouting( + worktreeSelector: string | undefined, + opts: TerminalCreateOptions, + availableWindow: TWindow | null +) { + const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true + return { + presentation: resolveTerminalPresentation(opts), + rendererWindow: opts.rendererBacked === true ? availableWindow : null, + shouldCreateInBackground: + worktreeSelector !== undefined && + (Boolean(opts.agentSessionClaim) || + opts.codexAccountId !== undefined || + (!requiresRendererFocus && opts.rendererBacked !== true) || + availableWindow === null) + } +} diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index b70d61396840..9d1c6868001a 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -17,7 +17,12 @@ export async function prepareCodexRuntimeHomeForLaunch( throw new Error('Codex runtime home service is not initialized') } const ensureRealHomeHooksIfSelected = async (): Promise => { - if (target?.runtime === 'wsl' || !runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv)) { + if ( + target?.runtime === 'wsl' || + (launchContext?.pinnedAccountId !== undefined + ? launchContext.pinnedAccountId !== null + : !runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv)) + ) { return false } // Why (flag ON, system default): the hook entry must exist, appended last, in @@ -32,13 +37,22 @@ export async function prepareCodexRuntimeHomeForLaunch( }) return true } + if (launchContext?.pinnedAccountId !== undefined && target?.runtime === 'wsl') { + throw new Error('--account supports native host execution only.') + } let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — // the fallbacks below all key off `null`, which means "system default", so // swallowing the refusal would launch the wrong account (#STA-4422). - let runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath - }) + let runtimeHomePath = + launchContext?.pinnedAccountId !== undefined + ? runtimeHome.prepareForPinnedCodexLaunch(launchContext.pinnedAccountId) + : await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, { + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + }) + if (launchContext?.pinnedAccountId === null) { + return runtimeHomePath + } if (runtimeHomePath === null && !realHomeHooksPrepared) { // Why: launch prep can reject an untrusted managed home and clear its // selection. Establish hook capability for that newly selected lane, then diff --git a/src/shared/agent-launch-account.ts b/src/shared/agent-launch-account.ts new file mode 100644 index 000000000000..2cd852b42d9a --- /dev/null +++ b/src/shared/agent-launch-account.ts @@ -0,0 +1,68 @@ +export type AgentLaunchAccountReceipt = { + provider: 'codex' + requested: string + effective: { id: string | null; email: string | null } +} + +export const CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY = 'codex.launch-account.v1' as const + +export function codexAccountSpawnOptions(accountId: string | null | undefined): { + codexAccountId?: string | null +} { + return accountId === undefined ? {} : { codexAccountId: accountId } +} + +export function assertNewCodexAccountPane( + accountId: string | null | undefined, + existingPane: unknown +): void { + if (accountId !== undefined && existingPane) { + throw new Error('--account cannot reuse a live or pending terminal pane.') + } +} + +export function assertCodexAccountLaunchRequest(args: { + account?: string + agent?: string + terminal?: string + on?: string +}): void { + if (args.account === undefined) { + return + } + if (args.terminal) { + throw new Error('--account cannot combine with existing-terminal reuse.') + } + if (args.on) { + throw new Error( + '--account does not support --on federation; connect directly to the execution runtime.' + ) + } + if (args.agent !== 'codex') { + throw new Error('--account requires --agent codex; other providers are unsupported.') + } +} + +export function isAgentLaunchAccountReceipt(value: unknown): value is AgentLaunchAccountReceipt { + if (!value || typeof value !== 'object') { + return false + } + if ( + !('provider' in value) || + value.provider !== 'codex' || + !('requested' in value) || + typeof value.requested !== 'string' || + !('effective' in value) || + !value.effective || + typeof value.effective !== 'object' + ) { + return false + } + const effective = value.effective + return ( + 'id' in effective && + (effective.id === null || typeof effective.id === 'string') && + 'email' in effective && + (effective.email === null || typeof effective.email === 'string') + ) +} diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index dad67996d4a9..90f887d47e2e 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -13,6 +13,7 @@ */ import type { TuiAgent } from './tui-agent' +import { isAgentLaunchAccountReceipt, type AgentLaunchAccountReceipt } from './agent-launch-account' /** How a launch's initial text reaches the agent. */ export type AgentLaunchPromptDelivery = @@ -55,6 +56,7 @@ export type AgentLaunchTarget = export type AgentLaunchReusedTerminal = { handle: string } export type AgentLaunchIntent = { + account?: AgentLaunchAccountReceipt agent: TuiAgent target: AgentLaunchTarget prompt?: AgentLaunchPrompt @@ -160,6 +162,7 @@ export type AgentLaunchPromptReceipt = { } & AgentLaunchPromptDisposal export type AgentLaunchResult = { + account?: AgentLaunchAccountReceipt outcome: AgentLaunchOutcome /** The workspace the agent runs in, resolved or created. */ worktreeId: string @@ -197,6 +200,7 @@ export type AgentLaunchModeReason = | 'wsl_execution_runtime' | 'codex_on_windows' | 'structured_unsupported_on_host' + | 'pinned_codex_account' /** Restates `WorkerStartModeReceipt` in surface-neutral terms so orchestration's receipt and a * mobile or renderer launch report the same vocabulary. */ @@ -226,6 +230,7 @@ export function isAgentLaunchResult(value: unknown): value is AgentLaunchResult return ( isAgentLaunchOutcome(result.outcome) && typeof result.worktreeId === 'string' && + (result.account === undefined || isAgentLaunchAccountReceipt(result.account)) && isAgentLaunchModeReceipt(result.receipt) && (result.warning === undefined || typeof result.warning === 'string') && (result.prompt === undefined || isAgentLaunchPromptReceipt(result.prompt)) @@ -302,6 +307,7 @@ export function agentLaunchTargetIsCreate( * sets one of these would route itself around the host's decision. */ export const AGENT_LAUNCH_RESERVED_CREATE_FIELDS = [ 'startupAgent', + 'startupAccount', 'startupCommand', 'startupPrompt', 'startupDraft', diff --git a/src/shared/agent-launch-operation.ts b/src/shared/agent-launch-operation.ts index d1a19d0d889e..0226b65b9b23 100644 --- a/src/shared/agent-launch-operation.ts +++ b/src/shared/agent-launch-operation.ts @@ -27,6 +27,7 @@ import { parseAgentSessionOperationTimestamp } from './agent-session-host-author * conflict merely because the setting moved between the two attempts. */ export type AgentLaunchFingerprintInput = { + account?: string agent: string target: | { kind: 'existing'; worktree: string } @@ -63,6 +64,7 @@ export function computeAgentLaunchFingerprint(input: AgentLaunchFingerprintInput return canonicalAgentSessionDigest({ method: 'agent.launch', agent: input.agent, + account: input.account, target: input.target, prompt: input.prompt, sessionOptions: input.sessionOptions, diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 2b34d58bb051..e77d104a952b 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -14,6 +14,7 @@ export { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from './orchestration-runtime-capabilities' import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resume-runtime-capabilities' +import { CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY } from './agent-launch-account' import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability' export { AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, @@ -345,6 +346,7 @@ export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = 'agentSession.create.tab-id.v1' as const export const RUNTIME_CAPABILITIES = [ + CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY, QODER_OWNED_TERMINAL_CREATE_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index 3926c610708a..4e7937d9c4c7 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -12,6 +12,7 @@ */ import { z } from 'zod' +import { LaunchAccountParam } from './launch-account-param' import { parseAgentSessionOperationTimestamp } from '../agent-session-host-authority' import { parsePaneKey } from '../stable-pane-id' import { isValidHostTerminalTabId } from '../terminal-tab-id' @@ -37,6 +38,7 @@ const LaunchAgent = z * every receiver parses `AgentLaunch` or `AgentLaunchReplay`. */ export const AgentLaunchFields = z.object({ agent: LaunchAgent, + account: LaunchAccountParam.optional(), /** * Names this launch so a retry replays instead of starting a second agent. * @@ -126,11 +128,49 @@ function refuseSessionIdForAnotherAgent( } } +function refusePinnedLegacyReplay( + launch: z.infer, + ctx: z.RefinementCtx +): void { + if ( + launch.account !== undefined && + launch.operationId === undefined && + launch.target.kind === 'create-worktree' && + launch.target.create.clientMutationId !== undefined + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['operationId'], + message: + 'Explicit accounts require an operationId for replay; legacy clientMutationId is unsupported.' + }) + } +} + +function refuseNestedLaunchAccount( + launch: z.infer, + ctx: z.RefinementCtx +): void { + if ( + launch.target.kind === 'create-worktree' && + launch.target.create.startupAccount !== undefined + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['target', 'create', 'startupAccount'], + message: + 'agent.launch requires account at the launch level, not target.create.startupAccount.' + }) + } +} + export const AgentLaunch = AgentLaunchFields.superRefine(refuseSessionIdForAnotherAgent) + .superRefine(refusePinnedLegacyReplay) + .superRefine(refuseNestedLaunchAccount) export type AgentLaunchParams = z.infer // A distinct method prevents an older receiver from silently dropping the replay requirement. -export const AgentLaunchReplay = AgentLaunchFields.required({ operationId: true }).superRefine( - refuseSessionIdForAnotherAgent -) +export const AgentLaunchReplay = AgentLaunchFields.required({ operationId: true }) + .superRefine(refuseSessionIdForAnotherAgent) + .superRefine(refuseNestedLaunchAccount) diff --git a/src/shared/rpc-contract/launch-account-param.ts b/src/shared/rpc-contract/launch-account-param.ts new file mode 100644 index 000000000000..57a407367ba6 --- /dev/null +++ b/src/shared/rpc-contract/launch-account-param.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +export const LaunchAccountParam = z + .string() + .trim() + .min(1) + .max(512) + .refine( + (value) => + !Array.from(value).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127), + 'Invalid account selector' + ) diff --git a/src/shared/rpc-contract/orchestration-worker-start-params.ts b/src/shared/rpc-contract/orchestration-worker-start-params.ts index dc6432aa47a9..ca8153b1b9eb 100644 --- a/src/shared/rpc-contract/orchestration-worker-start-params.ts +++ b/src/shared/rpc-contract/orchestration-worker-start-params.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { LaunchAccountParam } from './launch-account-param' import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' export const OptionalWorkerLaunchPreference = z @@ -27,6 +28,7 @@ export const WorkerStartParams = z setup: z.enum(['run', 'skip', 'inherit']).optional(), terminal: OptionalString, agent: OptionalString, + account: LaunchAccountParam.optional(), model: OptionalWorkerLaunchPreference, effort: OptionalWorkerLaunchPreference, retryOf: OptionalString, diff --git a/src/shared/rpc-contract/worktree-create-params.ts b/src/shared/rpc-contract/worktree-create-params.ts index 04de055a6393..f8d93afbdf0d 100644 --- a/src/shared/rpc-contract/worktree-create-params.ts +++ b/src/shared/rpc-contract/worktree-create-params.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { LaunchAccountParam } from './launch-account-param' import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema' import { TaskSourceContextSchema } from '../task-source-context-schema' import { workspaceSourceSchema } from '../telemetry-events' @@ -110,6 +111,7 @@ export const WorktreeCreate = z // Why: CLI clients should not hardcode agent launch quoting because SSH // workspaces execute in a different shell than the client process. startupAgent: OptionalTuiAgent, + startupAccount: LaunchAccountParam.optional(), startupPrompt: OptionalString, // Which surface asked for the agent the host launches from `startupAgent` or `startupDraft`. launchSource: LaunchSourceParam.optional(), @@ -127,6 +129,14 @@ export const WorktreeCreate = z cliProvenanceRequest: CliWorkspaceProvenanceRequest.optional() }) .superRefine((params, ctx) => { + if (params.startupAccount !== undefined && params.clientMutationId !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clientMutationId'], + message: + 'Explicit accounts require agent.launch with an operationId for replay; worktree.create clientMutationId is unsupported.' + }) + } assertLinkedWorkItemSourceContextMatch(params, ctx) if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { ctx.addIssue({ diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index 56cd6c67c483..473754fb6729 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -126,6 +126,7 @@ export type RuntimeWorktreeCreateResult = { warnings: WorktreeLineageWarning[] warning?: string startupTerminal?: CreateWorktreeResult['startupTerminal'] + account?: CreateWorktreeResult['account'] agentTerminalHandle?: string } diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index ca5dbe469720..72a439cd65b5 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -1,4 +1,5 @@ import type { WorktreeCatalogVersion } from './catalog-version' +import type { AgentLaunchAccountReceipt } from '../agent-launch-account' import type { ExecutionHostId } from '../execution-host' import type { ArchiveHookOverride } from './archive-hook-removal-gate' import type { WorkspaceSource } from '../workspace-source' @@ -161,6 +162,7 @@ export type AdoptProvisionedRootArgs = CreateWorktreeArgs & { } export type CreateWorktreeResult = { + account?: AgentLaunchAccountReceipt /** The catalog this create produced; additive, older hosts omit it. */ catalogVersion?: WorktreeCatalogVersion worktree: Worktree & { diff --git a/src/shared/worktree/worktree-startup-error.ts b/src/shared/worktree/worktree-startup-error.ts new file mode 100644 index 000000000000..2b64c39852db --- /dev/null +++ b/src/shared/worktree/worktree-startup-error.ts @@ -0,0 +1,9 @@ +export class WorktreeStartupError extends Error { + readonly worktreeId: string + + constructor(worktreeId: string, cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = 'WorktreeStartupError' + this.worktreeId = worktreeId + } +}