mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
fix(terminal): guard oversized SSH PTY writes
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user