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)
})