fix(code-runtime-python): preserve post-merge hardening

This commit is contained in:
Tianyi Cui
2026-08-31 16:03:32 +08:00
parent 8e9d5467b0
commit 61b3e06e97
20 changed files with 749 additions and 213 deletions
@@ -5,10 +5,8 @@
* boundary: model code has bash-equivalent trust, contained by an empty environment, RLIMIT_CPU
* + RLIMIT_AS, wall-clock timeout, and SIGTERM→grace→SIGKILL on the process group.
*
* The package owns the versionless fd-3 wire protocol between the Node host and
* the CPython subprocess. The protocol's host-side codec and hostile-frame
* validators are re-exported so every consumer of the wire shares one
* vocabulary.
* The package also owns the versionless fd-3 wire protocol itself; its host-side codec and
* hostile-frame validators are re-exported so every consumer of the wire shares one vocabulary.
* @module @deepseek-ai/dsh-experimental-code-runtime-python
*/
@@ -17,12 +15,13 @@ import { accessSync, copyFileSync, constants as fsConstants, mkdtempSync, readFi
import { tmpdir } from 'node:os'
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { getHeapStatistics } from 'node:v8'
import type { Duplex } from 'node:stream'
import { Context } from 'cordis'
import z from 'schemastery'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingErrorClass, CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts'
import { checkDoneValue, encodeJsonPlain, hasUnsafeIntegerToken, logTruncationMarker, validateChildFrame } from './protocol.ts'
@@ -70,7 +69,9 @@ export interface Config {
* under RLIMIT_AS with several copies live at once, so this cap times the
* worst-case Unicode expansion must fit the address space left after the
* interpreter baseline (see `addressSpaceMb`) — a load-time rejection, not a
* runtime clamp.
* runtime clamp. Also bounded at load by the host's configured heap like
* `maxValueBytes` (see its JSDoc): the effective frame cap minus the frame
* envelope.
*/
maxLogBytes?: number
/**
@@ -78,7 +79,12 @@ export interface Config {
* the same way `maxLogBytes` is: the child builds and encodes a near-budget
* value under RLIMIT_AS with several copies live at once, so this cap times the
* worst-case Unicode expansion must fit the address space left after the
* interpreter baseline.
* interpreter baseline. Both budgets are ALSO bounded at load by the host's
* configured heap: the effective frame cap (the protocol cap, or a lower
* heap-derived ceiling when the host heap cannot safely parse a near-cap
* frame — see `hostFrameParseCeiling`) minus the frame envelope, so a budget
* whose honest frame could OOM the host's own JSON.parse is rejected up
* front.
*/
maxValueBytes?: number
/** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
@@ -309,6 +315,56 @@ const OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE = 12
*/
const INTERPRETER_BASELINE_BYTES = 64 * 1024 * 1024
/**
* Worst-case peak host-heap bytes the PARSE of one inbound fd-3 frame can
* transiently occupy, expressed as a multiple of the frame's raw bytes.
* `JSON.parse` of a wide container materializes the object's property storage
* and key strings on top of the raw text; the WORST shape is a dict of many
* SHORT UNIQUE keys, which forces V8's dictionary-mode property storage
* (~32-64 bytes per entry) plus one interned string per key (header + data)
* plus string-table growth: measured 6.4x for a 3,000,000-key frame (~31 MB
* raw) on a 1 GiB heap, trending up with key count (a flat unique-key array
* is ~4x, a repeated-key dict ~3x). On a constrained heap the parse also
* retains the raw frame string while the object builds, so the safety factor
* is 16x — ~2.5x over the measured worst shape, ~1.6x over the claimed
* GC-headroom bound. Used with the host's configured heap limit to derive the
* largest frame whose parse cannot OOM the host process. This bounds the
* HOST's parse; {@link OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE} bounds
* the CHILD's build and encode under RLIMIT_AS, a different resource. A fixed
* safety invariant, not a knob.
*/
const HOST_PARSE_WORST_CASE_MULTIPLE = 16
/**
* Fixed host-heap headroom reserved for the application itself (the dsh
* fiber, plugins, and this runtime's own state) before the frame-parse
* multiple claims the rest: the effective frame cap is derived from
* `heap_size_limit - HOST_PARSE_BASELINE_BYTES`, so a constrained host's
* parse ceiling never spends the application's working set. A fixed safety
* margin, not a knob.
*/
const HOST_PARSE_BASELINE_BYTES = 64 * 1024 * 1024
/**
* The largest inbound fd-3 frame the HOST can parse without risking a
* process-level OOM on its current heap: the configured heap limit (honoring
* `--max-old-space-size`) minus the application baseline, divided by the
* worst-case parse multiple, floored to the protocol frame cap. The
* raw-byte cap alone does not protect the heap — `JSON.parse` of a
* ≤64 MiB wide-object frame materializes several times that in property
* storage — so the effective cap is the smaller of the two. A default Node
* heap (~4 GiB) never binds; a constrained host (e.g.
* `--max-old-space-size=256` reports a ~300 MiB limit) lowers it to ~29 MiB,
* and the load gate rejects budgets that cannot cross it.
* @param heapLimit - the host's configured heap limit; the live
* `heap_size_limit` when omitted. A parameter so the derivation is unit
* testable against simulated heap sizes.
* @returns the effective frame parse cap in bytes.
*/
export function hostFrameParseCeiling(heapLimit: number = getHeapStatistics().heap_size_limit): number {
return Math.min(FRAME_PARSE_CAP_BYTES, Math.floor((heapLimit - HOST_PARSE_BASELINE_BYTES) / HOST_PARSE_WORST_CASE_MULTIPLE))
}
/**
* Interval between process-group liveness probes while settlement waits for an
* escalated SIGKILL to empty the group (see the `killing` branch in
@@ -731,6 +787,11 @@ export class PythonCodeRuntime extends CodeRuntime {
readonly isolation = 'process'
private readonly config: ResolvedConfig
// The frame cap this instance enforces: the protocol cap, or the host's
// heap-derived parse ceiling when a constrained heap makes the protocol cap
// unsafe to parse (see {@link hostFrameParseCeiling}). Computed per
// instance so the config gate and the inbound checks agree.
private readonly frameParseCapBytes = hostFrameParseCeiling()
private readonly live = new Set<LiveRun>()
private disposed = false
@@ -834,10 +895,12 @@ export class PythonCodeRuntime extends CodeRuntime {
// into `CodeRunResult.error.message` and never re-crosses a frame-bounded
// channel, so it is not part of the wire-width bound (see its JSDoc). The
// admissible cap is therefore `parse-cap - envelope`: the receive path
// rejects raw frames past FRAME_PARSE_CAP_BYTES before decoding (the run
// settles as a worker-exit; a hostile compact-wide-frame OOM guard), so a
// budget must not exceed what an honest child's frame can actually carry
// through that parser.
// rejects raw frames past the effective parse cap (`frameParseCapBytes` —
// the protocol cap, or the host's heap-derived ceiling when a constrained
// heap makes the protocol cap unsafe to parse; see hostFrameParseCeiling)
// before decoding (the run settles as a worker-exit; a hostile
// compact-wide-frame OOM guard), so a budget must not exceed what an
// honest child's frame can actually carry through that parser.
for (const key of ['maxLogBytes', 'maxValueBytes'] as const) {
// Require an integer: the child reads these budgets through `int(...)`,
// which silently floors a float, so `maxLogBytes: 3.5` would truncate at 3
@@ -847,9 +910,16 @@ export class PythonCodeRuntime extends CodeRuntime {
if (!Number.isInteger(this.config[key])) {
throw new Error(`dsh-code-runtime-python: config.${key} must be a positive integer (the child reads it as an int, so a float diverges from the host), got ${String(this.config[key])}`)
}
const limit = FRAME_PARSE_CAP_BYTES - FRAME_ENVELOPE_BYTES
const limit = this.frameParseCapBytes - FRAME_ENVELOPE_BYTES
if (this.config[key] > limit) {
throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the fd-3 frame PARSER, which rejects raw frames past ${FRAME_PARSE_CAP_BYTES} bytes before decoding to bound host memory — a larger budget would admit a config whose honest child frames the host then rejects as a worker-exit), got ${String(this.config[key])}`)
// Only a host whose heap is below the protocol cap reaches the
// heap-constrained note; the constrained-heap rejection is exercised
// by the subprocess load test, but subprocess runs are not
// coverage-instrumented, so the note's arm is not schedulable from the
// instrumented suite (whose heap never binds).
/* v8 ignore next -- the heap-constrained message arm needs a host heap below the protocol cap. */
const heapNote = this.frameParseCapBytes < FRAME_PARSE_CAP_BYTES ? ` — this host's heap limits the parse to ${this.frameParseCapBytes} bytes, so the protocol cap of ${FRAME_PARSE_CAP_BYTES} would be unsafe` : ''
throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the fd-3 frame PARSER, which rejects raw frames past ${this.frameParseCapBytes} bytes before decoding to bound host memory${heapNote} — a larger budget would admit a config whose honest child frames the host then rejects as a worker-exit), got ${String(this.config[key])}`)
}
// Reject a log budget too small to honor: the truncation marker alone
// must serialize within the budget, or a marker-only truncated run
@@ -1421,6 +1491,29 @@ export class PythonCodeRuntime extends CodeRuntime {
// fd 3 between finish() and close must not regrow the host buffer.
/* v8 ignore next -- post-settlement data needs the child to outrace close after we decided. */
if (settled) return
// Schedule ONE post-batch outstanding-call check per macrotask. The
// check must see the TRUE count — the live count is inflated by this
// batch's own frames (the finallys run on the microtask queue, which
// drains only when the macrotask ends), and a per-event snapshot is
// stale when flowing mode fires several 'data' events within one
// macrotask before any microtask drains. setImmediate runs after the
// current macrotask's microtasks, so the count is exact; the flag
// dedupes the check across the events of one macrotask. The threshold
// is STRICT: exactly MAX_PENDING_REPLIES outstanding calls are allowed,
// so a program that returns with calls it never awaited still
// completes (the done frame settles the run; the check no-ops on
// `settled`).
if (!postBatchCheckPending) {
postBatchCheckPending = true
setImmediate(() => {
postBatchCheckPending = false
/* v8 ignore next -- the done frame can settle the run between the schedule and this callback. */
if (settled) return
if (pendingCalls > MAX_PENDING_REPLIES) {
finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
}
})
}
pendingChunks.push(chunk)
pendingBytes += chunk.length
// Check the counter BEFORE the join, not the joined line afterwards:
@@ -1451,11 +1544,11 @@ export class PythonCodeRuntime extends CodeRuntime {
// of the wire bytes. When this chunk DOES carry a newline the buffer
// holds several frames, so the FIRST-FRAME check below (not this
// counter, which charges them all) decides.
if (pendingBytes > FRAME_PARSE_CAP_BYTES && !chunk.includes(0x0a)) {
if (pendingBytes > this.frameParseCapBytes && !chunk.includes(0x0a)) {
pendingChunks = []
sealedBlocks = []
pendingBytes = 0
finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${FRAME_PARSE_CAP_BYTES} bytes on fd 3` } })
finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${this.frameParseCapBytes} bytes on fd 3` } })
return
}
// Bound the FRAGMENT COUNT as well as the byte total, but only AFTER the
@@ -1510,11 +1603,11 @@ export class PythonCodeRuntime extends CodeRuntime {
}
firstFrameLen += c.length
}
if (sawNewline && firstFrameLen > FRAME_PARSE_CAP_BYTES) {
if (sawNewline && firstFrameLen > this.frameParseCapBytes) {
pendingChunks = []
sealedBlocks = []
pendingBytes = 0
finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${FRAME_PARSE_CAP_BYTES} bytes on fd 3` } })
finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${this.frameParseCapBytes} bytes on fd 3` } })
return
}
let buffered = Buffer.concat(sealedBlocks.length > 0 ? [...sealedBlocks, ...pendingChunks] : pendingChunks)
@@ -1699,6 +1792,19 @@ export class PythonCodeRuntime extends CodeRuntime {
admit(message.text)
return
case 'done': {
// The call-backlog cap must also hold when the child finishes in
// the SAME batch as its flood: the post-macrotask check no-ops once
// this done frame settles the run, so a done arriving right after
// more than MAX_PENDING_REPLIES call frames in one data event would
// otherwise complete successfully with the outstanding closures
// left behind (a single sub-64 KiB write can carry 1025 compact
// calls plus a done). The strict threshold lets exactly
// MAX_PENDING_REPLIES outstanding calls — a program that returned
// without awaiting its calls — complete normally.
if (pendingCalls > MAX_PENDING_REPLIES) {
finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
return
}
if (message.error) {
finish({ error: { kind: message.error.kind, message: capMessage(message.error.message, this.config.maxValueBytes) } })
return
@@ -1763,17 +1869,12 @@ export class PythonCodeRuntime extends CodeRuntime {
sendReply({ type: 'reply', id: message.id, ok: false, message: capMessage(`unknown binding ${preview}`, cap) })
return
}
// A binding that never settles (or resolves too slowly to keep up
// with the child's call rate) must not let the flood accumulate one
// async closure per frame until the wall clock: the reply cap only
// counts resolved calls, so it never trips for in-flight ones.
// Count the outstanding binding calls here, before dispatch, and
// release the slot in the body's finally — bounding in-flight
// closures to MAX_PENDING_REPLIES exactly like the reply backlog.
if (pendingCalls >= MAX_PENDING_REPLIES) {
finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
return
}
// Count the outstanding binding call before dispatch and release the
// slot in the async body's finally. The CAP CHECK runs in the data
// handler's post-macrotask pass (where the finallys have drained),
// not here: a per-frame check would see every frame of one event as
// in-flight and false-positive on a legitimate gather of more than
// MAX_PENDING_REPLIES instant calls.
pendingCalls += 1
void (async () => {
try {
@@ -1856,9 +1957,14 @@ export class PythonCodeRuntime extends CodeRuntime {
// RESOLVED calls — `pendingReplies` grows after the await — so a child
// flooding calls against a binding that never settles would accumulate
// one async closure per frame until the wall clock without tripping it.
// Counted here before dispatch and released in the body's finally, the
// in-flight closures are bounded to the same MAX_PENDING_REPLIES.
// Counted here before dispatch and released in the body's finally; the
// data handler schedules a post-macrotask check (see there) that settles
// the run as worker-exit when the true outstanding count passes
// MAX_PENDING_REPLIES.
let pendingCalls = 0
// Dedupes the post-batch outstanding-call check across the 'data' events
// of one macrotask (see the data handler).
let postBatchCheckPending = false
let draining = false
// Resolve when fd 3 can take another frame, OR when it is gone: a pipe
// destroyed under the drain (child exited, close-deadline teardown) never
@@ -291,6 +291,12 @@ export function logTruncationMarker(maxBytes: number): string {
* @returns the compact JSON encoding.
*/
export function encodeJsonPlain(value: unknown): string {
// The task stack holds every member of the currently open containers — O(width)
// — but the encoded OUTPUT is itself O(total bytes) and the stack holds only
// references, so the walk's auxiliary state is same-order as its result; the
// metering walks (checkDoneValue/hasNonLosslessNumber) are the ones that must
// stay O(depth), since they can reject a wide payload without producing any
// output. Exempted by that same-order argument.
type Task = { text: string } | { value: unknown }
const chunks: string[] = []
const tasks: Task[] = [{ value }]
@@ -430,9 +436,36 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by
// classify differently (non-lossless vs over-budget), and the JSDoc promises
// an over-budget value is rejected as over-budget regardless.
let nonLossless = false
const stack: unknown[] = [value]
while (stack.length > 0) {
const current = stack.pop()
// One cursor per OPEN container (a values iterator for the root and arrays,
// an entries iterator for objects), mirroring hasNonLosslessNumber and the
// child's _check_done_value: a wide completion near the frame cap would
// otherwise copy every member's reference onto an explicit work stack —
// O(width) — OOMing the host after the parse already succeeded. The byte
// budget still bounds the walk: each member is metered as its cursor yields
// it, and the width lower-bound checks below bail an over-budget container
// before the cursor descends.
const cursors: Cursor[] = [{ kind: 'values', iter: [value].values() }]
while (cursors.length > 0) {
// The loop condition guarantees a top cursor.
const cursor = cursors.at(-1) as Cursor
const step = cursor.iter.next()
if (step.done === true) {
cursors.pop()
continue
}
let current: unknown
if (cursor.kind === 'entries') {
// Meter the key's escaped form without allocating it (same reason as the
// string branch), then add the colon separator, before the value's own
// bytes are counted.
const [key, member] = step.value as readonly [string, unknown]
const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes)
if (keyBytes === undefined) return { ok: false, reason: 'over-budget' }
bytes += keyBytes + 1
current = member
} else {
current = step.value
}
if (typeof current === 'number') {
// Flag a non-lossless number but keep counting its encoded bytes: a value
// that is BOTH non-lossless and over-budget must classify as over-budget
@@ -451,13 +484,13 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by
bytes += stringBytes
} else if (Array.isArray(current)) {
// Brackets plus one comma per gap; elements add themselves. Reject
// BEFORE enqueuing children: every element serializes to at least one
// BEFORE the cursor descends: every element serializes to at least one
// byte, so a forged flat array far above the budget fails here without
// pushing its elements onto the host stack. (The array itself is already
// materialized by the upstream parse; this only bounds the extra stack.)
// the cursor yielding any of them. (The array itself is already
// materialized by the upstream parse; this only bounds the extra walk.)
bytes += 2 + (current.length > 1 ? current.length - 1 : 0)
if (bytes + current.length > maxBytes) return { ok: false, reason: 'over-budget' }
for (const item of current) stack.push(item)
cursors.push({ kind: 'values', iter: (current as unknown[]).values() })
} else if (typeof current === 'object' && current !== null) {
const record = current as Record<string, unknown>
// Count own keys with for...in + hasOwn. This IS O(keys) — JS has no lazy
@@ -469,15 +502,7 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by
for (const key in record) if (Object.hasOwn(record, key)) count += 1
bytes += 2 + (count > 1 ? count - 1 : 0)
if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' }
for (const key in record) {
if (!Object.hasOwn(record, key)) continue
// Meter the key's escaped form without allocating it (same reason as the
// string branch), then add the colon separator. `+ 1` for the `:`.
const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes)
if (keyBytes === undefined) return { ok: false, reason: 'over-budget' }
bytes += keyBytes + 1
stack.push(record[key])
}
cursors.push({ kind: 'entries', iter: ownEntries(record) })
} else {
bytes += Buffer.byteLength(scalarJson(current), 'utf8')
}
@@ -538,6 +563,31 @@ export function hasUnsafeIntegerToken(line: string): boolean {
return false
}
/**
* One open container in checkDoneValue's cursor walk: a values iterator (the
* root and arrays) or an entries iterator (objects, so each key's escaped
* bytes can be metered when the entry is reached). A cursor bounds the walk's
* auxiliary state to O(depth), not O(width).
*/
type Cursor =
| { kind: 'values'; iter: Iterator<unknown> }
| { kind: 'entries'; iter: Iterator<readonly [string, unknown]> }
/**
* Lazily yield one plain object's own enumerable [key, value] entries. The
* key escapes are metered when {@link checkDoneValue}'s cursor walk reaches
* each entry, so a wide object never materializes a member list: each entry
* is produced straight off the already-parsed record, and the escaped key
* bytes are counted without building the escaped string.
* @param record - a JSON-parse-produced object.
* @yields each own enumerable [key, value] pair, in key order.
*/
function* ownEntries(record: Record<string, unknown>): Generator<readonly [string, unknown]> {
for (const key in record) {
if (Object.hasOwn(record, key)) yield [key, record[key]]
}
}
/**
* Lazily yield one plain object's own enumerable property values. A generator
* (not `Object.values`/`Object.entries`) because {@link hasNonLosslessNumber}