fix(terminal): guard oversized SSH PTY writes

This commit is contained in:
Jinwoo-H
2026-08-31 16:05:10 -04:00
parent f336e62b36
commit a56006ef23
3 changed files with 57 additions and 21 deletions
+32
View File
@@ -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),
+25 -1
View File
@@ -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 => {
-20
View File
@@ -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
}