1
0

runtime.spec.ts 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902
  1. import { describe, expect, it, onTestFinished } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { WorkerThreadCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker-thread'
  4. import type { Config } from '@deepseek-ai/dsh-code-runtime-worker-thread'
  5. import type { CodeBindingFunction, CodeBindingNamespace, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
  6. /**
  7. * Integration suite over REAL worker threads (no mocks — workers are cheap
  8. * and local, per docs/testing.md's real-over-mock policy). Each test builds
  9. * a fresh context so budgets can be tuned per case.
  10. */
  11. async function setup(config: Config = {}) {
  12. const ctx = new Context()
  13. await ctx.plugin(WorkerThreadCodeRuntime, config)
  14. const runtime = ctx.codeRuntime as WorkerThreadCodeRuntime
  15. return { ctx, runtime }
  16. }
  17. /** Convenience: one namespace `tools` with the given functions. */
  18. function tools(functions: Record<string, (args: unknown) => Promise<unknown>>): CodeBindingNamespace[] {
  19. return [{
  20. global: 'tools',
  21. functions: functions as Record<string, CodeBindingFunction>,
  22. errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
  23. }]
  24. }
  25. describe('WorkerThreadCodeRuntime — programs and bindings (real workers)', () => {
  26. it('registers with the seam descriptors', async () => {
  27. const { runtime } = await setup()
  28. expect(runtime.language).toBe('typescript')
  29. expect(runtime.isolation).toBe('worker-thread')
  30. })
  31. it('runs TypeScript (erasable syntax), captures output in order, returns the value', async () => {
  32. const { runtime } = await setup()
  33. const result = await runtime.run({
  34. program: `
  35. interface Point { x: number; y: number }
  36. const p: Point = { x: 1, y: 2 } as Point;
  37. console.log('point', p);
  38. process.stdout.write('raw-out\\n');
  39. console.warn('careful');
  40. return p.x + p.y;
  41. `,
  42. bindings: [],
  43. })
  44. expect(result.error).toBeUndefined()
  45. expect(result.value).toBe(3)
  46. expect(result.logs).toEqual(['point { x: 1, y: 2 }', 'raw-out\n', 'careful'])
  47. })
  48. it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
  49. const { runtime } = await setup()
  50. const calls: unknown[] = []
  51. const result = await runtime.run({
  52. program: `
  53. const first = await tools.echo({ n: 1 });
  54. let caught = {};
  55. try { await tools.fail({}) } catch (error) { caught = { isTyped: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }
  56. let caughtRaw = {};
  57. try { await tools.failRaw({}) } catch (error) { caughtRaw = { name: error.name, toolName: error.toolName, message: error.message } }
  58. return { first, caught, caughtRaw };
  59. `,
  60. bindings: tools({
  61. echo: async (args) => { calls.push(args); return { echoed: args } },
  62. fail: async () => { throw new Error('nope') },
  63. // A non-Error throw: the host renders it, the program still catches.
  64. failRaw: async () => { throw 'raw-nope' },
  65. }),
  66. })
  67. expect(result.error).toBeUndefined()
  68. expect(result.value).toEqual({
  69. first: { echoed: { n: 1 } },
  70. caught: { isTyped: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
  71. caughtRaw: { name: 'ToolCallError', toolName: 'failRaw', message: 'raw-nope' },
  72. })
  73. expect(calls).toEqual([{ n: 1 }])
  74. })
  75. it('materializes a typed rejection from a generic namespace descriptor', async () => {
  76. const { runtime } = await setup()
  77. const result = await runtime.run({
  78. program: `
  79. try { await helpers.fail({}) } catch (error) {
  80. return {
  81. isTyped: error instanceof HelperCallError,
  82. name: error.name,
  83. helperName: error.helperName,
  84. message: error.message,
  85. };
  86. }
  87. `,
  88. bindings: [{
  89. global: 'helpers',
  90. functions: { fail: async () => { throw new Error('nope') } },
  91. errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
  92. }],
  93. })
  94. expect(result.value).toEqual({
  95. isTyped: true,
  96. name: 'HelperCallError',
  97. helperName: 'fail',
  98. message: 'nope',
  99. })
  100. })
  101. it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => {
  102. const { runtime } = await setup()
  103. const result = await runtime.run({
  104. program: `
  105. let value = 'leaf';
  106. for (let depth = 0; depth < 3_000; depth++) value = [value];
  107. return await tools.echo(value);
  108. `,
  109. bindings: tools({ echo: async args => args }),
  110. })
  111. expect(result.error).toBeUndefined()
  112. let cursor = result.value
  113. for (let depth = 0; depth < 3_000; depth++) {
  114. expect(Array.isArray(cursor)).toBe(true)
  115. cursor = Array.isArray(cursor) ? cursor[0] : undefined
  116. }
  117. expect(cursor).toBe('leaf')
  118. }, 15_000)
  119. it('reports non-erasable syntax as an exception without spawning a worker', async () => {
  120. const { runtime } = await setup()
  121. const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
  122. expect(result.error?.kind).toBe('exception')
  123. expect(result.error?.message).toMatch(/enum|strip/i)
  124. })
  125. it('reports a runtime throw as an exception with the message', async () => {
  126. const { runtime } = await setup()
  127. const result = await runtime.run({ program: 'throw new Error("kaboom")', bindings: [] })
  128. expect(result.error?.kind).toBe('exception')
  129. expect(result.error?.message).toContain('kaboom')
  130. })
  131. it('gives the program an EMPTY environment', async () => {
  132. const { runtime } = await setup()
  133. const result = await runtime.run({ program: 'return JSON.stringify(process.env)', bindings: [] })
  134. expect(result.value).toBe('{}')
  135. })
  136. it('rejects a non-lossless completion instead of replacing it with rendered text', async () => {
  137. const { runtime } = await setup()
  138. const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
  139. expect(result.value).toBeUndefined()
  140. expect(result.error).toEqual({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
  141. })
  142. it('completes a program that returns nothing with no value at all', async () => {
  143. const { runtime } = await setup()
  144. const result = await runtime.run({ program: 'const x = 1', bindings: [] })
  145. expect(result.error).toBeUndefined()
  146. expect('value' in result).toBe(false)
  147. })
  148. it('keeps logs streamed before a failure', async () => {
  149. const { runtime } = await setup()
  150. const result = await runtime.run({
  151. program: 'console.log("before"); throw new Error("after-log")',
  152. bindings: [],
  153. })
  154. expect(result.error?.kind).toBe('exception')
  155. expect(result.logs).toContain('before')
  156. })
  157. })
  158. describe('WorkerThreadCodeRuntime — budgets and containment (real workers)', () => {
  159. it('ends a hot loop at the compute budget — including behind a pending decoy dispatch', async () => {
  160. const { runtime } = await setup({ computeMs: 300, maxWallMs: 30_000 })
  161. const result = await runtime.run({
  162. // The decoy: fire a call at a never-resolving binding WITHOUT awaiting,
  163. // then spin. Host-side pending-call bookkeeping would pause a naive
  164. // budget here; measured busy time cannot be fooled.
  165. program: 'void tools.slow({}); for (;;) {}',
  166. bindings: tools({ slow: () => new Promise(() => {}) }),
  167. })
  168. expect(result.error?.kind).toBe('timeout')
  169. expect(result.error?.message).toContain('compute budget')
  170. }, 15_000)
  171. it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
  172. // Source-worker bootstrap consumes busy time before the binding can begin.
  173. // The idle delay must still exceed the entire compute allowance.
  174. const computeMs = 5_000
  175. const bindingDelayMs = computeMs + 1_500
  176. const { ctx, runtime } = await setup({ computeMs, maxWallMs: 30_000 })
  177. let bindingTimer: ReturnType<typeof setTimeout> | undefined
  178. onTestFinished(async () => {
  179. clearTimeout(bindingTimer)
  180. await ctx.fiber.dispose()
  181. })
  182. const result = await runtime.run({
  183. program: 'return await tools.slow({})',
  184. bindings: tools({ slow: () => new Promise((resolve) => {
  185. bindingTimer = setTimeout(() => { resolve('slow-done') }, bindingDelayMs)
  186. }) }),
  187. })
  188. expect(result.error).toBeUndefined()
  189. expect(result.value).toBe('slow-done')
  190. }, 15_000)
  191. it('ends an idle-forever run at the wall-clock ceiling', async () => {
  192. const { runtime } = await setup({ computeMs: 30_000, maxWallMs: 400 })
  193. const result = await runtime.run({
  194. program: 'await tools.never({}); return 1',
  195. bindings: tools({ never: () => new Promise(() => {}) }),
  196. })
  197. expect(result.error?.kind).toBe('timeout')
  198. expect(result.error?.message).toContain('wall-clock ceiling')
  199. }, 15_000)
  200. it('reports an abort mid-run and stops the worker', async () => {
  201. const { runtime } = await setup()
  202. const controller = new AbortController()
  203. setTimeout(() => { controller.abort('user-cancel') }, 150)
  204. const result = await runtime.run({ program: 'for (;;) {}', bindings: [], signal: controller.signal })
  205. expect(result.error).toEqual({ kind: 'abort', message: 'user-cancel' })
  206. }, 15_000)
  207. it('reports a pre-aborted signal without spawning', async () => {
  208. const { runtime } = await setup()
  209. const controller = new AbortController()
  210. controller.abort('too-late')
  211. const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
  212. expect(result.error).toEqual({ kind: 'abort', message: 'too-late' })
  213. })
  214. it('applies the outer-output cap to failures before worker startup', async () => {
  215. const capped = await setup({ maxOutputBytes: 64 })
  216. const controller = new AbortController()
  217. controller.abort('A'.repeat(1_000))
  218. const aborted = await capped.runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
  219. expect(aborted).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } })
  220. const minimal = await setup({ maxOutputBytes: 4 })
  221. const invalid = await minimal.runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
  222. expect(invalid.error?.kind).toBe('output-limit')
  223. expect(Buffer.byteLength(JSON.stringify(invalid.logs), 'utf8') + Buffer.byteLength(JSON.stringify(invalid.error?.message), 'utf8')).toBeLessThanOrEqual(4)
  224. })
  225. it('drops a binding resolution that lands after the run settled', async () => {
  226. const { runtime } = await setup()
  227. const controller = new AbortController()
  228. let replyDelivered!: Promise<void>
  229. const result = await runtime.run({
  230. program: 'void tools.late({}); for (;;) {}',
  231. bindings: tools({
  232. // Anchored on invocation: abort 100ms after the call reaches the
  233. // host, resolve 400ms after — by then the run has settled, so the
  234. // resolution's reply hits the post-settlement drop.
  235. late: () => new Promise((resolve) => {
  236. setTimeout(() => { controller.abort('cancel-now') }, 100)
  237. replyDelivered = new Promise(done => setTimeout(() => { resolve('too-late'); done() }, 400))
  238. }),
  239. }),
  240. signal: controller.signal,
  241. })
  242. expect(result.error).toEqual({ kind: 'abort', message: 'cancel-now' })
  243. // Let the late resolution actually fire so its reply executes instead of
  244. // being cancelled with the test.
  245. await replyDelivered
  246. }, 15_000)
  247. it('contains an OOM under resourceLimits as worker-exit, host process healthy', async () => {
  248. const { runtime } = await setup({ maxOldGenerationSizeMb: 32 })
  249. const result = await runtime.run({
  250. program: 'const hog = []; for (;;) hog.push(new Array(1e6).fill(1));',
  251. bindings: [],
  252. })
  253. expect(result.error?.kind).toBe('worker-exit')
  254. // And the host is fine: run something else.
  255. const after = await runtime.run({ program: 'return "alive"', bindings: [] })
  256. expect(after.value).toBe('alive')
  257. }, 30_000)
  258. it('reports a worker that exits before publishing a completion', async () => {
  259. const { runtime } = await setup()
  260. const result = await runtime.run({ program: 'process.exit(7)', bindings: [] })
  261. expect(result).toEqual({
  262. logs: [],
  263. error: { kind: 'worker-exit', message: 'worker exited with code 7 before completing' },
  264. })
  265. })
  266. it('fails runaway log output explicitly while retaining a bounded prefix', async () => {
  267. const { runtime } = await setup({ maxOutputBytes: 300 })
  268. const result = await runtime.run({
  269. program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
  270. bindings: [],
  271. })
  272. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 300 bytes' })
  273. expect(result.value).toBeUndefined()
  274. expect(result.logs.length).toBeGreaterThan(0)
  275. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(300)
  276. })
  277. it('retains a fitting prefix when one oversized log is the first output', async () => {
  278. const { runtime } = await setup({ maxOutputBytes: 96 })
  279. const result = await runtime.run({
  280. program: 'console.log(`start-${`😀"\\\\\\n`.repeat(100)}`); return null',
  281. bindings: [],
  282. })
  283. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
  284. expect(result.logs).toHaveLength(1)
  285. expect(result.logs[0]?.startsWith('start-')).toBe(true)
  286. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
  287. + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(96)
  288. })
  289. it('fails an oversized return value without substituting a string', async () => {
  290. const { runtime } = await setup({ maxOutputBytes: 64 })
  291. const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
  292. expect(result.value).toBeUndefined()
  293. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
  294. })
  295. it('uses UTF-8 serialized bytes at the exact completion boundary', async () => {
  296. const exact = await setup({ maxOutputBytes: 7 })
  297. const exactResult = await exact.runtime.run({ program: 'return "€"', bindings: [] })
  298. // [] costs two bytes and JSON serialization of "€" costs five.
  299. expect(exactResult).toEqual({ logs: [], value: '€' })
  300. const over = await setup({ maxOutputBytes: 6 })
  301. const overResult = await over.runtime.run({ program: 'return "€"', bindings: [] })
  302. expect(overResult.error?.kind).toBe('output-limit')
  303. })
  304. it('accounts logs and completion in one exact combined ledger', async () => {
  305. // JSON(["abc"]) is seven bytes and JSON("xy") is four.
  306. const exact = await setup({ maxOutputBytes: 11 })
  307. expect(await exact.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] }))
  308. .toEqual({ logs: ['abc'], value: 'xy' })
  309. const over = await setup({ maxOutputBytes: 10 })
  310. const result = await over.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] })
  311. expect(result.value).toBeUndefined()
  312. expect(result.error?.kind).toBe('output-limit')
  313. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
  314. })
  315. it('accounts logs and exception diagnostics before the worker port boundary', async () => {
  316. // JSON(["abc"]) is seven bytes and JSON("xy") is four.
  317. const exact = await setup({ maxOutputBytes: 11 })
  318. expect(await exact.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] }))
  319. .toEqual({ logs: ['abc'], error: { kind: 'exception', message: 'xy' } })
  320. const over = await setup({ maxOutputBytes: 10 })
  321. const result = await over.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] })
  322. expect(result.error?.kind).toBe('output-limit')
  323. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
  324. + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
  325. })
  326. it('does not send a giant Error stack across the worker port', async () => {
  327. const { runtime } = await setup({ maxOutputBytes: 64 })
  328. const result = await runtime.run({
  329. program: 'throw new Error("x".repeat(1_000_000))',
  330. bindings: [],
  331. })
  332. expect(result).toEqual({
  333. logs: [],
  334. error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
  335. })
  336. })
  337. it('completes a program that awaits its write callback, capturing the chunk', async () => {
  338. // Node's write(chunk[, encoding][, callback]) contract: dropping the
  339. // callback would leave this promise pending until the wall ceiling and
  340. // misreport a completed program as a timeout.
  341. const { runtime } = await setup({ maxWallMs: 2_000 })
  342. const result = await runtime.run({
  343. program: 'await new Promise(resolve => process.stdout.write("flushed", resolve)); return "done"',
  344. bindings: [],
  345. })
  346. expect(result.error).toBeUndefined()
  347. expect(result.value).toBe('done')
  348. expect(result.logs).toContain('flushed')
  349. })
  350. it('returns a large JSON container exactly when the outer cap permits it', async () => {
  351. const { runtime } = await setup()
  352. const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] })
  353. expect(result.error).toBeUndefined()
  354. expect(result.value).toEqual(new Array(50_000).fill(7))
  355. })
  356. it('returns an exact completion at the default 64 MiB combined boundary', async () => {
  357. const { runtime } = await setup()
  358. // [] costs two bytes and the JSON string contributes two quotes, leaving
  359. // exactly this many payload bytes under the 67_108_864-byte default.
  360. const result = await runtime.run({ program: 'return "x".repeat(67_108_860)', bindings: [] })
  361. expect(result.error).toBeUndefined()
  362. expect(result.logs).toEqual([])
  363. expect(result.value).toHaveLength(67_108_860)
  364. }, 60_000)
  365. it('fails one byte over the default 64 MiB combined boundary', async () => {
  366. const { runtime } = await setup()
  367. const result = await runtime.run({ program: 'return "x".repeat(67_108_861)', bindings: [] })
  368. expect(result.value).toBeUndefined()
  369. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' })
  370. }, 60_000)
  371. it('accounts pipe writes that bypass the patched write slot in the same outer ledger', async () => {
  372. const { runtime } = await setup({ maxOutputBytes: 80 })
  373. const result = await runtime.run({
  374. // The prototype write bypasses the patched instance and reaches the real pipe. Pauses keep
  375. // writes in separate chunks and let both reach the host before settlement.
  376. program: `
  377. const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
  378. write('a'.repeat(20));
  379. await new Promise(resolve => setTimeout(resolve, 150));
  380. write('b'.repeat(100));
  381. await new Promise(resolve => setTimeout(resolve, 100));
  382. return 1;
  383. `,
  384. bindings: [],
  385. })
  386. expect(result.error?.kind).toBe('output-limit')
  387. expect(result.logs).toContain('a'.repeat(20))
  388. expect(result.logs[1]?.length).toBeGreaterThan(0)
  389. expect('b'.repeat(100).startsWith(result.logs[1] ?? '')).toBe(true)
  390. }, 15_000)
  391. it('drains pipe output queued before terminal worker teardown completes', async () => {
  392. const { runtime } = await setup({ maxOutputBytes: 200_000 })
  393. const payload = `late-pipe-${'x'.repeat(100_000)}`
  394. const result = await runtime.run({
  395. program: `
  396. const { parentPort } = await import('node:worker_threads');
  397. const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
  398. write('late-pipe-' + 'x'.repeat(100_000));
  399. parentPort.postMessage({ type: 'done', value: ['done'] });
  400. for (;;) {}
  401. `,
  402. bindings: [],
  403. })
  404. expect(result.error).toBeUndefined()
  405. expect(result.value).toBe('done')
  406. expect(result.logs.join('') === payload).toBe(true)
  407. }, 15_000)
  408. })
  409. describe('WorkerThreadCodeRuntime — hostile programs (real workers)', () => {
  410. it('survives forged port traffic: unknown binding names, duplicate ids, junk shapes', async () => {
  411. const { runtime } = await setup()
  412. const result = await runtime.run({
  413. program: `
  414. const { parentPort } = await import('node:worker_threads');
  415. parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
  416. parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
  417. parentPort.postMessage({ type: 'call', id: 7778, global: 'tools', name: 'constructor', args: {} });
  418. parentPort.postMessage({ type: 'junk' });
  419. return await tools.real({});
  420. `,
  421. bindings: tools({ real: async () => 'still-works' }),
  422. })
  423. expect(result.error).toBeUndefined()
  424. expect(result.value).toBe('still-works')
  425. })
  426. it('survives arbitrary junk on the port: non-objects, junk types, malformed calls, logs, and dones', async () => {
  427. const { runtime } = await setup()
  428. const result = await runtime.run({
  429. program: `
  430. const { parentPort } = await import('node:worker_threads');
  431. for (const junk of [
  432. null, 42, 'junk', [],
  433. { type: 'nope' },
  434. { type: 'call' },
  435. { type: 'call', id: 'x', global: 'tools', name: 'real', args: {} },
  436. { type: 'call', id: 1e9, global: 7, name: 'real', args: {} },
  437. { type: 'call', id: 1e9, global: 'tools', name: 7, args: {} },
  438. { type: 'log' },
  439. { type: 'log', text: null },
  440. { type: 'log', text: 7 },
  441. { type: 'log', text: {} },
  442. { type: 'done', error: 5 },
  443. { type: 'done', error: { kind: 'exception', message: 5 } },
  444. { type: 'done', error: { kind: 'invented', message: 'bad kind' } },
  445. ]) parentPort.postMessage(junk);
  446. return await tools.real({});
  447. `,
  448. bindings: tools({ real: async () => 'still-works' }),
  449. })
  450. expect(result.error).toBeUndefined()
  451. expect(result.value).toBe('still-works')
  452. expect(result.logs).toEqual([])
  453. })
  454. it('fails forged log floods and forged done values through the same outer cap', async () => {
  455. const { runtime } = await setup({ maxOutputBytes: 200 })
  456. const result = await runtime.run({
  457. // Forged messages bypass the worker-side LogBuffer and completion check
  458. // entirely — only the host-side ledger and re-cap stand between model
  459. // code and an unbounded result.
  460. program: `
  461. const { parentPort } = await import('node:worker_threads');
  462. for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true });
  463. parentPort.postMessage({ type: 'done', value: ['V'.repeat(100000)] });
  464. for (;;) {}
  465. `,
  466. bindings: [],
  467. })
  468. expect(result.value).toBeUndefined()
  469. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 200 bytes' })
  470. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
  471. })
  472. it('re-caps an oversized forged done value at the host boundary', async () => {
  473. const { runtime } = await setup({ maxOutputBytes: 64 })
  474. const result = await runtime.run({
  475. program: `
  476. const { parentPort } = await import('node:worker_threads');
  477. parentPort.postMessage({ type: 'done', value: ['V'.repeat(100_000)] });
  478. for (;;) {}
  479. `,
  480. bindings: [],
  481. })
  482. expect(result).toEqual({
  483. logs: [],
  484. error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
  485. })
  486. })
  487. it('bounds one oversized forged log while retaining its fitting escaped prefix', async () => {
  488. const { runtime } = await setup({ maxOutputBytes: 96 })
  489. const result = await runtime.run({
  490. program: `
  491. const { parentPort } = await import('node:worker_threads');
  492. parentPort.postMessage({ type: 'log', text: '"'.repeat(1_000_000) });
  493. for (;;) {}
  494. `,
  495. bindings: [],
  496. })
  497. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
  498. expect(result.logs).toHaveLength(1)
  499. expect(result.logs[0]).toMatch(/^"+$/)
  500. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify('outer output exceeded 96 bytes'), 'utf8')).toBeLessThanOrEqual(96)
  501. })
  502. it('drops a malformed forged done carrying both value and error', async () => {
  503. const { runtime } = await setup()
  504. const result = await runtime.run({
  505. program: `
  506. const { parentPort } = await import('node:worker_threads');
  507. parentPort.postMessage({ type: 'done', value: 'lied', error: { kind: 'exception', message: 'fake failure' } });
  508. return 'honest';
  509. `,
  510. bindings: [],
  511. })
  512. expect(result).toEqual({ logs: [], error: { kind: 'exception', message: 'fake failure' } })
  513. })
  514. it('contains a deeply nested forged completion without overflowing the host meter', async () => {
  515. const { runtime } = await setup()
  516. const result = await runtime.run({
  517. program: `
  518. const { parentPort } = await import('node:worker_threads');
  519. const value = [];
  520. for (let depth = 0; depth < 3_000; depth++) value.push({ kind: 'array', length: 1 });
  521. value.push(null);
  522. setTimeout(() => { parentPort.postMessage({ type: 'done', value }) }, 25);
  523. // Prevent bootstrap's normal undefined completion from racing the forged terminal.
  524. await new Promise(() => {});
  525. `,
  526. bindings: [],
  527. })
  528. expect(result.error).toBeUndefined()
  529. let value = result.value
  530. let depth = 0
  531. while (Array.isArray(value)) {
  532. expect(value).toHaveLength(1)
  533. value = value[0]
  534. depth += 1
  535. }
  536. expect(depth).toBe(3_000)
  537. expect(value).toBeNull()
  538. }, 15_000)
  539. it('turns forged over-limit error text into output-limit at the host', async () => {
  540. const { runtime } = await setup({ maxOutputBytes: 64 })
  541. const result = await runtime.run({
  542. program: `
  543. const { parentPort } = await import('node:worker_threads');
  544. parentPort.postMessage({ type: 'done', error: { kind: 'exception', message: '€'.repeat(1000) } });
  545. for (;;) {}
  546. `,
  547. bindings: [],
  548. })
  549. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
  550. })
  551. it('answers a binding whose resolution is not lossless JSON with a typed failure reply', async () => {
  552. const { runtime } = await setup()
  553. const result = await runtime.run({
  554. program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
  555. bindings: tools({ bad: async () => (() => 1) }),
  556. })
  557. expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
  558. })
  559. it('rejects lossy binding arguments in the worker before invoking the host binding', async () => {
  560. const { runtime } = await setup()
  561. let calls = 0
  562. const result = await runtime.run({
  563. program: `
  564. const decorated = [1]; Object.defineProperty(decorated, 'extra', { value: true });
  565. const values = [new Date(), decorated, () => 1];
  566. const failures = [];
  567. for (const value of values) {
  568. try { await tools.never(value) } catch (error) {
  569. failures.push({ typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message });
  570. }
  571. }
  572. return failures;
  573. `,
  574. bindings: tools({ never: async () => { calls += 1; return null } }),
  575. })
  576. expect(calls).toBe(0)
  577. expect(result.value).toEqual(new Array(3).fill({
  578. typed: true,
  579. name: 'ToolCallError',
  580. toolName: 'never',
  581. message: 'binding arguments must be lossless JSON',
  582. }))
  583. })
  584. it('rejects intrinsic-looking exotic objects as arguments and completions', async () => {
  585. const { runtime } = await setup()
  586. let calls = 0
  587. const forgeObject = `
  588. const prototype = Object.create(null);
  589. const SpoofedObject = function Object() {};
  590. SpoofedObject.prototype = prototype;
  591. Object.defineProperty(prototype, 'constructor', { value: SpoofedObject });
  592. const forged = Object.assign(Object.create(prototype), { value: 1 });
  593. Function.prototype.toString = () => 'function Object() { [native code] }';
  594. `
  595. const argument = await runtime.run({
  596. program: `${forgeObject}
  597. try { await tools.never(forged) } catch (error) {
  598. return { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
  599. }
  600. `,
  601. bindings: tools({ never: async () => { calls += 1; return null } }),
  602. })
  603. expect(calls).toBe(0)
  604. expect(argument.value).toEqual({
  605. typed: true,
  606. name: 'ToolCallError',
  607. toolName: 'never',
  608. message: 'binding arguments must be lossless JSON',
  609. })
  610. const completion = await runtime.run({ program: `${forgeObject}\nreturn forged`, bindings: [] })
  611. expect(completion).toEqual({
  612. logs: [],
  613. error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
  614. })
  615. })
  616. it('preserves binding and completion JSON after model code mutates boundary globals', async () => {
  617. const { runtime } = await setup()
  618. const result = await runtime.run({
  619. program: `
  620. const arrayPrototype = Array.prototype;
  621. const objectPrototype = Object.prototype;
  622. const setPrototype = Set.prototype;
  623. const stringPrototype = String.prototype;
  624. Array.isArray = () => false;
  625. arrayPrototype.at = arrayPrototype.includes = arrayPrototype.pop = arrayPrototype.push = () => { throw new Error('mutated array method') };
  626. Object.defineProperty = Object.getOwnPropertyDescriptor = Object.getPrototypeOf = Object.keys = () => { throw new Error('mutated object method') };
  627. Object.hasOwn = () => false;
  628. Object.is = () => true;
  629. objectPrototype.propertyIsEnumerable = () => false;
  630. Number.isFinite = Number.isSafeInteger = () => false;
  631. Reflect.apply = Reflect.ownKeys = () => { throw new Error('mutated reflect method') };
  632. setPrototype.add = setPrototype.delete = setPrototype.has = () => { throw new Error('mutated set method') };
  633. stringPrototype.charCodeAt = stringPrototype.codePointAt = stringPrototype.slice = () => { throw new Error('mutated string method') };
  634. Buffer.byteLength = () => 0;
  635. Function.prototype.toString = () => 'mutated';
  636. objectPrototype.get = () => undefined;
  637. objectPrototype.constructor = arrayPrototype.constructor = null;
  638. globalThis.Array = globalThis.Buffer = globalThis.Error = globalThis.Function = globalThis.Number = globalThis.Object = globalThis.Reflect = globalThis.Set = globalThis.String = undefined;
  639. const echoed = await tools.echo({ request: ['€', 1] });
  640. let failure;
  641. try { await tools.fail({}) } catch (error) {
  642. failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
  643. }
  644. return { echoed, failure, completion: { ok: true, amount: 42 } };
  645. `,
  646. bindings: tools({ echo: async args => args, fail: async () => { throw new Error('nope') } }),
  647. })
  648. expect(result).toEqual({
  649. logs: [],
  650. value: {
  651. echoed: { request: ['€', 1] },
  652. failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
  653. completion: { ok: true, amount: 42 },
  654. },
  655. })
  656. })
  657. it('rejects forged lossy binding arguments again at the host boundary', async () => {
  658. const { runtime } = await setup()
  659. let calls = 0
  660. const result = await runtime.run({
  661. program: `
  662. const { parentPort } = await import('node:worker_threads');
  663. const forged = (id, args) => new Promise((resolve) => {
  664. const receive = (message) => {
  665. if (message?.type !== 'reply' || message.id !== id) return;
  666. parentPort.off('message', receive);
  667. resolve(message);
  668. };
  669. parentPort.on('message', receive);
  670. parentPort.postMessage({ type: 'call', id, global: 'tools', name: 'never', args });
  671. });
  672. const sparse = []; sparse.length = 1;
  673. const cycle = {}; cycle.self = cycle;
  674. return await Promise.all([
  675. forged(8001, new Date()),
  676. forged(8002, -0),
  677. forged(8003, sparse),
  678. forged(8004, cycle),
  679. ]);
  680. `,
  681. bindings: tools({ never: async () => { calls += 1; return null } }),
  682. })
  683. expect(calls).toBe(0)
  684. expect(result.value).toEqual([8001, 8002, 8003, 8004].map(id => ({
  685. type: 'reply',
  686. id,
  687. ok: false,
  688. message: 'binding arguments must be lossless JSON',
  689. })))
  690. })
  691. it('contains throwing getters while snapshotting binding resolutions', async () => {
  692. const { runtime } = await setup()
  693. const result = await runtime.run({
  694. program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
  695. bindings: tools({ bad: async () => Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('getter exploded') } }) }),
  696. })
  697. expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
  698. })
  699. it('revalidates a forged lossy completion at the host boundary', async () => {
  700. const { runtime } = await setup()
  701. const result = await runtime.run({
  702. program: `
  703. const { parentPort } = await import('node:worker_threads');
  704. parentPort.postMessage({ type: 'done', value: -0 });
  705. for (;;) {}
  706. `,
  707. bindings: [],
  708. })
  709. expect(result).toEqual({ logs: [], error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } })
  710. })
  711. it('honors a forged worker-side output-limit signal', async () => {
  712. const { runtime } = await setup()
  713. const result = await runtime.run({
  714. program: `
  715. const { parentPort } = await import('node:worker_threads');
  716. parentPort.postMessage({ type: 'output-limit' });
  717. for (;;) {}
  718. `,
  719. bindings: [],
  720. })
  721. expect(result).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' } })
  722. })
  723. it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
  724. const { runtime } = await setup()
  725. const result = await runtime.run({
  726. program: 'return [await tools["__proto__"]({}), await tools["constructor"]({}), typeof tools["hasOwnProperty"]]',
  727. // Computed keys: a literal `'__proto__': …` entry would SET the record's
  728. // prototype instead of declaring a binding of that name.
  729. bindings: tools({ ['__proto__']: async () => 'proto-ok', ['constructor']: async () => 'ctor-ok' }),
  730. })
  731. expect(result.value).toEqual(['proto-ok', 'ctor-ok', 'undefined'])
  732. })
  733. })
  734. describe('WorkerThreadCodeRuntime — seam misuse and lifecycle', () => {
  735. it('rejects invalid and duplicate binding globals loudly', async () => {
  736. const { runtime } = await setup()
  737. const cases: [string, RegExp][] = [
  738. ['not valid!', /not a usable identifier/],
  739. ['await', /not a usable identifier/],
  740. // `$tools` is legal JS but outside the seam's language-portable subset:
  741. // the same namespace list must work against every backend's language.
  742. ['$tools', /not a usable identifier/],
  743. // `a$b` pins the second character class too: the old identifier regex
  744. // `[A-Za-z0-9_$]*` would have accepted a `$` after the first character.
  745. ['a$b', /not a usable identifier/],
  746. // `lambda` is a Python keyword, refused here directly (not just
  747. // transitively) so the worker's adoption of PORTABLE_RESERVED_WORDS is
  748. // its own regression, symmetric with the `$tools` case.
  749. ['lambda', /not a usable identifier/],
  750. ['console', /reserved binding global/],
  751. ]
  752. for (const [global, message] of cases) {
  753. await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
  754. }
  755. await expect(runtime.run({
  756. program: 'return 1',
  757. bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
  758. })).rejects.toThrow(/duplicate binding global/)
  759. await expect(runtime.run({
  760. program: 'return typeof ToolCallError',
  761. bindings: [{ global: 'ToolCallError', functions: {} }],
  762. })).resolves.toMatchObject({ value: 'object' })
  763. })
  764. it('rejects malformed or colliding binding error-class declarations', async () => {
  765. const { runtime } = await setup()
  766. const run = async (bindings: CodeBindingNamespace[]) => await runtime.run({ program: 'return 1', bindings })
  767. const namespace = (global: string, name: string, memberNameProperty = 'memberName'): CodeBindingNamespace => ({
  768. global,
  769. functions: {},
  770. errorClass: { name, memberNameProperty },
  771. })
  772. await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
  773. await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
  774. await expect(run([namespace('tools', 'console')])).rejects.toThrow(/reserved binding global/)
  775. await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
  776. await expect(run([
  777. namespace('tools', 'CallError'),
  778. namespace('helpers', 'CallError'),
  779. ])).rejects.toThrow(/duplicate injected global/)
  780. await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
  781. await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
  782. // The shared exclusion set covers Python's exception-protocol members and
  783. // dunders too, so the same errorClass is valid (or not) on every backend.
  784. await expect(run([namespace('tools', 'CallError', 'args')])).rejects.toThrow(/member property.*not usable/)
  785. await expect(run([namespace('tools', 'CallError', '__dict__')])).rejects.toThrow(/member property.*not usable/)
  786. // The Python backend's owned globals are refused here too (shared
  787. // RESERVED_BINDING_GLOBALS), keeping namespace lists backend-portable.
  788. await expect(runtime.run({ program: 'return 1', bindings: [{ global: '__dsh_main__', functions: {} }] }))
  789. .rejects.toThrow(/reserved binding global/)
  790. })
  791. it('rejects config values that are not positive numbers', async () => {
  792. const ctx = new Context()
  793. await expect(ctx.plugin(WorkerThreadCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/)
  794. })
  795. it('rejects a maxWallMs above Node\'s maximum timer delay', async () => {
  796. // setTimeout clamps a delay past 2^31-1 ms to 1 ms, so the positivity check
  797. // alone would accept a 25-day ceiling that expires on the first tick.
  798. const ctx = new Context()
  799. await expect(ctx.plugin(WorkerThreadCodeRuntime, { maxWallMs: 2_147_483_648 }))
  800. .rejects.toThrow(/maxWallMs must be at most 2147483647/)
  801. // The boundary itself is usable.
  802. await expect(ctx.plugin(WorkerThreadCodeRuntime, { maxWallMs: 2_147_483_647 })).resolves.toBeTruthy()
  803. })
  804. it('requires maxOutputBytes to fit the smallest counted outer payloads', async () => {
  805. const ctx = new Context()
  806. await expect(ctx.plugin(WorkerThreadCodeRuntime, { maxOutputBytes: 3 })).rejects.toThrow(/safe integer of at least 4/)
  807. await expect(ctx.plugin(WorkerThreadCodeRuntime, { maxOutputBytes: 4.5 })).rejects.toThrow(/safe integer of at least 4/)
  808. })
  809. it('keeps runs isolated: no state survives from one run to the next', async () => {
  810. const { runtime } = await setup()
  811. await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })
  812. const second = await runtime.run({ program: 'return typeof globalThis.leak', bindings: [] })
  813. expect(second.value).toBe('undefined')
  814. })
  815. it('disposal aborts in-flight runs, awaits worker exit, and rejects later runs', async () => {
  816. const ctx = new Context()
  817. const fiber = await ctx.plugin(WorkerThreadCodeRuntime)
  818. const runtime = ctx.codeRuntime as WorkerThreadCodeRuntime
  819. const inflight: Promise<CodeRunResult> = runtime.run({ program: 'for (;;) {}', bindings: [] })
  820. // Give the worker a moment to actually start spinning.
  821. await new Promise(resolve => setTimeout(resolve, 200))
  822. await fiber.dispose()
  823. const result = await inflight
  824. expect(result.error).toEqual({ kind: 'abort', message: 'runtime disposed' })
  825. await expect(runtime.run({ program: 'return 1', bindings: [] })).rejects.toThrow(/after disposal/)
  826. }, 15_000)
  827. it('removes ctx.codeRuntime when the providing fiber disposes (HMR safety)', async () => {
  828. const ctx = new Context()
  829. const fiber = await ctx.plugin(WorkerThreadCodeRuntime)
  830. expect(ctx.get('codeRuntime')).toBeInstanceOf(WorkerThreadCodeRuntime)
  831. await fiber.dispose()
  832. expect(ctx.get('codeRuntime')).toBeUndefined()
  833. })
  834. })