Files
orca/src/main/ssh/ssh-filesystem-stream-reader.ts
T
NeilandOrca 2100fb2553 fix(runtime): cap remote git.diff and file previews at the transport budget (#14160)
* fix(runtime): cap remote git.diff and file previews at the transport budget

A remote or mobile user who opens the diff of a large image loses their whole
WebSocket, not just that request: the E2EE channel closes with 1013 when a reply
exceeds the 4 MiB outbound envelope. Two producers can exceed it unaided.

git.diff/branchDiff/commitDiff cap text with MAX_RENDERED_DIFF_COMBINED_CHARACTERS
(6M chars) -- a *renderer* budget that sits above the transport limit -- and return
base64 for previewable binaries bounded only by MAX_GIT_SHOW_BYTES, so a 10 MiB PNG
changed in place is ~26.7 MiB in one envelope. files.readPreview inlines base64 up
to 10 MiB, and mobile calls it for every image tab.

Both now measure against a budget derived from the outbound limit. The check sits in
orca-runtime-git.ts, downstream of the dedupe and of both the SSH-provider and local
branches, so a payload forwarded verbatim by an old relay is covered by the same code
and src/relay needs no change. Local and in-process callers pass no budget and keep
full fidelity.

Measuring raw bytes would not work, which is the whole reason this needs a module.
JSON escaping turns one control byte into six (\u00XX), and binary-buffer.ts sniffs
only for NUL in the first 8 KiB -- so a NUL-free file of 0x01-0x1f bytes is classified
as *text*, would pass a raw-byte cap, and would then blow the envelope. The budget is
escape-aware, with a three-branch fast path that keeps normal diffs at two native
byteLength calls and scans only the ambiguous band.

The SSH branch of readFileExplorerPreview had the same raw-vs-escaped gap: its stat
gate sizes base64 binaries, but text crossed unbounded. It now honours the same
decoded-text limit the local branch already enforced.

No wire change: GitDiffResult is untouched -- no third kind, no new field. Old clients
see an error for one request instead of a dropped connection. diff_too_large joins the
structured passthrough codes and lands on an existing error arm in both mobile
consumers and the desktop remote path; file_too_large was already handled on both.

Instruments the 1013 close, which nothing measured before, so the incidence this cap
is meant to drive to zero is finally observable. `emitter` separates a producer size
bug from a wedged link.

Known regression: remote image previews between ~3.096 and ~3.146 MB now return
file_too_large. They only intermittently worked before -- above ~3.0 MB they killed
the socket -- so this trades intermittent connection loss for a consistent error.

Test: 10281 passed in src/main/runtime + src/shared + src/main/git; mobile 3427
passed. Each of the six budget-enforcement sites is independently mutation-killed.
Escaping fixtures cover newline-dense, control-char, CJK, lone-surrogate and base64
content against native JSON.stringify. tsc clean for node, web and cli; oxlint clean.

Co-authored-by: Orca <help@stably.ai>

* fix(runtime): harden remote reply transport budgets

* test(runtime): cover desktop remote preview budgets

* test(runtime): close telemetry review gaps

* chore(shared): repoint budget imports after the shared/types barrel removal

Upstream #14447 dropped the shared/types barrel; GitDiffResult now lives in
git-diff-compare-types and GlobalSettings in global-settings-types.

Co-authored-by: Orca <help@stably.ai>

* fix(ssh): surface an over-cap preview read as file_too_large

The stream reader aborts an over-cap read with StreamProtocolError, whose numeric
code falls through mapRuntimeError to a generic runtime_error carrying the raw
"Reported totalSize N exceeds client cap M" string. Neither preview client
recognizes that: runtime-file-client.ts and mobile-file-preview-response.ts both
key on file_too_large. It also made the two file_too_large guards directly below
the read unreachable on the streaming path.

Gives the cap its own error type so the caller can translate it, keeping the
bandwidth saving the cap exists for. A genuine protocol fault still propagates
unmasked.

Found by the readiness review. Mutation-verified: removing the translation fails
exactly the new test.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-14 00:24:57 -07:00

343 lines
11 KiB
TypeScript

import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import { STREAM_CHUNK_SIZE, JsonRpcErrorCode, RelayErrorCode } from './relay-protocol'
import type { FileReadLimits, FileReadResult } from '../providers/types'
import {
createSshFileStreamInactivityDeadline,
SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS
} from './ssh-file-stream-inactivity-deadline'
import { sshFileStreamReadCap } from './ssh-file-stream-read-cap'
const RESULT_ENCODING_BASE64 = 'base64'
const SENTINEL_STREAM_ID = -1
type StreamMetadataResponse = {
streamId?: number
totalSize: number
isBinary: boolean
isImage?: boolean
mimeType?: string
resultEncoding?: 'base64' | 'utf-8'
empty?: boolean
}
export function isMethodNotFoundError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
const code = (err as { code?: unknown }).code
return code === JsonRpcErrorCode.MethodNotFound
}
export class StreamProtocolError extends Error {
readonly code = RelayErrorCode.StreamProtocolError
constructor(message: string) {
super(message)
}
}
// Why: exceeding a cap the caller itself set is a size verdict, not a protocol fault — callers
// translate it into their own too-large error rather than leaking the raw stream message.
export class FileReadCapExceededError extends StreamProtocolError {}
export async function readFileViaStream(
mux: SshChannelMultiplexer,
filePath: string,
limits?: FileReadLimits
): Promise<FileReadResult> {
// Why: subscribe BEFORE awaiting the metadata response so a chunk arriving
// immediately after the response cannot beat the listener registration.
// streamIdRef stays at SENTINEL_STREAM_ID until metadata resolves; chunk
// handlers compare against it and drop unmatched ids cleanly.
const streamIdRef = { current: SENTINEL_STREAM_ID }
const unsubscribers: (() => void)[] = []
const cleanup = (): void => {
while (unsubscribers.length > 0) {
const fn = unsubscribers.pop()
try {
fn?.()
} catch {
// Best-effort cleanup
}
}
}
return new Promise<FileReadResult>((resolve, reject) => {
let buffer: Buffer | null = null
let resultEncoding: 'base64' | 'utf-8' = RESULT_ENCODING_BASE64
let isBinary = false
let isImage: boolean | undefined
let mimeType: string | undefined
let totalSize = 0
let expectedSeq = 0
let receivedChunks = 0
let totalChunks = 0
let bytesReceived = 0
let settled = false
// Why: chunk/end/error frames may arrive in the same dispatch tick as the
// metadata response. Queue them until streamIdRef is set, then drain.
type PendingFrame =
| { kind: 'chunk'; params: Record<string, unknown> }
| { kind: 'end'; params: Record<string, unknown> }
| { kind: 'error'; params: Record<string, unknown> }
const pending: PendingFrame[] = []
let metadataReady = false
const inactivity = createSshFileStreamInactivityDeadline(() => {
fail(
new StreamProtocolError(
`File stream stalled (>${SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS}ms without data)`
)
)
})
const cancel = (): void => {
if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) {
try {
mux.notify('fs.cancelStream', { streamId: streamIdRef.current })
} catch {
// Best-effort
}
}
}
const fail = (err: Error): void => {
if (settled) {
return
}
settled = true
inactivity.clear()
cancel()
cleanup()
reject(err)
}
const succeed = (value: FileReadResult): void => {
if (settled) {
return
}
settled = true
inactivity.clear()
cleanup()
resolve(value)
}
const handleChunk = (params: Record<string, unknown>): void => {
if (settled) {
return
}
const id = params.streamId as number | undefined
if (id !== streamIdRef.current) {
return
}
const seq = params.seq as number
const data = params.data as string
if (typeof seq !== 'number' || typeof data !== 'string') {
fail(new StreamProtocolError(`Malformed chunk for stream ${id}`))
return
}
if (seq !== expectedSeq) {
fail(
new StreamProtocolError(
`Out-of-order chunk for stream ${id}: expected ${expectedSeq}, got ${seq}`
)
)
return
}
const offset = seq * STREAM_CHUNK_SIZE
const decoded = Buffer.from(data, 'base64')
// Why: a short chunk would leave the pre-allocated buffer zero-filled and
// resolve as silently-corrupt data; validate each chunk's exact length.
const expectedLength = Math.min(STREAM_CHUNK_SIZE, totalSize - offset)
if (decoded.length !== expectedLength) {
fail(
new StreamProtocolError(
`Chunk length mismatch for stream ${id}: seq=${seq} expected=${expectedLength} got=${decoded.length}`
)
)
return
}
if (!buffer) {
fail(new StreamProtocolError(`Chunk arrived before metadata for stream ${id}`))
return
}
decoded.copy(buffer, offset)
expectedSeq += 1
receivedChunks += 1
bytesReceived += decoded.length
inactivity.reset()
// Why: credit-based flow control — the relay caps unacked chunks so bulk
// stream frames cannot queue unbounded ahead of interactive pty.data
// frames on the shared SSH channel. Old relays ignore this notification.
mux.notify('fs.streamAck', { streamId: id, seq })
}
const handleEnd = (params: Record<string, unknown>): void => {
if (settled) {
return
}
const id = params.streamId as number | undefined
if (id !== streamIdRef.current) {
return
}
if (receivedChunks !== totalChunks) {
fail(
new StreamProtocolError(
`Chunk count mismatch for stream ${id}: expected ${totalChunks}, received ${receivedChunks}`
)
)
return
}
// Why: redundant given the per-chunk length + count checks, but kept as a
// last-line invariant guard; never resolve with fewer bytes than declared.
if (bytesReceived !== totalSize) {
fail(
new StreamProtocolError(
`Byte count mismatch for stream ${id}: expected ${totalSize}, received ${bytesReceived}`
)
)
return
}
if (!buffer) {
fail(new StreamProtocolError(`Stream end before metadata for stream ${id}`))
return
}
const content =
resultEncoding === RESULT_ENCODING_BASE64
? buffer.toString('base64')
: buffer.toString('utf-8')
succeed({
content,
isBinary,
...(isImage !== undefined ? { isImage } : {}),
...(mimeType !== undefined ? { mimeType } : {})
})
}
const handleStreamError = (params: Record<string, unknown>): void => {
if (settled) {
return
}
const id = params.streamId as number | undefined
if (id !== streamIdRef.current) {
return
}
const message = (params.message as string | undefined) ?? 'stream error'
const code = (params.code as string | undefined) ?? 'ESTREAMERROR'
const err = new Error(message) as Error & { code: string }
err.code = code
fail(err)
}
const drainPending = (): void => {
while (!settled && pending.length > 0) {
const frame = pending.shift()!
if (frame.kind === 'chunk') {
handleChunk(frame.params)
} else if (frame.kind === 'end') {
handleEnd(frame.params)
} else {
handleStreamError(frame.params)
}
}
}
unsubscribers.push(
mux.onNotificationByMethod('fs.streamChunk', (params) => {
if (!metadataReady) {
pending.push({ kind: 'chunk', params })
return
}
handleChunk(params)
})
)
unsubscribers.push(
mux.onNotificationByMethod('fs.streamEnd', (params) => {
if (!metadataReady) {
pending.push({ kind: 'end', params })
return
}
handleEnd(params)
})
)
unsubscribers.push(
mux.onNotificationByMethod('fs.streamError', (params) => {
if (!metadataReady) {
pending.push({ kind: 'error', params })
return
}
handleStreamError(params)
})
)
const onDispose = mux.onDispose((reason) => {
const message =
reason === 'connection_lost'
? 'SSH connection lost, reconnecting...'
: 'Multiplexer disposed'
const err = new Error(message) as Error & { code: string }
err.code = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED'
fail(err)
})
unsubscribers.push(onDispose)
void mux
// Why: flowControl declares this client acks each chunk, letting a new
// relay pace the pump. Old relays ignore the extra param and flood.
.request('fs.readFileStream', { filePath, flowControl: 'ack' })
.then((rawMetadata) => {
if (settled) {
return
}
const metadata = rawMetadata as StreamMetadataResponse
isBinary = metadata.isBinary
isImage = metadata.isImage
mimeType = metadata.mimeType
resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64
if (metadata.empty) {
succeed({
content: '',
isBinary: metadata.isBinary,
...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}),
...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {})
})
return
}
if (typeof metadata.streamId !== 'number') {
fail(new StreamProtocolError('Metadata missing streamId for non-empty stream'))
return
}
const cap = sshFileStreamReadCap(metadata.isBinary, limits)
if (metadata.totalSize < 0 || metadata.totalSize > cap) {
streamIdRef.current = metadata.streamId
fail(
new FileReadCapExceededError(
`Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}`
)
)
return
}
totalSize = metadata.totalSize
totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE)
try {
buffer = Buffer.alloc(totalSize)
} catch (err) {
streamIdRef.current = metadata.streamId
fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`))
return
}
streamIdRef.current = metadata.streamId
metadataReady = true
inactivity.reset()
drainPending()
})
.catch((err) => {
fail(err as Error)
})
})
}