@mrrisega/dsh-remote 0.6.10-beta.10 → 0.6.10-beta.11

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.
@@ -1611,3 +1611,58 @@ test("★ 免费档(有 assign.continue 没 assign.new)的额度提醒:只说缺
1611
1611
  await ilink.close();
1612
1612
  }
1613
1613
  });
1614
+
1615
+ // ---------------------------------------------------------------------------
1616
+ // 「/help 回一坨」的根治(2026-09-23 用户实测)
1617
+ //
1618
+ // 旧实现对付费档 `#helpText()` 直接 `return HELP_TEXT` —— 那是个**数组**,
1619
+ // 而发送前 `String(数组)` 会按逗号拼接、**一个换行都没有**:用户看到的正是
1620
+ // 「/help 回一坨,没有换行、也没有编号」。两处一起防:帮助改为逐行 join,
1621
+ // 且 reply() 收到数组时按行拼接(让"想给多行却给了数组"退化成正确的多行,而不是一坨)。
1622
+ // ---------------------------------------------------------------------------
1623
+
1624
+ test("★ reply 收到数组时必须按行拼接,绝不能退化成逗号串", async () => {
1625
+ const ilink = await fakeIlink();
1626
+ try {
1627
+ const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
1628
+ await rt.reply("u", ["第一行", "· 第二行", "· 第三行"]);
1629
+ const t = lastText(ilink.state);
1630
+ assert.match(t, /第一行\n· 第二行\n· 第三行/, "★数组必须按 \\n 拼接");
1631
+ assert.ok(!/第一行,/.test(t), "★绝不能出现逗号拼接(String(数组) 的老行为 = 用户看到的一坨)");
1632
+ } finally {
1633
+ await ilink.close();
1634
+ }
1635
+ });
1636
+
1637
+ test("★ /help 必须逐行分隔:付费档也不许退化成一坨(无换行/无编号)", async () => {
1638
+ const ilink = await fakeIlink();
1639
+ try {
1640
+ const { rt } = await boundRuntime(ilink, {
1641
+ subscriber: makeFakeSubscriber(), tier: "pro",
1642
+ entitlements: {
1643
+ rev: "r-help", plan: "pro",
1644
+ caps: ["notify", "approve", "status", "stop", "assign", "sessions", "summary"],
1645
+ limits: { messages_per_month: 0 }
1646
+ }
1647
+ });
1648
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
1649
+ const t = lastText(ilink.state);
1650
+ const lines = t.split("\n");
1651
+ assert.ok(lines.length >= 6, `★帮助必须多行展示,实际只有 ${lines.length} 行:${t}`);
1652
+ assert.match(t, /现在能用的/, "应有一行小节标题");
1653
+ assert.ok(lines.some((l) => l.startsWith("· ")), "能力项应以「· 」开头(逐条,不是挤成一段)");
1654
+ assert.ok(!/现在能用的:,/.test(t) && !/,·/.test(t), "★不得是逗号拼接的一坨");
1655
+ // 免费档同样要多行(两条路都要能读)
1656
+ const freeIlink = await fakeIlink();
1657
+ try {
1658
+ const f = await boundRuntime(freeIlink, { subscriber: makeFakeSubscriber(), tier: "free" });
1659
+ await f.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
1660
+ const ft = lastText(freeIlink.state);
1661
+ assert.ok(ft.split("\n").length >= 6, `★免费档帮助也要多行,实际:${ft}`);
1662
+ } finally {
1663
+ await freeIlink.close();
1664
+ }
1665
+ } finally {
1666
+ await ilink.close();
1667
+ }
1668
+ });
@@ -1344,8 +1344,14 @@ export class WeChatRuntime {
1344
1344
 
1345
1345
  async reply(to, text) {
1346
1346
  if (!this.channel.account || !to) return false;
1347
+ // ★ 文本**必须是字符串**。历史上 `#helpText()` 对付费档直接 `return HELP_TEXT` ——
1348
+ // 那是个**数组**,而 `String(数组)` 会按逗号拼接、**一个换行都没有**:
1349
+ // 用户实测到的正是「/help 回一坨,没有换行也没有编号」。数组一律按行拼接,
1350
+ // 让"想给多行却给了数组"退化成**正确**的多行文本,而不是一坨。
1351
+ const body = Array.isArray(text) ? text.join("\n") : String(text == null ? "" : text);
1352
+ if (!body) return false;
1347
1353
  try {
1348
- await this.channel.client.sendMessage({ to, text });
1354
+ await this.channel.client.sendMessage({ to, text: body });
1349
1355
  this.channel.markPush(true);
1350
1356
  return true;
1351
1357
  } catch (e) {
package/dsh-setup.mjs CHANGED
@@ -420,6 +420,34 @@ function looksLikeWatcher(pid) {
420
420
  return Boolean(r.ok && /dsh-setup\.mjs/.test(r.stdout));
421
421
  }
422
422
 
423
+ /**
424
+ * 这个 pid 是不是我们的 **bridge 子进程**(不是 watcher、也不是别的 node)。
425
+ *
426
+ * 为什么要单独判:接管时我们要回收"上一任遗留的 bridge",而 pid 可能已被系统回收给无关进程 ——
427
+ * 对无关进程发 SIGKILL 是不可接受的。所以对 `ps` 出来的命令行做**双重**校验:
428
+ * 必须同时出现 bridge 脚本名与 clients/dsh-remote 路径片段。
429
+ */
430
+ function looksLikeBridge(pid) {
431
+ if (!Number.isInteger(pid) || pid <= 0) return false;
432
+ const r = sh(`ps -p ${pid} -o command=`);
433
+ if (!r.ok) return false;
434
+ const cmd = String(r.stdout || "");
435
+ return /dsh-bridge\.mjs/.test(cmd) && /clients[\\/]dsh-remote/.test(cmd);
436
+ }
437
+
438
+ /**
439
+ * 该不该回收这个"上一任留下的 bridge 子进程"(纯函数 —— 便于直接测)。
440
+ *
441
+ * 只有**确认它就是我们的 bridge 脚本**才敢动它;是自己、已死、非整数一律不动。
442
+ * 这条判断代价很高(误杀无关进程 / 漏杀导致两个实例抢同一微信账号的消息),所以抽出来单测。
443
+ */
444
+ function shouldReapLeftoverBridge({ pid, selfPid = 0, alive = false, isBridgeScript = false } = {}) {
445
+ if (!Number.isInteger(pid) || pid <= 0) return false;
446
+ if (pid === selfPid) return false;
447
+ if (!alive) return false;
448
+ return Boolean(isBridgeScript);
449
+ }
450
+
423
451
  /**
424
452
  * 去重决策(纯函数 —— 这条判断的代价很高,必须能被直接测)。
425
453
  *
@@ -1020,6 +1048,37 @@ async function runBridge() {
1020
1048
  return;
1021
1049
  }
1022
1050
  }
1051
+ // ── 回收「上一任留下的 bridge 子进程」────────────────────────────────────
1052
+ //
1053
+ // 🔴 真机 bug(2026-09-23 用户实测):一条微信指令**回两条**。
1054
+ // 成因链:接管时把老 watcher SIGTERM(它只"请"子进程退出、最多等 3 秒就自己 exit),
1055
+ // 而 bridge 此刻常正卡在 35 秒的 getUpdates 长轮询里 —— 来不及退 → 变成孤儿继续跑;
1056
+ // 若老 watcher 是被 SIGKILL 干掉的(等超 5 秒那条路),连"请"都不会发生。
1057
+ // 两个 bridge 各自持有自己的 updatesBuf、各自轮询同一个微信账号 ⇒ 同一条消息被消费两次、
1058
+ // 回两条。所以**接管之后必须显式清掉上一任的子进程**,不能只靠"它应该会自己退"。
1059
+ //
1060
+ // 安全边界:pid 可能已被系统回收给无关进程 —— 必须确认它**真的**是我们的 bridge 脚本才动手
1061
+ //(与 looksLikeWatcher 同一套纪律)。插件半的「脱离进程兜底」也会拉起 bridge,
1062
+ // 但那种情况**没有** watcher 在跑(有 watcher 就不会走兜底),所以这里回收不会误伤正常形态。
1063
+ {
1064
+ const leftover = readPidFile(BRIDGE_PID_FILE);
1065
+ const reap = shouldReapLeftoverBridge({
1066
+ pid: leftover,
1067
+ selfPid: process.pid,
1068
+ alive: leftover ? pidAlive(leftover) : false,
1069
+ isBridgeScript: leftover ? looksLikeBridge(leftover) : false
1070
+ });
1071
+ if (reap) {
1072
+ console.log(`[dsh-remote] 回收上一任遗留的 bridge 子进程(pid=${leftover})——避免两个实例抢同一账号的消息。`);
1073
+ try { process.kill(leftover, "SIGTERM"); } catch { /* 已退出 */ }
1074
+ const dl = Date.now() + 4000;
1075
+ while (Date.now() < dl && pidAlive(leftover)) sleepSync(100);
1076
+ if (pidAlive(leftover) && looksLikeBridge(leftover)) {
1077
+ try { process.kill(leftover, "SIGKILL"); } catch { /* 已退出 */ }
1078
+ }
1079
+ removePidFile(BRIDGE_PID_FILE);
1080
+ }
1081
+ }
1023
1082
  }
