fix(ssh): gate paired-viewer pane recovery on the narrowed session-gone predicate

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.
This commit is contained in:
Neil
2026-09-02 00:17:30 -07:00
parent ea079c8687
commit 69c774f310
2 changed files with 46 additions and 3 deletions
@@ -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'
@@ -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
}