runtime.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
  4. import type { Config } from '@deepseek-ai/dsh-code-runtime-worker'
  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(WorkerCodeRuntime, config)
  14. const runtime = ctx.codeRuntime as WorkerCodeRuntime
  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 [{ global: 'tools', functions: functions as Record<string, CodeBindingFunction> }]
  20. }
  21. describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
  22. it('registers with the seam descriptors', async () => {
  23. const { runtime } = await setup()
  24. expect(runtime.language).toBe('typescript')
  25. expect(runtime.isolation).toBe('worker-thread')
  26. })
  27. it('runs TypeScript (erasable syntax), captures output in order, returns the value', async () => {
  28. const { runtime } = await setup()
  29. const result = await runtime.run({
  30. program: `
  31. interface Point { x: number; y: number }
  32. const p: Point = { x: 1, y: 2 } as Point;
  33. console.log('point', p);
  34. process.stdout.write('raw-out\\n');
  35. console.warn('careful');
  36. return p.x + p.y;
  37. `,
  38. bindings: [],
  39. })
  40. expect(result.error).toBeUndefined()
  41. expect(result.value).toBe(3)
  42. expect(result.logs).toEqual(['point { x: 1, y: 2 }', 'raw-out\n', 'careful'])
  43. })
  44. it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
  45. const { runtime } = await setup()
  46. const calls: unknown[] = []
  47. const result = await runtime.run({
  48. program: `
  49. const first = await tools.echo({ n: 1 });
  50. let caught = {};
  51. try { await tools.fail({}) } catch (error) { caught = { isTyped: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }
  52. let caughtRaw = {};
  53. try { await tools.failRaw({}) } catch (error) { caughtRaw = { name: error.name, toolName: error.toolName, message: error.message } }
  54. return { first, caught, caughtRaw };
  55. `,
  56. bindings: tools({
  57. echo: async (args) => { calls.push(args); return { echoed: args } },
  58. fail: async () => { throw new Error('nope') },
  59. // A non-Error throw: the host renders it, the program still catches.
  60. failRaw: async () => { throw 'raw-nope' },
  61. }),
  62. })
  63. expect(result.error).toBeUndefined()
  64. expect(result.value).toEqual({
  65. first: { echoed: { n: 1 } },
  66. caught: { isTyped: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
  67. caughtRaw: { name: 'ToolCallError', toolName: 'failRaw', message: 'raw-nope' },
  68. })
  69. expect(calls).toEqual([{ n: 1 }])
  70. })
  71. it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => {
  72. const { runtime } = await setup()
  73. const result = await runtime.run({
  74. program: `
  75. let value = 'leaf';
  76. for (let depth = 0; depth < 3_000; depth++) value = [value];
  77. return await tools.echo(value);
  78. `,
  79. bindings: tools({ echo: async args => args }),
  80. })
  81. expect(result.error).toBeUndefined()
  82. let cursor = result.value
  83. for (let depth = 0; depth < 3_000; depth++) {
  84. expect(Array.isArray(cursor)).toBe(true)
  85. cursor = Array.isArray(cursor) ? cursor[0] : undefined
  86. }
  87. expect(cursor).toBe('leaf')
  88. }, 15_000)
  89. it('reports non-erasable syntax as an exception without spawning a worker', async () => {
  90. const { runtime } = await setup()
  91. const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
  92. expect(result.error?.kind).toBe('exception')
  93. expect(result.error?.message).toMatch(/enum|strip/i)
  94. })
  95. it('reports a runtime throw as an exception with the message', async () => {
  96. const { runtime } = await setup()
  97. const result = await runtime.run({ program: 'throw new Error("kaboom")', bindings: [] })
  98. expect(result.error?.kind).toBe('exception')
  99. expect(result.error?.message).toContain('kaboom')
  100. })
  101. it('gives the program an EMPTY environment', async () => {
  102. const { runtime } = await setup()
  103. const result = await runtime.run({ program: 'return JSON.stringify(process.env)', bindings: [] })
  104. expect(result.value).toBe('{}')
  105. })
  106. it('rejects a non-lossless completion instead of replacing it with rendered text', async () => {
  107. const { runtime } = await setup()
  108. const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
  109. expect(result.value).toBeUndefined()
  110. expect(result.error).toEqual({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
  111. })
  112. it('completes a program that returns nothing with no value at all', async () => {
  113. const { runtime } = await setup()
  114. const result = await runtime.run({ program: 'const x = 1', bindings: [] })
  115. expect(result.error).toBeUndefined()
  116. expect('value' in result).toBe(false)
  117. })
  118. it('keeps logs streamed before a failure', async () => {
  119. const { runtime } = await setup()
  120. const result = await runtime.run({
  121. program: 'console.log("before"); throw new Error("after-log")',
  122. bindings: [],
  123. })
  124. expect(result.error?.kind).toBe('exception')
  125. expect(result.logs).toContain('before')
  126. })
  127. })
  128. describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
  129. it('ends a hot loop at the compute budget — including behind a pending decoy dispatch', async () => {
  130. const { runtime } = await setup({ computeMs: 300, maxWallMs: 30_000 })
  131. const result = await runtime.run({
  132. // The decoy: fire a call at a never-resolving binding WITHOUT awaiting,
  133. // then spin. Host-side pending-call bookkeeping would pause a naive
  134. // budget here; measured busy time cannot be fooled.
  135. program: 'void tools.slow({}); for (;;) {}',
  136. bindings: tools({ slow: () => new Promise(() => {}) }),
  137. })
  138. expect(result.error?.kind).toBe('timeout')
  139. expect(result.error?.message).toContain('compute budget')
  140. }, 15_000)
  141. it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
  142. // Keep the binding delay above the compute allowance while leaving enough
  143. // headroom for worker bootstrap on loaded CI hosts.
  144. const { runtime } = await setup({ computeMs: 1_000, maxWallMs: 30_000 })
  145. const result = await runtime.run({
  146. program: 'return await tools.slow({})',
  147. bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 1_500)) }),
  148. })
  149. expect(result.error).toBeUndefined()
  150. expect(result.value).toBe('slow-done')
  151. }, 15_000)
  152. it('ends an idle-forever run at the wall-clock ceiling', async () => {
  153. const { runtime } = await setup({ computeMs: 30_000, maxWallMs: 400 })
  154. const result = await runtime.run({
  155. program: 'await tools.never({}); return 1',
  156. bindings: tools({ never: () => new Promise(() => {}) }),
  157. })
  158. expect(result.error?.kind).toBe('timeout')
  159. expect(result.error?.message).toContain('wall-clock ceiling')
  160. }, 15_000)
  161. it('reports an abort mid-run and stops the worker', async () => {
  162. const { runtime } = await setup()
  163. const controller = new AbortController()
  164. setTimeout(() => { controller.abort('user-cancel') }, 150)
  165. const result = await runtime.run({ program: 'for (;;) {}', bindings: [], signal: controller.signal })
  166. expect(result.error).toEqual({ kind: 'abort', message: 'user-cancel' })
  167. }, 15_000)
  168. it('reports a pre-aborted signal without spawning', async () => {
  169. const { runtime } = await setup()
  170. const controller = new AbortController()
  171. controller.abort('too-late')
  172. const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
  173. expect(result.error).toEqual({ kind: 'abort', message: 'too-late' })
  174. })
  175. it('applies the outer-output cap to failures before worker startup', async () => {
  176. const capped = await setup({ maxOutputBytes: 64 })
  177. const controller = new AbortController()
  178. controller.abort('A'.repeat(1_000))
  179. const aborted = await capped.runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
  180. expect(aborted).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } })
  181. const minimal = await setup({ maxOutputBytes: 4 })
  182. const invalid = await minimal.runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
  183. expect(invalid.error?.kind).toBe('output-limit')
  184. expect(Buffer.byteLength(JSON.stringify(invalid.logs), 'utf8') + Buffer.byteLength(JSON.stringify(invalid.error?.message), 'utf8')).toBeLessThanOrEqual(4)
  185. })
  186. it('drops a binding resolution that lands after the run settled', async () => {
  187. const { runtime } = await setup()
  188. const controller = new AbortController()
  189. let replyDelivered!: Promise<void>
  190. const result = await runtime.run({
  191. program: 'void tools.late({}); for (;;) {}',
  192. bindings: tools({
  193. // Anchored on invocation: abort 100ms after the call reaches the
  194. // host, resolve 400ms after — by then the run has settled, so the
  195. // resolution's reply hits the post-settlement drop.
  196. late: () => new Promise((resolve) => {
  197. setTimeout(() => { controller.abort('cancel-now') }, 100)
  198. replyDelivered = new Promise(done => setTimeout(() => { resolve('too-late'); done() }, 400))
  199. }),
  200. }),
  201. signal: controller.signal,
  202. })
  203. expect(result.error).toEqual({ kind: 'abort', message: 'cancel-now' })
  204. // Let the late resolution actually fire so its reply executes instead of
  205. // being cancelled with the test.
  206. await replyDelivered
  207. }, 15_000)
  208. it('contains an OOM under resourceLimits as worker-exit, host process healthy', async () => {
  209. const { runtime } = await setup({ maxOldGenerationSizeMb: 32 })
  210. const result = await runtime.run({
  211. program: 'const hog = []; for (;;) hog.push(new Array(1e6).fill(1));',
  212. bindings: [],
  213. })
  214. expect(result.error?.kind).toBe('worker-exit')
  215. // And the host is fine: run something else.
  216. const after = await runtime.run({ program: 'return "alive"', bindings: [] })
  217. expect(after.value).toBe('alive')
  218. }, 30_000)
  219. it('reports a worker that exits before publishing a completion', async () => {
  220. const { runtime } = await setup()
  221. const result = await runtime.run({ program: 'process.exit(7)', bindings: [] })
  222. expect(result).toEqual({
  223. logs: [],
  224. error: { kind: 'worker-exit', message: 'worker exited with code 7 before completing' },
  225. })
  226. })
  227. it('fails runaway log output explicitly while retaining a bounded prefix', async () => {
  228. const { runtime } = await setup({ maxOutputBytes: 300 })
  229. const result = await runtime.run({
  230. program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
  231. bindings: [],
  232. })
  233. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 300 bytes' })
  234. expect(result.value).toBeUndefined()
  235. expect(result.logs.length).toBeGreaterThan(0)
  236. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(300)
  237. })
  238. it('retains a fitting prefix when one oversized log is the first output', async () => {
  239. const { runtime } = await setup({ maxOutputBytes: 96 })
  240. const result = await runtime.run({
  241. program: 'console.log(`start-${`😀"\\\\\\n`.repeat(100)}`); return null',
  242. bindings: [],
  243. })
  244. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
  245. expect(result.logs).toHaveLength(1)
  246. expect(result.logs[0]?.startsWith('start-')).toBe(true)
  247. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
  248. + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(96)
  249. })
  250. it('fails an oversized return value without substituting a string', async () => {
  251. const { runtime } = await setup({ maxOutputBytes: 64 })
  252. const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
  253. expect(result.value).toBeUndefined()
  254. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
  255. })
  256. it('uses UTF-8 serialized bytes at the exact completion boundary', async () => {
  257. const exact = await setup({ maxOutputBytes: 7 })
  258. const exactResult = await exact.runtime.run({ program: 'return "€"', bindings: [] })
  259. // [] costs two bytes and JSON serialization of "€" costs five.
  260. expect(exactResult).toEqual({ logs: [], value: '€' })
  261. const over = await setup({ maxOutputBytes: 6 })
  262. const overResult = await over.runtime.run({ program: 'return "€"', bindings: [] })
  263. expect(overResult.error?.kind).toBe('output-limit')
  264. })
  265. it('accounts logs and completion in one exact combined ledger', async () => {
  266. // JSON(["abc"]) is seven bytes and JSON("xy") is four.
  267. const exact = await setup({ maxOutputBytes: 11 })
  268. expect(await exact.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] }))
  269. .toEqual({ logs: ['abc'], value: 'xy' })
  270. const over = await setup({ maxOutputBytes: 10 })
  271. const result = await over.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] })
  272. expect(result.value).toBeUndefined()
  273. expect(result.error?.kind).toBe('output-limit')
  274. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
  275. })
  276. it('completes a program that awaits its write callback, capturing the chunk', async () => {
  277. // Node's write(chunk[, encoding][, callback]) contract: dropping the
  278. // callback would leave this promise pending until the wall ceiling and
  279. // misreport a completed program as a timeout.
  280. const { runtime } = await setup({ maxWallMs: 2_000 })
  281. const result = await runtime.run({
  282. program: 'await new Promise(resolve => process.stdout.write("flushed", resolve)); return "done"',
  283. bindings: [],
  284. })
  285. expect(result.error).toBeUndefined()
  286. expect(result.value).toBe('done')
  287. expect(result.logs).toContain('flushed')
  288. })
  289. it('returns a large JSON container exactly when the outer cap permits it', async () => {
  290. const { runtime } = await setup()
  291. const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] })
  292. expect(result.error).toBeUndefined()
  293. expect(result.value).toEqual(new Array(50_000).fill(7))
  294. })
  295. it('returns an exact completion at the default 64 MiB combined boundary', async () => {
  296. const { runtime } = await setup()
  297. // [] costs two bytes and the JSON string contributes two quotes, leaving
  298. // exactly this many payload bytes under the 67_108_864-byte default.
  299. const result = await runtime.run({ program: 'return "x".repeat(67_108_860)', bindings: [] })
  300. expect(result.error).toBeUndefined()
  301. expect(result.logs).toEqual([])
  302. expect(result.value).toHaveLength(67_108_860)
  303. }, 60_000)
  304. it('fails one byte over the default 64 MiB combined boundary', async () => {
  305. const { runtime } = await setup()
  306. const result = await runtime.run({ program: 'return "x".repeat(67_108_861)', bindings: [] })
  307. expect(result.value).toBeUndefined()
  308. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' })
  309. }, 60_000)
  310. it('accounts pipe writes that bypass the patched write slot in the same outer ledger', async () => {
  311. const { runtime } = await setup({ maxOutputBytes: 80 })
  312. const result = await runtime.run({
  313. // The prototype write bypasses the patched instance and reaches the real pipe. Pauses keep
  314. // writes in separate chunks and let both reach the host before settlement.
  315. program: `
  316. const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
  317. write('a'.repeat(20));
  318. await new Promise(resolve => setTimeout(resolve, 150));
  319. write('b'.repeat(100));
  320. await new Promise(resolve => setTimeout(resolve, 100));
  321. return 1;
  322. `,
  323. bindings: [],
  324. })
  325. expect(result.error?.kind).toBe('output-limit')
  326. expect(result.logs).toContain('a'.repeat(20))
  327. expect(result.logs[1]?.length).toBeGreaterThan(0)
  328. expect('b'.repeat(100).startsWith(result.logs[1] ?? '')).toBe(true)
  329. }, 15_000)
  330. it('drains pipe output queued before terminal worker teardown completes', async () => {
  331. const { runtime } = await setup({ maxOutputBytes: 200_000 })
  332. const payload = `late-pipe-${'x'.repeat(100_000)}`
  333. const result = await runtime.run({
  334. program: `
  335. const { parentPort } = await import('node:worker_threads');
  336. const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
  337. write('late-pipe-' + 'x'.repeat(100_000));
  338. parentPort.postMessage({ type: 'done', value: ['done'] });
  339. for (;;) {}
  340. `,
  341. bindings: [],
  342. })
  343. expect(result.error).toBeUndefined()
  344. expect(result.value).toBe('done')
  345. expect(result.logs.join('') === payload).toBe(true)
  346. }, 15_000)
  347. })
  348. describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
  349. it('survives forged port traffic: unknown binding names, duplicate ids, junk shapes', async () => {
  350. const { runtime } = await setup()
  351. const result = await runtime.run({
  352. program: `
  353. const { parentPort } = await import('node:worker_threads');
  354. parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
  355. parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
  356. parentPort.postMessage({ type: 'call', id: 7778, global: 'tools', name: 'constructor', args: {} });
  357. parentPort.postMessage({ type: 'junk' });
  358. return await tools.real({});
  359. `,
  360. bindings: tools({ real: async () => 'still-works' }),
  361. })
  362. expect(result.error).toBeUndefined()
  363. expect(result.value).toBe('still-works')
  364. })
  365. it('survives arbitrary junk on the port: non-objects, junk types, malformed calls, logs, and dones', async () => {
  366. const { runtime } = await setup()
  367. const result = await runtime.run({
  368. program: `
  369. const { parentPort } = await import('node:worker_threads');
  370. for (const junk of [
  371. null, 42, 'junk', [],
  372. { type: 'nope' },
  373. { type: 'call' },
  374. { type: 'call', id: 'x', global: 'tools', name: 'real', args: {} },
  375. { type: 'call', id: 1e9, global: 7, name: 'real', args: {} },
  376. { type: 'call', id: 1e9, global: 'tools', name: 7, args: {} },
  377. { type: 'log' },
  378. { type: 'log', text: null },
  379. { type: 'log', text: 7 },
  380. { type: 'log', text: {} },
  381. { type: 'done', error: 5 },
  382. { type: 'done', error: { kind: 'exception', message: 5 } },
  383. { type: 'done', error: { kind: 'invented', message: 'bad kind' } },
  384. ]) parentPort.postMessage(junk);
  385. return await tools.real({});
  386. `,
  387. bindings: tools({ real: async () => 'still-works' }),
  388. })
  389. expect(result.error).toBeUndefined()
  390. expect(result.value).toBe('still-works')
  391. expect(result.logs).toEqual([])
  392. })
  393. it('fails forged log floods and forged done values through the same outer cap', async () => {
  394. const { runtime } = await setup({ maxOutputBytes: 200 })
  395. const result = await runtime.run({
  396. // Forged messages bypass the worker-side LogBuffer and completion check
  397. // entirely — only the host-side ledger and re-cap stand between model
  398. // code and an unbounded result.
  399. program: `
  400. const { parentPort } = await import('node:worker_threads');
  401. for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true });
  402. parentPort.postMessage({ type: 'done', value: ['V'.repeat(100000)] });
  403. for (;;) {}
  404. `,
  405. bindings: [],
  406. })
  407. expect(result.value).toBeUndefined()
  408. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 200 bytes' })
  409. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
  410. })
  411. it('bounds one oversized forged log while retaining its fitting escaped prefix', async () => {
  412. const { runtime } = await setup({ maxOutputBytes: 96 })
  413. const result = await runtime.run({
  414. program: `
  415. const { parentPort } = await import('node:worker_threads');
  416. parentPort.postMessage({ type: 'log', text: '"'.repeat(1_000_000) });
  417. for (;;) {}
  418. `,
  419. bindings: [],
  420. })
  421. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
  422. expect(result.logs).toHaveLength(1)
  423. expect(result.logs[0]).toMatch(/^"+$/)
  424. expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify('outer output exceeded 96 bytes'), 'utf8')).toBeLessThanOrEqual(96)
  425. })
  426. it('drops a malformed forged done carrying both value and error', async () => {
  427. const { runtime } = await setup()
  428. const result = await runtime.run({
  429. program: `
  430. const { parentPort } = await import('node:worker_threads');
  431. parentPort.postMessage({ type: 'done', value: 'lied', error: { kind: 'exception', message: 'fake failure' } });
  432. return 'honest';
  433. `,
  434. bindings: [],
  435. })
  436. expect(result).toEqual({ logs: [], error: { kind: 'exception', message: 'fake failure' } })
  437. })
  438. it('contains a deeply nested forged completion without overflowing the host meter', async () => {
  439. const { runtime } = await setup()
  440. const result = await runtime.run({
  441. program: `
  442. const { parentPort } = await import('node:worker_threads');
  443. const value = [];
  444. for (let depth = 0; depth < 3_000; depth++) value.push({ kind: 'array', length: 1 });
  445. value.push(null);
  446. setTimeout(() => { parentPort.postMessage({ type: 'done', value }) }, 25);
  447. // Prevent bootstrap's normal undefined completion from racing the forged terminal.
  448. await new Promise(() => {});
  449. `,
  450. bindings: [],
  451. })
  452. expect(result.error).toBeUndefined()
  453. let value = result.value
  454. let depth = 0
  455. while (Array.isArray(value)) {
  456. expect(value).toHaveLength(1)
  457. value = value[0]
  458. depth += 1
  459. }
  460. expect(depth).toBe(3_000)
  461. expect(value).toBeNull()
  462. }, 15_000)
  463. it('turns forged over-limit error text into output-limit at the host', async () => {
  464. const { runtime } = await setup({ maxOutputBytes: 64 })
  465. const result = await runtime.run({
  466. program: `
  467. const { parentPort } = await import('node:worker_threads');
  468. parentPort.postMessage({ type: 'done', error: { kind: 'exception', message: '€'.repeat(1000) } });
  469. for (;;) {}
  470. `,
  471. bindings: [],
  472. })
  473. expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
  474. })
  475. it('answers a binding whose resolution is not lossless JSON with a typed failure reply', async () => {
  476. const { runtime } = await setup()
  477. const result = await runtime.run({
  478. program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
  479. bindings: tools({ bad: async () => (() => 1) }),
  480. })
  481. expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
  482. })
  483. it('rejects lossy binding arguments in the worker before invoking the host binding', async () => {
  484. const { runtime } = await setup()
  485. let calls = 0
  486. const result = await runtime.run({
  487. program: `
  488. const decorated = [1]; Object.defineProperty(decorated, 'extra', { value: true });
  489. const values = [new Date(), decorated, () => 1];
  490. const failures = [];
  491. for (const value of values) {
  492. try { await tools.never(value) } catch (error) {
  493. failures.push({ typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message });
  494. }
  495. }
  496. return failures;
  497. `,
  498. bindings: tools({ never: async () => { calls += 1; return null } }),
  499. })
  500. expect(calls).toBe(0)
  501. expect(result.value).toEqual(new Array(3).fill({
  502. typed: true,
  503. name: 'ToolCallError',
  504. toolName: 'never',
  505. message: 'binding arguments must be lossless JSON',
  506. }))
  507. })
  508. it('rejects forged lossy binding arguments again at the host boundary', async () => {
  509. const { runtime } = await setup()
  510. let calls = 0
  511. const result = await runtime.run({
  512. program: `
  513. const { parentPort } = await import('node:worker_threads');
  514. const forged = (id, args) => new Promise((resolve) => {
  515. const receive = (message) => {
  516. if (message?.type !== 'reply' || message.id !== id) return;
  517. parentPort.off('message', receive);
  518. resolve(message);
  519. };
  520. parentPort.on('message', receive);
  521. parentPort.postMessage({ type: 'call', id, global: 'tools', name: 'never', args });
  522. });
  523. const sparse = []; sparse.length = 1;
  524. const cycle = {}; cycle.self = cycle;
  525. return await Promise.all([
  526. forged(8001, new Date()),
  527. forged(8002, -0),
  528. forged(8003, sparse),
  529. forged(8004, cycle),
  530. ]);
  531. `,
  532. bindings: tools({ never: async () => { calls += 1; return null } }),
  533. })
  534. expect(calls).toBe(0)
  535. expect(result.value).toEqual([8001, 8002, 8003, 8004].map(id => ({
  536. type: 'reply',
  537. id,
  538. ok: false,
  539. message: 'binding arguments must be lossless JSON',
  540. })))
  541. })
  542. it('contains throwing getters while snapshotting binding resolutions', async () => {
  543. const { runtime } = await setup()
  544. const result = await runtime.run({
  545. program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
  546. bindings: tools({ bad: async () => Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('getter exploded') } }) }),
  547. })
  548. expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
  549. })
  550. it('revalidates a forged lossy completion at the host boundary', async () => {
  551. const { runtime } = await setup()
  552. const result = await runtime.run({
  553. program: `
  554. const { parentPort } = await import('node:worker_threads');
  555. parentPort.postMessage({ type: 'done', value: -0 });
  556. for (;;) {}
  557. `,
  558. bindings: [],
  559. })
  560. expect(result).toEqual({ logs: [], error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } })
  561. })
  562. it('honors a forged worker-side output-limit signal', async () => {
  563. const { runtime } = await setup()
  564. const result = await runtime.run({
  565. program: `
  566. const { parentPort } = await import('node:worker_threads');
  567. parentPort.postMessage({ type: 'output-limit' });
  568. for (;;) {}
  569. `,
  570. bindings: [],
  571. })
  572. expect(result).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' } })
  573. })
  574. it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
  575. const { runtime } = await setup()
  576. const result = await runtime.run({
  577. program: 'return [await tools["__proto__"]({}), await tools["constructor"]({}), typeof tools["hasOwnProperty"]]',
  578. // Computed keys: a literal `'__proto__': …` entry would SET the record's
  579. // prototype instead of declaring a binding of that name.
  580. bindings: tools({ ['__proto__']: async () => 'proto-ok', ['constructor']: async () => 'ctor-ok' }),
  581. })
  582. expect(result.value).toEqual(['proto-ok', 'ctor-ok', 'undefined'])
  583. })
  584. })
  585. describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
  586. it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, reserved injected globals)', async () => {
  587. const { runtime } = await setup()
  588. const cases: [string, RegExp][] = [
  589. ['not valid!', /not a usable identifier/],
  590. ['await', /not a usable identifier/],
  591. ['console', /duplicate binding global/],
  592. ['ToolCallError', /duplicate binding global/],
  593. ]
  594. for (const [global, message] of cases) {
  595. await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
  596. }
  597. await expect(runtime.run({
  598. program: 'return 1',
  599. bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
  600. })).rejects.toThrow(/duplicate binding global/)
  601. })
  602. it('rejects config values that are not positive numbers', async () => {
  603. const ctx = new Context()
  604. await expect(ctx.plugin(WorkerCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/)
  605. })
  606. it('requires maxOutputBytes to fit the smallest counted outer payloads', async () => {
  607. const ctx = new Context()
  608. await expect(ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 3 })).rejects.toThrow(/safe integer of at least 4/)
  609. await expect(ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 4.5 })).rejects.toThrow(/safe integer of at least 4/)
  610. })
  611. it('keeps runs isolated: no state survives from one run to the next', async () => {
  612. const { runtime } = await setup()
  613. await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })
  614. const second = await runtime.run({ program: 'return typeof globalThis.leak', bindings: [] })
  615. expect(second.value).toBe('undefined')
  616. })
  617. it('disposal aborts in-flight runs, awaits worker exit, and rejects later runs', async () => {
  618. const ctx = new Context()
  619. const fiber = await ctx.plugin(WorkerCodeRuntime)
  620. const runtime = ctx.codeRuntime as WorkerCodeRuntime
  621. const inflight: Promise<CodeRunResult> = runtime.run({ program: 'for (;;) {}', bindings: [] })
  622. // Give the worker a moment to actually start spinning.
  623. await new Promise(resolve => setTimeout(resolve, 200))
  624. await fiber.dispose()
  625. const result = await inflight
  626. expect(result.error).toEqual({ kind: 'abort', message: 'runtime disposed' })
  627. await expect(runtime.run({ program: 'return 1', bindings: [] })).rejects.toThrow(/after disposal/)
  628. }, 15_000)
  629. it('removes ctx.codeRuntime when the providing fiber disposes (HMR safety)', async () => {
  630. const ctx = new Context()
  631. const fiber = await ctx.plugin(WorkerCodeRuntime)
  632. expect(ctx.get('codeRuntime')).toBeInstanceOf(WorkerCodeRuntime)
  633. await fiber.dispose()
  634. expect(ctx.get('codeRuntime')).toBeUndefined()
  635. })
  636. })