diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index f59f3e8d9a..0fa66dbd71 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -269,7 +269,7 @@ export class WorkerCodeRuntime extends CodeRuntime { if (this.disposed) throw new Error('dsh-code-runtime-worker: run() after disposal') const bindings = this.validateBindings(request) if (request.signal?.aborted) { - return { logs: [], error: { kind: 'abort', message: String(request.signal.reason) } } + return this.failureBeforeWorker({ kind: 'abort', message: String(request.signal.reason) }) } let code: string @@ -280,12 +280,17 @@ export class WorkerCodeRuntime extends CodeRuntime { // A program that does not survive the type-strip (syntax error, // non-erasable syntax like `enum`) is a program failure, reported the // same way a thrown exception would be — and no worker ever spawns. - return { logs: [], error: { kind: 'exception', message: messageOf(error) } } + return this.failureBeforeWorker({ kind: 'exception', message: messageOf(error) }) } return await this.execute(request, code, bindings) } + /** Apply the outer-output ledger to failures that occur before a worker owns one. */ + private failureBeforeWorker(error: CodeRunFailure): CodeRunResult { + return new OutputLedger(this.config.maxOutputBytes).failure([], error) + } + /** Reject (seam misuse) malformed binding namespaces: non-identifier or reserved globals, duplicates, and the `console` collision. */ private validateBindings(request: CodeRunRequest): Map> { const bindings = new Map>() @@ -405,9 +410,19 @@ export class WorkerCodeRuntime extends CodeRuntime { reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` }) return } + let args: CodeJsonValue | undefined + try { + args = snapshotJsonValue(message.args) as CodeJsonValue | undefined + } catch { + args = undefined + } + if (args === undefined) { + reply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' }) + return + } void (async () => { try { - const resolved = await fn(message.args) + const resolved = await fn(args) let value: CodeJsonValue | undefined try { value = snapshotJsonValue(resolved) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index 5615062286..8bcee8389b 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -171,6 +171,19 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { 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() @@ -449,6 +462,41 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => { })) }) + 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({