fix: validate and re-cap all inbound worker-port traffic (Codex round 1)
The host's message listener trusted the compile-time WorkerToHost shape on traffic from a peer that runs model code: postMessage(null) threw in the listener and crashed the host process; forged log/done messages bypassed maxLogBytes/maxValueBytes (the worker-side LogBuffer and prepareValue cap only honest flows); and the error-reply renegotiation re-echoed a forged non-cloneable call id, throwing outside any catch. Every inbound message now passes a runtime shape gate that validates and REBUILDS it field by field (junk drops without a throw; call ids must be numbers, so replies are always clone-plain; forged extra fields never ride along). One host-side ledger bounds everything landing in logs — honest port entries, forged ones, and stray pipe bytes — at the single documented maxLogBytes, with the shared in-band truncation marker emitted host-side when the ledger trips first; the completion value is re-capped host-side through the same prepareValue (with exactly the truncation suffix as slack so honest worker-capped values pass unchanged), and done error text is bounded. Also folds the stray-capture budget into that shared ledger (round-1 finding B: it was a second maxLogBytes on top of the documented shared cap).
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { logTruncationMarker } from './protocol.ts'
|
||||
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
|
||||
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
|
||||
@@ -62,7 +63,7 @@ export class LogBuffer {
|
||||
const cost = Buffer.byteLength(entry.text, 'utf8')
|
||||
if (cost > this.remaining) {
|
||||
this.truncated = true
|
||||
this.sink({ source: 'stderr', text: `[dsh-code-runtime-worker] log capture truncated at ${this.maxBytes} bytes` })
|
||||
this.sink({ source: 'stderr', text: logTruncationMarker(this.maxBytes) })
|
||||
return
|
||||
}
|
||||
this.remaining -= cost
|
||||
|
||||
@@ -18,6 +18,8 @@ import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingFunction, CodeLogEntry, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { prepareValue } from './bootstrap.ts'
|
||||
import { logTruncationMarker } from './protocol.ts'
|
||||
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
|
||||
export type { BootstrapPort, PatchableStream } from './bootstrap.ts'
|
||||
@@ -108,6 +110,64 @@ function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** The log sources / console levels the seam vocabulary admits, as runtime sets for inbound-message validation. */
|
||||
const LOG_SOURCES = new Set<string>(['console', 'stdout', 'stderr'])
|
||||
const LOG_LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug'])
|
||||
|
||||
/**
|
||||
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
|
||||
* can post anything — `null`, primitives, objects with poisoned fields — so
|
||||
* the compile-time `WorkerToHost` type means nothing here: everything is
|
||||
* re-validated and REBUILT field by field (a forged extra field never rides
|
||||
* along; a non-number call id can never be echoed into a reply). Junk returns
|
||||
* `undefined` and is dropped — a throw in the host's `message` listener would
|
||||
* crash the host process.
|
||||
*/
|
||||
function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined
|
||||
const m = raw as Record<string, unknown>
|
||||
switch (m.type) {
|
||||
case 'call': {
|
||||
if (typeof m.id !== 'number' || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
|
||||
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args }
|
||||
}
|
||||
case 'log': {
|
||||
const entry = m.entry
|
||||
if (typeof entry !== 'object' || entry === null) return undefined
|
||||
const e = entry as Record<string, unknown>
|
||||
if (typeof e.text !== 'string') return undefined
|
||||
if (typeof e.source !== 'string' || !LOG_SOURCES.has(e.source)) return undefined
|
||||
if (e.level !== undefined && (typeof e.level !== 'string' || !LOG_LEVELS.has(e.level))) return undefined
|
||||
return {
|
||||
type: 'log',
|
||||
entry: {
|
||||
source: e.source as CodeLogEntry['source'],
|
||||
...e.level !== undefined ? { level: e.level as Exclude<CodeLogEntry['level'], undefined> } : {},
|
||||
text: e.text,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'done': {
|
||||
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
|
||||
const error = m.error
|
||||
if (typeof error !== 'object' || error === null) return undefined
|
||||
const message = (error as Record<string, unknown>).message
|
||||
if (typeof message !== 'string') return undefined
|
||||
return { type: 'done', ...m.value !== undefined ? { value: m.value } : {}, error: { message } }
|
||||
}
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Headroom the host's value re-cap grants over `maxValueBytes`: exactly the
|
||||
* truncation suffix {@link prepareValue} appends, so a value the WORKER
|
||||
* already capped passes through unchanged instead of being marked twice.
|
||||
* (A multibyte rendering the worker sliced by characters can still exceed
|
||||
* this and pick up a second marker — bounded and harmless.)
|
||||
*/
|
||||
const VALUE_RENDER_SLACK = Buffer.byteLength('… [truncated]', 'utf8')
|
||||
|
||||
/**
|
||||
* The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
|
||||
* the `codeRuntime` service; every cap comes from validated config. See the
|
||||
@@ -234,13 +294,32 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
const answered = new Set<number>()
|
||||
const logs: CodeLogEntry[] = []
|
||||
const strayLogs: CodeLogEntry[] = []
|
||||
let strayBudget = this.config.maxLogBytes
|
||||
|
||||
// ONE host-side ledger for everything that lands in `logs`/`strayLogs`,
|
||||
// whatever the path: honest port entries, FORGED port entries (model
|
||||
// code posting `log` messages directly, bypassing the worker-side
|
||||
// LogBuffer), and stray pipe bytes. On the first overflow it emits the
|
||||
// same in-band marker the worker's LogBuffer would and drops the rest,
|
||||
// so the documented cap is one shared `maxLogBytes` however it is hit.
|
||||
let logBudget = this.config.maxLogBytes
|
||||
let logsTruncated = false
|
||||
const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => {
|
||||
if (logsTruncated) return
|
||||
const cost = Buffer.byteLength(entry.text, 'utf8')
|
||||
if (cost > logBudget) {
|
||||
logsTruncated = true
|
||||
sink.push({ source: 'stderr', text: logTruncationMarker(this.config.maxLogBytes) })
|
||||
return
|
||||
}
|
||||
logBudget -= cost
|
||||
sink.push(entry)
|
||||
}
|
||||
|
||||
// No settled guard: `finish` snapshots the arrays when it resolves, so
|
||||
// a chunk flushing after settlement mutates only the discarded buffers,
|
||||
// and the ledger bounds that growth until the pipes close.
|
||||
const captureStray = (source: 'stdout' | 'stderr') => (chunk: Buffer) => {
|
||||
if (settled || strayBudget <= 0) return
|
||||
const text = chunk.toString('utf8').slice(0, strayBudget)
|
||||
strayBudget -= Buffer.byteLength(text, 'utf8')
|
||||
strayLogs.push({ source, text })
|
||||
admit({ source, text: chunk.toString('utf8') }, strayLogs)
|
||||
}
|
||||
worker.stdout.on('data', captureStray('stdout'))
|
||||
worker.stderr.on('data', captureStray('stderr'))
|
||||
@@ -267,9 +346,14 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
|
||||
const onDone = (message: WorkerToHost): void => {
|
||||
if (message.type !== 'done') return
|
||||
// Re-cap the completion value HOST-side: the honest path already
|
||||
// capped it in the worker (prepareValue there), but a forged done
|
||||
// message bypasses the bootstrap entirely — without this, model code
|
||||
// could flood the host past maxValueBytes. Honest values pass
|
||||
// unchanged (see VALUE_RENDER_SLACK); the error text is bounded too.
|
||||
finish({
|
||||
...message.value !== undefined ? { value: message.value } : {},
|
||||
...message.error ? { error: { kind: 'exception' as const, message: message.error.message } } : {},
|
||||
...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK),
|
||||
...message.error ? { error: { kind: 'exception' as const, message: message.error.message.slice(0, this.config.maxValueBytes) } } : {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -308,8 +392,12 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
})()
|
||||
}
|
||||
|
||||
worker.on('message', (message: WorkerToHost) => {
|
||||
if (message.type === 'log' && !settled) logs.push(message.entry)
|
||||
worker.on('message', (raw: unknown) => {
|
||||
// Parse before touching: the peer can post ANY shape, and a throw in
|
||||
// this listener would crash the host process. Junk drops silently.
|
||||
const message = parseWorkerMessage(raw)
|
||||
if (!message) return
|
||||
if (message.type === 'log' && !settled) admit(message.entry, logs)
|
||||
onCall(message)
|
||||
onDone(message)
|
||||
})
|
||||
|
||||
@@ -63,3 +63,16 @@ export type WorkerToHost = CallMessage | LogMessage | DoneMessage
|
||||
export type ReplyMessage =
|
||||
| { type: 'reply'; id: number; ok: true; value: unknown }
|
||||
| { type: 'reply'; id: number; ok: false; message: string }
|
||||
|
||||
/**
|
||||
* The in-band marker entry text announcing that log capture stopped at the
|
||||
* byte budget. Shared wire vocabulary: the worker's LogBuffer emits it when
|
||||
* ITS budget exhausts, and the host emits the identical text when its own
|
||||
* ledger drops an entry first (forged port traffic, stray pipe bytes) — so
|
||||
* a truncated run reads the same however the cap was hit.
|
||||
* @param maxBytes - the configured `maxLogBytes` the marker names.
|
||||
* @returns the marker line.
|
||||
*/
|
||||
export function logTruncationMarker(maxBytes: number): string {
|
||||
return `[dsh-code-runtime-worker] log capture truncated at ${maxBytes} bytes`
|
||||
}
|
||||
Reference in New Issue
Block a user