fix(code-runtime): validate arguments before worker dispatch

This commit is contained in:
Tianyi Cui
2026-07-21 18:19:07 +08:00
parent 9e01fb060e
commit 2623ddbee4
8 changed files with 194 additions and 28 deletions
@@ -190,7 +190,7 @@ describe('makeNamespaces', () => {
await expect(tools['toString']?.({})).resolves.toBe('toString-ok')
})
it('rejects a non-cloneable argument without leaking the pending entry', async () => {
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
@@ -203,8 +203,8 @@ describe('makeNamespaces', () => {
}
const pending = new Map<number, PendingCall>()
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
const first = await rejectionOf(tools.x?.(() => 1) ?? Promise.resolve())
const second = await rejectionOf(tools.x?.(() => 1) ?? Promise.resolve())
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)
@@ -214,6 +214,32 @@ describe('makeNamespaces', () => {
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: [{ global: 'tools', names: ['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'
@@ -226,9 +252,14 @@ describe('makeNamespaces', () => {
expect(denied).toBeInstanceOf(Error)
expect(denied).not.toBeInstanceOf(ToolCallError)
const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve())
expect(invalid).toBeInstanceOf(Error)
expect(invalid).not.toBeInstanceOf(ToolCallError)
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?.(() => 1) ?? Promise.resolve())
const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve())
expect(cloneFailure).toBeInstanceOf(Error)
expect(cloneFailure).not.toBeInstanceOf(ToolCallError)
})
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { 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('')
})
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)
})
})
@@ -218,6 +218,19 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
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: [] })
@@ -304,7 +317,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
})
expect(result.error?.kind).toBe('output-limit')
expect(result.logs).toContain('a'.repeat(20))
expect(result.logs).not.toContain('b'.repeat(100))
expect(result.logs[1]?.length).toBeGreaterThan(0)
expect('b'.repeat(100).startsWith(result.logs[1] ?? '')).toBe(true)
}, 15_000)
})
@@ -409,6 +423,32 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
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('contains throwing getters while snapshotting binding resolutions', async () => {
const { runtime } = await setup()
const result = await runtime.run({
@@ -60,12 +60,21 @@ describe('snapshotCodeJsonValue', () => {
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 })
for (const value of [
new ExoticObject(),
new Map([['value', 1]]),
new ExoticArray(1),
new Array(1),
decorated,
compensatedSparse,
symbolDecorated,
cyclic,
[undefined],
{ value: undefined },