@mrrisega/dsh-remote 0.6.10-beta.4 → 0.6.10-beta.6

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/dsh-setup.mjs CHANGED
@@ -407,6 +407,36 @@ function readPidFile(name) {
407
407
  } catch { return null; }
408
408
  }
409
409
 
410
+ /**
411
+ * 这个 pid 看起来**真的**是 watcher 吗?
412
+ *
413
+ * 为什么必须查:pid 文件可能过期,而 pid 会被系统**回收给无关进程**。
414
+ * 去重逻辑信任 `pidAlive()` 只会导致"我多退一次"(安全方向);但监管者接管时要
415
+ * **主动结束**那个 pid —— 盲杀一个被回收的无关进程是事故。所以动它之前先认一下命令行。
416
+ */
417
+ function looksLikeWatcher(pid) {
418
+ if (!Number.isInteger(pid) || pid <= 0) return false;
419
+ const r = sh(`ps -p ${pid} -o command=`);
420
+ return Boolean(r.ok && /dsh-setup\.mjs/.test(r.stdout));
421
+ }
422
+
423
+ /**
424
+ * 去重决策(纯函数 —— 这条判断的代价很高,必须能被直接测)。
425
+ *
426
+ * · "run" → 没有别的守护(或就是自己),正常跑
427
+ * · "takeover" → 有别的守护,但**本进程是监管者(launchd)拉起的**:请它让位,我们接管
428
+ * · "yield" → 有别的守护,本进程是手动/插件脱离进程拉起的:直接退出,避免两个 bridge
429
+ * 抢同一个设备登记
430
+ *
431
+ * 为什么"被监管者拉起"必须走 takeover 而不是 yield:
432
+ * KeepAlive 每次重拉都秒退 → 监管者判为崩溃并节流 → 插件的自愈认定"服务坏了"→
433
+ * bootout 摘掉作业(入口判定成立时连 plist 一起删)→ **自启动彻底消失**(真机实测)。
434
+ */
435
+ function dedupDecision({ existingPid = null, selfPid = 0, supervisor = "" } = {}) {
436
+ if (!existingPid || existingPid === selfPid) return "run";
437
+ return supervisor ? "takeover" : "yield";
438
+ }
439
+
410
440
  function pidAlive(pid) {
411
441
  if (!Number.isInteger(pid) || pid <= 0) return false;
412
442
  try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
@@ -958,12 +988,37 @@ function loginRateLimitUntil() {
958
988
  // ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
959
989
  async function runBridge() {
960
990
  // 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
961
- // 两个 bridge 会抢同一个设备登记 → 以 pid 文件为准,已有守护在跑就直接退出。
991
+ // 两个 bridge 会抢同一个设备登记 → 以 pid 文件为准。
992
+ //
993
+ // ⚠️ 但**被监管者(launchd)拉起的那个实例绝不能再"直接退出"**。
994
+ // 真机实测的后果链:KeepAlive 每次重拉 → 本进程秒退 → 监管者判为崩溃并节流
995
+ // (`launchctl print` 显示 `state = spawn scheduled`)→ 插件的自愈据此认定"服务坏了"
996
+ // → `bootout` 摘掉作业(入口判定成立时连 plist 一起删)→
997
+ // **自启动彻底消失,连 `launchctl kickstart` 都报"域里找不到该服务"**,
998
+ // 只剩插件面板的「启动」能救回来。
999
+ // 被监管者拉起 = 我们才是那个该活下来的实例 → 请游离实例让位,然后接管。
962
1000
  {
963
1001
  const other = readPidFile(WATCHER_PID_FILE);
964
1002
  if (other && other !== process.pid && pidAlive(other)) {
965
- console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
966
- return;
1003
+ const supervisor = String(process.env.XPC_SERVICE_NAME || "");
1004
+ const decision = dedupDecision({ existingPid: other, selfPid: process.pid, supervisor });
1005
+ if (decision === "takeover") {
1006
+ console.log(`[dsh-remote] 检测到游离守护(pid=${other})——本进程由 ${supervisor} 监管,请它让位后接管。`);
1007
+ // ⚠️ pid 可能已被系统回收给无关进程:必须确认它**真的**是 watcher 才敢动它,
1008
+ // 否则宁可当作过期 pid 直接接管(下面 writePidFile 会覆盖掉)。
1009
+ if (looksLikeWatcher(other)) {
1010
+ try { process.kill(other, "SIGTERM"); } catch { /* 已退出 */ }
1011
+ // 等它自己退(它的退出钩子会清理 pid 文件并停掉它的 bridge)。最多 5 秒 ——
1012
+ // 不能等太久,否则监管者又看到一次"迟迟没起来"。
1013
+ const deadline = Date.now() + 5000;
1014
+ while (Date.now() < deadline && pidAlive(other)) sleepSync(100);
1015
+ if (pidAlive(other)) { try { process.kill(other, "SIGKILL"); } catch { /* 已退出 */ } }
1016
+ }
1017
+ writePidFile(WATCHER_PID_FILE, process.pid);
1018
+ } else {
1019
+ console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
1020
+ return;
1021
+ }
967
1022
  }
968
1023
  }
969
1024
  let cfg = loadConfig();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.10-beta.4",
3
+ "version": "0.6.10-beta.6",
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",
@@ -90,6 +90,20 @@ const DEFAULT_API = String(process.env.DSH_RELAY_DEFAULT_API || "").trim()
90
90
  || "https://n.risegao.cn:13443/relay-api";
91
91
  const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
92
92
 
93
+ /**
94
+ * 自启动服务(launchd plist / systemd unit)与子进程使用的 PATH。
95
+ *
96
+ * ⚠️ **必须含 `/usr/sbin` 与 `/sbin`**:它们是系统管理命令所在目录(`ioreg`、`sysctl`…),
97
+ * 而 bridge 会用这些命令探测本机信息。
98
+ * 真机实证(2026-09-22):本文件此前**硬编码**了不含 `/usr/sbin` 的 PATH,而插件每次
99
+ * 自愈/启动都会重写 plist —— 于是它覆盖掉了 dsh-setup.mjs 里那份**正确**的 SERVICE_PATH,
100
+ * 结果 bridge 日志里刷 `/bin/sh: ioreg: command not found`,机器指纹静默退化成
101
+ * hostname 哈希(用户改一次主机名就被当成新设备)。
102
+ * ⚠️ 与 `dsh-setup.mjs` 的 `SERVICE_PATH` **必须一致** —— 两处都会写同一个 plist,
103
+ * 谁写谁说了算;有测试锁死这条一致性(跨文件不变量)。
104
+ */
105
+ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
106
+
93
107
  /**
94
108
  * 把文件权限收紧到「只有本人可读写」。
95
109
  *
@@ -226,7 +240,9 @@ function spawnEnv(extra) {
226
240
  // PATH 分隔符必须按平台取(Windows 是 ";",POSIX 是 ":"):写死 ":" 会把整条 PATH
227
241
  // 在 Windows 上拼成一个不存在的路径,node/npx 全部找不到。
228
242
  // 追加的 POSIX 目录只在类 Unix 上有意义,Windows 上不追加。
229
- const posixDirs = isWindows() ? [] : ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"];
243
+ // 含 /usr/sbin 与 /sbin:插件用 spawnEnv 拉起的是 watcher,它会再拉起 bridge,
244
+ // 而 bridge 要用 ioreg 之类系统命令(缺了它 → 机器指纹静默退化,见 SERVICE_PATH 注释)。
245
+ const posixDirs = isWindows() ? [] : ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
230
246
  const PATH = [nodeDir, base, ...posixDirs].filter(Boolean).join(delimiter);
231
247
  return { ...process.env, PATH, ...(extra || {}) };
232
248
  }
@@ -1007,7 +1023,18 @@ function ensureRuntime(relayDir) {
1007
1023
  }
1008
1024
  }
1009
1025
 
1010
- /** 生成 plist(与 dsh-setup.mjs writeAutostartFile 同构),返回路径。 */
1026
+ /**
1027
+ * 生成 plist(与 dsh-setup.mjs 的 writeAutostartFile **必须同构**),返回路径。
1028
+ *
1029
+ * ⚠️ 两份模板历史上漂移过、并因此各造成一个真机事故(插件每次自愈/启动都会重写 plist,
1030
+ * 所以**插件这份谁写谁说了算**,漂移等于把安装器那份正确的覆盖掉):
1031
+ * · 漏 `/usr/sbin`、`/sbin`(SERVICE_PATH)→ bridge 找不到 `ioreg` → 机器指纹静默退化成
1032
+ * hostname 哈希(改一次主机名就被当成新设备);
1033
+ * · 漏 `LimitLoadToSessionType` → `bootstrap user/<uid>` 失败(rc=5)→ 域阶梯回退到
1034
+ * gui/<uid>,而 macOS 26 上 gui 域是 on-demand-only、`RunAtLoad`/`KeepAlive` 失效 →
1035
+ * **服务被系统(BTM)回收后再也回不来**,只能手动点面板「启动」。
1036
+ * 两者都由 launchd-domain.test.mjs 的跨文件不变量锁死。
1037
+ */
1011
1038
  function writeAutostartFile(relayDir) {
1012
1039
  const plistPath = launchAgentPath();
1013
1040
  if (!plistPath) return null;
@@ -1021,9 +1048,10 @@ function writeAutostartFile(relayDir) {
1021
1048
  <key>RunAtLoad</key><true/>
1022
1049
  <key>KeepAlive</key><true/>
1023
1050
  <key>ThrottleInterval</key><integer>10</integer>
1051
+ <key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
1024
1052
  <key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
1025
1053
  <key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
1026
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string><key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string></dict>
1054
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string><key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string></dict>
1027
1055
  </dict></plist>`;
1028
1056
  mkdirSync(dirname(plistPath), { recursive: true });
1029
1057
  writeFileSync(plistPath, plist, { mode: 0o644 });
@@ -2453,7 +2481,7 @@ const PLUGIN_ID = "dsh-remote-web";
2453
2481
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
2454
2482
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
2455
2483
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
2456
- const PLUGIN_VERSION = "0.6.10-beta.4";
2484
+ const PLUGIN_VERSION = "0.6.10-beta.6";
2457
2485
  const UPDATE_LOG = ".dsh-update.log";
2458
2486
  const UPDATE_MARKER = ".dsh-update-running";
2459
2487
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.10-beta.4",
3
+ "version": "0.6.10-beta.6",
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",
@@ -24,6 +24,8 @@ import { dirname, join } from "node:path";
24
24
 
25
25
  const SETUP = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "dsh-setup.mjs");
26
26
  const src = readFileSync(SETUP, "utf8");
27
+ /** 插件半边也会写同一份 plist —— 两份模板必须一致(谁写谁说了算)。 */
28
+ const INDEX_SRC = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "index.js"), "utf8");
27
29
 
28
30
  test("launchd 修复:plist 必须带 LimitLoadToSessionType(user domain 的必要条件)", () => {
29
31
  assert.match(
@@ -34,6 +36,16 @@ test("launchd 修复:plist 必须带 LimitLoadToSessionType(user domain 的
34
36
  // RunAtLoad / KeepAlive 仍需保留:user domain 下它们才真正生效
35
37
  assert.match(src, /<key>RunAtLoad<\/key><true\/>/, "RunAtLoad 不能删(user domain 下靠它开机自启)");
36
38
  assert.match(src, /<key>KeepAlive<\/key><true\/>/, "KeepAlive 不能删(user domain 下靠它崩溃自愈)");
39
+ // ★ 插件那份模板**也**必须有:它每次自愈/启动都会重写 plist(谁写谁说了算)。
40
+ // 真机实证 2026-09-22:插件模板漏了它 → bootstrap user/<uid> 失败 → 回退 gui/<uid> →
41
+ // macOS 26 上该域 on-demand-only、KeepAlive 失效 → 服务被系统回收后再也回不来,
42
+ // 现场表现就是「自启动服务偶尔整个消失,连 kickstart 都报域里找不到该服务」。
43
+ assert.match(
44
+ INDEX_SRC,
45
+ /<key>LimitLoadToSessionType<\/key><array><string>Aqua<\/string><string>Background<\/string><\/array>/,
46
+ "★插件半边的 plist 模板也必须有 LimitLoadToSessionType(否则它会覆盖掉安装器那份正确的)"
47
+ );
48
+ assert.match(INDEX_SRC, /<key>KeepAlive<\/key><true\/>/, "插件模板也要保留 KeepAlive");
37
49
  });
38
50
 
39
51
  test("launchd 修复:启动走 user → gui 阶梯,且每次都 kickstart", () => {
@@ -76,3 +88,50 @@ test("launchd 修复:服务 PATH 必须含 /usr/sbin 与 /sbin(否则 ioreg
76
88
  assert.ok(/\$\{SERVICE_PATH\}/.test(src), "plist/systemd 应使用 SERVICE_PATH 常量");
77
89
  assert.ok(!/Environment=PATH=\/usr\/local\/bin:\/usr\/bin:\/bin/.test(src), "systemd unit 不应再写死旧 PATH");
78
90
  });
91
+
92
+ test("★ 两份 plist 模板的 PATH 必须一致(插件那份曾漏掉 /usr/sbin,覆盖掉正确的那份)", () => {
93
+ // 真机实证(2026-09-22):插件半边在 plist 模板里**硬编码**了不含 /usr/sbin 的 PATH,
94
+ // 而它每次自愈/启动都会重写 plist → 覆盖掉 dsh-setup.mjs 里正确的那份 →
95
+ // bridge 日志刷 `/bin/sh: ioreg: command not found` → 机器指纹静默退化成 hostname 哈希。
96
+ const setupPath = /const SERVICE_PATH = "([^"]+)"/.exec(src);
97
+ const pluginPath = /const SERVICE_PATH = "([^"]+)"/.exec(INDEX_SRC);
98
+ assert.ok(setupPath, "dsh-setup.mjs 应定义 SERVICE_PATH");
99
+ assert.ok(pluginPath, "插件半边也必须定义 SERVICE_PATH(它也会写 plist)");
100
+ assert.equal(pluginPath[1], setupPath[1], "★两处 PATH 必须逐字一致:同一个 plist 谁写谁说了算");
101
+
102
+ // 插件写 plist 时必须用它,不能再硬编码
103
+ assert.ok(/\$\{SERVICE_PATH\}/.test(INDEX_SRC), "插件的 plist 模板必须使用 SERVICE_PATH");
104
+ assert.ok(!/<key>PATH<\/key><string>\/usr\/local\/bin:\/opt\/homebrew\/bin:\/usr\/bin:\/bin<\/string>/.test(INDEX_SRC),
105
+ "插件不得再硬编码不含 /usr/sbin 的 PATH");
106
+ // spawnEnv 拉起的是 watcher → 它会再拉起 bridge → 也必须能用到系统管理命令
107
+ for (const dir of ["/usr/sbin", "/sbin"]) {
108
+ assert.ok(INDEX_SRC.includes(`"${dir}"`), `spawnEnv 的 posixDirs 也应含 ${dir}`);
109
+ }
110
+ });
111
+
112
+ test("★ 被监管者(launchd)拉起的实例不得因『已有游离守护』而秒退", () => {
113
+ // 真机事故链:KeepAlive 重拉 → 本进程发现 pid 文件里已有游离守护 → 秒退
114
+ // → 监管者判为崩溃并节流(`launchctl print` = `state = spawn scheduled`)
115
+ // → 插件自愈认定"服务坏了" → bootout 摘作业(入口判定成立时连 plist 一起删)
116
+ // → **自启动彻底消失,`launchctl kickstart` 报"域里找不到该服务"**。
117
+ const from = src.indexOf("function dedupDecision(");
118
+ assert.ok(from > -1, "应能找到 dedupDecision");
119
+ const body = src.slice(from, src.indexOf("function writeWindowsTask("));
120
+ assert.ok(body.length > 0, "切片边界应正确");
121
+ const decide = new Function(`${body}\nreturn dedupDecision;`)();
122
+
123
+ assert.equal(decide({ existingPid: 123, selfPid: 456, supervisor: "com.dshremote.bridge" }), "takeover",
124
+ "★被监管者拉起时必须接管,绝不能退出(否则自启动会被监管者与自愈联手摘掉)");
125
+ assert.equal(decide({ existingPid: 123, selfPid: 456, supervisor: "" }), "yield",
126
+ "手动/插件脱离进程拉起时仍必须让位(两个 bridge 会抢同一设备登记)");
127
+ assert.equal(decide({ existingPid: 0, selfPid: 456, supervisor: "" }), "run", "没有别的守护 → 正常跑");
128
+ assert.equal(decide({ existingPid: 456, selfPid: 456, supervisor: "" }), "run", "pid 就是自己 → 正常跑");
129
+ assert.equal(decide({ existingPid: null, selfPid: 456, supervisor: "" }), "run");
130
+
131
+ // 接管路径的接线:判据用监管者注入的 XPC_SERVICE_NAME;动那个 pid 之前必须先认命令行
132
+ const rb = src.slice(src.indexOf("async function runBridge()"), src.indexOf("// ---------- setup"));
133
+ assert.ok(rb.length > 0, "应能切出 runBridge");
134
+ assert.match(rb, /XPC_SERVICE_NAME/, "必须用监管者注入的 XPC_SERVICE_NAME 作判据");
135
+ assert.match(rb, /looksLikeWatcher\(other\)/, "★接管前必须确认那个 pid 真的是 watcher(pid 会被回收给无关进程)");
136
+ assert.match(rb, /dedupDecision\(/, "决策必须走 dedupDecision(保证被判为活代码且可测)");
137
+ });