Skip to content
7 changes: 7 additions & 0 deletions src/main/agent-launch/agent-launch-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** An agent launch with a launch config proves itself by this token, in its env and on its PTY. */
export function agentLaunchTokenFor(
launch: { launchConfig?: unknown; launchToken?: string },
mint: () => string
): string | undefined {
return launch.launchConfig ? (launch.launchToken ?? mint()) : undefined
}
13 changes: 12 additions & 1 deletion src/main/ipc/pty-agent-launch-pane-attach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,18 @@ describe('a restored pane after a restart', () => {
worktreeId,
tabId,
leafId,
env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId }
env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId },
// A pane that still carries its launch attaches to the agent it started, never plans again.
...(daemon === 'live'
? {
agentLaunch: {
launch: {
agent: 'claude',
target: { kind: 'existing', worktree: `id:${worktreeId}` }
}
}
}
: {})
})
)

Expand Down
242 changes: 242 additions & 0 deletions src/main/ipc/pty-agent-pane-launch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentPaneLaunchRequest } from '../../shared/agent-pane-launch-request'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { registerPtyHandlers } from './pty/register-handlers'
import {
markAgentLaunchesClosedByUser,
resetAgentLaunchPanesForTests
} from '../agent-launch/agent-launch-pane-attachment'
import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../shared/agent-launch-pane-verdict'

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())
)

// An AI button's launch is planned inside its pane's spawn, with the host's planner, and writes no
// launch record.
describe('a pane that starts its own agent launch', () => {
const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite()
const tabId = 'tab-pane-launch'
const leafId = '66666666-6666-4666-8666-666666666666'
const worktreeId = 'repo-1::/tmp/pane-launch'
const PANE_ADDRESS = { worktreeId, tabId, leafId }
const AI_BUTTON: AgentPaneLaunchRequest = {
launch: { agent: 'claude', target: { kind: 'existing', worktree: `id:${worktreeId}` } }
}

afterEach(() => {
resetAgentLaunchPanesForTests()
})

function register(
providerSpawn: ReturnType<typeof vi.fn>,
plan: () => Promise<Record<string, unknown>> = async () => ({ command: 'claude' })
) {
installDaemonTestProvider({ spawn: providerSpawn })
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_pane'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
hasLiveTerminalForPaneKey: vi.fn(() => false),
reportAgentLaunchPaneVerdict: vi.fn(),
showTerminalWorkspaceLaunchScope: vi.fn(async () => ({
id: worktreeId,
path: '/tmp/pane-launch',
connectionId: null,
repo: null,
folderWorkspace: null
})),
planAgentPaneLaunch: vi.fn(plan),
getTerminalHandleForPaneKey: vi.fn(() => null),
closeTerminal: vi.fn(async () => ({}))
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window double, carrying the members a renderer spawn reaches.
const window = mainWindow as never
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a runtime carrying only the members a pane's own launch reaches.
const spawnRuntime = runtime as never
registerPtyHandlers(window, spawnRuntime)
return runtime
}

function spawnPane(
agentLaunch: AgentPaneLaunchRequest = AI_BUTTON,
pane: Record<string, unknown> = {}
): Promise<unknown> {
return Promise.resolve(
handlers.get('pty:spawn')!(null, {
...pane,
cols: 132,
rows: 40,
cwd: '/tmp/pane-launch',
worktreeId,
tabId,
leafId,
command: '',
agentLaunch
})
)
}

it("starts the host's planned command once, at the pane's size, and says it started", async () => {
const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' }))
const runtime = register(providerSpawn)

await expect(spawnPane()).resolves.toMatchObject({ id: 'pty-agent' })

expect(runtime.planAgentPaneLaunch).toHaveBeenCalledOnce()
expect(providerSpawn).toHaveBeenCalledOnce()
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({ command: 'claude', cols: 132, rows: 40 })
)
expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, {
kind: 'proceed'
})
})

// The command was quoted for the plan's runtime, so the PTY must run that runtime's shell. The tab's
// shell is inferred, not asked for, and would make the planner refuse a WSL-share repo.
it("runs the plan's runtime shell, not the tab's, as a host launch does", async () => {
const providerSpawn = vi.fn(async (_options: { shellOverride?: string }) => ({
id: 'pty-agent'
}))
const runtime = register(providerSpawn, async () => ({
command: 'claude',
launchRuntime: {
platform: process.platform,
pty: { shellOverride: '/bin/planned-shell', terminalWindowsWslDistro: null }
}
}))

await spawnPane(AI_BUTTON, { shellOverride: 'cmd.exe' })

expect(runtime.planAgentPaneLaunch).toHaveBeenCalledWith(
worktreeId,
expect.not.objectContaining({ shellOverride: expect.anything() })
)
expect(providerSpawn.mock.calls[0]?.[0].shellOverride).toBe('/bin/planned-shell')
})

