protocol.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718
  1. /**
  2. * Versionless, JSON-lines wire protocol between the Node host and the CPython subprocess. Frames
  3. * travel on the child's fd 3 (one JSON object per line), leaving stdout/stderr free for the
  4. * program's own output. Host treats every inbound frame as hostile because model code can post
  5. * anything through the same fd; the Python bootstrap trusts host replies.
  6. * @module @deepseek-ai/dsh-experimental-code-runtime-python/src/protocol
  7. */
  8. /**
  9. * The framed-JSON channel's file descriptor from the child's perspective. The
  10. * host pins it positionally when it spawns the child (`stdio` index 3, i.e.
  11. * `['pipe','pipe','pipe','pipe']`), and the Python bootstrap reads the same
  12. * number from its own `protocol.py`. Exported as the single TS-side source of
  13. * truth: the host wiring uses it, and the cross-language mirror test asserts the
  14. * Python constant equals it, so a drift on either side breaks the boot channel
  15. * loudly rather than silently.
  16. */
  17. export const PROTOCOL_FD = 3
  18. /**
  19. * One binding namespace declaration inside a {@link BootMessage}. `global` is
  20. * the program-visible name the namespace is materialized under; `errorClass`,
  21. * when present, asks the bootstrap to mint a program-visible exception class.
  22. */
  23. interface Namespace {
  24. global: string
  25. names: string[]
  26. errorClass?: ErrorClass
  27. }
  28. /**
  29. * A namespace's program-visible exception class: rejected calls raise its
  30. * instances carrying the failed member name on `memberNameProperty`.
  31. */
  32. interface ErrorClass {
  33. name: string
  34. memberNameProperty: string
  35. }
  36. /**
  37. * What the host sends immediately after spawn, as the first line on fd 3. The
  38. * Python bootstrap reads this, applies resource limits, then waits for the
  39. * subsequent run frame. Separated from the run so the run message stays
  40. * pure model input.
  41. */
  42. export interface BootMessage {
  43. type: 'boot'
  44. /** RLIMIT_CPU seconds; the Python bootstrap sets this on itself before executing model code. */
  45. cpuSeconds: number
  46. /** RLIMIT_AS bytes; caps address space so a runaway allocation fails cleanly. */
  47. addressSpaceBytes: number
  48. /** Shared byte budget for captured log text (Python-side ledger). */
  49. maxLogBytes: number
  50. /** Byte cap for the rendered completion value. */
  51. maxValueBytes: number
  52. /**
  53. * The namespaces to materialize inside the program (globals + names;
  54. * functions stay host-side). See {@link Namespace}.
  55. */
  56. namespaces: Namespace[]
  57. }
  58. /** Host → Python: sent after `boot-ack`; carries only the model's program body. */
  59. interface RunMessage {
  60. type: 'run'
  61. program: string
  62. }
  63. /** Python → host: acknowledges boot completed and resource limits are in place. */
  64. interface BootAckMessage {
  65. type: 'boot-ack'
  66. }
  67. /** Python → host: one bridged binding call (`await tools.name(args)` inside the program). */
  68. interface CallMessage {
  69. type: 'call'
  70. /** Python-issued correlation id; the host answers each id at most once and ignores duplicates. */
  71. id: number
  72. /** The namespace global the call targets. */
  73. global: string
  74. /** The function name within the namespace. */
  75. name: string
  76. /** The JSON-safe argument the model program passed. */
  77. args: unknown
  78. }
  79. /**
  80. * Python → host: captured text, streamed eagerly so output survives a
  81. * mid-run termination (RLIMIT_CPU, SIGTERM/SIGKILL, host wall-timeout).
  82. */
  83. interface LogMessage {
  84. type: 'log'
  85. text: string
  86. /**
  87. * Set when this frame IS the child ledger's truncation marker rather than
  88. * program output. The two ledgers can exhaust at different points — one
  89. * child entry larger than `maxLogBytes` sends only the marker while the host
  90. * ledger is still nearly empty — so the host cannot infer the child's state
  91. * from its own budget, and comparing the text against the marker string
  92. * would also honour a program that printed that string itself. Carrying it
  93. * as a field lets the host stop capturing at the same point the child did
  94. * and keeps exactly one marker in `logs`.
  95. */
  96. truncated?: boolean
  97. /**
  98. * Set on the frame an explicit `flush()` (or the settlement flush) pushes for
  99. * an UNTERMINATED line: the host holds it and appends the next log frame to
  100. * the same entry, so `print('a', end='', flush=True); print('b')` reads back
  101. * as one `'ab'` entry rather than a fake newline between two entries.
  102. */
  103. open?: boolean
  104. }
  105. /** The failure carried on a {@link DoneMessage}: one of three kinds plus text. */
  106. interface DoneErrorField {
  107. kind: 'exception' | 'invalid-output' | 'output-limit'
  108. message: string
  109. }
  110. /**
  111. * Python → host: the program settled. `error` carries a program exception
  112. * (traceback text), an `invalid-output` (completion value was not lossless
  113. * JSON), or an `output-limit` (serialized completion exceeded the configured
  114. * cap); wall/CPU budgets, aborts, and substrate death are observed host-side.
  115. * From the honest child `value` is present only on a clean completion that
  116. * produced one, and crosses as exact lossless JSON — never substituted or
  117. * truncated. A forged frame CAN carry both `value` and `error`;
  118. * {@link validateChildFrame} preserves both rather than guessing which to drop,
  119. * so a consumer MUST check `error` first and ignore `value` when it is set.
  120. */
  121. interface DoneMessage {
  122. type: 'done'
  123. value?: unknown
  124. error?: DoneErrorField
  125. }
  126. /**
  127. * Every message the Python side sends. The member interfaces stay module-
  128. * private: consumers match on the union's discriminant; the host sends the
  129. * boot and run frames as inline literals.
  130. */
  131. export type ChildToHost = BootAckMessage | CallMessage | LogMessage | DoneMessage
  132. /** Host → Python: successful answer to one {@link CallMessage}. */
  133. interface ReplyOk {
  134. type: 'reply'
  135. id: number
  136. ok: true
  137. value: unknown
  138. }
  139. /** Host → Python: failed answer to one {@link CallMessage}. */
  140. interface ReplyErr {
  141. type: 'reply'
  142. id: number
  143. ok: false
  144. message: string
  145. }
  146. /** Host → Python: the answer to one {@link CallMessage}. */
  147. export type ReplyMessage = ReplyOk | ReplyErr
  148. /** The required (non-optional) keys of `T`, as string literals. */
  149. type RequiredKeys<T> = { [K in keyof T]-?: object extends Pick<T, K> ? never : K }[keyof T] & string
  150. /** The optional keys of `T`, as string literals. */
  151. type OptionalKeys<T> = { [K in keyof T]-?: object extends Pick<T, K> ? K : never }[keyof T] & string
  152. /**
  153. * Whether each key of frame `T` is a `'required'` or `'optional'` wire field.
  154. * Because it is `Record<keyof T, …>`, an entry MUST list every key — a field
  155. * added to the interface without a corresponding entry fails typecheck — and
  156. * `keyof T`-typed keys reject a name no frame declares. The `'required'` /
  157. * `'optional'` tag must match the field's actual optionality (checked by the
  158. * `satisfies FrameFieldRoles<…>` clause on {@link WIRE_FRAME_FIELD_ROLES}), so
  159. * an optionality flip is caught too. This is the exhaustive counterpart the
  160. * array form could not express (a subset array satisfied it silently).
  161. */
  162. type FrameFieldRoles<T> = Record<RequiredKeys<T>, 'required'> & Record<OptionalKeys<T>, 'optional'>
  163. interface WireFrameShapes {
  164. BootMessage: BootMessage
  165. Namespace: Namespace
  166. RunMessage: RunMessage
  167. BootAckMessage: BootAckMessage
  168. CallMessage: CallMessage
  169. LogMessage: LogMessage
  170. DoneErrorField: DoneErrorField
  171. DoneMessage: DoneMessage
  172. ErrorClass: ErrorClass
  173. ReplyOk: ReplyOk
  174. ReplyErr: ReplyErr
  175. }
  176. /**
  177. * The frames carried on a message union: everything the host and child send as
  178. * a top-level frame (`ChildToHost`, the two reply variants, and the host→child
  179. * boot/run frames). The nested shapes `Namespace`, `ErrorClass`, and
  180. * `DoneErrorField` are fields of other frames, not frames themselves, so they
  181. * are excluded here and covered only by the roles `satisfies` and the mirror e2e.
  182. */
  183. type MessageFrames = ChildToHost | ReplyMessage | BootMessage | RunMessage
  184. /** The roster's value types minus the three nested (non-frame) shapes. */
  185. type RosterMessageFrames = Exclude<WireFrameShapes[keyof WireFrameShapes], Namespace | ErrorClass | DoneErrorField>
  186. /**
  187. * Compile-time proof that {@link WireFrameShapes}'s message-frame entries are
  188. * EXACTLY the frames on the message unions — checked BOTH directions. Forward
  189. * (`MessageFrames extends RosterMessageFrames`) catches a frame added to a union
  190. * without a roster entry; reverse (`RosterMessageFrames extends MessageFrames`)
  191. * catches a frame removed from a union while the roster still lists it (e.g.
  192. * dropping `ReplyErr` from `ReplyMessage`). Either divergence makes an alias
  193. * `false`, failing the assignment below. Type-only; the `const`s emit nothing
  194. * meaningful at runtime.
  195. */
  196. type UnionSubsetOfRoster = [MessageFrames] extends [RosterMessageFrames] ? true : false
  197. type RosterSubsetOfUnion = [RosterMessageFrames] extends [MessageFrames] ? true : false
  198. const _unionSubsetOfRoster: UnionSubsetOfRoster = true
  199. const _rosterSubsetOfUnion: RosterSubsetOfUnion = true
  200. void _unionSubsetOfRoster
  201. void _rosterSubsetOfUnion
  202. /**
  203. * Each frame's wire fields tagged by required/optional, keyed by field name so
  204. * the mapping is exhaustive over the frame interface (see {@link FrameFieldRoles})
  205. * across the whole {@link WireFrameShapes} roster. Bound to the interfaces by
  206. * `satisfies` below; {@link WIRE_FRAME_FIELDS} projects it to sorted
  207. * required/optional arrays for the cross-language mirror comparison. `global` is
  208. * the JSON key {@link CallMessage} and {@link Namespace} send (a reserved word
  209. * the Python side carries via a functional `TypedDict`).
  210. */
  211. const WIRE_FRAME_FIELD_ROLES = {
  212. BootMessage: { type: 'required', cpuSeconds: 'required', addressSpaceBytes: 'required', maxLogBytes: 'required', maxValueBytes: 'required', namespaces: 'required' },
  213. Namespace: { global: 'required', names: 'required', errorClass: 'optional' },
  214. RunMessage: { type: 'required', program: 'required' },
  215. BootAckMessage: { type: 'required' },
  216. CallMessage: { type: 'required', id: 'required', global: 'required', name: 'required', args: 'required' },
  217. LogMessage: { type: 'required', text: 'required', truncated: 'optional', open: 'optional' },
  218. DoneErrorField: { kind: 'required', message: 'required' },
  219. DoneMessage: { type: 'required', value: 'optional', error: 'optional' },
  220. ErrorClass: { name: 'required', memberNameProperty: 'required' },
  221. ReplyOk: { type: 'required', id: 'required', ok: 'required', value: 'required' },
  222. ReplyErr: { type: 'required', id: 'required', ok: 'required', message: 'required' },
  223. } as const satisfies { [K in keyof WireFrameShapes]: FrameFieldRoles<WireFrameShapes[K]> }
  224. /**
  225. * The wire field names of each frame, split into sorted required and optional
  226. * key arrays — the shape the cross-language mirror test compares against
  227. * `py/protocol.py`'s `TypedDict` `__required_keys__`/`__optional_keys__`.
  228. * Projected from {@link WIRE_FRAME_FIELD_ROLES}, so it inherits that mapping's
  229. * exhaustive, optionality-checked binding to the frame interfaces: a TS-side
  230. * field add, remove, rename, or optionality flip fails typecheck at the roles
  231. * map, and a Python-side divergence fails the mirror test at runtime.
  232. */
  233. export const WIRE_FRAME_FIELDS =
  234. Object.fromEntries(
  235. Object.entries(WIRE_FRAME_FIELD_ROLES).map(([frame, roles]) => {
  236. const required = Object.keys(roles).filter(key => (roles as Record<string, string>)[key] === 'required').sort()
  237. const optional = Object.keys(roles).filter(key => (roles as Record<string, string>)[key] === 'optional').sort()
  238. return [frame, { required, optional }]
  239. }),
  240. ) as Record<keyof typeof WIRE_FRAME_FIELD_ROLES, { required: string[]; optional: string[] }>
  241. /**
  242. * The in-band marker text announcing that log capture stopped at the byte
  243. * budget. Shared wire vocabulary: the Python-side LogBuffer emits it when ITS
  244. * ledger exhausts, and the host emits identical text when its own ledger drops
  245. * a frame first (forged fd-3 traffic, stray stdout bytes) — a truncated run
  246. * reads the same however the cap was hit.
  247. * @param maxBytes - the configured `maxLogBytes` the marker names.
  248. * @returns the marker line.
  249. */
  250. export function logTruncationMarker(maxBytes: number): string {
  251. return `[dsh-code-runtime-python] log capture truncated at ${maxBytes} bytes`
  252. }
  253. /**
  254. * Serialize one JSON-parse-produced value without recursion. `JSON.stringify`
  255. * recurses per nesting level and throws `RangeError` a few thousand levels
  256. * deep, but the seam's `CodeJsonValue` has no depth limit — an honest deep
  257. * completion or binding resolution below the byte budget must cross intact
  258. * (the worker backend's wire is equally stack-safe). Callers must pass a value
  259. * produced by `JSON.parse` (or equally JSON-plain): only `null`, finite
  260. * numbers, booleans, strings, dense arrays, and plain objects — this encoder
  261. * validates nothing. Output matches compact `JSON.stringify` byte for byte
  262. * EXCEPT on an integral double beyond the safe range, where {@link scalarJson}
  263. * emits the exact integer's BigInt digits rather than `JSON.stringify`'s rounded
  264. * spelling (`1152921504606846976`, not `...847000`) so the seam's lossless-JSON
  265. * promise holds across the wire.
  266. * @param value - a JSON-plain value (e.g. straight from `JSON.parse`).
  267. * @returns the compact JSON encoding.
  268. */
  269. export function encodeJsonPlain(value: unknown): string {
  270. // The task stack holds every member of the currently open containers — O(width)
  271. // — but the encoded OUTPUT is itself O(total bytes) and the stack holds only
  272. // references, so the walk's auxiliary state is same-order as its result; the
  273. // metering walks (checkDoneValue/hasNonLosslessNumber) are the ones that must
  274. // stay O(depth), since they can reject a wide payload without producing any
  275. // output. Exempted by that same-order argument.
  276. type Task = { text: string } | { value: unknown }
  277. const chunks: string[] = []
  278. const tasks: Task[] = [{ value }]
  279. for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
  280. if ('text' in task) {
  281. chunks.push(task.text)
  282. continue
  283. }
  284. const current = task.value
  285. if (typeof current === 'string') {
  286. chunks.push(JSON.stringify(current))
  287. } else if (Array.isArray(current)) {
  288. chunks.push('[')
  289. tasks.push({ text: ']' })
  290. for (let index = current.length - 1; index >= 0; index--) {
  291. if (index < current.length - 1) tasks.push({ text: ',' })
  292. tasks.push({ value: current[index] })
  293. }
  294. } else if (typeof current === 'object' && current !== null) {
  295. const record = current as Record<string, unknown>
  296. chunks.push('{')
  297. tasks.push({ text: '}' })
  298. const keys = Object.keys(record)
  299. for (let index = keys.length - 1; index >= 0; index--) {
  300. const key = keys[index] as string
  301. if (index < keys.length - 1) tasks.push({ text: ',' })
  302. tasks.push({ value: record[key] })
  303. tasks.push({ text: `${JSON.stringify(key)}:` })
  304. }
  305. } else {
  306. chunks.push(scalarJson(current))
  307. }
  308. }
  309. return chunks.join('')
  310. }
  311. /**
  312. * One scalar (null, boolean, finite number) as JSON text. A beyond-safe-range
  313. * integral double needs BigInt digits: `String(2 ** 60)` emits the ROUNDED
  314. * `...847000` form, and echoing that to the child would silently change the
  315. * integer the seam promised to carry losslessly — `BigInt(2 ** 60)` prints the
  316. * exact `...846976` the double actually holds.
  317. * @param current - a JSON-plain scalar (JSON.parse emits nothing else).
  318. * @returns its JSON encoding.
  319. */
  320. function scalarJson(current: unknown): string {
  321. if (typeof current === 'number' && Number.isInteger(current) && !Number.isSafeInteger(current)) {
  322. return BigInt(current).toString()
  323. }
  324. return String(current)
  325. }
  326. /**
  327. * Exact UTF-8 byte length of one string's compact JSON form (quotes + escapes),
  328. * computed by a single non-allocating scan that stops the instant the running
  329. * total exceeds `maxBytes`. Used instead of `Buffer.byteLength(JSON.stringify(s))`
  330. * so a control-heavy forged string — whose escaped copy expands up to ~6x — is
  331. * rejected BEFORE that copy is materialized: `JSON.stringify` would allocate the
  332. * full escaped form first, the very hundreds-of-MB spike the metered traversal
  333. * exists to avoid. Mirrors `JSON.stringify`'s escaping byte-for-byte: `"` and
  334. * `\` and the five short C0 escapes cost 2, other C0 controls `\uXXXX` cost 6, a
  335. * valid surrogate pair is one astral code point emitted as raw 4-byte UTF-8, a
  336. * LONE surrogate becomes `\uXXXX` at 6, and any other code point costs its raw
  337. * UTF-8 width.
  338. * @param text - the string to meter.
  339. * @param maxBytes - largest serialized size the caller can still admit.
  340. * @returns the exact serialized byte length, or `undefined` once it exceeds `maxBytes`.
  341. */
  342. function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined {
  343. let bytes = 2 // the two quotes
  344. if (bytes > maxBytes) return undefined
  345. for (let index = 0; index < text.length; index++) {
  346. const code = text.charCodeAt(index)
  347. if (code === 0x22 || code === 0x5c || code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) {
  348. bytes += 2 // `\"` `\\` `\b` `\t` `\n` `\f` `\r`
  349. } else if (code < 0x20) {
  350. bytes += 6 // other C0 controls: `\uXXXX`
  351. } else if (code < 0x80) {
  352. bytes += 1
  353. } else if (code < 0x800) {
  354. bytes += 2
  355. } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) {
  356. const next = text.charCodeAt(index + 1)
  357. if (next >= 0xdc00 && next <= 0xdfff) {
  358. bytes += 4 // valid high+low pair: one astral code point, raw 4-byte UTF-8
  359. index++
  360. } else {
  361. bytes += 6 // lone high surrogate: `\uXXXX`
  362. }
  363. } else if (code >= 0xd800 && code <= 0xdfff) {
  364. bytes += 6 // lone surrogate (unpaired high at end, or any low): `\uXXXX`
  365. } else {
  366. bytes += 3 // other BMP code point
  367. }
  368. if (bytes > maxBytes) return undefined
  369. }
  370. return bytes
  371. }
  372. /**
  373. * Meter a `JSON.parse`-produced done value's compact-JSON byte length AND its
  374. * number losslessness in one traversal, stopping the instant `maxBytes` is
  375. * crossed. This bounds the INCREMENTAL allocation the check itself would add on
  376. * top of the already-parsed value — the enqueued children; strings and keys are
  377. * metered by {@link jsonStringBytesUpTo} without allocating an escaped copy —
  378. * not the parse that produced `value`.
  379. * That upstream width is bounded separately, by the host-side cap on inbound
  380. * fd-3 frame size before `JSON.parse` runs (owned by the runtime that reads the
  381. * channel), so `value` cannot be arbitrarily large when it reaches here. The
  382. * budget is the `maxValueBytes` the boot frame carries — a required wire field
  383. * with no default at this layer. The traversal rejects over-budget BEFORE
  384. * materializing a string's escaped form or enqueuing an array's/object's
  385. * children, so a forgery within that frame cap cannot force those secondary
  386. * allocations. Object key COUNTING is
  387. * unavoidably O(keys) — JS has no lazy own-key iterator, and the parse already
  388. * built the key set — but the check still refuses the per-entry work before the
  389. * enqueue loop. A non-lossless number (non-finite, negative zero) is caught only
  390. * when the value fits the budget — an over-budget value is rejected regardless,
  391. * so the distinction is moot. Same JSON-plain precondition and traversal shape
  392. * as {@link encodeJsonPlain}; a number's byte length is measured through
  393. * {@link scalarJson} (matching the encoder, so a beyond-safe-range integer
  394. * meters its exact BigInt digits, not `JSON.stringify`'s rounded spelling) and
  395. * a string's/key's through {@link jsonStringBytesUpTo} (the exact escaped size,
  396. * scanned without allocating the escaped copy).
  397. * @param value - a JSON-plain value (e.g. straight from `JSON.parse`).
  398. * @param maxBytes - the completion-value budget in bytes.
  399. * @returns `{ ok: true, bytes }` with the exact serialized size, or
  400. * `{ ok: false, reason }` — `over-budget` once the size exceeds `maxBytes`,
  401. * `non-lossless` on a non-finite or negative-zero number.
  402. */
  403. export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; bytes: number } | { ok: false; reason: 'over-budget' | 'non-lossless' } {
  404. let bytes = 0
  405. // A non-lossless number is recorded, not returned on sight: over-budget must
  406. // win regardless of where in the value each violation sits, so the whole
  407. // metering finishes first. Otherwise `["<huge>", 1e400]` and `[1e400,
  408. // "<huge>"]` — the same over-budget value in two member orders — would
  409. // classify differently (non-lossless vs over-budget), and the JSDoc promises
  410. // an over-budget value is rejected as over-budget regardless.
  411. let nonLossless = false
  412. // One cursor per OPEN container (a values iterator for the root and arrays,
  413. // an entries iterator for objects), mirroring hasNonLosslessNumber and the
  414. // child's _check_done_value: a wide completion near the frame cap would
  415. // otherwise copy every member's reference onto an explicit work stack —
  416. // O(width) — OOMing the host after the parse already succeeded. The byte
  417. // budget still bounds the walk: each member is metered as its cursor yields
  418. // it, and the width lower-bound checks below bail an over-budget container
  419. // before the cursor descends.
  420. const cursors: Cursor[] = [{ kind: 'values', iter: [value].values() }]
  421. while (cursors.length > 0) {
  422. // The loop condition guarantees a top cursor.
  423. const cursor = cursors.at(-1) as Cursor
  424. const step = cursor.iter.next()
  425. if (step.done === true) {
  426. cursors.pop()
  427. continue
  428. }
  429. let current: unknown
  430. if (cursor.kind === 'entries') {
  431. // Meter the key's escaped form without allocating it (same reason as the
  432. // string branch), then add the colon separator, before the value's own
  433. // bytes are counted.
  434. const [key, member] = step.value as readonly [string, unknown]
  435. const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes)
  436. if (keyBytes === undefined) return { ok: false, reason: 'over-budget' }
  437. bytes += keyBytes + 1
  438. current = member
  439. } else {
  440. current = step.value
  441. }
  442. if (typeof current === 'number') {
  443. // Flag a non-lossless number but keep counting its encoded bytes: a value
  444. // that is BOTH non-lossless and over-budget must classify as over-budget
  445. // (the loop's byte check below wins), so the byte count cannot skip the
  446. // offending number. `scalarJson` gives the same spelling a legit scalar
  447. // would meter.
  448. if (!Number.isFinite(current) || Object.is(current, -0)) nonLossless = true
  449. bytes += Buffer.byteLength(scalarJson(current), 'utf8')
  450. } else if (typeof current === 'string') {
  451. // Meter the escaped form WITHOUT allocating it: jsonStringBytesUpTo scans
  452. // and bails the instant the running cost crosses the remaining budget, so
  453. // a control-heavy forgery (escaped copy up to ~6x) never materializes that
  454. // copy the way `JSON.stringify` would.
  455. const stringBytes = jsonStringBytesUpTo(current, maxBytes - bytes)
  456. if (stringBytes === undefined) return { ok: false, reason: 'over-budget' }
  457. bytes += stringBytes
  458. } else if (Array.isArray(current)) {
  459. // Brackets plus one comma per gap; elements add themselves. Reject
  460. // BEFORE the cursor descends: every element serializes to at least one
  461. // byte, so a forged flat array far above the budget fails here without
  462. // the cursor yielding any of them. (The array itself is already
  463. // materialized by the upstream parse; this only bounds the extra walk.)
  464. bytes += 2 + (current.length > 1 ? current.length - 1 : 0)
  465. if (bytes + current.length > maxBytes) return { ok: false, reason: 'over-budget' }
  466. cursors.push({ kind: 'values', iter: (current as unknown[]).values() })
  467. } else if (typeof current === 'object' && current !== null) {
  468. const record = current as Record<string, unknown>
  469. // Count own keys with for...in + hasOwn. This IS O(keys) — JS has no lazy
  470. // own-key iterator and the parse already built the key set — so the count
  471. // cannot be sublinear; what the bound below buys is refusing the per-entry
  472. // work (key escaping, value enqueue) before it runs. Each entry costs at
  473. // least a quoted key (>= 2 bytes) + colon + >= 1-byte value.
  474. let count = 0
  475. for (const key in record) if (Object.hasOwn(record, key)) count += 1
  476. bytes += 2 + (count > 1 ? count - 1 : 0)
  477. if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' }
  478. cursors.push({ kind: 'entries', iter: ownEntries(record) })
  479. } else {
  480. bytes += Buffer.byteLength(scalarJson(current), 'utf8')
  481. }
  482. if (bytes > maxBytes) return { ok: false, reason: 'over-budget' }
  483. }
  484. // The whole value fit the budget; a recorded number violation is the verdict.
  485. if (nonLossless) return { ok: false, reason: 'non-lossless' }
  486. return { ok: true, bytes }
  487. }
  488. /**
  489. * Whether a raw JSON line contains an integer token that would lose precision
  490. * as a JavaScript number. `JSON.parse` silently rounds such a token
  491. * (`9007199254740993` becomes `...992`) BEFORE any validation can see it, so
  492. * the check must read the source text; a beyond-safe-range token whose double
  493. * parse round-trips exactly (`2**53`, `2**60`) is lossless and passes. The scan walks the line skipping string literals (a digit run
  494. * inside a string is data, not a number token) and tests every number token
  495. * in plain integer form — no fraction or exponent, which parse as doubles by
  496. * intent. A reviver cannot do this job: the reviver walk recurses per nesting
  497. * level and would reintroduce the depth limit `encodeJsonPlain` removes.
  498. * @param line - the raw UTF-8 text of one JSON-lines frame.
  499. * @returns true when an unsafe integer token is present outside strings.
  500. */
  501. export function hasUnsafeIntegerToken(line: string): boolean {
  502. for (let index = 0; index < line.length; index++) {
  503. const char = line[index]
  504. if (char === '"') {
  505. // Skip the string literal, honoring backslash escapes.
  506. for (index++; index < line.length; index++) {
  507. if (line[index] === '\\') index++
  508. else if (line[index] === '"') break
  509. }
  510. continue
  511. }
  512. if (char === '-' || (char !== undefined && char >= '0' && char <= '9')) {
  513. let end = index + 1
  514. while (end < line.length) {
  515. const c = line[end] as string
  516. if ((c >= '0' && c <= '9') || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-') end++
  517. else break
  518. }
  519. const token = line.slice(index, end)
  520. // Beyond the safe range an integer token is still lossless IFF the
  521. // double parse round-trips exactly (2**53 does; 2**53+1 rounds) — the
  522. // canonical boundary accepts every JS-double-exact value, so only a
  523. // genuinely rounding token marks the frame as forged.
  524. if (/^-?\d+$/.test(token)) {
  525. const parsed = Number(token)
  526. // A token that parses to Infinity is trivially lossy; a finite
  527. // beyond-safe-range one is lossy only when the BigInt round-trip
  528. // disagrees.
  529. if (!Number.isFinite(parsed)) return true
  530. if (!Number.isSafeInteger(parsed) && BigInt(token) !== BigInt(parsed)) return true
  531. }
  532. index = end - 1
  533. }
  534. }
  535. return false
  536. }
  537. /**
  538. * One open container in checkDoneValue's cursor walk: a values iterator (the
  539. * root and arrays) or an entries iterator (objects, so each key's escaped
  540. * bytes can be metered when the entry is reached). A cursor bounds the walk's
  541. * auxiliary state to O(depth), not O(width).
  542. */
  543. type Cursor =
  544. | { kind: 'values'; iter: Iterator<unknown> }
  545. | { kind: 'entries'; iter: Iterator<readonly [string, unknown]> }
  546. /**
  547. * Lazily yield one plain object's own enumerable [key, value] entries. The
  548. * key escapes are metered when {@link checkDoneValue}'s cursor walk reaches
  549. * each entry, so a wide object never materializes a member list: each entry
  550. * is produced straight off the already-parsed record, and the escaped key
  551. * bytes are counted without building the escaped string.
  552. * @param record - a JSON-parse-produced object.
  553. * @yields each own enumerable [key, value] pair, in key order.
  554. */
  555. function* ownEntries(record: Record<string, unknown>): Generator<readonly [string, unknown]> {
  556. for (const key in record) {
  557. if (Object.hasOwn(record, key)) yield [key, record[key]]
  558. }
  559. }
  560. /**
  561. * Lazily yield one plain object's own enumerable property values. A generator
  562. * (not `Object.values`/`Object.entries`) because {@link hasNonLosslessNumber}
  563. * walks breadth it cannot bound: those helpers copy the whole VALUE (or
  564. * key/value pair) list into a fresh array up front, so a wide object would cost
  565. * that second full-breadth allocation before a single value is examined. The
  566. * `for...in` here does not make the walk sublinear — V8 still materializes the
  567. * key-name enumeration when the loop starts — but it avoids the extra value
  568. * array, yielding each value straight off the already-parsed object.
  569. * @param record - a JSON-parse-produced object.
  570. * @yields each own enumerable property value, in key order.
  571. */
  572. function* ownValues(record: object): Generator {
  573. for (const key in record) {
  574. if (Object.hasOwn(record, key)) yield (record as Record<string, unknown>)[key]
  575. }
  576. }
  577. /**
  578. * Whether a JSON.parse-produced value contains a number outside lossless
  579. * JSON: non-finite (`1e400` parses to `Infinity`) or negative zero (`-0.0`
  580. * parses to JS `-0`, whose sign bit a re-serialization drops). The honest
  581. * child's validator rejects these before sending, so a frame carrying one is
  582. * forged.
  583. *
  584. * Runs on `call.args`, which — unlike a completion value — has NO seam byte
  585. * cap, so there is no budget to reject a wide payload against the way
  586. * {@link checkDoneValue} does. The traversal therefore holds ONE cursor per
  587. * NESTING LEVEL (an array or {@link ownValues} iterator) instead of one entry
  588. * per member: a forged flat `args` at the top of the host's inbound frame-size
  589. * cap would
  590. * otherwise push tens of millions of stack entries — and `Object.values` would
  591. * copy each object's full breadth — allocating hundreds of megabytes beyond
  592. * what `JSON.parse` already holds. Iterative either way, so a deep frame
  593. * cannot overflow the host stack.
  594. * @param value - a JSON-parse-produced value from an fd-3 frame.
  595. * @returns true when any contained number is non-finite or negative zero.
  596. */
  597. export function hasNonLosslessNumber(value: unknown): boolean {
  598. const cursors: Iterator<unknown>[] = [[value].values()]
  599. while (cursors.length > 0) {
  600. // The loop condition guarantees a top cursor.
  601. const cursor = cursors.at(-1) as Iterator<unknown>
  602. const step = cursor.next()
  603. if (step.done === true) {
  604. cursors.pop()
  605. continue
  606. }
  607. const current = step.value
  608. if (typeof current === 'number') {
  609. if (!Number.isFinite(current) || Object.is(current, -0)) return true
  610. } else if (Array.isArray(current)) {
  611. cursors.push((current as unknown[]).values())
  612. } else if (typeof current === 'object' && current !== null) {
  613. cursors.push(ownValues(current))
  614. }
  615. }
  616. return false
  617. }
  618. /**
  619. * Runtime shape gate for inbound fd-3 traffic. Model code has full access to
  620. * fd 3 and can post anything — `null`, primitives, poisoned fields — so the
  621. * compile-time union means nothing here: every field is validated and REBUILT
  622. * before the host reads it (forged extras never ride along; a non-number id
  623. * can never be echoed into a reply). Junk returns `undefined` and is dropped
  624. * so a throw in the host's `message` handler cannot crash the host process.
  625. * @param raw - one JSON-parsed frame from fd 3.
  626. * @returns the rebuilt frame, or `undefined` to drop it silently.
  627. */
  628. export function validateChildFrame(raw: unknown): ChildToHost | undefined {
  629. if (typeof raw !== 'object' || raw === null) return undefined
  630. const m = raw as Record<string, unknown>
  631. switch (m.type) {
  632. case 'boot-ack':
  633. return { type: 'boot-ack' }
  634. case 'log':
  635. if (typeof m.text !== 'string') return undefined
  636. // Rebuilt, not passed through: a forged `truncated` of any other type
  637. // would reach the host as a truthy value and silence capture for the
  638. // rest of the run. Only the literal `true` counts; `open` likewise.
  639. return {
  640. type: 'log',
  641. text: m.text,
  642. ...m.truncated === true ? { truncated: true } : {},
  643. ...m.open === true ? { open: true } : {},
  644. }
  645. case 'call': {
  646. // The id must be a finite number: it is echoed verbatim into the reply
  647. // frame, and a forged `1e400` id (Infinity after JSON.parse) would make
  648. // the reply unencodable as strict JSON. Negative zero is rejected too:
  649. // it passes `Number.isFinite`, but the reply re-serializes it as `0`
  650. // (`JSON.stringify({id:-0})` is `{"id":0}`), colliding with a real call
  651. // whose id is `0` — the honest child never issues `-0`.
  652. if (typeof m.id !== 'number' || !Number.isFinite(m.id) || Object.is(m.id, -0) || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
  653. // A forged frame can omit `args` entirely; rebuilding it as `undefined`
  654. // would invoke the binding with a non-JSON value, bypassing the
  655. // lossless-JSON argument boundary. Any PRESENT value is JSON-plain by
  656. // construction (the frame came from JSON.parse), so presence is the
  657. // whole check.
  658. if (!Object.hasOwn(m, 'args')) return undefined
  659. // JSON.parse yields Infinity for 1e400 and preserves -0; both are
  660. // outside lossless JSON, and the honest child never sends them.
  661. if (hasNonLosslessNumber(m.args)) return undefined
  662. return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args }
  663. }
  664. case 'done': {
  665. // The value passes through untouched here: scanning it for non-lossless
  666. // numbers would push every member of a wide forged payload before any
  667. // byte cap runs. The done handler's bounded `checkDoneValue` folds the
  668. // losslessness check into the metered traversal, rejecting over-budget
  669. // before it enqueues children.
  670. const err = m.error
  671. if (err === undefined) {
  672. return m.value === undefined ? { type: 'done' } : { type: 'done', value: m.value }
  673. }
  674. if (typeof err !== 'object' || err === null) return undefined
  675. const { kind, message } = err as Record<string, unknown>
  676. if (typeof message !== 'string') return undefined
  677. if (kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') return undefined
  678. return m.value === undefined
  679. ? { type: 'done', error: { kind, message } }
  680. : { type: 'done', value: m.value, error: { kind, message } }
  681. }
  682. default:
  683. return undefined
  684. }
  685. }