跳到主要内容
路径文档

代码执行缝与 Worker 沙箱

审计基线 0.1.5-alpha.1 @ 5dda764ed3:包名、ctx key、配置键、协议消息与六类失败均与官方源码逐点核对;见 源码 / npm 渠道

一句话版ctx.codeRuntime 是"模型写一个程序、宿主提供函数、只回收它的打印与返回值"的能力缝。CodeRuntime.run() 永远以 result.error 报告程序失败——解析失败、抛异常、超时、abort、worker 死亡、非法完成、输出超限各自是一类 kind——只有调用方违反服务定义契约才 reject。已发布后端 dsh-code-runtime-worker-thread 把每个程序放进全新的 Node worker 线程:host 侧类型剥离、消息端口桥接绑定、双预算与硬终止。这是 containment(可控性),不是安全隔离

PTC 模式的概览与 run_code 的模型面契约在 工具执行工具总览;本篇只做深挖:缝的精确契约、端口协议、预算与账本,以及它和 沙箱 到底各隔离了什么。

一、两个包、三类角色

packages/code-runtime/ 是"能力缝"拆分的一个实例:定义与实现分离,消费方只依赖定义。

角色关键符号
@deepseek-ai/dsh-code-runtimeService Definition:只定义"做什么",不含任何执行代码CodeRuntimectx.codeRuntime)、CodeRunRequest/CodeRunResult/CodeRunFailure、四张保留名集合
@deepseek-ai/dsh-code-runtime-worker-threadService Provider(已发布):language: 'typescript'isolation: 'worker-thread'WorkerThreadCodeRuntimeConfigsrc/protocol.ts
@deepseek-ai/dsh-experimental-code-runtime-python实验性、未发布的 Python 后端(isolation 走子进程)不在 npm 上,组合里也不会出现

定义包自己就把话说死:"Runtimes know nothing about tools or sessions; consumers own those concerns."src/index.ts 模块注释)。它只做一件事——把 CodeBindingNamespace 里的宿主函数桥给程序,再把程序产出物收回来。

消费方有两条:

  • dsh-tools 的 PTC 模式:非 native 模式装配时 requireCodeRuntime(mode) 必须拿到 ctx.codeRuntime,且它的 language 要有已注册的 SDK renderer,否则装配期就报错packages/core/tools/src/index.ts)。
  • dsh-tool-presentationnative 直接返回;非 nativectx.inject(['codeRuntime'], …) 等待,条目一直 pending 就会被 dsh-agent-presets 报成"不可用的行"(packages/core/agent-tool-presentation/src/index.ts)。

二、seam 的词汇:请求、结果、失败分类

// packages/code-runtime/code-runtime/src/types.ts(摘)
export interface CodeRunRequest {
program: string
bindings: CodeBindingNamespace[]
signal?: AbortSignal
}
export interface CodeRunResult {
value?: CodeJsonValue
logs: string[]
error?: CodeRunFailure
}
export interface CodeRunFailure {
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'
message: string
}
字段语义(源码注释口径)
program程序源文本。以 async 函数体运行:顶层 await/return 可用,完成值成为 value
bindings每个 CodeBindingNamespace 变成程序里的一个全局对象
signal中止:运行时硬停程序(哪怕在循环里),结果 kind 为 'abort'在飞的绑定调用由调用方自己收尾——运行时只是不再发问
value?程序的完成值(顶层 return),且必须跨过无损 JSON 边界;非法或超限直接判失败,不会替换成渲染后的字符串
logs捕获的文本。同一来源内部保序,不同来源之间的交错是后端行为;只作为外层结果的一部分被限额
error?仅在失败时出现

CodeBindingNamespace 三件套:global(程序看到的全局名)、functions(按程序调用的确切名字键控)、可选 errorClassCodeBindingErrorClass = { name, memberNameProperty }:运行时会注入一个真的错误构造器,被拒的成员调用成为它的实例,并通过 memberNameProperty 暴露确切的成员名——PTC 用 ToolCallError + toolName 就是这套(第八节)。

