The dsh-tools half of the Code Mode RFC (its fourth, final change): the registry gains its first config — mode: native | code | both — and OWNS how its tools reach the model. 'code' contributes exactly one wire tool, run_code, plus a lazy tools:sdk prompt section declaring every other tool as a generated TypeScript API (jsonSchemaToTs: total over the defineTool subset, unknown degradation, lexicographic byte-identical rendering); 'both' ships both representations; 'native' is byte-for-byte the old behavior. Non-native modes fail every assembly loudly without a typescript-language ctx.codeRuntime. run_code's dispatch bridge: JSON-normalizes each binding argument before dispatch (what dispatches is what the tool/code-dispatch event logs — the append can never fail on payload shape; BigInt/circulars reject that one call), serializes all program tool calls through a per-run queue (even Promise.all — no concurrency-safety metadata yet), routes every sub-call through tools/pre-execute → tools/post-execute (a deny rejects the program-side promise), drops sub-call additionalContext (no safe outlet mid-run; pinned), owns a run-scoped abort that follows the outer signal in and fires on settlement (in-flight sub-dispatch aborted, queued abandoned, queue drained before returning), and converts a failed run into CodeRunFailedError → a structured isError carrying kind + captured logs. tool/code-dispatch joins SessionEventMap by declaration merging (log-only; deriveMessages ignores it). The composed surface: the tools config forwards through agent-core and both app packages; examples/code-agent + demo:code run the worker runtime under mode code (keyless boot smoke + a with-key e2e proving the collapsed [run_code] header, the dispatch events, and the file the program wrote); two new snapshot scenarios (code-mode-turn, both-mode-turn) record the SDK section, collapsed header, dispatch events, and result card — each its own header-pinning class (the harness gains per-scenario config overlays and per-class pins). Catalogs, graphs, cookbook, hooks-bridge notes, and the RFC (moved to implemented/, restructured to decision-era headings) updated in the same change.
4.1 KiB
@deepseek-ai/dsh-code-runtime-worker
Worker-thread implementation of the @deepseek-ai/dsh-code-runtime seam: WorkerCodeRuntime runs each program in ONE fresh Node worker_threads.Worker — TypeScript in, type-stripped host-side, bindings bridged over the message port, { value, logs, error? } out. Containment, not a security boundary: trust posture is bash-equivalent by design (the Code Mode RFC § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination.
Config
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
config:
computeMs: 60000 # busy-time budget (measured event-loop active time)
maxWallMs: 600000 # wall-clock ceiling; never pauses for anything
maxLogBytes: 65536 # shared byte budget for captured log text
maxValueBytes: 32768 # rendered-completion-value cap
maxOldGenerationSizeMb: 512 # worker heap cap (resourceLimits)
Every field is validated (positive numbers) and defaulted; there are no other tunables.
Design
- One fresh worker per run, no pooling — a program's world dies with its worker: no cross-run state to log, state bleed unrepresentable, runs reconstructable from the session log alone.
- Type-strip host-side, in execution context — the program is wrapped in an async-function shell, stripped with
node:module'sstripTypeScriptTypes(erasable syntax only —enum/namespaces are rejected as a programexceptionand no worker spawns), and sliced back out byte-positioned; it then executes as the body of anAsyncFunction, so top-levelawait/returnwork. - The port assumes a hostile peer — model code can reach
parentPortand forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (null, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forgedconstructorcannot walk a prototype chain), drops post-settlement replies, and converts a non-cloneable binding resolution into an error reply. Forgedlog/donemessages cannot bypass the caps: one host-side ledger bounds everything that lands inlogs, and the completion value is re-capped host-side. Worker-side namespaces are null-prototype withdefineProperty, so__proto__-shaped binding names are ordinary keys. - Two independent budgets, because the peer is hostile —
computeMsmeters the worker's MEASURED busy time (worker.performance.eventLoopUtilization()polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing.maxWallMsbackstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel intoworker.terminate(), which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (kind: 'worker-exit'). - Logs stream eagerly — console/stdout/stderr entries cross the port as they happen, so a timed-out or killed program still shows what it printed. ONE shared
maxLogBytesledger bounds everything: streamed entries, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once. - Empty environment — the worker gets
env: {}andexecArgv: []: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags. - Dispose to quiescence — teardown fails in-flight runs as
abortand AWAITS each worker's exit before resolving.
The worker entry, unbuilt and built
worker.ts is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns src/worker.ts directly and Node's native type stripping loads it; built, the entry ships as the sibling bundle lib/worker.js (its own tsdown entry). The built path is pinned by tests/built-lib.e2e.ts, the real-load-path guard from docs/testing.md.