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
+27 -15
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 {