失败分类是正交的,源码注释写得很直白:"a budget expiry is not an exception, an abort is not a timeout, and a substrate death is neither."

kind触发
exception程序抛出,或解析/转换失败(含不可擦除的 TypeScript)
timeout某个实现自有预算到期;message 说明是哪一个
abortCodeRunRequest.signal 触发
worker-exit执行基板在结算前死亡(例如 OOM)
invalid-output完成值不是无损 JSON
output-limit序列化后的外层 logs/value/诊断超过配置上限

两条容易被误解的契约

  1. 缝上没有流式/事件 API。 一次运行就是一个 Promise<CodeRunResult>logs 是结算后的有序数组。worker 后端内部确实把日志即时流回 host(这样被杀掉的程序也能看到它打了什么),但那是后端实现细节,不是 seam 的公开面——seam 也刻意不提供 ctx.on('code-run/…') 之类的事件。
  2. 请求里没有可选调参旋钮。 默认值(时间预算、输出上限)属于实现的已校验配置,请求不携带供隐藏 ?? 填的字段——显式优于隐式。

三、可移植性契约:一套绑定名单在哪个后端都合法

seam 把"可移植"当成硬约束:一个在 worker 后端合法的 bindings,在 Python 后端也必须合法。四张表由定义包独家拥有,所有后端必须一致执行。

常量内容为什么
标识符规则[A-Za-z_][A-Za-z0-9_]*语言可移植子集;$tools 这种 JS 专属拼法设计上就拒绝,不是"只有 Python 后端拒绝"
RESERVED_BINDING_GLOBALSconsole__dsh_main____builtins____name____debug__各自被某个后端占用(worker 的日志捕获槽、Python 的引导包装与种子全局)。__debug__ 特殊:CPython 在编译期把裸引用折叠成 True 并拒绝赋值,注入的同名全局在程序里不可达
PORTABLE_RESERVED_WORDSECMAScript ∪ Python 的保留字(含 let/static/implements 等严格模式名,以及 match/type/_ 软关键字)否则 lambda 能过 TS 后端、挂 Python 后端;扩语言=扩这个并集(按设计是破坏性评审)
RESERVED_ERROR_MEMBERS + DUNDER_MEMBERname/message/stack(JS Error 排除项)、args/with_traceback/add_note(Python 异常协议成员),以及全部 __x__ 形式部分 CPython 描述符在构造拒绝时 setattr 会抛;确切集合是解释器版本细节,所以 dunder 整体拒绝

worker 后端在 validateBindings() 里逐条执行这些规则,违规即 throw(这是"调用方违反契约",不是程序失败):binding global … is not a usable identifierreserved binding global …duplicate binding global …binding error class … is not a usable identifierduplicate injected global …binding error member property … is not usable。这些都是 host 侧同步抛错,不会 spawn worker

四、一次运行的生命周期

WorkerThreadCodeRuntime.run() 的顺序(src/index.ts):

  1. disposed 检查 → run() after disposal 直接 throw。
  2. validateBindings(request) → 上述契约校验。
  3. request.signal?.aborted 已中止 → failureBeforeWorker({ kind: 'abort', … })不 spawn
  4. host 侧类型剥离stripTypeScriptTypes(STRIP_WRAP.prefix + program + STRIP_WRAP.suffix),再按字节位置切回程序体。STRIP_WRAP 把程序包成 async function __dsh_program__() { … },因为裸模块解析会拒绝顶层 return;strip 模式位置保持(被删语法变成空白),所以包裹壳剥完仍逐字节一致。enum/namespace 这类不可擦除语法在这里失败 → kind: 'exception'不 spawn worker
  5. execute()new Worker(WORKER_PATH, …)workerDataWorkerBootData(剥离后的代码、命名空间声明、maxOutputBytes)。
  6. worker bootstrap 物化命名空间、执行程序、回 done;host 结算后 worker.terminate()await 退出

