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
@@ -188,6 +188,12 @@ export class ToolCallError extends Error {
}
}
/** Create the namespace-specific rejection for one lossy binding argument. */
function bindingArgumentFailure(global: string, name: string): Error {
const message = 'binding arguments must be lossless JSON'
return global === 'tools' ? new ToolCallError(name, message) : new Error(message)
}
/**
* Route host replies into the pending-call map: each reply settles its call
* at most once, and a reply for an unknown id (stray, or a duplicate answer
@@ -211,7 +217,8 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
* Build the binding namespace objects the program sees: one null-prototype global per
* namespace, each declared name an own enumerable async function that bridges over the port
* (`__proto__`/`constructor`/`toString` are ordinary keys, never prototype collisions).
* Non-cloneable arguments and host failure replies reject only the corresponding call.
* Lossy arguments reject before posting; clone failures and host failure
* replies reject only the corresponding call.
*
* @param data - the boot payload's namespace declarations (globals + names).
* @param port - the port binding calls are posted to.
@@ -230,22 +237,31 @@ export function makeNamespaces(
for (const name of names) {
Object.defineProperty(namespace, name, {
enumerable: true,
value: (args: unknown): Promise<unknown> => new Promise((resolve, reject) => {
const id = nextId.value++
pending.set(id, {
resolve,
reject: (error) => {
reject(global === 'tools' ? new ToolCallError(name, error.message) : error)
},
})
value: (args: unknown): Promise<unknown> => {
let detached: unknown
try {
port.postMessage({ type: 'call', id, global, name, args })
} catch (error: unknown) {
pending.delete(id)
const message = `binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`
reject(global === 'tools' ? new ToolCallError(name, message) : new Error(message))
detached = snapshotCodeJsonValue(args)
} catch {
detached = undefined
}
}),
if (detached === undefined) return Promise.reject(bindingArgumentFailure(global, name))
return new Promise((resolve, reject) => {
const id = nextId.value++
pending.set(id, {
resolve,
reject: (error) => {
reject(global === 'tools' ? new ToolCallError(name, error.message) : error)
},
})
try {
port.postMessage({ type: 'call', id, global, name, args: detached })
} catch (error: unknown) {
pending.delete(id)
const message = `binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`
reject(global === 'tools' ? new ToolCallError(name, message) : new Error(message))
}
})
},
})
}
return namespace
@@ -15,6 +15,7 @@ import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
import { truncateJsonStringBytes } from './output-json.ts'
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
export interface Config {
@@ -174,16 +175,29 @@ class OutputLedger {
return { logs, error }
}
/** Build the explicit output-limit failure while retaining the fitting log prefix. */
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
limit(logs: string[]): CodeRunResult {
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
let retainedBytes = this.bytes
const messageBytes = Buffer.byteLength(JSON.stringify(fullMessage), 'utf8')
while (logs.length > 0 && retainedBytes + messageBytes > this.maxBytes) {
const removed = logs.pop()
const retained = [...logs]
let retainedBytes = jsonBytes(retained)
const logBudget = this.maxBytes - messageBytes
while (retained.length > 0 && retainedBytes > logBudget) {
const removed = retained.pop()
/* v8 ignore next -- the while guard proves pop cannot return undefined. */
if (removed === undefined) throw new Error('output ledger lost its final log entry')
retainedBytes -= Buffer.byteLength(JSON.stringify(removed), 'utf8') + (logs.length > 0 ? 1 : 0)
const separatorBytes = retained.length > 0 ? 1 : 0
retainedBytes -= Buffer.byteLength(JSON.stringify(removed), 'utf8') + separatorBytes
const prefix = truncateJsonStringBytes(removed, logBudget - retainedBytes - separatorBytes)
if (prefix.length > 0) {
retained.push(prefix)
retainedBytes += Buffer.byteLength(JSON.stringify(prefix), 'utf8') + separatorBytes
break
}
}
if (logBudget < 2) {
retained.length = 0
retainedBytes = 2
}
const availableMessageBytes = this.maxBytes - retainedBytes
// This fixed diagnostic is ASCII with no JSON escapes, so two bytes are
@@ -191,7 +205,7 @@ class OutputLedger {
const message = messageBytes <= availableMessageBytes
? fullMessage
: fullMessage.slice(0, availableMessageBytes - 2)
return { logs, error: { kind: 'output-limit', message } }
return { logs: retained, error: { kind: 'output-limit', message } }
}
}
@@ -326,7 +340,8 @@ export class WorkerCodeRuntime extends CodeRuntime {
// a chunk flushing after settlement mutates only the discarded buffers,
// and the ledger bounds that growth until the pipes close.
const captureStray = (chunk: Buffer): void => {
if (!settled && !output.admit(chunk.toString('utf8'), strayLogs)) finish(output.limit([...logs, ...strayLogs]))
const text = chunk.toString('utf8')
if (!settled && !output.admit(text, strayLogs)) finish(output.limit([...logs, ...strayLogs, text]))
}
worker.stdout.on('data', captureStray)
worker.stderr.on('data', captureStray)
@@ -416,7 +431,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
const message = parseWorkerMessage(raw)
if (!message) return
if (message.type === 'log' && !settled && !output.admit(message.text, logs)) {
finish(output.limit([...logs, ...strayLogs]))
finish(output.limit([...logs, ...strayLogs, message.text]))
return
}
if (message.type === 'output-limit' && !settled) {
@@ -0,0 +1,36 @@
/** JSON string-prefix accounting for the outer-output ledger. @module @deepseek-ai/dsh-code-runtime-worker/output-json */
/** Control characters with a two-byte short JSON escape instead of `\u00XX`. */
const SHORT_ESCAPE_CODES = new Set([0x08, 0x09, 0x0a, 0x0c, 0x0d])
/** Serialized bytes contributed by one complete Unicode code point inside JSON quotes. */
function serializedCharacterBytes(character: string): number {
if (character.length === 2) return 4
if (character === '"' || character === '\\') return 2
const code = character.charCodeAt(0)
if (code >= 0xd800 && code <= 0xdfff) return 6
if (code < 0x20) return SHORT_ESCAPE_CODES.has(code) ? 2 : 6
return Buffer.byteLength(character, 'utf8')
}
/**
* Return the longest code-point-aligned prefix whose JSON string encoding,
* including its surrounding quotes, fits `maxBytes`.
*
* @param text - the candidate string.
* @param maxBytes - serialized JSON-string bytes available.
* @returns the fitting prefix, or an empty string when even useful content cannot fit.
*/
export function truncateJsonStringBytes(text: string, maxBytes: number): string {
if (maxBytes < 2) return ''
if (Buffer.byteLength(JSON.stringify(text), 'utf8') <= maxBytes) return text
let bytes = 2
let end = 0
for (const character of text) {
const cost = serializedCharacterBytes(character)
if (bytes + cost > maxBytes) break
bytes += cost
end += character.length
}
return text.slice(0, end)
}
@@ -33,6 +33,7 @@ export function snapshotCodeJsonValue(value: unknown): CodeJsonValue | undefined
if (Array.isArray(candidate)) {
if (Object.getPrototypeOf(candidate) !== Array.prototype) return undefined
if (Reflect.ownKeys(candidate).length !== candidate.length + 1) return undefined
return within(candidate, () => {
const result: CodeJsonValue[] = []
for (let index = 0; index < candidate.length; index++) {