mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
* 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>
152 lines
4.7 KiB
TypeScript
152 lines
4.7 KiB
TypeScript
import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from './remote-runtime-memory-limits'
|
|
import {
|
|
JsonStringifyByteLimitError,
|
|
stringifyJsonWithinByteLimit
|
|
} from './node-bounded-json-stringify'
|
|
|
|
// Why: reserve fixed reply fields and future additive metadata; the echoed request id is charged
|
|
// separately because the wire contract permits arbitrary strings.
|
|
const OUTBOUND_ENVELOPE_RESERVE_BYTES = 4 * 1024
|
|
const MAX_JSON_STRING_ESCAPE_EXPANSION = 6
|
|
type CachedResultBytes = {
|
|
skeleton: string
|
|
stringFields: readonly { name: string; value: string }[]
|
|
rawStringBytes: number
|
|
byteLength?: number
|
|
}
|
|
const cachedResultBytes = new WeakMap<object, CachedResultBytes>()
|
|
|
|
/** Ceiling for content in one RPC reply before charging its request id. */
|
|
export const REMOTE_RPC_MAX_CONTENT_BYTES =
|
|
REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES - OUTBOUND_ENVELOPE_RESERVE_BYTES
|
|
|
|
/** Content budget after charging the JSON-encoded request id echoed by the reply. */
|
|
export function remoteRpcContentBudget(requestId: string): number {
|
|
const requestIdBytes = Buffer.byteLength(JSON.stringify(requestId), 'utf8')
|
|
return Math.max(0, REMOTE_RPC_MAX_CONTENT_BYTES - requestIdBytes)
|
|
}
|
|
|
|
function jsonStringContentBytes(value: string): number {
|
|
let bytes = 0
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const code = value.charCodeAt(index)
|
|
if (code === 0x22 || code === 0x5c) {
|
|
bytes += 2
|
|
} else if (code < 0x20) {
|
|
bytes +=
|
|
code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6
|
|
} else if (code <= 0x7f) {
|
|
bytes += 1
|
|
} else if (code <= 0x7ff) {
|
|
bytes += 2
|
|
} else if (code >= 0xd800 && code <= 0xdbff) {
|
|
const next = value.charCodeAt(index + 1)
|
|
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
bytes += 4
|
|
index += 1
|
|
} else {
|
|
bytes += 6
|
|
}
|
|
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
bytes += 6
|
|
} else {
|
|
bytes += 3
|
|
}
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
/** Whether a complete RPC result exceeds its request-scoped content budget. */
|
|
export function remoteRpcResultExceedsContentBudget(
|
|
result: unknown,
|
|
maxBytes: number,
|
|
largeStringFields: readonly string[] = []
|
|
): boolean {
|
|
const stringFields: { name: string; value: string }[] = []
|
|
let measuredResult = result
|
|
if (result !== null && typeof result === 'object' && largeStringFields.length > 0) {
|
|
const skeleton = { ...(result as Record<string, unknown>) }
|
|
for (const field of largeStringFields) {
|
|
const value = skeleton[field]
|
|
if (typeof value === 'string') {
|
|
stringFields.push({ name: field, value })
|
|
skeleton[field] = ''
|
|
}
|
|
}
|
|
measuredResult = skeleton
|
|
}
|
|
|
|
let skeletonBytes: number
|
|
let serializedSkeleton: string
|
|
try {
|
|
const measurement = stringifyJsonWithinByteLimit(measuredResult, maxBytes)
|
|
skeletonBytes = measurement.byteLength
|
|
serializedSkeleton = measurement.serialized
|
|
} catch (error) {
|
|
if (error instanceof JsonStringifyByteLimitError) {
|
|
return true
|
|
}
|
|
throw error
|
|
}
|
|
|
|
let rawBytes: number | undefined
|
|
if (result !== null && typeof result === 'object') {
|
|
const cached = cachedResultBytes.get(result)
|
|
if (
|
|
cached?.skeleton === serializedSkeleton &&
|
|
cached.stringFields.length === stringFields.length &&
|
|
cached.stringFields.every(
|
|
(field, index) =>
|
|
field.name === stringFields[index]?.name && field.value === stringFields[index]?.value
|
|
)
|
|
) {
|
|
if (cached.byteLength !== undefined) {
|
|
return cached.byteLength > maxBytes
|
|
}
|
|
const remainingBytes = maxBytes - skeletonBytes
|
|
if (cached.rawStringBytes * MAX_JSON_STRING_ESCAPE_EXPANSION <= remainingBytes) {
|
|
return false
|
|
}
|
|
if (cached.rawStringBytes > remainingBytes) {
|
|
return true
|
|
}
|
|
rawBytes = cached.rawStringBytes
|
|
}
|
|
}
|
|
|
|
const remainingBytes = maxBytes - skeletonBytes
|
|
if (rawBytes === undefined) {
|
|
rawBytes = 0
|
|
for (const field of stringFields) {
|
|
rawBytes += Buffer.byteLength(field.value, 'utf8')
|
|
}
|
|
if (result !== null && typeof result === 'object') {
|
|
cachedResultBytes.set(result, {
|
|
skeleton: serializedSkeleton,
|
|
stringFields,
|
|
rawStringBytes: rawBytes
|
|
})
|
|
}
|
|
}
|
|
if (rawBytes * MAX_JSON_STRING_ESCAPE_EXPANSION <= remainingBytes) {
|
|
return false
|
|
}
|
|
if (rawBytes > remainingBytes) {
|
|
return true
|
|
}
|
|
|
|
let encodedBytes = 0
|
|
for (const field of stringFields) {
|
|
encodedBytes += jsonStringContentBytes(field.value)
|
|
}
|
|
if (result !== null && typeof result === 'object') {
|
|
cachedResultBytes.set(result, {
|
|
skeleton: serializedSkeleton,
|
|
stringFields,
|
|
rawStringBytes: rawBytes,
|
|
byteLength: skeletonBytes + encodedBytes
|
|
})
|
|
}
|
|
return encodedBytes > remainingBytes
|
|
}
|