DeepSeek Harness:打开 VS Code 报 502,DSH plugin 泄漏变量

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSHWindowsVS Codeopen-in-appELECTRON_RUN_AS_NODE环境变量泄漏502 launch-failed
点会话头的「在本地打开」选 VS Code,进程约 40 ms 就退出,界面提示打开失败,路由返回 502 launch-failed。真因是宿主自身带着 ELECTRON_RUN_AS_NODE=1,被 GUI 启动器原样继承。本文给三步复现与三种修法。

在 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)

在带该变量的环境里执行:

cmd
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,目标默认取一个新建的空目录(排除目录内可执行入口的干扰),两次使用同一目标,只改变环境变量:

sh
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. 变量的作用(交叉印证)

cmd
"%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) 返回

js
{ ...environment, ELECTRON_RUN_AS_NODE: "1", ... }   // 注释即 "Environment for a Node-mode child process"

在宿主进程(监听 127.0.0.1:19387 的那个进程,本机 PID 70924)内部读到的实测值是:

json
{ "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:

js
const env = { ...scrubbedParentEnv(), ...options.env };

scrubbedParentEnv() 的结果被整份展开,而它里面仍然带着 ELECTRON_RUN_AS_NODE。

第 3 步:净化函数不剔除 Electron 模式变量

@deepseek-ai/dsh-subprocess/lib/index.js:

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 路由据此:

js
sendJson(res, 502, { code: "launch-failed", ... })

第 5 步:为什么偏偏是 VS Code 完整命中

VS Code 在本机由目录表解析为:

js
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.js1311–1316Windows runner 以 process.execPath(即桌面 Electron 可执行文件)+ runner 脚本启动
同上694–695childEnv() → scrubbedParentEnv()
同上1343–1345runnerEnvironment() 取 childEnv(),没有显式补回 ELECTRON_RUN_AS_NODE
dsh-subprocess-local/lib/index.js601env: runnerEnvironment(WINDOWS_RUNNER_SELECTION, invocation)
同上629subprocess-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,并同步其源文件):

js
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 };

三处细节都是有意的:

  1. 按 toUpperCase() 比较——Windows 的环境变量键大小写不敏感,直接比对原名可能漏掉 electron_run_as_node 之类的拼写。
  2. 只删「继承」来的那一份——options.env 是适配器显式声明的,...options.env 放在后面合并,因此 GitHub Desktop cli.js 的 Node 模式 opt-in 不受影响。
  3. process.env 本身不被修改——宿主自己的环境不变,pwsh / grep 那条 runner 链路因此毫发无损。

关于改哪里:lib/index.js 与 lib/types/resolver.js 是生成产物(后者是同实现的副本)。引用它们只为定位受影响产物;正解是在源文件中修改并重新构建,不宜要求维护者手工维护两份生成代码。

修法二(独立加固):让内部 runner 显式声明 Node 模式

即使修法一落地,仍建议在 runnerEnvironment() 中为「以 Electron 可执行文件启动 Node runner」的路径显式提供该变量——这样 runner 不再依赖「碰巧继承到了」。

但有三条边界必须注意,否则会引入新问题:

  1. Windows 环境键大小写不敏感,显式补值时要注意避免产生重复键;
  2. 确认这项 bootstrap 设置不会被带进目标应用的环境(只作用于 runner);
  3. runnerEnvironment() 也服务其它启动路径,不能仅凭本次 Windows 反例就认定「无条件补值」已完整验证。

修法三(回归要求):四条断言随修复一起给

