代码执行缝与 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-runtime | Service Definition:只定义"做什么",不含任何执行代码 | CodeRuntime(ctx.codeRuntime)、CodeRunRequest/CodeRunResult/CodeRunFailure、四张保留名集合 |
@deepseek-ai/dsh-code-runtime-worker-thread | Service Provider(已发布):language: 'typescript'、isolation: 'worker-thread' | WorkerThreadCodeRuntime、Config、src/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-presentation:native直接返回;非native用ctx.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(按程序调用的确切名字键控)、可选 errorClass。CodeBindingErrorClass = { 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 说明是哪一个 |
abort | CodeRunRequest.signal 触发 |
worker-exit | 执行基板在结算前死亡(例如 OOM) |
invalid-output | 完成值不是无损 JSON |
output-limit | 序列化后的外层 logs/value/诊断超过配置上限 |
两条容易被误解的契约:
- 缝上没有流式/事件 API。 一次运行就是一个
Promise<CodeRunResult>,logs是结算后的有序数组。worker 后端内部确实把日志即时流回 host(这样被杀掉的程序也能看到它打了什么),但那是后端实现细节,不是 seam 的公开面——seam 也刻意不提供ctx.on('code-run/…')之类的事件。 - 请求里没有可选调参旋钮。 默认值(时间预算、输出上限)属于实现的已校验配置,请求不携带供隐藏
??填的字段——显式优于隐式。
三、可移植性契约:一套绑定名单在哪个后端都合法
seam 把"可移植"当成硬约束:一个在 worker 后端合法的 bindings,在 Python 后端也必须合法。四张表由定义包独家拥有,所有后端必须一致执行。
| 常量 | 内容 | 为什么 |
|---|---|---|
| 标识符规则 | [A-Za-z_][A-Za-z0-9_]* | 语言可移植子集;$tools 这种 JS 专属拼法设计上就拒绝,不是"只有 Python 后端拒绝" |
RESERVED_BINDING_GLOBALS | console、__dsh_main__、__builtins__、__name__、__debug__ | 各自被某个后端占用(worker 的日志捕获槽、Python 的引导包装与种子全局)。__debug__ 特殊:CPython 在编译期把裸引用折叠成 True 并拒绝赋值,注入的同名全局在程序里不可达 |
PORTABLE_RESERVED_WORDS | ECMAScript ∪ Python 的保留字(含 let/static/implements 等严格模式名,以及 match/type/_ 软关键字) | 否则 lambda 能过 TS 后端、挂 Python 后端;扩语言=扩这个并集(按设计是破坏性评审) |
RESERVED_ERROR_MEMBERS + DUNDER_MEMBER | name/message/stack(JS Error 排除项)、args/with_traceback/add_note(Python 异常协议成员),以及全部 __x__ 形式 | 部分 CPython 描述符在构造拒绝时 setattr 会抛;确切集合是解释器版本细节,所以 dunder 整体拒绝 |
worker 后端在 validateBindings() 里逐条执行这些规则,违规即 throw(这是"调用方违反契约",不是程序失败):binding global … is not a usable identifier、reserved binding global …、duplicate binding global …、binding error class … is not a usable identifier、duplicate injected global …、binding error member property … is not usable。这些都是 host 侧同步抛错,不会 spawn worker。
四、一次运行的生命周期
WorkerThreadCodeRuntime.run() 的顺序(src/index.ts):
disposed检查 →run() after disposal直接 throw。validateBindings(request)→ 上述契约校验。request.signal?.aborted已中止 →failureBeforeWorker({ kind: 'abort', … }),不 spawn。- host 侧类型剥离:
stripTypeScriptTypes(STRIP_WRAP.prefix + program + STRIP_WRAP.suffix),再按字节位置切回程序体。STRIP_WRAP把程序包成async function __dsh_program__() { … },因为裸模块解析会拒绝顶层return;strip 模式位置保持(被删语法变成空白),所以包裹壳剥完仍逐字节一致。enum/namespace 这类不可擦除语法在这里失败 →kind: 'exception',不 spawn worker。 execute():new Worker(WORKER_PATH, …),workerData是WorkerBootData(剥离后的代码、命名空间声明、maxOutputBytes)。- 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 / stderr | true | 兜底捕获: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→host | call | { id, global, name, args }——id 是 worker 自发的关联号 |
| worker→host | log | { text }——即时流回,所以中途被杀也留得下输出 |
| worker→host | output-limit | worker 侧捕获或完成值测量已越过上限 |
| worker→host | done | { value?, error? },error.kind 只有 'exception' | 'invalid-output' | 'output-limit'(预算/abort/基板死亡由 host 观察) |
| host→worker | reply | { 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 键、循环引用一律 undefined(src/worker-json.ts)。
六、预算、输出账本与终止
两条独立的预算,因为 peer 是敌对的:
| 预算 | 机制 | 为什么是它 |
|---|---|---|
computeMs | host 每 25 ms 轮询 worker.performance.eventLoopUtilization(),elu.active > computeMs 即结算 timeout | 计实测忙碌时间:热循环藏不进"挂起的诱饵分发",而等一个慢工具的等待不计费(公平且不可绕过)。代价是到期最多超出一个轮询间隔(25 ms 是内部常量,刻意不可配) |
maxWallMs | 单个 setTimeout 兜底 | 补忙碌时间看不见的东西——等一个没人 resolve 的 promise。加载时校验 ≤ MAX_TIMER_DELAY_MS(2_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 改,没有硬编码可调项:
| 配置 | 默认 | 校验 |
|---|---|---|
computeMs | 60_000 | 有限正数 |
maxWallMs | 600_000 | 有限正数且 ≤ 2_147_483_647 |
maxOutputBytes | 67_108_864(64 MiB) | 安全整数且 ≥ 4(空 logs 数组 + 空失败消息的最小可表示值) |
maxOldGenerationSizeMb | 512 | 有限正数 |
字段经 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: 4、maxArrayLength: 100、maxStringLength: 10_000)——刻意不是 Node 的完整 console。- 绑定中间值没有字节上限——程序可以用一个永远不变成外层输出的值吃光内存。
八、PTC 怎么走这条缝
细节与 PTC 表格见 工具执行(run_code 传输、SDK 段、子调用并发与 tool/ptc-dispatch* 事件)与 工具总览(工具面与配置)。这里只讲接缝处的四个动作(packages/core/tools/src/ptc.ts):
- 取运行时:
requireRuntime()=ctx.get('codeRuntime'),没有就抛dsh-tools: mode "ptc" requires a code runtime …;language没有 SDK renderer 也抛。run_code的 schema 文案在发射时按runtime.language解析,所以模型看到的描述与 SDK 段同语言。 - 只建一个命名空间:
global: 'tools',functions是调用方 Agent 可见的工具集合(registry.schemas(exec.agent),跳过run_code自己),用 null-prototype +defineProperty物化,与 worker 侧同构。 - 声明类型化拒绝:
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' }。所以程序里catch (e) { e.toolName }拿到的就是确切工具名(实测e.name === 'ToolCallError')。 - 调用与映射:
// 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 } : {} }
CodeRunFailedError 是 HarnessError(code: '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 限制 |
| 失败姿态 | 程序失败=结果字段;契约误用=throw | fail 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.ts | CodeRuntime、run/language/isolation、RESERVED_BINDING_GLOBALS、RESERVED_ERROR_MEMBERS、DUNDER_MEMBER、PORTABLE_RESERVED_WORDS、declare module '@deepseek-ai/cordis' 的 codeRuntime |
packages/code-runtime/code-runtime/src/types.ts | CodeBindingFunction、CodeJsonValue、CodeBindingErrorClass、CodeBindingNamespace、CodeRunRequest、CodeRunFailure、CodeRunResult、六个 kind |
packages/code-runtime/code-runtime-worker-thread/src/index.ts | WorkerThreadCodeRuntime、Config、ELU_POLL_INTERVAL_MS、MIN_OUTPUT_BYTES、IDENTIFIER、STRIP_WRAP、WORKER_PATH、LiveRun、OutputLedger、parseWorkerMessage、validateBindings、execute、teardown |
packages/code-runtime/code-runtime-worker-thread/src/protocol.ts | WorkerBootData、CallMessage、LogMessage、OutputLimitMessage、DoneMessage、WorkerToHost、ReplyMessage |
packages/code-runtime/code-runtime-worker-thread/src/bootstrap.ts | runWorkerMain、makeNamespaces、makeConsoleShim、captureStreamWrites、LogBuffer、makeBindingErrorClass、wireReplies、PendingCall、INSPECT_OPTIONS |
packages/code-runtime/code-runtime-worker-thread/src/worker.ts | runWorkerMain(parentPort, workerData, …) 入口 |
packages/code-runtime/code-runtime-worker-thread/src/worker-json.ts | WorkerJsonWire、encodeWorkerJson、decodeWorkerJson、snapshotCodeJsonValue |
packages/code-runtime/code-runtime-worker-thread/src/output-json.ts | jsonStringBytesUpTo、jsonValueBytesUpTo、truncateJsonStringBytes |
packages/core/tools/src/ptc.ts | RUN_CODE_NAME、createRunCodeTool、CodeRunFailedError(CODE_RUN_FAILED)、global: 'tools'、ToolCallError/toolName、runtime.run({…}) |
packages/core/tools/src/index.ts | requireCodeRuntime、peekRuntime、SDK_RENDERERS、maxParallelSubCalls |
packages/core/agent-tool-presentation/src/index.ts | inject = ['tools']、非 native 的 ctx.inject(['codeRuntime'], …) |
packages/bundle/headless/cordis.patch.yml、packages/bundle/web-app/cordis.patch.yml | id: code-runtime / name: '@deepseek-ai/dsh-code-runtime-worker-thread' 两处挂载 |
packages/util/timeout/src/index.ts | MAX_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: 4096、computeMs: 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"