Worker 的构造选项每条都有明确意图:

选项目的
env{}模型代码拿不到任何环境变量——比"给子进程洗过的 env"更严
execArgv[]不继承 host 的 loader flag(测试运行器/tsx 的 hook 在空 isolate 里无法满足)
resourceLimits.maxOldGenerationSizeMb配置值(默认 512)堆上限;溢出杀 worker → kind: 'worker-exit'
stdout / stderrtrue兜底捕获:bootstrap 已把 JS 层写入截进自己的有序缓冲,管道正常静默;仍到达的(native 级写入)追加在 done 日志之后

一次运行一个全新 worker,不池化。 程序的世界随 worker 一起死:没有跨运行状态可泄漏,运行只靠会话日志就能重建。teardown() 把服务标记不可用、把在飞运行全部结算为 { kind: 'abort', message: 'runtime disposed' },然后 await 每个 worker 退出才 resolve。

五、host↔worker 协议:一个被当作敌对的端口

src/protocol.ts无版本号、可结构化克隆的词汇表。模型代码能拿到 parentPort 并伪造流量,所以方向不对称:host 不信 worker,worker 信 host

方向消息载荷
worker→hostcall{ id, global, name, args }——id 是 worker 自发的关联号
worker→hostlog{ text }——即时流回,所以中途被杀也留得下输出
worker→hostoutput-limitworker 侧捕获或完成值测量已越过上限
worker→hostdone{ value?, error? }error.kind 只有 'exception' | 'invalid-output' | 'output-limit'(预算/abort/基板死亡由 host 观察)
host→workerreply{ id, ok: true, value }{ id, ok: false, message },每个 id 至多应答一次

host 侧的敌对规则(parseWorkerMessage + onCall):

  • 形状校验后逐字段重建:compile-time 的 WorkerToHost 在这里没有任何意义,peer 可以 post 任何东西。伪造的多余字段不会随行,非 number 的 call id 永远进不了 reply;垃圾消息返回 undefined 并被静默丢弃(在 message 监听器里抛异常会崩掉 host 进程)。
  • 每个 call id 至多应答一次:重复 id 忽略。
  • 绑定名只按 own property 解析Object.hasOwn(record, name),伪造的 constructor/hasOwnProperty 走不了原型链。
  • 参数与结果都过无损 JSON 校验:入参非法 → ok: false;绑定返回值非法 → ok: false, message: 'binding resolution must be lossless JSON';绑定抛/拒 → ok: false,在程序侧变成对应命名空间的类型化拒绝绝不崩 host

worker 侧对称地防自己:命名空间对象用 Object.create(null) + Object.defineProperty 物化,所以 __proto__/constructor 这类名字是普通 own 键而不是原型碰撞;每个函数先 snapshotCodeJsonValue 再发 call

跨端 JSON 用扁平 token 流。 WorkerJsonWire 是一个 pre-order 的 token 数组(容器标记 + 标量叶子同列),encodeWorkerJson/decodeWorkerJson 用迭代遍历,所以 worker_threads 的 structured clone 不必递归复制应用的嵌套深度。两端的 snapshotCodeJsonValue 只接受无损 JSON:NaN/Infinity/-0、稀疏数组、带原型或访问器的对象、symbol 键、循环引用一律 undefinedsrc/worker-json.ts)。

六、预算、输出账本与终止

两条独立的预算,因为 peer 是敌对的:

