DeepSeek Harness 的防御式模式:他们踩过的坑写成了编码约定

2026-08-17

先说清楚一件事:DeepSeek Harness 当前版本是 0.1.0-rc.5,仓库 README 里第一个二级标题就是 “Developer preview”,那一段正文用全大写写着会有破坏兼容性的变更。下面提到的每一个文件名、字段名和默认值都可能随时改,请以仓库最新内容为准。我们没有安装、也没有运行过这个项目,所有结论只来自源码和文档本身。

一条命令超时了,退出码却是 0

先摆一个具体场景。agent 调用 bash 工具跑一条命令,命令被超时砍掉了,但这个命令自己捕获了 SIGTERM 并且体面地 exit 0 退出。这时候调用方拿到的结果里,退出码是 0。如果结果结构里只有一个「成功/失败」的判断,或者把超时标记塞在「非零退出」那个分支里报,上层就会把一次被腰斩的运行读成一次干净的成功。

docs/defensive-patterns.md 的第一条规则说的正是这件事。文档开头自述,下面每一条都是这个项目实际发布或者差点发布出去的一类缺陷,写成防止复发的规则。这份文档一共七条,AGENTS.md 里专门有一节「Defensive patterns」,只有一句话:在写生命周期、并发、子进程或者清理代码之前先读它。

规则本身很短:timedOutsignalexitCode 是三个互相独立的事实,各报各的,绝不允许把一个标志的上报嵌进另一个标志的分支里。

顺着结果结构往下走

这条规则在代码里落成什么样,可以直接去 packages/shell/shell/src/types.tsShellRunResult 的字段。

字段类型源码里怎么写的
exitCodenumber | null死于信号时为 null
signalNodeJS.Signals | null正常退出时为 null
timedOutboolean执行器自己的超时是第一原因时为 true
abortedboolean调用方的 AbortSignal 是第一原因时为 true
timeoutMsnumber本次运行实际生效的超时值
stdout / stderrCollectedOutput收集式输出
sandbox可选不做限制的执行器上不存在这个字段

这里有个容易看漏的细节,值得和文档并排看一眼。文档强调的是「正交事实各报各的」,而这份类型定义里,timedOutaborted 的注释白纸黑字写着两者互斥:超时和取消共用一条熔合的 deadline,两者在进程关闭前抢跑时只报「第一原因」,不会同时为真。也就是说,退出事实之间是正交的,而「谁先把它砍掉」这个归因是单选的。这两处说法各自的位置都很明确,一处在 docs/defensive-patterns.md 的第一条,一处在 packages/shell/shell/src/types.ts 的字段注释里,读的时候别把它们当成同一回事。

再往下一层,packages/shell/bash-local/src/index.tsrunArgv 里能看到这两个布尔值是怎么算出来的:先建一条 deadline 把调用方的 signal 和 timeoutMs 熔在一起,超时原因打的标签是 'BASH_TIMEOUT';进程 done 之后,timedOut 取决于这条 signal 上能不能查到这个标签,aborted 则是「signal 被 abort 了但不是本执行器的超时」。注释写得很直白:只有本执行器自己的超时算 timedOut,外层的 deadline 一律算 abort。

再下一层就到 packages/subprocess/subprocess/src/types.tsSubprocessOutcome,那里干脆只有 exitCodesignal 两个字段。超时归类不属于这一层——spawn 这一层不做任何默认值,超时的语义由调用方拥有。三层结构是从进程事实到原因归类逐层加信息,而不是在最底层塞一个「成功了没」的布尔。

3000 毫秒写在哪一行

「dispose 必须达到完全停稳,而不仅仅是请求停止」是七条里最容易被跳过的一条:清理只发终止信号就返回,孤儿进程就留下了。

这条规则在子进程这一侧的落点是 graceMspackages/subprocess/subprocess-local/src/spawn.ts 里的 terminate() 先发 SIGTERM,然后 setTimeout 挂一个 spec.graceMs 之后的 SIGKILL。这个定时器有两处刻意的写法:一是它不在 cleanup() 里被清掉,注释说明升级必须能够在直接子进程结算之后仍然够得到树上的幸存者;二是 kill() 每次动手前都会重新探测整棵树是否还活着,避免 pid 复用之后误伤别人。同一个 graceMs 还兼作另一个兜底——子进程 exit 之后如果还有后代继承着管道不放,pipeDrainTimer 也按这个值把结果结算掉。

