diff --git a/src/main/runtime/agent-launch-typed-line-shell.test.ts b/src/main/runtime/agent-launch-typed-line-shell.test.ts index 1ef35b85292..9d17d07a49b 100644 --- a/src/main/runtime/agent-launch-typed-line-shell.test.ts +++ b/src/main/runtime/agent-launch-typed-line-shell.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' +import { + launchHostProvesAgentInFront, + nameLocalTypedLineShell +} from './agent-launch-typed-line-shell' describe('naming the shell a local launch line is typed into', () => { it('takes the request’s shell first, then the setting, then SHELL, as the spawn does', () => { @@ -28,3 +31,17 @@ describe('naming the shell a local launch line is typed into', () => { ).toBeUndefined() }) }) + +describe('whether a launch host can prove its agent is in front before a paste', () => { + it.each([ + ['a local macOS host', false, 'darwin', 'darwin', true], + ['a local Linux host', false, 'linux', 'linux', true], + ['a local Windows host', false, 'win32', 'win32', false], + // The pane runs in the distro, but the reads run on the Windows host. + ['a local WSL pane', false, 'linux', 'win32', false], + ['an SSH Linux host from Windows', true, 'linux', 'win32', true], + ['an SSH Windows host', true, 'win32', 'darwin', false] + ] as const)('%s: %s', (_label, isRemote, launchPlatform, hostPlatform, proves) => { + expect(launchHostProvesAgentInFront({ isRemote, launchPlatform, hostPlatform })).toBe(proves) + }) +}) diff --git a/src/main/runtime/agent-launch-typed-line-shell.ts b/src/main/runtime/agent-launch-typed-line-shell.ts index 6a7f83c048b..f733ade3385 100644 --- a/src/main/runtime/agent-launch-typed-line-shell.ts +++ b/src/main/runtime/agent-launch-typed-line-shell.ts @@ -25,3 +25,18 @@ export function nameLocalTypedLineShell(args: { '/bin/zsh' return basename(shellPath).toLowerCase() } + +/** + * Whether the execution host can prove a launched agent holds its terminal before a paste + * (`launched-agent-foreground`). A Windows host cannot, and a local WSL pane runs on one; an SSH + * host is judged by its own platform. + */ +export function launchHostProvesAgentInFront(args: { + isRemote: boolean + launchPlatform: NodeJS.Platform + hostPlatform?: NodeJS.Platform +}): boolean { + return args.isRemote + ? args.launchPlatform !== 'win32' + : (args.hostPlatform ?? process.platform) !== 'win32' +} diff --git a/src/main/runtime/launched-agent-composer-readiness-claude.test.ts b/src/main/runtime/launched-agent-composer-readiness-claude.test.ts index 889f08a2ef6..14647906266 100644 --- a/src/main/runtime/launched-agent-composer-readiness-claude.test.ts +++ b/src/main/runtime/launched-agent-composer-readiness-claude.test.ts @@ -131,7 +131,7 @@ describe('a fresh orchestration worker start for Claude', () => { }) }) -describe('whether a shell is proven in front of a launched agent’s terminal', () => { +describe('what holds a launched agent’s terminal', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! const setPlatform = (value: NodeJS.Platform): void => { Object.defineProperty(process, 'platform', { configurable: true, value }) @@ -140,315 +140,206 @@ describe('whether a shell is proven in front of a launched agent’s terminal', Object.defineProperty(process, 'platform', originalPlatform) }) - describe('macOS and Linux: a fresh foreground read', () => { + describe('macOS and Linux: one fresh foreground read decides', () => { beforeEach(() => setPlatform('darwin')) it.each([ // For its first 5 s the daemon's cached read names the launch agent while zsh is in front. - ['claude', 'zsh', true], - ['claude', 'claude', false], - ['zsh', 'zsh', true], - ['-zsh', '-zsh', true], - ['bash', 'bash', true], - [null, 'zsh', true], - [null, null, false] - ])('cached %s, scan %s: %s', async (foregroundProcess, scanned, shellInFront) => { + ['claude', 'zsh', 'shell'], + // A stub agent that crashed: the cached read can still name it after zsh took the terminal. + ['python3', 'zsh', 'shell'], + ['claude', 'claude', 'agent'], + // A native Claude names itself by its version. + ['zsh', '2.1.285', 'agent'], + ['zsh', 'node', 'agent'], + ['-zsh', '-zsh', 'shell'], + ['bash', 'bash', 'shell'], + [null, 'zsh', 'shell'], + ['claude', null, 'unknown'] + ] as const)('cached %s, scan %s: %s', async (foregroundProcess, scanned, found) => { const scan = vi.fn() + const proof = vi.fn() const { runtime } = await createTranscriptPane({ paneTitle: 'Claude Code', foregroundProcess, confirmedForegroundProcess: scanned, onForegroundScan: scan, + shellForegroundProven: false, + onShellForegroundProof: proof, launchAgent: 'claude', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - shellInFront - ) + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude') + ).resolves.toBe(found) expect(scan).toHaveBeenCalledOnce() + expect(proof).not.toHaveBeenCalled() }) - // The terminal daemon answers the shell-foreground check from its recovery state, which an - // agent that exits without leaving a full-screen mode up never sets. - it('an agent that exited: the scan names zsh while the daemon’s shell check says no', async () => { - const proof = vi.fn() + it('proves nothing on a controller without a fresh scan', async () => { const { runtime } = await createTranscriptPane({ - paneTitle: 'copilot', - foregroundProcess: 'copilot', - confirmedForegroundProcess: 'zsh', - shellForegroundProven: false, - onShellForegroundProof: proof, + paneTitle: 'Claude Code', + foregroundProcess: 'claude', + launchAgent: 'claude', + data: '' + }) + + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude') + ).resolves.toBe('unknown') + }) + + // Captured with `ps -o pid=,ppid=,pgid=,tpgid=,stat=,command=` on a pane spawned the way a macOS + // pane is, under `login`: zsh holds the terminal in its own group, never the root's. + it('reads a login-wrapped pane’s zsh at its prompt as the shell', async () => { + const rows: ProcessTableRow[] = [ + { + pid: 60404, + ppid: 60394, + pgid: 60404, + tpgid: 60406, + stat: 'Ss', + tty: 'ttys004', + startTime: 'Fri Oct 2 01:20:11 2026', + command: '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c' + }, + { + pid: 60406, + ppid: 60404, + pgid: 60406, + tpgid: 60406, + stat: 'S+', + tty: 'ttys004', + startTime: 'Fri Oct 2 01:20:11 2026', + command: '-/bin/zsh -f' + } + ] + const inspection = { + foregroundProcess: 'zsh', + hasChildProcesses: true, + foregroundProcessEvidence: resolveRemoteForegroundEvidence( + { rootPid: 60404, fallbackProcess: 'zsh' }, + { + ptyId: TRANSCRIPT_PANE_PTY_ID, + ptyIncarnationId: 'inc-1', + authorityGeneration: 'gen-1', + observationEpoch: 1, + capturedAgeMs: 0, + platform: 'darwin' + }, + rows + ) + } + // Presence precondition: the fence is live, and its foreground group is not the root's. + expect(inspection.foregroundProcessEvidence).toMatchObject({ + verdict: 'live', + fence: { shellPid: 60404, foregroundPgid: 60406 } + }) + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'python3', + processInspection: inspection, + confirmedForegroundProcess: '-zsh', + launchAgent: 'claude', + data: '' + }) + + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude') + ).resolves.toBe('shell') + }) + + // A wrapper that does not `exec` an agent the scan cannot recognize keeps its own name in front, + // which reads as a shell: the prompt stays undelivered rather than risk the user's shell. + it('reads a bash wrapper still in front of an unrecognized agent as a shell', async () => { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'bash', + confirmedForegroundProcess: 'bash', launchAgent: 'copilot', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( - true - ) - expect(proof).not.toHaveBeenCalled() - }) - - it('takes the cached read on a controller without a scan', async () => { - const { runtime } = await createTranscriptPane({ - paneTitle: 'Claude Code', - foregroundProcess: 'zsh', - launchAgent: 'claude', - data: '' - }) - - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - true - ) + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'copilot') + ).resolves.toBe('shell') }) }) - describe('Windows: only the shell-foreground check', () => { + // The scan names the pane's shell for an agent it cannot recognize (an npm agent as `node.exe`), + // and Git Bash and WSL keep other processes in the shell's job, so nothing proves the agent. + describe('Windows: only the shell-foreground check, and never the agent', () => { beforeEach(() => setPlatform('win32')) - // The scan names the pane's shell for an agent it cannot recognize (an npm agent as - // `node.exe`); only the shell alone in the pane's job proves one. it.each([ - ['powershell.exe', false, false], - ['powershell.exe', true, true], - ['claude', true, true], - ['claude', false, false] - ])('cached %s, shell check %s: %s', async (foregroundProcess, proven, shellInFront) => { + ['powershell.exe', false, 'unknown'], + ['powershell.exe', true, 'shell'], + ['claude', true, 'shell'], + ['claude', false, 'unknown'], + ['node', false, 'unknown'] + ] as const)('cached %s, shell check %s: %s', async (foregroundProcess, proven, found) => { const scan = vi.fn() const { runtime } = await createTranscriptPane({ paneTitle: 'Claude Code', foregroundProcess, - confirmedForegroundProcess: 'powershell.exe', + confirmedForegroundProcess: 'claude', onForegroundScan: scan, shellForegroundProven: proven, launchAgent: 'claude', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - shellInFront - ) + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude') + ).resolves.toBe(found) expect(scan).not.toHaveBeenCalled() }) - - it('proves nothing on a controller without a shell check', async () => { - const { runtime } = await createTranscriptPane({ - paneTitle: 'Claude Code', - foregroundProcess: 'powershell.exe', - confirmedForegroundProcess: 'powershell.exe', - launchAgent: 'claude', - data: '' - }) - - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - false - ) - }) }) // The stubs always disagree with the relay's name, so asking either would flip the answer. it.each([ - ['claude', false], - ['bash', true] - ])( - 'SSH: takes the relay’s own read %s (shell %s), without a scan or check', - async (relayRead, shell) => { + ['claude', 'agent'], + ['bash', 'shell'] + ] as const)( + 'SSH: takes the relay’s own read %s (%s), without a scan or check', + async (relayRead, found) => { + setPlatform('darwin') const scan = vi.fn() const proof = vi.fn() const { runtime } = await createTranscriptPane({ paneTitle: 'Claude Code', foregroundProcess: relayRead, - confirmedForegroundProcess: shell ? 'claude' : 'zsh', + confirmedForegroundProcess: found === 'shell' ? 'claude' : 'zsh', onForegroundScan: scan, - shellForegroundProven: !shell, + shellForegroundProven: found !== 'shell', onShellForegroundProof: proof, connectionId: 'ssh-1', launchAgent: 'claude', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - shell - ) + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude') + ).resolves.toBe(found) expect(scan).not.toHaveBeenCalled() expect(proof).not.toHaveBeenCalled() } ) - describe('macOS and Linux: by the shell’s identity, from the host’s process inspection', () => { - beforeEach(() => setPlatform('darwin')) - - // Captured with `ps -axo pid=,ppid=,pgid=,tpgid=,stat=,tty=,lstart=,command=` on a pty running - // `zsh -f -i` (macOS 26): first while it ran `bash -c 'sleep 4; :'`, then back at its prompt. - const zsh = (tpgid: number, stat: string): ProcessTableRow => ({ - pid: 20894, - ppid: 20891, - pgid: 20894, - tpgid, - stat, - tty: 'ttys000', - startTime: 'Wed Sep 30 09:18:50 2026', - command: '/bin/zsh -f -i' - }) - const wrapperRunning: ProcessTableRow[] = [ - zsh(20996, 'Ss'), - { - pid: 20996, - ppid: 20894, - pgid: 20996, - tpgid: 20996, - stat: 'S+', - tty: 'ttys000', - startTime: 'Wed Sep 30 09:18:52 2026', - command: 'bash -c sleep 4; :' - }, - { - pid: 20997, - ppid: 20996, - pgid: 20996, - tpgid: 20996, - stat: 'S+', - tty: 'ttys000', - startTime: 'Wed Sep 30 09:18:52 2026', - command: 'sleep 4' - } - ] - const zshAtPrompt: ProcessTableRow[] = [zsh(20894, 'Ss+')] - const inspect = (rows: ProcessTableRow[]) => ({ - foregroundProcess: 'zsh', - hasChildProcesses: rows.length > 1, - foregroundProcessEvidence: resolveRemoteForegroundEvidence( - { rootPid: 20894, fallbackProcess: 'zsh' }, - { - ptyId: TRANSCRIPT_PANE_PTY_ID, - ptyIncarnationId: 'inc-1', - authorityGeneration: 'gen-1', - observationEpoch: 1, - capturedAgeMs: 0, - platform: 'darwin' - }, - rows - ) - }) - - it.each([ - // A wrapper script's bash runs as its own job: named like a shell, but not the pane's shell. - ['a bash wrapper under zsh is not the shell', wrapperRunning, 'bash', false, false], - ['zsh back at its prompt is the shell', zshAtPrompt, 'zsh', true, true] - ] as const)('%s', async (_label, rows, foregroundName, shell, scanned) => { - const inspection = inspect([...rows]) - // Presence precondition: the captured rows are a live observation, not an unreadable one. - expect(inspection.foregroundProcessEvidence).toMatchObject({ verdict: 'live' }) - const scan = vi.fn() - const { runtime } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: foregroundName, - processInspection: inspection, - confirmedForegroundProcess: foregroundName, - onForegroundScan: scan, - launchAgent: 'copilot', - data: '' - }) - - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( - shell - ) - expect(scan).toHaveBeenCalledTimes(scanned ? 1 : 0) - }) - - it('SSH: takes the relay’s inspection the same way', async () => { - const { runtime } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'bash', - processInspection: inspect([...wrapperRunning]), - connectionId: 'ssh-1', - launchAgent: 'copilot', - data: '' - }) - - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( - false - ) - }) - - it('an agent in the shell’s own group (a shell without job control) is not the shell', async () => { - const { runtime } = await createTranscriptPane({ - paneTitle: 'Copilot', - // node-pty names the group's leader, which is the shell itself here. - foregroundProcess: 'zsh', - processInspection: inspect([...zshAtPrompt]), - confirmedForegroundProcess: 'node', - launchAgent: 'copilot', - data: '' - }) - - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( - false - ) - }) - - it('an inspection that cannot observe proves nothing, and falls back to no name', async () => { - const scan = vi.fn() - const { runtime } = await createTranscriptPane({ - paneTitle: 'Terminal', - foregroundProcess: 'zsh', - processInspection: inspect([]), - confirmedForegroundProcess: 'zsh', - onForegroundScan: scan, - launchAgent: 'claude', - data: '' - }) - - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - false - ) - expect(scan).not.toHaveBeenCalled() - }) - }) - // Why: a Windows relay names the pane's shell for an agent its scan cannot recognize (node.exe). - it('SSH to a Windows host: the relay’s shell name proves nothing', async () => { + it('SSH to a Windows host: never the agent', async () => { const { runtime } = await createTranscriptPane({ paneTitle: 'Copilot', - foregroundProcess: 'powershell.exe', + foregroundProcess: 'node', connectionId: 'ssh-1', remoteWindowsHost: true, launchAgent: 'copilot', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( - false - ) + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'copilot') + ).resolves.toBe('unknown') }) - - // Why no read: a native Claude names itself by its version, which the cached read gives at once. - it.each([ - ['darwin', '2.1.285'], - ['darwin', 'node'], - ['win32', 'node'] - ] as const)( - '%s: takes the non-shell name %s, other than the agent’s own, at once', - async (platform, foregroundProcess) => { - setPlatform(platform) - const scan = vi.fn() - const proof = vi.fn() - const { runtime } = await createTranscriptPane({ - paneTitle: 'Claude Code', - foregroundProcess, - confirmedForegroundProcess: 'zsh', - onForegroundScan: scan, - shellForegroundProven: true, - onShellForegroundProof: proof, - launchAgent: 'claude', - data: '' - }) - - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - false - ) - expect(scan).not.toHaveBeenCalled() - expect(proof).not.toHaveBeenCalled() - } - ) }) diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts index 7c8fa6871a0..49db277d0f3 100644 --- a/src/main/runtime/launched-agent-composer-readiness.ts +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -9,7 +9,8 @@ * a composer from a startup dialog drawn in the same mode, so it counts only while the pane shows no * startup dialog and no Codex provisional header (`readFreshComposerHold`). Unlike the desktop's * paste it reads only output after the shell's last `?2004l`, and drops a signal while a shell is - * proven in front (`isLaunchShellInFront`). + * proven in front (`readLaunchedAgentForeground`). A signal is never proof by itself: a shell back + * at its prompt turns bracketed paste on too, so the write still needs the agent found in front. * * Where the desktop pasted blind once its budget ran out, the host falls back to the `tui-idle` * evidence ranking (idle titles, known ready screens), which also reports a dialog left up. A few diff --git a/src/main/runtime/launched-agent-crash-guard.live-shell.test.ts b/src/main/runtime/launched-agent-crash-guard.live-shell.test.ts new file mode 100644 index 00000000000..8dd13f8543c --- /dev/null +++ b/src/main/runtime/launched-agent-crash-guard.live-shell.test.ts @@ -0,0 +1,174 @@ +/** + * A launched agent that exits at startup hands the terminal back to its shell, which turns bracketed + * paste on at its next prompt just as an agent's composer does. Real zsh with a slow user config, + * spawned the way a pane is (under `login` on macOS), read through the terminal daemon's own + * foreground tracker: the launch must find the shell and write nothing, and must still find a live + * agent. + */ +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import { getStrictProcessTableSnapshotWithAge } from '../../shared/process-table-snapshot-reader' +import { createPtyForegroundProcessTracker } from '../daemon/pty-subprocess/foreground-process-tracker' +import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process' +import { + prepareMacosTccLoginShell, + wrapShellSpawnForMacosTccAttribution +} from '../providers/macos-tcc-login-shell' +import { readLaunchedAgentForeground } from './launched-agent-foreground' +import { createLaunchedAgentWriteGuard } from './launched-agent-write-guard' +import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness' + +const PTY_ID = 'pty-1' +const ZSH_PATH = + process.platform === 'win32' + ? '' + : (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout ?? '').trim() +const describeWithZsh = ZSH_PATH ? describe : describe.skip + +const STUBS = { + // Exits at startup, as an agent with a bad config or a missing dependency does. + crash: "#!/bin/sh\nprintf 'stub: crashing at startup\\r\\n'\nexit 1\n", + // Opens its composer the way an agent does, then stays up. + ready: "#!/bin/sh\nprintf '\\033[?2004hstub agent\\r\\n> '\nexec sleep 30\n" +} as const + +let home = '' +let savedLoginOptOut: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-crash-guard-')) + // The production spawn: a pane on macOS runs under `login` unless the user opted out. + savedLoginOptOut = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL + delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL +}) + +afterEach(() => { + if (savedLoginOptOut !== undefined) { + process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = savedLoginOptOut + } + rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) +}) + +/** Launches the `claude` stub in a fresh zsh pane and runs the launch's own readiness wait. */ +async function launchStub(stub: keyof typeof STUBS, readyTimeoutMs: number) { + const bin = join(home, 'bin') + mkdirSync(bin) + // Typed by absolute path: `login` resets HOME and PATH, so a bare name would find a real agent. + const stubPath = join(bin, 'claude') + writeFileSync(stubPath, STUBS[stub]) + chmodSync(stubPath, 0o755) + writeFileSync(join(home, '.zshrc'), 'sleep 2\n') + const env = { PATH: '/usr/bin:/bin', HOME: home, ZDOTDIR: home, TERM: 'xterm-256color' } + await prepareMacosTccLoginShell() + const spawn = wrapShellSpawnForMacosTccAttribution(ZSH_PATH, ['-i'], env) + const proc = pty.spawn(spawn.file, spawn.args, { cols: 80, rows: 24, cwd: home, env }) + let dead = false + proc.onExit(() => { + dead = true + }) + const listeners = new Set<(data: string) => void>() + let output = '' + const tracker = createPtyForegroundProcessTracker({ + process: proc, + shellPath: ZSH_PATH, + cwd: home, + sessionId: 'wt-crash-guard@@pane', + startupAgentRecognition: recognizeAgentProcess('claude'), + isDead: () => dead + }) + proc.onData((data) => { + tracker.recordOutput(data) + output += data + for (const listener of listeners) { + listener(data) + } + }) + // The daemon's answers, each from the same pieces the daemon uses. + const controller = { + getForegroundProcess: async () => tracker.getForegroundProcess(), + confirmForegroundProcess: () => tracker.confirmForegroundProcess(), + confirmShellForeground: () => tracker.confirmShellForeground(), + inspectProcess: async () => { + const snapshot = await getStrictProcessTableSnapshotWithAge() + return { + foregroundProcess: tracker.getForegroundProcess(), + hasChildProcesses: true, + foregroundProcessEvidence: resolveRemoteForegroundEvidence( + { rootPid: proc.pid, fallbackProcess: tracker.getForegroundProcess() }, + { + ptyId: PTY_ID, + ptyIncarnationId: 'inc-1', + authorityGeneration: 'gen-1', + observationEpoch: 1, + capturedAgeMs: snapshot.capturedAgeMs, + platform: process.platform + }, + snapshot.rows + ) + } + } + } + const readForeground = (ptyId: string) => + readLaunchedAgentForeground(controller, { remote: false, windows: false }, ptyId, 'claude') + const subscribe = (_ptyId: string, listener: (data: string) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + const ready = waitForWorktreeStartupDraft( + { + getPtyId: () => PTY_ID, + getForegroundProcess: controller.getForegroundProcess, + subscribeToData: subscribe, + readRecentOutput: () => undefined, + write: () => {} + }, + 'term-1', + 'claude', + { + timeoutMs: readyTimeoutMs, + isShellInFront: async (ptyId) => (await readForeground(ptyId)) === 'shell' + } + ) + proc.write(`'${stubPath}'\r`) + const guard = createLaunchedAgentWriteGuard( + { readLaunchedAgentForeground: readForeground, subscribeToTerminalData: subscribe }, + 'claude' + ) + const close = async (): Promise => { + guard.dispose() + proc.kill() + await new Promise((resolve) => setTimeout(resolve, 200)) + } + return { ready, guard, close, output: () => output } +} + +describeWithZsh('a launch prompt after the launched agent exits at startup', () => { + it('drops the shell’s prompt as a ready signal and refuses the write', async () => { + const launch = await launchStub('crash', 9_000) + try { + // The shell's prompt after the crash turned bracketed paste on and went quiet, yet no agent did. + await expect(launch.ready).resolves.toBeNull() + // Presence precondition: the stub, not anything else on the host, is what ran and exited. + expect(launch.output()).toContain('stub: crashing at startup') + await expect(launch.guard.beforeWrite(PTY_ID)).rejects.toThrow('agent_not_in_foreground') + } finally { + await launch.close() + } + }, 30_000) + + it('still finds an agent that stays up, and lets the write through', async () => { + const launch = await launchStub('ready', 15_000) + try { + await expect(launch.ready).resolves.toBe(PTY_ID) + expect(launch.output()).toContain('stub agent') + await expect(launch.guard.beforeWrite(PTY_ID)).resolves.toBeUndefined() + } finally { + await launch.close() + } + }, 30_000) +}) diff --git a/src/main/runtime/launched-agent-exit-delivery.test.ts b/src/main/runtime/launched-agent-exit-delivery.test.ts new file mode 100644 index 00000000000..37c90e4354f --- /dev/null +++ b/src/main/runtime/launched-agent-exit-delivery.test.ts @@ -0,0 +1,192 @@ +/** + * An agent that exits at startup leaves its shell at a prompt that turns bracketed paste on, which + * is the same signal an agent's composer gives. Driven through the launch's own readiness wait, + * foreground read and write guard, with each host answering the way it does after the exit. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { deliverTerminalAgentLaunchPrompt } from './rpc/methods/agent-launch-terminal-prompt' +import { readLaunchedAgentForeground } from './launched-agent-foreground' +import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness' + +const PTY_ID = 'pty-1' +const SHELL_HANDOFF = '\x1b[?2004l' + +/** zsh's prompt, the launch line it runs, then its prompt again once that command exited. */ +function zshRunsLaunchLineThatExits(): string[] { + const zsh = readFileSync(join(__dirname, '__fixtures__', 'zsh-prompt-runs-command.txt'), 'utf8') + const split = zsh.indexOf('\n', zsh.indexOf(SHELL_HANDOFF)) + 1 + return [zsh.slice(0, split), 'stub: crashing at startup\r\n', zsh.slice(split)] +} + +/** Git Bash and WSL's bash: readline turns bracketed paste on at each prompt. */ +function bashRunsLaunchLineThatExits(): string[] { + return [ + '\x1b[?2004hqa@host:~$ claude\r\n\x1b[?2004l\r', + 'stub: crashing at startup\r\n', + '\x1b[?2004hqa@host:~$ ' + ] +} + +type HostAnswers = { + host: { remote: boolean; windows: boolean } + cached: string | null + scanned: string | null + shellAlone: boolean +} + +const HOSTS: [string, HostAnswers, () => string[]][] = [ + // The shell keeps other processes in its job, so the shell-alone check never answers. + [ + 'Windows Git Bash', + { + host: { remote: false, windows: true }, + cached: 'claude', + scanned: 'bash', + shellAlone: false + }, + bashRunsLaunchLineThatExits + ], + [ + 'Windows WSL', + { host: { remote: false, windows: true }, cached: 'claude', scanned: 'wsl', shellAlone: false }, + bashRunsLaunchLineThatExits + ], + // A pane under `login`, whose cached name is still the exited stub's. + [ + 'macOS zsh', + { + host: { remote: false, windows: false }, + cached: 'python3', + scanned: 'zsh', + shellAlone: false + }, + zshRunsLaunchLineThatExits + ] +] + +function launchedPane(answers: HostAnswers) { + const listeners = new Set<(data: string) => void>() + const subscribe = (_ptyId: string, listener: (data: string) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + const controller = { + getForegroundProcess: async () => answers.cached, + confirmForegroundProcess: async () => answers.scanned, + confirmShellForeground: async () => answers.shellAlone + } + const readForeground = (ptyId: string) => + readLaunchedAgentForeground(controller, answers.host, ptyId, 'claude') + const writes: string[] = [] + const runtime = { + waitForFreshWorkerComposer: async (_handle: string, agent: 'claude', timeoutMs: number) => { + const ptyId = await waitForWorktreeStartupDraft( + { + getPtyId: () => PTY_ID, + getForegroundProcess: controller.getForegroundProcess, + subscribeToData: subscribe, + readRecentOutput: () => undefined, + write: () => {} + }, + 'term-1', + agent, + { + timeoutMs, + requireComposerMarker: false, + isShellInFront: async (id) => (await readForeground(id)) === 'shell' + } + ) + if (!ptyId) { + throw new Error('timeout') + } + return { handle: 'term-1', satisfied: true, status: 'running' } + }, + // The idle evidence after the budget: the shell's prompt can look settled too. + waitForTerminal: vi.fn(async () => ({ handle: 'term-1', satisfied: true, status: 'idle' })), + readLaunchedAgentForeground: (ptyId: string) => readForeground(ptyId), + subscribeToTerminalData: subscribe, + sendTerminalAgentPrompt: vi.fn( + async ( + handle: string, + text: string, + options: { beforeWrite?: (id: string) => Promise } + ) => { + await options.beforeWrite?.(PTY_ID) + writes.push(text) + return { handle, accepted: true, bytesWritten: text.length } + } + ) + } + return { + runtime, + writes, + emit: (data: string) => { + for (const listener of listeners) { + listener(data) + } + } + } +} + +describe('a launch prompt after the launched agent exits at startup', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it.each(HOSTS)( + '%s: types nothing and reports it undelivered', + async (_label, answers, transcript) => { + const pane = launchedPane(answers) + const delivery = deliverTerminalAgentLaunchPrompt({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches only the methods built above. + runtime: pane.runtime as unknown as Parameters< + typeof deliverTerminalAgentLaunchPrompt + >[0]['runtime'], + handle: 'term-1', + agent: 'claude', + freshLaunch: true, + text: 'QA prompt that must never run as a shell command' + }) + for (const chunk of transcript()) { + pane.emit(chunk) + } + await vi.advanceTimersByTimeAsync(61_000) + + await expect(delivery).resolves.toBe(false) + expect(pane.writes).toEqual([]) + } + ) + + it('macOS zsh: still writes into an agent that stays up', async () => { + const pane = launchedPane({ + host: { remote: false, windows: false }, + cached: 'claude', + scanned: 'claude', + shellAlone: false + }) + const delivery = deliverTerminalAgentLaunchPrompt({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches only the methods built above. + runtime: pane.runtime as unknown as Parameters< + typeof deliverTerminalAgentLaunchPrompt + >[0]['runtime'], + handle: 'term-1', + agent: 'claude', + freshLaunch: true, + text: 'QA prompt' + }) + pane.emit(zshRunsLaunchLineThatExits()[0]) + pane.emit('\x1b[?2004hClaude Code\r\n> ') + await vi.advanceTimersByTimeAsync(2_000) + + await expect(delivery).resolves.toBe(true) + expect(pane.writes).toEqual(['QA prompt']) + }) +}) diff --git a/src/main/runtime/launched-agent-foreground.ts b/src/main/runtime/launched-agent-foreground.ts new file mode 100644 index 00000000000..2d1abb1f9bf --- /dev/null +++ b/src/main/runtime/launched-agent-foreground.ts @@ -0,0 +1,60 @@ +import { isExpectedAgentProcess } from '../../shared/agent-process-recognition' +import { isShellProcess } from '../../shared/shell-process-detection' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import type { RuntimePtyController } from './runtime-pty-controller-contract' + +/** + * What holds a launched agent's terminal: the agent (anything but the pane's shell), the shell + * (the launch line has not run yet, or the agent exited), or `unknown` when the host cannot tell. + * Only `agent` lets a launch write its prompt; only `shell` drops a ready signal. + */ +export type LaunchedAgentForeground = 'agent' | 'shell' | 'unknown' + +/** A login shell is reported as `-zsh`. */ +function isLaunchShell(processName: string): boolean { + return isShellProcess(processName.replace(/^-/, '')) +} + +/** + * One fresh read of the terminal's foreground process: on a local POSIX host the scan behind + * `confirmForegroundProcess`, and on an SSH host the relay's name, which it reads from the terminal + * when asked. Never the cached name a tab icon uses: it can still name a process that already + * exited. Not the process-group fence either: a macOS pane runs its shell under `login`, so the + * fence's root is never the shell's group. + * + * Windows has no foreground process group, and its scan names the pane's shell for an agent it + * cannot recognize, while Git Bash and WSL keep other processes in the shell's job, so nothing + * there proves the agent. A Windows host can still prove its shell alone (`confirmShellForeground`). + */ +export async function readLaunchedAgentForeground( + controller: Pick< + RuntimePtyController, + 'getForegroundProcess' | 'confirmForegroundProcess' | 'confirmShellForeground' + > | null, + host: { remote: boolean; windows: boolean }, + ptyId: string, + agent: TuiAgent +): Promise { + if (!controller) { + return 'unknown' + } + try { + if (host.windows) { + // An SSH pane has no such check, so on a Windows relay nothing proves a shell. + return (await controller.confirmShellForeground?.(ptyId)) ? 'shell' : 'unknown' + } + const foreground = host.remote + ? await controller.getForegroundProcess(ptyId) + : ((await controller.confirmForegroundProcess?.(ptyId)) ?? null) + if (!foreground) { + return 'unknown' + } + return isLaunchShell(foreground) && + !isExpectedAgentProcess(foreground, TUI_AGENT_CONFIG[agent].expectedProcess) + ? 'shell' + : 'agent' + } catch { + return 'unknown' + } +} diff --git a/src/main/runtime/launched-agent-shell-foreground.ts b/src/main/runtime/launched-agent-shell-foreground.ts deleted file mode 100644 index 9b5c20bb7f1..00000000000 --- a/src/main/runtime/launched-agent-shell-foreground.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - isExpectedAgentProcess, - recognizeAgentProcess -} from '../../shared/agent-process-recognition' -import { isShellProcess } from '../../shared/shell-process-detection' -import type { TuiAgent } from '../../shared/tui-agent' -import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' -import type { RuntimePtyController } from './runtime-pty-controller-contract' - -/** A login shell is reported as `-zsh`. */ -function isLaunchShell(processName: string): boolean { - return isShellProcess(processName.replace(/^-/, '')) -} - -/** - * Whether the pane's own shell, rather than the agent a launch started, is proven to be in the - * terminal's foreground: the launch line has not run yet, or the agent exited. A read that fails or - * cannot tell proves nothing, so it never delays a launch. - * - * On macOS and Linux identity decides first: the host's process inspection reports the terminal's - * foreground process group, and the pane's shell can be in front only when that group is the - * shell's own. A wrapper script's bash, or a conda hook, runs as its own job, so it is not the - * pane's shell even though it is named like one. A fresh read must then also name a shell. - * - * Windows has no foreground process group, and its scan names the pane's shell for any agent it - * cannot recognize (an npm agent running as `node.exe`), so it takes only the host's - * shell-foreground check (the shell alone in the pane's job). A daemon pane answers that from its - * recovery state, which a plain exit leaves unset, and an SSH pane has none, so there it proves - * nothing. - * - * Why no check for a non-shell name other than the agent's own: for its first 5 s the daemon's - * cached read names the launch agent whenever a shell is in front (measured on a zsh pane: cached - * `copilot`, scan `zsh`), so any other non-shell name it gives is the process itself. macOS - * reports the native Claude by its version (`2.1.258`), and a runtime such as node can front an - * agent. - */ -export async function isShellInFrontOfLaunchedAgent( - controller: Pick< - RuntimePtyController, - | 'getForegroundProcess' - | 'inspectProcess' - | 'confirmForegroundProcess' - | 'confirmShellForeground' - > | null, - host: { remote: boolean; windows: boolean }, - ptyId: string, - agent: TuiAgent -): Promise { - if (!controller) { - return false - } - try { - const cached = await controller.getForegroundProcess(ptyId) - if (cached && !isLaunchShell(cached) && recognizeAgentProcess(cached)?.agent !== agent) { - return false - } - if (host.windows) { - // An SSH pane has no such check, so on a Windows relay nothing proves a shell. - return (await controller.confirmShellForeground?.(ptyId)) ?? false - } - const evidence = (await controller.inspectProcess?.(ptyId))?.foregroundProcessEvidence - if ( - evidence && - !( - evidence.verdict === 'live' && - evidence.fence.platform === 'posix' && - evidence.fence.foregroundPgid === evidence.fence.shellPid - ) - ) { - return false - } - // Why the name too: a shell without job control runs its commands in its own group. - // A host that predates the evidence (an older daemon or relay, or a pane outside the daemon) - // has only the name, which reads a wrapper script's bash as the shell. A relay's cached name is - // read live, and it has no scan. - const foreground = - !host.remote && controller.confirmForegroundProcess - ? await controller.confirmForegroundProcess(ptyId) - : cached - return isShellName(foreground, agent) - } catch { - return false - } -} - -function isShellName(processName: string | null, agent: TuiAgent): boolean { - return ( - !!processName && - isLaunchShell(processName) && - !isExpectedAgentProcess(processName, TUI_AGENT_CONFIG[agent].expectedProcess) - ) -} diff --git a/src/main/runtime/launched-agent-write-guard.test.ts b/src/main/runtime/launched-agent-write-guard.test.ts index 4af5f64c3b7..29d6aed51ca 100644 --- a/src/main/runtime/launched-agent-write-guard.test.ts +++ b/src/main/runtime/launched-agent-write-guard.test.ts @@ -1,17 +1,20 @@ import { describe, expect, it, vi } from 'vitest' +import type { LaunchedAgentForeground } from './launched-agent-foreground' import { createLaunchedAgentWriteGuard, type LaunchedAgentWriteGuardRuntime } from './launched-agent-write-guard' -function guardHarness(shellInFront: boolean[]) { - const answers = [...shellInFront] +function guardHarness(foregrounds: LaunchedAgentForeground[]) { + const answers = [...foregrounds] let listener: ((data: string) => void) | null = null const unsubscribe = vi.fn(() => { listener = null }) const runtime: LaunchedAgentWriteGuardRuntime = { - isLaunchShellInFront: vi.fn(async () => answers.shift() ?? false), + readLaunchedAgentForeground: vi.fn( + async (): Promise => answers.shift() ?? 'agent' + ), subscribeToTerminalData: vi.fn((_ptyId: string, next: (data: string) => void) => { listener = next return unsubscribe @@ -22,23 +25,35 @@ function guardHarness(shellInFront: boolean[]) { } describe('the check before each write of a launch prompt', () => { - it('reuses a read that found no shell for the writes after it', async () => { - const { runtime, guard } = guardHarness([false]) + it('reuses a read that found the agent for the writes after it', async () => { + const { runtime, guard } = guardHarness(['agent']) await guard.beforeWrite('pty-1') // The agent's own output after the paste says nothing about a shell. await guard.beforeWrite('pty-1') await guard.beforeWrite('pty-1') - expect(runtime.isLaunchShellInFront).toHaveBeenCalledTimes(1) + expect(runtime.readLaunchedAgentForeground).toHaveBeenCalledTimes(1) }) + // Why: a ready signal can come from a shell whose agent exited, so only a read that finds the + // agent may let the text through; an unanswered read is not that. + it.each(['shell', 'unknown'] as const)( + 'refuses a write when the read finds %s', + async (found) => { + const { guard, unsubscribe } = guardHarness([found]) + + await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') + expect(unsubscribe).toHaveBeenCalledTimes(1) + } + ) + it.each([ ['a shell turning bracketed paste on', '$ \x1b[?2004h'], ['a shell integration prompt mark', '\x1b]133;D;1\x07\x1b]133;A\x07$ '], ['a mark split across two chunks', ['\x1b[?20', '04h']] ] as const)('reads again after %s, and refuses a shell', async (_label, output) => { - const { runtime, guard, emit } = guardHarness([false, true]) + const { runtime, guard, emit } = guardHarness(['agent', 'shell']) await guard.beforeWrite('pty-1') for (const chunk of typeof output === 'string' ? [output] : output) { @@ -46,17 +61,17 @@ describe('the check before each write of a launch prompt', () => { } await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') - expect(runtime.isLaunchShellInFront).toHaveBeenCalledTimes(2) + expect(runtime.readLaunchedAgentForeground).toHaveBeenCalledTimes(2) }) it('catches a shell that returns while the first read is still running', async () => { const { runtime, guard, emit } = guardHarness([]) - vi.mocked(runtime.isLaunchShellInFront) + vi.mocked(runtime.readLaunchedAgentForeground) .mockImplementationOnce(async () => { emit('\x1b[?2004h') - return false + return 'agent' }) - .mockResolvedValueOnce(true) + .mockResolvedValueOnce('shell') await guard.beforeWrite('pty-1') @@ -64,11 +79,11 @@ describe('the check before each write of a launch prompt', () => { }) it('stops watching when it refuses and when it is disposed', async () => { - const refused = guardHarness([true]) + const refused = guardHarness(['shell']) await expect(refused.guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') expect(refused.unsubscribe).toHaveBeenCalledTimes(1) - const cleared = guardHarness([false]) + const cleared = guardHarness(['agent']) await cleared.guard.beforeWrite('pty-1') cleared.guard.dispose() expect(cleared.unsubscribe).toHaveBeenCalledTimes(1) diff --git a/src/main/runtime/launched-agent-write-guard.ts b/src/main/runtime/launched-agent-write-guard.ts index 80f0bf71d6b..7e29f3008c4 100644 --- a/src/main/runtime/launched-agent-write-guard.ts +++ b/src/main/runtime/launched-agent-write-guard.ts @@ -1,5 +1,6 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { OrcaRuntimeService } from './orca-runtime' +import type { LaunchedAgentForeground } from './launched-agent-foreground' /** A shell back at its prompt turns bracketed paste on, and Orca's shell integration marks it. */ const SHELL_RETURN_MARKERS = ['\x1b[?2004', '\x1b]133;'] as const @@ -7,14 +8,15 @@ const MARKER_CARRY_CHARS = Math.max(...SHELL_RETURN_MARKERS.map((marker) => mark export type LaunchedAgentWriteGuardRuntime = Pick< OrcaRuntimeService, - 'isLaunchShellInFront' | 'subscribeToTerminalData' + 'readLaunchedAgentForeground' | 'subscribeToTerminalData' > /** * The check before each write of a launch prompt: the paste, its Enter, and Codex's second Enter. - * A shell proven in front refuses the write. Once a read finds none, later writes reuse it until the - * terminal shows a shell coming back to its prompt, so Enter follows the paste on the desktop's - * timing instead of waiting out another process read. + * A write needs a fresh read that finds the agent in front; a shell, or a host that cannot tell, + * refuses it, since a ready signal alone can come from a shell back at its prompt. Once a read finds + * the agent, later writes reuse it until the terminal shows a shell coming back to its prompt, so + * Enter follows the paste on the desktop's timing instead of waiting out another process read. */ export function createLaunchedAgentWriteGuard( runtime: LaunchedAgentWriteGuardRuntime, @@ -41,14 +43,14 @@ export function createLaunchedAgentWriteGuard( watch.shellMayHaveReturned = true } }) - let shellInFront: boolean + let foreground: LaunchedAgentForeground try { - shellInFront = await runtime.isLaunchShellInFront(ptyId, agent) + foreground = await runtime.readLaunchedAgentForeground(ptyId, agent) } catch (error) { watch.unsubscribe() throw error } - if (shellInFront) { + if (foreground !== 'agent') { watch.unsubscribe() throw new Error('agent_not_in_foreground') } diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index cecf3223b10..7a17aadfc3c 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -33,7 +33,10 @@ import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning' import { readFreshComposerHold } from './launched-agent-composer-readiness' import { buildTerminalWaitText } from './terminal-wait-tail-state' -import { isShellInFrontOfLaunchedAgent } from './launched-agent-shell-foreground' +import { + readLaunchedAgentForeground, + type LaunchedAgentForeground +} from './launched-agent-foreground' export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees { async activateManagedWorktree( @@ -208,7 +211,8 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM timeoutMs, requireComposerMarker, signal: stop.signal, - isShellInFront: (ownerPtyId) => this.isLaunchShellInFront(ownerPtyId, agent), + isShellInFront: async (ownerPtyId) => + (await this.readLaunchedAgentForeground(ownerPtyId, agent)) === 'shell', accept: (readyPtyId) => { const pty = this.ptysById.get(readyPtyId) const hold = pty @@ -234,12 +238,17 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM return this.buildTuiIdleProbeResult(handle, null) } - /** Whether a shell, not the agent a launch started, is proven to be in the terminal's foreground. */ - isLaunchShellInFront(ptyId: string, agent: TuiAgent): Promise { + /** What holds the terminal a launch started its agent in, read fresh from the execution host. */ + readLaunchedAgentForeground(ptyId: string, agent: TuiAgent): Promise { const pty = this.ptysById.get(ptyId) - return isShellInFrontOfLaunchedAgent( + const remote = !!pty?.connectionId + return readLaunchedAgentForeground( this.ptyController, - { remote: !!pty?.connectionId, windows: this.pathFlavorForPty(pty) === 'win32' }, + // A local WSL pane still runs on a Windows host, whose process reads cannot see into it. + { + remote, + windows: remote ? this.pathFlavorForPty(pty) === 'win32' : process.platform === 'win32' + }, ptyId, agent ) 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 cbbf85aa37c..643441467ec 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -21,7 +21,10 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup' import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' -import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' +import { + launchHostProvesAgentInFront, + nameLocalTypedLineShell +} from './agent-launch-typed-line-shell' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -273,13 +276,16 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) }), opts.startupPrompt ?? '', - nameLocalTypedLineShell({ - isRemote, - ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), - ...(settings.terminalDefaultShell - ? { defaultShellSetting: settings.terminalDefaultShell } - : {}) - }) + { + shellName: nameLocalTypedLineShell({ + isRemote, + ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), + ...(settings.terminalDefaultShell + ? { defaultShellSetting: settings.terminalDefaultShell } + : {}) + }), + provesAgentInFront: launchHostProvesAgentInFront({ isRemote, launchPlatform: platform }) + } ) if (!startupPlan) { // Why: an explicit agent that yields no plan would otherwise spawn a bare diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts index 0c43c77b40d..fc58a875377 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts @@ -26,8 +26,8 @@ function runtimeStub(overrides: { send?: SendFn /** Whether the agent's composer signal fires within its budget; else the idle evidence decides. */ composerSignal?: boolean - /** Whether a shell is proven in the terminal's foreground. */ - shellInFront?: boolean + /** What a fresh read finds in the terminal's foreground; default: the agent. */ + foreground?: 'agent' | 'shell' | 'unknown' }) { const queued = [...(overrides.waits ?? [])] const waitForTerminal = vi.fn( @@ -40,7 +40,7 @@ function runtimeStub(overrides: { } return COMPOSER_READY }) - const isLaunchShellInFront = vi.fn(async () => overrides.shellInFront ?? false) + const readLaunchedAgentForeground = vi.fn(async () => overrides.foreground ?? 'agent') const subscribeToTerminalData = vi.fn(() => () => {}) const sendTerminalAgentPrompt = vi.fn( overrides.send ?? (async () => ({ handle: 'term_1', accepted: true, bytesWritten: 12 })) @@ -49,13 +49,13 @@ function runtimeStub(overrides: { waitForTerminal, waitForFreshWorkerComposer, sendTerminalAgentPrompt, - isLaunchShellInFront, + readLaunchedAgentForeground, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches exactly these five runtime methods; anything else would throw rather than read a wrong value. runtime: { waitForTerminal, waitForFreshWorkerComposer, sendTerminalAgentPrompt, - isLaunchShellInFront, + readLaunchedAgentForeground, subscribeToTerminalData } as unknown as Parameters[0]['runtime'] } @@ -228,12 +228,13 @@ describe('writing a launch prompt into a terminal agent', () => { }) it.each([ - ['refuses the write when the shell is back in front', true, false], - // An unread foreground is no proof the agent exited; the composer was just seen ready. - ['writes unless a shell is proven in front', false, true] - ])('%s', async (_label, shellInFront, delivered) => { + ['writes when a fresh read finds the agent in front', 'agent', true], + ['refuses the write when the shell is back in front', 'shell', false], + // A ready signal can be a shell's own prompt, so a read that cannot tell proves nothing. + ['refuses the write when the host cannot tell what is in front', 'unknown', false] + ] as const)('%s', async (_label, foreground, delivered) => { // An agent that exits at startup hands its shell back, which must never run the prompt. - const stub = runtimeStub({ composerSignal: true, shellInFront }) + const stub = runtimeStub({ composerSignal: true, foreground }) stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => { const { beforeWrite } = options if (typeof beforeWrite === 'function') { @@ -251,7 +252,7 @@ describe('writing a launch prompt into a terminal agent', () => { text: 'do the thing' }) ).resolves.toBe(delivered) - expect(stub.isLaunchShellInFront).toHaveBeenCalledWith('pty-1', 'claude') + expect(stub.readLaunchedAgentForeground).toHaveBeenCalledWith('pty-1', 'claude') }) it('checks the foreground once for the paste, its Enter and the second Enter', async () => { @@ -275,7 +276,7 @@ describe('writing a launch prompt into a terminal agent', () => { text: 'do the thing' }) ).resolves.toBe(true) - expect(stub.isLaunchShellInFront).toHaveBeenCalledTimes(1) + expect(stub.readLaunchedAgentForeground).toHaveBeenCalledTimes(1) }) it('writes as soon as the composer signal fires, without consulting the idle evidence', async () => { @@ -381,6 +382,27 @@ describe('writing a launch prompt into a terminal agent', () => { // A reused pane's render state is only inferred, so its Enter still waits for the render. expect(stub.sendTerminalAgentPrompt.mock.calls[0]?.[2]?.composerReady).toBe(false) }) + + it('writes nothing into a reused terminal whose agent is no longer in front', async () => { + const stub = runtimeStub({ foreground: 'shell' }) + stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => { + const { beforeWrite } = options + if (typeof beforeWrite === 'function') { + await beforeWrite('pty-1') + } + return { handle, accepted: true, bytesWritten: 12 } + }) + + await expect( + deliverTerminalAgentLaunchPrompt({ + runtime: stub.runtime, + handle: 'term_existing', + agent: 'claude', + freshLaunch: false, + text: 'do the thing' + }) + ).resolves.toBe(false) + }) }) function fakeClock() { diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts index 5af709eb0b1..8cffda5cf01 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts @@ -107,9 +107,9 @@ export async function deliverTerminalAgentLaunchPrompt(args: { if (args.text.trim().length === 0) { return false } - // Before the paste and again before Enter: an agent that exited in between leaves its shell in - // the foreground, which must never receive the prompt or its Enter. - const guard = args.freshLaunch ? createLaunchedAgentWriteGuard(args.runtime, args.agent) : null + // Before the paste and again before Enter, for a reused pane too: a ready signal can come from a + // shell whose agent exited, so only a read that finds the agent in front lets the text through. + const guard = createLaunchedAgentWriteGuard(args.runtime, args.agent) try { const wait = await waitThroughBlockingPrompts( args.runtime, @@ -130,7 +130,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: { inputKind: 'launch', // A fresh launch's composer was just seen ready; a reused pane's state is only inferred. composerReady: args.freshLaunch, - ...(guard ? { beforeWrite: guard.beforeWrite } : {}), + beforeWrite: guard.beforeWrite, // Paired: together these take the queued path, which settles an unobserved turn start into // an `input_accepted` receipt rather than raising it. Without the id the write is verified // strictly and a slow first turn throws. @@ -151,6 +151,6 @@ export async function deliverTerminalAgentLaunchPrompt(args: { ) return false } finally { - guard?.dispose() + guard.dispose() } } diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 139679ba705..26fd683074f 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -10,7 +10,10 @@ import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selectio import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' -import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' +import { + launchHostProvesAgentInFront, + nameLocalTypedLineShell +} from './agent-launch-typed-line-shell' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -153,16 +156,18 @@ export function buildWorktreeStartupForAgent( const prompt = environment.prompt ?? '' let startupPlan: ReturnType if (environment.onPromptCarry) { - const offered = planStartupWithPromptCandidate( - planInputs, - prompt, - nameLocalTypedLineShell({ + const offered = planStartupWithPromptCandidate(planInputs, prompt, { + shellName: nameLocalTypedLineShell({ isRemote: repoIsRemote(repo), ...(settings.terminalDefaultShell ? { defaultShellSetting: settings.terminalDefaultShell } : {}) + }), + provesAgentInFront: launchHostProvesAgentInFront({ + isRemote: repoIsRemote(repo), + launchPlatform: environment.getLaunchPlatform() }) - ) + }) startupPlan = offered.plan if (startupPlan && prompt.trim()) { environment.onPromptCarry(offered.promptCarried) diff --git a/src/shared/startup-line-prompt-carry.test.ts b/src/shared/startup-line-prompt-carry.test.ts index 360fd2b484c..4520b3635d1 100644 --- a/src/shared/startup-line-prompt-carry.test.ts +++ b/src/shared/startup-line-prompt-carry.test.ts @@ -12,16 +12,24 @@ import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-run function offer( agent: TuiAgent, prompt: string, - extra: { cmdOverride?: string; shellName?: string | undefined } = {} + extra: { + cmdOverride?: string + shellName?: string | undefined + platform?: NodeJS.Platform + provesAgentInFront?: boolean + } = {} ) { return planStartupWithPromptCandidate( { agent, cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {}, - platform: 'darwin' + platform: extra.platform ?? 'darwin' }, prompt, - extra.shellName + { + ...(extra.shellName ? { shellName: extra.shellName } : {}), + provesAgentInFront: extra.provesAgentInFront ?? true + } ) } @@ -173,6 +181,24 @@ describe('a multi-line prompt typed into a shell the host names', () => { }) }) +// Why: a host that cannot prove the agent took the terminal could paste into the shell of one that +// exited, so there the line carries the prompt whatever its size, as it did before. +describe('on a host that cannot prove the launched agent is in front', () => { + it.each([ + ['a long single line', 'x'.repeat(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES * 4)], + ['a multi-line prompt', linesOf(5, 40)] + ])('carries %s on the launch line', (_label, prompt) => { + const { plan, promptCarried } = offer('claude', prompt, { + platform: 'win32', + provesAgentInFront: false + }) + expect(promptCarried).toBe(true) + expect(plan?.launchCommand).toContain(prompt.split('\n')[0]) + // Control: the same prompt is pasted where the host can prove the agent. + expect(offer('claude', prompt, { platform: 'win32' }).promptCarried).toBe(false) + }) +}) + describe('the capability clients gate a prompted launch on', () => { it('is advertised by every host that applies the typed-line rule', () => { // An older host folds any prompt into the typed line, so its absence is the gate. diff --git a/src/shared/startup-line-prompt-carry.ts b/src/shared/startup-line-prompt-carry.ts index 65e70e2d11d..e808cb9c74d 100644 --- a/src/shared/startup-line-prompt-carry.ts +++ b/src/shared/startup-line-prompt-carry.ts @@ -1,6 +1,7 @@ /** * Whether a launch prompt rides the command line that gets TYPED into the user's shell, or the agent - * starts clean and the prompt is pasted once it is ready. + * starts clean and the prompt is pasted once it is ready. A paste needs a host that can prove the + * agent holds its terminal (`launched-agent-foreground`); elsewhere the line carries it, as on main. * * Measured on the built line, not the raw prompt: quoting, the launcher, its arguments and session * options all land on that line, and every failure a long or multi-line typed line has is a property @@ -37,11 +38,18 @@ export function startupLineCarriesPrompt(args: { withPrompt: AgentStartupPlan | null /** The shell the line is typed into, when the host can name it before the spawn. */ shellName?: string + /** Whether the host can prove the launched agent holds its terminal before it pastes. */ + hostProvesAgentInFront: boolean }): boolean { const { withPrompt } = args if (!withPrompt || withPrompt.followupPrompt !== null) { return false } + // Where nothing can prove the agent took the terminal, a paste could land in the shell of an + // agent that exited, so the line carries the prompt at any size, as it always did. + if (!args.hostProvesAgentInFront) { + return true + } // Hermes types a fixed line that reads the prompt from the spawn env, so the line never grows // with the text; its own env budget already returned null above when the text did not fit. if (TUI_AGENT_CONFIG[args.agent].promptInjectionMode === 'hermes-query') { @@ -93,7 +101,7 @@ type StartupPlanInputs = Omit< export function planStartupWithPromptCandidate( inputs: StartupPlanInputs, prompt: string, - shellName?: string + host: { shellName?: string; provesAgentInFront: boolean } ): { plan: AgentStartupPlan | null; promptCarried: boolean } { if (prompt.trim()) { const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true }) @@ -101,7 +109,8 @@ export function planStartupWithPromptCandidate( startupLineCarriesPrompt({ agent: inputs.agent, withPrompt, - ...(shellName ? { shellName } : {}) + ...(host.shellName ? { shellName: host.shellName } : {}), + hostProvesAgentInFront: host.provesAgentInFront }) ) { return { plan: withPrompt, promptCarried: true }