runtime.spec.ts 40 KB

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