1024
1083
  let cfg = loadConfig();
1025
1084
  let warnedNoLogin = false;
@@ -1142,7 +1201,21 @@ async function runBridge() {
1142
1201
  try { bridgeProc.send({ type: "shutdown" }); } catch { /* 通道已断,只能随父进程一起被回收 */ }
1143
1202
  }
1144
1203
  cleanup();
1145
- setTimeout(() => process.exit(0), 3000);
1204
+ // 🔴 但**只"请"是不够的**(2026-09-23 真机 bug:一条微信指令回两条):
1205
+ // bridge 此刻常正卡在 35s 的 getUpdates 长轮询里,3 秒根本退不完;
1206
+ // 老 watcher 一 exit,子进程就被 reparent 成孤儿、继续轮询同一个微信账号 →
1207
+ // 与新 bridge 抢消息(一条被消费两次、回两条)。所以退出前必须**确保它真的死了**。
1208
+ setTimeout(() => {
1209
+ if (bridgeProc && bridgeProc.exitCode === null) {
1210
+ try { bridgeProc.kill("SIGTERM"); } catch { /* 已退出 */ }
1211
+ }
1212
+ }, 1500);
1213
+ setTimeout(() => {
1214
+ if (bridgeProc && bridgeProc.exitCode === null) {
1215
+ try { bridgeProc.kill("SIGKILL"); } catch { /* 已退出 */ }
1216
+ }
1217
+ process.exit(0);
1218
+ }, 3000);
1146
1219
  });
