DeepSeek Harness:打开 VS Code 报 502,DSH plugin 泄漏变量
在 Windows 桌面端点击会话头右侧「在本地打开」(槽位 conversation.session.header.utilities,registrant open-in-app)并选择 VS Code 后,不会出现工作区窗口:启动进程约 40 ms 就自行退出,界面提示「打开失败,请重试」,宿主路由 POST /open-in-app/open {"app":"vscode","path":"<workspace>"} 返回 502 {"code":"launch-failed","message":"failed to launch vscode"}。 根因是桌面宿主进程自身带着 ELECTRON_RUN_AS_NODE=1(桌面壳以 Electron 的 Node 模式派生核心宿主),而 GUI 启动器 launchDetachedApp 把它原样继承给了 VS Code(#8049)。于是 Code.exe 不去打开工作区,而是以 Node 模式去 require('H:\Harness'),报 Cannot find module、退出码 1,被启动器判定为失败。最容易踩的坑是修法本身:在宿主里全局 delete process.env.ELECTRON_RUN_AS_NODE 会顺手打断经 Windows Job runner 的全部命令执行——本文会把这条反例连同三种正确修法一并讲清。该问题在 0.2.0-rc.2 上仍被独立复现。
先分诊:先读懂 502 与 200 各自代表什么
一句话:这套启动器把「子进程退出 0」或「看护窗口到期时仍在运行」都判为成功,所以 200 不代表 GUI 真的打开了;而本例是明确收到 502,属于「看护窗口内非零退出」这一支。
launchDetachedApp 有两个 resolve 条件:
① 子进程 exit 0
② 看护窗口 launchWatchMs 到期时,子进程仍在运行
由此可得两条推论,能省掉很多误判:
- 200 ≠ 窗口已出现。 它只代表「判定为已启动」。GUI 是否真的打开需另行观察。
- 502 = 看护窗口内明确失败。 本例的 40 ms 退出就属于这一支,不是超时、不是排队、不是权限弹窗。
分诊时可以按「耗时」先粗筛:
| 现象 | 耗时 | 含义 |
|---|---|---|
进程瞬间退出 + 502 launch-failed | ~40–95 ms | 本例:目标应用以错误模式启动后立即结束 |
| 迟迟无响应后 502 | 接近看护窗口 | 更可能是目标应用启动慢或弹了对话框 |
| 200 但没窗口 | 正常返回 | 判定为已启动,但 GUI 未真正打开(另一类问题) |
复现与根因:三步最小复现与五步根因链
三步最小复现:不依赖 DSH 也能看到
报告人最有力的一手,是把问题从 DSH 里剥出来,在纯 Windows 上复现——这直接排除了「DSH 特有逻辑」的干扰。
1. 最小复现(纯 Windows)
在带该变量的环境里执行:
set ELECTRON_RUN_AS_NODE=1
"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe" "H:\Harness"
echo %ERRORLEVEL%
:: → 1,耗时约 40 ms
stderr:
Error: Cannot find module 'H:\Harness'
at Module._resolveFilename (node:internal/modules/cjs/loader:1524:15)
...
at node:electron/js2c/node_init:2:18159
注意栈里是 node:electron/js2c/node_init——它在跑 Electron 的 Node 初始化路径,而不是 VS Code 的 GUI 初始化路径。 参数 H:\Harness 被当成了模块名。
2. 环境有/无对照
用归档脚本 env-toggle-proof.mjs,目标默认取一个新建的空目录(排除目录内可执行入口的干扰),两次使用同一目标,只改变环境变量:
node env-toggle-proof.mjs "%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe"
# ELECTRON_RUN_AS_NODE=1 -> exit 1 (FAILED) ← Node 模式
# ELECTRON_RUN_AS_NODE unset -> exit 0 / 到期仍在运行 ← 启动判定通过
3. 变量的作用(交叉印证)
"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe" --version
:: → v24.18.1 ← Node 的版本,不是 VS Code 的
"%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd" --version
:: → 1.137.0 / 645f29cc3176500b4b5762ba887cf2a7f0ffdf2c / x64
同一台机器、同一个产品,Code.exe 报的是 Node 版本、code.cmd 报的是 VS Code 版本——这就是「变量把 Code.exe 变成了 Node」最直白的证据。
4. UI 复现路径
桌面端打开任一工作区会话 → 点会话头右侧「在本地打开」→ 菜单选 VS Code。
根因链:五步,一步都不能少
一句话:宿主确实带着变量 → 启动器原样继承 → 净化函数不管它 → 非零退出被判定为失败 → 而 VS Code 恰好被目录表以「直接启动 exe」的方式解析,于是完整命中。
第 1 步:宿主进程自身带着该变量(实测,非推断)
桌面壳以 Electron 的 Node 模式派生核心宿主:asar 根 lib/main.js 的 desktopNodeEnvironment(executable, bin, environment) 返回
{ ...environment, ELECTRON_RUN_AS_NODE: "1", ... } // 注释即 "Environment for a Node-mode child process"
在宿主进程(监听 127.0.0.1:19387 的那个进程,本机 PID 70924)内部读到的实测值是:
{ "pid": 70924, "action": "removed", "before": "1", "after": null }
(这条记录只证明当时的状态,不是实时证明。第二位复核者在其桌面版会话里由工具子进程读到同样含 ELECTRON_RUN_AS_NODE=1,并把源头指向 apps/desktop/src/node-environment.ts 的 desktopNodeEnvironment。)
第 2 步:GUI 启动器原样继承
@deepseek-ai/dsh-host-open-in-app 的运行时入口是 lib/index.js(package.json 的 exports["."].default);launchDetachedApp 内联在该文件 第 348 行,第 8 行导入 scrubbedParentEnv:
const env = { ...scrubbedParentEnv(), ...options.env };
scrubbedParentEnv() 的结果被整份展开,而它里面仍然带着 ELECTRON_RUN_AS_NODE。
第 3 步:净化函数不剔除 Electron 模式变量
@deepseek-ai/dsh-subprocess/lib/index.js:
// 第 32 行
const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i;
// 第 52 行:遍历时另丢弃 DSH_* 前缀,并处理代理变量
ELECTRON_RUN_AS_NODE 既不含敏感关键词、也不是 DSH_* 前缀,因此毫发无损地穿过净化层。
第 4 步:启动失败被判定为错误
同一函数中 child.on('exit', ...) 对看护窗口内的非 0 退出 reject;open 路由据此:
sendJson(res, 502, { code: "launch-failed", ... })
第 5 步:为什么偏偏是 VS Code 完整命中
VS Code 在本机由目录表解析为:
spec(appPaths('Code.exe'), installRecord('Microsoft Visual Studio Code', 'Code.exe'), file([...]))
即直接启动 exe(而不是经过 code.cmd 这类会自己处理环境的包装器)。本机 HKCU\...\App Paths\Code.exe 存在、Code.exe 存在——解析本身是健康的,问题不在解析,而在启动时继承的那个变量。
修复边界与三条修法:先清后合 / runner 声明 / 四条断言
修复边界:为什么不能「在宿主里删掉那个变量」
这是全文最重要的一节。 报告人最初的临时变通是「在宿主里 delete process.env.ELECTRON_RUN_AS_NODE」,它打断了经 Windows Job runner 的命令执行。
前提限定:以下结论适用于本次这种 Electron Node 模式宿主 + 当前 Windows Job runner 实现;不涵盖直接调用其它可执行文件、其它 provider、或不同宿主启动方式的情形。
源码链(已逐处核对):
| 包 · 文件 | 位置 | 内容 |
|---|---|---|
dsh-subprocess-local/lib/runner-launch-B2zsQ1Dz.js | 1311–1316 | Windows runner 以 process.execPath(即桌面 Electron 可执行文件)+ runner 脚本启动 |
| 同上 | 694–695 | childEnv() → scrubbedParentEnv() |
| 同上 | 1343–1345 | runnerEnvironment() 取 childEnv(),没有显式补回 ELECTRON_RUN_AS_NODE |
dsh-subprocess-local/lib/index.js | 601 | env: runnerEnvironment(WINDOWS_RUNNER_SELECTION, invocation) |
| 同上 | 629 | subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty |
实测后果(删除该变量之后的同一会话内):经该 runner 的 pwsh 工具与 grep 工具内部的 ripgrep 调用均失败,两者都返回上面第 629 行那句——退出码 0、无残留进程,符合「runner 以 GUI 入口启动、未履行 IPC 协议即退出」。(其它调用未逐一验证,不作断言。)
第二位复核者补上了机制一侧的解释:Windows 沙箱 runner 的 argv 前缀是 [process.execPath, runner.js](packages/sandbox/sandbox-local/src/index.ts 的 windowsAclRunnerInvocation()),而 ConfinedArgv 不携带环境——桌面端它依赖继承该变量才能以 Node 模式启动。仓库内也已有「按边界各自声明」的先例:ptc-runtime-node 在 packages/ptc-runtime/ptc-runtime-node/src/index.ts 为自己的子进程单独剔除该变量(host-failures.spec.ts 有对应断言)。
结论:这个变量是「宿主跑 Node runner」的必需品,只能在「要启动的目标应用」这个边界上清理。
修法一(本问题的直接修复):在启动边界先清后合
要点:先清理继承值**,再合并适配器的显式值——顺序反了,就会把 GitHub Desktop cli.js 适配器那种故意的 opt-in 一起清掉。**
launchDetachedApp(lib/index.js:348,并同步其源文件):
const inherited = scrubbedParentEnv();
for (const key of Object.keys(inherited)) {
if (key.toUpperCase() === 'ELECTRON_RUN_AS_NODE') delete inherited[key];
}
const env = { ...inherited, ...options.env };
三处细节都是有意的:
- 按
toUpperCase()比较——Windows 的环境变量键大小写不敏感,直接比对原名可能漏掉electron_run_as_node之类的拼写。 - 只删「继承」来的那一份——
options.env是适配器显式声明的,...options.env放在后面合并,因此 GitHub Desktopcli.js的 Node 模式 opt-in 不受影响。 process.env本身不被修改——宿主自己的环境不变,pwsh/grep那条 runner 链路因此毫发无损。
关于改哪里:
lib/index.js与lib/types/resolver.js是生成产物(后者是同实现的副本)。引用它们只为定位受影响产物;正解是在源文件中修改并重新构建,不宜要求维护者手工维护两份生成代码。
修法二(独立加固):让内部 runner 显式声明 Node 模式
即使修法一落地,仍建议在 runnerEnvironment() 中为「以 Electron 可执行文件启动 Node runner」的路径显式提供该变量——这样 runner 不再依赖「碰巧继承到了」。
但有三条边界必须注意,否则会引入新问题:
- Windows 环境键大小写不敏感,显式补值时要注意避免产生重复键;
- 确认这项 bootstrap 设置不会被带进目标应用的环境(只作用于 runner);
runnerEnvironment()也服务其它启动路径,不能仅凭本次 Windows 反例就认定「无条件补值」已完整验证。
修法三(回归要求):四条断言随修复一起给
建议把下面四条作为该修复的验收条件,其中第四条正是本次反例的回归测试:
- GUI 子进程环境已清理——启动器不再收到该变量;
- 适配器显式 opt-in 仍保留——GitHub Desktop
cli.js一路仍以 Node 模式运行; - 宿主自身环境不被修改——
process.env不因启动应用而改变; - 宿主缺少该变量时,runner 的握手与目标命令仍然成功(这条覆盖修法二是否真的自洽)。
已有人按修法一在本地实现并验证:vitest run packages/host/open-in-app/tests(4 文件 / 64 用例)通过、tsc -b 该包通过、oxlint(含类型感知规则)0 error / 0 warning;并新增回归断言覆盖「两种大小写拼写的继承值都不会到达被启动的应用、适配器显式声明的值仍会到达」。
不要用调整 launchWatchMs 来掩盖
一句话:加大看护窗口只能覆盖更多「较晚发生的失败」,不能修复 Node 模式导致的退出;缩短窗口则可能提前判定成功,同样不代表 GUI 已打开。
本例的失败发生在 ~42 ms,任何合理的看护窗口都覆盖得到——问题不是「太早判定」,而是「启动了错误的模式」。调窗口既治不了病,还会污染 200/502 的语义。
影响面与现状:谁受影响、0.2.0-rc.2 仍复现
影响面:谁受影响、谁豁免、哪些还没验
按讨论中收窄后的表述:
- 本机实测:VS Code 在 Windows 桌面端必失败。
- 机制上可能同样受影响(未逐一实测):凡「被目录表以
App Paths\*.exe直接启动、且自身尊重该变量」的 Electron 应用——目录表中的 VS Code Insiders、Cursor、Windsurf 属此形态。 - 机制上可豁免:Electron 应用若关闭了
runAsNodefuse,会忽略该变量(Electron 官方文档明确这一点),故不做「所有 Electron 应用必失败」的断言。 - 未验证:非 Electron 条目(JetBrains 系、Sublime、Windows Terminal、
shell-open走explorer.exe)不直接受此机制影响,但未逐项实测;macOS / Linux 未验证。
现状:0.2.0-rc.2 仍复现
两位不同机器的复核者都确认了同一件事:
- 一位在 Windows 11 上跑 DSH Desktop 0.2.0-rc.2:宿主进程
process.env.ELECTRON_RUN_AS_NODE === '1';launchDetachedApp仍是env: { ...scrubbedParentEnv(), ...options.env };scrubbedParentEnv仍只过滤/KEY|PASSWORD|SECRET|TOKEN/i与DSH_*;Code.exe <dir>仍以Cannot find module '<dir>'退出码 1 结束 → 502launch-failed;同机同命令清掉该变量后 exit 0、窗口正常打开。即该问题未随 0.2.0-rc.2 修复。 - 另一位在另一台机器上独立复核,结论一致,并额外给出了用与
launchDetachedApp逐项相同 spawn 参数(detached: true、stdio: 'ignore'、env = scrubbedParentEnv() + 适配器 env)启动Code.exe <空目录>的对照:继承该变量 →exit=1、约 95 ms、stderr 为Cannot find module '<目录>';仅删这一个变量、其余环境不动 → 1 s 后仍在运行、按产品语义计为 launched、工作区窗口正常打开。
附:为什么「外部包装器」这条路走不通
报告人试过的另一条绕行是在 HKCU\...\App Paths\Code.exe 指向一个「清掉变量再启动真正 Code.exe」的包装器——本机所有实现均失败,该注册项已恢复为真实 Code.exe(备份在案)。
三条可独立核对的事实:
- 包装器里
.NET Process.Start直启失败:即使包装器已清除ELECTRON_RUN_AS_NODE,Process.Start(Code.exe, [dir])的四种配置(UseShellExecutetrue/false ×CreateNoWindowtrue/false,含设置WorkingDirectory)仍全部让 Code 退出0x80000003(STATUS_BREAKPOINT),未观察到工作区窗口。 - 补一个新控制台也不解决:改用
CreateProcessW+CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLE、bInheritHandles = FALSE,启动中间node.exe再由其 detached 启动Code.exe,隐藏/可见控制台都试过,仍退出0x80000003。 - 同机同期存在成功对照:从
pwsh经node直接启动(子进程环境为已删除该变量的副本)、cmd /c start "" Code.exe <dir>、code.cmd <dir>都成功。
包装器链与成功对照的差别尚未定位(候选:Job Object、继承句柄表、窗口站/桌面、Chromium 早期 CHECK),但已明确两点:包装器的 0x80000003 不是「仍在 Node 模式」导致(与 Node 模式的 exit 1 是两种不同现象);且没有异常栈就不应指定具体触发点。
一个可能相关的新数据点:第三位复核者发现,在 DSH 受限会话(工具沙箱约束下的进程)里以同样方式启动 Code.exe(变量已删),会稳定退出 0x80000003(约 90 ms、stderr 为空)——父进程已被 Job / 受限令牌约束时,Chromium 早期初始化即失败;同一命令换到不受限进程立即正常。这与包装器链的现象吻合,提示差异可能来自某一环所处的进程约束,而非变量本身(仅陈述观察,不指定触发点)。
对上游的意义:这组实验说明「靠外部包装器兜底」在本机不可行,但不构成对根因判断的反驳——主要依据仍是「环境有/无对照」与启动器源码;正式修复应放在应用启动边界,只清理目标子进程继承到的环境。
排查注意事项
- 先看耗时。 40–95 ms 就退出、且是 502
launch-failed,基本锁定「目标应用以错误模式启动后立即结束」。 - 别把 200 当成「窗口已打开」。 启动器把「exit 0」和「看护窗口到期仍在运行」都算成功,语义比字面宽。
- 用
--version分辨目标应用跑的是谁。Code.exe --version打印 Node 版本、code.cmd --version打印 VS Code 版本,是最廉价的判据。 - 把问题从 DSH 里剥出来复现。 纯 Windows 上
set ELECTRON_RUN_AS_NODE=1再启动Code.exe <目录>,能直接看到Cannot find module。 - 做环境对照要「同一目标、只改一个变量」。 目标用新建空目录,避免目录内可执行入口干扰。
- 千万不要在宿主里全局删这个变量。 它会打断经 Windows Job runner 的命令执行(
pwsh、grep内部的 ripgrep 都会失败)。 - 修在应用启动边界,且「先清继承、后合显式」。 顺序反了会清掉 GitHub Desktop
cli.js这类故意的 opt-in。 - 改源码,别改生成产物。
lib/index.js与lib/types/resolver.js是构建产物,手工维护两份迟早会漂移。 - 调看护窗口不是修法。 它只影响「什么时候判定」,不影响「以什么模式启动」。
- 界定影响面时带上 fuse 条件。 关闭
runAsNodefuse 的 Electron 应用会被豁免,所以结论要写成「按配置而定」,而不是「所有 Electron 应用都失败」。
来源
- #8049 — [Windows] 桌面宿主把 ELECTRON_RUN_AS_NODE 传给 VS Code,导致「在本地打开」工作区失败(502 launch-failed)(五步根因链、三步最小复现、修复边界反例、包装器实验、0.2.0-rc.2 复核与本地补丁验证,均出自该讨论)
- deepseek-ai/deepseek-harness(
dsh-host-open-in-app/lib/index.js、dsh-subprocess/lib/index.js、dsh-subprocess-local/lib/runner-launch-*.js、packages/sandbox/sandbox-local/src/index.ts、packages/ptc-runtime/ptc-runtime-node/src/index.ts等源码位置) - Electron 环境变量文档(ELECTRON_RUN_AS_NODE)
这个案例真正通用的一课是:一个为「用同一个 Electron 可执行文件跑 Node 代码」而设的环境开关,会在隔了几层之后,把用户点了半天的 GUI 应用变成 Node 进程。 如果你也在写派生子进程的宿主代码,建议马上做两件事:把所有「只对子进程 Node 模式成立」的环境变量(不止 ELECTRON_RUN_AS_NODE)在启动第三方应用的边界上显式清理,并且先清继承、后合显式;同时给「宿主缺少该变量时 runner 仍能工作」补一条回归测试——否则你很可能在修好「打开 VS Code」的当天,把命令执行一起弄坏。定位这类「打开失败」时,可先在 DSH Plugin Hub 的已安装列表与系统日志里对齐现场。

常见问题
看两个特征:一是**耗时极短**(约 40–95 ms 就退出,不是等了很久),二是宿主路由 POST /open-in-app/open {"app":"vscode","path":"<工作区>"} 返回 **502** 且 body 是 {"code":"launch-failed","message":"failed to launch vscode"}。想再确认一步,就在带 ELECTRON_RUN_AS_NODE=1 的环境里直接跑 "%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe" "<目录>"——若 stderr 是 Error: Cannot find module '<目录>',就是同一条。
因为带了这个变量时,Code.exe 根本没在跑 VS Code,而是在跑**内嵌的 Node**。ELECTRON_RUN_AS_NODE=1 会让 Electron 可执行文件以纯 Node 模式启动,于是它把后面的路径参数当成**要加载的模块**去解析,--version 自然打印 Node 的版本号。这也正是「打开工作区」失败的方式:VS Code 收不到「打开这个目录」的意图,而是去 require('<目录>')。对照组是 code.cmd --version,它打印 1.137.0 / <commit> / x64。
**千万别**。报告人试过在宿主里 delete process.env.ELECTRON_RUN_AS_NODE,结果**打断了经 Windows Job runner 的命令执行**——同一会话内 pwsh 工具与 grep 工具内部的 ripgrep 调用都失败,返回 subprocess-local: Windows Job runner exited with exit code 0 before proving its managed range empty。原因是 Windows runner 以 process.execPath(桌面 Electron 可执行文件)+ runner 脚本启动,依赖继承这个变量才能进入 Node 模式,而 ConfinedArgv 不携带环境。修复必须放在**应用启动边界**,只清理要启动的那个子进程继承到的环境。
按该讨论的收窄表述:本机实测 **VS Code 在 Windows 桌面端必失败**;机制上**可能**同样受影响的是「被目录表以 App Paths\*.exe 直接启动、且自身尊重该变量」的 Electron 应用,如同一目录表里的 VS Code Insiders、Cursor、Windsurf。机制上**可豁免**的是关闭了 runAsNode fuse 的 Electron 应用(官方文档明确该 fuse 关闭后会忽略此变量)。非 Electron 条目(JetBrains 系、Sublime、Windows Terminal、shell-open 走 explorer.exe)不直接受此机制影响,但未逐项实测;macOS / Linux 未验证。
按讨论中的复核,**0.2.0-rc.2 仍然复现**,链路与首帖完全一致。已有人在本地按建议修法 1 实现并验证:在 launchDetachedApp 构建启动环境时,先 scrubbedParentEnv()、再按大小写不敏感剔除 ELECTRON_RUN_AS_NODE、最后叠加适配器显式 env。验证结果是 vitest run packages/host/open-in-app/tests(4 文件 / 64 用例)通过、tsc -b 该包通过、oxlint 0 error / 0 warning,并补了「两种大小写拼写的继承值都到不了被启动应用、适配器显式声明的值仍能到达」的回归断言。
相关术语
- ELECTRON_RUN_AS_NODE
- Electron 的环境变量开关:置为 `1` 时,Electron 可执行文件不启动 GUI,而以一个纯 Node.js 运行时启动。桌面端派生子进程时常用它来「用同一个可执行文件跑 Node 代码」。危险之处在于它会被子进程原样继承——把 GUI 应用也变成 Node 模式,于是目标应用把路径参数当成要加载的模块。— https://github.com/deepseek-ai/deepseek-harness/discussions/8049
- launchDetachedApp / launchWatchMs
- `@deepseek-ai/dsh-host-open-in-app/lib/index.js:348` 的分离式启动器。它有两个 resolve 条件:①子进程 **exit 0**;②看护窗口 `launchWatchMs` 到期时子进程**仍在运行**。因此 HTTP **200 只代表「判定为已启动」,不保证 GUI 真的打开了**;本例是明确收到 502。调整 `launchWatchMs` 无法修复 Node 模式导致的退出。— https://github.com/deepseek-ai/deepseek-harness/discussions/8049
- scrubbedParentEnv()
- `@deepseek-ai/dsh-subprocess` 提供的「净化父进程环境」函数,用于给子进程构造环境变量。它按 `SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i` 丢弃敏感项,并额外丢弃 `DSH_*` 前缀、处理代理变量——但**不剔除 `ELECTRON_RUN_AS_NODE`**。这正是变量能从宿主一路泄漏到 GUI 应用的原因。— https://github.com/deepseek-ai/deepseek-harness/discussions/8049
- runAsNode fuse
- Electron 打包期可设置的 fuse 开关。关闭它之后,该应用会**忽略** `ELECTRON_RUN_AS_NODE`,因此不会因为继承到该变量而进入 Node 模式。这是「不是所有 Electron 应用都必然失败」的原因,也提醒受影响范围要按 fuse 配置来界定,而不能一概而论。— https://github.com/deepseek-ai/deepseek-harness/discussions/8049
来源
- #8049 — [Windows] 桌面宿主把 ELECTRON_RUN_AS_NODE 传给 VS Code,导致「在本地打开」工作区失败(502 launch-failed)· deepseek-ai(GitHub Discussions)
- deepseek-ai/deepseek-harness(源码仓库)· GitHub