默认值本身在执行器那一层。packages/shell/bash-local/src/index.ts 里写着 DEFAULT_GRACE_MS = 3_000,配置项就叫 graceMs,注释里注明这个 3 秒是对齐 OpenCode 的;packages/shell/pwsh-local/src/index.ts 也是同一个值。子代理那边另有一套:几个 subagent 包里的 DEFAULT_DISPOSE_GRACE_MS 是 3000,DEFAULT_DISPOSE_EOF_GRACE_MS 是 6000。packages/lsp/lsp-stdioDEFAULT_KILL_GRACE_MS 则是 2000。这些都是配置默认值,不是「你用起来会等多久」的承诺——真实等待时间取决于被杀的进程自己怎么响应信号。

graceMs 的取值还有一道校验:必须是正的有限数,且不大于 MAX_TIMER_DELAY_MS,也就是 2_147_483_647,超出直接抛错。顺带一提,这个常量在仓库里有两处各自的定义,packages/util/timeout/src/index.tspackages/schedule/schedule/src/runtime.ts 都有,值相同。

真正把「停稳」兑现的是 packages/subprocess/subprocess-local/src/index.ts 里的 disposeManagedProcesses():它对每个存活句柄先 terminate(),然后 push 的是 handle.done 之后再接 handle.waitForExit()——等的是整棵树退出,不只是直接子进程结算。所有等待走 Promise.allSettled,只要有失败就再来一轮同步的 terminateForHostExit(),最后一个失败原样抛出,多个失败包成 AggregateError。服务构造时还用 process.prependListener('exit', ...) 挂了一个宿主退出时的同步兜底。

这些机制的存在不等于「有沙箱所以安全」。这个项目本来就要在你本机起子进程、跑 shell,能不能杀干净和该不该让它跑是两个问题。

环境变量那一刀切在哪

第六条规则叫「绝不将环境变量或可预测路径暴露给不可信输出」。落点非常好找:packages/subprocess/subprocess/src/index.ts 里导出了一个正则

export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i

以及 scrubbedParentEnv()。这个函数遍历 process.env,凡是名字命中上面这个正则的、或者大写后以 DSH_ 前缀开头的(前缀常量 DSH_ENV_PREFIX 定义在同包的 types.ts),一律不带给子进程。两道清理都是大小写不敏感的,注释里给的理由是 Windows 的环境变量名本身不区分大小写,否则父进程里一个小写的 dsh_* 就能活着传下去,在子进程里读回来还是 $env:DSH_*

关键在顺序:显式传入的 env 是在这次清理之后合并的,所以你确实想传的那一项能活下来,而 harness 自己的 $DEEPSEEK_API_KEY 之类不会隐式泄漏。这个函数被刻意导出成普通函数,注释说明是为了让那些走不了服务层的 spawner(node-pty 后端、SDK 管理的传输层)共用同一份清理定义。

spill 文件为什么要随机名

同一条规则的后半句管临时文件。packages/spill/spill-local/src/store.ts 是一个很好的样本,里面几乎每一行都能对回规则原文:

  • 默认根目录用 mkdtempSync 在系统临时目录下建 dsh-spill- 前缀的私有目录,注释给的理由是可预测且全局可读的路径会让本机其他用户读到落盘的工具输出,或者提前把符号链接种在那里;
  • 会话子目录是 session- 加上 sessionId 的 sha256 前 12 位;
  • 建目录时 mode: 0o700
  • 文件名是 randomBytes(6) 的十六进制再拼上净化过的建议名,前半段保证不可预测,后半段保证还能看懂;
  • 落盘用 open(path, 'wx', 0o600),独占且只有属主可读写,路径已存在就直接失败——不管它是不是链接,提前种好的目标改不了这次写入的去向。

顺带值得看一眼的是同文件里的 encodeSegment():它把任意字符串编成一个安全路径段,[A-Za-z0-9._-] 之外的码元转义成 ~XXXX,空串编成 ~,整段是 . 编成 ~002E.. 编成 ~002E~002E。这是把「会话 id 和建议名都是不可信输入」这件事按字面处理掉。

