bootstrap.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import { describe, expect, it } from 'vitest'
  2. import { EventEmitter } from 'node:events'
  3. import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, runWorkerMain, ToolCallError, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
  4. import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
  5. import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
  6. /**
  7. * An in-process stand-in for the worker's parentPort: the test plays the
  8. * HOST side — inspect what the bootstrap posted, feed replies back — so
  9. * every line of worker-side logic runs under coverage without spawning an
  10. * isolate (real-worker behavior is pinned by runtime.spec.ts).
  11. */
  12. class FakePort implements BootstrapPort {
  13. sent: WorkerToHost[] = []
  14. private readonly emitter = new EventEmitter()
  15. /** Host-scripted responder; return undefined to leave the call pending. */
  16. respond: (message: WorkerToHost) => ReplyMessage | undefined = () => undefined
  17. postMessage(message: WorkerToHost): void {
  18. this.sent.push(message)
  19. const reply = this.respond(message)
  20. if (reply) queueMicrotask(() => this.emitter.emit('message', reply))
  21. }
  22. on(event: 'message', listener: (message: ReplyMessage) => void): void {
  23. this.emitter.on(event, listener)
  24. }
  25. deliver(message: ReplyMessage): void {
  26. this.emitter.emit('message', message)
  27. }
  28. logs(): string[] {
  29. return this.sent.filter(message => message.type === 'log').map(message => message.text)
  30. }
  31. done(): WorkerToHost | undefined {
  32. return this.sent.find(message => message.type === 'done')
  33. }
  34. }
  35. function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } {
  36. return { stdout: { write: () => true }, stderr: { write: () => true } }
  37. }
  38. /** Capture one promise rejection without Vitest's intentionally `any` matcher channel. */
  39. async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
  40. try {
  41. await promise
  42. return undefined
  43. } catch (error: unknown) {
  44. return error
  45. }
  46. }
  47. const BOOT = { maxOutputBytes: 65_536 }
  48. describe('LogBuffer', () => {
  49. it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => {
  50. const seen: string[] = []
  51. let limits = 0
  52. const buffer = new LogBuffer(10, text => seen.push(text), () => { limits += 1 })
  53. buffer.push('12345')
  54. buffer.push('123456')
  55. buffer.push('dropped')
  56. expect(seen).toEqual(['12345', '12345'])
  57. expect(limits).toBe(1)
  58. const exactlyFull: string[] = []
  59. const fullBuffer = new LogBuffer(4, text => exactlyFull.push(text))
  60. fullBuffer.push('1234')
  61. fullBuffer.push('no-prefix-fits')
  62. expect(exactlyFull).toEqual(['1234'])
  63. })
  64. })
  65. describe('makeConsoleShim', () => {
  66. it('captures the five methods and renders non-strings inspect-style', () => {
  67. const seen: string[] = []
  68. const shim = makeConsoleShim(new LogBuffer(1_000, text => seen.push(text)))
  69. shim.log('plain', { a: 1 })
  70. shim.info('i')
  71. shim.warn('w')
  72. shim.error('e')
  73. shim.debug('d')
  74. expect(seen).toEqual(['plain { a: 1 }', 'i', 'w', 'e', 'd'])
  75. })
  76. })
  77. describe('captureStreamWrites', () => {
  78. it('redirects writes into the buffer and restores on request', () => {
  79. const seen: string[] = []
  80. const buffer = new LogBuffer(1_000, text => seen.push(text))
  81. let underlying = ''
  82. const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } }
  83. const restore = captureStreamWrites(buffer, stream)
  84. stream.write('captured', 'utf8')
  85. stream.write(Buffer.from('bytes'))
  86. restore()
  87. stream.write('after')
  88. expect(seen).toEqual(['captured', 'bytes'])
  89. expect(underlying).toBe('after')
  90. })
  91. it('invokes the write callback asynchronously, in both optional-encoding shapes', async () => {
  92. const buffer = new LogBuffer(1_000, () => {})
  93. const stream: PatchableStream = { write: () => true }
  94. captureStreamWrites(buffer, stream)
  95. const calls: (Error | null | undefined)[] = []
  96. stream.write('two-arg', (error?: Error | null) => calls.push(error))
  97. stream.write('three-arg', 'utf8', (error?: Error | null) => calls.push(error))
  98. // Node's contract: the callback fires after the write call returns.
  99. expect(calls).toEqual([])
  100. await new Promise<void>(resolve => stream.write('awaited flush', resolve))
  101. expect(calls).toEqual([null, null])
  102. })
  103. it('still fires the callback for a write the exhausted budget drops', async () => {
  104. const buffer = new LogBuffer(4, () => {})
  105. const stream: PatchableStream = { write: () => true }
  106. captureStreamWrites(buffer, stream)
  107. stream.write('this write overflows the budget and is dropped')
  108. await new Promise<void>(resolve => stream.write('also dropped', resolve))
  109. })
  110. })
  111. describe('prepareCompletion', () => {
  112. it('omits undefined and passes lossless JSON values exactly', () => {
  113. expect(prepareCompletion(undefined, 100)).toEqual({})
  114. expect(prepareCompletion({ a: [1, 'two'] }, 100)).toEqual({ value: { a: [1, 'two'] } })
  115. })
  116. it('turns every lossy completion shape into invalid-output', () => {
  117. const cyclic: Record<string, unknown> = {}
  118. cyclic.self = cyclic
  119. const sparse = Array(2)
  120. class Exotic { readonly marker = true }
  121. for (const value of [{ fn: () => 1 }, -0, Number.POSITIVE_INFINITY, sparse, cyclic, new Exotic()]) {
  122. expect(prepareCompletion(value, 1_000)).toEqual({
  123. error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
  124. })
  125. }
  126. })
  127. it('reports an oversized value instead of substituting rendered text', () => {
  128. expect(prepareCompletion('x'.repeat(50), 10)).toEqual({
  129. error: { kind: 'output-limit', message: 'outer output exceeded 10 bytes' },
  130. })
  131. })
  132. it('measures the exact JSON serialization at and over the boundary', () => {
  133. expect(prepareCompletion('€', 5)).toEqual({ value: '€' })
  134. expect(prepareCompletion('€', 4)).toEqual({
  135. error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
  136. })
  137. })
  138. it('contains a getter failure as invalid-output', () => {
  139. const value = Object.defineProperty({}, 'x', { enumerable: true, get() { throw new Error('getter exploded') } })
  140. expect(prepareCompletion(value, 1_000)).toEqual({
  141. error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
  142. })
  143. })
  144. })
  145. describe('truncateUtf8Bytes', () => {
  146. it('returns a fitting string whole', () => {
  147. expect(truncateUtf8Bytes('fits', 4)).toBe('fits')
  148. })
  149. it('cuts at a code-point boundary, never mid-surrogate-pair', () => {
  150. // Each 😀 is one code point, two code units, four UTF-8 bytes: a 5-byte
  151. // budget fits exactly one — and never leaves a lone surrogate behind.
  152. const cut = truncateUtf8Bytes('😀😀', 5)
  153. expect(cut).toBe('😀')
  154. expect(Buffer.byteLength(truncateUtf8Bytes('😀😀', 3), 'utf8')).toBe(0)
  155. })
  156. })
  157. describe('makeNamespaces', () => {
  158. it('exposes prototype-colliding names as ordinary own properties', async () => {
  159. const port = new FakePort()
  160. port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: `${message.name}-ok` } : undefined
  161. const pending = new Map<number, PendingCall>()
  162. wireReplies(port, pending)
  163. const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
  164. expect(Object.getPrototypeOf(tools)).toBeNull()
  165. await expect(tools['__proto__']?.({})).resolves.toBe('__proto__-ok')
  166. await expect(tools['constructor']?.({})).resolves.toBe('constructor-ok')
  167. await expect(tools['toString']?.({})).resolves.toBe('toString-ok')
  168. })
  169. it('rejects a postMessage clone failure without leaking the pending entry', async () => {
  170. let firstCall = true
  171. const throwingPort: BootstrapPort = {
  172. // First call throws an Error (the real DataCloneError shape), the
  173. // second a bare string — the rejection renders both.
  174. postMessage: () => {
  175. if (firstCall) { firstCall = false; throw new Error('DataCloneError-ish') }
  176. throw 'raw-clone-failure'
  177. },
  178. on: () => {},
  179. }
  180. const pending = new Map<number, PendingCall>()
  181. const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
  182. const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve())
  183. const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve())
  184. expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
  185. expect(second).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
  186. expect(first).toBeInstanceOf(ToolCallError)
  187. expect(second).toBeInstanceOf(ToolCallError)
  188. expect((first as Error).message).toMatch(/DataCloneError-ish/)
  189. expect((second as Error).message).toMatch(/raw-clone-failure/)
  190. expect(pending.size).toBe(0)
  191. })
  192. it('rejects lossy arguments before posting or allocating a call id', async () => {
  193. let posts = 0
  194. const port: BootstrapPort = { postMessage: () => { posts += 1 }, on: () => {} }
  195. const pending = new Map<number, PendingCall>()
  196. const nextId = { value: 1 }
  197. const [tools] = makeNamespaces(
  198. { namespaces: [{ global: 'tools', names: ['x'] }] }, port, pending, nextId,
  199. ) as [Record<string, (args: unknown) => Promise<unknown>>]
  200. const decorated = [1]
  201. Object.defineProperty(decorated, 'extra', { value: true })
  202. const throwing = Object.defineProperty({}, 'value', {
  203. enumerable: true,
  204. get: () => { throw new Error('getter exploded') },
  205. })
  206. for (const value of [() => 1, new Date(), decorated, throwing]) {
  207. const failure = await rejectionOf(tools.x?.(value) ?? Promise.resolve())
  208. expect(failure).toMatchObject({
  209. name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON',
  210. })
  211. }
  212. expect(posts).toBe(0)
  213. expect(pending.size).toBe(0)
  214. expect(nextId.value).toBe(1)
  215. })
  216. it('uses ordinary Error for non-tools namespace failures', async () => {
  217. const deniedPort = new FakePort()
  218. deniedPort.respond = message => message.type === 'call'
  219. ? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
  220. : undefined
  221. const deniedPending = new Map<number, PendingCall>()
  222. wireReplies(deniedPort, deniedPending)
  223. const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
  224. const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve())
  225. expect(denied).toBeInstanceOf(Error)
  226. expect(denied).not.toBeInstanceOf(ToolCallError)
  227. const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve())
  228. expect(invalid).toBeInstanceOf(Error)
  229. expect(invalid).not.toBeInstanceOf(ToolCallError)
  230. expect((invalid as Error).message).toBe('binding arguments must be lossless JSON')
  231. const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} }
  232. const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
  233. const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve())
  234. expect(cloneFailure).toBeInstanceOf(Error)
  235. expect(cloneFailure).not.toBeInstanceOf(ToolCallError)
  236. })
  237. })
  238. describe('runWorkerMain', () => {
  239. it('runs a program end-to-end: bindings, console, return value', async () => {
  240. const port = new FakePort()
  241. port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: (message.args as { n: number }).n * 2 } : undefined
  242. await runWorkerMain(port, {
  243. ...BOOT,
  244. code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
  245. namespaces: [{ global: 'tools', names: ['double'] }],
  246. }, fakeStreams())
  247. expect(port.logs()).toEqual(['got 42'])
  248. expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } })
  249. })
  250. it('reports worker-side log capture overflow before completing', async () => {
  251. const port = new FakePort()
  252. await runWorkerMain(port, {
  253. maxOutputBytes: 4,
  254. code: 'console.log("12345"); return null',
  255. namespaces: [],
  256. }, fakeStreams())
  257. expect(port.sent).toContainEqual({ type: 'log', text: '1234' })
  258. expect(port.sent).toContainEqual({ type: 'output-limit' })
  259. expect(port.done()).toEqual({ type: 'done', value: null })
  260. })
  261. it('reports a thrown program error on the done message', async () => {
  262. const port = new FakePort()
  263. await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
  264. const done = port.done()
  265. expect(done?.type).toBe('done')
  266. expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception')
  267. expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom')
  268. expect(done?.type === 'done' ? done.value : undefined).toBeUndefined()
  269. })
  270. it('renders non-Error throws and stack-less Errors on the done message', async () => {
  271. const rawPort = new FakePort()
  272. await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
  273. expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } })
  274. const barePort = new FakePort()
  275. await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
  276. expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
  277. })
  278. it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
  279. const port = new FakePort()
  280. port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
  281. await runWorkerMain(port, {
  282. ...BOOT,
  283. code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
  284. namespaces: [{ global: 'tools', names: ['x'] }],
  285. }, fakeStreams())
  286. expect(port.done()).toEqual({
  287. type: 'done',
  288. value: { caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' },
  289. })
  290. expect(new ToolCallError('x', 'nope')).toMatchObject({ name: 'ToolCallError', toolName: 'x', message: 'nope' })
  291. })
  292. it('ignores replies for unknown pending ids', async () => {
  293. const port = new FakePort()
  294. port.respond = (message) => {
  295. if (message.type !== 'call') return undefined
  296. // Deliver a stray reply first; the real one follows.
  297. port.deliver({ type: 'reply', id: 9_999, ok: true, value: 'stray' })
  298. return { type: 'reply', id: message.id, ok: true, value: 'real' }
  299. }
  300. await runWorkerMain(port, {
  301. ...BOOT,
  302. code: 'return await tools.x({})',
  303. namespaces: [{ global: 'tools', names: ['x'] }],
  304. }, fakeStreams())
  305. expect(port.done()).toEqual({ type: 'done', value: 'real' })
  306. })
  307. it('captures raw stream writes through the patched process streams', async () => {
  308. const port = new FakePort()
  309. const streams = fakeStreams()
  310. await runWorkerMain(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
  311. streams.stdout.write('never seen — already restored? no: patch persists in worker')
  312. // The patch stays installed for the worker's lifetime; writes during the
  313. // program landed in order. Here the program wrote nothing via streams, so
  314. // only the post-run write above went through the patched slot.
  315. expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker')
  316. })
  317. })