TL;DR:桌面版宿主带着
ELECTRON_RUN_AS_NODE=1运行,open-in-app 的环境清理函数没过滤这个变量,启动 VS Code 时原样透传。VS Code 是 Electron 应用,收到这个变量就进入 Node 模式、秒退,宿主回报502 launch-failed。文末附已打包的修复插件,web / desktop 两个 profile 各装一次即可。所有结论对照v0.2.1-rc.3源码核验,插件附带 16 项断言冒烟测试。
一、现象
DeepSeek Harness 会话页右上角有一排"在应用中打开"按钮:VS Code、Cursor、Windsurf、GitHub Desktop、文件管理器......
| 启动方式 | VS Code 按钮 | 资源管理器 | JetBrains |
|---|---|---|---|
源码版 dsh web(git clone + pnpm) |
✅ 正常 | ✅ | ✅ |
| 桌面版应用 | ❌ 无反应 | ✅ | ✅ |
桌面版附带的全局 dsh web |
❌ 无反应 | ✅ | ✅ |
界面无任何报错,开发者工具里才能看到后端返回 502 launch-failed。Cursor、Windsurf、VS Code Insiders 同样中招------共性很明显:中招的全是 Electron 应用。
二、根因:ELECTRON_RUN_AS_NODE 漏网
ELECTRON_RUN_AS_NODE=1 是 Electron 的隐藏开关:环境里带着它,可执行文件启动时跳过 GUI,直接进入 Node.js 模式,把命令行参数当 Node 脚本执行。DeepSeek Harness 桌面版自己也依赖这个开关。
问题链路三步。
-
打包宿主带着变量跑。
桌面版宿主启动时显式设置
ELECTRON_RUN_AS_NODE=1(apps/desktop/src/node-environment.ts);全局dsh命令同理,apps/desktop/cli/dsh.cmd第 3 行就是set "ELECTRON_RUN_AS_NODE=1",启动的是同一个打包宿主。源码版跑在普通node.exe上,环境里天然没这个变量------这就是分界线。 -
清理函数漏了它。
open-in-app 启动外部应用前调用
scrubbedParentEnv()清理父进程环境,但它只过滤名字带KEY/PASSWORD/SECRET/TOKEN的变量和DSH_*前缀,ELECTRON_RUN_AS_NODE顺利漏网。 -
VS Code 秒退。
ELECTRON_RUN_AS_NODE=1 Code.exe "C:\工作区"→ Code.exe 找不到名为工作区路径的"Node 脚本",非零退出 → 宿主回 502。
资源管理器、JetBrains 不是 Electron 应用,不读这个变量,所以没事。
佐证:DSH 自己的更新器测试脚本里明确写了 ^ELECTRON_RUN_AS_NODE$ 的过滤正则,说明这个坑上游是知道的,只是通用清理函数漏了。上游在 launchDetachedApp 里删一行即可根治(社区提案:discussions/8455)。
三、修复思路:在启动请求窗口内摘变量
处理
/open-in-app/open请求的那一瞬间,临时从process.env摘掉ELECTRON_RUN_AS_NODE;请求结束,立刻恢复。
原理:open-in-app 的 spawn 在请求 handler 调用栈内同步完成,只要 handler 执行期间环境干净,被启动的应用拿到的就是干净环境。桌面版自己依赖该变量的子进程(pnpm 运行时、沙箱)不在这个窗口内启动,不受影响。
开发过程中有两个插曲值得一说。
一是直接包装 child_process.spawn 的思路被证伪了:ESM 对内置模块的具名导入在 namespace 创建时就快照固化,插件激活时宿主早就创建过 node:child_process 的 namespace,事后 patch 对打包产物里的 import { spawn } 无效,直接 TypeError。只能上移一层,包装 WebServer 路由。
二是 Windows 环境变量大小写不敏感,process.env 里可能出现任意大小写变体,必须按 toUpperCase() 收集,不能精确匹配。
四、插件实现(139 行 index.js,四块)
第一块是包装启动路由,做三件事------摘变量、finally 恢复、打防重标记:
js
function wrapRoute(route) {
const handler = route.handler
if (handler[PATCHED] === true) return
const wrapped = async function (req, res) {
const saved = Object.keys(process.env)
.filter((key) => key.toUpperCase() === RUN_AS_NODE)
.map((key) => [key, process.env[key]])
for (const [key] of saved) delete process.env[key]
try {
return await handler.call(this, req, res)
} finally {
for (const [key, value] of saved) process.env[key] = value
}
}
wrapped[PATCHED] = true
wrapped[ORIGINAL_HANDLER] = handler
route.handler = wrapped
wrappedRoutes.add(route)
}
第二块是识别启动路由,只认 path === '/open-in-app/open'(wire 常量)且 handler 是函数。不多加条件,避免上游改注册方式时静默漏拦。
第三块是双保险拦截。插件激活顺序不可控(bundle 注释明确"行序无加载语义"),apply 里做两道:
js
export function apply(ctx) {
const server = ctx.webServer
const proto = Object.getPrototypeOf(server)
// 第一道:本插件先激活 → 包装 register,之后注册的启动路由都经过包装
if (proto[PATCHED] !== true) {
const originalRegister = proto.register
proto.register = function (route) {
if (isOpenRoute(route)) wrapRoute(route)
return originalRegister.call(this, route)
}
}
// 第二道:open-in-app 先激活 → 扫存量路由表,原位替换
for (const tableName of ['exact', 'prefixes']) {
const table = server[tableName]
if (!(table instanceof Map)) continue
for (const route of table.values()) {
if (isOpenRoute(route)) wrapRoute(route)
}
}
}
路由表 exact 虽然在 TypeScript 里是 private,运行时只是普通 Map 属性,直接读写生效;读不到也不报错,第一道已兜底。
第四块是卸载还原,dispose 时逐路由换回 ORIGINAL_HANDLER、还原 prototype.register;老版本 cordis 没有 effect 就退回 ctx.on('dispose', cleanup)。
边界情况有三处。
GitHub Desktop 是唯一例外:它故意用 Node 模式启动自家 CLI,但其显式声明的变量在 scrub 后单独 overlay,插件只清继承层、不碰 overlay,行为不变(有专门测试用例锁定)。
并发请求的删除/恢复用保存值配对,互不干扰。
dispose 还原不留任何痕迹。
冒烟测试跑在 v0.2.1-rc.3 真实构建出的 WebServer 类上,16 项断言覆盖两种激活顺序、dispose 还原、并发请求、GitHub Desktop 显式 env 透传。
五、安装与验证
sh
# 全局 dsh web 命令(web profile)
dsh plugin --profile web add ./dsh-open-in-app-electron-fix-0.1.0.tgz
# DeepSeek Harness 桌面版(desktop profile)
dsh plugin --profile desktop add ./dsh-open-in-app-electron-fix-0.1.0.tgz
注意 web 和 desktop 是两个 profile,要分别装,只装一处另一处照样打不开。装完重启 dsh web 或桌面版。
Sanity check(先确认机制没变):
sh
ELECTRON_RUN_AS_NODE=1 "$LOCALAPPDATA/Programs/Microsoft VS Code/Code.exe" --version
# 输出 Node 版本号(而不是 VS Code 版本号)= 机制成立
然后开会话页点 VS Code 按钮,工作区应正常打开。每次升级 DSH 建议重跑这条 check:输出还是 Node 版本号、按钮却打不开了,说明上游改了启动路径,插件没拦到。
六、总结
- 现象:源码版正常;桌面版应用与其全局
dsh web点 VS Code 按钮无反应(502); - 根因:打包宿主带
ELECTRON_RUN_AS_NODE=1运行,经scrubbedParentEnv()盲区泄漏给被启动的 Electron 应用 → Node 模式秒退; - 修复:包装
/open-in-app/open路由,请求窗口内摘变量、结束恢复;web / desktop 两个 profile 各装一次; - 退役计划:上游合并 discussions/8455 修复后,插件清理全部变为无害 no-op,直接卸载即可。
插件仓库:github.com/LeviathanNo...
觉得有用的话点个赞再走,评论区欢迎补充你遇到的 Electron 环境变量坑。