Windows 上那条 junction 的坑

第七条规则单独讲删除:可能是符号链接或者 Windows junction 的路径,要先 lstatSync().isSymbolicLink() 判断再 unlinkSync,因为 unlink 只删链接本身、拒绝真实目录,绝不会跟着链接进到目标里去。文档里明写,Windows 上对 junction 调 rmSync(link) 会抛 ERR_FS_EISDIR,而带 recursive 的递归删除可能穿过 junction 进到它指向的地方。

实现可以看 packages/boot/app-boot/src/profile.ts 里的 ensureSymlink():先 lstat,不是链接就直接抛错让人自己去挪(错误信息里明说是为了让 dsh 能管理安装回退目录),链接指向已经对了就返回,否则 unlinkSync 再重建,重建时 symlinkSync 的第三个参数写死 'junction'。并发启动撞上 EEXIST 时它会再 lstat + readlink 复核一次,指向一致就算赢。

这条规则在仓库里还有一条对应的记录:.agents/notes/implemented/bug-fix/ 下有一篇 2026-08-12 的笔记,讲的是测试夹具清理时先 unlink 掉所有 reparse point 再删树。规则和它的来历是分开放的。

剩下三条:异步、回调、公共约定

  • 回调异常要在分发器里兜住。 packages/core/agent/src/dispatch.tsemit 没有直接用 Cordis 的 emit,注释写明原因:Cordis 的 emit 通过 Array.map 调回调,一个同步 throw 会饿死排在后面的监听器,返回的 promise 也被丢弃。所以它自己拿到过滤后的回调集合,逐个 try/catch,同时给返回值挂 .catch(),两种失败模式各记一条 warn。
  • 异步状态不是同步状态。 packages/core/agent-loop/src/agent.tsfollowup(input) 的返回类型就是 void,它只是把消息以 'next-turn' 塞进 inbox 并唤醒驱动。所以文档说,别把 agent/status 或者 whenIdle() 当成某一次 followup 的结果——多条排队消息、steering 和注入的工作可能共用同一段 running 区间。whenIdle() 的实现是个 do-while,反复 await 当前的 activity,直到前后两次拿到的是同一个 promise。
  • 公共约定两侧都要守。 packages/llm/llm/src/index.tsLlmRuntime.stream() 的文档注释划了一条很清楚的线:适配器选择、派发和迭代过程中的失败,会被转成终止型的 finish 分片(adapterFailureChunk() 按是否 abort 分成 kind: 'aborted'kind: 'error');而 middleware、嵌套调用、清理和消费方自身的失败仍然以异常抛出。同包的 invariant.ts 里还有对应的断言:终止 finish 之后再来分片要失败,流结束却没有终止 finish 也要失败。

这份文档自己也上着秤

最后一个细节我觉得比七条规则本身更值得抄走。scripts/doc-budgets.manifest.json 给九份常驻文档各定了一个词数上限,docs/defensive-patterns.md 的上限是 550 词;scripts/verify-doc-budgets.tswc -w 的口径数词,超过就让门禁失败,错误信息里直接写着「抬高上限需要在 PR 里给出理由」。我们按同样口径数了一下当前这份文档,是 518 词。同一份清单里 AGENTS.md 的上限是 1900,当前正好 1900——脚本的判定是 words <= ceiling,所以卡在线上算过。

这个安排的效果是:新踩的坑想进这份文档,就得挤掉旧的,或者搬到别处去。仓库里 .agents/skills/ 下的代码评审 skill 也把这份文档列进了必读清单,评审生命周期、并发、子进程和清理相关改动时按它逐条对。

如果你要从这套东西里拿一样走,我会拿这条:把「这次运行发生了什么」的每个独立事实拆成独立字段,别在返回值上做归纳ShellRunResult 那张表就是这条规则的样子——exitCodesignaltimedOutabortedtimeoutMs 各占一格,上层想怎么读是上层的事。至于这七条规则在你自己的项目里适不适用,那是另一回事;上面这些只是这个仓库当前快照里写着的东西。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。