refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runWorkerMain, wireReplies } from '../src/bootstrap.ts'
|
||||
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
|
||||
import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts'
|
||||
|
||||
/**
|
||||
* An in-process stand-in for the worker's parentPort: the test plays the
|
||||
* HOST side — inspect what the bootstrap posted, feed replies back — so
|
||||
* every line of worker-side logic runs under coverage without spawning an
|
||||
* isolate (real-worker behavior is pinned by runtime.spec.ts).
|
||||
*/
|
||||
class FakePort implements BootstrapPort {
|
||||
sent: WorkerToHost[] = []
|
||||
private readonly emitter = new EventEmitter()
|
||||
/** Host-scripted responder; return undefined to leave the call pending. */
|
||||
respond: (message: WorkerToHost) => ReplyMessage | undefined = () => undefined
|
||||
|
||||
postMessage(message: WorkerToHost): void {
|
||||
this.sent.push(message)
|
||||
const reply = this.respond(message)
|
||||
if (reply) queueMicrotask(() => this.emitter.emit('message', reply))
|
||||
}
|
||||
|
||||
on(event: 'message', listener: (message: ReplyMessage) => void): void {
|
||||
this.emitter.on(event, listener)
|
||||
}
|
||||
|
||||
deliver(message: ReplyMessage): void {
|
||||
this.emitter.emit('message', message)
|
||||
}
|
||||
|
||||
logs(): string[] {
|
||||
return this.sent.filter(message => message.type === 'log').map(message => message.text)
|
||||
}
|
||||
|
||||
done(): WorkerToHost | undefined {
|
||||
return this.sent.find(message => message.type === 'done')
|
||||
}
|
||||
|
||||
doneValue(): unknown {
|
||||
const done = this.done()
|
||||
return done?.type === 'done' && done.value !== undefined ? decodeWorkerJson(done.value) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } {
|
||||
return { stdout: { write: () => true }, stderr: { write: () => true } }
|
||||
}
|
||||
|
||||
/** Capture one promise rejection without Vitest's intentionally `any` matcher channel. */
|
||||
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await promise
|
||||
return undefined
|
||||
} catch (error: unknown) {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
const BOOT = { maxOutputBytes: 65_536 }
|
||||
const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const
|
||||
|
||||
/** One worker declaration for the Code Mode tools namespace. */
|
||||
function toolNamespace(names: string[]) {
|
||||
return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS }
|
||||
}
|
||||
|
||||
describe('LogBuffer', () => {
|
||||
it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => {
|
||||
const seen: string[] = []
|
||||
let limits = 0
|
||||
const buffer = new LogBuffer(15, text => seen.push(text), () => { limits += 1 })
|
||||
buffer.push('12345')
|
||||
buffer.push('123456')
|
||||
buffer.push('dropped')
|
||||
expect(seen).toEqual(['12345', '123'])
|
||||
expect(limits).toBe(1)
|
||||
expect(buffer.remainingOutputBytes()).toBe(0)
|
||||
|
||||
const exactlyFull: string[] = []
|
||||
const fullBuffer = new LogBuffer(6, text => exactlyFull.push(text))
|
||||
fullBuffer.push('12')
|
||||
fullBuffer.push('no-prefix-fits')
|
||||
expect(exactlyFull).toEqual(['12'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeConsoleShim', () => {
|
||||
it('captures the five methods and renders non-strings inspect-style', () => {
|
||||
const seen: string[] = []
|
||||
const shim = makeConsoleShim(new LogBuffer(1_000, text => seen.push(text)))
|
||||
shim.log('plain', { a: 1 })
|
||||
shim.info('i')
|
||||
shim.warn('w')
|
||||
shim.error('e')
|
||||
shim.debug('d')
|
||||
expect(seen).toEqual(['plain { a: 1 }', 'i', 'w', 'e', 'd'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureStreamWrites', () => {
|
||||
it('redirects writes into the buffer and restores on request', () => {
|
||||
const seen: string[] = []
|
||||
const buffer = new LogBuffer(1_000, text => seen.push(text))
|
||||
let underlying = ''
|
||||
const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } }
|
||||
const restore = captureStreamWrites(buffer, stream)
|
||||
stream.write('captured', 'utf8')
|
||||
stream.write(Buffer.from('bytes'))
|
||||
restore()
|
||||
stream.write('after')
|
||||
expect(seen).toEqual(['captured', 'bytes'])
|
||||
expect(underlying).toBe('after')
|
||||
})
|
||||
|
||||
it('invokes the write callback asynchronously, in both optional-encoding shapes', async () => {
|
||||
const buffer = new LogBuffer(1_000, () => {})
|
||||
const stream: PatchableStream = { write: () => true }
|
||||
captureStreamWrites(buffer, stream)
|
||||
const calls: (Error | null | undefined)[] = []
|
||||
stream.write('two-arg', (error?: Error | null) => calls.push(error))
|
||||
stream.write('three-arg', 'utf8', (error?: Error | null) => calls.push(error))
|
||||
// Node's contract: the callback fires after the write call returns.
|
||||
expect(calls).toEqual([])
|
||||
await new Promise<void>(resolve => stream.write('awaited flush', resolve))
|
||||
expect(calls).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('still fires the callback for a write the exhausted budget drops', async () => {
|
||||
const buffer = new LogBuffer(4, () => {})
|
||||
const stream: PatchableStream = { write: () => true }
|
||||
captureStreamWrites(buffer, stream)
|
||||
stream.write('this write overflows the budget and is dropped')
|
||||
await new Promise<void>(resolve => stream.write('also dropped', resolve))
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareCompletion', () => {
|
||||
it('omits undefined and passes lossless JSON values exactly', () => {
|
||||
expect(prepareCompletion(undefined, 100)).toEqual({})
|
||||
expect(prepareCompletion({ a: [1, 'two'] }, 100)).toEqual({ value: encodeWorkerJson({ a: [1, 'two'] }) })
|
||||
})
|
||||
|
||||
it('turns every lossy completion shape into invalid-output', () => {
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
const sparse = Array(2)
|
||||
class Exotic { readonly marker = true }
|
||||
for (const value of [{ fn: () => 1 }, -0, Number.POSITIVE_INFINITY, sparse, cyclic, new Exotic()]) {
|
||||
expect(prepareCompletion(value, 1_000)).toEqual({
|
||||
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('reports an oversized value instead of substituting rendered text', () => {
|
||||
expect(prepareCompletion('x'.repeat(50), 10)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 10 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('measures the exact JSON serialization at and over the boundary', () => {
|
||||
expect(prepareCompletion('€', 5)).toEqual({ value: encodeWorkerJson('€') })
|
||||
expect(prepareCompletion('€', 4)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('contains a getter failure as invalid-output', () => {
|
||||
const value = Object.defineProperty({}, 'x', { enumerable: true, get() { throw new Error('getter exploded') } })
|
||||
expect(prepareCompletion(value, 1_000)).toEqual({
|
||||
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the remaining combined budget for invalid-output diagnostics', () => {
|
||||
expect(prepareCompletion(() => 1, 4, 64)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareException', () => {
|
||||
it('passes a fitting diagnostic and rejects one byte over without carrying its text', () => {
|
||||
expect(prepareException('boom', 6, 64)).toEqual({ error: { kind: 'exception', message: 'boom' } })
|
||||
expect(prepareException('boom', 5, 64)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('contains a thrown value whose string conversion fails', () => {
|
||||
const thrown = { toString() { throw new Error('cannot render') } }
|
||||
expect(prepareException(thrown, 1_000)).toEqual({
|
||||
error: { kind: 'exception', message: 'program threw an unrenderable value' },
|
||||
})
|
||||
|
||||
const strangeStack = Object.defineProperty(new Error('ignored'), 'stack', { value: 42 })
|
||||
expect(prepareException(strangeStack, 1_000)).toEqual({
|
||||
error: { kind: 'exception', message: '42' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeNamespaces', () => {
|
||||
it('rejects a malformed success reply instead of resolving a lossy binding value', async () => {
|
||||
const port = new FakePort()
|
||||
const pending = new Map<number, PendingCall>()
|
||||
wireReplies(port, pending)
|
||||
const result = new Promise<unknown>((resolve, reject) => { pending.set(1, { resolve, reject }) })
|
||||
port.deliver({ type: 'reply', id: 1, ok: true, value: [undefined] as never })
|
||||
await expect(result).rejects.toThrow('binding resolution must be lossless JSON')
|
||||
})
|
||||
|
||||
it('exposes prototype-colliding names as ordinary own properties', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call'
|
||||
? { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(`${message.name}-ok`) }
|
||||
: undefined
|
||||
const pending = new Map<number, PendingCall>()
|
||||
wireReplies(port, pending)
|
||||
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
expect(Object.getPrototypeOf(tools)).toBeNull()
|
||||
await expect(tools['__proto__']?.({})).resolves.toBe('__proto__-ok')
|
||||
await expect(tools['constructor']?.({})).resolves.toBe('constructor-ok')
|
||||
await expect(tools['toString']?.({})).resolves.toBe('toString-ok')
|
||||
})
|
||||
|
||||
it('rejects a postMessage clone failure without leaking the pending entry', async () => {
|
||||
let firstCall = true
|
||||
const throwingPort: BootstrapPort = {
|
||||
// First call throws an Error (the real DataCloneError shape), the
|
||||
// second a bare string — the rejection renders both.
|
||||
postMessage: () => {
|
||||
if (firstCall) { firstCall = false; throw new Error('DataCloneError-ish') }
|
||||
throw 'raw-clone-failure'
|
||||
},
|
||||
on: () => {},
|
||||
}
|
||||
const pending = new Map<number, PendingCall>()
|
||||
const data = { namespaces: [toolNamespace(['x'])] }
|
||||
const errorClasses = makeBindingErrorClasses(data)
|
||||
const ToolCallError = errorClasses.get('tools')
|
||||
const [tools] = makeNamespaces(
|
||||
data,
|
||||
throwingPort,
|
||||
pending,
|
||||
{ value: 1 },
|
||||
errorClasses,
|
||||
) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve())
|
||||
const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve())
|
||||
expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
|
||||
expect(second).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
|
||||
expect(first).toBeInstanceOf(ToolCallError)
|
||||
expect(second).toBeInstanceOf(ToolCallError)
|
||||
expect((first as Error).message).toMatch(/DataCloneError-ish/)
|
||||
expect((second as Error).message).toMatch(/raw-clone-failure/)
|
||||
expect(pending.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects lossy arguments before posting or allocating a call id', async () => {
|
||||
let posts = 0
|
||||
const port: BootstrapPort = { postMessage: () => { posts += 1 }, on: () => {} }
|
||||
const pending = new Map<number, PendingCall>()
|
||||
const nextId = { value: 1 }
|
||||
const [tools] = makeNamespaces(
|
||||
{ namespaces: [toolNamespace(['x'])] }, port, pending, nextId,
|
||||
) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const decorated = [1]
|
||||
Object.defineProperty(decorated, 'extra', { value: true })
|
||||
const throwing = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => { throw new Error('getter exploded') },
|
||||
})
|
||||
|
||||
for (const value of [() => 1, new Date(), decorated, throwing]) {
|
||||
const failure = await rejectionOf(tools.x?.(value) ?? Promise.resolve())
|
||||
expect(failure).toMatchObject({
|
||||
name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON',
|
||||
})
|
||||
}
|
||||
expect(posts).toBe(0)
|
||||
expect(pending.size).toBe(0)
|
||||
expect(nextId.value).toBe(1)
|
||||
})
|
||||
|
||||
it('uses ordinary Error for non-tools namespace failures', async () => {
|
||||
const deniedPort = new FakePort()
|
||||
deniedPort.respond = message => message.type === 'call'
|
||||
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
|
||||
: undefined
|
||||
const deniedPending = new Map<number, PendingCall>()
|
||||
wireReplies(deniedPort, deniedPending)
|
||||
const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve())
|
||||
expect(denied).toBeInstanceOf(Error)
|
||||
expect(denied).toMatchObject({ name: 'Error', message: 'helper denied' })
|
||||
expect(denied).not.toHaveProperty('toolName')
|
||||
|
||||
const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve())
|
||||
expect(invalid).toBeInstanceOf(Error)
|
||||
expect((invalid as Error).message).toBe('binding arguments must be lossless JSON')
|
||||
|
||||
const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} }
|
||||
const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve())
|
||||
expect(cloneFailure).toBeInstanceOf(Error)
|
||||
expect(cloneFailure).not.toHaveProperty('toolName')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runWorkerMain', () => {
|
||||
it('runs a program end-to-end: bindings, console, return value', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = (message) => {
|
||||
if (message.type !== 'call') return undefined
|
||||
const args = decodeWorkerJson(message.args) as { n: number }
|
||||
return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(args.n * 2) }
|
||||
}
|
||||
await runWorkerMain(port, {
|
||||
...BOOT,
|
||||
code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
|
||||
namespaces: [{ global: 'tools', names: ['double'] }],
|
||||
}, fakeStreams())
|
||||
expect(port.logs()).toEqual(['got 42'])
|
||||
expect(port.doneValue()).toEqual({ doubled: 42 })
|
||||
})
|
||||
|
||||
it('reports worker-side log capture overflow before completing', async () => {
|
||||
const port = new FakePort()
|
||||
await runWorkerMain(port, {
|
||||
maxOutputBytes: 4,
|
||||
code: 'console.log("12345"); return null',
|
||||
namespaces: [],
|
||||
}, fakeStreams())
|
||||
expect(port.logs()).toEqual([])
|
||||
expect(port.sent).toContainEqual({ type: 'output-limit' })
|
||||
expect(port.done()).toEqual({
|
||||
type: 'done',
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a thrown program error on the done message', async () => {
|
||||
const port = new FakePort()
|
||||
await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
|
||||
const done = port.done()
|
||||
expect(done?.type).toBe('done')
|
||||
expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception')
|
||||
expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom')
|
||||
expect(done?.type === 'done' ? done.value : undefined).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders non-Error throws and stack-less Errors on the done message', async () => {
|
||||
const rawPort = new FakePort()
|
||||
await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
|
||||
expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } })
|
||||
|
||||
const barePort = new FakePort()
|
||||
await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
|
||||
expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
|
||||
})
|
||||
|
||||
it('replaces giant thrown strings and Error stacks before posting the done message', async () => {
|
||||
const rawPort = new FakePort()
|
||||
await runWorkerMain(rawPort, {
|
||||
maxOutputBytes: 64,
|
||||
code: 'throw "x".repeat(1_000_000)',
|
||||
namespaces: [],
|
||||
}, fakeStreams())
|
||||
expect(rawPort.done()).toEqual({
|
||||
type: 'done',
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
|
||||
const stackPort = new FakePort()
|
||||
await runWorkerMain(stackPort, {
|
||||
maxOutputBytes: 64,
|
||||
code: 'throw new Error("x".repeat(1_000_000))',
|
||||
namespaces: [],
|
||||
}, fakeStreams())
|
||||
expect(stackPort.done()).toEqual({
|
||||
type: 'done',
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
|
||||
await runWorkerMain(port, {
|
||||
...BOOT,
|
||||
code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
namespaces: [toolNamespace(['x'])],
|
||||
}, fakeStreams())
|
||||
expect(port.doneValue()).toEqual({ caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' })
|
||||
})
|
||||
|
||||
it('materializes a consumer-declared rejection class without knowing the namespace', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call'
|
||||
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
|
||||
: undefined
|
||||
await runWorkerMain(port, {
|
||||
...BOOT,
|
||||
code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }',
|
||||
namespaces: [{
|
||||
global: 'helpers',
|
||||
names: ['x'],
|
||||
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
|
||||
}],
|
||||
}, fakeStreams())
|
||||
expect(port.doneValue()).toEqual({ caught: true, name: 'HelperCallError', helperName: 'x', message: 'helper denied' })
|
||||
})
|
||||
|
||||
it('ignores replies for unknown pending ids', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = (message) => {
|
||||
if (message.type !== 'call') return undefined
|
||||
// Deliver a stray reply first; the real one follows.
|
||||
port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodeWorkerJson('stray') })
|
||||
return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson('real') }
|
||||
}
|
||||
await runWorkerMain(port, {
|
||||
...BOOT,
|
||||
code: 'return await tools.x({})',
|
||||
namespaces: [{ global: 'tools', names: ['x'] }],
|
||||
}, fakeStreams())
|
||||
expect(port.doneValue()).toBe('real')
|
||||
})
|
||||
|
||||
it('captures raw stream writes through the patched process streams', async () => {
|
||||
const port = new FakePort()
|
||||
const streams = fakeStreams()
|
||||
await runWorkerMain(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
|
||||
streams.stdout.write('never seen — already restored? no: patch persists in worker')
|
||||
// The patch stays installed for the worker's lifetime; writes during the
|
||||
// program landed in order. Here the program wrote nothing via streams, so
|
||||
// only the post-run write above went through the patched slot.
|
||||
expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless built-artifact smoke: plain Node imports the package by name through its exports map,
|
||||
* then exercises type stripping, sibling `worker.cjs` loading, bindings, and logs. Unit tests use
|
||||
* `src/worker.ts`; this pins the downstream `lib/index.js` path. It skips when `lib/` is absent,
|
||||
* and CI runs it after the build.
|
||||
*/
|
||||
|
||||
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const built = ['lib/index.js', 'lib/worker.cjs'].every(file => existsSync(join(pkgDir, file)))
|
||||
&& existsSync(join(pkgDir, '../code-runtime/lib/index.js'))
|
||||
|
||||
describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.cjs entry', async () => {
|
||||
const script = `
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { WorkerThreadCodeRuntime } = await import('@deepseek-ai/dsh-code-runtime-worker-thread')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WorkerThreadCodeRuntime, {})
|
||||
const result = await ctx.codeRuntime.run({
|
||||
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); let failure; try { await tools.fail({}) } catch (error) { failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } } return { doubled, failure };',
|
||||
bindings: [{
|
||||
global: 'tools',
|
||||
functions: {
|
||||
double: async args => args.n * 2,
|
||||
fail: async () => { throw new Error('denied') },
|
||||
},
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}],
|
||||
})
|
||||
console.log(JSON.stringify(result))
|
||||
process.exit(0)
|
||||
`
|
||||
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: pkgDir,
|
||||
stdin: 'ignore',
|
||||
timeout: 55_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
|
||||
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown }
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toEqual({
|
||||
doubled: 42,
|
||||
failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'denied' },
|
||||
})
|
||||
expect(result.logs).toContain('halfway 42')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from '../src/output-json.ts'
|
||||
|
||||
describe('truncateJsonStringBytes', () => {
|
||||
it('returns a fitting string whole and rejects budgets without JSON quotes', () => {
|
||||
expect(truncateJsonStringBytes('fits', 6)).toBe('fits')
|
||||
expect(truncateJsonStringBytes('x', 1)).toBe('')
|
||||
expect(jsonStringBytesUpTo('fits', 6)).toBe(6)
|
||||
expect(jsonStringBytesUpTo('fits', 5)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accounts every JSON escape and cuts only between complete code points', () => {
|
||||
const prefix = '"\\\b\t\n\f\r\u0000😀\ud800€a'
|
||||
const text = `${prefix}z`
|
||||
const budget = Buffer.byteLength(JSON.stringify(prefix), 'utf8')
|
||||
|
||||
expect(truncateJsonStringBytes(text, budget)).toBe(prefix)
|
||||
expect(Buffer.byteLength(JSON.stringify(truncateJsonStringBytes(text, budget)), 'utf8')).toBe(budget)
|
||||
})
|
||||
|
||||
it('bounds hostile strings without materializing their complete escaped form', () => {
|
||||
const stringify = vi.spyOn(JSON, 'stringify').mockImplementation(() => { throw new Error('must not stringify') })
|
||||
try {
|
||||
expect(jsonStringBytesUpTo('"'.repeat(10_000), 32)).toBeUndefined()
|
||||
expect(truncateJsonStringBytes('"'.repeat(10_000), 32)).toBe('"'.repeat(15))
|
||||
} finally {
|
||||
stringify.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('jsonValueBytesUpTo', () => {
|
||||
it('matches JSON serialization for every lossless value branch and stops at the cap', () => {
|
||||
const value = {
|
||||
empty: {},
|
||||
nil: null,
|
||||
yes: true,
|
||||
no: false,
|
||||
number: 1.5,
|
||||
text: '"\n😀',
|
||||
array: [1, 'x'],
|
||||
}
|
||||
const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8')
|
||||
|
||||
expect(jsonValueBytesUpTo(value, bytes)).toBe(bytes)
|
||||
expect(jsonValueBytesUpTo(value, bytes - 1)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo({}, 1)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo([], 1)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo([], 2)).toBe(2)
|
||||
expect(jsonValueBytesUpTo(null, 3)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo(10, 1)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo(false, 4)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo(new Array<never>(1), 10)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo([null], 5)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo([0, 0], 3)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo({ a: null, b: null }, 10)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo({ long: null }, 2)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo({ '': null }, 4)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo({ a: null }, 9)).toBeUndefined()
|
||||
expect(jsonValueBytesUpTo({ a: undefined } as unknown as CodeJsonValue, 100)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('meters deeply nested arrays without recursive stack growth', () => {
|
||||
let value: CodeJsonValue = null
|
||||
for (let depth = 0; depth < 5_000; depth++) value = [value]
|
||||
|
||||
expect(jsonValueBytesUpTo(value, 10_004)).toBe(10_004)
|
||||
expect(jsonValueBytesUpTo(value, 10_003)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses module-captured intrinsics after model-visible globals are mutated', () => {
|
||||
const value: CodeJsonValue = { payload: ['€', 42] }
|
||||
const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8')
|
||||
const arrayIsArrayDescriptor = Object.getOwnPropertyDescriptor(Array, 'isArray')!
|
||||
const arrayPopDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'pop')!
|
||||
const arrayPushDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'push')!
|
||||
const byteLengthDescriptor = Object.getOwnPropertyDescriptor(Buffer, 'byteLength')!
|
||||
const objectKeysDescriptor = Object.getOwnPropertyDescriptor(Object, 'keys')!
|
||||
const charCodeAtDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'charCodeAt')!
|
||||
const codePointAtDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'codePointAt')!
|
||||
const sliceDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'slice')!
|
||||
let measured: number | undefined
|
||||
let prefix = ''
|
||||
try {
|
||||
Array.isArray = (_value: unknown): _value is never[] => false
|
||||
Array.prototype.pop = () => { throw new Error('mutated pop') }
|
||||
Array.prototype.push = () => { throw new Error('mutated push') }
|
||||
Buffer.byteLength = () => 0
|
||||
Object.keys = () => []
|
||||
String.prototype.charCodeAt = () => { throw new Error('mutated charCodeAt') }
|
||||
String.prototype.codePointAt = () => { throw new Error('mutated codePointAt') }
|
||||
String.prototype.slice = () => { throw new Error('mutated slice') }
|
||||
measured = jsonValueBytesUpTo(value, bytes)
|
||||
prefix = truncateJsonStringBytes('€x', 5)
|
||||
} finally {
|
||||
Object.defineProperty(Array, 'isArray', arrayIsArrayDescriptor)
|
||||
Object.defineProperty(Array.prototype, 'pop', arrayPopDescriptor)
|
||||
Object.defineProperty(Array.prototype, 'push', arrayPushDescriptor)
|
||||
Object.defineProperty(Buffer, 'byteLength', byteLengthDescriptor)
|
||||
Object.defineProperty(Object, 'keys', objectKeysDescriptor)
|
||||
Object.defineProperty(String.prototype, 'charCodeAt', charCodeAtDescriptor)
|
||||
Object.defineProperty(String.prototype, 'codePointAt', codePointAtDescriptor)
|
||||
Object.defineProperty(String.prototype, 'slice', sliceDescriptor)
|
||||
}
|
||||
expect(measured).toBe(bytes)
|
||||
expect(prefix).toBe('€')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,893 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { WorkerThreadCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker-thread'
|
||||
import type { Config } from '@deepseek-ai/dsh-code-runtime-worker-thread'
|
||||
import type { CodeBindingFunction, CodeBindingNamespace, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
/**
|
||||
* Integration suite over REAL worker threads (no mocks — workers are cheap
|
||||
* and local, per docs/testing.md's real-over-mock policy). Each test builds
|
||||
* a fresh context so budgets can be tuned per case.
|
||||
*/
|
||||
async function setup(config: Config = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WorkerThreadCodeRuntime, config)
|
||||
const runtime = ctx.codeRuntime as WorkerThreadCodeRuntime
|
||||
return { ctx, runtime }
|
||||
}
|
||||
|
||||
/** Convenience: one namespace `tools` with the given functions. */
|
||||
function tools(functions: Record<string, (args: unknown) => Promise<unknown>>): CodeBindingNamespace[] {
|
||||
return [{
|
||||
global: 'tools',
|
||||
functions: functions as Record<string, CodeBindingFunction>,
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}]
|
||||
}
|
||||
|
||||
describe('WorkerThreadCodeRuntime — programs and bindings (real workers)', () => {
|
||||
it('registers with the seam descriptors', async () => {
|
||||
const { runtime } = await setup()
|
||||
expect(runtime.language).toBe('typescript')
|
||||
expect(runtime.isolation).toBe('worker-thread')
|
||||
})
|
||||
|
||||
it('runs TypeScript (erasable syntax), captures output in order, returns the value', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
interface Point { x: number; y: number }
|
||||
const p: Point = { x: 1, y: 2 } as Point;
|
||||
console.log('point', p);
|
||||
process.stdout.write('raw-out\\n');
|
||||
console.warn('careful');
|
||||
return p.x + p.y;
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe(3)
|
||||
expect(result.logs).toEqual(['point { x: 1, y: 2 }', 'raw-out\n', 'careful'])
|
||||
})
|
||||
|
||||
it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
|
||||
const { runtime } = await setup()
|
||||
const calls: unknown[] = []
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const first = await tools.echo({ n: 1 });
|
||||
let caught = {};
|
||||
try { await tools.fail({}) } catch (error) { caught = { isTyped: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }
|
||||
let caughtRaw = {};
|
||||
try { await tools.failRaw({}) } catch (error) { caughtRaw = { name: error.name, toolName: error.toolName, message: error.message } }
|
||||
return { first, caught, caughtRaw };
|
||||
`,
|
||||
bindings: tools({
|
||||
echo: async (args) => { calls.push(args); return { echoed: args } },
|
||||
fail: async () => { throw new Error('nope') },
|
||||
// A non-Error throw: the host renders it, the program still catches.
|
||||
failRaw: async () => { throw 'raw-nope' },
|
||||
}),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toEqual({
|
||||
first: { echoed: { n: 1 } },
|
||||
caught: { isTyped: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
|
||||
caughtRaw: { name: 'ToolCallError', toolName: 'failRaw', message: 'raw-nope' },
|
||||
})
|
||||
expect(calls).toEqual([{ n: 1 }])
|
||||
})
|
||||
|
||||
it('materializes a typed rejection from a generic namespace descriptor', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
try { await helpers.fail({}) } catch (error) {
|
||||
return {
|
||||
isTyped: error instanceof HelperCallError,
|
||||
name: error.name,
|
||||
helperName: error.helperName,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
`,
|
||||
bindings: [{
|
||||
global: 'helpers',
|
||||
functions: { fail: async () => { throw new Error('nope') } },
|
||||
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
|
||||
}],
|
||||
})
|
||||
expect(result.value).toEqual({
|
||||
isTyped: true,
|
||||
name: 'HelperCallError',
|
||||
helperName: 'fail',
|
||||
message: 'nope',
|
||||
})
|
||||
})
|
||||
|
||||
it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
let value = 'leaf';
|
||||
for (let depth = 0; depth < 3_000; depth++) value = [value];
|
||||
return await tools.echo(value);
|
||||
`,
|
||||
bindings: tools({ echo: async args => args }),
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
let cursor = result.value
|
||||
for (let depth = 0; depth < 3_000; depth++) {
|
||||
expect(Array.isArray(cursor)).toBe(true)
|
||||
cursor = Array.isArray(cursor) ? cursor[0] : undefined
|
||||
}
|
||||
expect(cursor).toBe('leaf')
|
||||
}, 15_000)
|
||||
|
||||
it('reports non-erasable syntax as an exception without spawning a worker', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
|
||||
expect(result.error?.kind).toBe('exception')
|
||||
expect(result.error?.message).toMatch(/enum|strip/i)
|
||||
})
|
||||
|
||||
it('reports a runtime throw as an exception with the message', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'throw new Error("kaboom")', bindings: [] })
|
||||
expect(result.error?.kind).toBe('exception')
|
||||
expect(result.error?.message).toContain('kaboom')
|
||||
})
|
||||
|
||||
it('gives the program an EMPTY environment', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return JSON.stringify(process.env)', bindings: [] })
|
||||
expect(result.value).toBe('{}')
|
||||
})
|
||||
|
||||
it('rejects a non-lossless completion instead of replacing it with rendered text', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
|
||||
})
|
||||
|
||||
it('completes a program that returns nothing with no value at all', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'const x = 1', bindings: [] })
|
||||
expect(result.error).toBeUndefined()
|
||||
expect('value' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps logs streamed before a failure', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'console.log("before"); throw new Error("after-log")',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error?.kind).toBe('exception')
|
||||
expect(result.logs).toContain('before')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkerThreadCodeRuntime — budgets and containment (real workers)', () => {
|
||||
it('ends a hot loop at the compute budget — including behind a pending decoy dispatch', async () => {
|
||||
const { runtime } = await setup({ computeMs: 300, maxWallMs: 30_000 })
|
||||
const result = await runtime.run({
|
||||
// The decoy: fire a call at a never-resolving binding WITHOUT awaiting,
|
||||
// then spin. Host-side pending-call bookkeeping would pause a naive
|
||||
// budget here; measured busy time cannot be fooled.
|
||||
program: 'void tools.slow({}); for (;;) {}',
|
||||
bindings: tools({ slow: () => new Promise(() => {}) }),
|
||||
})
|
||||
expect(result.error?.kind).toBe('timeout')
|
||||
expect(result.error?.message).toContain('compute budget')
|
||||
}, 15_000)
|
||||
|
||||
it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
|
||||
// Keep the binding delay above the compute allowance while leaving enough
|
||||
// headroom for worker bootstrap on loaded CI hosts.
|
||||
const { runtime } = await setup({ computeMs: 1_000, maxWallMs: 30_000 })
|
||||
const result = await runtime.run({
|
||||
program: 'return await tools.slow({})',
|
||||
bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 1_500)) }),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('slow-done')
|
||||
}, 15_000)
|
||||
|
||||
it('ends an idle-forever run at the wall-clock ceiling', async () => {
|
||||
const { runtime } = await setup({ computeMs: 30_000, maxWallMs: 400 })
|
||||
const result = await runtime.run({
|
||||
program: 'await tools.never({}); return 1',
|
||||
bindings: tools({ never: () => new Promise(() => {}) }),
|
||||
})
|
||||
expect(result.error?.kind).toBe('timeout')
|
||||
expect(result.error?.message).toContain('wall-clock ceiling')
|
||||
}, 15_000)
|
||||
|
||||
it('reports an abort mid-run and stops the worker', async () => {
|
||||
const { runtime } = await setup()
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => { controller.abort('user-cancel') }, 150)
|
||||
const result = await runtime.run({ program: 'for (;;) {}', bindings: [], signal: controller.signal })
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'user-cancel' })
|
||||
}, 15_000)
|
||||
|
||||
it('reports a pre-aborted signal without spawning', async () => {
|
||||
const { runtime } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort('too-late')
|
||||
const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'too-late' })
|
||||
})
|
||||
|
||||
it('applies the outer-output cap to failures before worker startup', async () => {
|
||||
const capped = await setup({ maxOutputBytes: 64 })
|
||||
const controller = new AbortController()
|
||||
controller.abort('A'.repeat(1_000))
|
||||
const aborted = await capped.runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
|
||||
expect(aborted).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } })
|
||||
|
||||
const minimal = await setup({ maxOutputBytes: 4 })
|
||||
const invalid = await minimal.runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
|
||||
expect(invalid.error?.kind).toBe('output-limit')
|
||||
expect(Buffer.byteLength(JSON.stringify(invalid.logs), 'utf8') + Buffer.byteLength(JSON.stringify(invalid.error?.message), 'utf8')).toBeLessThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('drops a binding resolution that lands after the run settled', async () => {
|
||||
const { runtime } = await setup()
|
||||
const controller = new AbortController()
|
||||
let replyDelivered!: Promise<void>
|
||||
const result = await runtime.run({
|
||||
program: 'void tools.late({}); for (;;) {}',
|
||||
bindings: tools({
|
||||
// Anchored on invocation: abort 100ms after the call reaches the
|
||||
// host, resolve 400ms after — by then the run has settled, so the
|
||||
// resolution's reply hits the post-settlement drop.
|
||||
late: () => new Promise((resolve) => {
|
||||
setTimeout(() => { controller.abort('cancel-now') }, 100)
|
||||
replyDelivered = new Promise(done => setTimeout(() => { resolve('too-late'); done() }, 400))
|
||||
}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'cancel-now' })
|
||||
// Let the late resolution actually fire so its reply executes instead of
|
||||
// being cancelled with the test.
|
||||
await replyDelivered
|
||||
}, 15_000)
|
||||
|
||||
it('contains an OOM under resourceLimits as worker-exit, host process healthy', async () => {
|
||||
const { runtime } = await setup({ maxOldGenerationSizeMb: 32 })
|
||||
const result = await runtime.run({
|
||||
program: 'const hog = []; for (;;) hog.push(new Array(1e6).fill(1));',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
// And the host is fine: run something else.
|
||||
const after = await runtime.run({ program: 'return "alive"', bindings: [] })
|
||||
expect(after.value).toBe('alive')
|
||||
}, 30_000)
|
||||
|
||||
it('reports a worker that exits before publishing a completion', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'process.exit(7)', bindings: [] })
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'worker-exit', message: 'worker exited with code 7 before completing' },
|
||||
})
|
||||
})
|
||||
|
||||
it('fails runaway log output explicitly while retaining a bounded prefix', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 300 })
|
||||
const result = await runtime.run({
|
||||
program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 300 bytes' })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.logs.length).toBeGreaterThan(0)
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(300)
|
||||
})
|
||||
|
||||
it('retains a fitting prefix when one oversized log is the first output', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 96 })
|
||||
const result = await runtime.run({
|
||||
program: 'console.log(`start-${`😀"\\\\\\n`.repeat(100)}`); return null',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
|
||||
expect(result.logs).toHaveLength(1)
|
||||
expect(result.logs[0]?.startsWith('start-')).toBe(true)
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
|
||||
+ Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(96)
|
||||
})
|
||||
|
||||
it('fails an oversized return value without substituting a string', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
|
||||
})
|
||||
|
||||
it('uses UTF-8 serialized bytes at the exact completion boundary', async () => {
|
||||
const exact = await setup({ maxOutputBytes: 7 })
|
||||
const exactResult = await exact.runtime.run({ program: 'return "€"', bindings: [] })
|
||||
// [] costs two bytes and JSON serialization of "€" costs five.
|
||||
expect(exactResult).toEqual({ logs: [], value: '€' })
|
||||
|
||||
const over = await setup({ maxOutputBytes: 6 })
|
||||
const overResult = await over.runtime.run({ program: 'return "€"', bindings: [] })
|
||||
expect(overResult.error?.kind).toBe('output-limit')
|
||||
})
|
||||
|
||||
it('accounts logs and completion in one exact combined ledger', async () => {
|
||||
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
|
||||
const exact = await setup({ maxOutputBytes: 11 })
|
||||
expect(await exact.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] }))
|
||||
.toEqual({ logs: ['abc'], value: 'xy' })
|
||||
|
||||
const over = await setup({ maxOutputBytes: 10 })
|
||||
const result = await over.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('accounts logs and exception diagnostics before the worker port boundary', async () => {
|
||||
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
|
||||
const exact = await setup({ maxOutputBytes: 11 })
|
||||
expect(await exact.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] }))
|
||||
.toEqual({ logs: ['abc'], error: { kind: 'exception', message: 'xy' } })
|
||||
|
||||
const over = await setup({ maxOutputBytes: 10 })
|
||||
const result = await over.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] })
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
|
||||
+ Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('does not send a giant Error stack across the worker port', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: 'throw new Error("x".repeat(1_000_000))',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('completes a program that awaits its write callback, capturing the chunk', async () => {
|
||||
// Node's write(chunk[, encoding][, callback]) contract: dropping the
|
||||
// callback would leave this promise pending until the wall ceiling and
|
||||
// misreport a completed program as a timeout.
|
||||
const { runtime } = await setup({ maxWallMs: 2_000 })
|
||||
const result = await runtime.run({
|
||||
program: 'await new Promise(resolve => process.stdout.write("flushed", resolve)); return "done"',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('done')
|
||||
expect(result.logs).toContain('flushed')
|
||||
})
|
||||
|
||||
it('returns a large JSON container exactly when the outer cap permits it', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] })
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toEqual(new Array(50_000).fill(7))
|
||||
})
|
||||
|
||||
it('returns an exact completion at the default 64 MiB combined boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
// [] costs two bytes and the JSON string contributes two quotes, leaving
|
||||
// exactly this many payload bytes under the 67_108_864-byte default.
|
||||
const result = await runtime.run({ program: 'return "x".repeat(67_108_860)', bindings: [] })
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs).toEqual([])
|
||||
expect(result.value).toHaveLength(67_108_860)
|
||||
}, 60_000)
|
||||
|
||||
it('fails one byte over the default 64 MiB combined boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return "x".repeat(67_108_861)', bindings: [] })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' })
|
||||
}, 60_000)
|
||||
|
||||
it('accounts pipe writes that bypass the patched write slot in the same outer ledger', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 80 })
|
||||
const result = await runtime.run({
|
||||
// The prototype write bypasses the patched instance and reaches the real pipe. Pauses keep
|
||||
// writes in separate chunks and let both reach the host before settlement.
|
||||
program: `
|
||||
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
|
||||
write('a'.repeat(20));
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
write('b'.repeat(100));
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return 1;
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(result.logs).toContain('a'.repeat(20))
|
||||
expect(result.logs[1]?.length).toBeGreaterThan(0)
|
||||
expect('b'.repeat(100).startsWith(result.logs[1] ?? '')).toBe(true)
|
||||
}, 15_000)
|
||||
|
||||
it('drains pipe output queued before terminal worker teardown completes', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 200_000 })
|
||||
const payload = `late-pipe-${'x'.repeat(100_000)}`
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
|
||||
write('late-pipe-' + 'x'.repeat(100_000));
|
||||
parentPort.postMessage({ type: 'done', value: ['done'] });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('done')
|
||||
expect(result.logs.join('') === payload).toBe(true)
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
describe('WorkerThreadCodeRuntime — hostile programs (real workers)', () => {
|
||||
it('survives forged port traffic: unknown binding names, duplicate ids, junk shapes', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
|
||||
parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
|
||||
parentPort.postMessage({ type: 'call', id: 7778, global: 'tools', name: 'constructor', args: {} });
|
||||
parentPort.postMessage({ type: 'junk' });
|
||||
return await tools.real({});
|
||||
`,
|
||||
bindings: tools({ real: async () => 'still-works' }),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('still-works')
|
||||
})
|
||||
|
||||
it('survives arbitrary junk on the port: non-objects, junk types, malformed calls, logs, and dones', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
for (const junk of [
|
||||
null, 42, 'junk', [],
|
||||
{ type: 'nope' },
|
||||
{ type: 'call' },
|
||||
{ type: 'call', id: 'x', global: 'tools', name: 'real', args: {} },
|
||||
{ type: 'call', id: 1e9, global: 7, name: 'real', args: {} },
|
||||
{ type: 'call', id: 1e9, global: 'tools', name: 7, args: {} },
|
||||
{ type: 'log' },
|
||||
{ type: 'log', text: null },
|
||||
{ type: 'log', text: 7 },
|
||||
{ type: 'log', text: {} },
|
||||
{ type: 'done', error: 5 },
|
||||
{ type: 'done', error: { kind: 'exception', message: 5 } },
|
||||
{ type: 'done', error: { kind: 'invented', message: 'bad kind' } },
|
||||
]) parentPort.postMessage(junk);
|
||||
return await tools.real({});
|
||||
`,
|
||||
bindings: tools({ real: async () => 'still-works' }),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('still-works')
|
||||
expect(result.logs).toEqual([])
|
||||
})
|
||||
|
||||
it('fails forged log floods and forged done values through the same outer cap', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 200 })
|
||||
const result = await runtime.run({
|
||||
// Forged messages bypass the worker-side LogBuffer and completion check
|
||||
// entirely — only the host-side ledger and re-cap stand between model
|
||||
// code and an unbounded result.
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true });
|
||||
parentPort.postMessage({ type: 'done', value: ['V'.repeat(100000)] });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 200 bytes' })
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('re-caps an oversized forged done value at the host boundary', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', value: ['V'.repeat(100_000)] });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds one oversized forged log while retaining its fitting escaped prefix', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 96 })
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'log', text: '"'.repeat(1_000_000) });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
|
||||
expect(result.logs).toHaveLength(1)
|
||||
expect(result.logs[0]).toMatch(/^"+$/)
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify('outer output exceeded 96 bytes'), 'utf8')).toBeLessThanOrEqual(96)
|
||||
})
|
||||
|
||||
it('drops a malformed forged done carrying both value and error', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', value: 'lied', error: { kind: 'exception', message: 'fake failure' } });
|
||||
return 'honest';
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({ logs: [], error: { kind: 'exception', message: 'fake failure' } })
|
||||
})
|
||||
|
||||
it('contains a deeply nested forged completion without overflowing the host meter', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
const value = [];
|
||||
for (let depth = 0; depth < 3_000; depth++) value.push({ kind: 'array', length: 1 });
|
||||
value.push(null);
|
||||
setTimeout(() => { parentPort.postMessage({ type: 'done', value }) }, 25);
|
||||
// Prevent bootstrap's normal undefined completion from racing the forged terminal.
|
||||
await new Promise(() => {});
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
let value = result.value
|
||||
let depth = 0
|
||||
while (Array.isArray(value)) {
|
||||
expect(value).toHaveLength(1)
|
||||
value = value[0]
|
||||
depth += 1
|
||||
}
|
||||
expect(depth).toBe(3_000)
|
||||
expect(value).toBeNull()
|
||||
}, 15_000)
|
||||
|
||||
it('turns forged over-limit error text into output-limit at the host', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', error: { kind: 'exception', message: '€'.repeat(1000) } });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
|
||||
})
|
||||
|
||||
it('answers a binding whose resolution is not lossless JSON with a typed failure reply', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
bindings: tools({ bad: async () => (() => 1) }),
|
||||
})
|
||||
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
|
||||
})
|
||||
|
||||
it('rejects lossy binding arguments in the worker before invoking the host binding', async () => {
|
||||
const { runtime } = await setup()
|
||||
let calls = 0
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const decorated = [1]; Object.defineProperty(decorated, 'extra', { value: true });
|
||||
const values = [new Date(), decorated, () => 1];
|
||||
const failures = [];
|
||||
for (const value of values) {
|
||||
try { await tools.never(value) } catch (error) {
|
||||
failures.push({ typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message });
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
`,
|
||||
bindings: tools({ never: async () => { calls += 1; return null } }),
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
expect(result.value).toEqual(new Array(3).fill({
|
||||
typed: true,
|
||||
name: 'ToolCallError',
|
||||
toolName: 'never',
|
||||
message: 'binding arguments must be lossless JSON',
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects intrinsic-looking exotic objects as arguments and completions', async () => {
|
||||
const { runtime } = await setup()
|
||||
let calls = 0
|
||||
const forgeObject = `
|
||||
const prototype = Object.create(null);
|
||||
const SpoofedObject = function Object() {};
|
||||
SpoofedObject.prototype = prototype;
|
||||
Object.defineProperty(prototype, 'constructor', { value: SpoofedObject });
|
||||
const forged = Object.assign(Object.create(prototype), { value: 1 });
|
||||
Function.prototype.toString = () => 'function Object() { [native code] }';
|
||||
`
|
||||
const argument = await runtime.run({
|
||||
program: `${forgeObject}
|
||||
try { await tools.never(forged) } catch (error) {
|
||||
return { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
|
||||
}
|
||||
`,
|
||||
bindings: tools({ never: async () => { calls += 1; return null } }),
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
expect(argument.value).toEqual({
|
||||
typed: true,
|
||||
name: 'ToolCallError',
|
||||
toolName: 'never',
|
||||
message: 'binding arguments must be lossless JSON',
|
||||
})
|
||||
|
||||
const completion = await runtime.run({ program: `${forgeObject}\nreturn forged`, bindings: [] })
|
||||
expect(completion).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves binding and completion JSON after model code mutates boundary globals', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const arrayPrototype = Array.prototype;
|
||||
const objectPrototype = Object.prototype;
|
||||
const setPrototype = Set.prototype;
|
||||
const stringPrototype = String.prototype;
|
||||
Array.isArray = () => false;
|
||||
arrayPrototype.at = arrayPrototype.includes = arrayPrototype.pop = arrayPrototype.push = () => { throw new Error('mutated array method') };
|
||||
Object.defineProperty = Object.getOwnPropertyDescriptor = Object.getPrototypeOf = Object.keys = () => { throw new Error('mutated object method') };
|
||||
Object.hasOwn = () => false;
|
||||
Object.is = () => true;
|
||||
objectPrototype.propertyIsEnumerable = () => false;
|
||||
Number.isFinite = Number.isSafeInteger = () => false;
|
||||
Reflect.apply = Reflect.ownKeys = () => { throw new Error('mutated reflect method') };
|
||||
setPrototype.add = setPrototype.delete = setPrototype.has = () => { throw new Error('mutated set method') };
|
||||
stringPrototype.charCodeAt = stringPrototype.codePointAt = stringPrototype.slice = () => { throw new Error('mutated string method') };
|
||||
Buffer.byteLength = () => 0;
|
||||
Function.prototype.toString = () => 'mutated';
|
||||
objectPrototype.get = () => undefined;
|
||||
objectPrototype.constructor = arrayPrototype.constructor = null;
|
||||
globalThis.Array = globalThis.Buffer = globalThis.Error = globalThis.Function = globalThis.Number = globalThis.Object = globalThis.Reflect = globalThis.Set = globalThis.String = undefined;
|
||||
const echoed = await tools.echo({ request: ['€', 1] });
|
||||
let failure;
|
||||
try { await tools.fail({}) } catch (error) {
|
||||
failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
|
||||
}
|
||||
return { echoed, failure, completion: { ok: true, amount: 42 } };
|
||||
`,
|
||||
bindings: tools({ echo: async args => args, fail: async () => { throw new Error('nope') } }),
|
||||
})
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
value: {
|
||||
echoed: { request: ['€', 1] },
|
||||
failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
|
||||
completion: { ok: true, amount: 42 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects forged lossy binding arguments again at the host boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
let calls = 0
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
const forged = (id, args) => new Promise((resolve) => {
|
||||
const receive = (message) => {
|
||||
if (message?.type !== 'reply' || message.id !== id) return;
|
||||
parentPort.off('message', receive);
|
||||
resolve(message);
|
||||
};
|
||||
parentPort.on('message', receive);
|
||||
parentPort.postMessage({ type: 'call', id, global: 'tools', name: 'never', args });
|
||||
});
|
||||
const sparse = []; sparse.length = 1;
|
||||
const cycle = {}; cycle.self = cycle;
|
||||
return await Promise.all([
|
||||
forged(8001, new Date()),
|
||||
forged(8002, -0),
|
||||
forged(8003, sparse),
|
||||
forged(8004, cycle),
|
||||
]);
|
||||
`,
|
||||
bindings: tools({ never: async () => { calls += 1; return null } }),
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
expect(result.value).toEqual([8001, 8002, 8003, 8004].map(id => ({
|
||||
type: 'reply',
|
||||
id,
|
||||
ok: false,
|
||||
message: 'binding arguments must be lossless JSON',
|
||||
})))
|
||||
})
|
||||
|
||||
it('contains throwing getters while snapshotting binding resolutions', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
bindings: tools({ bad: async () => Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('getter exploded') } }) }),
|
||||
})
|
||||
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
|
||||
})
|
||||
|
||||
it('revalidates a forged lossy completion at the host boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', value: -0 });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({ logs: [], error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } })
|
||||
})
|
||||
|
||||
it('honors a forged worker-side output-limit signal', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'output-limit' });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' } })
|
||||
})
|
||||
|
||||
it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'return [await tools["__proto__"]({}), await tools["constructor"]({}), typeof tools["hasOwnProperty"]]',
|
||||
// Computed keys: a literal `'__proto__': …` entry would SET the record's
|
||||
// prototype instead of declaring a binding of that name.
|
||||
bindings: tools({ ['__proto__']: async () => 'proto-ok', ['constructor']: async () => 'ctor-ok' }),
|
||||
})
|
||||
expect(result.value).toEqual(['proto-ok', 'ctor-ok', 'undefined'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkerThreadCodeRuntime — seam misuse and lifecycle', () => {
|
||||
it('rejects invalid and duplicate binding globals loudly', async () => {
|
||||
const { runtime } = await setup()
|
||||
const cases: [string, RegExp][] = [
|
||||
['not valid!', /not a usable identifier/],
|
||||
['await', /not a usable identifier/],
|
||||
// `$tools` is legal JS but outside the seam's language-portable subset:
|
||||
// the same namespace list must work against every backend's language.
|
||||
['$tools', /not a usable identifier/],
|
||||
// `a$b` pins the second character class too: the old identifier regex
|
||||
// `[A-Za-z0-9_$]*` would have accepted a `$` after the first character.
|
||||
['a$b', /not a usable identifier/],
|
||||
// `lambda` is a Python keyword, refused here directly (not just
|
||||
// transitively) so the worker's adoption of PORTABLE_RESERVED_WORDS is
|
||||
// its own regression, symmetric with the `$tools` case.
|
||||
['lambda', /not a usable identifier/],
|
||||
['console', /reserved binding global/],
|
||||
]
|
||||
for (const [global, message] of cases) {
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
|
||||
}
|
||||
await expect(runtime.run({
|
||||
program: 'return 1',
|
||||
bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
|
||||
})).rejects.toThrow(/duplicate binding global/)
|
||||
|
||||
await expect(runtime.run({
|
||||
program: 'return typeof ToolCallError',
|
||||
bindings: [{ global: 'ToolCallError', functions: {} }],
|
||||
})).resolves.toMatchObject({ value: 'object' })
|
||||
})
|
||||
|
||||
it('rejects malformed or colliding binding error-class declarations', async () => {
|
||||
const { runtime } = await setup()
|
||||
const run = async (bindings: CodeBindingNamespace[]) => await runtime.run({ program: 'return 1', bindings })
|
||||
const namespace = (global: string, name: string, memberNameProperty = 'memberName'): CodeBindingNamespace => ({
|
||||
global,
|
||||
functions: {},
|
||||
errorClass: { name, memberNameProperty },
|
||||
})
|
||||
|
||||
await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/reserved binding global/)
|
||||
await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([
|
||||
namespace('tools', 'CallError'),
|
||||
namespace('helpers', 'CallError'),
|
||||
])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
|
||||
await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
|
||||
// The shared exclusion set covers Python's exception-protocol members and
|
||||
// dunders too, so the same errorClass is valid (or not) on every backend.
|
||||
await expect(run([namespace('tools', 'CallError', 'args')])).rejects.toThrow(/member property.*not usable/)
|
||||
await expect(run([namespace('tools', 'CallError', '__dict__')])).rejects.toThrow(/member property.*not usable/)
|
||||
// The Python backend's owned globals are refused here too (shared
|
||||
// RESERVED_BINDING_GLOBALS), keeping namespace lists backend-portable.
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [{ global: '__dsh_main__', functions: {} }] }))
|
||||
.rejects.toThrow(/reserved binding global/)
|
||||
})
|
||||
|
||||
it('rejects config values that are not positive numbers', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(WorkerThreadCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/)
|
||||
})
|
||||
|
||||
it('rejects a maxWallMs above Node\'s maximum timer delay', async () => {
|
||||
// setTimeout clamps a delay past 2^31-1 ms to 1 ms, so the positivity check
|
||||
// alone would accept a 25-day ceiling that expires on the first tick.
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(WorkerThreadCodeRuntime, { maxWallMs: 2_147_483_648 }))
|
||||
.rejects.toThrow(/maxWallMs must be at most 2147483647/)
|
||||
// The boundary itself is usable.
|
||||
await expect(ctx.plugin(WorkerThreadCodeRuntime, { maxWallMs: 2_147_483_647 })).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
it('requires maxOutputBytes to fit the smallest counted outer payloads', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(WorkerThreadCodeRuntime, { maxOutputBytes: 3 })).rejects.toThrow(/safe integer of at least 4/)
|
||||
await expect(ctx.plugin(WorkerThreadCodeRuntime, { maxOutputBytes: 4.5 })).rejects.toThrow(/safe integer of at least 4/)
|
||||
})
|
||||
|
||||
it('keeps runs isolated: no state survives from one run to the next', async () => {
|
||||
const { runtime } = await setup()
|
||||
await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })
|
||||
const second = await runtime.run({ program: 'return typeof globalThis.leak', bindings: [] })
|
||||
expect(second.value).toBe('undefined')
|
||||
})
|
||||
|
||||
it('disposal aborts in-flight runs, awaits worker exit, and rejects later runs', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(WorkerThreadCodeRuntime)
|
||||
const runtime = ctx.codeRuntime as WorkerThreadCodeRuntime
|
||||
const inflight: Promise<CodeRunResult> = runtime.run({ program: 'for (;;) {}', bindings: [] })
|
||||
// Give the worker a moment to actually start spinning.
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
await fiber.dispose()
|
||||
const result = await inflight
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'runtime disposed' })
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [] })).rejects.toThrow(/after disposal/)
|
||||
}, 15_000)
|
||||
|
||||
it('removes ctx.codeRuntime when the providing fiber disposes (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(WorkerThreadCodeRuntime)
|
||||
expect(ctx.get('codeRuntime')).toBeInstanceOf(WorkerThreadCodeRuntime)
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('codeRuntime')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { expect, it } from 'vitest'
|
||||
import { decodeWorkerJson } from '../src/worker-json.ts'
|
||||
|
||||
/**
|
||||
* Prove the unbuilt worker is a self-contained source closure. Copying it out
|
||||
* of the workspace makes any package runtime import fail even when local
|
||||
* `lib/` artifacts happen to exist.
|
||||
*/
|
||||
it('boots the source worker without workspace package outputs', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-code-source-worker-'))
|
||||
let worker: Worker | undefined
|
||||
try {
|
||||
const files = ['worker.ts', 'bootstrap.ts', 'protocol.ts', 'worker-json.ts', 'output-json.ts']
|
||||
await Promise.all(files.map(async (file) => {
|
||||
await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file))
|
||||
}))
|
||||
|
||||
worker = new Worker(join(directory, 'worker.ts'), {
|
||||
workerData: { code: 'return { answer: 42 }', namespaces: [], maxOutputBytes: 65_536 },
|
||||
env: {},
|
||||
execArgv: [],
|
||||
})
|
||||
const message = await new Promise<unknown>((resolve, reject) => {
|
||||
worker?.once('message', resolve)
|
||||
worker?.once('error', reject)
|
||||
})
|
||||
|
||||
expect(message).toMatchObject({ type: 'done' })
|
||||
const value = typeof message === 'object' && message !== null ? (message as { value?: unknown }).value : undefined
|
||||
expect(decodeWorkerJson(value)).toEqual({ answer: 42 })
|
||||
} finally {
|
||||
if (worker) await worker.terminate()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,257 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from '../src/worker-json.ts'
|
||||
|
||||
describe('snapshotCodeJsonValue', () => {
|
||||
it('matches the canonical scalar boundary', () => {
|
||||
const unsupported = [undefined, 1n, Symbol('value'), () => 1]
|
||||
for (const value of [null, false, 'text', 1.25, -0, Number.NaN, Number.POSITIVE_INFINITY, ...unsupported]) {
|
||||
expect(snapshotCodeJsonValue(value)).toEqual(snapshotJsonValue(value))
|
||||
}
|
||||
})
|
||||
|
||||
it('detaches dense arrays and plain or null-prototype records', () => {
|
||||
const shared = { value: 1 }
|
||||
const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, { shared })
|
||||
const source = { list: [nullPrototype, shared], alias: shared }
|
||||
|
||||
const snapshot = snapshotCodeJsonValue(source) as Record<string, unknown>
|
||||
shared.value = 2
|
||||
|
||||
expect(snapshot).toEqual({ list: [{ shared: { value: 1 } }, { value: 1 }], alias: { value: 1 } })
|
||||
expect(snapshot).not.toBe(source)
|
||||
expect((snapshot.list as unknown[])[0]).not.toBe(nullPrototype)
|
||||
expect(snapshot.alias).not.toBe(shared)
|
||||
})
|
||||
|
||||
it('accepts intrinsic plain containers from another JavaScript realm', () => {
|
||||
const foreign = runInNewContext('({ object: { nested: [1] }, array: [2, { ok: true }] })') as {
|
||||
object: unknown
|
||||
array: unknown
|
||||
}
|
||||
|
||||
expect(snapshotCodeJsonValue(foreign.object)).toEqual({ nested: [1] })
|
||||
expect(snapshotCodeJsonValue(foreign.array)).toEqual([2, { ok: true }])
|
||||
})
|
||||
|
||||
it('reads each accepted slot once and preserves a literal __proto__ key', () => {
|
||||
let objectReads = 0
|
||||
let arrayReads = 0
|
||||
const source = Object.create(null) as Record<string, unknown>
|
||||
Object.defineProperty(source, '__proto__', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
objectReads += 1
|
||||
return { safe: true }
|
||||
},
|
||||
})
|
||||
const array = new Array<unknown>(1)
|
||||
Object.defineProperty(array, 0, {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
arrayReads += 1
|
||||
return arrayReads === 1 ? source : undefined
|
||||
},
|
||||
})
|
||||
|
||||
const snapshot = snapshotCodeJsonValue(array) as Record<string, unknown>[]
|
||||
|
||||
expect(objectReads).toBe(1)
|
||||
expect(arrayReads).toBe(1)
|
||||
expect(Object.getPrototypeOf(snapshot[0])).toBe(Object.prototype)
|
||||
expect(Object.hasOwn(snapshot[0]!, '__proto__')).toBe(true)
|
||||
expect(snapshot[0]?.['__proto__']).toEqual({ safe: true })
|
||||
})
|
||||
|
||||
it('accepts deeply nested valid JSON without using the JavaScript call stack', () => {
|
||||
let value: unknown = 'leaf'
|
||||
for (let depth = 0; depth < 5_000; depth++) value = [value]
|
||||
|
||||
let cursor = snapshotCodeJsonValue(value)
|
||||
for (let depth = 0; depth < 5_000; depth++) {
|
||||
expect(Array.isArray(cursor)).toBe(true)
|
||||
cursor = Array.isArray(cursor) ? cursor[0] : undefined
|
||||
}
|
||||
expect(cursor).toBe('leaf')
|
||||
})
|
||||
|
||||
it('rejects exotic containers, sparse arrays, cycles, and invalid children', () => {
|
||||
class ExoticObject {
|
||||
readonly value = 1
|
||||
}
|
||||
class ExoticArray extends Array<number> {}
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
const decorated = [1]
|
||||
Object.defineProperty(decorated, 'extra', { value: true })
|
||||
const compensatedSparse = new Array(1)
|
||||
Object.defineProperty(compensatedSparse, 'extra', { value: true })
|
||||
const symbolDecorated = [1]
|
||||
Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true })
|
||||
const hiddenObject = Object.defineProperty({}, 'hidden', { value: true })
|
||||
const symbolObject = { [Symbol('extra')]: true }
|
||||
const customPrototype = Object.create(null) as Record<string, unknown>
|
||||
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record<string, unknown>, { value: 1 })
|
||||
const forgedPrototype: unknown[] = []
|
||||
Object.setPrototypeOf(forgedPrototype, null)
|
||||
const forgedArray = [1]
|
||||
Object.setPrototypeOf(forgedArray, forgedPrototype)
|
||||
const spoofedObjectPrototype = Object.create(null) as Record<string, unknown>
|
||||
const SpoofedObject = function Object() {}
|
||||
SpoofedObject.prototype = spoofedObjectPrototype
|
||||
Object.defineProperty(spoofedObjectPrototype, 'constructor', { value: SpoofedObject })
|
||||
const spoofedObject = Object.create(spoofedObjectPrototype) as Record<string, unknown>
|
||||
spoofedObject.value = 1
|
||||
const revokedPrototype = Object.create(null) as Record<string, unknown>
|
||||
const RevokedObject = function Object() {}
|
||||
RevokedObject.prototype = revokedPrototype
|
||||
const revokedConstructor = Proxy.revocable(RevokedObject, {})
|
||||
Object.defineProperty(revokedPrototype, 'constructor', { value: revokedConstructor.proxy })
|
||||
const revokedObject = Object.create(revokedPrototype) as Record<string, unknown>
|
||||
revokedConstructor.revoke()
|
||||
const spoofedArrayPrototype: unknown[] = []
|
||||
Object.setPrototypeOf(spoofedArrayPrototype, Object.prototype)
|
||||
const SpoofedArray = function Array() {}
|
||||
SpoofedArray.prototype = spoofedArrayPrototype
|
||||
Object.defineProperty(spoofedArrayPrototype, 'constructor', { value: SpoofedArray })
|
||||
const spoofedArray = [1]
|
||||
Object.setPrototypeOf(spoofedArray, spoofedArrayPrototype)
|
||||
|
||||
for (const value of [
|
||||
new ExoticObject(),
|
||||
new Map([['value', 1]]),
|
||||
new ExoticArray(1),
|
||||
new Array(1),
|
||||
decorated,
|
||||
compensatedSparse,
|
||||
symbolDecorated,
|
||||
hiddenObject,
|
||||
symbolObject,
|
||||
customPrototypeObject,
|
||||
forgedArray,
|
||||
spoofedObject,
|
||||
revokedObject,
|
||||
spoofedArray,
|
||||
cyclic,
|
||||
[undefined],
|
||||
{ value: undefined },
|
||||
]) {
|
||||
const canonical = snapshotJsonValue(value)
|
||||
expect(canonical).toBeUndefined()
|
||||
expect(snapshotCodeJsonValue(value)).toEqual(canonical)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an array whose getter mutates the validated length', () => {
|
||||
const array = [0, 2]
|
||||
Object.defineProperty(array, 0, {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
array.length = 1
|
||||
return 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(snapshotCodeJsonValue(array)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('propagates a throwing getter and releases its recursion guard', () => {
|
||||
const failure = new Error('getter failed')
|
||||
const source = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => { throw failure },
|
||||
})
|
||||
|
||||
expect(() => snapshotCodeJsonValue(source)).toThrow(failure)
|
||||
expect(snapshotCodeJsonValue({ after: true })).toEqual({ after: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('flat worker JSON wire', () => {
|
||||
it('round-trips every JSON root while preserving object keys and container order', () => {
|
||||
const withPrototypeKey = Object.create(null) as Record<string, unknown>
|
||||
withPrototypeKey.__proto__ = { safe: true }
|
||||
const values = [null, false, true, 1.25, 'text', [], {}, [1, { nested: [2] }], withPrototypeKey]
|
||||
for (const value of values) {
|
||||
const snapshot = snapshotCodeJsonValue(value)
|
||||
expect(snapshot).not.toBeUndefined()
|
||||
expect(decodeWorkerJson(encodeWorkerJson(snapshot!))).toEqual(snapshot)
|
||||
}
|
||||
const decoded = decodeWorkerJson(encodeWorkerJson(snapshotCodeJsonValue(withPrototypeKey)!)) as Record<string, unknown>
|
||||
expect(Object.hasOwn(decoded, '__proto__')).toBe(true)
|
||||
expect(decoded.__proto__).toEqual({ safe: true })
|
||||
})
|
||||
|
||||
it('round-trips deep values through a bounded-depth token array', () => {
|
||||
let value: unknown = 'leaf'
|
||||
for (let depth = 0; depth < 5_000; depth++) value = [value]
|
||||
const snapshot = snapshotCodeJsonValue(value)!
|
||||
const wire = encodeWorkerJson(snapshot)
|
||||
expect(wire).toHaveLength(5_001)
|
||||
|
||||
let cursor = decodeWorkerJson(wire)
|
||||
for (let depth = 0; depth < 5_000; depth++) {
|
||||
expect(Array.isArray(cursor)).toBe(true)
|
||||
cursor = Array.isArray(cursor) ? cursor[0] : undefined
|
||||
}
|
||||
expect(cursor).toBe('leaf')
|
||||
})
|
||||
|
||||
it('rejects malformed, incomplete, lossy, sparse, decorated, and throwing wire values', () => {
|
||||
const sparse = new Array(1)
|
||||
const compensatedSparse = new Array(1)
|
||||
Object.defineProperty(compensatedSparse, 'extra', { value: true })
|
||||
const decorated: unknown[] = [null]
|
||||
Object.defineProperty(decorated, 'extra', { value: true })
|
||||
const throwing: unknown[] = []
|
||||
Object.defineProperty(throwing, 0, { enumerable: true, get: () => { throw new Error('wire getter') } })
|
||||
const decoratedKeys: unknown[] = ['x']
|
||||
Object.defineProperty(decoratedKeys, 'extra', { value: true })
|
||||
const foreignMarker: Record<string, unknown> = { kind: 'array', length: 0 }
|
||||
Object.setPrototypeOf(foreignMarker, {})
|
||||
const hiddenMarker = Object.defineProperty({ kind: 'array', length: 0 }, 'hidden', { value: true })
|
||||
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
{},
|
||||
[],
|
||||
sparse,
|
||||
compensatedSparse,
|
||||
decorated,
|
||||
throwing,
|
||||
[undefined],
|
||||
[-0],
|
||||
[Number.NaN],
|
||||
[Number.POSITIVE_INFINITY],
|
||||
[1, 2],
|
||||
[[]],
|
||||
[foreignMarker],
|
||||
[hiddenMarker],
|
||||
[{ kind: 'unknown' }],
|
||||
[{ kind: 'array', bogus: 0 }],
|
||||
[{ kind: 'array' }],
|
||||
[{ kind: 'array', length: '1' }],
|
||||
[{ kind: 'array', length: -1 }],
|
||||
[{ kind: 'array', length: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[{ kind: 'array', length: 1 }],
|
||||
[{ kind: 'array', length: 2 }, { kind: 'array', length: 1 }, null],
|
||||
[{ kind: 'array', length: 0, extra: true }],
|
||||
[{ kind: 'object' }],
|
||||
[{ kind: 'object', keys: 'x' }],
|
||||
[{ kind: 'object', keys: decoratedKeys }],
|
||||
[{ kind: 'object', keys: [1] }],
|
||||
[{ kind: 'object', keys: ['x', 'x'] }, 1, 2],
|
||||
[{ kind: 'object', keys: ['x'] }],
|
||||
[{ kind: 'object', keys: [], extra: true }],
|
||||
]) {
|
||||
expect(decodeWorkerJson(value)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid values passed through a forged static type', () => {
|
||||
expect(() => encodeWorkerJson([undefined] as never)).toThrow(/sparse JSON array/)
|
||||
expect(() => encodeWorkerJson({ value: undefined } as never)).toThrow(/undefined JSON object property/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user