From a56006ef232e665b6bb2c24129d5530650fa39fe Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 31 Aug 2026 15:57:35 -0400 Subject: [PATCH] fix(terminal): guard oversized SSH PTY writes --- src/main/providers/ssh-pty-write.test.ts | 32 ++++++++++++++++++++++++ src/main/providers/ssh-pty-write.ts | 26 ++++++++++++++++++- src/main/runtime/orca-runtime.ts | 20 --------------- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/main/providers/ssh-pty-write.test.ts b/src/main/providers/ssh-pty-write.test.ts index 7da03677b3e..77cd529ba63 100644 --- a/src/main/providers/ssh-pty-write.test.ts +++ b/src/main/providers/ssh-pty-write.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { SshPtyProvider } from './ssh-pty-provider' import { SSH_PTY_WRITE_SETTLEMENT_TIMEOUT_MS } from './ssh-pty-write' +import { MULTIPLEXER_ORDINARY_QUEUE_MAX_BYTES } from '../ssh/ssh-multiplexer-transport-writer' describe('SSH PTY writes', () => { afterEach(() => { @@ -42,6 +43,37 @@ describe('SSH PTY writes', () => { await expect(pending).resolves.toBe(false) }) + it('rejects an atomic write that cannot fit in one ordinary relay frame', () => { + const mux = { + isDisposed: vi.fn().mockReturnValue(false), + notify: vi.fn(), + onNotification: vi.fn() + } + const provider = new SshPtyProvider('conn-1', mux as never) + + expect( + provider.write('ssh:conn-1@@pty-1', 'x'.repeat(MULTIPLEXER_ORDINARY_QUEUE_MAX_BYTES)) + ).toBe(false) + expect(mux.notify).not.toHaveBeenCalled() + }) + + it('rejects an oversized settled write before touching the mux', async () => { + const mux = { + isDisposed: vi.fn().mockReturnValue(false), + notifyWithSettlement: vi.fn(), + onNotification: vi.fn() + } + const provider = new SshPtyProvider('conn-1', mux as never) + + await expect( + provider.writeWithSettlement( + 'ssh:conn-1@@pty-1', + 'x'.repeat(MULTIPLEXER_ORDINARY_QUEUE_MAX_BYTES) + ) + ).resolves.toBe(false) + expect(mux.notifyWithSettlement).not.toHaveBeenCalled() + }) + it('rejects settled writes immediately after the transport is disposed', async () => { const mux = { isDisposed: vi.fn().mockReturnValue(true), diff --git a/src/main/providers/ssh-pty-write.ts b/src/main/providers/ssh-pty-write.ts index 08adae7e833..6f5b65d20d0 100644 --- a/src/main/providers/ssh-pty-write.ts +++ b/src/main/providers/ssh-pty-write.ts @@ -1,9 +1,23 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { TIMEOUT_MS } from '../ssh/relay-protocol' +import { encodeJsonRpcFrame, TIMEOUT_MS } from '../ssh/relay-protocol' +import { MULTIPLEXER_ORDINARY_QUEUE_MAX_BYTES } from '../ssh/ssh-multiplexer-transport-writer' // Allow ordinary-lane backpressure to clear well beyond the mux health window. export const SSH_PTY_WRITE_SETTLEMENT_TIMEOUT_MS = TIMEOUT_MS * 3 +export function assertSshPtyWriteFitsTransport(relayPtyId: string, data: string): void { + const frame = encodeJsonRpcFrame( + { jsonrpc: '2.0', method: 'pty.data', params: { id: relayPtyId, data } }, + 0, + 0 + ) + if (frame.length > MULTIPLEXER_ORDINARY_QUEUE_MAX_BYTES) { + throw new Error( + `SSH PTY input exceeds the ${MULTIPLEXER_ORDINARY_QUEUE_MAX_BYTES}-byte transport limit` + ) + } +} + export function writeToSshPty( mux: SshChannelMultiplexer, relayPtyId: string, @@ -12,6 +26,11 @@ export function writeToSshPty( if (mux.isDisposed()) { return false } + try { + assertSshPtyWriteFitsTransport(relayPtyId, data) + } catch { + return false + } mux.notify('pty.data', { id: relayPtyId, data }) return !mux.isDisposed() } @@ -24,6 +43,11 @@ export function writeToSshPtyWithSettlement( if (mux.isDisposed()) { return Promise.resolve(false) } + try { + assertSshPtyWriteFitsTransport(relayPtyId, data) + } catch { + return Promise.resolve(false) + } return new Promise((resolve) => { let settled = false const finish = (accepted: boolean): void => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 81c29dfc39e..4b67d27d8dc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -148,7 +148,6 @@ import { iterateTerminalInputChunks } from '../../shared/terminal-input' import { - AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_SUBMIT, buildAgentPromptPasteBytes, getAgentPromptSubmitDelayMs, @@ -22000,8 +21999,6 @@ export class OrcaRuntimeService { const pasteByteLength = Buffer.byteLength(pastePayload, 'utf8') const pasteIngestMs = getTerminalPasteIngestMs(writeHostPlatform, pasteByteLength) const renderGate = this.createAgentPromptRenderGate(ptyId, pasteIngestMs) - let wrotePasteBytes = false - let completedPaste = false try { assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) @@ -22020,24 +22017,7 @@ export class OrcaRuntimeService { if (!wrote) { throw new Error('terminal_not_writable') } - wrotePasteBytes = true - completedPaste = true } catch (error) { - if ( - wrotePasteBytes && - !completedPaste && - this.getPtyLifecycleGeneration(ptyId) === generation - ) { - // Why: a lease that moved mid-paste also refuses this terminator, leaving the TUI in paste - // mode — the incoming owner re-establishes the mode, and feeding a session we no longer own - // is the worse outcome. - try { - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) - } catch { - // The original refusal is the actionable error. - } - } renderGate?.dispose() throw error }