首页 / DeepSeek Harness 入门教程 / 防御性模式与事故复盘

DeepSeek Harness 入门教程

防御性模式与事故复盘

本教程共 32 篇 · 第 29 篇 · 更新于 2026-08-15 · 约 7 分钟阅读

防御性编程postmortem事故复盘测试工程文化可靠性

本节目标:把 dsh 官方文档里「来之不易」的两类知识装进口袋——七条防御性编程规则,以及四篇事故复盘背后的共同教训。

官方文档里有两份文档很特别:defensive-patterns.md 开头写着「来之不易的缺陷类别规则」,postmortem/ 目录记录真实事故。它们不教你怎么用 dsh,而是教你怎么不翻车。这一章把它们浓缩成干货。

防御性模式:七条规则

每条规则都对应一类真实发布或差点发布的缺陷。写生命周期、并发、子进程或清理代码前,先过一遍:

1. 正交结果独立上报。 一个结果可以同时有多种性质:进程可能超时了,却仍以退出码 0 结束,因为它捕获了终止信号。timedOutsignalexitCode 是三个独立事实,必须分开上报。嵌套上报会让调用方把「提前终止」误判成「正常成功」。

2. 公共约定两侧都要遵守。 一个实现收到同一结果的多种表示时,应在通过公共 API 返回前规范化。比如 LlmAdapter.stream() 可以抛异常或发 finish {kind:'error'|'aborted'},但 LlmRuntime.stream() 只通过终止型 finish 暴露模型请求失败。消费方不必猜测异常来自哪一层。

3. 异步状态不是同步状态。 agent.followup() 没有逐消息的完成状态;多条排队消息可能共用同一个 running 区间。不要把 agent/statuswhenIdle() 当作某次 followup 的结果。真正拥有一次运行的调用方,必须显式定义区间(例如从入队回执等到下一次 idle)。

4. dispose 必须完全停稳。 清理时只发终止信号就返回,会留下孤儿进程。正确顺序:先移除监听器,再发信号,然后等待子进程真正退出,超时再升级 SIGKILL。

5. 分发器隔离回调异常。 用户提供的监听器抛异常,不能让它所在的 promise 被 reject,也不能饿死后面的监听器。用 try/catch 包裹分发循环并记录日志——一个行为不当的订阅者绝不能破坏核心生命周期。

6. 不把环境变量或可预测路径暴露给不可信输出。 启动命令前清理环境变量,移除名称匹配 *KEY**SECRET**TOKEN**PASSWORD* 的项。临时文件和 spill 文件放权限 0700 的私有目录,随机文件名,独占打开('wx'0o600)。可预测且全局可读的路径会引发符号链接竞态和信息泄露。

# 从一个空环境启动 dsh,只放行必要的变量,密钥进不去
env -i PATH="$PATH" HOME="$HOME" dsh --profile headless --task "总结 README"

7. 链接形态的路径用 unlink 删除。 可能是符号链接或 Windows junction 的路径,先 lstatSync().isSymbolicLink() 判断,再用 unlinkSync。unlink 只删链接本身并拒绝真实目录,绝不会跟随链接进入目标;只有真实目录才用带 recursivermSync

import { lstatSync, unlinkSync, rmSync } from 'node:fs';

if (lstatSync(p).isSymbolicLink()) {
  unlinkSync(p);                        // 只删链接本身,不跟随进入目标
} else {
  rmSync(p, { recursive: true });       // 真实目录才整树删除
}
Warning

这些规则不是插件作者的专属。第 6 条尤其重要:你的 DEEPSEEK_API_KEY 一旦进入命令输出、env 或 spill 文件,任何能读到输出的对象都拿到了它。

复盘文化:什么事故值得写

官方 postmortem 的定位很明确:一个 bug 出现在了不该出现的地方(真实用户、已合并的 PR、已发布的版本),值得关注的是为什么流程放过了它,而不是那一行修复。

每篇复盘回答四个问题:

