protocol.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  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-code-runtime-python/src/protocol
  7. */
  8. // The protocol channel is fd 3 from the child's perspective — the host pins it
  9. // positionally via `stdio: ['pipe','pipe','pipe','pipe']` (index.ts), and the
  10. // Python bootstrap reads the same constant from its own protocol.py.
  11. /**
  12. * What the host sends immediately after spawn, as the first line on fd 3. The
  13. * Python bootstrap reads this, applies resource limits, then waits for the
  14. * subsequent run frame. Separated from the run so the run message stays
  15. * pure model input.
  16. */
  17. export interface BootMessage {
  18. type: 'boot'
  19. /** RLIMIT_CPU seconds; the Python bootstrap sets this on itself before executing model code. */
  20. cpuSeconds: number
  21. /** RLIMIT_AS bytes; caps address space so a runaway allocation fails cleanly. */
  22. addressSpaceBytes: number
  23. /** Shared byte budget for captured log text (Python-side ledger). */
  24. maxLogBytes: number
  25. /** Byte cap for the rendered completion value. */
  26. maxValueBytes: number
  27. /**
  28. * The namespaces to materialize inside the program (globals + names;
  29. * functions stay host-side). `errorClass` asks the bootstrap to mint a
  30. * program-visible exception class under that global: rejected calls raise
  31. * its instances carrying the member name on `memberNameProperty`.
  32. */
  33. namespaces: { global: string; names: string[]; errorClass?: { name: string; memberNameProperty: string } }[]
  34. }
  35. // The run request `{ type: 'run', program }` follows BootMessage once the
  36. // child acknowledges with `boot-ack`; the host sends it as an inline literal
  37. // (it carries only the model's program body — caps and bindings crossed on boot).
  38. /** Python → host: acknowledges boot completed and resource limits are in place. */
  39. interface BootAckMessage {
  40. type: 'boot-ack'
  41. }
  42. /** Python → host: one bridged binding call (`await tools.name(args)` inside the program). */
  43. interface CallMessage {
  44. type: 'call'
  45. /** Python-issued correlation id; the host answers each id at most once and ignores duplicates. */
  46. id: number
  47. /** The namespace global the call targets. */
  48. global: string
  49. /** The function name within the namespace. */
  50. name: string
  51. /** The JSON-safe argument the model program passed. */
  52. args: unknown
  53. }
  54. /**
  55. * Python → host: captured text, streamed eagerly so output survives a
  56. * mid-run termination (RLIMIT_CPU, SIGTERM/SIGKILL, host wall-timeout).
  57. */
  58. interface LogMessage {
  59. type: 'log'
  60. text: string
  61. /**
  62. * Set when this frame IS the child ledger's truncation marker rather than
  63. * program output. The two ledgers can exhaust at different points — one
  64. * child entry larger than `maxLogBytes` sends only the marker while the host
  65. * ledger is still nearly empty — so the host cannot infer the child's state
  66. * from its own budget, and comparing the text against the marker string
  67. * would also honour a program that printed that string itself. Carrying it
  68. * as a field lets the host stop capturing at the same point the child did
  69. * and keeps exactly one marker in `logs`.
  70. */
  71. truncated?: boolean
  72. }
  73. /**
  74. * Python → host: the program settled. `error` carries a program exception
  75. * (traceback text), an `invalid-output` (completion value was not lossless
  76. * JSON), or an `output-limit` (serialized completion exceeded the configured
  77. * cap); wall/CPU budgets, aborts, and substrate death are observed host-side.
  78. * From the honest child `value` is present only on a clean completion that
  79. * produced one, and crosses as exact lossless JSON — never substituted or
  80. * truncated. A forged frame CAN carry both `value` and `error`;
  81. * {@link validateChildFrame} preserves both rather than guessing which to drop,
  82. * so a consumer MUST check `error` first and ignore `value` when it is set.
  83. */
  84. interface DoneMessage {
  85. type: 'done'
  86. value?: unknown
  87. error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string }
  88. }
  89. /**
  90. * Every message the Python side sends. The member interfaces stay module-
  91. * private: consumers match on the union's discriminant; the host sends the
  92. * boot and run frames as inline literals.
  93. */
  94. export type ChildToHost = BootAckMessage | CallMessage | LogMessage | DoneMessage
  95. /** Host → Python: the answer to one {@link CallMessage}. */
  96. export type ReplyMessage =
  97. | { type: 'reply'; id: number; ok: true; value: unknown }
  98. | { type: 'reply'; id: number; ok: false; message: string }
  99. /**
  100. * The in-band marker text announcing that log capture stopped at the byte
  101. * budget. Shared wire vocabulary: the Python-side LogBuffer emits it when ITS
  102. * ledger exhausts, and the host emits identical text when its own ledger drops
  103. * a frame first (forged fd-3 traffic, stray stdout bytes) — a truncated run
  104. * reads the same however the cap was hit.
  105. * @param maxBytes - the configured `maxLogBytes` the marker names.
  106. * @returns the marker line.
  107. */
  108. export function logTruncationMarker(maxBytes: number): string {
  109. return `[dsh-code-runtime-python] log capture truncated at ${maxBytes} bytes`
  110. }
  111. /**
  112. * Serialize one JSON-parse-produced value without recursion. `JSON.stringify`
  113. * recurses per nesting level and throws `RangeError` a few thousand levels
  114. * deep, but the seam's `CodeJsonValue` has no depth limit — an honest deep
  115. * completion or binding resolution below the byte budget must cross intact
  116. * (the worker backend's wire is equally stack-safe). Callers must pass a value
  117. * produced by `JSON.parse` (or equally JSON-plain): only `null`, finite
  118. * numbers, booleans, strings, dense arrays, and plain objects — this encoder
  119. * validates nothing. Output matches compact `JSON.stringify` byte for byte
  120. * EXCEPT on an integral double beyond the safe range, where {@link scalarJson}
  121. * emits the exact integer's BigInt digits rather than `JSON.stringify`'s rounded
  122. * spelling (`1152921504606846976`, not `...847000`) so the seam's lossless-JSON
  123. * promise holds across the wire.
  124. * @param value - a JSON-plain value (e.g. straight from `JSON.parse`).
  125. * @returns the compact JSON encoding.
  126. */
  127. export function encodeJsonPlain(value: unknown): string {
  128. type Task = { text: string } | { value: unknown }
  129. const chunks: string[] = []
  130. const tasks: Task[] = [{ value }]
  131. for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
  132. if ('text' in task) {
  133. chunks.push(task.text)
  134. continue
  135. }
  136. const current = task.value
  137. if (typeof current === 'string') {
  138. chunks.push(JSON.stringify(current))
  139. } else if (Array.isArray(current)) {
  140. chunks.push('[')
  141. tasks.push({ text: ']' })
  142. for (let index = current.length - 1; index >= 0; index--) {
  143. if (index < current.length - 1) tasks.push({ text: ',' })
  144. tasks.push({ value: current[index] })
  145. }
  146. } else if (typeof current === 'object' && current !== null) {
  147. const record = current as Record<string, unknown>
  148. chunks.push('{')
  149. tasks.push({ text: '}' })
  150. const keys = Object.keys(record)
  151. for (let index = keys.length - 1; index >= 0; index--) {
  152. const key = keys[index] as string
  153. if (index < keys.length - 1) tasks.push({ text: ',' })
  154. tasks.push({ value: record[key] })
  155. tasks.push({ text: `${JSON.stringify(key)}:` })
  156. }
  157. } else {
  158. chunks.push(scalarJson(current))
  159. }
  160. }
  161. return chunks.join('')
  162. }
  163. /**
  164. * One scalar (null, boolean, finite number) as JSON text. A beyond-safe-range
  165. * integral double needs BigInt digits: `String(2 ** 60)` emits the ROUNDED
  166. * `...847000` form, and echoing that to the child would silently change the
  167. * integer the seam promised to carry losslessly — `BigInt(2 ** 60)` prints the
  168. * exact `...846976` the double actually holds.
  169. * @param current - a JSON-plain scalar (JSON.parse emits nothing else).
  170. * @returns its JSON encoding.
  171. */
  172. function scalarJson(current: unknown): string {
  173. if (typeof current === 'number' && Number.isInteger(current) && !Number.isSafeInteger(current)) {
  174. return BigInt(current).toString()
  175. }
  176. return String(current)
  177. }
  178. /**
  179. * Meter a forged done value's compact-JSON byte length AND its number
  180. * losslessness in one bounded traversal, stopping the instant `maxBytes` is
  181. * crossed. A forged `done.value` arrives straight off fd 3 and can sit anywhere
  182. * below the 256 MiB frame ceiling while `maxValueBytes` defaults to 32 KiB. The
  183. * previous split — an unbounded `hasNonLosslessNumber` scan in
  184. * {@link validateChildFrame} followed by a separate byte meter — pushed every
  185. * member of a wide flat payload onto a scan stack before any cap check ran, so
  186. * a below-ceiling forgery could still force a hundreds-of-megabytes host
  187. * allocation. Folding both jobs here rejects over-budget BEFORE enqueuing an
  188. * array's or object's children, keeping the traversal O(cap). A non-lossless
  189. * number (non-finite, negative zero) is caught only when the value fits the
  190. * budget — an over-budget value is rejected regardless, so the distinction is
  191. * moot. Same JSON-plain precondition and traversal shape as
  192. * {@link encodeJsonPlain}; per-scalar byte length is measured through
  193. * {@link scalarJson} (matching the encoder, so a beyond-safe-range integer
  194. * meters its exact BigInt digits, not `JSON.stringify`'s rounded spelling) and
  195. * `JSON.stringify` for strings.
  196. * @param value - a JSON-plain value (e.g. straight from `JSON.parse`).
  197. * @param maxBytes - the completion-value budget in bytes.
  198. * @returns `{ ok: true, bytes }` with the exact serialized size, or
  199. * `{ ok: false, reason }` — `over-budget` once the size exceeds `maxBytes`,
  200. * `non-lossless` on a non-finite or negative-zero number.
  201. */
  202. export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; bytes: number } | { ok: false; reason: 'over-budget' | 'non-lossless' } {
  203. let bytes = 0
  204. const stack: unknown[] = [value]
  205. while (stack.length > 0) {
  206. const current = stack.pop()
  207. if (typeof current === 'number') {
  208. if (!Number.isFinite(current) || Object.is(current, -0)) return { ok: false, reason: 'non-lossless' }
  209. bytes += Buffer.byteLength(scalarJson(current), 'utf8')
  210. } else if (typeof current === 'string') {
  211. // Lower-bound BEFORE materializing the escaped form: every UTF-16 code
  212. // unit is at least one UTF-8 byte plus the two quotes, so a huge or
  213. // control-heavy forged string (whose escaped copy expands severalfold)
  214. // is rejected without allocating that copy.
  215. if (bytes + current.length + 2 > maxBytes) return { ok: false, reason: 'over-budget' }
  216. bytes += Buffer.byteLength(JSON.stringify(current), 'utf8')
  217. } else if (Array.isArray(current)) {
  218. // Brackets plus one comma per gap; elements add themselves. Reject
  219. // BEFORE enqueuing children: every element serializes to at least one
  220. // byte, so a forged flat array below the frame ceiling but far above
  221. // the budget fails here without growing the host stack by millions of
  222. // entries first.
  223. bytes += 2 + (current.length > 1 ? current.length - 1 : 0)
  224. if (bytes + current.length > maxBytes) return { ok: false, reason: 'over-budget' }
  225. for (const item of current) stack.push(item)
  226. } else if (typeof current === 'object' && current !== null) {
  227. const record = current as Record<string, unknown>
  228. // Count own keys WITHOUT Object.entries/Object.keys (either allocates one
  229. // slot per member up front), AND bail mid-count the instant the minimum
  230. // encoding exceeds the budget: braces (+2), each entry a quoted key
  231. // (>= 2 bytes) + colon + >= 1-byte value (>= 4 bytes), and a comma per
  232. // gap. A forged wide object with millions of keys and a small cap must
  233. // fail in O(cap), not walk its whole breadth first. `bytes` still holds
  234. // the pre-object total throughout this loop.
  235. let count = 0
  236. for (const key in record) {
  237. if (!Object.hasOwn(record, key)) continue
  238. count += 1
  239. if (bytes + 2 + count * 4 + (count - 1) > maxBytes) return { ok: false, reason: 'over-budget' }
  240. }
  241. // The loop's final iteration already proved the whole object's lower
  242. // bound fits, so no separate post-count check is needed here.
  243. bytes += 2 + (count > 1 ? count - 1 : 0)
  244. for (const key in record) {
  245. if (!Object.hasOwn(record, key)) continue
  246. // The same string lower bound, before escaping the key.
  247. if (bytes + key.length + 3 > maxBytes) return { ok: false, reason: 'over-budget' }
  248. bytes += Buffer.byteLength(JSON.stringify(key), 'utf8') + 1
  249. stack.push(record[key])
  250. }
  251. } else {
  252. bytes += Buffer.byteLength(scalarJson(current), 'utf8')
  253. }
  254. if (bytes > maxBytes) return { ok: false, reason: 'over-budget' }
  255. }
  256. return { ok: true, bytes }
  257. }
  258. /**
  259. * Whether a raw JSON line contains an integer token that would lose precision
  260. * as a JavaScript number. `JSON.parse` silently rounds such a token
  261. * (`9007199254740993` becomes `...992`) BEFORE any validation can see it, so
  262. * the check must read the source text; a beyond-safe-range token whose double
  263. * parse round-trips exactly (`2**53`, `2**60`) is lossless and passes. The scan walks the line skipping string literals (a digit run
  264. * inside a string is data, not a number token) and tests every number token
  265. * in plain integer form — no fraction or exponent, which parse as doubles by
  266. * intent. A reviver cannot do this job: the reviver walk recurses per nesting
  267. * level and would reintroduce the depth limit `encodeJsonPlain` removes.
  268. * @param line - the raw UTF-8 text of one JSON-lines frame.
  269. * @returns true when an unsafe integer token is present outside strings.
  270. */
  271. export function hasUnsafeIntegerToken(line: string): boolean {
  272. for (let index = 0; index < line.length; index++) {
  273. const char = line[index]
  274. if (char === '"') {
  275. // Skip the string literal, honoring backslash escapes.
  276. for (index++; index < line.length; index++) {
  277. if (line[index] === '\\') index++
  278. else if (line[index] === '"') break
  279. }
  280. continue
  281. }
  282. if (char === '-' || (char !== undefined && char >= '0' && char <= '9')) {
  283. let end = index + 1
  284. while (end < line.length) {
  285. const c = line[end] as string
  286. if ((c >= '0' && c <= '9') || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-') end++
  287. else break
  288. }
  289. const token = line.slice(index, end)
  290. // Beyond the safe range an integer token is still lossless IFF the
  291. // double parse round-trips exactly (2**53 does; 2**53+1 rounds) — the
  292. // canonical boundary accepts every JS-double-exact value, so only a
  293. // genuinely rounding token marks the frame as forged.
  294. if (/^-?\d+$/.test(token)) {
  295. const parsed = Number(token)
  296. // A token that parses to Infinity is trivially lossy; a finite
  297. // beyond-safe-range one is lossy only when the BigInt round-trip
  298. // disagrees.
  299. if (!Number.isFinite(parsed)) return true
  300. if (!Number.isSafeInteger(parsed) && BigInt(token) !== BigInt(parsed)) return true
  301. }
  302. index = end - 1
  303. }
  304. }
  305. return false
  306. }
  307. /**
  308. * Lazily yield one plain object's own enumerable property values. A generator
  309. * (not `Object.values`/`Object.entries`) because {@link hasNonLosslessNumber}
  310. * traverses breadth it cannot bound: those helpers copy the whole member list
  311. * up front, so a wide forged object would cost a second full-breadth
  312. * allocation before a single value is examined.
  313. * @param record - a JSON-parse-produced object.
  314. * @yields each own enumerable property value, in key order.
  315. */
  316. function* ownValues(record: object): Generator {
  317. for (const key in record) {
  318. if (Object.hasOwn(record, key)) yield (record as Record<string, unknown>)[key]
  319. }
  320. }
  321. /**
  322. * Whether a JSON.parse-produced value contains a number outside lossless
  323. * JSON: non-finite (`1e400` parses to `Infinity`) or negative zero (`-0.0`
  324. * parses to JS `-0`, whose sign bit a re-serialization drops). The honest
  325. * child's validator rejects these before sending, so a frame carrying one is
  326. * forged.
  327. *
  328. * Runs on `call.args`, which — unlike a completion value — has NO seam byte
  329. * cap, so there is no budget to reject a wide payload against the way
  330. * {@link checkDoneValue} does. The traversal therefore holds ONE cursor per
  331. * NESTING LEVEL (an array or {@link ownValues} iterator) instead of one entry
  332. * per member: a forged flat `args` just below the 256 MiB frame ceiling would
  333. * otherwise push tens of millions of stack entries — and `Object.values` would
  334. * copy each object's full breadth — allocating hundreds of megabytes beyond
  335. * what `JSON.parse` already holds. Iterative either way, so a deep frame
  336. * cannot overflow the host stack.
  337. * @param value - a JSON-parse-produced value from an fd-3 frame.
  338. * @returns true when any contained number is non-finite or negative zero.
  339. */
  340. export function hasNonLosslessNumber(value: unknown): boolean {
  341. const cursors: Iterator<unknown>[] = [[value].values()]
  342. while (cursors.length > 0) {
  343. // The loop condition guarantees a top cursor.
  344. const cursor = cursors.at(-1) as Iterator<unknown>
  345. const step = cursor.next()
  346. if (step.done === true) {
  347. cursors.pop()
  348. continue
  349. }
  350. const current = step.value
  351. if (typeof current === 'number') {
  352. if (!Number.isFinite(current) || Object.is(current, -0)) return true
  353. } else if (Array.isArray(current)) {
  354. cursors.push((current as unknown[]).values())
  355. } else if (typeof current === 'object' && current !== null) {
  356. cursors.push(ownValues(current))
  357. }
  358. }
  359. return false
  360. }
  361. /**
  362. * Runtime shape gate for inbound fd-3 traffic. Model code has full access to
  363. * fd 3 and can post anything — `null`, primitives, poisoned fields — so the
  364. * compile-time union means nothing here: every field is validated and REBUILT
  365. * before the host reads it (forged extras never ride along; a non-number id
  366. * can never be echoed into a reply). Junk returns `undefined` and is dropped
  367. * so a throw in the host's `message` handler cannot crash the host process.
  368. * @param raw - one JSON-parsed frame from fd 3.
  369. * @returns the rebuilt frame, or `undefined` to drop it silently.
  370. */
  371. export function validateChildFrame(raw: unknown): ChildToHost | undefined {
  372. if (typeof raw !== 'object' || raw === null) return undefined
  373. const m = raw as Record<string, unknown>
  374. switch (m.type) {
  375. case 'boot-ack':
  376. return { type: 'boot-ack' }
  377. case 'log':
  378. if (typeof m.text !== 'string') return undefined
  379. // Rebuilt, not passed through: a forged `truncated` of any other type
  380. // would reach the host as a truthy value and silence capture for the
  381. // rest of the run. Only the literal `true` counts.
  382. return { type: 'log', text: m.text, ...m.truncated === true ? { truncated: true } : {} }
  383. case 'call': {
  384. // The id must be a finite number: it is echoed verbatim into the reply
  385. // frame, and a forged `1e400` id (Infinity after JSON.parse) would make
  386. // the reply unencodable as strict JSON. Negative zero is rejected too:
  387. // it passes `Number.isFinite`, but the reply re-serializes it as `0`
  388. // (`JSON.stringify({id:-0})` is `{"id":0}`), colliding with a real call
  389. // whose id is `0` — the honest child never issues `-0`.
  390. if (typeof m.id !== 'number' || !Number.isFinite(m.id) || Object.is(m.id, -0) || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
  391. // A forged frame can omit `args` entirely; rebuilding it as `undefined`
  392. // would invoke the binding with a non-JSON value, bypassing the
  393. // lossless-JSON argument boundary. Any PRESENT value is JSON-plain by
  394. // construction (the frame came from JSON.parse), so presence is the
  395. // whole check.
  396. if (!Object.hasOwn(m, 'args')) return undefined
  397. // JSON.parse yields Infinity for 1e400 and preserves -0; both are
  398. // outside lossless JSON, and the honest child never sends them.
  399. if (hasNonLosslessNumber(m.args)) return undefined
  400. return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args }
  401. }
  402. case 'done': {
  403. // The value passes through untouched here: scanning it for non-lossless
  404. // numbers would push every member of a wide forged payload before any
  405. // byte cap runs. The done handler's bounded `checkDoneValue` folds the
  406. // losslessness check into the metered traversal, rejecting over-budget
  407. // before it enqueues children.
  408. const err = m.error
  409. if (err === undefined) {
  410. return m.value === undefined ? { type: 'done' } : { type: 'done', value: m.value }
  411. }
  412. if (typeof err !== 'object' || err === null) return undefined
  413. const { kind, message } = err as Record<string, unknown>
  414. if (typeof message !== 'string') return undefined
  415. if (kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') return undefined
  416. return m.value === undefined
  417. ? { type: 'done', error: { kind, message } }
  418. : { type: 'done', value: m.value, error: { kind, message } }
  419. }
  420. default:
  421. return undefined
  422. }
  423. }