DeepSeek Harness 防御性编程

2026-09-08 00:00    #AI   #工具   #DeepSeek  

官方归纳的"来之不易的缺陷类别规则",每一条都是真实发布或差点发布的缺陷。这些模式防止"一个简单的边界情况把整个 Agent 搞挂"。

① 正交结果独立上报

一个结果可同时具有多种性质:进程可能已超时,却仍以退出码 0 结束(它捕获了终止信号)。每个独立事实(timedOutsignalexitCode)应单独上报,千万不要把一个标志的上报嵌套在另一个标志的分支里。

 1// 文件路径:packages/my-shell/src/run.ts
 2import { spawn, type ChildProcess } from 'node:child_process'
 3
 4export interface RunResult {
 5  timedOut: boolean
 6  signal: NodeJS.Signals | null
 7  exitCode: number | null
 8  stdout: string
 9  stderr: string
10}
11
12export function run(argv: string[], timeoutMs: number): Promise<RunResult> {
13  return new Promise((resolve, reject) => {
14    const child: ChildProcess = spawn(argv[0], argv.slice(1), {
15      stdio: ['ignore', 'pipe', 'pipe'],
16    })
17    let stdout = ''
18    let stderr = ''
19    child.stdout.on('data', (d: Buffer) => (stdout += d))
20    child.stderr.on('data', (d: Buffer) => (stderr += d))
21
22    let timedOut = false
23    const timer = setTimeout(() => {
24      timedOut = true
25      child.kill('SIGTERM')
26    }, timeoutMs)
27
28    child.on('close', (code, signal) => {
29      clearTimeout(timer)
30      resolve({ timedOut, signal, exitCode: code, stdout, stderr })
31    })
32    child.on('error', reject)
33  })
34}

调用方可组合判断:timedOut 为真但 exitCode 为 0 = “被强杀但进程用 0 掩盖了”。嵌套上报时这个组合永远表达不出来。

② dispose 必须达到完全停稳

清理流程若只发终止信号便返回,会留下孤儿进程。应异步等待子进程退出(发信号后等待 done),并在终止前关闭监听器注册表与通知注册表,使迟到的完成事件保持静默。

 1// 文件路径:packages/my-shell/src/dispose.ts
 2import { once } from 'node:events'
 3import type { ChildProcess } from 'node:child_process'
 4
 5export async function disposeQuiescent(child: ChildProcess): Promise<void> {
 6  child.removeAllListeners()
 7
 8  child.kill('SIGTERM')
 9
10  const forceKill = new Promise<void>((resolve) => {
11    setTimeout(() => {
12      child.kill('SIGKILL')
13      resolve()
14    }, 5_000)
15  })
16
17  await Promise.race([once(child, 'exit').then(() => undefined), forceKill])
18}

dispose 返回时,子进程要么已退出,要么已被 SIGKILL 强制终止。

③ 凭据擦除:绝不把环境变量暴露给不可信输出

启动命令使用清理过的环境变量,移除名称匹配 *KEY**SECRET**TOKEN**PASSWORD* 的项;否则 harness 凭证可能通过命令输出、env 或 spill 文件泄漏。临时/spill 文件应放在权限 0700 的私有目录,用随机文件名,以独占且仅所有者可访问方式打开('wx'0o600)。

 1// 文件路径:packages/my-shell/src/env.ts
 2const SENSITIVE_NAME = /.*(?:KEY|SECRET|TOKEN|PASSWORD).*/i
 3
 4export function scrubEnv(env: NodeJS.ProcessEnv): Record<string, string> {
 5  const clean: Record<string, string> = {}
 6  for (const [key, value] of Object.entries(env)) {
 7    if (SENSITIVE_NAME.test(key)) continue
 8    if (value !== undefined) clean[key] = value
 9  }
10  return clean
11}
12
13// 用法:spawn(argv[0], argv.slice(1), { env: scrubEnv(process.env), ... })

DEEPSEEK_API_KEY 这类变量必须走这条擦除路径。

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

 1// 文件路径:packages/my-fs/src/remove.ts
 2import { lstatSync, rmSync, unlinkSync } from 'node:fs'
 3
 4export function removePath(p: string): void {
 5  if (lstatSync(p).isSymbolicLink()) {
 6    unlinkSync(p)
 7    return
 8  }
 9  rmSync(p, { recursive: true })
10}

⑤ 分发器中隔离回调异常

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

 1// 文件路径:packages/my-events/src/dispatch.ts
 2export function dispatch(listeners: ReadonlyArray<() => void>): void {
 3  for (const listener of listeners) {
 4    try {
 5      listener()
 6    } catch (err) {
 7      console.error('[dispatch] a listener failed:', err)
 8    }
 9  }
10}

此模式在 dsh 内部随处可见:session/event 观察者失败被记录并隔离,不使已提交的 append 失败。

更多模式

模式规则
公共约定两侧都要遵守收到同一结果的多种表示时,应在通过公共 API 返回前规范化;消费方不必猜测异常来自提供方、包装层还是自身组装逻辑
异步状态不是同步状态不要把 agent/statuswhenIdle() 当作某次 followup() 的结果;真正拥有一次运行的调用方必须显式定义区间

自测

  1. 进程超时但退出码 0 意味着什么?→ 捕获了终止信号并自行以 0 退出;timedOut 与 exitCode 是两个独立事实
  2. 只发 SIGTERM 就返回会怎样?→ 留孤儿进程;应等待退出,超时升级 SIGKILL
  3. 删除路径前如何判断?→ 先 lstatSync 判断再用 unlinkSync;真实目录才用 rmSync recursive