预算机制为什么是它
computeMshost 每 25 ms 轮询 worker.performance.eventLoopUtilization()elu.active > computeMs 即结算 timeout实测忙碌时间:热循环藏不进"挂起的诱饵分发",而等一个慢工具的等待不计费(公平且不可绕过)。代价是到期最多超出一个轮询间隔(25 ms 是内部常量,刻意不可配)
maxWallMs单个 setTimeout 兜底补忙碌时间看不见的东西——等一个没人 resolve 的 promise。加载时校验 ≤ MAX_TIMER_DELAY_MS2_147_483_647),因为 setTimeout 会把更长的延时钳到 1 ms

两者都汇进 worker.terminate()也能终止热的同步循环——这是普通"超时后不再等"做不到的。

输出账本(OutputLedger 只计 JSON 序列化字节:外层 logs 数组 + 完成值或失败诊断,固定信封语法(CodeRunResult 的字段名)不计。规则:

  • 每条日志按序 admit,超限即停并标记;
  • 完成值先测剩余预算:不合法 → invalid-output;合法但装不下 → output-limit绝不替换成 inspect 出来的字符串);
  • 失败诊断同样预检——抛出的百万字节栈在 worker 边界变成固定output-limit 诊断;
  • 无论哪条路径,结果里都保留一段装得下的日志前缀(实测:4096 字节上限下保留 4058 字节)。

默认 64 MiB 是拒绝边界,不是可恢复存储:越过运行时限的字节根本到不了 spill 层,spill 只能保存被限额后返回的 logs 与诊断。

七、配置面与失败模式

WorkerThreadCodeRuntime.Config 是 schemastery schema,每个上限都可从组合 YAML 改,没有硬编码可调项

配置默认校验
computeMs60_000有限正数
maxWallMs600_000有限正数且 ≤ 2_147_483_647
maxOutputBytes67_108_864(64 MiB)安全整数且 ≥ 4(空 logs 数组 + 空失败消息的最小可表示值)
maxOldGenerationSizeMb512有限正数

字段经 Loader 由 schema 填默认;直接构造(绕过 Loader)必须给全值,构造器再做正数/上界检查,报错形如 config.maxWallMs must be at most 2147483647 (Node clamps a longer setTimeout delay to 1ms), got 2147483648

失败模式里,程序结果与调用方错误泾渭分明

  • 程序结果 → result.error(六类 kind);
  • 调用方违反契约 → throw:run() after disposal、绑定名校验失败、config.* 校验失败。

README 明列的当前限制(都是包约束,不是待办清单):

  • 程序 spawn 的 OS 进程在 terminate() 后存活——只杀线程,比 bash-local 的进程组 kill 弱;孤儿清理是部署责任,直到有 container 类后端。
  • 类型剥离依赖实验性 stripTypeScriptTypes;若行为漂移,amaro / sucrase 是点名的替代品。
  • console 只有五个方法log/info/warn/error/debug),参数用 inspect 渲染(depth: 4maxArrayLength: 100maxStringLength: 10_000)——刻意不是 Node 的完整 console。
  • 绑定中间值没有字节上限——程序可以用一个永远不变成外层输出的值吃光内存。

八、PTC 怎么走这条缝

细节与 PTC 表格见 工具执行run_code 传输、SDK 段、子调用并发与 tool/ptc-dispatch* 事件)与 工具总览(工具面与配置)。这里只讲接缝处的四个动作(packages/core/tools/src/ptc.ts):

  1. 取运行时requireRuntime() = ctx.get('codeRuntime'),没有就抛 dsh-tools: mode "ptc" requires a code runtime …language 没有 SDK renderer 也抛。run_code 的 schema 文案在发射时runtime.language 解析,所以模型看到的描述与 SDK 段同语言。
  2. 只建一个命名空间global: 'tools'functions调用方 Agent 可见的工具集合(registry.schemas(exec.agent),跳过 run_code 自己),用 null-prototype + defineProperty 物化,与 worker 侧同构。
  3. 声明类型化拒绝errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' }。所以程序里 catch (e) { e.toolName } 拿到的就是确切工具名(实测 e.name === 'ToolCallError')。
  4. 调用与映射
