DeepSeek Harness Windows 子进程报 0xC0000142:受限令牌与宿主定位
症状是「进程根本没起来」:Windows 上 DSH 的 shell 工具(pwsh / cmd)全部失败,退出码十进制 3221225794(0xC0000142 / STATUS_DLL_INIT_FAILED),stdout 与 stderr 全空,偶尔还弹出「应用程序无法正常启动(0xc0000142)」的系统级模态对话框。它有两个互不相同、却给出同一个退出码的成因:一是受限令牌下用 CREATE_NO_WINDOW(也就是 windowsHide: true)创建子进程**,边界精确到这一个标志——换成 STARTF_USESHOWWINDOW | SW_HIDE 就正常;二是桌面端那双击启动的 GUI 宿主叠加完整性降级失败,令牌停在 Medium 却背着写限制。它不写进 DSH 会话日志(工具返回里只会看到退出码 1),所以必须去 Windows 事件日志与「换宿主」的单变量实验里找证据。**
先分诊:0xC0000142 是「进程没起来」,先分清是哪一条链
结论先行:0xC0000142 只说明「子进程在 DLL 初始化阶段死了」,它本身不区分成因。 上报里出现过两种完全不同的机制,却给出同一个退出码;分诊的价值就在于先把它们分开,否则你会在错误的层反复试探。
第一条判据是环境:是命令行 dsh web 宿主失败,还是双击启动的桌面端失败?有报告明确指出,同机、同版本、同沙箱配置下,命令行宿主里沙箱边界完全正常——工作区外写入被拒(UnauthorizedAccessException)、工作区内写入通过、whoami.exe 被拒(受限令牌在场所记录的现象)。这说明「沙箱没坏」,孤立的是宿主环境。
第二条判据是能不能拿到证据。这里必须先记住一件事:这个失败不会进入 DSH 会话日志。 有报告把本机四个会话的存档逐帧解压后检索,工具返回里出现过的退出码只有 1,0xC0000142 / 3221225794 出现 0 次——错误发生在子进程创建阶段,上层只拿到「无输出 / 退出码 1」,真实退出码在格式化时就被丢弃了。权威来源因此是 Windows 系统事件日志(来源 Application Popup,事件 ID 26),但它也不能当可靠信号:同样的失败创建,有些记了事件、有些没记;真正可靠的判据是退出码(#6930)。
第三条判据是单变量。先用一条最小的探针把环境变量收窄到一个:
const { spawnSync } = require("child_process");
// 失败
spawnSync("cmd.exe", ["/c", "exit"], { stdio: "ignore", windowsHide: true });
// => status 3221225794 (0xC0000142)
// 成功
spawnSync("cmd.exe", ["/c", "exit"], { stdio: "ignore", windowsHide: false });
// => status 0
把 stdio 固定成 ignore、detached 不设置,唯一的变量就只剩 windowsHide 这一个布尔值。这也是后面两节所有结论的地基(#6930)。
成因一:受限令牌下用 CREATE_NO_WINDOW 创建子进程
根因:DSH 在 Windows 上无条件以 windowsHide: true 启动托管子进程;Node 的 windowsHide: true 由 CREATE_NO_WINDOW 实现;而受限令牌下带控制台隔离标志创建子进程,会在 DLL 初始化期间死亡——这正是项目自己的沙箱文档记录过的限制。
代码事实(packages/subprocess/subprocess-local/src/spawn.ts):
windowsHide: platform === 'win32'
win32 上无条件为 true,该层没有任何环境变量或配置开关。辅助进程同样如此:spawn.ts:120(taskkill 清理)、windows-inspector.ts:155、windows-job.ts:141(后者是 alpha.2 新增,连沙箱 runner 的 spawn 也加了 windowsHide: true)(#6930)。
项目文档早就写下了这个失败模式(packages/sandbox/sandbox-windows-acl/README.zh.md,「控制台隔离不可用」一节):
以
CREATE_NO_WINDOW/CREATE_NEW_CONSOLE创建的子进程在 DLL 初始化期间以STATUS_DLL_INIT_FAILED(0xC0000142)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。
上报者用一组单变量矩阵把「受限令牌 + CREATE_NO_WINDOW」钉成了充分条件(同一会话内完成,whoami 的退出码作为令牌判据):
| 文件策略 | whoami | cmd.exe /c exit hide=true | node -e 0 hide=true | hide=false | Application Popup |
|---|---|---|---|---|---|
workspace-write(第 1 轮) | Access denied(exit 1) | 3221225794 | 3221225794 | 0 / 0 | 无 |
danger-full-access(一次性升级) | exit 0 | 0 | 0 | 0 / 0 | 无 |
workspace-write(第 2 轮,自动回落) | Access denied(exit 1) | 3221225794 | 3221225794 | 0 / 0 | 无 |
三点结论直接来自这张表:普通令牌下 hide=true 全部返回 0;受限令牌下同样的行确定性地返回 0xC0000142;windowsHide=false 在两种令牌下都成功。 上报者据此更正了自己最初「这是间歇性缺陷」的判断——那是两次测量落在不同文件策略下造成的错觉,在相同条件下它是确定性的(#6930)。
还有一条更细的边界,值得抄下来:真正致病的不是「隐藏窗口」这个意图,而是 CREATE_NO_WINDOW 这一个标志。
| 子进程创建方式 | 普通令牌 | 受限令牌 |
|---|---|---|
spawnSync(..., { windowsHide: true })(= CREATE_NO_WINDOW) | 0 | 3221225794 |
spawnSync(..., { windowsHide: false }) | 0 | 0 |
.NET CreateNoWindow=true(= CREATE_NO_WINDOW) | n/a | 0xC0000142 |
.NET CreateNoWindow=false + WindowStyle=Hidden(= STARTF_USESHOWWINDOW | SW_HIDE) | 推断 0 | 0 |
.NET CreateNoWindow=false + WindowStyle=Normal | 0 | 0 |
所以「用 SW_HIDE 隐藏窗口」在受限令牌下是安全的。这也解释了上游方向的正确性:0.1.6-alpha.2 的提交 f8b1309fe5("hide Windows shell console windows at creation")给 Node Job runner 的启动加了 windowsHide: true,但原生进程创建刻意不加 CREATE_NO_WINDOW,而是改用 STARTF_USESHOWWINDOW | SW_HIDE;win32-process/src/process.ts 里的注释写得很明白:「Preserve console inheritance: CREATE_NO_WINDOW can fail restricted-token DLL initialization」,该提交的说明也把「remove consoles from every process」列为被否决的备选(#6930)。
成因二:桌面端 GUI 宿主与完整性降级失败
第二条链出现在双击启动的桌面端上,机制不同却给出同一个退出码。两个独立发现合起来解释它:① 宿主二进制本身是决定性变量——GUI 子系统进程不持有控制台;② 新版本引入的「把受限令牌完整性级别降到 Low」这一步在默认账户上会失败(缺 SeRelabelPrivilege),令牌于是停在 Medium 却带着 WRITE_RESTRICTED。
发现一:换宿主就换结果。 上报者做了决定性 A/B——同一台机器、同一个官方 ACL runner、同一个子进程、同一组 argv,只改变承载 runner 的宿主二进制:
| 宿主 | PE Subsystem | 结果 |
|---|---|---|
node.exe(DSH runtime 自带) | 3 = Console | 子进程执行成功并写出文件;区内可写、区外 Access denied |
DeepSeek Harness.exe(ELECTRON_RUN_AS_NODE=1) | 2 = GUI | 子进程从未执行:无输出、退出码 0(静默失败) |
并且直接把「宿主是否持有控制台」测了出来(从同一个带控制台的父进程、同样的 stdio 启动两个宿主,各自用 koffi 调 kernel32!GetConsoleWindow()):
| 宿主 | Node | GetConsoleWindow() | 控制台代码页 |
|---|---|---|---|
node.exe | 24.21.0 | 330662(持有控制台) | 936 / 65001 |
DeepSeek Harness.exe | 24.18.1 | NULL(不持有控制台) | 0 / 0 |
这里有一个逻辑陷阱值得单独记住:「从带控制台的终端启动桌面端 exe 仍然失败」不能用来反驳控制台假说——Windows 上 GUI 子系统进程不会因为父进程持有控制台就附着上去,实测它从带控制台的父进程启动后依然 GetConsoleWindow() == NULL。要隔离这个变量,正确做法是固定其它一切、只替换宿主二进制,也就是上面那张表(#6822)。
发现二:完整性降级失败,且没有 fail-closed。 另一位上报者对比了本地源码检出与装好的 app.asar,发现一个只存在于发布二进制、源码树里完全没有的函数:
// 存在于装好的 0.2.0-rc.2;源码 token.ts 里完全没有
function restrictTokenIntegrity(api, token, lowLabelSidPtr) {
...
api.setTokenInformation(token, 25, info, info.length)
// "Lower the restricted token's integrity level to Low (S-1-16-4096) ...
// a token left at Medium would ignore them"
}
TokenIntegrityLevel 在那个源码检出里出现 0 次、在发布包里出现 1 次——因为检出太旧(0.1.2-alpha.4),装不下这条关键路径。而这一步在本机是失败的:降低令牌完整性级别需要 SeRelabelPrivilege,实测账户情况是 IsInRole(Administrator) = True、完整性级别 High、SeDebugPrivilege 存在,但 SeRelabelPrivilege 完全不存在(该权限默认不授予)。于是:
CreateRestrictedToken(..., WRITE_RESTRICTED)成功 → 令牌已带写限制;SetTokenInformation(TokenIntegrityLevel, Low)失败,返回Win32 998 (ERROR_NOACCESS);- 令牌于是停在 Medium 完整性,却已经背着
WRITE_RESTRICTED; - 本该让二者一致的那次降级从未发生;
- 子进程在 DLL 初始化阶段死亡 →
0xC0000142,因为死在执行命令之前,stdout/stderr 全空。
这同时解释了为什么 cmd.exe 和 pwsh 一样受害——它走的是同一条受限路径,与 shell 无关(#6822)。
一个可复现的判定性实验(同一会话、不重启 DSH、不改任何配置,只切换文件策略):
| 顺序 | 策略 | 命令 | 结果 |
|---|---|---|---|
| 1 | workspace-write | Write-Output test | ❌ 0xC0000142,零输出 |
| 2 | danger-full-access | Write-Output test | ✅ 打印出 test |
| 3 | 切回 workspace-write | Write-Output test | ❌ 0xC0000142,零输出 |
唯一的变量是文件沙箱策略,workspace-write 下每次必挂、danger-full-access 下每次必过。这同时排除了三类替代解释:「排查过程把 DSH 弄坏了」(切回后又复现)、「要重启才恢复」(三轮之间从未重启)、「会话状态变脏」(切换后立刻复现,无累积效应)(#6822)。
一个值得指出的自相矛盾:同文件里的邻居调用 SetTokenInformation(..., TokenDefaultDacl, ...) 是 fail-closed 的——失败就抛 throwWin32;而 restrictTokenIntegrity() 自己的注释声称 "fails closed before any child is spawned",实际却没有拦住那次 spawn。这个矛盾看起来正是缺陷所在。
要说明的是,这两条发现可能是同时存在的两个机制:relabel 失败会让沙箱边界与文档不符,而宿主无控制台决定低完整性子进程能否起来。有回复明确指出,SeRelabelPrivilege 缺失无法单独解释换宿主的 A/B——同账户、同权限、同 runner,只换宿主就得到「成功 / 静默失败」两种结果(#6822)。
修复与绕行
可做的事分三层:库内脚本换成 SW_HIDE;需要交互式命令时按文档化路径一次性升级到 danger-full-access;桌面端则用保留隔离的 runnerCommand 绕行,直到上游修好。
第一层:把 CREATE_NO_WINDOW 换成 SW_HIDE。 这是成因一的正确修法,且不牺牲「隐藏窗口」的效果。在 .NET 里对应的是 ProcessStartInfo 使用 UseShellExecute=false、CreateNoWindow=false、WindowStyle='Hidden'。DSH 自己那条工具路径从 0.1.6-alpha.2 起已经这么做了——上报者在受限令牌会话里连续跑了 10+ 次 pwsh 调用,没有一次 0xC0000142、也没有弹窗。
第二层:需要真实控制台交互时,用一次性升级。 项目里文档化的升级路径是 workspace-write → danger-full-access。实测切换后立刻成功、切回后立刻复现。但要清楚这是一次关掉沙箱的操作,不该当日常方案;文档也说明「基于管道的 stdio 重定向不受影响」,所以大多数普通命令继续用默认模式即可。
第三层:桌面端用保留隔离的 runnerCommand 绕行。 思路是指定「控制台子系统的 node.exe + 一个 bwrap→ACL 转接脚本」:
- id: sandbox
name: "@deepseek-ai/dsh-sandbox-local"
config:
runnerCommand:
- "<console-subsystem node.exe>"
- "<bwrap-to-acl>.mjs"
runnerFailureSignatures:
- "windows-acl-run:"
转接器做的事:seam 给自定义 runner 追加的参数是 bwrap 方言(--ro-bind / / --dev /dev --unshare-pid --proc /proc --die-with-parent,workspace-write 时再加 --tmpfs /tmp --bind <root> <root>),把它翻译成 ACL runner 的 --workspace/--temp/--mode,然后在原进程内改写 process.argv 后 import 官方 runner——不复制任何隔离逻辑,fd、退出码语义与 windows-acl-run: 失败签名全部沿用官方实现。实测(workspace-write):命令正常执行,TEMP 被重写到 …\Local\Temp\dsh-xxxx(说明 runner 确实在包着跑),工作区内写入成功,写入 C:\Users\… 与 ~/.dsh 均 Access denied(隔离保留)。注意 runnerCommand 按文档会跳过内置 runner 选择与探针,属运维侧断言,非官方推荐配置(#6822)。
这不是 0.2.0 引入的。 对比 dsh-base 的 cordis.patch.yml:0.1.5-rc.1、0.1.7-rc.2、0.2.0-rc.2 三个版本都挂载 pwsh-sandbox,且 sandbox-policy.mode 默认都是 workspace-write。所以这是新宿主暴露出来的老限制,而非版本回归。
排查与注意事项
0xC0000142不等于「命令失败」,等于「进程没起来」。 全空输出就是它的招牌。看到这个组合,先怀疑进程创建路径,而不是命令本身。- 别只在 DSH 会话日志里找。 真实退出码在格式化时就被丢掉了,日志里只会有退出码 1。要看 Windows 事件日志(来源
Application Popup,事件 ID 26),但它不总是记录——可靠判据仍是退出码。查事件日志时记得按 provider 过滤并读EventData,否则会混进一堆无关记录。 - 「从终端启动桌面端 exe」不是有效的对照实验。 GUI 子系统进程不会附着到父进程的控制台,所以它照样失败,却容易被误读成「控制台假说被推翻」。要隔离变量,就固定 runner、子进程与 argv,只替换宿主二进制(
node.exevsDeepSeek Harness.exe)。 - 两个容易踩的 PowerShell 陷阱(在受限令牌下都实测过):①
Start-Process -WindowStyle Hidden同样会失败——PowerShell 内部把它转成了CreateNoWindow,三个目标全部0xC0000142;要隐藏窗口请用 .NET 的ProcessStartInfo+UseShellExecute=false+CreateNoWindow=false+WindowStyle='Hidden'。② 通过 PowerShell 管道读取原生命令的输出($x = & native ...、... 2>&1、-RedirectStandardOutput)同样会让子进程在受限令牌下死掉,而且可能让你既拿不到输出、也拿不到退出码——看起来就像「命令成功但没打印任何东西」。要验证副作用,让子进程写文件再读文件。 - 文档引用请引文字、别引行号。 那条「控制台隔离不可用」的说明在
sandbox-windows-acl/README.zh.md里是:117(0.1.5-rc.1 / 0.1.6-alpha.2),到 0.2.0-rc.2 变成了:129。 - 仍有残留:
runner-launch-*.js里两处taskkill清理路径在 0.2.0-rc.2 上依旧传windowsHide: true,于是受限令牌下那个taskkill会以0xC0000142死亡——工具调用刚结束时能看到一条taskkill.exe的弹窗事件。代码容忍非零状态,所以无害但嘈杂;把这两处也走同一个SW_HIDE助手就能清掉最后一点痕迹。
来源:
- #6822 — 双击启动的桌面端下所有 shell 工具以 STATUS_DLL_INIT_FAILED (0xC0000142) 失败,命令行 dsh web 宿主正常
- #6930 — 托管子进程以 windowsHide: true 启动时失败 (0xC0000142) 并弹出模态错误对话框
这类问题的教训很明确:当失败信息只剩下一个退出码时,你需要的是一套能自己造证据的手段。 如果你希望环境侧的变化(插件、版本、配置、日志)更容易被集中查看,可以装一个 DSH Plugin Hub——DeepSeek Harness 桌面端内置的官方插件市场,用于浏览、安装、卸载与更新插件,其「系统日志」页会把安装、卸载、设置变更与诊断的操作轨迹集中列出,也支持直接定位日志文件:

排查宿主级故障时,把环境变动和系统日志放在一起看,比事后从空输出里倒推要省事得多。
常见问题
3221225794 十六进制是 0xC0000142,即 STATUS_DLL_INIT_FAILED——子进程在 DLL 初始化阶段就死了,还没跑到执行命令、更没来得及产生任何输出。所以「全空输出 + 这个退出码」不是命令本身报错,而是进程根本没起来。这也是它难以自诊断的原因:你能拿到的唯一信息就是这个码。
因为它不进入 DSH 会话日志。有报告逐帧解压并检索了四个会话存档,工具返回里出现过的退出码**只有 1**,0xC0000142 出现 0 次——错误发生在子进程创建阶段,上层只拿到「无输出 / 退出码 1」,真实退出码在格式化时就被丢掉了。权威来源是 Windows 系统事件日志(来源 Application Popup,事件 ID 26),但注意它并不总是记录,可靠的判据仍是退出码。
Node 的 windowsHide: true 是用 CREATE_NO_WINDOW 实现的,而 CREATE_NO_WINDOW 在受限令牌(沙箱)下会让子进程在 DLL 初始化期间死亡。实测边界非常干净:失败的是 CREATE_NO_WINDOW 这一个标志,而不是「隐藏窗口」这个意图——用 STARTF_USESHOWWINDOW | SW_HIDE 在受限令牌下完全正常。所以修复方向是「换成 SW_HIDE」,而不是「放弃隐藏」。
因为宿主的可执行文件本身就是一个可独立证伪的变量。有报告做了决定性 A/B:同一个官方 runner、同一个子进程、同一组 argv,只换宿主二进制——node.exe(PE Subsystem 3,控制台程序)能正常执行子进程;DeepSeek Harness.exe(Subsystem 2,GUI)则子进程从未执行、无输出。直接用 GetConsoleWindow() 测量也印证:node.exe 返回非零句柄,桌面端二进制返回 NULL。注意「从带控制台的终端启动桌面端 exe 仍然失败」**不能**反驳这一点,因为 Windows 上 GUI 子系统进程不会附着到父进程的控制台。
分情况。① 如果你是在受限令牌下、由自己写的脚本或第三方库触发(windowsHide: true),把创建方式换成 STARTF_USESHOWWINDOW | SW_HIDE:.NET 上用 ProcessStartInfo 且 UseShellExecute=false、CreateNoWindow=false、WindowStyle='Hidden'。② 如果只是需要交互式命令,按文档化的一次性升级路径跑在 danger-full-access 下(实测切换后立刻成功,切回 workspace-write 立刻复现)。③ 桌面端在 0.2.0-rc.2 上仍受影响,可先用保留隔离的 runnerCommand 绕行。DSH 自己那条工具路径在 0.1.6-alpha.2 起已改用 SW_HIDE(pwsh 连续 10+ 次调用无失败),但库内脚本与 taskkill 清理路径的残留仍在。
相关术语
- STATUS_DLL_INIT_FAILED(0xC0000142)
- Windows 的进程错误状态,十进制 `3221225794`。含义是子进程在 DLL 初始化阶段就终止了,因此**执行命令之前**就死掉——结果是退出码为该值、stdout 与 stderr 全空。它是一条「进程根本没起来」的信号,而不是「命令运行失败」;把它误当成普通命令失败,会一直在错误的方向排查。— https://github.com/deepseek-ai/deepseek-harness/discussions/6930
- CREATE_NO_WINDOW 与 STARTF_USESHOWWINDOW | SW_HIDE
- 两种「让子进程不显示窗口」的手段,语义与副作用都不同。`CREATE_NO_WINDOW` 让子进程**完全没有控制台**,在受限令牌下会导致 DLL 初始化失败;`STARTF_USESHOWWINDOW | SW_HIDE` 只是把首窗口的显示状态设为隐藏,子进程仍继承控制台,在受限令牌下正常。Node 的 `windowsHide: true` 走的是前者——这就是「同样是隐藏,一个炸一个不炸」的原因。— https://github.com/deepseek-ai/deepseek-harness/discussions/6930
- 受限令牌(restricted token)与 WRITE_RESTRICTED
- Windows ACL 沙箱通过 `CreateRestrictedToken` 构造的降权令牌,带写限制(`WRITE_RESTRICTED`),用于保证「工作区外写入被拒、工作区内通过」。在桌面端那条链里,它还可能叠加一次「把令牌完整性级别降到 Low」的 `SetTokenInformation(TokenIntegrityLevel, …)`;这一步需要 `SeRelabelPrivilege`,而该权限默认不授予普通账户,失败后令牌会停在 Medium 却仍背着写限制。— https://github.com/deepseek-ai/deepseek-harness/discussions/6822
- GetConsoleWindow()
- `kernel32!GetConsoleWindow()` 返回调用进程所持有的控制台窗口句柄:控制台子系统进程返回非零句柄,GUI 子系统进程(如 Electron 二进制)返回 `NULL`。它是判断「宿主是否持有控制台」的直接手段——关键前提是:在 Windows 上,即使从带控制台的父进程启动,GUI 子系统进程也不会自动附着到那个控制台。— https://github.com/deepseek-ai/deepseek-harness/discussions/6822
来源
- #6822 — [Windows][Desktop] 双击启动的桌面端下所有 shell 工具以 STATUS_DLL_INIT_FAILED (0xC0000142) 失败,命令行 dsh web 宿主正常· deepseek-ai(GitHub Discussions)
- #6930 — [Windows] 托管子进程以 windowsHide: true 启动时失败 (0xC0000142) 并弹出模态错误对话框· deepseek-ai(GitHub Discussions)