@mrrisega/dsh-remote 0.6.18 → 0.7.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * 桌面端(DeepSeek Harness Desktop / Electron)适配回归。
3
+ *
4
+ * 背景:DeepSeek Harness 于 2026-09-29 发布桌面端 v0.2 预览(Electron 薄壳 + 完整 dsh Web Host,
5
+ * Host 以 `--no-open --port 0` 启动,端口随机、每进程令牌鉴权)。dsh-remote 的两大难点
6
+ * (随机端口、会话 Cookie)此前已因真实事故适配过,因此这次新增的适配**集中在三处**:
7
+ *
8
+ * ① **Electron 宿主下 process.execPath 不是 node**:它是 Electron 应用二进制。
9
+ * 直接 `spawn(process.execPath, [script])` 得到的不是"跑脚本的 node",而是**又开一个桌面端应用**
10
+ * (还会撞桌面端单实例锁)。必须补 `ELECTRON_RUN_AS_NODE=1` —— 官方桌面端自己也正是这么做的
11
+ * (其内置 CLI 就是 `ELECTRON_RUN_AS_NODE=1 exec <Electron> --expose-internals <cli.js>`)。
12
+ * ② **自启动服务里的解释器必须活过桌面应用的退出与更新**:`.app` 路径会变、可能被换掉,
13
+ * 所以"真实 node 优先,找不到才回落 Electron 自身(并带上 ① 的变量)"。
14
+ * ③ **重启语义不同**:桌面端 Host 由 Electron 托管,插件杀掉它会触发桌面端**原生致命恢复弹窗**,
15
+ * 用户观感是"应用崩了"。故 restartHarness 在 Electron 宿主下**有意拒绝**并如实说明,
16
+ * 面板据此不摆一个点了必然失败的按钮。
17
+ *
18
+ * ⚠️ 这些分支无法在开发机上"真装一个桌面端"来跑,所以本用例用两种手段:
19
+ * · 对纯函数(spawnEnv / resolveProfileDir)用**行为断言**(切片 eval,注入宿主相关常量);
20
+ * · 对散落在多文件的模板与分支用**锚点不变量**(跨文件必须同构,防漂移)。
21
+ */
22
+ import assert from "node:assert/strict";
23
+ import { readFileSync } from "node:fs";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import test from "node:test";
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
29
+ const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8").replace(/\r\n/g, "\n");
30
+ const CLIENT_SRC = readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8").replace(/\r\n/g, "\n");
31
+ const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8").replace(/\r\n/g, "\n");
32
+
33
+ // ─────────────── ① 子进程环境:Electron 宿主必须补 ELECTRON_RUN_AS_NODE ───────────────
34
+
35
+ /**
36
+ * 以给定的宿主形态构造一个真实的 spawnEnv 并调用它。
37
+ * 切片只含 spawnEnv 函数体,故它引用的模块级常量/工具必须显式注入。
38
+ * @param {object} selfNodeEnv 该宿主形态下 SELF_NODE_ENV 的值
39
+ * @param {object} [extra] 调用方业务变量
40
+ */
41
+ function makeSpawnEnv(selfNodeEnv, extra) {
42
+ const start = INDEX_SRC.indexOf("function spawnEnv(extra)");
43
+ const end = INDEX_SRC.indexOf("function safeSpawn(");
44
+ assert.ok(start > 0 && end > start, "切片锚点失效:spawnEnv / safeSpawn 结构变了");
45
+ const src = INDEX_SRC.slice(start, end);
46
+ const make = new Function("SELF_NODE_ENV", "isWindows", "delimiter", "dirname",
47
+ `${src}\nreturn spawnEnv;`);
48
+ return make(selfNodeEnv, () => false, path.delimiter, path.dirname)(extra);
49
+ }
50
+
51
+ test("桌面端:Electron 宿主下 spawnEnv 必须注入 ELECTRON_RUN_AS_NODE=1", () => {
52
+ const env = makeSpawnEnv({ ELECTRON_RUN_AS_NODE: "1" });
53
+ assert.equal(env.ELECTRON_RUN_AS_NODE, "1",
54
+ "缺这个变量时 spawn(process.execPath, [script]) 会变成'又开一个桌面端应用',bridge/watcher 永远起不来");
55
+ });
56
+
57
+ test("Web 版(真实 node):绝不得注入 ELECTRON_RUN_AS_NODE(不能污染既有环境)", () => {
58
+ const env = makeSpawnEnv({});
59
+ assert.ok(!("ELECTRON_RUN_AS_NODE" in env),
60
+ "非 Electron 宿主下注入该变量会污染子进程环境、并让 node 版本探测类工具困惑");
61
+ });
62
+
63
+ test("spawnEnv:NODE_INTERPRETER 变量不得挤掉调用方的业务变量,PATH 仍按平台分隔符拼接", () => {
64
+ const env = makeSpawnEnv({ ELECTRON_RUN_AS_NODE: "1" }, { DSH_BRIDGE_INSTALL_SOURCE: "npx" });
65
+ assert.equal(env.DSH_BRIDGE_INSTALL_SOURCE, "npx", "业务变量必须仍能覆盖");
66
+ assert.ok(env.PATH.includes(path.delimiter), "PATH 必须用平台分隔符(写死 ':' 会让 Windows 上 node/npx 全找不到)");
67
+ });
68
+
69
+ // ─────────────── ② 服务定义:plist / systemd 必须带上解释器变量(跨文件同构)───────────────
70
+
71
+ test("跨文件不变量:插件与安装器的 plist / systemd 模板都必须带解释器变量占位", () => {
72
+ // 为什么是"两份都要查":历史上前述两份模板漂移过、并因此各造成一个真机事故
73
+ // (插件每次自愈都会重写 plist,谁写谁说了算 → 漂移等于把另一份正确的覆盖掉)。
74
+ for (const [name, src] of [["lib/index.js", INDEX_SRC], ["dsh-setup.mjs", SETUP_SRC]]) {
75
+ assert.match(src, /const SERVICE_NODE_ENV\b/, `${name}: 必须有 SERVICE_NODE_ENV`);
76
+ assert.match(src, /<key>PATH<\/key><string>\$\{SERVICE_PATH\}<\/string>\$\{SERVICE_NODE_ENV_PLIST\}/,
77
+ `${name}: plist 的 EnvironmentVariables 必须紧跟 PATH 注入 SERVICE_NODE_ENV_PLIST`);
78
+ // ⚠️ systemd 模板两种写法都要认:lib/index.js 是多行模板(真换行),
79
+ // dsh-setup.mjs 是单行模板(字面量 `\n`)。只认其中一种就会漏掉另一份 —— 那正是"漂移"本身。
80
+ assert.match(src, /Environment=PATH=\$\{SERVICE_PATH\}(?:\n|\\n)\$\{SERVICE_NODE_ENV_UNIT\}Environment=DSH_BRIDGE/,
81
+ `${name}: systemd unit 必须在 PATH 之后注入 SERVICE_NODE_ENV_UNIT`);
82
+ }
83
+ });
84
+
85
+ test("桌面端:真实 node 存在时服务定义必须优先登记它(.app 路径会随更新失效)", () => {
86
+ for (const [name, src] of [["lib/index.js", INDEX_SRC], ["dsh-setup.mjs", SETUP_SRC]]) {
87
+ assert.match(src, /electron: IS_ELECTRON_HOST/,
88
+ `${name}: 解释器解析结果必须显式标注"是不是 Electron 二进制"(服务定义据此决定要不要补变量)`);
89
+ }
90
+ // 安装器额外要求:把"当前可执行文件"归一化后再比对,避免 symlink/node@20 造成误判
91
+ assert.match(SETUP_SRC, /const selfReal = resolveExecutable\(process\.execPath\)/,
92
+ "dsh-setup.mjs: 必须归一化自身可执行文件再判断是否为 Electron 本体");
93
+ });
94
+
95
+ // ─────────────── ③ 重启语义:桌面端必须拒绝,且如实说明 ───────────────
96
+
97
+ test("桌面端:restartHarness 在 Electron 宿主下**行为级**拒绝(不靠源码字符串)", async () => {
98
+ // 为什么需要行为级:源码锚点(下面那条)只能证明"代码里写了",证明不了"跑起来真的是这个结果"。
99
+ // 注入 IS_ELECTRON_HOST=true、skipsSystemOps=false(模拟**生产**:
100
+ // 桌面端下 DSH_RELAY_SKIP_SERVICE 不会设置,故必须走到 Electron 守卫而不是被测试隔离短路)。
101
+ const start = INDEX_SRC.indexOf("async function restartHarness(");
102
+ const end = INDEX_SRC.indexOf("async function relayFetch(");
103
+ assert.ok(start > 0 && end > start, "切片锚点失效:restartHarness 结构变了");
104
+ const src = INDEX_SRC.slice(start, end);
105
+ // 只注入守卫之前会被求值的两个依赖;函数体其余部分不会被走到(守卫提前 return),
106
+ // 故其引用的模块级标识符无需注入(new Function 只在**执行**时才解析标识符)。
107
+ const make = new Function("IS_ELECTRON_HOST", "skipsSystemOps", `${src}\nreturn restartHarness;`);
108
+ const restartHarness = make(true, () => false);
109
+ const r = await restartHarness("/tmp/does-not-matter");
110
+ assert.equal(r.ok, false, "桌面端重启必须如实返回失败,不得假装成功");
111
+ assert.equal(r.status, "desktop-managed", "面板据该状态改文案,不能是通用错误");
112
+ assert.equal(r.mode, "desktop");
113
+ assert.match(r.detail, /退出并重新打开/, "必须告诉用户正确做法");
114
+ // 对照组:非 Electron 宿主**不得**走这条分支(否则 Web 版重启会被误禁)
115
+ const restartWeb = make(false, () => true); // skipsSystemOps=true 模拟测试隔离,避免真的重启
116
+ const rw = await restartWeb("/tmp/does-not-matter");
117
+ assert.notEqual(rw.status, "desktop-managed", "Web 版绝不能返回桌面端状态");
118
+ });
119
+
120
+ test("桌面端:restartHarness 必须拒绝重启 Host,并返回可识别的 desktop-managed 状态", () => {
121
+ // 切片终点用 restartHarness **之后**的下一个顶层函数(buildRestartHelperSource 在它之前,不能用)
122
+ const fn = INDEX_SRC.slice(INDEX_SRC.indexOf("async function restartHarness("),
123
+ INDEX_SRC.indexOf("async function relayFetch("));
124
+ assert.ok(fn.length > 0, "切片锚点失效:restartHarness 找不到了");
125
+ assert.match(fn, /if \(IS_ELECTRON_HOST\)/, "必须先有 Electron 宿主守卫");
126
+ assert.match(fn, /status: "desktop-managed"/, "必须返回可识别状态(面板据此换文案、不摆失败按钮)");
127
+ assert.match(fn, /退出并重新打开 DeepSeek Harness/, "必须如实告诉用户正确做法");
128
+ // 守卫必须在真正生成重启脚本**之前**:否则仍会写脚本、动 launchd
129
+ assert.ok(fn.indexOf("if (IS_ELECTRON_HOST)") < fn.indexOf("const cmd = selfCommand();"),
130
+ "守卫必须早于 selfCommand()/脚本生成");
131
+ });
132
+
133
+ test("桌面端:状态接口必须下发宿主形态,面板才能自适应", () => {
134
+ assert.match(INDEX_SRC, /desktopHost: IS_ELECTRON_HOST,/, "composeStatus 必须下发 desktopHost");
135
+ assert.match(INDEX_SRC, /restartSupported: !IS_ELECTRON_HOST,/, "必须下发 restartSupported");
136
+ });
137
+
138
+ test("桌面端:面板不得自动倒计时重启,且不摆点了必然失败的重启按钮", () => {
139
+ assert.match(CLIENT_SRC, /var desktopHost = !!\(st && st\.desktopHost\)/, "面板必须读 desktopHost");
140
+ assert.match(CLIENT_SRC, /isRestartHard \|\| desktopHost \|\| busy !== ""/,
141
+ "自动倒计时重启必须在桌面端短路(Host 由 Electron 托管,不能替用户重启)");
142
+ assert.match(CLIENT_SRC, /\(desktopHost && !isRefreshOnly\) \? null : h\("button"/,
143
+ "桌面端且非'刷新即可'时不得渲染重启按钮");
144
+ // 旧版 host 不下发该字段 → undefined → 行为必须与改动前一致
145
+ assert.match(CLIENT_SRC, /\? "🔄 插件已更新:重启 dsh web 后生效"[\s\S]{0,40}: "🔄 插件已更新:刷新页面即可生效"\);/,
146
+ "非桌面端文案必须保留原有判据 isRestartHard(不得顺手改发布版文案)");
147
+ });
148
+
149
+ test("桌面端:面板文案改为引导'退出并重开应用',且不影响 Web 版", () => {
150
+ assert.match(CLIENT_SRC, /退出并重新打开 DeepSeek Harness 后生效/, "桌面端要有明确引导");
151
+ assert.match(CLIENT_SRC, /桌面端由应用本身托管运行环境/, "底部常驻按钮位置要有桌面端说明");
152
+ });
153
+
154
+ // ─────────────── ④ 安装:--profile desktop 必须真的生效 ───────────────
155
+
156
+ /** 用安装器切片构造真实的 resolveProfileDir(注入 os / path,伪造 HOME)。 */
157
+ function makeResolveProfileDir(home) {
158
+ const start = SETUP_SRC.indexOf("const KNOWN_PROFILE_NAMES");
159
+ const end = SETUP_SRC.indexOf("function warnIfDesktopProfile");
160
+ assert.ok(start > 0 && end > start, "切片锚点失效:profile 解析函数结构变了");
161
+ const src = SETUP_SRC.slice(start, end);
162
+ const osStub = { homedir: () => home };
163
+ const make = new Function("os", "path", `${src}\nreturn { resolveProfileDir, profileArgvOf, isDesktopProfileDir, profileDirOfName };`);
164
+ return make(osStub, path);
165
+ }
166
+
167
+ test("安装器:--profile desktop 解析到桌面端 profile 目录,不再是相对路径/被忽略", () => {
168
+ const { resolveProfileDir, isDesktopProfileDir } = makeResolveProfileDir("/Users/tester");
169
+ assert.equal(resolveProfileDir(["--profile", "desktop"]), path.join("/Users/tester", ".dsh", "profiles", "desktop"));
170
+ assert.ok(isDesktopProfileDir(resolveProfileDir(["--profile", "desktop"])), "必须能识别出这是桌面端 profile");
171
+ // 老行为必须原样保留
172
+ assert.equal(resolveProfileDir(["--profile", "web"]), path.join("/Users/tester", ".dsh", "profiles", "web"));
173
+ assert.equal(resolveProfileDir(["--profile", "/custom/dir"]), "/custom/dir", "任意路径仍原样当目录用");
174
+ assert.equal(resolveProfileDir([]), path.join("/Users/tester", ".dsh", "profiles", "web"), "缺省仍是 Web 版");
175
+ });
176
+
177
+ test("安装器:setup() 必须把 --profile 透传给插件挂载步骤", () => {
178
+ const { profileArgvOf } = makeResolveProfileDir("/Users/tester");
179
+ assert.deepEqual(profileArgvOf(["--profile", "desktop"]), ["--profile", "desktop"]);
180
+ assert.deepEqual(profileArgvOf([]), [], "未指定时不得凭空塞参数");
181
+ // 事故面:setup() 原本调 pluginCmd([]) → --profile desktop 被静默忽略、插件装进 web profile
182
+ assert.match(SETUP_SRC, /pluginCmd\(profileArgvOf\(argv\)\)/, "setup 必须透传 profile 选择");
183
+ assert.ok(!/pluginCmd\(\[\]\)/.test(SETUP_SRC), "不得再有硬编码空参数(会把 --profile 吞掉)");
184
+ });
185
+
186
+ test("安装器:desktop profile 未初始化时给桌面端专属引导(不得照抄 Web 版的 npx 说法)", () => {
187
+ // 用定长切片而不是尾部锚点:这段逻辑就在 pluginCmd 开头,之后的结构变化不该让用例变脆。
188
+ const start = SETUP_SRC.indexOf("async function pluginCmd(");
189
+ assert.ok(start > 0, "切片锚点失效:pluginCmd 找不到了");
190
+ const fn = SETUP_SRC.slice(start, start + 2500);
191
+ assert.match(fn, /isDesktopProfileDir\(profileDir\)/, "必须区分桌面端 profile");
192
+ assert.match(fn, /启动一次 DeepSeek Harness 应用/, "必须给桌面端专属引导:profile 由应用自己创建");
193
+ assert.match(fn, /完全退出应用/, "必须提醒完全退出(应用独占 profile,运行中安装不会被装载)");
194
+ // 事故面:桌面端 profile 由 Electron 应用初始化,npm 版 dsh 明确拒绝管理它
195
+ //(apps/cli 的 rejectElectronProfile)—— 照抄 Web 版那句会让用户白折腾。
196
+ assert.ok(!/未找到 dsh web profile/.test(fn), "不得再把桌面端统一报成 'dsh web profile'");
197
+ });
198
+
199
+ // ─────────────── ⑤ 桌面端自带 Node:跨平台候选(Windows 用 node.exe)───────────────
200
+
201
+ test("桌面端自带 Node 的候选:跨平台 + 命中官方真实布局 + 稳定落点优先", () => {
202
+ for (const [name, src] of [["lib/index.js", INDEX_SRC], ["dsh-setup.mjs", SETUP_SRC]]) {
203
+ assert.match(src, /function desktopRuntimeNodeCandidates\(\)/, `${name}: 必须有候选构造器`);
204
+ // 真实布局(2026-10-07 拆官方 0.2.0-rc.2 DMG 实测):
205
+ // …/Resources/runtime/primary-runtime/dependencies/node/bin/node (Node v24.21.0)
206
+ assert.match(src, /"dependencies", "node", "bin"/,
207
+ `${name}: 必须命中官方真实布局 dependencies/node/bin(猜错路径等于没这个候选)`);
208
+ assert.match(src, /DSH_DESKTOP_NODE_EXECUTABLE/,
209
+ `${name}: 必须采信应用自报的 node 路径(平台无关,比猜路径可靠)`);
210
+ assert.match(src, /\.\.\.desktopRuntimeNodeCandidates\(\)/,
211
+ `${name}: 必须真的并入解释器候选(不是死代码)`);
212
+ }
213
+
214
+ // 行为断言:注入平台与 HOME,验证真实产出(而不是只查字符串)
215
+ const start = SETUP_SRC.indexOf("function desktopRuntimeNodeCandidates()");
216
+ const end = SETUP_SRC.indexOf("function preferredNode()");
217
+ assert.ok(start > 0 && end > start, "切片锚点失效:候选构造器结构变了");
218
+ const src = SETUP_SRC.slice(start, end);
219
+ const make = new Function("process", "os", "path", `${src}\nreturn desktopRuntimeNodeCandidates;`);
220
+ const home = path.join(path.sep, "Users", "tester");
221
+ const win = make({ platform: "win32", env: {} }, { homedir: () => home }, path)();
222
+ const mac = make({ platform: "darwin", env: {} }, { homedir: () => home }, path)();
223
+
224
+ assert.ok(win.length > 0 && win.every((p) => p.endsWith("node.exe")),
225
+ `Windows 候选必须都是 node.exe(否则永远解析不到解释器),实际 ${JSON.stringify(win)}`);
226
+ assert.ok(mac.length > 0 && mac.every((p) => p.endsWith("node")),
227
+ `POSIX 候选必须都是 node,实际 ${JSON.stringify(mac)}`);
228
+ // 稳定落点优先:首条必须落在用户目录,且命中官方真实布局
229
+ assert.ok(mac[0].startsWith(home),
230
+ `首条候选必须在用户目录(要活过桌面端更新),实际 ${mac[0]}`);
231
+ assert.ok(mac[0].endsWith(path.join("dependencies", "node", "bin", "node")),
232
+ `首条候选必须是官方真实布局 dependencies/node/bin/node,实际 ${mac[0]}`);
233
+ // 会随桌面端更新失效的 .app 路径只能当后手
234
+ assert.ok(!mac[0].startsWith("/Applications"),
235
+ "不得把 .app 内部路径排在稳定落点之前(桌面端一升级自启动就失效)");
236
+ });
@@ -20,6 +20,12 @@ import { apply } from "../lib/index.js";
20
20
  // ⚠️ 必须用 fileURLToPath:Windows 上 `new URL(...).pathname` 得到的是 "/D:/a/..."(带前导斜杠),