建议把下面四条作为该修复的验收条件,其中第四条正是本次反例的回归测试:

  1. GUI 子进程环境已清理——启动器不再收到该变量;
  2. 适配器显式 opt-in 仍保留——GitHub Desktop cli.js 一路仍以 Node 模式运行;
  3. 宿主自身环境不被修改——process.env 不因启动应用而改变;
  4. 宿主缺少该变量时,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 应用若关闭了 runAsNode fuse,会忽略该变量(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 结束 → 502 launch-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(备份在案)。

三条可独立核对的事实:

  1. 包装器里 .NET Process.Start 直启失败:即使包装器已清除 ELECTRON_RUN_AS_NODE,Process.Start(Code.exe, [dir]) 的四种配置(UseShellExecute true/false × CreateNoWindow true/false,含设置 WorkingDirectory)仍全部让 Code 退出 0x80000003(STATUS_BREAKPOINT),未观察到工作区窗口。
  2. 补一个新控制台也不解决:改用 CreateProcessW + CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLE、bInheritHandles = FALSE,启动中间 node.exe 再由其 detached 启动 Code.exe,隐藏/可见控制台都试过,仍退出 0x80000003。
  3. 同机同期存在成功对照:从 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 早期初始化即失败;同一命令换到不受限进程立即正常。这与包装器链的现象吻合,提示差异可能来自某一环所处的进程约束,而非变量本身(仅陈述观察,不指定触发点)。

对上游的意义:这组实验说明「靠外部包装器兜底」在本机不可行,但不构成对根因判断的反驳——主要依据仍是「环境有/无对照」与启动器源码;正式修复应放在应用启动边界,只清理目标子进程继承到的环境。

排查注意事项

  1. 先看耗时。 40–95 ms 就退出、且是 502 launch-failed,基本锁定「目标应用以错误模式启动后立即结束」。
  2. 别把 200 当成「窗口已打开」。 启动器把「exit 0」和「看护窗口到期仍在运行」都算成功,语义比字面宽。
  3. 用 --version 分辨目标应用跑的是谁。 Code.exe --version 打印 Node 版本、code.cmd --version 打印 VS Code 版本,是最廉价的判据。
  4. 把问题从 DSH 里剥出来复现。 纯 Windows 上 set ELECTRON_RUN_AS_NODE=1 再启动 Code.exe <目录>,能直接看到 Cannot find module。
  5. 做环境对照要「同一目标、只改一个变量」。 目标用新建空目录,避免目录内可执行入口干扰。
  6. 千万不要在宿主里全局删这个变量。 它会打断经 Windows Job runner 的命令执行(pwsh、grep 内部的 ripgrep 都会失败)。
  7. 修在应用启动边界,且「先清继承、后合显式」。 顺序反了会清掉 GitHub Desktop cli.js 这类故意的 opt-in。
  8. 改源码,别改生成产物。 lib/index.js 与 lib/types/resolver.js 是构建产物,手工维护两份迟早会漂移。
  9. 调看护窗口不是修法。 它只影响「什么时候判定」,不影响「以什么模式启动」。
  10. 界定影响面时带上 fuse 条件。 关闭 runAsNode fuse 的 Electron 应用会被豁免,所以结论要写成「按配置而定」,而不是「所有 Electron 应用都失败」。

来源


这个案例真正通用的一课是:一个为「用同一个 Electron 可执行文件跑 Node 代码」而设的环境开关,会在隔了几层之后,把用户点了半天的 GUI 应用变成 Node 进程。 如果你也在写派生子进程的宿主代码,建议马上做两件事:把所有「只对子进程 Node 模式成立」的环境变量(不止 ELECTRON_RUN_AS_NODE)在启动第三方应用的边界上显式清理,并且先清继承、后合显式;同时给「宿主缺少该变量时 runner 仍能工作」补一条回归测试——否则你很可能在修好「打开 VS Code」的当天,把命令执行一起弄坏。定位这类「打开失败」时,可先在 DSH Plugin Hub 的已安装列表与系统日志里对齐现场。

DSH Plugin Hub 确认卸载弹窗:展示待卸载的插件与来源仓库,确认后才从当前环境移除

常见问题

「在本地打开」选 VS Code 后界面只说打开失败,怎么确认是这条问题?

看两个特征:一是**耗时极短**(约 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 --version` 打印出的是 `v24.18.1`,而不是 VS Code 的版本?

因为带了这个变量时,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 会中招吗?

按该讨论的收窄表述:本机实测 **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

来源