// Orchestration callers, hook evidence and title presence read the launch's token off its PTY.
it("gives the agent its launch token and agent identity, as the host's own launch did", async () => {
const providerSpawn = vi.fn(async (_options: { env?: Record<string, string> }) => ({
id: 'pty-agent',
incarnationId: 'inc-1'
}))
const runtime = register(providerSpawn, async () => ({
command: 'claude',
launchAgent: 'claude',
launchConfig: { agentArgs: '', agentEnv: {} }
}))

await spawnPane()

const env = providerSpawn.mock.calls[0]?.[0]?.env ?? {}
expect(env.ORCA_AGENT_LAUNCH_TOKEN).toMatch(/.+/)
expect(JSON.stringify(runtime.registerPty.mock.calls)).toContain(
JSON.stringify({ launchToken: env.ORCA_AGENT_LAUNCH_TOKEN, launchAgent: 'claude' })
)
})

it('starts one agent when the pane spawns twice at once', async () => {
const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' }))
register(providerSpawn)

await Promise.all([spawnPane(), spawnPane()])

expect(providerSpawn).toHaveBeenCalledOnce()
})

it('starts no shell when the plan fails, and the pane says why', async () => {
const providerSpawn = vi.fn(async () => ({ id: 'pty-shell' }))
const runtime = register(providerSpawn, async () => {
throw new Error('agent_launch_agent_disabled')
})

await expect(spawnPane()).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE)

expect(providerSpawn).not.toHaveBeenCalled()
expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, {
kind: 'not-started',
code: 'agent_launch_agent_disabled'
})
})

it('cannot confirm a start whose provider answer was lost', async () => {
const providerSpawn = vi.fn(async () => {
throw new Error('daemon reply lost')
})
const runtime = register(providerSpawn)

await expect(spawnPane()).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE)

expect(providerSpawn).toHaveBeenCalledOnce()
expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, {
kind: 'unconfirmed'
})
})

it('starts nothing when the user closed the pane while it was planned', async () => {
let releasePlan!: () => void
const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' }))
const runtime = register(
providerSpawn,
() =>
new Promise((done) => {
releasePlan = () => done({ command: 'claude' })
})
)

const spawned = spawnPane()
await vi.waitFor(() => expect(runtime.planAgentPaneLaunch).toHaveBeenCalled())
markAgentLaunchesClosedByUser(worktreeId, { kind: 'pane', tabId, leafId })
releasePlan()

await expect(spawned).rejects.toThrow('agent_launch_tab_closed')
expect(providerSpawn).not.toHaveBeenCalled()
expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled()
})
})
84 changes: 84 additions & 0 deletions src/main/ipc/pty/ipc/spawn-agent-launch-end.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Finishes a pane's own agent launch on every way its spawn ends.

import {
AGENT_LAUNCH_PANE_REFUSED_CODE,
type AgentLaunchPaneAddress,
type AgentLaunchPaneVerdict
} from '../../../../shared/agent-launch-pane-verdict'
import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed'
import type { RunningAgentLaunchPane } from '../../../agent-launch/agent-launch-pane-attachment'
import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime'
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import { agentLaunchFailureCode } from '../../../runtime/rpc/methods/agent-launch-failure-code'
import type { PtyIpcSpawnState } from './spawn-state'

export type PaneAgentLaunch = {
runtime: OrcaRuntimeService
address: AgentLaunchPaneAddress
paneKey: string
running: RunningAgentLaunchPane
/** The plan's runtime fact: the PTY's shell comes only from it, as for a host launch. */
launchRuntime?: AgentLaunchRuntime
/** The provider was asked for the process: a failure after this proves nothing ran. */
dispatched: boolean
/** Right before the provider spawn: refuses a launch whose pane the user closed meanwhile. */
refuseIfClosed: () => void
}

type PaneAgentLaunchEnd =
| { started: true }
/** The agent's process started, then exited before it registered. */
| { exitedDuringStart: true }
| { error: unknown }

function finish(launch: PaneAgentLaunch, verdict: AgentLaunchPaneVerdict): void {
launch.running.finish(verdict)
launch.runtime.reportAgentLaunchPaneVerdict(launch.address, verdict)
}

/** Ends the pane's launch, once; returns the error its spawn throws, if it failed. */
export function endPaneAgentLaunch(ctx: PtyIpcSpawnState, end: PaneAgentLaunchEnd): Error | null {
const launch = ctx.paneAgentLaunch
ctx.paneAgentLaunch = null
if (!launch) {
return null
}
if ('exitedDuringStart' in end) {
finish(launch, { kind: 'not-started', code: 'agent_session_exited_during_start' })
return null
}
const closed = launch.running.closedByUser()
if ('started' in end) {
if (closed) {
stopClosed(launch)
} else {
finish(launch, { kind: 'proceed' })
}
return null
}
if (closed || end.error instanceof AgentLaunchTabClosedError) {
stopClosed(launch)
return toError(end.error)
}
finish(
launch,
// The provider may have started it before its answer was lost.
launch.dispatched
? { kind: 'unconfirmed' }
: { kind: 'not-started', code: agentLaunchFailureCode(end.error) }
)
return new Error(AGENT_LAUNCH_PANE_REFUSED_CODE)
}

/** The user closed it while it started: its agent, if one spawned, stops, as a closed tab's does. */
function stopClosed(launch: PaneAgentLaunch): void {
const handle = launch.runtime.getTerminalHandleForPaneKey(launch.paneKey)
if (handle) {
void launch.runtime.closeTerminal(handle).catch(() => {})
}
launch.running.finish({ kind: 'withdrawn' })
}

function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
Loading
Loading