From 69c774f31018876c18257fc43db372ea8656bb1f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:33:44 -0700 Subject: [PATCH] fix(ssh): gate paired-viewer pane recovery on the narrowed session-gone predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isSshSessionGoneError landed on the IPC transport, which never calls terminal.recoverPane. The one caller that does — recoverExpiredHostPane in the paired-viewer transport — still triggered on a bare SSH_SESSION_EXPIRED substring, so the identity-mismatch reply (the relay found a LIVE PTY under that id owned by another pane, which is evidence of presence) still asked the HUB to replace the pane, putting a second agent on one transcript. Main already refuses the respawn on that same reply; this makes the two agree. A pane whose shell genuinely died is unaffected: plain SSH_SESSION_EXPIRED still matches. The mismatch reply now surfaces as an error instead of a respawn. --- ...ty-transport-expired-pane-recovery.test.ts | 39 +++++++++++++++++++ .../remote-runtime-pty-transport.ts | 10 +++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts index a7494317a24..b6f3b9802e7 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts @@ -132,6 +132,45 @@ describe('createRemoteRuntimePtyTransport', () => { expect(onError).not.toHaveBeenCalled() }) + it('does not recover a pane whose relay reply was an identity mismatch', async () => { + // The mismatch suffix means the relay found a LIVE PTY under that id owned by ANOTHER pane, so + // it is evidence of presence, not absence. This transport is the only caller of + // terminal.recoverPane, so a bare SSH_SESSION_EXPIRED substring test here put a second agent on + // one transcript even though main already refuses the respawn on the same reply. + const onError = vi.fn() + resolvedPaneHandle = 'terminal-mismatch' + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('hub-env', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + transport.attach({ + existingPtyId: 'remote:hub-env@@terminal-mismatch', + callbacks: { onError } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + runtimeCall.mockClear() + + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'error', + streamId: latestSubscribePayload().streamId, + message: 'SSH_SESSION_EXPIRED: pty-1 SSH_PTY_IDENTITY_MISMATCH' + } + }) + + await vi.waitFor(() => expect(onError).toHaveBeenCalled()) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.recoverPane' }) + ) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.create' }) + ) + expect(transport.getPtyId()).toBe('remote:hub-env@@terminal-mismatch') + }) + it('fails closed when an older HUB cannot recover an expired SSH pane', async () => { const onError = vi.fn() resolvedPaneHandle = 'terminal-expired' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 62c3ebee134..c2e8a7c2b36 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -32,6 +32,7 @@ import type { PtyTransportRecoveryState } from './pty-transport-types' import { createPtyOutputProcessor } from './pty-transport' +import { isSshSessionGoneError } from './pty-connection/pty-connect-limits' import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from '../../runtime/runtime-rpc-client' import { getRemoteRuntimePtyEnvironmentId, @@ -116,7 +117,6 @@ type RemoteAgentSessionLaunchResult = | RuntimeEnsureAgentSessionResult | RuntimeCreateAgentSessionResult | { terminal: RuntimeTerminalCreate; disposition?: undefined } -const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' function isRemoteTerminalStaleMessage(message: string): boolean { return message.includes('terminal_handle_stale') @@ -1600,8 +1600,12 @@ export function createRemoteRuntimePtyTransport( retireRemoteTerminalId() return } - if (message.includes(SSH_SESSION_EXPIRED_ERROR)) { - // Why: only the HUB may replace its expired SSH pane; a paired viewer must never fall back to client-local SSH. + if (isSshSessionGoneError(message)) { + // Why: only the HUB may replace its expired SSH pane; a paired viewer must never fall back to + // client-local SSH. The identity-mismatch suffix is excluded because it means the opposite — + // the relay found a LIVE PTY under that id owned by another pane — and this is the one + // transport that actually calls terminal.recoverPane, so a bare substring test here spawned + // a second agent onto one transcript. recoverExpiredHostPane() return }