fix: pre-dispatch rejection of unloggable args, mutation-proof event copies, proto-safe bindings (Codex round 1)

Three findings from the PR-4 convergence round:

(A) A root-undefined binding argument passed normalization untouched, so
the sub-call DISPATCHED and only then failed the tool/code-dispatch append
(Session.append rejects undefined event data) — a sub-call executed with
no log record, violating the nothing-executes-unlogged contract. And the
tool received the SAME object later handed to the append, so a tool
mutating its args desynced the logged record from what was dispatched (or
re-poisoned the append). jsonNormalizeArgs now rejects undefined up front
with a model-correctable message and returns TWO independent parses of the
canonical JSON text: the tool gets one, the event logs the sibling —
identical by construction, mutation-proof.

(B) The bridge built its bindings record with plain-object assignment, so
a registered tool named __proto__ hit the prototype setter and silently
vanished (the runtime host resolves binding names as own properties). The
record is now null-prototype with defineProperty, mirroring the
worker-side namespace build.

(B) The header-pin sanity assertions ran only inside NON-pinning
scenarios, so a class consisting solely of its pinning scenario (the two
Code Mode classes) would accept a re-recorded pin carrying several headers
or a header-delta. A fixtures meta-test now asserts every pinning fixture
directly.
This commit is contained in:
Tianyi Cui
2026-07-08 13:39:51 +08:00
parent 2cb10cbc63
commit 84088300bc
3 changed files with 92 additions and 21 deletions

View File

@@ -87,17 +87,21 @@ function summarize(text: string): string {
}
/**
* JSON-normalize one binding call's argument: a `JSON.parse(JSON.stringify(…))`
* round-trip, so the value dispatched to the tool and the value logged on the
* `tool/code-dispatch` event are the same JSON value by construction (the
* runtime's structured-clone boundary is wider than JSON; the session log
* accepts only JSON). A value that does not survive (`BigInt`, a circular
* structure, a bare function) rejects that one call with a model-correctable
* error. `undefined` passes through — the tool's own schema validation
* rejects it with its usual "must be an object" feedback.
* JSON-normalize one binding call's argument into TWO independent parses of
* the same canonical text: `dispatched` goes to the tool, `logged` to the
* `tool/code-dispatch` event — identical by construction (the runtime's
* structured-clone boundary is wider than JSON; the session log accepts only
* JSON), and separate objects, so a tool mutating its args can neither
* desync the log from what was dispatched nor re-poison the append. A value
* that does not survive the round-trip (`undefined` — the log rejects it as
* event data — `BigInt`, a circular structure, a bare function) rejects that
* one call BEFORE dispatch with a model-correctable error: nothing ever
* executes unlogged.
*/
function jsonNormalizeArgs(value: unknown): unknown {
if (value === undefined) return undefined
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
if (value === undefined) {
throw new Error('tool arguments must be JSON-serializable (call the tool with an arguments object, e.g. `{}`)')
}
let text: string | undefined
try {
text = JSON.stringify(value)
@@ -108,7 +112,7 @@ function jsonNormalizeArgs(value: unknown): unknown {
// root really yields `undefined` at runtime — the guard is live.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (text === undefined) throw new Error('tool arguments must be JSON-serializable (got a value JSON cannot represent)')
return JSON.parse(text) as unknown
return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown }
}
/** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */
@@ -198,7 +202,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
const result = await registry.execute({
callId: subCallId,
name,
arguments: normalized,
arguments: normalized.dispatched,
...exec.agent ? { agent: exec.agent } : {},
signal: runController.signal,
})
@@ -212,7 +216,10 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
parentCallId: exec.callId,
subCallId,
name,
arguments: normalized,
// The SIBLING parse of the dispatched value: byte-identical JSON,
// but a separate object — a tool mutating its args cannot desync
// this record from what it actually received.
arguments: normalized.logged,
isError: result.isError,
resultSummary: summarize(text),
})
@@ -231,10 +238,15 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
return outcome.text
}
const functions: Record<string, CodeBindingFunction> = {}
// Null-prototype + defineProperty, mirroring the worker-side namespace
// build: a registered tool named `__proto__` must become an ordinary
// own key (a plain-object assignment would hit the prototype setter,
// silently dropping the binding), and the runtime host resolves
// binding names as own properties only.
const functions: Record<string, CodeBindingFunction> = Object.create(null) as Record<string, CodeBindingFunction>
for (const schema of registry.schemas()) {
if (schema.name === RUN_CODE_NAME) continue
functions[schema.name] = binding(schema.name)
Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(schema.name) })
}
try {

View File

@@ -431,17 +431,18 @@ describe('the run_code dispatch bridge', () => {
expect(dispatch.resultSummary.endsWith('…')).toBe(true)
})
it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments', async () => {
it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
registerEcho(ctx)
const calls = registerEcho(ctx)
const { agent, events } = fakeAgent()
runtime.behavior = async (request) => {
const echo = request.bindings[0]!.functions.echo!
const catchMessage = (promise: Promise<unknown>) => promise.then(() => 'resolved', (error: unknown) => error instanceof Error ? error.message : String(error))
return {
logs: [],
value: [
// undefined passes normalization untouched; the tool's own schema
// validation rejects it with its usual feedback.
// Root undefined must reject up front: the event log rejects it as
// data, and nothing may execute unlogged.
await catchMessage(echo(undefined)),
// A toJSON that throws a NON-Error propagates out of JSON.stringify.
await catchMessage(echo({ toJSON() { throw 'raw-throw' } })),
@@ -450,11 +451,55 @@ describe('the run_code dispatch bridge', () => {
].join(' | '),
}
}
const result = await runCode(ctx, 'program')
const result = await runCode(ctx, 'program', { agent })
const text = (result.content[0] as { text: string }).text
expect(text).toContain('must be an object')
expect(text).toContain('call the tool with an arguments object')
expect(text).toContain('JSON-serializable: raw-throw')
expect(text).toContain('a value JSON cannot represent')
// None of the three dispatched, none logged.
expect(calls).toEqual([])
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
})
it('logs the value the tool RECEIVED even when the tool mutates its arguments', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
ctx.tools.register(defineTool({
name: 'mutator',
description: 'Mutates its own args object.',
parameters: { list: { type: 'array', required: true } },
execute(args) {
args.list.push('injected-by-tool')
return Promise.resolve([{ type: 'text' as const, text: 'mutated' }])
},
}))
runtime.behavior = async (request) => {
await request.bindings[0]!.functions.mutator!({ list: ['original'] })
return { logs: [] }
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(false)
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
expect(dispatch.arguments).toEqual({ list: ['original'] })
})
it('exposes a tool named __proto__ as an ordinary own binding', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineTool({
name: '__proto__',
description: 'A prototype-colliding tool name.',
parameters: {},
execute() { return Promise.resolve([{ type: 'text' as const, text: 'proto-tool-ok' }]) },
}))
runtime.behavior = async (request) => {
const functions = request.bindings[0]!.functions
expect(Object.getPrototypeOf(functions)).toBeNull()
const value = await functions['__proto__']!({})
return { logs: [], value }
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'proto-tool-ok' })
})
it('renders a non-string completion value inspect-style', async () => {