diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 191b736ea18..a3c29e4a948 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3462,7 +3462,8 @@ "file": "src/main/runtime/orca-runtime-terminal-split-authority.test.ts", "assertions": [ "rejected split cleanup passes the spawned incarnation through exact stop and final retirement", - "a same-id replacement installed behind the stop barrier remains current and never reaches an ID-only kill" + "a same-id replacement installed behind the stop barrier remains current and never reaches an ID-only kill", + "a legacy SSH owner is refused before split spawn, and missing spawn proof never reaches ID-only cleanup" ] }, { @@ -3694,8 +3695,8 @@ "platform": "macos", "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts src/main/runtime/orca-runtime-terminal-split-authority.test.ts src/main/ipc/pty-daemon-controller-teardown.test.ts src/main/ipc/pty-ssh-stop-verdict.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts src/main/daemon/daemon-protocol-version.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/main/daemon/terminal-host.test.ts", "result": "passed", - "durationSeconds": 4.87, - "summary": "Fourteen files and 230 tests passed on the final candidate, including controlled headless and rejected-split same-ID barriers, failed split-leaf retention, the runtime close transaction, local same-id admission, controller cleanup fencing, daemon protocol skew and owner routing, host duplicate teardown, SSH method skew, and relay process-owner rejection." + "durationSeconds": 4.75, + "summary": "Fourteen files and 232 tests passed on the final candidate, including controlled headless and rejected-split same-ID barriers, legacy split-owner preflight refusal, missing-proof cleanup refusal, failed split-leaf retention, the runtime close transaction, local same-id admission, controller cleanup fencing, daemon protocol skew and owner routing, host duplicate teardown, SSH method skew, and relay process-owner rejection." }, { "date": "2026-08-20", diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index a021aa3bf2d..5808ac09723 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5904,8 +5904,8 @@ export function registerPtyHandlers( (await provider?.supportsIncarnationAddressedShutdown?.(ptyId, opts)) === true return ( supported && - opts?.expectedIncarnationId !== undefined && - ptyIncarnationById.get(ptyId) === opts.expectedIncarnationId + (opts?.expectedIncarnationId === undefined || + ptyIncarnationById.get(ptyId) === opts.expectedIncarnationId) ) }, getForegroundProcess: async (ptyId) => { diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index 5b236b1c3a0..f4c03bce543 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -79,8 +79,10 @@ function createHarness( deferReveal?: boolean deferSpawn?: boolean deferStop?: boolean + exactStopSupported?: boolean includePairedSnapshot?: boolean rendererMounted?: boolean + spawnIncarnationId?: string | null sourceIncarnationId?: string stopAndWaitResult?: boolean } = {} @@ -109,15 +111,20 @@ function createHarness( }, persistPtyBinding: () => true } - let resolveSpawn: ((result: { id: string; incarnationId: string }) => void) | undefined + const spawnIncarnationId = + options.spawnIncarnationId === undefined ? SPLIT_INCARNATION_ID : options.spawnIncarnationId + let resolveSpawn: ((result: { id: string; incarnationId?: string }) => void) | undefined const spawn = options.deferSpawn ? vi.fn( () => - new Promise<{ id: string; incarnationId: string }>((resolve) => { + new Promise<{ id: string; incarnationId?: string }>((resolve) => { resolveSpawn = resolve }) ) - : vi.fn(async () => ({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID })) + : vi.fn(async () => ({ + id: SPLIT_PTY_ID, + ...(spawnIncarnationId ? { incarnationId: spawnIncarnationId } : {}) + })) const kill = vi.fn(() => true) const retireRejectedPty = vi.fn() let resolveStop: ((stopped: boolean) => void) | undefined @@ -129,6 +136,7 @@ function createHarness( }) ) : vi.fn(async () => options.stopAndWaitResult ?? true) + const supportsIncarnationAddressedStop = vi.fn(async () => options.exactStopSupported ?? true) let resolveReveal: ((result: { tabId: string }) => void) | undefined const revealTerminalSession = options.deferReveal ? vi.fn( @@ -153,6 +161,7 @@ function createHarness( write: () => true, kill, retireRejectedPty, + supportsIncarnationAddressedStop, ...(options.stopAndWaitResult !== undefined || options.deferStop ? { stopAndWait } : {}), getForegroundProcess: async () => null }) @@ -202,6 +211,7 @@ function createHarness( kill, retireRejectedPty, stopAndWait, + supportsIncarnationAddressedStop, revealTerminalSession, getSession: () => session, getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID), @@ -228,7 +238,11 @@ function createHarness( }), getSplitIncarnation: () => internals.ptysById.get(SPLIT_PTY_ID)?.incarnationId, resolveStop: (stopped: boolean) => resolveStop?.(stopped), - resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID }) + resolveSpawn: () => + resolveSpawn?.({ + id: SPLIT_PTY_ID, + ...(spawnIncarnationId ? { incarnationId: spawnIncarnationId } : {}) + }) } } @@ -280,6 +294,46 @@ describe('remote runtime terminal split authority', () => { expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled() }) + it('refuses a legacy SSH split owner before spawning an unaddressable PTY', async () => { + const harness = createHarness(true, { + connectionId: 'ssh-legacy', + exactStopSupported: false, + sourceIncarnationId: 'source-incarnation' + }) + + await expect( + harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' }) + ).rejects.toThrow('terminal_incarnation_fence_unavailable') + + expect(harness.supportsIncarnationAddressedStop).toHaveBeenCalledWith(SOURCE_PTY_ID, { + deadlineMs: expect.any(Number), + expectedIncarnationId: 'source-incarnation' + }) + expect(harness.spawn).not.toHaveBeenCalled() + expect(harness.stopAndWait).not.toHaveBeenCalled() + expect(harness.kill).not.toHaveBeenCalled() + }) + + it('never stops a rejected split by ID when spawn omits its incarnation', async () => { + const harness = createHarness(false, { + deferReveal: true, + includePairedSnapshot: true, + sourceIncarnationId: 'projected-before', + spawnIncarnationId: null, + stopAndWaitResult: true + }) + + const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) + await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce()) + harness.replaceSourceIncarnation('projected-after') + harness.resolveReveal() + + await expect(split).rejects.toThrow('terminal_split_source_not_found') + expect(harness.stopAndWait).not.toHaveBeenCalled() + expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false, undefined) + expect(harness.kill).not.toHaveBeenCalled() + }) + it.each([ { label: 'local', connectionId: null, expectedHostId: 'local' }, { label: 'SSH', connectionId: 'ssh-1', expectedHostId: 'ssh:ssh-1' } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f14e4ebbb69..b7b98bbe773 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -30275,6 +30275,14 @@ export class OrcaRuntimeService { } const sourceIncarnationId = sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId + if ( + (await this.ptyController.supportsIncarnationAddressedStop?.(pty.ptyId, { + deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, + ...(sourceIncarnationId ? { expectedIncarnationId: sourceIncarnationId } : {}) + })) === false + ) { + throw new Error('terminal_incarnation_fence_unavailable') + } const leafId = randomUUID() const preAllocatedHandle = this.createPreAllocatedTerminalHandle() const paneKey = makePaneKey(parentTabId, leafId) @@ -30393,14 +30401,16 @@ export class OrcaRuntimeService { } catch (error) { this.setPairedRendererSessionOwnership(result.id, false) let stopped = false - try { - stopped = - (await this.ptyController.stopAndWait?.(result.id, { - deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, - ...(result.incarnationId ? { expectedIncarnationId: result.incarnationId } : {}) - })) ?? false - } catch { - // Exact cleanup remains unverifiable; preserve the original split authority error. + if (result.incarnationId) { + try { + stopped = + (await this.ptyController.stopAndWait?.(result.id, { + deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, + expectedIncarnationId: result.incarnationId + })) ?? false + } catch { + // Exact cleanup remains unverifiable; preserve the original split authority error. + } } try { this.ptyController.retireRejectedPty?.(result.id, stopped, result.incarnationId)