mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
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:
+39
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user