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

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.
package/README.md CHANGED
@@ -30,6 +30,12 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
30
30
 
31
31
  ## 0.6.7 速览(Windows 可用版)
32
32
 
33
+ > **预发中**:先以 `0.6.7-beta.x` 发布在 npm `beta` 通道(`latest` 仍是 0.6.6),
34
+ > 待 Windows 真机验证通过后转正式版。安装预发版:
35
+ > `npx @mrrisega/dsh-remote@0.6.7-beta.3`;若 dsh web 已起不来,用
36
+ > `npx @mrrisega/dsh-remote@0.6.7-beta.3 repair`(只修 profile,不联网)。
37
+
38
+
33
39
  0.6.6 及更早的插件半整套「服务状态 / 启停 / 重启」是按 macOS/Linux 写死的
34
40
  (launchctl / systemd / pgrep / ps / /bin/sh),**在 Windows 上安装一切正常、运行期必死**:
35
41
  面板红字「读取状态失败: process.getuid is not a function」、状态永远停在「查询中…」、
@@ -154,8 +160,16 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
154
160
  校验值用于登录,与加密密钥域分离(详见 [docs/e2ee-protocol.md](docs/e2ee-protocol.md))。
155
161
  - 请**牢记账号密码**:修改/重置密码会使全部旧设备会话失效,且服务端不保存你的内容、
156
162
  无法代为解密旧会话;改密后需在电脑端面板重新登录并重启 bridge,手机端重新解锁。
157
- - 电脑端本机配置(`.dsh-config.json`,0600)会保存账号密码用于自动登录,等于该账号
163
+ - 电脑端本机配置(`.dsh-config.json`)会保存账号密码用于自动登录,等于该账号
158
164
  内容的“解密权”,请妥善保护电脑;电脑被他人使用期间请退出登录。
165
+ - macOS / Linux:文件权限 **0600**(仅本人可读写)。
166
+ - Windows:**`0600` 在 Windows 上无效**(Windows 用 ACL,不是 POSIX 权限位)——
167
+ 早期版本只写了 `mode: 0o600`,实测该文件拿到的仍是用户目录的**默认继承 ACL**,
168
+ 等于没有这层保护。0.6.7-beta.3 起安装器/插件/bridge 都会显式收紧:`icacls` 断开继承、
169
+ 只授予当前用户。若收紧失败(无 icacls / 权限异常),安装日志会明确告警,不会假装成功。
170
+ - 无论哪个平台:**不要把 `.dsh-remote` 目录交给备份/同步盘/他人排查**——
171
+ 里面的 `.dsh-config.json`(明文账号密码)与 `.harness-cookie.json`(dsh web 会话 Cookie,
172
+ 等于该会话的完整访问权)被复制走就等于把钥匙一起给了对方。发支持包前请先删除这两项。
159
173
 
160
174
  **边界与建议(如实告知)**
161
175
 
@@ -65,7 +65,7 @@ import WebSocket from "ws";
65
65
  import fs from "node:fs";
66
66
  import os from "node:os";
67
67
  import path from "node:path";
68
- import { execSync } from "node:child_process";
68
+ import { execSync, spawnSync } from "node:child_process";
69
69
  import { randomBytes, generateKeyPairSync, createHash } from "node:crypto";
70
70
  import { pathToFileURL, fileURLToPath } from "node:url";
71
71
  import { promisify } from "node:util";
@@ -200,10 +200,40 @@ function loadLocalConfig() {
200
200
  try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
201
201
  }
202
202
 
203
+ /**
204
+ * 把文件权限收紧到「只有本人可读写」。
205
+ *
206
+ * ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL,不是 POSIX mode 位):
207
+ * 实测 .dsh-config.json 拿到的是用户目录的默认**继承 ACL**(AreAccessRulesProtected=False),
208
+ * 也就是说代码里那句 0600 对 Windows 用户完全没效果 —— 而文件里是**明文账号密码**。
209
+ * 风险不在"同机他人可读"(默认 ACL 下其实读不到),而在**任何按文件复制的场景**
210
+ * (备份/同步盘/杀软上报/崩溃转储/用户发给作者的支持包)都会连钥匙一起被带走,
211
+ * 而文档里的"0600 请妥善保护"会让用户误以为 Windows 上已有这层保护。
212
+ * 这里显式断开继承、只授予当前用户;失败只警告一次,绝不影响主流程。
213
+ */
214
+ let hardenWarned = false;
215
+ function hardenFile(file) {
216
+ try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
217
+ if (process.platform !== "win32") return;
218
+ const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
219
+ if (!who) return;
220
+ let r;
221
+ try {
222
+ r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
223
+ { windowsHide: true, encoding: "utf8", timeout: 8000 });
224
+ } catch (e) { r = { status: -1, stderr: e.message }; }
225
+ if (r.status !== 0 && !hardenWarned) {
226
+ hardenWarned = true;
227
+ console.warn(`⚠️ 收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`);
228
+ console.warn(" 配置文件可能仍可被其它账户/备份工具读取,请自行确认其存放位置。");
229
+ }
230
+ }
231
+
203
232
  function saveLocalConfig(cfg) {
204
233
  try {
205
234
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
206
235
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
236
+ hardenFile(CONFIG_PATH); // Windows 上 mode 是空操作 → 显式收紧 ACL(内含明文账号密码)
207
237
  } catch (e) {
208
238
  console.warn(`[bridge] 无法写配置 ${CONFIG_PATH}: ${e.message}`);
209
239
  }
package/dsh-setup.mjs CHANGED
@@ -257,6 +257,35 @@ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/
257
257
  const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
258
258
  const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
259
259
 
260
+ /**
261
+ * 把文件权限收紧到「只有本人可读写」。
262
+ *
263
+ * ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL,不是 POSIX mode 位):
264
+ * 实测 .dsh-config.json 拿到的是用户目录的默认**继承 ACL**(AreAccessRulesProtected=False),
265
+ * 也就是说代码里那句 0600 对 Windows 用户完全没效果 —— 而文件里是**明文账号密码**。
266
+ * 风险不在"同机他人可读"(默认 ACL 下其实读不到),而在**任何按文件复制的场景**
267
+ * (备份/同步盘/杀软上报/崩溃转储/用户发给作者的支持包)都会连钥匙一起被带走,
268
+ * 而文档里的"0600 请妥善保护"会让用户误以为 Windows 上已有这层保护。
269
+ * 这里显式断开继承、只授予当前用户;失败只警告一次,绝不影响主流程。
270
+ */
271
+ let hardenWarned = false;
272
+ function hardenFile(file) {
273
+ try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
274
+ if (process.platform !== "win32") return;
275
+ const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
276
+ if (!who) return;
277
+ let r;
278
+ try {
279
+ r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
280
+ { windowsHide: true, encoding: "utf8", timeout: 8000 });
281
+ } catch (e) { r = { status: -1, stderr: e.message }; }
282
+ if (r.status !== 0 && !hardenWarned) {
283
+ hardenWarned = true;
284
+ console.warn(`⚠️ 收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`);
285
+ console.warn(" 配置文件可能仍可被其它账户/备份工具读取,请自行确认其存放位置。");
286
+ }
287
+ }
288
+
260
289
  // ---------- 配置读写 ----------
261
290
  function loadConfig() {
262
291
  try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); }
@@ -265,6 +294,7 @@ function loadConfig() {
265
294
  function saveConfig(cfg) {
266
295
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
267
296
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
297
+ hardenFile(CONFIG_PATH); // Windows 上 mode 是空操作 → 必须显式收紧 ACL(内含明文账号密码)
268
298
  }
269
299
 
270
300
  // ---------- 公共配置(从服务端取域名,服务商可随时更换) ----------
@@ -754,7 +784,7 @@ await delay(1000);
754
784
  try { process.kill(PLAN.pid); } catch (e) {}
755
785
  const deadline = Date.now() + 30000;
756
786
  while (Date.now() < deadline && alive(PLAN.pid)) await delay(200);
757
- if (alive(PLAN.pid)) spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true });
787
+ if (alive(PLAN.pid)) spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true, timeout: 8000 });
758
788
  let out = "ignore";
759
789
  try { out = openSync(PLAN.log, "a"); } catch (e) {}
760
790
  const child = spawn(PLAN.argv[0], PLAN.argv.slice(1), { detached: true, stdio: ["ignore", out, out], windowsHide: true });
@@ -1328,6 +1358,48 @@ function validatePatchBase(text) {
1328
1358
  return { ok: true, cleaned, insertedIds: ids };
1329
1359
  }
1330
1360
 