21
21
  // path.join 之后会拼成 "D:\\D:\\a\\..." → ENOENT。CI 的 Windows 专项就死在这条上。
22
22
  const HERE = path.dirname(fileURLToPath(import.meta.url));
23
+ /**
24
+ * 版本号**从真实 manifest 派生**,不要写死。
25
+ * 写死会在每次发版时把这条无关用例弄红(2026-10-07 发 0.7.0 时实测踩到:断言里钉着 `/0\.6\./`),
26
+ * 而"按版本分桶"要的恰恰是**跟着真实版本走**。
27
+ */
28
+ const PLUGIN_VERSION = JSON.parse(readFileSync(path.join(HERE, "..", "package.json"), "utf8")).version;
23
29
  const readOrEmpty = async (p) => { try { return await readFile(p, "utf8"); } catch { return ""; } };
24
30
 
25
31
  /** 假反馈服务:只收 /api/feedback 与 /api/feedback/:id/replies(与企业端契约同构)。 */
@@ -124,7 +130,7 @@ test("预览:把「将要发送的每一个字」摊开,且不含手机号
124
130
  const b = await getJson(`${base}/dsh-remote/diag/bundle`);
125
131
  assert.equal(b.ok, true);
126
132
  assert.match(b.title, /\[诊断\]/);
127
- assert.match(b.title, /0\.6\./, "标题要带插件版本,便于后台按版本分桶");
133
+ assert.ok(b.title.includes(PLUGIN_VERSION), `标题要带插件版本(${PLUGIN_VERSION}),便于后台按版本分桶`);
128
134
  const all = b.summary + "\n" + b.parts.join("\n");
129
135
  for (const need of ["【阶段】", "【bridge】", "【上游】", "【配置目录】", env.relayDir]) {
130
136
  assert.ok(all.includes(need), `预览必须包含 ${need}(这些正是 2026-09-23 那次反馈里缺失的证据)`);
@@ -178,7 +178,12 @@ test("回归护栏:plugin 子命令必须把激活结果 return 给 setup(
178
178
  const call = src.match(/return convergePluginActivation\(profileDir[^\n]*/);
179
179
  assert.ok(call, "pluginCmd 必须 return convergePluginActivation(...) 的结果");
180
180
  // setup() 必须真的消费这个结果
181
- assert.match(src, /pluginResult = await pluginCmd\(\[\]\)/, "setup 应保存 pluginCmd 的返回值");
181
+ // ⚠️ 2026-10-07 起参数从 `[]` 改为 `profileArgvOf(argv)`:桌面端要支持
182
+ // `--profile desktop`,而 setup() 原本**不把 --profile 透传给 pluginCmd**,
183
+ // 导致"指定 desktop profile"被静默忽略、插件装进 web profile,
184
+ // 桌面端用户装完看不到面板且极难自查。这里是那次修复的护栏。
185
+ assert.match(src, /pluginResult = await pluginCmd\(profileArgvOf\(argv\)\)/, "setup 应保存 pluginCmd 的返回值");
186
+ assert.match(src, /function profileArgvOf\(/, "必须有 profileArgvOf 把 --profile 挑出来");
182
187
  assert.match(src, /pluginResult\.hotPatch/, "应据 hotPatch 判断是否走热挂载形态");
183
188
  });
184
189
 
@@ -529,9 +529,10 @@ test("win32:登录任务经 wscript+VBS 隐藏启动(旧版会弹黑色空
529
529
  SETUP_SRC.indexOf("function winHiddenLauncherPath()"),
530
530
  SETUP_SRC.indexOf("function writeWindowsTask()"),
531
531
  );
532
- const make = new Function("CONFIG_DIR", "fs", "path",
532
+ // SERVICE_NODE_ENV 是模块级常量:切片只含函数体,必须显式注入(真实 node 时为空对象)。
533
+ const make = new Function("CONFIG_DIR", "fs", "path", "SERVICE_NODE_ENV",
533
534
  `${src}\nreturn { winHiddenLauncherPath, writeWinHiddenLauncher };`);
534
- const { winHiddenLauncherPath, writeWinHiddenLauncher } = make(dir, nodeFs, path);
535
+ const { winHiddenLauncherPath, writeWinHiddenLauncher } = make(dir, nodeFs, path, {});
535
536
 
536
537
  // 用「空格 + 中文 + 括号」的路径:Windows 上最常见的翻车形状
537
538
  const node = "C:\\Program Files\\nodejs\\node.exe";
@@ -554,6 +555,36 @@ test("win32:登录任务经 wscript+VBS 隐藏启动(旧版会弹黑色空
554
555
  assert.equal(value, `"${node}" "${setup}" run`, "VBS 字面量转义必须可往返(内嵌引号写成两个)");
555
556
  assert.ok(value.includes("My App (x64)"), "含空格/括号的路径必须完整保留");
556
557
  assert.ok(value.includes("张三"), "中文路径必须完整保留");
558
+ assert.ok(!/ELECTRON_RUN_AS_NODE/.test(vbs), "真实 node 时不得注入 Electron 变量(避免污染环境)");
559
+ } finally {
560
+ await rm(dir, { recursive: true, force: true });
561
+ }
562
+ });
563
+
564
+ test("win32:桌面端(Electron 解释器)自启动必须先设 ELECTRON_RUN_AS_NODE=1", async () => {
565
+ // 现场价值:DeepSeek Harness 桌面端宿主里 process.execPath 是 Electron 应用二进制。
566
+ // 不设这个变量,自启动拉起的不是 bridge 守护,而是**又开一个桌面端应用** ——
567
+ // 表现为「服务登记成功、进程秒退、bridge 永远起不来」,与 0.6.15 端口事故一样难排查。
568
+ const dir = await mkdtemp(path.join(os.tmpdir(), "dsh-win-electron-"));
569
+ try {
570
+ const src = SETUP_SRC.slice(
571
+ SETUP_SRC.indexOf("function winHiddenLauncherPath()"),
572
+ SETUP_SRC.indexOf("function writeWindowsTask()"),
573
+ );
574
+ const make = new Function("CONFIG_DIR", "fs", "path", "SERVICE_NODE_ENV",
575
+ `${src}\nreturn { writeWinHiddenLauncher };`);
576
+ const { writeWinHiddenLauncher } = make(dir, nodeFs, path, { ELECTRON_RUN_AS_NODE: "1" });
577
+
578
+ const node = "C:\\Program Files\\DeepSeek Harness\\DeepSeek Harness.exe";
579
+ const setup = path.join(dir, "dsh-setup.mjs");
580
+ const vbs = readFileSync(writeWinHiddenLauncher(node, setup), "utf8");
581
+ assert.match(vbs, /Environment\("PROCESS"\)\("ELECTRON_RUN_AS_NODE"\)\s*=\s*"1"/,
582
+ "必须先往进程环境写入 ELECTRON_RUN_AS_NODE=1,再 Run");
583
+ assert.ok(vbs.indexOf("ELECTRON_RUN_AS_NODE") < vbs.indexOf(".Run "),
584
+ "设置环境变量必须发生在 Run **之前**(否则白设)");
585
+ // 注入变量不得破坏原有的隐藏启动语义
586
+ const line = vbs.replace(/\r\n/g, "\n").split("\n").find((l) => l.includes(".Run "));
587
+ assert.ok(/,\s*0,\s*False\s*$/.test(line), `Run 仍必须是 (cmd, 0, False),实际:${line}`);
557
588
  } finally {
558
589
  await rm(dir, { recursive: true, force: true });
559
590
  }