四问内容
什么坏了三十秒执行摘要
机制是什么用直白的话说根因
为什么每道安全网都没拦住测试、工具、约定的缺口
新增了什么防护让同类 bug 下次明确报错

不是所有 bug 都值得写。三个条件同时满足才写:隐蔽(机制不显而易见)、系统性(逃逸原因是流程缺口而非笔误)、重新发现代价高(消耗过真实调试时间,下次还会如此)。

四篇复盘:坏什么、为什么逃逸、教训是什么

0001:export default 丢掉了插件的 inject

ACP 服务器在真实编辑器(Zed)连接的瞬间崩溃,报 cannot get property "agents" without inject。根因是一行多余的 export default apply:Cordis Loader 的 unwrapExports 优先取 .default,裸函数上没有 nameinjectConfig,插件于是在一个没注入任何服务的 fiber 里运行。

为什么 178 个绿色单元测试 + 100% 行覆盖率都没拦住?所有测试都用手动 ctx.plugin(...) 挂载,绕过了真实 Loader 的加载路径。

教训:命名空间插件(name/inject/Config/apply 分开导出)与 default export 互斥;至少一个测试必须端到端驱动真实 Loader;行覆盖率证明行被执行过,不证明功能按交付方式正常工作。

0002:一个字面量 !!js 对象禁用了一整套文件系统工具

作者想用 disabled: !!js ... 条件启用文件系统插件,但 Cordis 只在插件 config 内部对 JS 表达式求值,直接读 disabled 配置项元数据时,它看到的是一个 truthy 对象——文件系统栈在所有模式下都被禁用了。快照刷新还把 UNKNOWN_TOOL 结果当成新的预期输出接受了。

教训:语法上被接受的配置值,不一定在该位置被求值;快照刷新是 fixture 的生产过程,不是正确性审查——语义上不可能的结果(工具没注册)需要独立于预期输出的断言。

0003:Web agent 验收了替代服务器,而非自己的 GUI

Web agent 修改了 GUI 源码,却不知道当前会话由哪个 URL、哪个进程承载。它把裸 Vite 返回的 HTTP 200 当成功(页面其实白屏),又去验收另一个端口的替代 dsh web 服务器,从未探测用户真正在用的 3081 端口。

教训:HTTP 就绪、构建成功、启动 manifest 是三个不同的事实;验收必须指明确切 origin 并从外部观察改动是否在那里生效;替代服务无法证明既有页面已经改变。

0004:Landlock 通知把子进程失败误归类成沙箱故障

在较旧 Landlock ABI 的内核上,launcher 会打印无害的 landlock-run: partial enforcement (older Landlock ABI) 通知。harness 用不区分大小写的 landlock-run: 子串匹配它,再把「非零退出 + 该子串」判定为 runner 失败——ripgrep 无匹配时退出码 1 这种正常结果,被呈现成 SANDBOX_UNAVAILABLE

教训:共享前缀不是协议,进程归因需要多项独立证据同时成立;信息性诊断与致命诊断可能共享同一命名空间,排除规则必须精确、对未知致命行保持失败关闭;适配器必须保留下层 seam 的结构化失败,别用自己的通用错误类别替换它。

四条主线:真实入口路径

四篇复盘放在一起,逃逸原因惊人地一致:测试没有走真实入口路径。手动挂载插件、把快照刷新当验收、mock 掉真实边界、用超时冒充快速失败——每一条都让「单元全绿、产品却坏了」成为可能。

Tip

对你日常使用 dsh 的启示:升级版本、换 Provider、改 patch 之后,别只看「能启动」。跑一次真实任务、检查外部世界(文件真的写了吗、进程真的被沙箱限制了吗),再验收。

小结

七条防御性规则防边界翻车,四篇复盘讲流程缺口。它们共同指向一句话:可靠不是靠聪明,是靠「测试真实入口路径 + 对每类缺陷留一道明确报错的防护」。