DeepSeek Harness 工具调用永久卡死:Windows runner 挂起与沙箱拒绝不提示

故障排查发布于 2026-10-17作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH 沙箱工具调用卡死runner 超时sandbox deniedworkspace-write僵尸进程
DeepSeek Harness 在 Windows 上偶发工具调用永久卡在「执行中」,最长两小时不返回,实际是 runner 等一个丢失的启动帧且全程没有超时;另一类相反:沙箱确实拒绝了写工作区外路径,但拒绝信息打在 stdout 没被分类,模型看不到标记就改道绕过。本文给出僵尸进程识别脚本与补标记的处置办法。

Windows 上的沙箱问题有两种极端表现:一种是「什么都说了但什么都没跑」——工具调用永久卡在执行中,界面一直转,等两小时也不返回;另一种是「跑了也被拒,但没人告诉模型」,于是模型看不到任何拒绝标记,自己换个写法绕过沙箱。 前者来自沙箱子进程的启动握手没有任何超时:runner 已经 fork、IPC 也连上了,但那条 start 帧丢在管道里,两边就永远互等(社区核对源码确认 runner.js 整个文件里 setTimeout 出现 0 次);后者来自拒绝分类的匹配面太窄:沙箱只对 stderr 匹配拒绝签名,而某些运行时把 EACCES 打在 stdout,于是 sandbox.denied 永远不会被置位,[sandbox: file access denied …] 标记与升级提示都不会出现(#9515、#9497)。本文分别给出「怎么判定」与「怎么修/怎么绕」,两条都能在不重启整个 DSH 的前提下处理完。

DeepSeek Harness 工具调用永久卡死:start 帧丢失 + 三层都不设超时

一句话结论:这类卡死的本质是「启动握手丢帧」,而不是命令太慢——因为从父进程到 runner 再到工具层,没有任何一层给它上过超时。 症状特征是:GUI 一直显示执行中(最长两小时以上),会话里既没有 tool/result 也没有错误,重启应用才恢复(#9515)。

DSH插件 机制:为什么三层超时都兜不住

据社区逐行核对源码,缺超时的地方有三处,层层递进:

  1. runner 自身无超时:dsh-subprocess-local/lib/runner.js 在等待 IPC 的 start 消息时进入等待,全文件没有任何 setTimeout,start 帧丢失即双方永久互等;
  2. 会话统计无看门狗:sessionStats.pendingCalls 只是计数,不参与任何超时判定;
  3. 工具层是协作式超时:tools/execute 的超时包装只 abort exec.signal 后继续 await next(),不能 race 掉一个永不 settle 的 promise;而且它只在工具声明了 timeoutMs 时才武装,而现场没有任何工具声明过(#9515)。

把这三条连起来看就很清楚了:「丢帧」是小概率事件,但一旦发生,整条链上没有任何一层会把它掐断——所以它会一直挂着,直到你重启。

DeepSeek Harness 三步判定:这是僵尸 runner,不是「模型在思考」

判据是三件事同时成立——进程活着、几乎不耗 CPU、没有任何子进程。三步:

  1. 看 runner 进程是否还在。 在非受限 PowerShell 里列出候选:
powershell
Get-Process node -ErrorAction SilentlyContinue |
  Where-Object { $_.Path -like "*dsh-subprocess-local*" } |
  Select-Object Id, ProcessName, StartTime, CPU

预期:能看到一个 StartTime 明显早于现在、且 CPU 数值很小的进程。

  1. 看它有没有子进程。 执行:
powershell
$runner = <上一步拿到的 Id>
Get-CimInstance Win32_Process -Filter "ParentProcessId=$runner" | Select-Object ProcessId, CommandLine
#   → 预期无输出:runner 已起,但目标命令从未被启动
  1. 看它是否「活着但不动」。 间隔 30 秒连续两次采样 CPU 值,若两次几乎相同且小于 1 秒,同时 StartTime 已超过 3 分钟,即可判定为僵尸 runner(#9515)。

对照确认:此时会话里应当没有该工具对应的 tool/result 事件——如果已经有结果,那就不是本族,去查别的方向(子进程在加载器阶段就启动失败、报 0xC0000142 的那种,见 DeepSeek Harness Windows 子进程报 0xC0000142)。

DSH插件 处置:杀僵尸 + 加非协作兜底

恢复很快,防复发要靠非协作超时。 两步:

  1. 结束僵尸 runner(最快恢复手段)。 据讨论中实测,杀掉进程后约 2 秒即自愈,工具调用立刻返回,不需要重启 DSH:
powershell
Stop-Process -Id $runner -Force
#   → 预期:卡住的工具调用在约 2 秒内以失败返回,会话恢复可用
  1. 装非协作的 deadline 兜底。 社区插件 @argszero/cordis-plugin-tool-deadline-guard 挂在 tools/pre-execute 上,用一个与底层 promise 无关的墙钟上界强制结束调用——这正是「协作式超时」缺的那层:
bash
npm i @argszero/cordis-plugin-tool-deadline-guard

在 profile 的 patch 层挂载:

yaml
- insert:
    - id: tool-deadline-guard
      name: '@argszero/cordis-plugin-tool-deadline-guard'
  1. 验证: 重开后跑一条会超时的命令(例如 pwsh -Command "Start-Sleep 600"),预期在插件设定的墙钟上界内收到超时错误,而不是无限转圈。

边界提醒:本族与「沙箱路径挂起」是两回事——后者只在 workspace-write 下出现、切 danger-full-access 就正常;而僵尸 runner 在没有沙箱的场景里也能复现。所以先看「切完全权限是否恢复」这一条,能立刻分流(#9515)。

DeepSeek Harness 沙箱拒绝不提示:拒绝信息打在 stdout,被分类器漏掉了

一句话结论:沙箱确实拒绝了这次写入,但「拒绝」这件事只在一处被识别——stderr 上的签名匹配;当拒绝码出现在 stdout 时,标记与升级提示都不会渲染,模型看到的是「命令失败了」而不是「你被策略拦住了」。 后果比看起来严重:模型不知道这是策略边界,会改成别的写法绕过(讨论里举的例子是覆盖 XDG_CONFIG_HOME 把配置写到工作区内),于是「安全控制被默默消解」;同族里还有一类是失败被静默丢弃掉(管道 stdio 把输出丢成 0 字节、CIM 返回静默默认值等),见 DeepSeek Harness 沙箱命令全失败(#9497)。

DSH插件 机制:三个条件同时不满足,标记就不会出现

据社区核对源码,控制标记渲染的链路有三道门:

  1. 分类只认 stderr:classifyDenial / matchesSignature 只对命令的 stderr 匹配 DENIAL_SIGNATURES;
  2. 有些工具把拒绝码打在 stdout:例如某些运行时的 telemetry 把 EACCES 写进 stdout,stderr 只剩 launcher 的信息行,于是签名一条都匹配不上;
  3. 渲染层还有额外门:标记与提示由 render 层产生,且 gated on result.sandbox?.denied;升级提示还需要 composition 提供非空的 escalationModes。而 DENIAL_SIGNATURES 是模块私有常量、未导出,插件无法直接复用它来补判(#9497)。

DeepSeek Harness 三步判定:这次失败到底是不是沙箱拒绝

  1. 看路径是不是真的在工作区外。 检查失败命令里被写的目标路径,确认它不在当前工作区之内(工作区路径在 设置 → 工作区 里可见)。
  2. 看退出码与结果文本。 预期是「退出码非 0」+「结果里没有 [sandbox: file access denied …] 标记」+「没有升级提示」。「确实被拒、但没有任何 sandbox 标记」就是本族的指纹(#9497)。
  3. 手动验证拒绝发生在哪条流上。 在受限会话里执行:
powershell
pwsh -Command "echo probe > $env:USERPROFILE\probe-denied.txt"
#   预期:文件未创建、命令非 0 退出
#   把同样内容改成写工作区内路径,预期成功 —— 这条对照排除「命令写错」

DSH插件 处置:在 post-execute 上补标记

核心思路是不去改沙箱,而是在结果出来之后把 sandbox.denied 显式置真,让上层自己渲染标准标记。 三步:

  1. 装社区插件 @argszero/dsh-stdout-denial-guard(它在 tools/post-execute 上设置 sandbox.denied = true,hint 通过 additionalContexts 下发):
bash
npm i @argszero/dsh-stdout-denial-guard
  1. 在 profile 的 patch 层挂载:
yaml
- insert:
    - id: stdout-denial-guard
      name: '@argszero/dsh-stdout-denial-guard'
  1. 验证:重开会话后再跑第 2 步那条被拒命令,预期结果里出现 [sandbox: file access denied …] 标记,且模型收到一句「这是策略拒绝、可申请升级」的提示而不再改道绕过。

一定要知道的边界:该插件只覆盖模型侧——它让模型知道「被拒了」,但不改变沙箱实际行为。而且它对 shell 命令行里拼出来的路径只做尽力识别,不能当作安全边界(#9497)。

DSH插件 排查注意事项

两条症状看起来无关,但都属于「沙箱行为没有被正确表达给上层」——一条是没超时,一条是没标记。判断顺序建议先分流、再深挖。 七条要点:

  1. 先做一次分流:切 danger-full-access 后问题消失 → 偏向沙箱路径;不消失 → 偏向 runner/进程层(#9515)。
  2. 卡死时不要急着重启 DSH:先按三步判定确认是僵尸 runner,杀那一个进程即可,重启会中断所有会话(#9515)。
  3. 「runner 存活 ≥3 分钟 + CPU < 1 秒 + 无子进程」直接判定僵尸,不必等更久(#9515)。
  4. 协作式超时不可靠:凡是「超时了也不返回」的现象,优先怀疑底层 promise 永不 settle,而不是超时值设得不够大(#9515)。
  5. 2>&1 合并流救不了拒绝标记:拒绝码本来就不在 stderr 上,合并之后仍不是匹配目标(#9497)。
  6. 看到模型「自作聪明换写法」时优先查标记:模型绕过沙箱往往是它没被告知被拒,而不是它在对抗策略(#9497)。
  7. 修复状态如实:两条都未修——超时补丁与拒绝分类都还是社区插件形态,插件层也只能「兜底」(补 deadline、补标记),不能替核心修掉根因。

排查这类跨进程问题时,除了看进程状态,也值得把安装/卸载类操作集中到一个地方,减少变量。如果你希望插件的装卸、更新与系统日志都在一个面板里,与其在各处手敲命令,不如用 DSH Plugin Hub——它把插件装卸、更新确认、诊断与日志收在一起,能帮你先把「插件层」这个变量排掉。

系统日志

来源:Discussion #9515、Discussion #9497。

常见问题

DeepSeek Harness 里工具调用一直显示「执行中」不返回,等了两小时也没结果,重启就恢复了,是什么原因?

DeepSeek Harness 里工具调用一直「执行中」不返回、等两小时也没结果,大概率是沙箱子进程的启动帧丢失。据社区核对源码,subprocess-local 的 runner 在等待 IPC 的 start 消息时没有设置任何超时,一旦这条帧在管道里丢了,父进程与 runner 会互相永久等待;而上层的 tools/execute 超时包装是协作式的(只有在工具自己声明 timeoutMs 时才武装),所以没有任何一层会把它掐掉。最快的恢复是结束那个僵尸 runner 进程(据实测约 2 秒自愈),无需重启整个 DSH。

DeepSeek Harness 的 workspace-write 下命令因写工作区外路径被拒,但结果里没有 [sandbox: file access denied] 标记,模型就自己换写法绕过,怎么让它能被识别?

DeepSeek Harness 的 workspace-write 下命令被拒却不显示 [sandbox: file access denied] 标记,是因为拒绝签名只对 stderr 做匹配,而某些运行时会把这个 EACCES 打在 stdout,stderr 只剩 launcher 的信息行,于是分类器什么都没匹配到,标记与升级提示都不会渲染。可用做法是在 tools/post-execute 钩子上把 sandbox.denied 显式置为 true,让上层自己渲染标准标记,并通过 additionalContexts 补一句升级提示;社区已有 @argszero/dsh-stdout-denial-guard 做这件事,但它只覆盖模型侧、不改变沙箱行为。

怎么快速判断一次卡死是 DeepSeek Harness 的「runner 挂起」,而非模型在思考或命令真的慢?

判断 DeepSeek Harness 卡死是不是僵尸 runner,看三件事:一是子进程是否已存在(runner 已 fork、IPC 已连),二是它是否长时间没有任何 CPU 时间与子进程,三是会话里有没有出现对应工具的 tool/result 事件。据讨论中给出的判据,runner 存活超过 3 分钟、自身 CPU 时间小于 1 秒、且没有任何子进程,就可以判定为僵尸 runner;此时工具调用一定不会再返回,直接结束该进程即可恢复。

把 DeepSeek Harness 命令的 2>&1 合并流之后,沙箱拒绝提示会出现吗?

DeepSeek Harness 里把命令用 2>&1 合并流后,沙箱拒绝提示同样不会出现,因为拒绝码原本是打在 stdout 上的,合并之后它仍然不是 stderr 路径上的匹配目标,分类器依旧匹配不到 DENIAL_SIGNATURES。判据很简单:如果命令确实被沙箱拒绝(退出码非 0 且路径确实在工作区外),但结果里没有任何 sandbox 标记,那就属于本节这一族,得靠 post-execute 补标记来解决。

相关术语

runner(沙箱子进程)
runner 是 DeepSeek Harness 在 Windows ACL 沙箱下用来承载受限命令的辅助子进程,由 subprocess-local 以受限令牌启动,父进程通过 IPC 向它下发启动与执行指令。它的启动握手一旦丢失就无人超时,是本族「工具调用永久挂起」的直接原因。— https://github.com/deepseek-ai/deepseek-harness/discussions/9515
DENIAL_SIGNATURES
DENIAL_SIGNATURES 是沙箱用来识别「这次失败确实是被沙箱拒绝」的签名集合,命中后才会在结果上打 sandbox.denied 标记并附带升级提示。它是模块私有常量,只对 stderr 匹配,因此拒绝信息出现在 stdout 时不会被识别。— https://github.com/deepseek-ai/deepseek-harness/discussions/9497
协作式超时(cooperative timeout)
协作式超时指超时实现依赖被调用方主动检查取消信号,而不是由外层强制 race 掉 promise。DeepSeek Harness 的 tools/execute 超时包装属于这种:它只 abort exec.signal 后继续 await next(),因此当底层 promise 永不 settle 时,超时形同不存在。— https://github.com/deepseek-ai/deepseek-harness/discussions/9515
tools/post-execute 钩子
tools/post-execute 是 DeepSeek Harness 在工具执行结束后触发的钩子,插件可在这一步改写结果(例如补上 sandbox.denied 标记或追加 additionalContexts)。它是目前在不改核心的情况下修复「拒绝未被分类」的主要落点。— https://github.com/deepseek-ai/deepseek-harness/discussions/9497

来源