1147
1220
  } catch { /* 该信号在本平台不可注册 */ }
1148
1221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.10-beta.10",
3
+ "version": "0.6.10-beta.11",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.10-beta.10",
3
+ "version": "0.6.10-beta.11",
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",
@@ -135,3 +135,46 @@ test("★ 被监管者(launchd)拉起的实例不得因『已有游离守护』
135
135
  assert.match(rb, /looksLikeWatcher\(other\)/, "★接管前必须确认那个 pid 真的是 watcher(pid 会被回收给无关进程)");
136
136
  assert.match(rb, /dedupDecision\(/, "决策必须走 dedupDecision(保证被判为活代码且可测)");
137
137
  });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // 「一条微信指令回两条」的根治(2026-09-23 用户实测)
141
+ //
142
+ // 成因链:接管时把老 watcher SIGTERM,它只**请**子进程退出、最多等 3 秒就自己 exit;
143
+ // 而 bridge 此刻常正卡在 35 秒的 getUpdates 长轮询里 —— 来不及退 → reparent 成孤儿继续跑。
144
+ // 两个 bridge 各持自己的 updatesBuf、各轮询同一账号 ⇒ **同一条消息被消费两次、用户收到两条**。
145
+ // 真机现场:`ps` 里两个 `dsh-bridge.mjs`,一个 PPID=1(孤儿)、一个挂在 watcher 下。
146
+ // ---------------------------------------------------------------------------
147
+
148
+ test("★ 遗留 bridge 的回收判定:只认「活着 + 确实是我们的 bridge 脚本 + 不是自己」", () => {
149
+ const from = src.indexOf("function shouldReapLeftoverBridge(");
150
+ assert.ok(from > -1, "应能找到 shouldReapLeftoverBridge");
151
+ const body = src.slice(from, src.indexOf("function writeWindowsTask("));
152
+ assert.ok(body.length > 0, "切片边界应正确");
153
+ const reap = new Function(`${body}\nreturn shouldReapLeftoverBridge;`)();
154
+
155
+ assert.equal(reap({ pid: 1538, selfPid: 1529, alive: true, isBridgeScript: true }), true,
156
+ "★活着且确认是我们的 bridge → 必须回收(否则它会和新实例抢同一账号的消息)");
157
+ assert.equal(reap({ pid: 1538, selfPid: 1529, alive: false, isBridgeScript: true }), false, "已经死了不用管");
158
+ assert.equal(reap({ pid: 1529, selfPid: 1529, alive: true, isBridgeScript: true }), false, "是自己不能杀");
159
+ assert.equal(reap({ pid: 999, selfPid: 1529, alive: true, isBridgeScript: false }), false,
160
+ "★pid 可能被系统回收给无关进程 —— 认不出是我们的 bridge 就绝不动它");
161
+ assert.equal(reap({ pid: 0, selfPid: 1529, alive: true, isBridgeScript: true }), false, "非法 pid");
162
+ assert.equal(reap({ pid: -1, selfPid: 1529, alive: true, isBridgeScript: true }), false, "非法 pid");
163
+ assert.equal(reap({}), false, "缺参一律不动");
164
+
165
+ const rb = src.slice(src.indexOf("async function runBridge()"), src.indexOf("// ---------- setup"));
166
+ assert.match(rb, /shouldReapLeftoverBridge\(/, "★接管后必须真的调用回收判定(保证它是活代码)");
167
+ assert.match(rb, /looksLikeBridge\(leftover\)/, "★动手前必须确认那个 pid 真的是 bridge 脚本");
168
+ assert.match(rb, /readPidFile\(BRIDGE_PID_FILE\)/, "回收目标取自 bridge 的 pid 文件");
169
+ });
170
+
171
+ test("★ watcher 退出前必须确保子进程真的死了(只『请』不够)", () => {
172
+ const rb = src.slice(src.indexOf("async function runBridge()"), src.indexOf("// ---------- setup"));
173
+ assert.match(rb, /bridgeProc\.send\(\{ type: "shutdown" \}\)/, "先请它优雅退出(跑完 notifystop)");
174
+ // 关键:退出前兜底 SIGTERM/SIGKILL —— 否则长轮询中的子进程会变成孤儿继续抢消息
175
+ const tail = rb.slice(rb.indexOf('for (const sig of ["SIGINT"'));
176
+ assert.match(tail, /bridgeProc\.kill\("SIGTERM"\)/, "★宽限期后必须补 SIGTERM(bridge 常卡在 35s 长轮询里)");
177
+ assert.match(tail, /bridgeProc\.kill\("SIGKILL"\)/, "★再宽限仍不退必须 SIGKILL(否则退出后留孤儿)");
178
+ assert.match(tail, /bridgeProc\.exitCode === null/, "判活要用 exitCode(已退出就别再杀)");
179
+ assert.match(src, /function looksLikeBridge\(pid\)/, "应有 looksLikeBridge 供回收前校验命令行");
180
+ });