@mrrisega/dsh-remote 0.6.7-beta.2 → 0.6.7-beta.4

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,442 @@
1
+ /**
2
+ * 卸载安全回归(0.6.7-beta.3 新增)—— 两个"卸载把 dsh web 弄坏"的致命缺陷。
3
+ *
4
+ * 缺陷一:卸载后 cordis.patch.yml 只剩注释。
5
+ * dsh 要求该文件顶层是**数组**,而纯注释文档的 YAML 解析结果是 `null`(不是空数组),
6
+ * 于是 dsh 直接启动失败:
7
+ * `Error: dsh: overlay …/cordis.patch.yml must be a top-level YAML array of loader patch entries`
8
+ * 官方空 profile 模板是「注释 + `[]`」,这个占位符不能省。用户卸载后 dsh web 起不来,
9
+ * 而面板也在那个进程里 → 只能靠 CLI 自救。
10
+ *
11
+ * 缺陷二:卸载全链路同步执行在 dsh web 的事件循环里,且把易卡的运行时清理排在前面。
12
+ * 一次 spawnSync 卡住(Windows 沙箱拦 schtasks;spawnSync 的 timeout 在 Windows 上不可靠,
13
+ * 实测 18s+ 未返回)→ 真正"卸载插件"的那步永远不执行,且**整个 dsh web 僵死**
14
+ * (端口在听、连接建立、永不响应)。
15
+ *
16
+ * ⚠️ 本文件的断言对象刻意落在**可用性**上(文件还能不能被解析成数组、进程结果、响应契约),
17
+ * 而不是"函数返回了什么"——上一轮正是因为只断言后者才漏掉了挂载缺陷。
18
+ */
19
+ import assert from "node:assert/strict";
20
+ import { spawn, spawnSync } from "node:child_process";
21
+ import fs from "node:fs";
22
+ import http from "node:http";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import test from "node:test";
26
+ import { fileURLToPath } from "node:url";
27
+ import { apply } from "../lib/index.js";
28
+
29
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
30
+ const INDEX_SRC = fs.readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
31
+ const CLIENT_SRC = fs.readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8");
32
+ const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
33
+ const SETUP_SRC = fs.readFileSync(SETUP, "utf8");
34
+
35
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36
+ const PLUGIN_ID = "dsh-remote-web";
37
+ const LEGACY_ID = "dsh-remote-ui";
38
+
39
+ /**
40
+ * 独立复刻 dsh 的判定:顶层必须是 YAML 数组。
41
+ * 只认**顶层(无缩进)非注释行** —— 缩进行属于上一条目;纯注释 → 结构行数为 0 → 判定不合法
42
+ * (这正是缺陷一的形态)。刻意不引用被测实现里的同名函数,避免自证。
43
+ */
44
+ function isTopLevelArrayDoc(text) {
45
+ const structural = String(text).split(/\r?\n/)
46
+ .filter((l) => l.trim() !== "" && !/^\s*#/.test(l) && !/^[ \t]/.test(l)) // 只保留顶层非注释行
47
+ .map((l) => l.trim());
48
+ if (structural.length === 0) return false;
49
+ return structural.every((l) => l === "[]" || l.startsWith("-"));
50
+ }
51
+
52
+ function boot(relayDir) {
53
+ const routes = new Map();
54
+ apply({
55
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
56
+ effect(register) { return register(); },
57
+ logger: { info() {}, warn() {} },
58
+ }, { relayDir });
59
+ return routes;
60
+ }
61
+
62
+ async function serve(routes) {
63
+ const host = http.createServer((req, res) => {
64
+ const handler = routes.get(new URL(req.url, "http://x").pathname);
65
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
66
+ });
67
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
68
+ return { host, base: `http://127.0.0.1:${host.address().port}` };
69
+ }
70
+
71
+ /** 造一个装了插件的 profile:patch(标记块形式)+ 依赖 + 本地拷贝 + node_modules 链接。 */
72
+ async function plantProfile(home, { extraPatch = "", unmarked = false } = {}) {
73
+ const profile = path.join(home, ".dsh", "profiles", "web");
74
+ fs.mkdirSync(path.join(profile, "node_modules"), { recursive: true });
75
+ const block = unmarked
76
+ ? `- insert:\n - id: ${PLUGIN_ID}\n name: '${PLUGIN_ID}'\n`
77
+ : `# >>> ${PLUGIN_ID} (managed by dsh-remote plugin; do not edit)\n- insert:\n - id: ${PLUGIN_ID}\n name: '${PLUGIN_ID}'\n config:\n relayDir: '${path.join(home, "relay")}'\n# <<< ${PLUGIN_ID}\n`;
78
+ fs.writeFileSync(path.join(profile, "cordis.patch.yml"), `# 空 profile 的 patch 层\n${extraPatch}${block}`);
79
+ fs.writeFileSync(path.join(profile, "package.json"), JSON.stringify({
80
+ name: "dsh-profile-web",
81
+ dependencies: { [PLUGIN_ID]: "file:./dsh-remote-web-plugin", other: "^1.0.0" },
82
+ dsh: { profile: { bundles: [] } },
83
+ }, null, 2));
84
+ const local = path.join(profile, "dsh-remote-web-plugin");
85
+ fs.mkdirSync(path.join(local, "lib"), { recursive: true });
86
+ fs.writeFileSync(path.join(local, "package.json"), JSON.stringify({ name: PLUGIN_ID, version: "0.0.0" }));
87
+ fs.writeFileSync(path.join(local, "lib", "index.js"), "export const x = 1;\n");
88
+ fs.symlinkSync(local, path.join(profile, "node_modules", PLUGIN_ID), "dir");
89
+ fs.mkdirSync(path.join(home, "relay"), { recursive: true });
90
+ fs.writeFileSync(path.join(home, "relay", ".dsh-config.json"), JSON.stringify({ phone: "13800000000", password: "pw" }));
91
+ return { profile, patchFile: path.join(profile, "cordis.patch.yml") };
92
+ }
93
+
94
+ // ─────────────────────── ① patch 占位符(缺陷一) ───────────────────────
95
+
96
+ test("对照:纯注释文档必须判为「不是顶层数组」(这正是 dsh 起不来的形态)", () => {
97
+ assert.equal(isTopLevelArrayDoc("# 只有注释\n# 再来一行\n"), false);
98
+ assert.equal(isTopLevelArrayDoc(""), false);
99
+ assert.equal(isTopLevelArrayDoc("[]\n"), true);
100
+ assert.equal(isTopLevelArrayDoc("# c\n[]\n"), true);
101
+ assert.equal(isTopLevelArrayDoc("- insert:\n - id: x\n name: 'x'\n"), true, "缩进行属于条目,不能判成非法");
102
+ });
103
+
104
+ test("卸载:patch 条目被摘光后必须补回 `[]` 占位(否则 dsh web 起不来)", async () => {
105
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-safety-"));
106
+ const prevHome = process.env.HOME;
107
+ const prevSkip = process.env.DSH_RELAY_SKIP_SERVICE;
108
+ process.env.HOME = home;
109
+ process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 隔离:绝不碰真实系统服务
110
+ try {
111
+ const { profile, patchFile } = await plantProfile(home);
112
+ const routes = boot(path.join(home, "relay"));
113
+ const { host, base } = await serve(routes);
114
+ try {
115
+ const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
116
+ assert.equal(r.ok, true);
117
+ assert.equal(r.removedPatch, true, "插件引用应被摘除");
118
+
119
+ const after = fs.readFileSync(patchFile, "utf8");
120
+ assert.ok(!after.includes(PLUGIN_ID), "patch 里不该再有本插件:" + JSON.stringify(after));
121
+ assert.ok(isTopLevelArrayDoc(after), "★卸载后 patch 必须仍是合法顶层数组(含 [] 占位):" + JSON.stringify(after));
122
+ assert.match(after, /\[\]/, "被摘空时必须补回 `[]` 占位");
123
+
124
+ // 依赖 / 本地拷贝 / 链接也被移除(市场卸载才能接手)
125
+ const pkg = JSON.parse(fs.readFileSync(path.join(profile, "package.json"), "utf8"));
126
+ assert.equal(pkg.dependencies[PLUGIN_ID], undefined);
127
+ assert.equal(pkg.dependencies.other, "^1.0.0", "别人的依赖不得被误删");
128
+ assert.equal(fs.existsSync(path.join(profile, "dsh-remote-web-plugin")), false);
129
+ assert.equal(fs.existsSync(path.join(profile, "node_modules", PLUGIN_ID)), false);
130
+ } finally {
131
+ host.close();
132
+ }
133
+ } finally {
134
+ if (prevSkip === undefined) delete process.env.DSH_RELAY_SKIP_SERVICE; else process.env.DSH_RELAY_SKIP_SERVICE = prevSkip;
135
+ process.env.HOME = prevHome;
136
+ fs.rmSync(home, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test("卸载:无标记的 `- insert:` 块(旧版/其它工具写入的形式)也要被摘掉", async () => {
141
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-safety-unmarked-"));
142
+ const prevHome = process.env.HOME;
143
+ const prevSkip = process.env.DSH_RELAY_SKIP_SERVICE;
144
+ process.env.HOME = home;
145
+ process.env.DSH_RELAY_SKIP_SERVICE = "1";
146
+ try {
147
+ const { patchFile } = await plantProfile(home, { unmarked: true });
148
+ const routes = boot(path.join(home, "relay"));
149
+ const { host, base } = await serve(routes);
150
+ try {
151
+ await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
152
+ const after = fs.readFileSync(patchFile, "utf8");
153
+ // 只摘标记块是不够的:目录已删而 patch 仍引用 → dsh 插件树加载失败 → 整个 dsh web 起不来
154
+ assert.ok(!after.includes(PLUGIN_ID), "无标记的 insert 块也必须摘掉:" + JSON.stringify(after));
155
+ assert.ok(isTopLevelArrayDoc(after), "摘空后仍须是合法数组:" + JSON.stringify(after));
156
+ } finally {
157
+ host.close();
158
+ }
159
+ } finally {
160
+ if (prevSkip === undefined) delete process.env.DSH_RELAY_SKIP_SERVICE; else process.env.DSH_RELAY_SKIP_SERVICE = prevSkip;
161
+ process.env.HOME = prevHome;
162
+ fs.rmSync(home, { recursive: true, force: true });
163
+ }
164
+ });
165
+
166
+ test("卸载:别人的 patch 条目必须原样保留(不许顺手清空整份文件)", async () => {
167
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-safety-other-"));
168
+ const prevHome = process.env.HOME;
169
+ const prevSkip = process.env.DSH_RELAY_SKIP_SERVICE;
170
+ process.env.HOME = home;
171
+ process.env.DSH_RELAY_SKIP_SERVICE = "1";
172
+ try {
173
+ const other = "- insert:\n - id: some-other-plugin\n name: 'other'\n";
174
+ const { patchFile } = await plantProfile(home, { extraPatch: other });
175
+ const routes = boot(path.join(home, "relay"));
176
+ const { host, base } = await serve(routes);
177
+ try {
178
+ await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
179
+ const after = fs.readFileSync(patchFile, "utf8");
180
+ assert.ok(after.includes("some-other-plugin"), "别人的条目必须保留:" + JSON.stringify(after));
181
+ assert.ok(!after.includes(PLUGIN_ID), "我们的条目必须摘掉");
182
+ assert.ok(isTopLevelArrayDoc(after));
183
+ assert.ok(!/\[\]/.test(after), "还有条目时不该多写 `[]`(会变成 YAML 语法错误)");
184
+ } finally {
185
+ host.close();
186
+ }
187
+ } finally {
188
+ if (prevSkip === undefined) delete process.env.DSH_RELAY_SKIP_SERVICE; else process.env.DSH_RELAY_SKIP_SERVICE = prevSkip;
189
+ process.env.HOME = prevHome;
190
+ fs.rmSync(home, { recursive: true, force: true });
191
+ }
192
+ });
193
+
194
+ test("CLI 卸载(dsh-remote plugin --uninstall):同样必须留下合法数组", async () => {
195
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-safety-cli-"));
196
+ try {
197
+ const { patchFile } = await plantProfile(home);
198
+ // 用真实安装器跑一遍 CLI 卸载(隔离 HOME + 隔离开关)
199
+ const r = spawnSync(process.execPath, [SETUP, "plugin", "--uninstall"], {
200
+ encoding: "utf8", timeout: 60000,
201
+ env: { ...process.env, HOME: home, DSH_RELAY_SKIP_SERVICE: "1", DSH_RELAY_DIR: path.join(home, "relay"), DSH_PROFILE_DIR: path.dirname(patchFile) },
202
+ });
203
+ assert.equal(r.status, 0, `CLI 卸载应成功:${r.stdout}\n${r.stderr}`);
204
+ const after = fs.readFileSync(patchFile, "utf8");
205
+ assert.ok(!after.includes(PLUGIN_ID), "CLI 卸载也应摘掉条目:" + JSON.stringify(after));
206
+ assert.ok(isTopLevelArrayDoc(after), "★CLI 卸载后同样必须是合法数组:" + JSON.stringify(after));
207
+ } finally {
208
+ fs.rmSync(home, { recursive: true, force: true });
209
+ }
210
+ });
211
+
212
+ /** 本机是否有真正的 dsh CLI(CI 通常没有 → 该用例自动跳过)。 */
213
+ function dshAvailable() {
214
+ try { return spawnSync("dsh", ["--help"], { stdio: "ignore", timeout: 20000 }).status === 0; }
215
+ catch { return false; }
216
+ }
217
+ const HAS_DSH = dshAvailable();
218
+
219
+ /**
220
+ * 最强的一条:把卸载结果交给**真实 dsh 解析器**判定。
221
+ * 报告的 Fix E 正是要求这个 —— 断言对象不是"我们写了什么",而是"dsh 能不能 compose 出插件树"
222
+ *(compose 成功 ≈ dsh web 能启动)。另外附一条反例:纯注释文档必须被 dsh 拒绝,
223
+ * 否则这条用例就是空断言(证明它有牙齿)。
224
+ */
225
+ test("(需本机装有 dsh)卸载后的 patch 必须能被真实 dsh 解析:--dump-config 成功", { skip: !HAS_DSH && "本机没有 dsh CLI" }, async () => {
226
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-real-"));
227
+ const prevHome = process.env.HOME;
228
+ const prevSkip = process.env.DSH_RELAY_SKIP_SERVICE;
229
+ process.env.HOME = home;
230
+ process.env.DSH_RELAY_SKIP_SERVICE = "1";
231
+ const dshHome = path.join(home, "dshhome");
232
+ const profile = path.join(dshHome, "profiles", "web");
233
+ fs.mkdirSync(profile, { recursive: true });
234
+ fs.writeFileSync(path.join(profile, "package.json"), JSON.stringify({ name: "web", version: "0.0.0", dsh: { profile: {} } }));
235
+ const patchFile = path.join(profile, "cordis.patch.yml");
236
+ const comments = "# Your patch layer for this dsh profile, applied after every bundle layer:\n# a top-level YAML array of loader patch entries\n";
237
+ const runDsh = () => spawnSync("dsh", ["--profile", "web", "--dump-config"], {
238
+ encoding: "utf8", timeout: 60000, env: { ...process.env, DSH_HOME: dshHome },
239
+ });
240
+ try {
241
+ // 反例(负向对照):纯注释 → dsh 必须报错(否则本用例没有牙齿)
242
+ fs.writeFileSync(patchFile, comments);
243
+ const negative = runDsh();
244
+ assert.notEqual(negative.status, 0, "纯注释文档必须被 dsh 拒绝(这就是用户遇到的启动失败)");
245
+ assert.match(String(negative.stderr || "") + String(negative.stdout || ""), /top-level YAML array/);
246
+
247
+ // 正向:我们的安装器写入的 patch → dsh 必须接受
248
+ fs.writeFileSync(patchFile, `${comments}[]\n`);
249
+ fs.mkdirSync(path.join(profile, "node_modules"), { recursive: true });
250
+ fs.writeFileSync(path.join(profile, "node_modules", "probe"), "");
251
+ const ok = runDsh();
252
+ assert.equal(ok.status, 0, `安装态 patch 应能被 dsh 接受:${ok.stderr}`);
253
+
254
+ // 关键:走一遍真实卸载(插件路由),卸载后 dsh 仍必须能 compose
255
+ const { patchFile: installedPatch } = await (async () => {
256
+ const { profile: p2, patchFile: pf } = await plantProfile(home);
257
+ return { patchFile: pf };
258
+ })();
259
+ const routes = boot(path.join(home, "relay"));
260
+ const { host, base } = await serve(routes);
261
+ try {
262
+ await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
263
+ } finally { host.close(); }
264
+ fs.copyFileSync(installedPatch, patchFile); // 把"卸载后的 patch"交给真实 dsh 判定
265
+ const after = runDsh();
266
+ assert.equal(after.status, 0, `★卸载后的 patch 必须仍能被 dsh 接受(否则 dsh web 起不来):${after.stderr}`);
267
+ assert.ok(isTopLevelArrayDoc(fs.readFileSync(patchFile, "utf8")));
268
+ } finally {
269
+ if (prevSkip === undefined) delete process.env.DSH_RELAY_SKIP_SERVICE; else process.env.DSH_RELAY_SKIP_SERVICE = prevSkip;
270
+ process.env.HOME = prevHome;
271
+ fs.rmSync(home, { recursive: true, force: true });
272
+ }
273
+ });
274
+
275
+ // ─────────────────────── ② 卸载顺序与阻塞(缺陷二) ───────────────────────
276
+
277
+ test("源码契约:卸载必须先卸插件、再关自愈、最后才把慢活交子进程", () => {
278
+ const route = INDEX_SRC.slice(INDEX_SRC.indexOf('path: "/dsh-remote/self/uninstall"'), INDEX_SRC.indexOf("// ---------- 路由", INDEX_SRC.indexOf('path: "/dsh-remote/self/uninstall"')));
279
+ const iSelf = route.indexOf("uninstallSelf(relayDir, profileDir");
280
+ const iMark = route.indexOf("markUninstalled(relayDir)");
281
+ const iSpawn = route.indexOf("spawnRuntimeCleanupDetached(relayDir, profileDir)");
282
+ const iSend = route.indexOf("sendJson(res, 200");
283
+ assert.ok(iSelf > -1 && iMark > -1 && iSpawn > -1 && iSend > -1, "应能定位四个关键步骤");
284
+ assert.ok(iSelf < iSpawn, "★先卸插件(快、纯 fs),再交子进程做慢活 —— 旧实现顺序相反,卡住就「什么都没卸载」");
285
+ assert.ok(iMark < iSpawn, "自愈标记必须在慢步骤之前置位,否则慢步骤期间可能被复活");
286
+ // 兜底路径(进程内同步清理)必须在响应之后:它会阻塞事件循环,用户不能先等它
287
+ const iFallback = route.indexOf("uninstallRuntime(relayDir, profileDir)");
288
+ assert.ok(iFallback === -1 || iSend < iFallback, "兜底的进程内清理必须排在响应之后");
289
+ assert.ok(!/const rt = uninstallRuntime\(relayDir, profileDir\);/.test(route), "不得再同步执行 uninstallRuntime");
290
+ // 事件循环禁令:请求路径上不许出现同步子进程调用
291
+ assert.ok(!/spawnSync\(/.test(route), "卸载请求路径不得出现 spawnSync(会把事件循环钉死)");
292
+ });
293
+
294
+ test("行为:响应里插件引用已经卸掉(不依赖慢步骤先跑完)", async () => {
295
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-order-"));
296
+ const prevHome = process.env.HOME;
297
+ const prevSkip = process.env.DSH_RELAY_SKIP_SERVICE;
298
+ process.env.HOME = home;
299
+ process.env.DSH_RELAY_SKIP_SERVICE = "1";
300
+ try {
301
+ const { patchFile } = await plantProfile(home);
302
+ const routes = boot(path.join(home, "relay"));
303
+ const { host, base } = await serve(routes);
304
+ try {
305
+ const t0 = Date.now();
306
+ const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
307
+ const ms = Date.now() - t0;
308
+ assert.equal(r.removedPatch, true, "响应必须已包含「插件引用已移除」");
309
+ assert.ok(isTopLevelArrayDoc(fs.readFileSync(patchFile, "utf8")), "响应返回时 patch 必须已写好");
310
+ assert.equal(r.runtimeCleanup, "deferred", "运行时清理应交给子进程");
311
+ assert.ok(ms < 5000, `响应不该等运行时清理(实测 ${ms}ms)`);
312
+ } finally {
313
+ host.close();
314
+ }
315
+ } finally {
316
+ if (prevSkip === undefined) delete process.env.DSH_RELAY_SKIP_SERVICE; else process.env.DSH_RELAY_SKIP_SERVICE = prevSkip;
317
+ process.env.HOME = prevHome;
318
+ fs.rmSync(home, { recursive: true, force: true });
319
+ }
320
+ });
321
+
322
+ // ─────────────────────── ③ 运行时清理助手 ───────────────────────
323
+
324
+ /** 从插件源码里取出助手生成函数(安装器/插件都是 CLI+ESM,import 会执行 main)。 */
325
+ function loadCleanupHelperBuilder() {
326
+ const src = INDEX_SRC.slice(
327
+ INDEX_SRC.indexOf("function buildRuntimeCleanupHelper("),
328
+ INDEX_SRC.indexOf("/**\n * 把「运行时清理」交给一个 detached 子进程"));
329
+ return new Function("osPlatform", "WIN_TASK_NAME", "WATCHER_PID_FILE", "BRIDGE_PID_FILE", "join", "homedir", "tmpdir",
330
+ `${src}; return buildRuntimeCleanupHelper;`)(
331
+ () => process.platform, "dsh-remote-bridge", ".dsh-watcher.pid", ".dsh-bridge.pid", path.join, os.homedir, os.tmpdir);
332
+ }
333
+
334
+ test("清理助手:生成的脚本语法合法,且每个外部命令都有 timeout(超时不能当唯一防线,但要有)", () => {
335
+ const src = loadCleanupHelperBuilder()(path.join(os.tmpdir(), "relay-x"), path.join(os.tmpdir(), "prof-x"));
336
+ const file = path.join(os.tmpdir(), `dsh-helper-check-${process.pid}.mjs`);
337
+ fs.writeFileSync(file, src);
338
+ try {
339
+ const chk = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
340
+ assert.equal(chk.status, 0, `生成的清理助手语法必须合法:${chk.stderr}`);
341
+ } finally {
342
+ fs.rmSync(file, { force: true });
343
+ }
344
+ // 外部命令一律经 run(cmd, args, timeout)
345
+ const calls = src.match(/run\("[a-z]+", \[[^\]]*\], (\d+)\)/g) || [];
346
+ assert.ok(calls.length >= 2, `应通过 run(...) 调外部命令(实际 ${calls.length} 处)`);
347
+ assert.ok(!/spawnSync\("[a-z]+", \[[^\]]*\](?!, \{)/.test(src), "不得有裸 spawnSync(缺 timeout)");
348
+ assert.ok(/DSH_RELAY_SKIP_SERVICE === "1"/.test(src), "助手必须遵守测试隔离开关(否则用例会动到真实服务)");
349
+ });
350
+
351
+ test("清理助手:真跑一遍 → 结束 pid 文件里的进程并清空配置目录", async () => {
352
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-helper-"));
353
+ const relayDir = path.join(home, "relay");
354
+ fs.mkdirSync(relayDir, { recursive: true });
355
+ fs.writeFileSync(path.join(relayDir, ".dsh-config.json"), JSON.stringify({ phone: "x", password: "y" }));
356
+ // 用一个真实存活的长命进程当"残留 bridge"
357
+ const sleeper = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: "ignore" });
358
+ fs.writeFileSync(path.join(relayDir, ".dsh-bridge.pid"), String(sleeper.pid));
359
+ const src = loadCleanupHelperBuilder()(relayDir, path.join(home, "prof"));
360
+ const helper = path.join(home, "helper.mjs");
361
+ fs.writeFileSync(helper, src);
362
+ try {
363
+ // 注意:这里**不**设隔离开关,用来验证真实的 kill 路径;
364
+ // 但 HOME 已指向临时目录 → 自启动 plist 路径落在临时目录、不存在 → 不会碰真实 launchd。
365
+ // 显式去掉隔离开关:本用例要验证**真实**的 kill 路径;HOME 指向临时目录保证不碰真实服务
366
+ const env = { ...process.env, HOME: home };
367
+ delete env.DSH_RELAY_SKIP_SERVICE;
368
+ const r = spawnSync(process.execPath, [helper], { encoding: "utf8", timeout: 30000, env });
369
+ assert.equal(r.status, 0, `助手应正常退出:${r.stderr}`);
370
+ assert.equal(fs.existsSync(relayDir), false, "★配置目录必须被清空");
371
+ assert.equal(fs.existsSync(path.join(relayDir, ".dsh-config.json")), false);
372
+ // pid 文件里的进程应被结束
373
+ let alive = true;
374
+ for (let i = 0; i < 40; i += 1) {
375
+ try { process.kill(sleeper.pid, 0); } catch { alive = false; break; }
376
+ await sleep(50);
377
+ }
378
+ assert.equal(alive, false, "★pid 文件里记录的残留进程必须被结束");
379
+ } finally {
380
+ try { sleeper.kill("SIGKILL"); } catch { /* 已退出 */ }
381
+ fs.rmSync(home, { recursive: true, force: true });
382
+ }
383
+ });
384
+
385
+ test("清理助手:护栏命中时绝不删 profile(relayDir 误配成 profile 的兜底)", () => {
386
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-helper-guard-"));
387
+ const profile = path.join(home, "prof");
388
+ fs.mkdirSync(profile, { recursive: true });
389
+ fs.writeFileSync(path.join(profile, "package.json"), "{}");
390
+ const src = loadCleanupHelperBuilder()(profile, profile); // relayDir === protectedPath
391
+ const helper = path.join(home, "helper.mjs");
392
+ fs.writeFileSync(helper, src);
393
+ try {
394
+ spawnSync(process.execPath, [helper], { encoding: "utf8", timeout: 30000, env: { ...process.env, HOME: home, DSH_RELAY_SKIP_SERVICE: "1" } });
395
+ assert.equal(fs.existsSync(path.join(profile, "package.json")), true, "★护栏命中:profile 必须完好");
396
+ } finally {
397
+ fs.rmSync(home, { recursive: true, force: true });
398
+ }
399
+ });
400
+
401
+ // ─────────────────────── ④ Windows ACL 收紧(mode 0600 是空操作) ───────────────────────
402
+
403
+ test("安全:三处机密写入都要显式收紧 ACL(Windows 上 mode 0o600 无效)", () => {
404
+ for (const [label, src, callSites] of [
405
+ ["安装器", SETUP_SRC, [/hardenFile\(CONFIG_PATH\)/]],
406
+ ["插件半", INDEX_SRC, [/hardenFile\(configPathOf\(relayDir\)\)/, /hardenFile\(join\(relayDir, HARNESS_COOKIE_FILE\)\)/]],
407
+ ["bridge", fs.readFileSync(path.join(HERE, "..", "..", "..", "clients", "dsh-remote", "dsh-bridge.mjs"), "utf8"), [/hardenFile\(CONFIG_PATH\)/]],
408
+ ]) {
409
+ assert.ok(/function hardenFile\(file\)/.test(src), `${label} 应有 hardenFile`);
410
+ assert.ok(/icacls/.test(src) && /inheritance:r/.test(src), `${label} 必须在 Windows 上断开继承并只授权当前用户`);
411
+ assert.ok(/process\.platform !== "win32"/.test(src), `${label} 非 Windows 走 chmod 即可`);
412
+ for (const re of callSites) assert.ok(re.test(src), `${label} 的机密写入点必须调用 hardenFile:${re}`);
413
+ }
414
+ });
415
+
416
+ test("安全:hardenFile 在非 Windows 上只 chmod(不引入任何子进程,零副作用)", () => {
417
+ const src = SETUP_SRC.slice(SETUP_SRC.indexOf("function hardenFile(file)"), SETUP_SRC.indexOf("// ---------- 配置读写 ----------"));
418
+ const calls = [];
419
+ const fakeSpawn = () => { calls.push("spawn"); return { status: 0 }; };
420
+ const fakeFs = { ...fs, chmodSync: (f, m) => { calls.push(`chmod:${m.toString(8)}`); } };
421
+ const harden = new Function("fs", "spawnSync", "process", "hardenWarned", `${src}; return hardenFile;`)(fakeFs, fakeSpawn, process, false);
422
+ const file = path.join(os.tmpdir(), `dsh-harden-${process.pid}.tmp`);
423
+ fs.writeFileSync(file, "x");
424
+ try {
425
+ harden(file);
426
+ if (process.platform !== "win32") {
427
+ assert.deepEqual(calls, ["chmod:600"], `非 Windows 只应 chmod 600,实际 ${JSON.stringify(calls)}`);
428
+ }
429
+ } finally {
430
+ fs.rmSync(file, { force: true });
431
+ }
432
+ });
433
+
434
+ // ─────────────────────── ⑤ 前端:请求超时 ───────────────────────
435
+
436
+ test("前端:任何请求都有超时上界(node 半僵死时不再永久转圈)", () => {
437
+ assert.ok(/var API_TIMEOUT_MS = \d+/.test(CLIENT_SRC), "应有默认请求超时");
438
+ assert.ok(/AbortController/.test(CLIENT_SRC), "应通过 AbortController 实现超时");
439
+ assert.ok(/postWithTimeout\("\/dsh-remote\/self\/uninstall"/.test(CLIENT_SRC), "卸载这类可能拖住的操作应使用更短超时");
440
+ assert.ok(/请求超时/.test(CLIENT_SRC), "超时后必须给用户一句能照做的话");
441
+ assert.ok(/clearTimeout\(timer\)/.test(CLIENT_SRC), "请求结束必须清掉定时器(否则泄漏)");
442
+ });