diff --git a/src/main/providers/pty-process-inspection.ts b/src/main/providers/pty-process-inspection.ts index 852960c6b7d..9ad7cc49cf5 100644 --- a/src/main/providers/pty-process-inspection.ts +++ b/src/main/providers/pty-process-inspection.ts @@ -1,9 +1,13 @@ import type { IPtyProvider } from './types' +import type { PtyProcessInspectionEvidence } from '../../shared/pty-process-inspection-evidence' export type PtyProcessInspection = { foregroundProcess: string | null hasChildProcesses: boolean unavailable?: true + // Why optional: hosts that predate the evidence contract omit it; readers + // fall back to the legacy fields (see readPtyProcessInspectionEvidence). + processEvidence?: PtyProcessInspectionEvidence } type CompletionSensitivePtyProvider = IPtyProvider & { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 02ea1ba1afc..9b43bfff6b2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -577,6 +577,7 @@ import { type TerminalQuickCommandMutation } from '../../shared/terminal-quick-commands' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { PtyProcessInspectionEvidence } from '../../shared/pty-process-inspection-evidence' import { buildAgentDraftLaunchPlan, buildAgentResumeStartupPlan, @@ -2042,9 +2043,12 @@ type RuntimePtyController = { markReversibleStops?(ptyIds: readonly string[]): () => void getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise - inspectProcess?( - ptyId: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> + inspectProcess?(ptyId: string): Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + unavailable?: true + processEvidence?: PtyProcessInspectionEvidence + }> confirmForegroundProcess?(ptyId: string): Promise confirmShellForeground?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise @@ -22459,9 +22463,12 @@ export class OrcaRuntimeService { return { removed: true } } - async inspectTerminalProcess( - terminalSelector: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> { + async inspectTerminalProcess(terminalSelector: string): Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + unavailable?: true + processEvidence?: PtyProcessInspectionEvidence + }> { const leaf = this.resolveLiveLeafForHandle(terminalSelector) if (!leaf?.ptyId || !this.ptyController) { throw new Error('terminal_gone') diff --git a/src/preload/index.ts b/src/preload/index.ts index d36b385d10a..dc488de7a2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ import type { SkillDeleteResult } from '../shared/skill-delete-contract' import type { AppIdentity } from '../shared/app-identity' +import type { PtyProcessInspectionEvidence } from '../shared/pty-process-inspection-evidence' import type { MacCapturedDigitRowChord } from '../shared/macos-symbolic-hotkeys' import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { @@ -1227,6 +1228,7 @@ const api = { foregroundProcess: string | null hasChildProcesses: boolean unavailable?: true + processEvidence?: PtyProcessInspectionEvidence }> => ipcRenderer.invoke('pty:inspectProcess', { id }), confirmForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), diff --git a/src/relay/pty-handler-attach-replay.test.ts b/src/relay/pty-handler-attach-replay.test.ts index 7de5242f2ae..0a8ac1f3304 100644 --- a/src/relay/pty-handler-attach-replay.test.ts +++ b/src/relay/pty-handler-attach-replay.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import * as ptyShellUtils from './pty-shell-utils' +import * as ptyProcessProbes from './pty-process-probes' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -80,7 +80,7 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) expect(onExitCb).toBeDefined() - const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) + const aliveSpy = vi.spyOn(ptyProcessProbes, 'isProcessAlive').mockReturnValue(false) try { await expect( dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true }) @@ -109,7 +109,7 @@ describe('PtyHandler', () => { await dispatcher.callRequest('pty.spawn', {}) let shutdown: Promise | undefined - const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockImplementation(() => { + const aliveSpy = vi.spyOn(ptyProcessProbes, 'isProcessAlive').mockImplementation(() => { shutdown = dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true }) return false }) @@ -141,7 +141,7 @@ describe('PtyHandler', () => { const spawn = await spawnPty() dataCallback?.('prompt$ ') - const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) + const aliveSpy = vi.spyOn(ptyProcessProbes, 'isProcessAlive').mockReturnValue(true) try { const result = await attachPty({ id: 'pty-1', diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index aae35a2e166..8ef44520039 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import * as ptyShellUtils from './pty-shell-utils' +import * as ptyProcessProbes from './pty-process-probes' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -368,7 +368,7 @@ describe('PtyHandler', () => { handler.onPtyPoolEmpty(poolEmpty) await spawnPty() - const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) + const aliveSpy = vi.spyOn(ptyProcessProbes, 'isProcessAlive').mockReturnValue(false) try { await expect(attachPty({ id: 'pty-1', suppressReplayNotification: true })).rejects.toThrow( 'PTY "pty-1" not found' diff --git a/src/relay/pty-handler-startup-command-delivery.test.ts b/src/relay/pty-handler-startup-command-delivery.test.ts index 8e522e68f59..9ea11237bd7 100644 --- a/src/relay/pty-handler-startup-command-delivery.test.ts +++ b/src/relay/pty-handler-startup-command-delivery.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../shared/setup-agent-sequencing' -import * as ptyShellUtils from './pty-shell-utils' +import * as ptyProcessProbes from './pty-process-probes' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -607,7 +607,7 @@ describe('PtyHandler', () => { } expect(handler.retainedStartupCommandCount).toBe(1) - const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) + const aliveSpy = vi.spyOn(ptyProcessProbes, 'isProcessAlive').mockReturnValue(false) try { await expect(dispatcher.callRequest('pty.attach', { id: 'pty-1' })).rejects.toThrow( 'PTY "pty-1" not found' diff --git a/src/relay/pty-handler-test-harness.ts b/src/relay/pty-handler-test-harness.ts index 04dc828e507..b29e345c989 100644 --- a/src/relay/pty-handler-test-harness.ts +++ b/src/relay/pty-handler-test-harness.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest' import type { Mock } from 'vitest' -import * as ptyShellUtils from './pty-shell-utils' +import * as ptyProcessProbes from './pty-process-probes' import { PtyHandler } from './pty-handler' import type { RelayDispatcher } from './dispatcher' @@ -103,7 +103,8 @@ export function beginPtyHandlerTest(mocks: PtyHandlerTestMocks): { notifyOutput: vi.fn(), dispose: vi.fn() }) - vi.spyOn(ptyShellUtils, 'processHasChildren').mockResolvedValue(false) + vi.spyOn(ptyProcessProbes, 'processHasChildren').mockResolvedValue(false) + vi.spyOn(ptyProcessProbes, 'probeProcessChildren').mockResolvedValue({ verdict: 'exited' }) mockPtySpawn.mockReturnValue({ ...mockPtyInstance }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index a9fd21a4fe2..e1344dac760 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -11,11 +11,19 @@ import { resolveDefaultShell, resolveDefaultCwd, resolveProcessCwd, - processHasChildren, - getForegroundProcessName, - isProcessAlive, listShellProfiles } from './pty-shell-utils' +import { + processHasChildren, + probeProcessChildren, + getForegroundProcessName, + observeForegroundProcess, + isProcessAlive +} from './pty-process-probes' +import { + buildPtyProcessInspectionWireResult, + type PtyProcessInspectionWireResult +} from '../shared/pty-process-inspection-evidence' import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { addWslEnvKeys } from '../shared/wsl-env' import { SHELL_STARTUP_FEATURE_ENV } from '../main/shell-startup-features' @@ -2080,23 +2088,20 @@ export class PtyHandler { return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) } - private async inspectProcess(params: Record): Promise<{ - foregroundProcess: string | null - hasChildProcesses: boolean - }> { + private async inspectProcess( + params: Record + ): Promise { const id = params.id as string const managed = this.ptys.get(id) if (!managed || managed.disposed) { throw new Error('terminal_gone') } - const foregroundProcess = await getForegroundProcessName( - managed.pty.pid, - managed.pty.process || null - ) - return { - foregroundProcess, - hasChildProcesses: await processHasChildren(managed.pty.pid) - } + // Why evidence, not booleans: completion consumers must be able to tell + // "no children observed" from "the probe could not run". The builder keeps + // the legacy fields collapsing exactly as before for pre-evidence clients. + const foreground = await observeForegroundProcess(managed.pty.pid, managed.pty.process || null) + const children = await probeProcessChildren(managed.pty.pid) + return buildPtyProcessInspectionWireResult(foreground, children) } private async listProcesses(): Promise { diff --git a/src/relay/pty-process-probes.test.ts b/src/relay/pty-process-probes.test.ts new file mode 100644 index 00000000000..43ab1844d5f --- /dev/null +++ b/src/relay/pty-process-probes.test.ts @@ -0,0 +1,417 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, execFileSyncMock, getAllProcessesMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + getAllProcessesMock: vi.fn(), + execFileSyncMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: execFileMock, + execFileSync: execFileSyncMock +})) + +import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows' +import { __setWindowsProcessTreeLoaderForTests } from '../main/windows/windows-process-table' +import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot' +import { + getForegroundProcessName, + isProcessAlive, + observeForegroundProcess, + probeProcessChildren, + processHasChildren +} from './pty-process-probes' + +function mockExecFile( + implementation: (command: string, args: string[]) => { stdout: string; stderr?: string } | Error +): void { + execFileMock.mockImplementation( + (command: string, args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + const result = implementation(command, args) + if (result instanceof Error) { + callback(result, { stdout: '', stderr: '' }) + return + } + callback(null, { stdout: result.stdout, stderr: result.stderr ?? '' }) + } + ) +} + +/** + * Feed the native Windows snapshot. A real snapshot always contains the + * querying process, and the reader rejects a table without it. + */ +function mockWindowsProcessTable( + rows: { pid: number; ppid: number; name: string; commandLine?: string }[] +): void { + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses: (cb: (value: typeof rows | undefined) => void) => + cb([{ pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }, ...rows]) + })) + getAllProcessesMock.mockClear() +} + +async function withProcessPlatform( + platform: NodeJS.Platform, + run: () => T | Promise +): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await run() + } finally { + if (descriptor) { + Object.defineProperty(process, 'platform', descriptor) + } + } +} + +beforeEach(() => { + vi.resetModules() + execFileMock.mockReset() + execFileSyncMock.mockReset() + resetProcessTableSnapshotForTests() + resetWindowsProcessRowsSnapshotForTests() + __setWindowsProcessTreeLoaderForTests() +}) + +describe('isProcessAlive', () => { + it('reports the test runner process as alive', () => { + expect(isProcessAlive(process.pid)).toBe(true) + }) + + it('reports a process as dead ONLY on ESRCH', () => { + // Why: attach() reaps a lingering managed PTY based on this, so a false + // positive would kill a live remote shell. Only "no such process" counts. + const spy = vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('no such process') as NodeJS.ErrnoException + err.code = 'ESRCH' + throw err + }) + try { + expect(isProcessAlive(2147483646)).toBe(false) + } finally { + spy.mockRestore() + } + }) + + it('treats an unsignalable process (EPERM) as alive', () => { + const spy = vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('operation not permitted') as NodeJS.ErrnoException + err.code = 'EPERM' + throw err + }) + try { + expect(isProcessAlive(1)).toBe(true) + } finally { + spy.mockRestore() + } + }) +}) + +describe('getForegroundProcessName', () => { + it('returns clear non-wrapper foregrounds without process-table enrichment', async () => { + await expect(getForegroundProcessName(100, 'vim')).resolves.toBe('vim') + + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('recognizes SSH relay node-wrapped agents from descendant command lines', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: ['100 99 Ss bash -l', '101 100 S+ node /home/dev/.local/bin/codex'].join( + '\n' + ) + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'node')).resolves.toBe('codex') + }) + }) + + it('recognizes Windows SSH relay shell-rooted agent descendants', async () => { + await withProcessPlatform('win32', async () => { + mockWindowsProcessTable([ + { pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' }, + { + pid: 101, + ppid: 100, + name: 'node.exe', + commandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd' + } + ]) + + await expect(getForegroundProcessName(100, 'powershell.exe')).resolves.toBe('codex') + }) + }) + + it('recognizes SSH relay wrapped agents when no foreground marker is available', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: [ + '100 99 Ss bash -l', + '101 100 S node /home/dev/.local/bin/node_modules/@google/gemini-cli/bundle/gemini.mjs' + ].join('\n') + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'node')).resolves.toBe('gemini') + }) + }) + + it('does not guess when SSH relay wrapper descendants are ambiguous', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: [ + '100 99 Ss bash -l', + '101 100 S node /home/dev/project/server.js', + '102 100 S node /home/dev/.local/bin/node_modules/@openai/codex/bin/codex.js' + ].join('\n') + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node') + }) + }) + + it('does not report a stopped SSH relay agent when another process has foreground', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: [ + '100 99 Ss bash -l', + '101 100 T node /home/dev/.local/bin/codex', + '102 100 S+ vim notes.txt' + ].join('\n') + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node') + }) + }) + + // Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364). + it('reports the outer omp wrapper over the wrapped pi child from a shell fallback', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n') + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'bash')).resolves.toBe('omp') + }) + }) + + it('rescans for the omp wrapper when node-pty reports the wrapped pi as foreground', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n') + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp') + }) + }) + + it('returns an outer omp fallback without a process-table scan', async () => { + mockExecFile(() => new Error('unexpected process-table scan')) + + await expect(getForegroundProcessName(100, 'omp')).resolves.toBe('omp') + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('rescans a Windows pi fallback for its outer omp wrapper', async () => { + await withProcessPlatform('win32', async () => { + mockWindowsProcessTable([ + { pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' }, + { pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp.exe' }, + { pid: 102, ppid: 101, name: 'pi.exe', commandLine: 'pi.exe' } + ]) + + await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp') + }) + }) + + it('keeps a pi fallback as pi when no omp wrapper is in the tree', async () => { + await withProcessPlatform('linux', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { stdout: ['100 99 Ss bash -l', '101 100 S+ pi'].join('\n') } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('pi') + }) + }) + + it('falls back to the root process command when descendant inspection fails', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return new Error('ps table unavailable') + } + return { stdout: 'bash\n' } + }) + + await expect(getForegroundProcessName(100)).resolves.toBe('bash') + }) +}) + +function subprocessExitError( + message: string, + overrides: { code?: number | string; killed?: boolean; signal?: string | null } = {} +): Error { + const error = new Error(message) as Error & { + code?: number | string + killed?: boolean + signal?: string | null + } + error.code = overrides.code + error.killed = overrides.killed ?? false + error.signal = overrides.signal ?? null + return error +} + +describe('probeProcessChildren', () => { + it('reports live children from pgrep output', async () => { + mockExecFile(() => ({ stdout: '4242\n' })) + + await expect(probeProcessChildren(100)).resolves.toEqual({ verdict: 'live' }) + }) + + it('treats pgrep exit 1 as positive absence', async () => { + // pgrep exits 1 with no output when it ran and matched nothing. + mockExecFile(() => subprocessExitError('no match', { code: 1 })) + + await expect(probeProcessChildren(100)).resolves.toEqual({ verdict: 'exited' }) + }) + + it('keeps a missing pgrep binary unverifiable, never exited', async () => { + mockExecFile(() => subprocessExitError('spawn pgrep ENOENT', { code: 'ENOENT' })) + + await expect(probeProcessChildren(100)).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) + + it('keeps a timed-out pgrep unverifiable, never exited', async () => { + mockExecFile(() => subprocessExitError('killed', { killed: true, signal: 'SIGTERM' })) + + await expect(probeProcessChildren(100)).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) + + it('keeps a pgrep fatal error (exit 3) unverifiable', async () => { + mockExecFile(() => subprocessExitError('fatal', { code: 3 })) + + await expect(probeProcessChildren(100)).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) + + it('answers live from the Windows process table', async () => { + await withProcessPlatform('win32', async () => { + mockWindowsProcessTable([ + { pid: 100, ppid: 99, name: 'powershell.exe' }, + { pid: 101, ppid: 100, name: 'codex.exe' } + ]) + + await expect(probeProcessChildren(100)).resolves.toEqual({ verdict: 'live' }) + }) + }) + + it('answers exited when the Windows table observes a childless root', async () => { + await withProcessPlatform('win32', async () => { + mockWindowsProcessTable([{ pid: 100, ppid: 99, name: 'powershell.exe' }]) + + await expect(probeProcessChildren(100)).resolves.toEqual({ verdict: 'exited' }) + }) + }) + + it('keeps a Windows table that misses the PTY root unverifiable', async () => { + await withProcessPlatform('win32', async () => { + mockWindowsProcessTable([]) + + await expect(probeProcessChildren(100)).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) + }) + + it('collapses every probe failure to false for the legacy boolean', async () => { + mockExecFile(() => subprocessExitError('spawn pgrep ENOENT', { code: 'ENOENT' })) + + await expect(processHasChildren(100)).resolves.toBe(false) + }) +}) + +describe('observeForegroundProcess', () => { + it('reports a successful ps read as observed', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { stdout: '' } + } + return { stdout: 'vim\n' } + }) + + await expect(observeForegroundProcess(100)).resolves.toEqual({ + verdict: 'observed', + processName: 'vim' + }) + }) + + it('reports ps exit 1 as an observed absence of the process', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return new Error('ps table unavailable') + } + return subprocessExitError('no such pid', { code: 1 }) + }) + + await expect(observeForegroundProcess(100)).resolves.toEqual({ + verdict: 'observed', + processName: null + }) + }) + + it('keeps a timed-out ps read unverifiable', async () => { + mockExecFile(() => subprocessExitError('killed', { killed: true, signal: 'SIGTERM' })) + + await expect(observeForegroundProcess(100)).resolves.toMatchObject({ + verdict: 'unverifiable' + }) + }) + + it('keeps the node-pty fallback observed when descendant enrichment fails', async () => { + mockExecFile(() => subprocessExitError('killed', { killed: true, signal: 'SIGTERM' })) + + await expect(observeForegroundProcess(100, 'node')).resolves.toEqual({ + verdict: 'observed', + processName: 'node' + }) + }) + + it('collapses an unverifiable observation to null for the legacy reader', async () => { + mockExecFile(() => subprocessExitError('killed', { killed: true, signal: 'SIGTERM' })) + + await expect(getForegroundProcessName(100)).resolves.toBeNull() + }) +}) diff --git a/src/relay/pty-process-probes.ts b/src/relay/pty-process-probes.ts new file mode 100644 index 00000000000..5d15ea3cd24 --- /dev/null +++ b/src/relay/pty-process-probes.ts @@ -0,0 +1,280 @@ +import { execFile as execFileCb } from 'node:child_process' +import { promisify } from 'node:util' +import { + isAgentForegroundWrapperProcess, + isExpectedAgentProcess, + recognizeAgentProcess, + recognizeAgentProcessFromCommandLine +} from '../shared/agent-process-recognition' +import { getFirstCommandToken } from '../shared/command-token-scanner' +import { getProcessTableSnapshot, type ProcessTableRow } from '../shared/process-table-snapshot' +import { + resolveOuterWrapperForegroundProcess, + shouldInspectOuterWrapperForegroundProcess +} from '../shared/foreground-wrapper-agent' +import { isShellProcess } from '../shared/shell-process-detection' +import type { + PtyChildProcessesEvidence, + PtyForegroundProcessEvidence +} from '../shared/pty-process-inspection-evidence' +import { + resolveWindowsAgentForegroundProcess, + shouldInspectWindowsAgentForeground +} from '../main/providers/windows-agent-foreground-process' +import { queryWindowsPaneProcessInventory } from '../main/providers/windows-foreground-process-rows' + +const execFile = promisify(execFileCb) + +/** + * Check whether a process has child processes (via pgrep). + * + * Legacy collapse kept for the standalone `pty.hasChildProcesses` RPC: every + * probe failure reads as `false`. Completion-sensitive callers must use + * `probeProcessChildren`, whose verdict keeps "could not ask" distinct. + */ +export async function processHasChildren(pid: number): Promise { + try { + const { stdout } = await execFile('pgrep', ['-P', String(pid)], { + encoding: 'utf-8', + timeout: 3000 + }) + return stdout.trim().length > 0 + } catch { + return false + } +} + +// Why not NodeJS.ErrnoException: execFile stamps the numeric exit status into +// `code`, which the lib types declare as string-only. +type SubprocessProbeError = { + code?: number | string + killed?: boolean + signal?: NodeJS.Signals | null + message?: string +} + +// Exit code 1 without a kill signal: pgrep/ps RAN and matched nothing — that +// is positive absence, not a failed probe. +function subprocessRanAndMatchedNothing(error: unknown): boolean { + const failure = error as SubprocessProbeError + return failure?.code === 1 && failure.killed !== true && !failure.signal +} + +function describeProcessProbeFailure(command: string, error: unknown): string { + const failure = error as SubprocessProbeError + if (failure?.killed === true || failure?.signal) { + return `${command} did not answer before its deadline` + } + return `${command} could not run: ${failure?.code ?? failure?.message ?? 'unknown failure'}` +} + +/** + * Probe whether the PTY's root process has live children, keeping the + * live / unverifiable / exited contract: a probe that could not run (pgrep + * missing, fork pressure, timeout) is `unverifiable`, never `exited`. + */ +export async function probeProcessChildren(pid: number): Promise { + if (process.platform === 'win32') { + // pgrep does not exist on Windows hosts; ask the native process table the + // relay already reads for foreground resolution (TTL-cached snapshot). + const inventory = await queryWindowsPaneProcessInventory(pid) + if (!inventory) { + return { + verdict: 'unverifiable', + reason: 'the Windows process table could not observe the PTY root' + } + } + return inventory.candidates.length > 0 ? { verdict: 'live' } : { verdict: 'exited' } + } + try { + const { stdout } = await execFile('pgrep', ['-P', String(pid)], { + encoding: 'utf-8', + timeout: 3000 + }) + return stdout.trim().length > 0 ? { verdict: 'live' } : { verdict: 'exited' } + } catch (error) { + if (subprocessRanAndMatchedNothing(error)) { + return { verdict: 'exited' } + } + return { verdict: 'unverifiable', reason: describeProcessProbeFailure('pgrep', error) } + } +} + +// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no +// such process") proves the pid is gone; EPERM means it exists but is +// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so +// a liveness check can only ever declare a *provably* dead process dead. +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (err) { + return (err as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +function collectDescendants( + rows: ProcessTableRow[], + rootPid: number +): (ProcessTableRow & { depth: number })[] { + const childrenByParent = new Map() + for (const row of rows) { + const children = childrenByParent.get(row.ppid) ?? [] + children.push(row) + childrenByParent.set(row.ppid, children) + } + + const descendants: (ProcessTableRow & { depth: number })[] = [] + const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) + while (stack.length > 0) { + const { row, depth } = stack.pop()! + descendants.push({ ...row, depth }) + for (const child of childrenByParent.get(row.pid) ?? []) { + stack.push({ row: child, depth: depth + 1 }) + } + } + return descendants +} + +function candidateScore(row: ProcessTableRow & { depth: number }): number { + return (row.stat.includes('+') ? 10_000 : 0) + row.depth +} + +function processCommandToken(command: string): string { + return getFirstCommandToken(command) +} + +function candidateMatchesFallbackWrapper( + candidate: ProcessTableRow, + fallbackProcess: string +): boolean { + return isExpectedAgentProcess(processCommandToken(candidate.command), fallbackProcess) +} + +async function getRecognizedForegroundDescendant( + pid: number, + fallbackProcess?: string | null +): Promise { + try { + const rows = await getProcessTableSnapshot() + const root = rows.find((row) => row.pid === pid) + const candidates = collectDescendants(rows, pid).sort( + (a, b) => candidateScore(b) - candidateScore(a) + ) + // Why: SSH relays do not have the daemon's async wrapper cache. Inspect the + // remote process tree so node/python agent entrypoints become real agents. + const foregroundIsKnown = + root?.stat.includes('+') === true || + candidates.some((candidate) => candidate.stat.includes('+')) + const foregroundCandidates = foregroundIsKnown + ? candidates.filter((candidate) => candidate.stat.includes('+')) + : candidates + const inspectionCandidates = + fallbackProcess && isAgentForegroundWrapperProcess(fallbackProcess) + ? foregroundCandidates.filter((candidate) => + candidateMatchesFallbackWrapper(candidate, fallbackProcess) + ) + : foregroundCandidates + if ( + fallbackProcess && + isAgentForegroundWrapperProcess(fallbackProcess) && + inspectionCandidates.length !== 1 + ) { + return null + } + for (const candidate of inspectionCandidates) { + const recognized = recognizeAgentProcessFromCommandLine(candidate.command) + if (recognized) { + // Why: return the outer wrapper (omp) rather than the deeper wrapped child + // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. + return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates) + } + } + } catch { + // Fall through to node-pty's process name or the root command name. + } + return null +} + +/** + * Get the foreground process name of a given pid (via ps). + * + * Legacy collapse kept for the standalone `pty.getForegroundProcess` RPC and + * pane titles: an unverifiable observation reads as `null`. Completion- + * sensitive callers must use `observeForegroundProcess`. + */ +export async function getForegroundProcessName( + pid: number, + fallbackProcess?: string | null +): Promise { + const evidence = await observeForegroundProcess(pid, fallbackProcess) + return evidence.verdict === 'observed' ? evidence.processName : null +} + +function observedForeground(processName: string | null): PtyForegroundProcessEvidence { + return { verdict: 'observed', processName } +} + +/** + * Observe the foreground process, keeping "could not ask" distinct from + * "observed nothing". Fallback names come from node-pty's own record of the + * terminal's foreground process — a genuine observation even when descendant + * enrichment fails. Only the last-resort `ps` read can be unverifiable: exit 1 + * proves the pid is gone (`observed` + null), while a probe that could not run + * proves nothing. + */ +export async function observeForegroundProcess( + pid: number, + fallbackProcess?: string | null +): Promise { + if (fallbackProcess) { + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + if (fallbackRecognition) { + // Why: node-pty can report OMP's wrapped Pi; enrich only that ambiguous + // fallback so authoritative OMP reads keep the zero-subprocess fast path. + if (shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) { + if (process.platform === 'win32') { + return observedForeground( + (await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? + fallbackRecognition.processName + ) + } + return observedForeground( + (await getRecognizedForegroundDescendant(pid, fallbackProcess)) ?? + fallbackRecognition.processName + ) + } + return observedForeground(fallbackRecognition.processName) + } + if (process.platform === 'win32') { + if (!shouldInspectWindowsAgentForeground(fallbackProcess)) { + return observedForeground(fallbackProcess) + } + return observedForeground( + (await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? fallbackProcess + ) + } + if (!isShellProcess(fallbackProcess) && !isAgentForegroundWrapperProcess(fallbackProcess)) { + return observedForeground(fallbackProcess) + } + } + const recognized = await getRecognizedForegroundDescendant(pid, fallbackProcess) + if (recognized) { + return observedForeground(recognized) + } + if (fallbackProcess) { + return observedForeground(fallbackProcess) + } + try { + const { stdout } = await execFile('ps', ['-o', 'comm=', '-p', String(pid)], { + encoding: 'utf-8', + timeout: 3000 + }) + return observedForeground(stdout.trim() || null) + } catch (error) { + if (subprocessRanAndMatchedNothing(error)) { + return observedForeground(null) + } + return { verdict: 'unverifiable', reason: describeProcessProbeFailure('ps', error) } + } +} diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts index ac913e689f0..340054690bc 100644 --- a/src/relay/pty-shell-utils.test.ts +++ b/src/relay/pty-shell-utils.test.ts @@ -1,8 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { execFileMock, execFileSyncMock, getAllProcessesMock } = vi.hoisted(() => ({ +const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), - getAllProcessesMock: vi.fn(), execFileSyncMock: vi.fn() })) @@ -14,58 +13,7 @@ vi.mock('child_process', () => ({ import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows' import { __setWindowsProcessTreeLoaderForTests } from '../main/windows/windows-process-table' import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot' -import { - getForegroundProcessName, - isProcessAlive, - resolveDefaultCwd, - resolveWindowsDefaultShell -} from './pty-shell-utils' - -function mockExecFile( - implementation: (command: string, args: string[]) => { stdout: string; stderr?: string } | Error -): void { - execFileMock.mockImplementation( - (command: string, args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - const result = implementation(command, args) - if (result instanceof Error) { - callback(result, { stdout: '', stderr: '' }) - return - } - callback(null, { stdout: result.stdout, stderr: result.stderr ?? '' }) - } - ) -} - -/** - * Feed the native Windows snapshot. A real snapshot always contains the - * querying process, and the reader rejects a table without it. - */ -function mockWindowsProcessTable( - rows: { pid: number; ppid: number; name: string; commandLine?: string }[] -): void { - __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, - getAllProcesses: (cb: (value: typeof rows | undefined) => void) => - cb([{ pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }, ...rows]) - })) - getAllProcessesMock.mockClear() -} - -async function withProcessPlatform( - platform: NodeJS.Platform, - run: () => T | Promise -): Promise { - const descriptor = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) - try { - return await run() - } finally { - if (descriptor) { - Object.defineProperty(process, 'platform', descriptor) - } - } -} +import { resolveDefaultCwd, resolveWindowsDefaultShell } from './pty-shell-utils' beforeEach(() => { vi.resetModules() @@ -76,40 +24,6 @@ beforeEach(() => { __setWindowsProcessTreeLoaderForTests() }) -describe('isProcessAlive', () => { - it('reports the test runner process as alive', () => { - expect(isProcessAlive(process.pid)).toBe(true) - }) - - it('reports a process as dead ONLY on ESRCH', () => { - // Why: attach() reaps a lingering managed PTY based on this, so a false - // positive would kill a live remote shell. Only "no such process" counts. - const spy = vi.spyOn(process, 'kill').mockImplementation(() => { - const err = new Error('no such process') as NodeJS.ErrnoException - err.code = 'ESRCH' - throw err - }) - try { - expect(isProcessAlive(2147483646)).toBe(false) - } finally { - spy.mockRestore() - } - }) - - it('treats an unsignalable process (EPERM) as alive', () => { - const spy = vi.spyOn(process, 'kill').mockImplementation(() => { - const err = new Error('operation not permitted') as NodeJS.ErrnoException - err.code = 'EPERM' - throw err - }) - try { - expect(isProcessAlive(1)).toBe(true) - } finally { - spy.mockRestore() - } - }) -}) - describe('resolveWindowsDefaultShell', () => { it('uses an existing SHELL override when one is provided', () => { expect( @@ -297,174 +211,3 @@ describe('resolveDefaultCwd', () => { expect(resolveDefaultCwd({ HOME: '/home/alice' }, 'linux', '/fallback')).toBe('/home/alice') }) }) - -describe('getForegroundProcessName', () => { - it('returns clear non-wrapper foregrounds without process-table enrichment', async () => { - await expect(getForegroundProcessName(100, 'vim')).resolves.toBe('vim') - - expect(execFileMock).not.toHaveBeenCalled() - }) - - it('recognizes SSH relay node-wrapped agents from descendant command lines', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: ['100 99 Ss bash -l', '101 100 S+ node /home/dev/.local/bin/codex'].join( - '\n' - ) - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'node')).resolves.toBe('codex') - }) - }) - - it('recognizes Windows SSH relay shell-rooted agent descendants', async () => { - await withProcessPlatform('win32', async () => { - mockWindowsProcessTable([ - { pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' }, - { - pid: 101, - ppid: 100, - name: 'node.exe', - commandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd' - } - ]) - - await expect(getForegroundProcessName(100, 'powershell.exe')).resolves.toBe('codex') - }) - }) - - it('recognizes SSH relay wrapped agents when no foreground marker is available', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: [ - '100 99 Ss bash -l', - '101 100 S node /home/dev/.local/bin/node_modules/@google/gemini-cli/bundle/gemini.mjs' - ].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'node')).resolves.toBe('gemini') - }) - }) - - it('does not guess when SSH relay wrapper descendants are ambiguous', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: [ - '100 99 Ss bash -l', - '101 100 S node /home/dev/project/server.js', - '102 100 S node /home/dev/.local/bin/node_modules/@openai/codex/bin/codex.js' - ].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node') - }) - }) - - it('does not report a stopped SSH relay agent when another process has foreground', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: [ - '100 99 Ss bash -l', - '101 100 T node /home/dev/.local/bin/codex', - '102 100 S+ vim notes.txt' - ].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'node')).resolves.toBe('node') - }) - }) - - // Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364). - it('reports the outer omp wrapper over the wrapped pi child from a shell fallback', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'bash')).resolves.toBe('omp') - }) - }) - - it('rescans for the omp wrapper when node-pty reports the wrapped pi as foreground', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { - stdout: ['100 99 Ss bash -l', '101 100 S+ omp', '102 101 S+ pi'].join('\n') - } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp') - }) - }) - - it('returns an outer omp fallback without a process-table scan', async () => { - mockExecFile(() => new Error('unexpected process-table scan')) - - await expect(getForegroundProcessName(100, 'omp')).resolves.toBe('omp') - expect(execFileMock).not.toHaveBeenCalled() - }) - - it('rescans a Windows pi fallback for its outer omp wrapper', async () => { - await withProcessPlatform('win32', async () => { - mockWindowsProcessTable([ - { pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' }, - { pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp.exe' }, - { pid: 102, ppid: 101, name: 'pi.exe', commandLine: 'pi.exe' } - ]) - - await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('omp') - }) - }) - - it('keeps a pi fallback as pi when no omp wrapper is in the tree', async () => { - await withProcessPlatform('linux', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return { stdout: ['100 99 Ss bash -l', '101 100 S+ pi'].join('\n') } - } - return new Error('unexpected command') - }) - - await expect(getForegroundProcessName(100, 'pi')).resolves.toBe('pi') - }) - }) - - it('falls back to the root process command when descendant inspection fails', async () => { - mockExecFile((_command, args) => { - if (args[0] === '-axo') { - return new Error('ps table unavailable') - } - return { stdout: 'bash\n' } - }) - - await expect(getForegroundProcessName(100)).resolves.toBe('bash') - }) -}) diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index 9860917c588..9e063780b29 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -3,24 +3,6 @@ import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { win32 as pathWin32 } from 'node:path' import { promisify } from 'node:util' -import { - isAgentForegroundWrapperProcess, - isExpectedAgentProcess, - recognizeAgentProcess, - recognizeAgentProcessFromCommandLine -} from '../shared/agent-process-recognition' -import { getFirstCommandToken } from '../shared/command-token-scanner' -import { getProcessTableSnapshot, type ProcessTableRow } from '../shared/process-table-snapshot' -import { - resolveOuterWrapperForegroundProcess, - shouldInspectOuterWrapperForegroundProcess -} from '../shared/foreground-wrapper-agent' -import { isShellProcess } from '../shared/shell-process-detection' -import { - resolveWindowsAgentForegroundProcess, - shouldInspectWindowsAgentForeground -} from '../main/providers/windows-agent-foreground-process' - const execFile = promisify(execFileCb) const OPENSSH_REGISTRY_KEY = 'HKLM\\SOFTWARE\\OpenSSH' @@ -165,173 +147,6 @@ export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promi return fallbackCwd } -/** - * Check whether a process has child processes (via pgrep). - */ -export async function processHasChildren(pid: number): Promise { - try { - const { stdout } = await execFile('pgrep', ['-P', String(pid)], { - encoding: 'utf-8', - timeout: 3000 - }) - return stdout.trim().length > 0 - } catch { - return false - } -} - -// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no -// such process") proves the pid is gone; EPERM means it exists but is -// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so -// a liveness check can only ever declare a *provably* dead process dead. -export function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (err) { - return (err as NodeJS.ErrnoException).code !== 'ESRCH' - } -} - -function collectDescendants( - rows: ProcessTableRow[], - rootPid: number -): (ProcessTableRow & { depth: number })[] { - const childrenByParent = new Map() - for (const row of rows) { - const children = childrenByParent.get(row.ppid) ?? [] - children.push(row) - childrenByParent.set(row.ppid, children) - } - - const descendants: (ProcessTableRow & { depth: number })[] = [] - const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) - while (stack.length > 0) { - const { row, depth } = stack.pop()! - descendants.push({ ...row, depth }) - for (const child of childrenByParent.get(row.pid) ?? []) { - stack.push({ row: child, depth: depth + 1 }) - } - } - return descendants -} - -function candidateScore(row: ProcessTableRow & { depth: number }): number { - return (row.stat.includes('+') ? 10_000 : 0) + row.depth -} - -function processCommandToken(command: string): string { - return getFirstCommandToken(command) -} - -function candidateMatchesFallbackWrapper( - candidate: ProcessTableRow, - fallbackProcess: string -): boolean { - return isExpectedAgentProcess(processCommandToken(candidate.command), fallbackProcess) -} - -async function getRecognizedForegroundDescendant( - pid: number, - fallbackProcess?: string | null -): Promise { - try { - const rows = await getProcessTableSnapshot() - const root = rows.find((row) => row.pid === pid) - const candidates = collectDescendants(rows, pid).sort( - (a, b) => candidateScore(b) - candidateScore(a) - ) - // Why: SSH relays do not have the daemon's async wrapper cache. Inspect the - // remote process tree so node/python agent entrypoints become real agents. - const foregroundIsKnown = - root?.stat.includes('+') === true || - candidates.some((candidate) => candidate.stat.includes('+')) - const foregroundCandidates = foregroundIsKnown - ? candidates.filter((candidate) => candidate.stat.includes('+')) - : candidates - const inspectionCandidates = - fallbackProcess && isAgentForegroundWrapperProcess(fallbackProcess) - ? foregroundCandidates.filter((candidate) => - candidateMatchesFallbackWrapper(candidate, fallbackProcess) - ) - : foregroundCandidates - if ( - fallbackProcess && - isAgentForegroundWrapperProcess(fallbackProcess) && - inspectionCandidates.length !== 1 - ) { - return null - } - for (const candidate of inspectionCandidates) { - const recognized = recognizeAgentProcessFromCommandLine(candidate.command) - if (recognized) { - // Why: return the outer wrapper (omp) rather than the deeper wrapped child - // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. - return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates) - } - } - } catch { - // Fall through to node-pty's process name or the root command name. - } - return null -} - -/** - * Get the foreground process name of a given pid (via ps). - */ -export async function getForegroundProcessName( - pid: number, - fallbackProcess?: string | null -): Promise { - if (fallbackProcess) { - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - if (fallbackRecognition) { - // Why: node-pty can report OMP's wrapped Pi; enrich only that ambiguous - // fallback so authoritative OMP reads keep the zero-subprocess fast path. - if (shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) { - if (process.platform === 'win32') { - return ( - (await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? - fallbackRecognition.processName - ) - } - return ( - (await getRecognizedForegroundDescendant(pid, fallbackProcess)) ?? - fallbackRecognition.processName - ) - } - return fallbackRecognition.processName - } - if (process.platform === 'win32') { - if (!shouldInspectWindowsAgentForeground(fallbackProcess)) { - return fallbackProcess - } - return ( - (await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? fallbackProcess - ) - } - if (!isShellProcess(fallbackProcess) && !isAgentForegroundWrapperProcess(fallbackProcess)) { - return fallbackProcess - } - } - const recognized = await getRecognizedForegroundDescendant(pid, fallbackProcess) - if (recognized) { - return recognized - } - if (fallbackProcess) { - return fallbackProcess - } - try { - const { stdout } = await execFile('ps', ['-o', 'comm=', '-p', String(pid)], { - encoding: 'utf-8', - timeout: 3000 - }) - return stdout.trim() || null - } catch { - return null - } -} - /** * List available shell profiles from /etc/shells (or known fallbacks). */ diff --git a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts index 39ca68e1ad4..a97a440321c 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts @@ -5,6 +5,7 @@ import { import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition' import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' +import { readPtyProcessInspectionEvidence } from '../../../../shared/pty-process-inspection-evidence' import { NO_EVIDENCE_ACTIVITY_HOT_WINDOW_MS, POLL_TIER_INTERVAL_MS, @@ -74,18 +75,33 @@ export function createAgentCompletionProcessMonitor({ scheduleNextPoll() return false } - state.consecutiveInspectionErrors = 0 - const recognized = recognizeAgentProcess(result.foregroundProcess) - if (recognized) { - handleRecognizedProcess(recognized) - return true + const evidence = readPtyProcessInspectionEvidence(result) + if (evidence.foreground.verdict === 'observed') { + const recognized = recognizeAgentProcess(evidence.foreground.processName) + if (recognized) { + state.consecutiveInspectionErrors = 0 + handleRecognizedProcess(recognized) + return true + } } + if ( + evidence.foreground.verdict === 'unverifiable' || + evidence.children.verdict === 'unverifiable' + ) { + // The host could not ask; that is never exit evidence. Re-arm the + // two-sample confirmation like any other failed inspection. + state.pendingProcessExitAgent = null + state.consecutiveInspectionErrors += 1 + scheduleNextPoll() + return false + } + state.consecutiveInspectionErrors = 0 if (hasPendingHookDone() || hasPendingCodexAttention()) { scheduleNextPoll() return false } if (state.lastForegroundAgent && state.hasAgentRunEvidence) { - if (result.hasChildProcesses) { + if (evidence.children.verdict === 'live') { state.pendingProcessExitAgent = null scheduleNextPoll() return false diff --git a/src/renderer/src/components/terminal-pane/agent-completion-unverifiable-evidence.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-unverifiable-evidence.test.ts new file mode 100644 index 00000000000..220ba3dcfef --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-unverifiable-evidence.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { + processResult, + useAgentCompletionCoordinatorLifecycle +} from './agent-completion-coordinator-test-harness' +import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' + +function unverifiableChildren(foregroundProcess: string | null): RuntimeTerminalProcessInspection { + return { + foregroundProcess, + hasChildProcesses: false, + processEvidence: { + foreground: { verdict: 'observed', processName: foregroundProcess }, + children: { verdict: 'unverifiable', reason: 'pgrep did not answer before its deadline' } + } + } +} + +function unverifiableForeground(): RuntimeTerminalProcessInspection { + return { + foregroundProcess: null, + hasChildProcesses: false, + processEvidence: { + foreground: { verdict: 'unverifiable', reason: 'ps did not answer before its deadline' }, + children: { verdict: 'exited' } + } + } +} + +function confirmedExit(foregroundProcess: string | null): RuntimeTerminalProcessInspection { + return { + foregroundProcess, + hasChildProcesses: false, + processEvidence: { + foreground: { verdict: 'observed', processName: foregroundProcess }, + children: { verdict: 'exited' } + } + } +} + +describe('agent completion with inspection evidence', () => { + useAgentCompletionCoordinatorLifecycle() + + function startCoordinator(results: () => RuntimeTerminalProcessInspection) { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => results()), + dispatchCompletion, + isLive: () => true + }) + coordinator.startProcessTracking() + return { coordinator, dispatchCompletion } + } + + it('never turns unverifiable child evidence into a process-exit completion', async () => { + let result: RuntimeTerminalProcessInspection = processResult('codex') + const { dispatchCompletion } = startCoordinator(() => result) + + await vi.advanceTimersByTimeAsync(2_000) + + // The host stops being able to answer pgrep; codex may still be running. + result = unverifiableChildren('zsh') + await vi.advanceTimersByTimeAsync(60_000) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('never turns unverifiable foreground evidence into a process-exit completion', async () => { + let result: RuntimeTerminalProcessInspection = processResult('codex') + const { dispatchCompletion } = startCoordinator(() => result) + + await vi.advanceTimersByTimeAsync(2_000) + + result = unverifiableForeground() + await vi.advanceTimersByTimeAsync(60_000) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('keeps tracking a recognized agent whose child probe is unverifiable', async () => { + let result: RuntimeTerminalProcessInspection = { + foregroundProcess: 'codex', + hasChildProcesses: false, + processEvidence: { + foreground: { verdict: 'observed', processName: 'codex' }, + children: { verdict: 'unverifiable', reason: 'pgrep could not run: ENOENT' } + } + } + const { dispatchCompletion } = startCoordinator(() => result) + + await vi.advanceTimersByTimeAsync(4_000) + expect(dispatchCompletion).not.toHaveBeenCalled() + + // Once the probes answer and positively observe the exit, the completion + // still fires — recognition was not lost to the unverifiable child probe. + result = confirmedExit('zsh') + await vi.advanceTimersByTimeAsync(4_000) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex', { + source: 'process-exit', + quietedHookDone: false, + terminalIdleConfirmed: true + }) + }) + + it('treats malformed evidence from a foreign host as unverifiable', async () => { + let result: RuntimeTerminalProcessInspection = processResult('codex') + const { dispatchCompletion } = startCoordinator(() => result) + + await vi.advanceTimersByTimeAsync(2_000) + + result = { + foregroundProcess: null, + hasChildProcesses: false, + processEvidence: { + foreground: { verdict: 'observed', processName: null }, + children: { verdict: 'someday-new-verdict' } as never + } + } + await vi.advanceTimersByTimeAsync(60_000) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + + it('still completes from legacy fields when the host predates evidence', async () => { + // An old host publishes no processEvidence; its legacy answer remains the + // only available reading and completion detection must keep working. + let result: RuntimeTerminalProcessInspection = processResult('codex') + const { dispatchCompletion } = startCoordinator(() => result) + + await vi.advanceTimersByTimeAsync(2_000) + + result = processResult(null, false) + await vi.advanceTimersByTimeAsync(2_000) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex', { + source: 'process-exit', + quietedHookDone: false, + terminalIdleConfirmed: true + }) + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index 29be1aed435..de9a464f60c 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PtyProcessInspectionEvidence } from '../../../shared/pty-process-inspection-evidence' import type { RuntimeTerminalSend } from '../../../shared/runtime-types' import { makePaneKey } from '../../../shared/stable-pane-id' import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input' @@ -14,6 +15,9 @@ export type RuntimeTerminalProcessInspection = { hasChildProcesses: boolean // Why: callers must not treat a stale remote handle as authoritative idle evidence. unavailable?: true + // Why: relay hosts publish per-probe live/unverifiable/exited evidence so a + // failed host probe cannot be read as exit evidence; absent on older hosts. + processEvidence?: PtyProcessInspectionEvidence } const REMOTE_PTY_ID_PREFIX = 'remote:' diff --git a/src/shared/pty-process-inspection-evidence.ts b/src/shared/pty-process-inspection-evidence.ts new file mode 100644 index 00000000000..e501738aa85 --- /dev/null +++ b/src/shared/pty-process-inspection-evidence.ts @@ -0,0 +1,103 @@ +/** + * Evidence contract for PTY agent-process inspection. + * + * The completion monitor concludes "the agent finished" from two observations: + * the foreground process is no longer a recognized agent, and the shell has no + * child processes. Both probes run on the execution host, and on a relay host + * "could not ask" is a normal steady state — probes time out under load, and + * minimal hosts lack `pgrep`. Per docs/reference/ssh-execution-boundary.md the + * vocabulary is `live` / `unverifiable` / `exited`: a probe that failed to run + * is `unverifiable` and must never be read as exit evidence; only a probe that + * ran and positively observed absence may say `exited`. + * + * Wire compatibility (docs/reference/remote-wire-compatibility.md): the + * evidence rides in a NEW OPTIONAL FIELD (`processEvidence`) beside the legacy + * `foregroundProcess` / `hasChildProcesses` fields, whose published values keep + * the exact legacy collapse. An old client ignores the field and sees identical + * content; a new client against an old host reads the legacy fields as the only + * available answer. + */ + +export type PtyForegroundProcessEvidence = + | { verdict: 'observed'; processName: string | null } + | { verdict: 'unverifiable'; reason: string } + +export type PtyChildProcessesEvidence = + | { verdict: 'live' } + | { verdict: 'exited' } + | { verdict: 'unverifiable'; reason: string } + +export type PtyProcessInspectionEvidence = { + foreground: PtyForegroundProcessEvidence + children: PtyChildProcessesEvidence +} + +export type PtyProcessInspectionWireResult = { + foregroundProcess: string | null + hasChildProcesses: boolean + processEvidence: PtyProcessInspectionEvidence +} + +/** + * Collapse probe evidence into the wire result. The legacy fields reproduce the + * pre-evidence behavior exactly (`unverifiable` collapses to `null` / `false`) + * so clients that predate `processEvidence` observe unchanged host content. + */ +export function buildPtyProcessInspectionWireResult( + foreground: PtyForegroundProcessEvidence, + children: PtyChildProcessesEvidence +): PtyProcessInspectionWireResult { + return { + foregroundProcess: foreground.verdict === 'observed' ? foreground.processName : null, + hasChildProcesses: children.verdict === 'live', + processEvidence: { foreground, children } + } +} + +/** + * Read the evidence off a wire result, tolerating peers this client cannot + * vouch for. A host that predates the field gets the legacy interpretation — + * its published values are the only answer it can give. A malformed shape from + * a foreign host reads as `unverifiable`, never as an observation. + */ +export function readPtyProcessInspectionEvidence(result: { + foregroundProcess: string | null + hasChildProcesses: boolean + processEvidence?: PtyProcessInspectionEvidence +}): PtyProcessInspectionEvidence { + const evidence = result.processEvidence + if (evidence === undefined) { + return { + foreground: { verdict: 'observed', processName: result.foregroundProcess }, + children: result.hasChildProcesses ? { verdict: 'live' } : { verdict: 'exited' } + } + } + return { + foreground: normalizeForegroundEvidence(evidence?.foreground), + children: normalizeChildrenEvidence(evidence?.children) + } +} + +function normalizeForegroundEvidence( + evidence: PtyForegroundProcessEvidence | undefined +): PtyForegroundProcessEvidence { + if (evidence?.verdict === 'observed') { + return { verdict: 'observed', processName: evidence.processName ?? null } + } + if (evidence?.verdict === 'unverifiable') { + return { verdict: 'unverifiable', reason: evidence.reason ?? 'unspecified' } + } + return { verdict: 'unverifiable', reason: 'malformed foreground inspection evidence' } +} + +function normalizeChildrenEvidence( + evidence: PtyChildProcessesEvidence | undefined +): PtyChildProcessesEvidence { + if (evidence?.verdict === 'live' || evidence?.verdict === 'exited') { + return { verdict: evidence.verdict } + } + if (evidence?.verdict === 'unverifiable') { + return { verdict: 'unverifiable', reason: evidence.reason ?? 'unspecified' } + } + return { verdict: 'unverifiable', reason: 'malformed child-process inspection evidence' } +} diff --git a/tests/e2e/relay-completion-evidence/relay-inspection-unverifiable-completion.unit.test.ts b/tests/e2e/relay-completion-evidence/relay-inspection-unverifiable-completion.unit.test.ts new file mode 100644 index 00000000000..0540a33ba9e --- /dev/null +++ b/tests/e2e/relay-completion-evidence/relay-inspection-unverifiable-completion.unit.test.ts @@ -0,0 +1,274 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' +import * as ptyProcessProbes from '../../../src/relay/pty-process-probes' + +const { execFileMock, execFileSyncMock, mockPtySpawn, mockPtyInstance, mockReadinessProbe } = + vi.hoisted(() => ({ + execFileMock: vi.fn(), + execFileSyncMock: vi.fn(), + mockPtySpawn: vi.fn(), + mockReadinessProbe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } + })) + +vi.mock('child_process', () => ({ + execFile: execFileMock, + execFileSync: execFileSyncMock +})) + +vi.mock('node-pty', () => ({ + spawn: mockPtySpawn +})) + +vi.mock('../../../src/main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +vi.mock('../../../src/main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mockReadinessProbe +})) + +import { resetProcessTableSnapshotForTests } from '../../../src/shared/process-table-snapshot' +import type { PtyHandler } from '../../../src/relay/pty-handler' +import { + beginPtyHandlerTest, + createPtyRequestHelpers, + endPtyHandlerTest, + type MockDispatcher +} from '../../../src/relay/pty-handler-test-harness' +import { + createAgentCompletionCoordinator, + resetAgentCompletionCoordinatorIdentitiesForTest +} from '../../../src/renderer/src/components/terminal-pane/agent-completion-coordinator' +import { resetAgentProcessInspectionQueueForTests } from '../../../src/renderer/src/components/terminal-pane/agent-process-inspection-queue' +import type { RuntimeTerminalProcessInspection } from '../../../src/renderer/src/runtime/runtime-terminal-inspection' + +/** + * "Failure becomes fact" coercion, SSH relay completion site. + * + * The relay adapter (pty-shell-utils) swallows `pgrep`/`ps` failures and + * resolves `{ foregroundProcess: , hasChildProcesses: false }` — the + * exact payload the agent-completion monitor reads as positive exit evidence. + * On a distressed or minimal relay host (probe timeouts under load, procps + * missing) the truth is "could not ask", which must stay `unverifiable` and + * must never become a completion. Loss of contact is never evidence of death + * (docs/reference/ssh-execution-boundary.md). + */ +describe('relay inspection under probe failure', () => { + let dispatcher: MockDispatcher + let handler: PtyHandler + let originalPlatform: PropertyDescriptor | undefined + + const { spawnPty } = createPtyRequestHelpers(() => dispatcher) + + // Timeout-killed subprocess: what execFile yields when the host is too + // loaded to answer `ps`/`pgrep` inside the 3s probe deadline. + function timeoutKilledError(command: string): Error { + const error = new Error(`spawn ${command} ETIMEDOUT`) as Error & { + killed: boolean + signal: string + code: null + } + error.killed = true + error.signal = 'SIGTERM' + error.code = null + return error + } + + type ExecBehavior = 'healthy' | 'probe-failure' + let execBehavior: ExecBehavior = 'healthy' + + function installExecFile(): void { + execFileMock.mockImplementation( + (command: string, args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as ( + err: Error | null, + result: { stdout: string; stderr: string } + ) => void + if (execBehavior === 'probe-failure') { + callback(timeoutKilledError(command), { stdout: '', stderr: '' }) + return + } + if (command === 'ps' && args[0] === '-axo') { + callback(null, { + stdout: [ + `${process.pid} 1 Ss bash -l`, + `99999 ${process.pid} S+ node /home/dev/.local/bin/codex` + ].join('\n'), + stderr: '' + }) + return + } + if (command === 'pgrep') { + callback(null, { stdout: '99999\n', stderr: '' }) + return + } + callback(new Error(`unexpected command ${command}`), { stdout: '', stderr: '' }) + } + ) + } + + beforeEach(() => { + execBehavior = 'healthy' + execFileMock.mockReset() + installExecFile() + resetProcessTableSnapshotForTests() + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ + mockPtySpawn, + mockPtyInstance, + mockCreateShellPromptReadinessProbe: mockReadinessProbe + })) + // The harness stubs processHasChildren for unrelated PTY tests; this suite + // exercises the real relay adapter, including its failure handling. + ;(ptyProcessProbes.processHasChildren as unknown as Mock).mockRestore() + ;(ptyProcessProbes.probeProcessChildren as unknown as Mock).mockRestore() + // node-pty reports the wrapper entrypoint as the foreground process for + // node-launched agents; descendant scans resolve it to the real agent. + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, process: 'node' }) + vi.spyOn(Math, 'random').mockReturnValue(0.5) + }) + + afterEach(async () => { + await endPtyHandlerTest(handler, originalPlatform) + resetAgentProcessInspectionQueueForTests() + resetAgentCompletionCoordinatorIdentitiesForTest() + resetProcessTableSnapshotForTests() + }) + + it('does not conclude an agent finished when the host process probes fail', async () => { + const { id } = await spawnPty({}) + + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => id, + getSettings: () => null, + // Real transport shape: the renderer awaits the relay RPC result and + // treats whatever resolves as the inspection answer. + inspectProcess: async (_settings, ptyId) => + (await dispatcher.callRequest('pty.inspectProcess', { + id: ptyId + })) as RuntimeTerminalProcessInspection, + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + coordinator.observeTitle('Codex working') + + // Healthy poll: the descendant scan proves codex is live in this pane. + await vi.advanceTimersByTimeAsync(2_000) + expect(dispatchCompletion).not.toHaveBeenCalled() + + // Host distress: every process probe now times out. Codex is still + // running; the host merely cannot answer. + execBehavior = 'probe-failure' + await vi.advanceTimersByTimeAsync(20_000) + + expect(dispatchCompletion).not.toHaveBeenCalled() + + coordinator.dispose() + }) + + // Old-client behavior proof (remote-wire-compatibility Rule 1/Rule 3): the + // legacy fields keep the exact pre-evidence collapse, so a client that + // predates `processEvidence` observes byte-identical host content. + it('publishes unchanged legacy fields beside the evidence', async () => { + const { id } = await spawnPty({}) + + const healthy = await dispatcher.callRequest('pty.inspectProcess', { id }) + expect(healthy).toEqual({ + foregroundProcess: 'codex', + hasChildProcesses: true, + processEvidence: { + foreground: { verdict: 'observed', processName: 'codex' }, + children: { verdict: 'live' } + } + }) + + execBehavior = 'probe-failure' + await vi.advanceTimersByTimeAsync(600) + const failed = await dispatcher.callRequest('pty.inspectProcess', { id }) + expect(failed).toEqual({ + // node-pty's own record still answers the foreground question. + foregroundProcess: 'node', + // The pre-evidence collapse old clients have always received. + hasChildProcesses: false, + processEvidence: { + foreground: { verdict: 'observed', processName: 'node' }, + children: { verdict: 'unverifiable', reason: expect.any(String) } + } + }) + }) + + it('still confirms a real exit once the probes answer again', async () => { + const { id } = await spawnPty({}) + + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-2:leaf-1', + getPtyId: () => id, + getSettings: () => null, + inspectProcess: async (_settings, ptyId) => + (await dispatcher.callRequest('pty.inspectProcess', { + id: ptyId + })) as RuntimeTerminalProcessInspection, + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + coordinator.observeTitle('Codex working') + await vi.advanceTimersByTimeAsync(2_000) + + execBehavior = 'probe-failure' + await vi.advanceTimersByTimeAsync(10_000) + expect(dispatchCompletion).not.toHaveBeenCalled() + + // Recovery, and the agent has genuinely exited: bash has the foreground + // again and pgrep positively reports no children. + execBehavior = 'healthy' + execFileMock.mockImplementation( + (command: string, args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as ( + err: Error | null, + result: { stdout: string; stderr: string } + ) => void + if (command === 'ps' && args[0] === '-axo') { + callback(null, { stdout: `${process.pid} 1 Ss+ bash -l`, stderr: '' }) + return + } + if (command === 'pgrep') { + // pgrep ran and matched nothing: exits 1 with empty output. + const noMatch = new Error('pgrep exited 1') as Error & { code: number; killed: boolean } + noMatch.code = 1 + noMatch.killed = false + callback(noMatch, { stdout: '', stderr: '' }) + return + } + callback(new Error(`unexpected command ${command}`), { stdout: '', stderr: '' }) + } + ) + + await vi.advanceTimersByTimeAsync(30_000) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledWith('codex', { + source: 'process-exit', + quietedHookDone: false, + terminalIdleConfirmed: true + }) + + coordinator.dispose() + }) +})