From 1cf921bb297babef0ee397b52e72c6ddf71a4c28 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 20 Aug 2026 07:00:38 -0400 Subject: [PATCH] fix(terminal): require split source incarnation proof --- config/reliability-gates.jsonc | 6 +- src/main/ipc/pty.ts | 4 +- ...a-runtime-terminal-split-authority.test.ts | 21 ++++- src/main/runtime/orca-runtime.test.ts | 93 +++++++++++++++---- src/main/runtime/orca-runtime.ts | 5 +- 5 files changed, 101 insertions(+), 28 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index a3c29e4a948..4bd79614b05 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3463,7 +3463,7 @@ "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 legacy SSH owner is refused before split spawn, and missing spawn proof never reaches ID-only cleanup" + "missing source proof and a legacy SSH owner are refused before split spawn, while missing spawn proof never reaches ID-only cleanup" ] }, { @@ -3695,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.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." + "durationSeconds": 4.72, + "summary": "Fourteen files and 233 tests passed on the final candidate, including controlled headless and rejected-split same-ID barriers, missing-source and legacy-owner pre-spawn 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 5808ac09723..a021aa3bf2d 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 f4c03bce543..7104db8eceb 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -12,6 +12,7 @@ const TAB_ID = 'tab-remote' const SOURCE_LEAF_ID = '11111111-1111-4111-8111-111111111111' const SOURCE_PTY_ID = 'pty-source' const SPLIT_PTY_ID = 'pty-split' +const SOURCE_INCARNATION_ID = '11111111-1111-4111-8111-111111111112' const SPLIT_INCARNATION_ID = '22222222-2222-4222-8222-222222222222' const REPLACEMENT_SPLIT_INCARNATION_ID = '33333333-3333-4333-8333-333333333333' @@ -83,12 +84,14 @@ function createHarness( includePairedSnapshot?: boolean rendererMounted?: boolean spawnIncarnationId?: string | null - sourceIncarnationId?: string + sourceIncarnationId?: string | null stopAndWaitResult?: boolean } = {} ) { let session = persistedSession(includeSource) const connectionId = options.connectionId ?? null + const sourceIncarnationId = + options.sourceIncarnationId === undefined ? SOURCE_INCARNATION_ID : options.sourceIncarnationId const ownerHostId = connectionId ? `ssh:${connectionId}` : 'local' const requestedSessionHostIds: (string | undefined)[] = [] const repo = { @@ -196,7 +199,7 @@ function createHarness( runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, { tabId: TAB_ID, leafId: SOURCE_LEAF_ID, - ...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {}) + ...(sourceIncarnationId ? { incarnationId: sourceIncarnationId } : {}) }) const internals = runtime as unknown as { issuePtyHandle: (pty: unknown) => string @@ -314,6 +317,19 @@ describe('remote runtime terminal split authority', () => { expect(harness.kill).not.toHaveBeenCalled() }) + it('refuses a split source without incarnation proof before capability probing', async () => { + const harness = createHarness(true, { sourceIncarnationId: null }) + + await expect( + harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' }) + ).rejects.toThrow('terminal_incarnation_fence_unavailable') + + expect(harness.supportsIncarnationAddressedStop).not.toHaveBeenCalled() + 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, @@ -404,6 +420,7 @@ describe('remote runtime terminal split authority', () => { const harness = createHarness(true, { deferSpawn: true, includePairedSnapshot: false, + sourceIncarnationId: null, stopAndWaitResult: true }) harness.replacePersistedSourceIncarnation('persisted-before') diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9968b00fcfb..2006a35033e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -6451,8 +6451,14 @@ describe('OrcaRuntimeService', () => { vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'pty-remote-initial' }) - .mockResolvedValueOnce({ id: 'pty-remote-setup-split' }) + .mockResolvedValueOnce({ + id: 'pty-remote-initial', + incarnationId: 'pty-remote-initial-incarnation' + }) + .mockResolvedValueOnce({ + id: 'pty-remote-setup-split', + incarnationId: 'pty-remote-setup-split-incarnation' + }) const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-remote-split' }) registerSshGitProvider('ssh-1', provider as never) registerSshFilesystemProvider('ssh-1', fsProvider as never) @@ -15288,8 +15294,8 @@ describe('OrcaRuntimeService', () => { it('splits visible pty-backed terminal sessions through the parent renderer tab', async () => { const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'pty-source' }) - .mockResolvedValueOnce({ id: 'pty-split' }) + .mockResolvedValueOnce({ id: 'pty-source', incarnationId: 'pty-source-incarnation' }) + .mockResolvedValueOnce({ id: 'pty-split', incarnationId: 'pty-split-incarnation' }) const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg' }) const splitTerminal = vi.fn() const runtime = new OrcaRuntimeService(store) @@ -15498,7 +15504,8 @@ describe('OrcaRuntimeService', () => { runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { tabId, - leafId: HEADLESS_LEAF_ID + leafId: HEADLESS_LEAF_ID, + incarnationId: 'closing-persisted-incarnation' }) const internals = runtime as unknown as { issuePtyHandle: (pty: unknown) => string @@ -15592,8 +15599,14 @@ describe('OrcaRuntimeService', () => { const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-split-')) const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'pty-folder-source' }) - .mockResolvedValueOnce({ id: 'pty-folder-split' }) + .mockResolvedValueOnce({ + id: 'pty-folder-source', + incarnationId: 'pty-folder-source-incarnation' + }) + .mockResolvedValueOnce({ + id: 'pty-folder-split', + incarnationId: 'pty-folder-split-incarnation' + }) const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-folder' }) const folderWorkspace = makeFolderWorkspace({ folderPath }) const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) @@ -15693,7 +15706,10 @@ describe('OrcaRuntimeService', () => { }), 'ssh:ssh-1' ) - const spawn = vi.fn().mockResolvedValue({ id: splitPtyId }) + const spawn = vi.fn().mockResolvedValue({ + id: splitPtyId, + incarnationId: 'ssh-split-incarnation' + }) const runtime = new OrcaRuntimeService({ ...runtimeStore, getRepos: () => [remoteRepo], @@ -15708,7 +15724,8 @@ describe('OrcaRuntimeService', () => { runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) runtime.registerPty(sourcePtyId, TEST_WORKTREE_ID, 'ssh-1', { tabId, - leafId: HEADLESS_LEAF_ID + leafId: HEADLESS_LEAF_ID, + incarnationId: 'ssh-source-incarnation' }) const internals = runtime as unknown as { issuePtyHandle: (pty: unknown) => string @@ -28722,7 +28739,13 @@ describe('OrcaRuntimeService', () => { }) const runtime = new OrcaRuntimeService(runtimeStore as never) runtime.setPtyController({ - spawn: vi.fn().mockResolvedValueOnce({ id: ptyId }).mockResolvedValueOnce({ id: splitPtyId }), + spawn: vi + .fn() + .mockResolvedValueOnce({ id: ptyId, incarnationId: 'runtime-owned-incarnation' }) + .mockResolvedValueOnce({ + id: splitPtyId, + incarnationId: 'runtime-owned-split-incarnation' + }), write: () => true, kill, getForegroundProcess: async () => null, @@ -28942,8 +28965,14 @@ describe('OrcaRuntimeService', () => { it('publishes laptop-created remote runtime split terminals to phone session tabs', async () => { const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'laptop-created-pty' }) - .mockResolvedValueOnce({ id: 'laptop-split-pty' }) + .mockResolvedValueOnce({ + id: 'laptop-created-pty', + incarnationId: 'laptop-created-incarnation' + }) + .mockResolvedValueOnce({ + id: 'laptop-split-pty', + incarnationId: 'laptop-split-incarnation' + }) const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn, @@ -44700,8 +44729,14 @@ describe('OrcaRuntimeService', () => { const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-cli-setup-split' }) const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'pty-cli-setup-main' }) - .mockResolvedValueOnce({ id: 'pty-cli-setup-setup' }) + .mockResolvedValueOnce({ + id: 'pty-cli-setup-main', + incarnationId: 'pty-cli-setup-main-incarnation' + }) + .mockResolvedValueOnce({ + id: 'pty-cli-setup-setup', + incarnationId: 'pty-cli-setup-child-incarnation' + }) runtime.setPtyController({ spawn, write: () => true, @@ -44788,8 +44823,14 @@ describe('OrcaRuntimeService', () => { const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg-setup-split' }) const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'pty-bg-split-main' }) - .mockResolvedValueOnce({ id: 'pty-bg-split-setup' }) + .mockResolvedValueOnce({ + id: 'pty-bg-split-main', + incarnationId: 'pty-bg-split-main-incarnation' + }) + .mockResolvedValueOnce({ + id: 'pty-bg-split-setup', + incarnationId: 'pty-bg-split-child-incarnation' + }) runtime.setPtyController({ spawn, write: () => true, @@ -44870,8 +44911,14 @@ describe('OrcaRuntimeService', () => { const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-active-setup-split' }) const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'pty-active-split-main' }) - .mockResolvedValueOnce({ id: 'pty-active-split-setup' }) + .mockResolvedValueOnce({ + id: 'pty-active-split-main', + incarnationId: 'pty-active-split-main-incarnation' + }) + .mockResolvedValueOnce({ + id: 'pty-active-split-setup', + incarnationId: 'pty-active-split-child-incarnation' + }) runtime.setPtyController({ spawn, write: () => true, @@ -45577,8 +45624,14 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(runtimeStore as never) const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'pty-startup-split-main' }) - .mockResolvedValueOnce({ id: 'pty-startup-split-setup' }) + .mockResolvedValueOnce({ + id: 'pty-startup-split-main', + incarnationId: 'pty-startup-split-main-incarnation' + }) + .mockResolvedValueOnce({ + id: 'pty-startup-split-setup', + incarnationId: 'pty-startup-split-child-incarnation' + }) const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-startup-split' }) runtime.setPtyController({ spawn, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b7b98bbe773..7f97c28babf 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -30275,10 +30275,13 @@ export class OrcaRuntimeService { } const sourceIncarnationId = sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId + if (!sourceIncarnationId) { + throw new Error('terminal_incarnation_fence_unavailable') + } if ( (await this.ptyController.supportsIncarnationAddressedStop?.(pty.ptyId, { deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, - ...(sourceIncarnationId ? { expectedIncarnationId: sourceIncarnationId } : {}) + expectedIncarnationId: sourceIncarnationId })) === false ) { throw new Error('terminal_incarnation_fence_unavailable')