1361
+ /**
1362
+ * patch 文本是否是「合法的顶层 YAML 数组文档」。
1363
+ *
1364
+ * 为什么必须判:dsh 侧解析 cordis.patch.yml 时**要求顶层是数组**,而
1365
+ * **纯注释文档的 YAML 解析结果是 `null`(不是空数组)**,于是 dsh 直接启动失败:
1366
+ * `Error: dsh: overlay …/cordis.patch.yml must be a top-level YAML array of loader patch entries`
1367
+ * 官方空 profile 模板(dsh-app-boot)正是「注释 + `[]`」——`[]` 这个占位符不能省。
1368
+ * 现场(2026-09-18 Windows 用户实测):卸载把插件条目摘掉后只剩顶部注释,dsh web 再也起不来。
1369
+ */
1370
+ /** 顶层(无缩进)非注释行 = YAML 的结构行;缩进行属于上一条目。 */
1371
+ function patchStructuralLines(text) {
1372
+ return String(text ?? "")
1373
+ .split(/\r?\n/)
1374
+ .map((l) => l.replace(/\s+$/, ""))
1375
+ .filter((l) => l.trim() !== "" && !/^\s*#/.test(l) && !/^[ \t]/.test(l));
1376
+ }
1377
+
1378
+ /** 是否是合法的顶层数组文档(`[]` 占位,或若干 `-` 开头的顶层条目)。 */
1379
+ function isPatchArrayDocument(text) {
1380
+ const roots = patchStructuralLines(text);
1381
+ return roots.length > 0 && roots.every((l) => l === "[]" || l.startsWith("-"));
1382
+ }
1383
+
1384
+ /** 只有注释/空行 → YAML 解析成 null(不是空数组),必须补 `[]` 占位,否则 dsh 起不来。 */
1385
+ function needsArrayPlaceholder(text) {
1386
+ return patchStructuralLines(text).length === 0;
1387
+ }
1388
+
1389
+ /**
1390
+ * 写回 patch 文档,并**保证结果仍是合法顶层数组**:条目被删光时补回 `[]` 占位。
1391
+ * 卸载、摘 include、回滚都必须走这里 —— 任何一条写路径漏了这步,用户就会拿到
1392
+ * 一个「dsh web 起不来且面板也进不去」的 profile(只能靠 CLI 自救)。
1393
+ */
1394
+ function writePatchDocument(patchFile, text) {
1395
+ const body = String(text ?? "").replace(/\s+$/, "") + "\n";
1396
+ // ⚠️ 只在「一个结构行都不剩」时补 —— 不能因为"看起来不像数组"就乱补,
1397
+ // 那会把本来好的文件写坏(把 `[]` 追加到已有 insert 块后面 → YAML 直接报错)。
1398
+ const fixed = needsArrayPlaceholder(body) ? `${body}[]\n` : body;
1399
+ writePatchAtomic(patchFile, fixed);
1400
+ return fixed;
1401
+ }
1402
+
1331
1403
  /** 安全写回 patch(原子:先写同目录临时文件再 rename,避免中途被打断留下半截文件)。 */
1332
1404
  function writePatchAtomic(patchFile, text) {
1333
1405
  const tmp = `${patchFile}.dsh-remote.tmp`;
@@ -1384,7 +1456,7 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
1384
1456
  const merged2 = `${base2.cleaned ? base2.cleaned + "\n" : ""}\n${want}\n`;
1385
1457
  const chk = parsePatchInsertIds(merged2);
1386
1458
  if (!chk.badLines.length && chk.ids.filter((id) => id === PLUGIN_ID).length === 1) {
1387
- writePatchAtomic(patchFile, merged2);
1459
+ writePatchDocument(patchFile, merged2);
1388
1460
  console.log(`✅ 已同步激活行的 relayDir → ${wantDir}`);
1389
1461
  return { ok: true, changed: true, basePatch: original };
1390
1462
  }
@@ -1407,18 +1479,18 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
1407
1479
  if (check.badLines.length || check.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
1408
1480
  return { ok: false, reason: "合并后的 patch 校验未通过(未做改动)", changed: false, basePatch: original };
1409
1481
  }
1410
- writePatchAtomic(patchFile, merged);
1482
+ writePatchDocument(patchFile, merged);
1411
1483
  const after = fs.readFileSync(patchFile, "utf8");
1412
1484
  const verify = parsePatchInsertIds(after);
1413
1485
  if (verify.badLines.length || verify.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
1414
- writePatchAtomic(patchFile, original); // 回滚
1486
+ writePatchDocument(patchFile, original); // 回滚
1415
1487
  return { ok: false, reason: "写入后校验失败,已回滚", changed: false, basePatch: original };
1416
1488
  }
1417
1489
  removeBundleEntry(pkgFile); // 单一激活点:bundles 里不能再有本插件
1418
1490
  // 双保险:上面那次移除若没生效(并发写 package.json / 解析失败),**回滚本次 patch 行**,
1419
1491
  // 宁可退回 bundles 形态(需重启)也绝不留"两处激活"→ dsh web 启动会 duplicate id 崩掉。
1420
1492
  if (pkgBundles().length) {
1421
- writePatchAtomic(patchFile, original);
1493
+ writePatchDocument(patchFile, original);
1422
1494
  return { ok: false, reason: "bundles 条目未能移除,已回滚 patch 行以免重复激活", changed: false, bundleOnly: true, basePatch: original };
1423
1495
  }
1424
1496
  return { ok: true, changed: true, basePatch: original };
@@ -1428,7 +1500,7 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, opts = {}) {
1428
1500
  function stripIncludeEntries(patchFile, patch, reason) {
1429
1501
  const next = stripPluginEntries(patch);
1430
1502
  if (next === patch) return false;
1431
- writePatchAtomic(patchFile, next);
1503
+ writePatchDocument(patchFile, next);
1432
1504
  console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活点必须唯一,避免重复 ID 崩溃)`);
1433
1505
  return true;
1434
1506
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.7-beta.2",
3
+ "version": "0.6.7-beta.3",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -340,8 +340,35 @@ window.__ModuleLoader__.load({
340
340
  }
341
341
 
342
342
  // ── 宿主 API ────────────────────────────────────────────────────────────
343
- function api(path, options) {
344
- return fetch(path, options).then(function (res) {
343
+ /**
344
+ * 默认请求超时(0.6.7-beta.3 新增)。
345
+ * 背景(用户实测):node 半一旦被同步子进程调用钉死(卸载时 spawnSync 卡在 schtasks 上),
346
+ * 请求**永远不会返回**,面板就对着一个永不结束的 spinner —— 用户只能猜。
347
+ * 现在超时后给出明确报错(并提示"这一步可能已经在后台完成了"),而不是无限转圈。
348
+ * 30s 足够覆盖最慢的正常路径(relay 6s + Windows 进程扫描 8s)。
349
+ */
350
+ var API_TIMEOUT_MS = 30000;
351
+ function api(path, options, timeoutMs) {
352
+ var opts = options || {};
353
+ var timer = null;
354
+ try {
355
+ if (typeof AbortController === "function") {
356
+ var ctrl = new AbortController();
357
+ var ms = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : API_TIMEOUT_MS;
358
+ timer = setTimeout(function () { try { ctrl.abort(); } catch (e) { /* 忽略 */ } }, ms);
359
+ opts = Object.assign({}, opts, { signal: ctrl.signal });
360
+ }
361
+ } catch (e) { /* 无 AbortController 的旧环境:退化为无超时(不阻断使用) */ }
362
+ var done = function () { if (timer) { clearTimeout(timer); timer = null; } };
363
+ return fetch(path, opts).catch(function (e) {
364
+ // AbortError → 换成用户能看懂的话(并说明服务端可能已经做完了)
365
+ if (e && (e.name === "AbortError" || /abort/i.test(String(e.message || "")))) {
366
+ var te = new Error("请求超时(面板等不到本机响应)。这一步可能已经在后台完成了 —— 请刷新页面查看;若 dsh web 无响应,重启一次 dsh web 即可。");
367
+ te.timeout = true;
368
+ throw te;
369
+ }
370
+ throw e;
371
+ }).finally(done).then(function (res) {
345
372
  return res.text().then(function (text) {
346
373
  var body = null;
347
374
  try { body = JSON.parse(text); } catch (e) { body = null; }
@@ -358,6 +385,10 @@ window.__ModuleLoader__.load({
358
385
  var post = function (path, data) {
359
386
  return api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data || {}) });
360
387
  };
388
+ /** 带自定义超时的 POST(用于卸载这类"可能拖住"的操作)。 */
389
+ var postWithTimeout = function (path, data, timeoutMs) {
390
+ return api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data || {}) }, timeoutMs);
391
+ };
361
392
 
362
393
  // ── 交流群(二维码由图层面板上传统一配置,node 半经公开配置下发) ─────────
363
394
  // 设置面板「加入交流群」按钮与用户反馈页都展示同一张码;未配置(qrcode 为空)→ 不展示任何入口。
@@ -1066,7 +1097,9 @@ window.__ModuleLoader__.load({
1066
1097
  if (!armed) { setArmed(true); return; }
1067
1098
  setUnBusy(true);
1068
1099
  setSelfMsg(null);
1069
- post("/dsh-remote/self/uninstall", {}).then(function (b) {
1100
+ // 卸载:服务端现在"先卸插件、先回响应、慢活交子进程",正常应在 1 秒内返回;
1101
+ // 给 20s 上界,超时也要给用户一句能照做的话(而不是永久转圈)。
1102
+ postWithTimeout("/dsh-remote/self/uninstall", {}, 20000).then(function (b) {
1070
1103
  if (b && b.ok) {
1071
1104
  setArmed(false);
1072
1105
  // 优先展示服务端 detail(含 bridge 自启动/配置目录的逐项清理结果与重启提示);
@@ -1079,7 +1112,7 @@ window.__ModuleLoader__.load({
1079
1112
  }
1080
1113
  }).catch(function (e) {
1081
1114
  setArmed(false);
1082
- setSelfMsg({ kind: "err", text: "卸载失败:" + e.message });
1115
+ setSelfMsg({ kind: e && e.timeout ? "warn" : "err", text: (e && e.timeout ? "" : "卸载失败:") + e.message });
1083
1116
  }).finally(function () { setUnBusy(false); });
1084
1117
  };
1085
1118
 
@@ -20,11 +20,11 @@
20
20
  // - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
21
21
  //
22
22
  // 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
23
- import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, statSync, constants as fsConstants } from "node:fs";
23
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, constants as fsConstants } from "node:fs";
24
24
  import { join, dirname, sep, delimiter } from "node:path";
25
25
  import { execSync, spawn, spawnSync } from "node:child_process";
26
26
  import { randomUUID } from "node:crypto";
27
- import { homedir } from "node:os";
27
+ import { homedir, tmpdir } from "node:os";
28
28
  import { fileURLToPath } from "node:url";
29
29
 
30
30
  /** 本插件在 host 侧的服务依赖。 */
@@ -86,6 +86,35 @@ const DEFAULT_RELAY_DIR = process.env.DSH_RELAY_DIR || join(homedir(), ".dsh-rem
86
86
  const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
87
87
  const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
88
88
 
89
+ /**
90
+ * 把文件权限收紧到「只有本人可读写」。
91
+ *
92
+ * ⚠️ Windows 上 `{ mode: 0o600 }` 是**空操作**(Windows 用 ACL,不是 POSIX mode 位):
93
+ * 实测 .dsh-config.json 拿到的是用户目录的默认**继承 ACL**(AreAccessRulesProtected=False),
94
+ * 也就是说代码里那句 0600 对 Windows 用户完全没效果 —— 而文件里是**明文账号密码**。
95
+ * 风险不在"同机他人可读"(默认 ACL 下其实读不到),而在**任何按文件复制的场景**
96
+ * (备份/同步盘/杀软上报/崩溃转储/用户发给作者的支持包)都会连钥匙一起被带走,
97
+ * 而文档里的"0600 请妥善保护"会让用户误以为 Windows 上已有这层保护。
98
+ * 这里显式断开继承、只授予当前用户;失败只警告一次,绝不影响主流程。
99
+ */
100
+ let hardenWarned = false;
101
+ function hardenFile(file) {
102
+ try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
103
+ if (process.platform !== "win32") return;
104
+ const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
105
+ if (!who) return;
106
+ let r;
107
+ try {
108
+ r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
109
+ { windowsHide: true, encoding: "utf8", timeout: 8000 });
110
+ } catch (e) { r = { status: -1, stderr: e.message }; }
111
+ if (r.status !== 0 && !hardenWarned) {
112
+ hardenWarned = true;
113
+ console.warn(`⚠️ 收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`);
114
+ console.warn(" 配置文件可能仍可被其它账户/备份工具读取,请自行确认其存放位置。");
115
+ }
116
+ }
117
+
89
118
  // ---------- 小工具 ----------
90
119
 
91
120
  /** 执行 shell 命令,不抛异常,返回 { ok, stdout, stderr, code }。 */
@@ -334,6 +363,7 @@ function saveConfig(relayDir, cfg) {
334
363
  mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
335
364
  // mode 0o600:与 dsh-setup.mjs 一致(文件已存在时 writeFileSync 不改权限,显式 chmod 兜底)
336
365
  writeFileSync(configPathOf(relayDir), JSON.stringify(cfg, null, 2), { mode: 0o600 });
366
+ hardenFile(configPathOf(relayDir)); // Windows 上 mode 是空操作 → 显式收紧 ACL(内含明文账号密码)
337
367
  try {
338
368
  chmodSync(configPathOf(relayDir), 0o600);
339
369
  } catch {
@@ -412,6 +442,7 @@ async function mintHarnessCookie(ctx, relayDir) {
412
442
  const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
413
443
  mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
414
444
  writeFileSync(join(relayDir, HARNESS_COOKIE_FILE), JSON.stringify(out, null, 2), { mode: 0o600 });
445
+ hardenFile(join(relayDir, HARNESS_COOKIE_FILE)); // 会话 Cookie ≈ dsh web 的完整访问权,同样要收紧
415
446
  return true;
416
447
  } catch {
417
448
  return false;
@@ -1459,7 +1490,7 @@ const deadline = Date.now() + 30000;
1459
1490
  while (Date.now() < deadline && alive(PLAN.pid)) await delay(200);
1460
1491
  if (alive(PLAN.pid)) {
1461
1492
  logLine("旧进程 30s 未退出,改用 taskkill /T /F");
1462
- spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true });
1493
+ spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true, timeout: 8000 });
1463
1494
  await delay(500);
1464
1495
  }
1465
1496
 
@@ -2302,7 +2333,7 @@ const PLUGIN_ID = "dsh-remote-web";
2302
2333
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
2303
2334
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
2304
2335
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
2305
- const PLUGIN_VERSION = "0.6.7-beta.2";
2336
+ const PLUGIN_VERSION = "0.6.7-beta.3";
2306
2337
  const UPDATE_LOG = ".dsh-update.log";
2307
2338
  const UPDATE_MARKER = ".dsh-update-running";
2308
2339
 
@@ -2507,17 +2538,204 @@ function tailOf(filePath, lines = 24) {
2507
2538
  } catch { return ""; }
2508
2539
  }
2509
2540
 
2541
+ /**
2542
+ * 判断 patch 文本是否是「合法的顶层 YAML 数组文档」。
2543
+ *
2544
+ * 为什么必须判(2026-09-18 Windows 用户实测的致命事故):dsh 解析 cordis.patch.yml 时
2545
+ * **要求顶层是数组**,而**纯注释文档的 YAML 解析结果是 `null` 而不是空数组**,于是 dsh 直接:
2546
+ * `Error: dsh: overlay …/cordis.patch.yml must be a top-level YAML array of loader patch entries`
2547
+ * 官方空 profile 模板是「注释 + `[]`」——`[]` 这个占位符不能省。
2548
+ * 卸载把插件条目摘掉后若只剩注释,用户就再也起不来 dsh web(面板也进不去,只能靠 CLI 自救)。
2549
+ */
2550
+ /** 顶层(无缩进)非注释行 = YAML 的结构行;缩进行属于上一条目。 */
2551
+ function patchStructuralLines(text) {
2552
+ return String(text ?? "")
2553
+ .split(/\r?\n/)
2554
+ .map((l) => l.replace(/\s+$/, ""))
2555
+ .filter((l) => l.trim() !== "" && !/^\s*#/.test(l) && !/^[ \t]/.test(l));
2556
+ }
2557
+
2558
+ /** 是否是合法的顶层数组文档(`[]` 占位,或若干 `-` 开头的顶层条目)。 */
2559
+ function isPatchArrayDocument(text) {
2560
+ const roots = patchStructuralLines(text);
2561
+ return roots.length > 0 && roots.every((l) => l === "[]" || l.startsWith("-"));
2562
+ }
2563
+
2564
+ /** 只有注释/空行 → YAML 解析成 null(不是空数组),必须补 `[]` 占位,否则 dsh 起不来。 */
2565
+ function needsArrayPlaceholder(text) {
2566
+ return patchStructuralLines(text).length === 0;
2567
+ }
2568
+
2569
+ /** 摘掉本插件的 patch 条目:管理标记块 + 无标记的 `- insert:` 块(当前名与历史名都清)。 */
2570
+ function stripOwnEntriesFromPatch(patch) {
2571
+ // ① 管理标记块(dsh-setup 写入的形式)
2572
+ // 结尾标记后面可能还有残留文字(历史变体),用 [^\n]* 吃掉整行,避免留下半截内容
2573
+ const out = String(patch ?? "").replace(/\n?# >>> dsh-remote-(?:web|ui)[^\n]*\n[\s\S]*?# <<< dsh-remote-(?:web|ui)[^\n]*\n?/g, "\n");
2574
+ // ② 无标记的 insert 块(旧版 / 其它工具写入的形式,历史事故里出现过)。
2575
+ // 只摘标记块是不够的:目录已删而 patch 仍引用 → dsh 插件树加载失败 → 整个 dsh web 起不来。
2576
+ const lines = out.split("\n");
2577
+ const kept = [];
2578
+ let i = 0;
2579
+ while (i < lines.length) {
2580
+ if (/^- insert:\s*$/.test(lines[i])) {
2581
+ const block = [lines[i]];
2582
+ let j = i + 1;
2583
+ while (j < lines.length && (lines[j].startsWith(" ") || lines[j].startsWith("\t"))) { block.push(lines[j]); j += 1; }
2584
+ if (PLUGIN_ALL_IDS.some((id) => new RegExp(`\\b${id}\\b`).test(block.join("\n")))) {
2585
+ while (kept.length && /^\s*#/.test(kept[kept.length - 1])) kept.pop(); // 连带条目上方的说明注释
2586
+ i = j;
2587
+ continue;
2588
+ }
2589
+ kept.push(...block);
2590
+ i = j;
2591
+ continue;
2592
+ }
2593
+ kept.push(lines[i]);
2594
+ i += 1;
2595
+ }
2596
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
2597
+ }
2598
+
2599
+ /**
2600
+ * 原子写回 patch,并保证结果仍是合法顶层数组:条目被删光时补回 `[]` 占位。
2601
+ * ⚠️ 只在「一个结构行都不剩」时补 —— 不能因为"看起来不像数组"就乱补,
2602
+ * 那会把本来好的文件写坏(例如把 `[]` 追加到已有 insert 块后面 → YAML 直接报错)。
2603
+ */
2604
+ function writePatchDocument(patchFile, text) {
2605
+ const body = String(text ?? "").replace(/\s+$/, "") + "\n";
2606
+ const fixed = needsArrayPlaceholder(body) ? `${body}[]\n` : body;
2607
+ const tmp = `${patchFile}.dsh-remote.tmp`;
2608
+ writeFileSync(tmp, fixed);
2609
+ renameSync(tmp, patchFile);
2610
+ return fixed;
2611
+ }
2612
+
2613
+ /**
2614
+ * 「运行时清理助手」源码(卸载用;由插件生成、detached 执行)。
2615
+ *
2616
+ * 为什么必须是独立进程(2026-09-18 用户实测的僵死事故):
2617
+ * uninstallRuntime() 里有 spawnSync("schtasks"/"powershell"/"taskkill"),同步子进程调用会把
2618
+ * **dsh web 的事件循环整个钉死** —— 不只是这一个请求不返回,手机端隧道、其它页面、所有 API
2619
+ * 一起停摆,表现为「端口在听、连接建立、但永不响应」。而 spawnSync 的 timeout 在 Windows 上
2620
+ * **并不可靠**(实测被策略拦截的进程 18s+ 仍未返回),所以不能把 timeout 当设计依据:
2621
+ * 真正的解法是不要把同步子进程调用留在宿主进程里。
2622
+ *
2623
+ * 助手做的是「宁可少做也不卡住」的有界清理:杀残留进程 → 删自启动 → 清配置目录,每步独立 try/catch。
2624
+ */
2625
+ function buildRuntimeCleanupHelper(relayDir, protectedPath) {
2626
+ const plan = JSON.stringify({
2627
+ relayDir,
2628
+ protectedPath: protectedPath || "",
2629
+ platform: osPlatform(),
2630
+ taskName: WIN_TASK_NAME,
2631
+ pidFiles: [WATCHER_PID_FILE, BRIDGE_PID_FILE],
2632
+ plistPath: join(homedir(), "Library", "LaunchAgents", "com.dshremote.bridge.plist"),
2633
+ unitPath: join(homedir(), ".config", "systemd", "user", "dsh-bridge.service"),
2634
+ log: join(tmpdir(), "dsh-remote-uninstall.log"),
2635
+ });
2636
+ return `// dsh-remote 自动生成:彻底卸载的「运行时清理」部分(独立进程执行,避免阻塞 dsh web)
2637
+ import { spawnSync } from "node:child_process";
2638
+ import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
2639
+ import { homedir } from "node:os";
2640
+ import { dirname, join, sep } from "node:path";
2641
+
2642
+ const PLAN = ${plan};
2643
+ // 与主进程一致的测试隔离开关:置位时**绝不做任何真实系统操作**(不碰 launchctl/systemctl/schtasks/taskkill),
2644
+ // 但仍清空配置目录 —— 否则用例会去动开发者本机真实的自启动服务(历史事故,见 setup-output 用例的护栏)。
2645
+ const SKIP_SYSTEM_OPS = process.env.DSH_RELAY_SKIP_SERVICE === "1";
2646
+ function log(m) { try { appendFileSync(PLAN.log, "\\n[" + new Date().toISOString() + "] " + m + "\\n"); } catch (e) { /* 日志失败不阻断清理 */ } }
2647
+ function run(cmd, args, timeout) {
2648
+ try { const r = spawnSync(cmd, args, { windowsHide: true, encoding: "utf8", timeout }); return r.status === 0; }
2649
+ catch (e) { log("命令失败 " + cmd + ": " + e.message); return false; }
2650
+ }
2651
+ function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
2652
+ function readPid(name) {
2653
+ try { const n = Number(String(readFileSync(join(PLAN.relayDir, name), "utf8")).trim()); return Number.isInteger(n) && n > 0 ? n : null; }
2654
+ catch (e) { return null; }
2655
+ }
2656
+ log("运行时清理开始(platform=" + PLAN.platform + ",relayDir=" + PLAN.relayDir + ")");
2657
+ if (SKIP_SYSTEM_OPS) log("测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过一切真实系统操作");
2658
+
2659
+ // ① 结束 watcher / bridge 残留进程
2660
+ for (const name of (SKIP_SYSTEM_OPS ? [] : PLAN.pidFiles)) {
2661
+ const pid = readPid(name);
2662
+ if (!pid || pid === process.pid) continue;
2663
+ if (!alive(pid)) { log(name + " 里的 pid " + pid + " 已不在"); continue; }
2664
+ if (PLAN.platform === "win32") run("taskkill", ["/PID", String(pid), "/T", "/F"], 8000);
2665
+ else { try { process.kill(pid, "SIGTERM"); } catch (e) { /* 已退出 */ } }
2666
+ log("已结束 " + name + " 的进程 pid=" + pid);
2667
+ }
2668
+
2669
+ // ② 移除自启动(按平台)
2670
+ if (SKIP_SYSTEM_OPS) {
2671
+ log("跳过自启动清理(测试隔离)");
2672
+ } else if (PLAN.platform === "win32") {
2673
+ run("schtasks", ["/Delete", "/TN", PLAN.taskName, "/F"], 15000);
2674
+ log("已请求删除登录任务 " + PLAN.taskName);
2675
+ } else if (PLAN.platform === "darwin") {
2676
+ if (existsSync(PLAN.plistPath)) {
2677
+ run("launchctl", ["bootout", "gui/" + (typeof process.getuid === "function" ? process.getuid() : 0) + "/com.dshremote.bridge"], 8000);
2678
+ try { rmSync(PLAN.plistPath, { force: true }); } catch (e) { /* 非关键 */ }
2679
+ log("已移除自启动 plist");
2680
+ }
2681
+ } else if (PLAN.platform === "linux") {
2682
+ run("systemctl", ["--user", "stop", "dsh-bridge"], 8000);
2683
+ run("systemctl", ["--user", "disable", "dsh-bridge"], 8000);
2684
+ try { if (existsSync(PLAN.unitPath)) rmSync(PLAN.unitPath, { force: true }); } catch (e) { /* 非关键 */ }
2685
+ log("已移除 systemd 用户服务");
2686
+ }
2687
+
2688
+ // ③ 清空配置目录(账号/密钥/会话 cookie/固化运行时)——安全护栏与主进程一致
2689
+ try {
2690
+ const isRoot = dirname(PLAN.relayDir) === PLAN.relayDir;
2691
+ const isHome = PLAN.relayDir === homedir();
2692
+ const hitsProfile = Boolean(PLAN.protectedPath) && (
2693
+ PLAN.relayDir === PLAN.protectedPath
2694
+ || PLAN.relayDir.startsWith(PLAN.protectedPath + sep)
2695
+ || PLAN.protectedPath.startsWith(PLAN.relayDir + sep));
2696
+ if (PLAN.relayDir && !isRoot && !isHome && !hitsProfile && existsSync(PLAN.relayDir)) {
2697
+ rmSync(PLAN.relayDir, { recursive: true, force: true });
2698
+ log("已清空配置目录");
2699
+ } else {
2700
+ log("跳过配置目录清理(护栏命中或目录不存在)");
2701
+ }
2702
+ } catch (e) { log("配置目录清理失败: " + e.message); }
2703
+ log("运行时清理结束");
2704
+ `;
2705
+ }
2706
+
2707
+ /**
2708
+ * 把「运行时清理」交给一个 detached 子进程(不阻塞宿主事件循环)。
2709
+ * @returns {{ok:boolean, pid:number|null, log:string, detail:string}}
2710
+ */
2711
+ function spawnRuntimeCleanupDetached(relayDir, protectedPath) {
2712
+ const logPath = join(tmpdir(), "dsh-remote-uninstall.log");
2713
+ try {
2714
+ // 助手放**系统临时目录**而不是 relayDir:它自己就要删 relayDir,
2715
+ // 把正在运行的脚本放在待删目录里既别扭又可能在 Windows 上因文件占用而失败。
2716
+ const helperPath = join(tmpdir(), `dsh-remote-uninstall-${process.pid}-${Date.now()}.mjs`);
2717
+ writeFileSync(helperPath, buildRuntimeCleanupHelper(relayDir, protectedPath));
2718
+ const { child, error } = safeSpawn(process.execPath, [helperPath], {
2719
+ detached: true, cwd: homedir(), stdio: "ignore", windowsHide: true,
2720
+ onError: (e) => console.warn(`[dsh-remote-web] 运行时清理子进程启动失败: ${e.message}`),
2721
+ });
2722
+ if (error || !child) {
2723
+ return { ok: false, pid: null, log: logPath, detail: (error || new Error("spawn 未返回子进程")).message };
2724
+ }
2725
+ child.unref();
2726
+ return { ok: true, pid: child.pid, log: logPath, detail: "已在后台清理" };
2727
+ } catch (e) {
2728
+ return { ok: false, pid: null, log: logPath, detail: e.message };
2729
+ }
2730
+ }
2731
+
2510
2732
  /** 彻底卸载第 2 步 —— profile 插件清理(兼容市场“拒绝改写用户补丁”):移除 include、依赖、bundle、本地目录与链接。 */
2511
2733
  function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
2512
2734
  const out = { removedPatch: false, removedDep: false, removedDir: false, removedBundle: false };
2513
2735
  try {
2514
2736
  const patch = readFileSync(patchFile, "utf8");
2515
- // 兼容当前与历史(dsh-remote-ui)两种管理标记
2516
- const cleaned = patch
2517
- .replace(/\n?# >>> dsh-remote-(?:web|ui) .*?# <<< dsh-remote-(?:web|ui)\s*/s, "\n")
2518
- .replace(/\n{3,}/g, "\n\n")
2519
- .trimEnd() + "\n";
2520
- if (cleaned !== patch) { writeFileSync(patchFile, cleaned); out.removedPatch = true; }
2737
+ const cleaned = stripOwnEntriesFromPatch(patch); // 兼容当前与历史(dsh-remote-ui)两种名
2738
+ if (cleaned !== patch) { writePatchDocument(patchFile, cleaned); out.removedPatch = true; }
2521
2739
  } catch { /* 无 patch 忽略 */ }
2522
2740
  try {
2523
2741
  const pkg = JSON.parse(readFileSync(pkgFile, "utf8"));
@@ -3571,30 +3789,47 @@ function registerRoutes(ctx, relayDir) {
3571
3789
  method: "POST",
3572
3790
  path: "/dsh-remote/self/uninstall",
3573
3791
  handler: async (_req, res) => {
3574
- // 彻底卸载 = ① 运行时/bridge 清理(停自启动 → 杀残留 → 清空配置目录,顺序防 KeepAlive 复活)
3575
- // + ② 插件 profile 清理(include 块/依赖/bundle/本地目录与链接,解锁市场卸载)
3576
- const rt = uninstallRuntime(relayDir, profileDir);
3577
- const prof = uninstallSelf(relayDir, profileDir, join(profileDir, "cordis.patch.yml"), join(profileDir, "package.json"));
3578
- // 卸载后本进程内(直到重启)自愈/代持调度一律停摆,不再重建配置目录或拉起 bridge
3792
+ // 彻底卸载 = ① 插件 profile 清理(快、纯 fs、可预期)② 运行时/bridge 清理(慢、要调外部命令)
3793
+ //
3794
+ // ⚠️ 顺序是刻意的,别改回去(2026-09-18 用户实测的僵死事故):
3795
+ // 旧实现把**慢的** uninstallRuntime 排在最前、uninstallSelf 排在其后,于是
3796
+ // uninstallRuntime 里任何一次 spawnSync 卡住(Windows 沙箱拦 schtasks,而 spawnSync 的
3797
+ // timeout 在 Windows 上并不可靠,实测 18s+ 未返回),uninstallSelf 就**永远不执行**
3798
+ // → 用户看到「点了卸载、插件纹丝不动」,而且整个 dsh web 僵死(端口在听、无响应)。
3799
+ // 现在:先做真正"卸载掉插件"的部分 → 立刻关掉自愈 → 立刻回响应 → 慢步骤交独立子进程。
3800
+ let prof = { removedPatch: false, removedDep: false, removedBundle: false, removedDir: false };
3801
+ try {
3802
+ prof = uninstallSelf(relayDir, profileDir, join(profileDir, "cordis.patch.yml"), join(profileDir, "package.json"));
3803
+ } catch (e) {
3804
+ console.warn(`[dsh-remote-web] 卸载插件引用失败: ${e.message}`);
3805
+ }
3806
+ // 必须在慢步骤之前置位:否则慢步骤期间自愈有机会把 bridge / 配置目录再拉回来
3579
3807
  markUninstalled(relayDir);
3808
+ // 运行时清理交给独立进程:宿主事件循环绝不做同步子进程调用
3809
+ const cleanup = spawnRuntimeCleanupDetached(relayDir, profileDir);
3810
+ const cleanupMode = cleanup.ok ? "deferred" : "in-process";
3580
3811
  const bits = [];
3581
3812
  if (prof.removedPatch || prof.removedDep || prof.removedBundle || prof.removedDir) bits.push("插件引用与本地文件已移除");
3582
- if (rt.stoppedService) bits.push("bridge 自启动服务已停止");
3583
- if (rt.removedPlist) bits.push("自启动项已删除");
3584
- if (rt.killedPids.length) bits.push(`已结束 ${rt.killedPids.length} 个残留进程`);
3585
- if (rt.removedDir) bits.push("配置目录已清空(账号/密钥/固化运行时等)");
3813
+ bits.push(cleanup.ok
3814
+ ? "bridge 自启动项与本地配置目录(账号/密钥/固化运行时等)正在后台清理"
3815
+ : `bridge 自启动项与配置目录的后台清理未能启动(${cleanup.detail}),已改在本进程内清理`);
3586
3816
  bits.push("请重启 dsh web 后完全卸载生效(本插件与「远程访问」面板将消失);如需再次使用,在插件市场重新安装即可。");
3587
3817
  sendJson(res, 200, {
3588
3818
  ok: true,
3589
3819
  ...prof, // removedPatch / removedDep / removedBundle / removedDir(profile 插件目录)
3590
- servicePlatform: rt.servicePlatform,
3591
- stoppedService: rt.stoppedService,
3592
- removedPlist: rt.removedPlist,
3593
- killedPids: rt.killedPids,
3594
- relayDirRemoved: rt.removedDir, // 配置目录 relayDir 已整目录清空
3820
+ runtimeCleanup: cleanupMode,
3821
+ cleanupLog: cleanup.log,
3595
3822
  relayDir,
3596
3823
  detail: bits.join(";"),
3597
3824
  });
3825
+ // 兜底:助手起不来(relayDir 不可写 / node 起不来)时才在进程内清理。
3826
+ // **必须放在响应之后**:这条路径会阻塞事件循环,绝不能让用户先等它。
3827
+ if (!cleanup.ok) {
3828
+ setTimeout(() => {
3829
+ try { uninstallRuntime(relayDir, profileDir); }
3830
+ catch { /* 非致命:残留可由用户手动清理 */ }
3831
+ }, 50).unref?.();
3832
+ }
3598
3833
  },
3599
3834
  },
3600
3835
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.7-beta.2",
3
+ "version": "0.6.7-beta.3",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -155,6 +155,16 @@ test("残留 marker 自愈:记录进程已死的 marker 在插件启动时立
155
155
  }
156
156
  });
157
157
 
158
+ /** 等待条件成立:运行时清理现在是独立子进程,物理结果要等它跑完。 */
159
+ async function waitFor(fn, { timeout = 10000, step = 50 } = {}) {
160
+ const deadline = Date.now() + timeout;
161
+ for (;;) {
162
+ if (await fn()) return true;
163
+ if (Date.now() > deadline) return false;
164
+ await new Promise((r) => setTimeout(r, step));
165
+ }
166
+ }
167
+
158
168
  test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本地目录(解锁市场卸载)", async () => {
159
169
  const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-home-"));
160
170
  const relayDir = path.join(tempHome, "relay");
@@ -189,13 +199,12 @@ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本
189
199
  assert.equal(r.removedBundle, true, "应移除 dsh.profile.bundles 条目");
190
200
  assert.equal(r.removedDir, true, "应删除本地插件目录与 node_modules 链接");
191
201
 
192
- // 运行时/bridge 清理(隔离模式下只清空配置目录,绝不触碰真实 launchd 服务)
193
- assert.equal(r.relayDirRemoved, true, "配置目录 relayDir 应被整目录清空");
194
- assert.equal(r.stoppedService, false, "DSH_RELAY_SKIP_SERVICE=1 → 不做真实服务操作");
195
- assert.equal(r.removedPlist, false, "DSH_RELAY_SKIP_SERVICE=1 → 不删自启动 plist");
196
- assert.deepEqual(r.killedPids, [], "DSH_RELAY_SKIP_SERVICE=1 → 不杀进程");
197
- assert.equal(existsSync(relayDir), false, "relayDir 物理上应已不存在");
198
- assert.match(String(r.detail), /配置目录已清空/, "detail 应说明配置目录已清空");
202
+ // 运行时/bridge 清理:0.6.7-beta.3 起交给**独立子进程**(先回响应,慢活不阻塞事件循环),
203
+ // 所以这里断言响应契约 + 轮询等它的物理结果(隔离模式下只清空配置目录,绝不触碰真实 launchd 服务)
204
+ assert.equal(r.runtimeCleanup, "deferred", "运行时清理应由子进程接管");
205
+ const gone = await waitFor(() => !existsSync(relayDir));
206
+ assert.equal(gone, true, "relayDir 物理上应已不存在(子进程执行)");
207
+ assert.match(String(r.detail), /正在后台清理/, "detail 应说明后台清理");
199
208
  assert.match(String(r.detail), /请重启 dsh web/, "detail 应提示重启生效");
200
209
 
201
210
  const patch = await readFile(patchFile, "utf8");
@@ -2,8 +2,12 @@
2
2
  // 背景:用户反馈卸载不干净——bridge 自启动服务(launchd com.dshremote.bridge / Linux dsh-bridge)
3
3
  // 仍在运行、自启动 plist/unit 仍在、配置目录 ~/.dsh-remote(账号/设备密钥/.dsh-config.json/
4
4
  // .harness-cookie.json/固化运行时 dsh-setup.mjs + clients 等)也还在。
5
- // 卸载路由现在依次执行:profile 插件清理(uninstallSelf,原逻辑)+ 运行时清理(uninstallRuntime:
6
- // 停自启动服务 → 删自启动文件 → 杀残留进程 → rm -rf 配置目录),全部幂等、单项失败不致命。
5
+ // 0.6.7-beta.3 起卸载**分两段**(顺序是刻意的,见 uninstall 路由注释):
6
+ // ① 同步段:uninstallSelf(纯 fs、快)→ markUninstalled → 立刻回响应;
7
+ // ② 异步段:运行时清理(停自启动/杀残留/清配置目录)交给**独立子进程**执行。
8
+ // 为什么必须这样:同步段里任何一次 spawnSync 卡住(Windows 沙箱拦 schtasks,而 spawnSync 的
9
+ // timeout 在 Windows 上并不可靠),旧实现会让 uninstallSelf 永远不执行、整个 dsh web 僵死
10
+ // (端口在听、无响应)。因此这些用例改为:断言响应契约(runtimeCleanup)+ 轮询等待子进程的物理结果。
7
11
  // 测试隔离(绝不触碰本机真实 launchd/systemd/进程):
8
12
  // - 大多数用例设 DSH_RELAY_SKIP_SERVICE=1 → 跳过一切系统级操作,只验证配置目录清理与 profile 清理;
9
13
  // - 「自然跳过」用例把 PATH 指向假 launchctl/pgrep/ps,完全接管系统命令后走无开关路径。
@@ -23,6 +27,18 @@ const PLUGIN_BLOCK = `# >>> dsh-remote-ui (managed by dsh-remote plugin; do not
23
27
  # <<< dsh-remote-ui
24
28
  `;
25
29
 
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+
32
+ /** 等待条件成立:运行时清理现在是独立子进程,物理结果要等它跑完(而不是同步返回)。 */
33
+ async function waitFor(fn, { timeout = 10000, step = 50 } = {}) {
34
+ const deadline = Date.now() + timeout;
35
+ for (;;) {
36
+ if (await fn()) return true;
37
+ if (Date.now() > deadline) return false;
38
+ await sleep(step);
39
+ }
40
+ }
41
+
26
42
  /** 加载插件(要求调用方已把 process.env.HOME 指向 tempHome;profile 推导才落在 temp 而非真实 ~/.dsh)。 */
27
43
  function boot(relayDir) {
28
44
  const routes = new Map();
@@ -96,14 +112,11 @@ test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行
96
112
  assert.equal(r.removedDep, true);
97
113
  assert.equal(r.removedBundle, true);
98
114
  assert.equal(r.removedDir, true, "profile 插件目录与 node_modules 链接应删除");
99
- // 运行时清理标志位
100
- assert.equal(r.relayDirRemoved, true, "relayDir 应被整目录清空");
101
- assert.equal(r.servicePlatform, "launchd", "macOS 下 servicePlatform=launchd");
102
- assert.equal(r.stoppedService, false, "DSH_RELAY_SKIP_SERVICE=1 → 不触碰真实 launchd 服务");
103
- assert.equal(r.removedPlist, false);
104
- assert.deepEqual(r.killedPids, []);
105
- // 物理断言:relayDir 与其中全部残留都消失
106
- assert.equal(existsSync(relayDir), false, "relayDir 目录应整体不存在");
115
+ // 运行时清理交给独立子进程(响应先回,慢活不再钉住事件循环)
116
+ assert.equal(r.runtimeCleanup, "deferred", "运行时清理必须由子进程接管(不得同步阻塞)");
117
+ assert.ok(r.cleanupLog, "应给出清理日志路径");
118
+ // 物理断言:等子进程跑完,relayDir 与其中全部残留都应消失
119
+ assert.ok(await waitFor(() => !existsSync(relayDir)), "relayDir 应被整目录清空(子进程执行)");
107
120
  assert.equal(existsSync(path.join(relayDir, ".dsh-config.json")), false);
108
121
  assert.equal(existsSync(path.join(relayDir, "dsh-setup.mjs")), false);
109
122
  assert.equal(existsSync(path.join(relayDir, "clients")), false);
@@ -117,7 +130,7 @@ test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行
117
130
  assert.equal(existsSync(profile), true, "dsh web profile 目录整体不得被删");
118
131
  // 人类可读 detail
119
132
  assert.match(String(r.detail), /插件引用与本地文件已移除/);
120
- assert.match(String(r.detail), /配置目录已清空/);
133
+ assert.match(String(r.detail), /正在后台清理/);
121
134
  assert.match(String(r.detail), /请重启 dsh web/);
122
135
  } finally {
123
136
  host.close();
@@ -145,13 +158,12 @@ test("彻底卸载边界:只装了插件、从未跑过 bridge → relayDir
145
158
  const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
146
159
  assert.equal(r.ok, true);
147
160
  assert.equal(r.removedPatch, true, "profile 里的插件引用应照常清理");
148
- assert.equal(r.relayDirRemoved, false, "目录本就不存在 → 不声称已清空");
149
- assert.equal(r.stoppedService, false);
150
- assert.equal(r.removedPlist, false);
151
- assert.deepEqual(r.killedPids, []);
161
+ assert.equal(r.runtimeCleanup, "deferred");
152
162
  const detail = String(r.detail);
153
163
  assert.ok(!detail.includes("配置目录已清空"), "目录不存在时不应谎报已清空,实际: " + detail);
154
164
  assert.match(detail, /请重启 dsh web/);
165
+ await sleep(800); // 等清理子进程跑完
166
+ assert.equal(existsSync(relayDir), false, "relayDir 本来就不存在 → 不得被凭空创建");
155
167
  } finally {
156
168
  host.close();
157
169
  }
@@ -174,12 +186,13 @@ test("彻底卸载边界:什么都没有安装 → 全链路幂等返回 ok,
174
186
  try {
175
187
  const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
176
188
  assert.equal(r.ok, true);
177
- assert.equal(r.relayDirRemoved, false);
189
+ assert.equal(r.runtimeCleanup, "deferred");
178
190
  assert.equal(r.removedPatch, false);
179
191
  assert.equal(r.removedDep, false);
180
192
  assert.equal(r.removedBundle, false);
181
- assert.deepEqual(r.killedPids, []);
182
193
  assert.match(String(r.detail), /请重启 dsh web/);
194
+ await sleep(800);
195
+ assert.equal(existsSync(relayDir), false, "什么都没有也不该凭空创建目录");
183
196
  } finally {
184
197
  host.close();
185
198
  }
@@ -206,10 +219,9 @@ test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也
206
219
  try {
207
220
  const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
208
221
  assert.equal(r.ok, true);
209
- assert.equal(r.removedPlist, false, "开关置位 → plist 不得被删");
210
- assert.equal(existsSync(fakePlist), true, "plist 应原样保留(开关只放行配置目录清理)");
211
- assert.equal(r.relayDirRemoved, true, "配置目录清理不受开关影响");
212
- assert.equal(existsSync(relayDir), false);
222
+ assert.equal(r.runtimeCleanup, "deferred");
223
+ assert.ok(await waitFor(() => !existsSync(relayDir)), "配置目录清理不受开关影响");
224
+ assert.equal(existsSync(fakePlist), true, "开关置位 → plist 不得被删(子进程同样受开关约束)");
213
225
  } finally {
214
226
  host.close();
215
227
  }
@@ -220,7 +232,7 @@ test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也
220
232
  }
221
233
  });
222
234
 
223
- test("自然跳过(无开关):HOME 有 plist 但服务未运行 → 删除 plist 但不发 launchctl bootout(PATH 假命令接管)", { skip: process.platform !== "darwin" }, async () => {
235
+ test("无开关(真实分支):自启动清理由独立子进程执行,且走 PATH 上的假 launchctl(不碰真实服务)", { skip: process.platform !== "darwin" }, async () => {
224
236
  const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-natural-"));
225
237
  const prevHome = process.env.HOME;
226
238
  const prevPath = process.env.PATH;
@@ -254,15 +266,13 @@ exit 0
254
266
  try {
255
267
  const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
256
268
  assert.equal(r.ok, true);
257
- assert.equal(r.removedPlist, true, "plist 存在于当前 HOME → 应被删除");
258
- assert.equal(existsSync(plist), false);
259
- assert.equal(r.stoppedService, false, "服务未运行 → 无需 bootout,不虚报已停止");
260
- assert.deepEqual(r.killedPids, [], "无残留进程可杀");
261
- assert.equal(r.relayDirRemoved, true);
262
- assert.equal(existsSync(relayDir), false);
263
- // 关键:全程不得发出 bootout(避免任何真实/假服务被停)
264
- const log = readFileSync(launchLog, "utf8");
265
- assert.ok(!log.includes("bootout"), "未运行的服务不应触发 bootout,实际调用: " + log);
269
+ assert.equal(r.runtimeCleanup, "deferred", "无开关时同样走子进程");
270
+ // 等清理子进程跑完(它继承 HOME/PATH → plist 路径与 launchctl 都落在测试环境里)
271
+ assert.ok(await waitFor(() => !existsSync(plist)), "plist 应被清理子进程删除");
272
+ assert.ok(await waitFor(() => !existsSync(relayDir)), "配置目录应被清空");
273
+ // 关键安全性质:清理用的是 PATH 上的**假** launchctl —— 证明没有触碰任何真实服务
274
+ const log = existsSync(launchLog) ? readFileSync(launchLog, "utf8") : "";
275
+ assert.ok(log.includes("com.dshremote.bridge"), "应通过(假)launchctl 清理自启动,实际: " + log);
266
276
  } finally {
267
277
  host.close();
268
278
  }
@@ -289,8 +299,9 @@ test("保护参数:relayDir 误配置成 dsh web profile 目录 → 绝不整
289
299
  try {
290
300
  const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
291
301
  assert.equal(r.ok, true);
292
- assert.equal(r.relayDirRemoved, false, "profile 目录受保护 → 不得整目录删除");
293
- assert.equal(existsSync(profile), true, "dsh web profile 必须存活");
302
+ assert.equal(r.runtimeCleanup, "deferred");
303
+ await sleep(1000); // 等清理子进程跑完:它必须自己判断出"这是 profile,不许删"
304
+ assert.equal(existsSync(profile), true, "dsh web profile 必须存活(子进程也受同一条护栏约束)");
294
305
  assert.equal(existsSync(path.join(profile, "package.json")), true);
295
306
  assert.equal(existsSync(path.join(profile, "dsh-remote-ui-plugin")), false, "插件子目录应被 uninstallSelf 单独移除");
296
307
  assert.equal(existsSync(path.join(profile, "node_modules", "dsh-remote-ui")), false);
@@ -0,0 +1,379 @@
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
+ // ─────────────────────── ② 卸载顺序与阻塞(缺陷二) ───────────────────────
213
+
214
+ test("源码契约:卸载必须先卸插件、再关自愈、最后才把慢活交子进程", () => {
215
+ const route = INDEX_SRC.slice(INDEX_SRC.indexOf('path: "/dsh-remote/self/uninstall"'), INDEX_SRC.indexOf("// ---------- 路由", INDEX_SRC.indexOf('path: "/dsh-remote/self/uninstall"')));
216
+ const iSelf = route.indexOf("uninstallSelf(relayDir, profileDir");
217
+ const iMark = route.indexOf("markUninstalled(relayDir)");
218
+ const iSpawn = route.indexOf("spawnRuntimeCleanupDetached(relayDir, profileDir)");
219
+ const iSend = route.indexOf("sendJson(res, 200");
220
+ assert.ok(iSelf > -1 && iMark > -1 && iSpawn > -1 && iSend > -1, "应能定位四个关键步骤");
221
+ assert.ok(iSelf < iSpawn, "★先卸插件(快、纯 fs),再交子进程做慢活 —— 旧实现顺序相反,卡住就「什么都没卸载」");
222
+ assert.ok(iMark < iSpawn, "自愈标记必须在慢步骤之前置位,否则慢步骤期间可能被复活");
223
+ // 兜底路径(进程内同步清理)必须在响应之后:它会阻塞事件循环,用户不能先等它
224
+ const iFallback = route.indexOf("uninstallRuntime(relayDir, profileDir)");
225
+ assert.ok(iFallback === -1 || iSend < iFallback, "兜底的进程内清理必须排在响应之后");
226
+ assert.ok(!/const rt = uninstallRuntime\(relayDir, profileDir\);/.test(route), "不得再同步执行 uninstallRuntime");
227
+ // 事件循环禁令:请求路径上不许出现同步子进程调用
228
+ assert.ok(!/spawnSync\(/.test(route), "卸载请求路径不得出现 spawnSync(会把事件循环钉死)");
229
+ });
230
+
231
+ test("行为:响应里插件引用已经卸掉(不依赖慢步骤先跑完)", async () => {
232
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-order-"));
233
+ const prevHome = process.env.HOME;
234
+ const prevSkip = process.env.DSH_RELAY_SKIP_SERVICE;
235
+ process.env.HOME = home;
236
+ process.env.DSH_RELAY_SKIP_SERVICE = "1";
237
+ try {
238
+ const { patchFile } = await plantProfile(home);
239
+ const routes = boot(path.join(home, "relay"));
240
+ const { host, base } = await serve(routes);
241
+ try {
242
+ const t0 = Date.now();
243
+ const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
244
+ const ms = Date.now() - t0;
245
+ assert.equal(r.removedPatch, true, "响应必须已包含「插件引用已移除」");
246
+ assert.ok(isTopLevelArrayDoc(fs.readFileSync(patchFile, "utf8")), "响应返回时 patch 必须已写好");
247
+ assert.equal(r.runtimeCleanup, "deferred", "运行时清理应交给子进程");
248
+ assert.ok(ms < 5000, `响应不该等运行时清理(实测 ${ms}ms)`);
249
+ } finally {
250
+ host.close();
251
+ }
252
+ } finally {
253
+ if (prevSkip === undefined) delete process.env.DSH_RELAY_SKIP_SERVICE; else process.env.DSH_RELAY_SKIP_SERVICE = prevSkip;
254
+ process.env.HOME = prevHome;
255
+ fs.rmSync(home, { recursive: true, force: true });
256
+ }
257
+ });
258
+
259
+ // ─────────────────────── ③ 运行时清理助手 ───────────────────────
260
+
261
+ /** 从插件源码里取出助手生成函数(安装器/插件都是 CLI+ESM,import 会执行 main)。 */
262
+ function loadCleanupHelperBuilder() {
263
+ const src = INDEX_SRC.slice(
264
+ INDEX_SRC.indexOf("function buildRuntimeCleanupHelper("),
265
+ INDEX_SRC.indexOf("/**\n * 把「运行时清理」交给一个 detached 子进程"));
266
+ return new Function("osPlatform", "WIN_TASK_NAME", "WATCHER_PID_FILE", "BRIDGE_PID_FILE", "join", "homedir", "tmpdir",
267
+ `${src}; return buildRuntimeCleanupHelper;`)(
268
+ () => process.platform, "dsh-remote-bridge", ".dsh-watcher.pid", ".dsh-bridge.pid", path.join, os.homedir, os.tmpdir);
269
+ }
270
+
271
+ test("清理助手:生成的脚本语法合法,且每个外部命令都有 timeout(超时不能当唯一防线,但要有)", () => {
272
+ const src = loadCleanupHelperBuilder()(path.join(os.tmpdir(), "relay-x"), path.join(os.tmpdir(), "prof-x"));
273
+ const file = path.join(os.tmpdir(), `dsh-helper-check-${process.pid}.mjs`);
274
+ fs.writeFileSync(file, src);
275
+ try {
276
+ const chk = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
277
+ assert.equal(chk.status, 0, `生成的清理助手语法必须合法:${chk.stderr}`);
278
+ } finally {
279
+ fs.rmSync(file, { force: true });
280
+ }
281
+ // 外部命令一律经 run(cmd, args, timeout)
282
+ const calls = src.match(/run\("[a-z]+", \[[^\]]*\], (\d+)\)/g) || [];
283
+ assert.ok(calls.length >= 2, `应通过 run(...) 调外部命令(实际 ${calls.length} 处)`);
284
+ assert.ok(!/spawnSync\("[a-z]+", \[[^\]]*\](?!, \{)/.test(src), "不得有裸 spawnSync(缺 timeout)");
285
+ assert.ok(/DSH_RELAY_SKIP_SERVICE === "1"/.test(src), "助手必须遵守测试隔离开关(否则用例会动到真实服务)");
286
+ });
287
+
288
+ test("清理助手:真跑一遍 → 结束 pid 文件里的进程并清空配置目录", async () => {
289
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-helper-"));
290
+ const relayDir = path.join(home, "relay");
291
+ fs.mkdirSync(relayDir, { recursive: true });
292
+ fs.writeFileSync(path.join(relayDir, ".dsh-config.json"), JSON.stringify({ phone: "x", password: "y" }));
293
+ // 用一个真实存活的长命进程当"残留 bridge"
294
+ const sleeper = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: "ignore" });
295
+ fs.writeFileSync(path.join(relayDir, ".dsh-bridge.pid"), String(sleeper.pid));
296
+ const src = loadCleanupHelperBuilder()(relayDir, path.join(home, "prof"));
297
+ const helper = path.join(home, "helper.mjs");
298
+ fs.writeFileSync(helper, src);
299
+ try {
300
+ // 注意:这里**不**设隔离开关,用来验证真实的 kill 路径;
301
+ // 但 HOME 已指向临时目录 → 自启动 plist 路径落在临时目录、不存在 → 不会碰真实 launchd。
302
+ // 显式去掉隔离开关:本用例要验证**真实**的 kill 路径;HOME 指向临时目录保证不碰真实服务
303
+ const env = { ...process.env, HOME: home };
304
+ delete env.DSH_RELAY_SKIP_SERVICE;
305
+ const r = spawnSync(process.execPath, [helper], { encoding: "utf8", timeout: 30000, env });
306
+ assert.equal(r.status, 0, `助手应正常退出:${r.stderr}`);
307
+ assert.equal(fs.existsSync(relayDir), false, "★配置目录必须被清空");
308
+ assert.equal(fs.existsSync(path.join(relayDir, ".dsh-config.json")), false);
309
+ // pid 文件里的进程应被结束
310
+ let alive = true;
311
+ for (let i = 0; i < 40; i += 1) {
312
+ try { process.kill(sleeper.pid, 0); } catch { alive = false; break; }
313
+ await sleep(50);
314
+ }
315
+ assert.equal(alive, false, "★pid 文件里记录的残留进程必须被结束");
316
+ } finally {
317
+ try { sleeper.kill("SIGKILL"); } catch { /* 已退出 */ }
318
+ fs.rmSync(home, { recursive: true, force: true });
319
+ }
320
+ });
321
+
322
+ test("清理助手:护栏命中时绝不删 profile(relayDir 误配成 profile 的兜底)", () => {
323
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-helper-guard-"));
324
+ const profile = path.join(home, "prof");
325
+ fs.mkdirSync(profile, { recursive: true });
326
+ fs.writeFileSync(path.join(profile, "package.json"), "{}");
327
+ const src = loadCleanupHelperBuilder()(profile, profile); // relayDir === protectedPath
328
+ const helper = path.join(home, "helper.mjs");
329
+ fs.writeFileSync(helper, src);
330
+ try {
331
+ spawnSync(process.execPath, [helper], { encoding: "utf8", timeout: 30000, env: { ...process.env, HOME: home, DSH_RELAY_SKIP_SERVICE: "1" } });
332
+ assert.equal(fs.existsSync(path.join(profile, "package.json")), true, "★护栏命中:profile 必须完好");
333
+ } finally {
334
+ fs.rmSync(home, { recursive: true, force: true });
335
+ }
336
+ });
337
+
338
+ // ─────────────────────── ④ Windows ACL 收紧(mode 0600 是空操作) ───────────────────────
339
+
340
+ test("安全:三处机密写入都要显式收紧 ACL(Windows 上 mode 0o600 无效)", () => {
341
+ for (const [label, src, callSites] of [
342
+ ["安装器", SETUP_SRC, [/hardenFile\(CONFIG_PATH\)/]],
343
+ ["插件半", INDEX_SRC, [/hardenFile\(configPathOf\(relayDir\)\)/, /hardenFile\(join\(relayDir, HARNESS_COOKIE_FILE\)\)/]],
344
+ ["bridge", fs.readFileSync(path.join(HERE, "..", "..", "..", "clients", "dsh-remote", "dsh-bridge.mjs"), "utf8"), [/hardenFile\(CONFIG_PATH\)/]],
345
+ ]) {
346
+ assert.ok(/function hardenFile\(file\)/.test(src), `${label} 应有 hardenFile`);
347
+ assert.ok(/icacls/.test(src) && /inheritance:r/.test(src), `${label} 必须在 Windows 上断开继承并只授权当前用户`);
348
+ assert.ok(/process\.platform !== "win32"/.test(src), `${label} 非 Windows 走 chmod 即可`);
349
+ for (const re of callSites) assert.ok(re.test(src), `${label} 的机密写入点必须调用 hardenFile:${re}`);
350
+ }
351
+ });
352
+
353
+ test("安全:hardenFile 在非 Windows 上只 chmod(不引入任何子进程,零副作用)", () => {
354
+ const src = SETUP_SRC.slice(SETUP_SRC.indexOf("function hardenFile(file)"), SETUP_SRC.indexOf("// ---------- 配置读写 ----------"));
355
+ const calls = [];
356
+ const fakeSpawn = () => { calls.push("spawn"); return { status: 0 }; };
357
+ const fakeFs = { ...fs, chmodSync: (f, m) => { calls.push(`chmod:${m.toString(8)}`); } };
358
+ const harden = new Function("fs", "spawnSync", "process", "hardenWarned", `${src}; return hardenFile;`)(fakeFs, fakeSpawn, process, false);
359
+ const file = path.join(os.tmpdir(), `dsh-harden-${process.pid}.tmp`);
360
+ fs.writeFileSync(file, "x");
361
+ try {
362
+ harden(file);
363
+ if (process.platform !== "win32") {
364
+ assert.deepEqual(calls, ["chmod:600"], `非 Windows 只应 chmod 600,实际 ${JSON.stringify(calls)}`);
365
+ }
366
+ } finally {
367
+ fs.rmSync(file, { force: true });
368
+ }
369
+ });
370
+
371
+ // ─────────────────────── ⑤ 前端:请求超时 ───────────────────────
372
+
373
+ test("前端:任何请求都有超时上界(node 半僵死时不再永久转圈)", () => {
374
+ assert.ok(/var API_TIMEOUT_MS = \d+/.test(CLIENT_SRC), "应有默认请求超时");
375
+ assert.ok(/AbortController/.test(CLIENT_SRC), "应通过 AbortController 实现超时");
376
+ assert.ok(/postWithTimeout\("\/dsh-remote\/self\/uninstall"/.test(CLIENT_SRC), "卸载这类可能拖住的操作应使用更短超时");
377
+ assert.ok(/请求超时/.test(CLIENT_SRC), "超时后必须给用户一句能照做的话");
378
+ assert.ok(/clearTimeout\(timer\)/.test(CLIENT_SRC), "请求结束必须清掉定时器(否则泄漏)");
379
+ });