// packages/core/tools/src/ptc.ts(摘)
result = await runtime.run({
program: args.code,
bindings: [{ global: 'tools', functions,
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' } }],
signal: runController.signal, // 跟随外层 signal,且运行结算时必 fire
})
if (result.error) {
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}`)
}
return { logs: result.logs, ...result.value !== undefined ? { result: result.value } : {} }

CodeRunFailedErrorHarnessErrorcode: 'CODE_RUN_FAILED'),被注册表转成结构化 isError 让模型自纠。注意 seam 的边界:运行时不知道工具、会话、模型上下文的存在;绑定流量与中间值留在执行本地,只有外层 { logs, result? } 进入模型上下文。

九、与 sandbox 的分工:谁隔离了什么

两个名字都像"隔离",但层级完全不同:

ctx.codeRuntime(本篇)ctx.sandbox
隔离对象一次程序执行(JS isolate 内的线程)confine 包装的进程及其全部子进程
机制node:worker_threads + 空 env + execArgv: [] + 堆上限 + 硬终止OS 级:Linux bwrap/Landlock、macOS Seatbelt、Windows restricted-token runner
限制面资源与可终止性(时间、堆、输出),不是能力文件效应read-only/workspace-write/danger-full-access);词汇里没有网络/进程/syscall 限制
失败姿态程序失败=结果字段;契约误用=throwfail closed:没有可用后端就抛 SANDBOX_UNAVAILABLE,绝不裸跑
信任姿态源码注释:"bash-equivalent trust"isolation 字段只是诊断标签,不是安全声明默认不信任,由调用方按调用选策略

结论一句话:code-runtime 给的是 containment——独立 isolate、空环境、堆上限、能杀死热循环的硬终止;它限制程序能碰的文件、网络或它 spawn 的进程。程序照样能 import Node API、起子进程,而那些子进程terminate() 后存活、也不受 worker 约束。要文件栅栏,必须让程序通过绑定调用(例如 bash 工具)走 ctx.sandbox;或者等一个 container 类后端来提供真正的边界。

十、源码佐证

位置符号 / 事实
packages/code-runtime/code-runtime/src/index.tsCodeRuntimerun/language/isolationRESERVED_BINDING_GLOBALSRESERVED_ERROR_MEMBERSDUNDER_MEMBERPORTABLE_RESERVED_WORDSdeclare module '@deepseek-ai/cordis'codeRuntime
packages/code-runtime/code-runtime/src/types.tsCodeBindingFunctionCodeJsonValueCodeBindingErrorClassCodeBindingNamespaceCodeRunRequestCodeRunFailureCodeRunResult、六个 kind
packages/code-runtime/code-runtime-worker-thread/src/index.tsWorkerThreadCodeRuntimeConfigELU_POLL_INTERVAL_MSMIN_OUTPUT_BYTESIDENTIFIERSTRIP_WRAPWORKER_PATHLiveRunOutputLedgerparseWorkerMessagevalidateBindingsexecuteteardown
packages/code-runtime/code-runtime-worker-thread/src/protocol.tsWorkerBootDataCallMessageLogMessageOutputLimitMessageDoneMessageWorkerToHostReplyMessage
packages/code-runtime/code-runtime-worker-thread/src/bootstrap.tsrunWorkerMainmakeNamespacesmakeConsoleShimcaptureStreamWritesLogBuffermakeBindingErrorClasswireRepliesPendingCallINSPECT_OPTIONS
packages/code-runtime/code-runtime-worker-thread/src/worker.tsrunWorkerMain(parentPort, workerData, …) 入口
packages/code-runtime/code-runtime-worker-thread/src/worker-json.tsWorkerJsonWireencodeWorkerJsondecodeWorkerJsonsnapshotCodeJsonValue
packages/code-runtime/code-runtime-worker-thread/src/output-json.tsjsonStringBytesUpTojsonValueBytesUpTotruncateJsonStringBytes
packages/core/tools/src/ptc.tsRUN_CODE_NAMEcreateRunCodeToolCodeRunFailedErrorCODE_RUN_FAILED)、global: 'tools'ToolCallError/toolNameruntime.run({…})
packages/core/tools/src/index.tsrequireCodeRuntimepeekRuntimeSDK_RENDERERSmaxParallelSubCalls
packages/core/agent-tool-presentation/src/index.tsinject = ['tools']、非 native 的 ctx.inject(['codeRuntime'], …)
packages/bundle/headless/cordis.patch.ymlpackages/bundle/web-app/cordis.patch.ymlid: code-runtime / name: '@deepseek-ai/dsh-code-runtime-worker-thread' 两处挂载
packages/util/timeout/src/index.tsMAX_TIMER_DELAY_MS = 2_147_483_647

十一、验证

# 1. 组合:web / headless 都应有 code-runtime 行,且 tools 模式可被 DSH_TOOLS_MODE 覆盖
dsh web --dump-config | grep -iE "code-runtime|tool-presentation|maxOutputBytes"
grep -n "code-runtime" packages/bundle/*/cordis.patch.yml

# 2. seam 词汇与六类失败就在两个文件里
grep -n "kind: 'exception'\|RESERVED_BINDING_GLOBALS\|PORTABLE_RESERVED_WORDS" \
packages/code-runtime/code-runtime/src/types.ts packages/code-runtime/code-runtime/src/index.ts
grep -n "type: 'call'\|type: 'reply'\|type: 'done'\|type: 'output-limit'" \
packages/code-runtime/code-runtime-worker-thread/src/protocol.ts

# 3. 预算、账本与端口敌对规则
grep -n "eventLoopUtilization\|MAX_TIMER_DELAY_MS\|Object.hasOwn\|MIN_OUTPUT_BYTES\|STRIP_WRAP" \
packages/code-runtime/code-runtime-worker-thread/src/index.ts

# 4. 消费方接缝
grep -n "runtime.run({\|errorClass\|requireCodeRuntime" packages/core/tools/src/ptc.ts packages/core/tools/src/index.ts

想在运行时自己看六类失败:把 WorkerThreadCodeRuntime 直接构造起来跑几个程序即可(不必启动整个 dsh)。实测输出(本机 Node v24.15.0,maxOutputBytes: 4096computeMs: 400):

success {"value":{"ok":true,"r":{"n":42}},"logs":["hello { a: 1 }","r=42"]}
exception {"logs":[],"error":{"kind":"exception","message":"Error: program blew up\n at …"}}
invalid-output {"logs":[],"error":{"kind":"invalid-output","message":"program completion must be lossless JSON"}}
binding-rejection {"value":{"name":"ToolCallError","toolName":"boom","message":"host says no"}}
timeout {"logs":[],"error":{"kind":"timeout","message":"compute budget exhausted (400ms busy)"}}
abort {"logs":[],"error":{"kind":"abort","message":"caller gave up"}}
output-limit {"logCount":1,"logLen":4058,"error":{"kind":"output-limit","message":"outer output exceeded 4096 bytes"}}
enum {"logs":[],"error":{"kind":"exception","message":"TypeScript enum is not supported in strip-only mode"}}
run() after disposal → throw: dsh-code-runtime-worker-thread: run() after disposal
reserved global console → throw: dsh-code-runtime-worker-thread: reserved binding global "console"

下一步

  • 工具执行:PTC 模式、SDK 段与 tool/ptc-dispatch* 事件(本篇刻意不重复)
  • 沙箱与安全ctx.sandbox.confine 的文件栅栏与 fail-closed 姿态
  • 写服务:怎么用 ctx.provide 自己提供一条能力缝
  • Spill:外层 run_code 结果进入模型上下文后的溢出策略
  • 工具总览:当前装了哪些工具、怎么按 profile 配