From db84894eefde96fa68bbdb0fdee4242765c195d4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:21:42 -0400 Subject: [PATCH] fix(runtime): isolate paired terminal creates from host focus (#17713) --- ...t-navigation-isolation.integration.test.ts | 37 +++++++++ .../terminal-create-idempotency.test.ts | 72 ++++++++++++++++++ .../terminal/terminal-lifecycle-methods.ts | 75 +++++++++++-------- 3 files changed, 152 insertions(+), 32 deletions(-) diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index a8bcfa8d5ae..fd70c8803d3 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -465,6 +465,43 @@ describe('paired runtime navigation isolation', () => { ).toBe('activateWorktree') }) + it('normalizes a paired focused terminal.create before host-renderer activation', async () => { + const harness = await startHarness() + const created = { handle: 'term-b', worktreeId: CLIENT_B_WORKTREE_ID, title: null } + const createTerminal = vi + .spyOn(harness.runtime, 'createTerminal') + .mockImplementation(async (_worktree, options) => { + if (options?.presentation === 'focused') { + harness.hostSelections.worktreeId = CLIENT_B_WORKTREE_ID + } + return created as never + }) + vi.spyOn(harness.runtime, 'dedupeTerminalCreate').mockImplementation( + async (_owner, worktree, _mutationId, _reconcile, run) => run(worktree, undefined) + ) + + send(harness.clientB, { + id: 'terminal-create-b', + method: 'terminal.create', + params: { + worktree: `id:${CLIENT_B_WORKTREE_ID}`, + presentation: 'focused' + } + }) + await expect(harness.readerB.next('terminal-create-b')).resolves.toMatchObject({ + ok: true, + result: { terminal: created } + }) + expect(createTerminal).toHaveBeenCalledWith( + `id:${CLIENT_B_WORKTREE_ID}`, + expect.objectContaining({ presentation: 'background', focus: false, activate: false }) + ) + expect(harness.hostSelections).toEqual({ + worktreeId: HOST_WORKTREE_ID, + tabId: 'host-tab' + }) + }) + it('still reveals a host-originated create-with-activate on the host and every client', async () => { const harness = await startHarness() await subscribeBothClientEventStreams(harness) diff --git a/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts b/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts index b60a95a1269..51001605be0 100644 --- a/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts +++ b/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts @@ -60,4 +60,76 @@ describe('terminal.create RPC idempotency', () => { ) expect(result).toEqual({ terminal }) }) + + it('does not let a paired focused create navigate the host by default', async () => { + const terminal = { handle: 'terminal-focused', worktreeId: 'worktree-1', title: null } + const createTerminal = vi.fn(async () => terminal) + const dedupeTerminalCreate = vi.fn( + async ( + _clientIdentity: string, + _worktree: string | undefined, + _mutationId: string | undefined, + _reconcileExisting: boolean, + run: (worktree: string | undefined, handle: string | undefined) => Promise + ) => run('id:worktree-1', undefined) + ) + const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create') + if (!method) { + throw new Error('terminal.create method missing') + } + + await method.handler( + { + worktree: 'id:worktree-1', + presentation: 'focused', + focus: true, + activate: true + }, + { + runtime: { createTerminal, dedupeTerminalCreate }, + pairedDeviceId: 'device-b', + clientKind: 'runtime' + } as unknown as RpcContext, + vi.fn() + ) + + expect(createTerminal).toHaveBeenCalledWith( + 'id:worktree-1', + expect.objectContaining({ + presentation: 'background', + focus: false, + activate: false + }) + ) + }) + + it('preserves focus for an in-process caller', async () => { + const createTerminal = vi.fn(async () => ({ handle: 'terminal-host' })) + const dedupeTerminalCreate = vi.fn( + async ( + _owner: string, + _worktree: string | undefined, + _mutationId: string | undefined, + _reconcile: boolean, + run: (worktree: string | undefined, handle: string | undefined) => Promise + ) => run('id:worktree-1', undefined) + ) + const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create') + if (!method) { + throw new Error('terminal.create method missing') + } + + await method.handler( + { worktree: 'id:worktree-1', presentation: 'focused', focus: true, activate: true }, + { + runtime: { createTerminal, dedupeTerminalCreate } + } as unknown as RpcContext, + vi.fn() + ) + + expect(createTerminal).toHaveBeenCalledWith( + 'id:worktree-1', + expect.objectContaining({ presentation: 'focused', focus: true, activate: true }) + ) + }) }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 37c4cda51b4..d052d3015fb 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -33,38 +33,49 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.create', params: TerminalCreateParams, - handler: async (params, { runtime, pairedDeviceId, clientId }) => ({ - terminal: await runtime.dedupeTerminalCreate( - pairedDeviceId ?? clientId ?? 'local', - params.worktree, - params.clientMutationId, - params.reconcileExisting === true, - (canonicalWorktreeSelector, preAllocatedHandle) => - runtime.createTerminal(canonicalWorktreeSelector, { - command: params.command, - startupCommandDelivery: params.startupCommandDelivery, - env: params.env, - envToDelete: params.envToDelete, - ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), - ...(params.resumeProviderSession - ? { resumeProviderSession: params.resumeProviderSession } - : {}), - ...(params.launchToken ? { launchToken: params.launchToken } : {}), - ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), - ...(params.terminalColorQueryReplies - ? { terminalColorQueryReplies: params.terminalColorQueryReplies } - : {}), - title: params.title, - focus: params.focus === true, - rendererBacked: params.rendererBacked === true, - activate: params.activate === true, - presentation: params.presentation, - tabId: params.tabId, - leafId: params.leafId, - ...(preAllocatedHandle ? { preAllocatedHandle } : {}) - }) - ) - }) + handler: async (params, { runtime, pairedDeviceId, clientId, clientKind }) => { + // A focused terminal create predates paired-client navigation. Keep the + // authority boundary here so a remote caller cannot activate the host + // renderer. This legacy RPC remains a background create for paired viewers; + // caller-local selection belongs to the session-tab RPC flow. + const pairedViewer = clientKind !== undefined + const focus = pairedViewer ? false : params.focus === true + const activate = pairedViewer ? false : params.activate === true + const presentation = + pairedViewer && params.presentation === 'focused' ? 'background' : params.presentation + return { + terminal: await runtime.dedupeTerminalCreate( + pairedDeviceId ?? clientId ?? 'local', + params.worktree, + params.clientMutationId, + params.reconcileExisting === true, + (canonicalWorktreeSelector, preAllocatedHandle) => + runtime.createTerminal(canonicalWorktreeSelector, { + command: params.command, + startupCommandDelivery: params.startupCommandDelivery, + env: params.env, + envToDelete: params.envToDelete, + ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), + ...(params.resumeProviderSession + ? { resumeProviderSession: params.resumeProviderSession } + : {}), + ...(params.launchToken ? { launchToken: params.launchToken } : {}), + ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.terminalColorQueryReplies + ? { terminalColorQueryReplies: params.terminalColorQueryReplies } + : {}), + title: params.title, + focus, + rendererBacked: params.rendererBacked === true, + activate, + presentation, + tabId: params.tabId, + leafId: params.leafId, + ...(preAllocatedHandle ? { preAllocatedHandle } : {}) + }) + ) + } + } }), defineMethod({ name: 'terminal.split',