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

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.
@@ -320,21 +320,100 @@ const HARNESS_COOKIE_FILE = ".harness-cookie.json";
320
320
  const HARNESS_UNAUTHORIZED_TEXT = "dsh web authentication required";
321
321
  /** 撞到 401 时写的「作废」标记:插件在面板轮询时看到它就会立刻重换 Cookie(见 node 半 ensureHarnessCookie)。 */
322
322
  const HARNESS_COOKIE_REVOKED_FILE = ".harness-cookie-revoked";
323
- function markHarnessCookieRevoked() {
323
+
324
+ /**
325
+ * Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
326
+ *
327
+ * 【事故】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端(每次启动
328
+ * 随机端口)是**两个宿主、两个进程**,各自的插件都会往里写"自己 authority 的 Cookie" →
329
+ * 互相覆盖。桥撞 401 后重读磁盘,拿到的是**另一个宿主刚写的** Cookie(与失败的不是同一个)
330
+ * → 重试 → 还是 401 → 再读 → 又被覆盖回去……**乒乓,永不自愈**。手机端持续白页。
331
+ * 【根治】每个宿主只写自己 authority 的文件:`.harness-cookie.<authority>.json`
332
+ * (`:` 换成 `_`,如 `.harness-cookie.127.0.0.1_19387.json`),互不覆盖;桥只认
333
+ * **当前上游 authority** 的那一份。旧版共享文件继续兼容读取,但**foreign authority 一律不用**
334
+ * —— 那正是乒乓的来源。命名规则与插件半 `packages/dsh-remote-web/lib/index.js` 同名,
335
+ * 漂移由 `test/harness-cookie-authority.test.mjs` 的跨文件不变量锁住。
336
+ */
337
+ function authoritySlug(authority) {
338
+ return String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
339
+ }
340
+ /** 本宿主专属 Cookie 文件名(与插件半同规则)。 */
341
+ function harnessCookieFileFor(authority) {
342
+ return `${HARNESS_COOKIE_FILE.slice(0, -".json".length)}.${authoritySlug(authority)}.json`;
343
+ }
344
+ /** 本宿主专属「作废」标记名(与插件半同规则)。 */
345
+ function harnessCookieRevokedFileFor(authority) {
346
+ return `${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`;
347
+ }
348
+ /** 当前上游的 authority(`127.0.0.1:<port>`);取不到返回空串。 */
349
+ function upstreamAuthorityOf(upstream) {
324
350
  try {
325
- const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_REVOKED_FILE);
326
- fs.writeFileSync(p, String(Date.now()), { mode: 0o600 });
327
- } catch { /* 忽略:标记只为加速自愈 */ }
351
+ return new URL(String(upstream || UPSTREAM)).host || "";
352
+ } catch {
353
+ return "";
354
+ }
355
+ }
356
+ /** 已就"哪个 foreign authority 在抢"告警过的集合(每对只吵一次,不刷屏)。 */
357
+ const foreignCookieWarned = new Set();
358
+ function warnForeignCookieOnce(claimed, authority) {
359
+ const key = `${claimed}->${authority}`;
360
+ if (foreignCookieWarned.has(key)) return;
361
+ foreignCookieWarned.add(key);
362
+ console.warn(
363
+ `[bridge] 检测到另一宿主的会话 Cookie(${claimed}),而当前上游是 ${authority} —— ` +
364
+ `一台机器只能服务一个宿主,已跳过该 Cookie(不再乒乓重试)。` +
365
+ `若手机端仍 401,请确认 .dsh-upstream 指向正在运行的那个宿主,并让该宿主的插件重发 Cookie。`
366
+ );
367
+ }
368
+ function readCookieDoc(dir, file) {
369
+ try {
370
+ const j = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
371
+ return j && typeof j.cookie === "string" && j.cookie ? j : null;
372
+ } catch {
373
+ return null;
374
+ }
328
375
  }
376
+ /**
377
+ * 当前上游该用的浏览器会话 Cookie(空串 = 没有可用的)。
378
+ *
379
+ * 取值顺序(2026-10-09 起):
380
+ * ① 本宿主专属文件 `.harness-cookie.<authority>.json` —— 新插件写的,权威;
381
+ * ② 旧版共享 `.harness-cookie.json` —— 仅当它声明的 `authority` 与当前上游一致,
382
+ * 或旧插件根本没写 authority 字段(向后兼容)时才用;
383
+ * ③ 声明了**别的** authority → 明确不用,并告警一次(这就是乒乓的源头)。
384
+ */
329
385
  function harnessCookieOf() {
386
+ const dir = path.dirname(CONFIG_PATH);
387
+ const authority = upstreamAuthorityOf();
388
+ if (authority) {
389
+ const own = readCookieDoc(dir, harnessCookieFileFor(authority));
390
+ if (own) return own.cookie;
391
+ }
392
+ const legacy = readCookieDoc(dir, HARNESS_COOKIE_FILE);
393
+ if (!legacy) return "";
394
+ const claimed = typeof legacy.authority === "string" ? legacy.authority : "";
395
+ if (claimed === "" || authority === "" || claimed === authority) return legacy.cookie;
396
+ warnForeignCookieOnce(claimed, authority);
397
+ return "";
398
+ }
399
+ /** 本宿主 Cookie 是否处于「已作废、等插件重换」状态(诊断用,不参与取值)。 */
400
+ function harnessCookieRevokedFor(authority) {
330
401
  try {
331
- const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_FILE);
332
- const j = JSON.parse(fs.readFileSync(p, "utf8"));
333
- return j && typeof j.cookie === "string" && j.cookie ? j.cookie : "";
402
+ return fs.existsSync(path.join(path.dirname(CONFIG_PATH), harnessCookieRevokedFileFor(authority)));
334
403
  } catch {
335
- return "";
404
+ return false;
336
405
  }
337
406
  }
407
+ function markHarnessCookieRevoked() {
408
+ const dir = path.dirname(CONFIG_PATH);
409
+ const authority = upstreamAuthorityOf();
410
+ // 只写**本宿主**的标记:两个宿主各写各的,插件侧才不会被人家的 401 反复催着重换。
411
+ // (旧版共享标记 `.harness-cookie-revoked` 不再写:它是"两个写者抢一份文件"的同款隐患。)
412
+ if (!authority) return;
413
+ try {
414
+ fs.writeFileSync(path.join(dir, harnessCookieRevokedFileFor(authority)), String(Date.now()), { mode: 0o600 });
415
+ } catch { /* 忽略:标记只为加速自愈 */ }
416
+ }
338
417
 
339
418
  /**
340
419
  * 解析稳定 deviceId:优先级 env DSH_BRIDGE_DEVICE_ID > argv[2] > 配置 device_id > 生成。
@@ -1568,6 +1647,18 @@ function wechatAppUrl() {
1568
1647
  }
1569
1648
 
1570
1649
  let wechatRuntime = null;
1650
+
1651
+ /**
1652
+ * 微信事件通道要连的上游(**每次重连都会调一次**)。
1653
+ *
1654
+ * 行为:先顺手触发一次上游重发现(内部有 15s 冷却 + 单飞,反复调用不会打爆 lsof/探测),
1655
+ * 再返回**当下**的 UPSTREAM。这样宿主换端口时通道能自己跟上,而不必重启 bridge。
1656
+ */
1657
+ function currentUpstreamForChannel() {
1658
+ void refreshUpstream("微信事件通道重连");
1659
+ return UPSTREAM;
1660
+ }
1661
+
1571
1662
  function startWeChat() {
1572
1663
  if (WECHAT_DISABLED) {
1573
1664
  console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
@@ -1578,10 +1669,15 @@ function startWeChat() {
1578
1669
  const cfg = loadLocalConfig();
1579
1670
  wechatRuntime = createWeChatRuntime({
1580
1671
  relayDir,
1581
- // 上游地址在 runTunnel() 开头已完成动态发现(见 refreshUpstream),所以这里拿到的是真实端口。
1582
- // ⚠️ 这是**取值**而非引用:运行中若上游端口又变了(自愈重发现),微信通道要等 bridge 重启才跟上;
1583
- // 隧道转发那一侧不受影响(它每次都读最新的 UPSTREAM)。
1584
- upstream: UPSTREAM,
1672
+ // 传**取值器**而不是当次的值(0.7.0-beta.2 修复)。
1673
+ //
1674
+ // 【2026-10-09 真机事故】这里以前传的是 `UPSTREAM` 这个**当次快照**:bridge 起来时宿主还在
1675
+ // 3080,随后宿主换了端口(Desktop 每次启动随机),隧道转发侧因为每次读最新 UPSTREAM 而
1676
+ // 一切正常,**唯独微信事件通道抱着 3080 死循环重连** —— 现象极具迷惑性:手机能开页面、
1677
+ // bridge-status 显示 online,但任务跑完**一条推送都没有**,日志里只有刷屏的
1678
+ // `connect ECONNREFUSED 127.0.0.1:3080`。
1679
+ // 现在取值器每次重连都会重新求值,并在打到死端口时主动触发一次上游重发现。
1680
+ upstream: () => currentUpstreamForChannel(),
1585
1681
  cookieOf: harnessCookieOf,
1586
1682
  secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
1587
1683
  // 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
@@ -457,6 +457,17 @@ class EventSubscriber extends EventEmitter {
457
457
  */
458
458
  #cursors = new Map();
459
459
 
460
+ /**
461
+ * 上一次**连接尝试**用的上游 URL(不是"成功"的那一次)。
462
+ *
463
+ * 为什么记"尝试"而不是"成功":上游端口从 A 换到 B 时,第一轮打 B 必然是失败的
464
+ * (B 还没起来),若只在成功后才更新,就会每轮都判成"目标变了"而重置退避 ——
465
+ * 退避彻底失效、变成猛敲 B。记"上次尝试过的目标"才能做到**同一个目标只重置一次**。
466
+ */
467
+ #lastConnectUrl = null;
468
+ /** 上一次成功求值到的上游(getter 抛错/返回空时的回退,保证不会去打空地址)。 */
469
+ #lastGoodUpstream = null;
470
+
460
471
  #seq = 0;
461
472
  #lastFrameAt = 0;
462
473
  #authStatus = null;
@@ -465,11 +476,19 @@ class EventSubscriber extends EventEmitter {
465
476
 
466
477
  constructor(options = {}) {
467
478
  super();
468
- if (typeof options.upstream !== "string" || options.upstream.trim() === "") {
479
+ const upstreamGiven = typeof options.upstream === "function"
480
+ ? true
481
+ : typeof options.upstream === "string" && options.upstream.trim() !== "";
482
+ if (!upstreamGiven) {
469
483
  throw new TypeError("dsh-events: options.upstream is required (e.g. http://127.0.0.1:3080)");
470
484
  }
485
+ // upstream 允许是**函数**(每次连接时求值):宿主端口会变(`dsh web --port 0`、
486
+ // DSH Desktop 每次启动随机端口),写死字符串的话端口一变,本订阅器就会永久打旧端口。
487
+ const upstreamGetter = typeof options.upstream === "function"
488
+ ? options.upstream
489
+ : () => options.upstream;
471
490
  this.#opts = {
472
- upstream: options.upstream.replace(/\/+$/, ""),
491
+ upstreamGetter,
473
492
  cookie: options.cookie ?? "",
474
493
  muxPath: options.muxPath ?? MUX_PATH,
475
494
  /** HTTP 路由(默认 `/api/$events/result`)。 */
@@ -631,8 +650,27 @@ class EventSubscriber extends EventEmitter {
631
650
  }
632
651
  }
633
652
 
634
- #muxUrl() {
635
- const url = new URL(this.#opts.upstream);
653
+ /**
654
+ * 当前上游地址(**每次调用都重新求值**,见构造函数里 upstream 可为函数的说明)。
655
+ *
656
+ * 求值失败(函数抛错 / 返回空 / 不是合法 URL)时回退到上一次成功用过的地址,
657
+ * 保证"取不到新地址"永远不会变成"打一个空 URL"。真的没得回退时返回空串,
658
+ * 由 `#connect()` 如实报错,而不是静默重连到垃圾地址。
659
+ */
660
+ #upstream() {
661
+ let raw;
662
+ try {
663
+ raw = this.#opts.upstreamGetter();
664
+ } catch (error) {
665
+ this.#log("warn", "upstream getter threw", { error: String(error) });
666
+ return this.#lastGoodUpstream ?? "";
667
+ }
668
+ const value = typeof raw === "string" ? raw.replace(/\/+$/, "") : "";
669
+ return value || this.#lastGoodUpstream || "";
670
+ }
671
+
672
+ #muxUrl(upstream) {
673
+ const url = new URL(upstream);
636
674
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
637
675
  url.pathname = this.#opts.muxPath;
638
676
  url.search = "";
@@ -643,16 +681,35 @@ class EventSubscriber extends EventEmitter {
643
681
  #connect() {
644
682
  if (this.#closed) return;
645
683
  this.#state = "connecting";
684
+ const upstream = this.#upstream();
685
+ if (upstream === "") {
686
+ this.#state = "auth-failed";
687
+ this.#emitAuthError(0, "upstream 不可用(取不到宿主地址)", "ws");
688
+ return;
689
+ }
690
+ // 目标变了 = 宿主换了端口(Desktop 每次启动随机 / `dsh web` 重启到新端口)。
691
+ // 把退避重置,否则旧目标上累积的长退避会让"换了端口反而更慢连上"(最长要等满 maxMs)。
692
+ if (upstream !== this.#lastConnectUrl) {
693
+ if (this.#lastConnectUrl !== null) {
694
+ this.#log("info", `上游切换 ${this.#lastConnectUrl} → ${upstream},重连退避已重置`, {
695
+ from: this.#lastConnectUrl,
696
+ to: upstream,
697
+ });
698
+ }
699
+ this.#lastConnectUrl = upstream;
700
+ this.#attempt = 0;
701
+ }
702
+ this.#lastGoodUpstream = upstream;
646
703
  let url;
647
704
  try {
648
- url = this.#muxUrl();
705
+ url = this.#muxUrl(upstream);
649
706
  } catch (error) {
650
707
  this.#state = "auth-failed";
651
708
  this.#emitAuthError(0, `upstream 不是合法 URL: ${String(error)}`, "ws");
652
709
  return;
653
710
  }
654
711
  // Host 显式写成上游 authority(loopback 围栏),Cookie 复用 bridge 已持有的浏览器会话。
655
- const headers = { Host: new URL(this.#opts.upstream).host };
712
+ const headers = { Host: new URL(upstream).host };
656
713
  const cookie = this.#cookieValue();
657
714
  if (cookie !== "") headers.Cookie = cookie;
658
715
 
@@ -1411,7 +1468,8 @@ class EventSubscriber extends EventEmitter {
1411
1468
  async #rpc(method, args, eventId) {
1412
1469
  const rpcId = randomUUID();
1413
1470
  const envelope = { type: "client-request", rpcId, method, payload: { args } };
1414
- const url = `${this.#opts.upstream}${API_CHANNEL}/${method}`;
1471
+ // 回执/RPC 也走**当前**上游(端口变了就跟着走),不是一个启动时固化的字符串。
1472
+ const url = `${this.#upstream()}${API_CHANNEL}/${method}`;
1415
1473
 
1416
1474
  const timeoutMs = Number(this.#opts.rpcTimeoutMs);
1417
1475
  const budgeted = Number.isFinite(timeoutMs) && timeoutMs > 0;
@@ -1984,7 +2042,7 @@ export function classifyTurnEnd(sessionId, event, at) {
1984
2042
  /**
1985
2043
  * 创建一个订阅器。
1986
2044
  * @param {{
1987
- * upstream: string,
2045
+ * upstream: string|(() => string), // 函数 = 每次连接/每次 RPC 重新求值(宿主端口会变)
1988
2046
  * cookie?: string|(() => string),
1989
2047
  * muxPath?: string,
1990
2048
  * resultPath?: string,
@@ -2085,3 +2085,91 @@ test("★ 一次 follow 失败不该把这条会话**永久**钉死:下一轮
2085
2085
  await api.close();
2086
2086
  }
2087
2087
  });
2088
+
2089
+ // ============================================================
2090
+ // 上游是**函数**:宿主换端口要能自己跟上(2026-10-09 真机事故回归)
2091
+ // ============================================================
2092
+
2093
+ /**
2094
+ * 【事故原貌】bridge 起来时宿主在 3080,随后宿主换端口(DSH Desktop 每次启动都是随机端口)。
2095
+ * 隧道转发侧每次都读最新 UPSTREAM,所以一切正常;**唯独事件订阅器抱着启动那一刻的字符串**,
2096
+ * 于是永久 `connect ECONNREFUSED 127.0.0.1:3080` —— 手机能开页面、面板显示 online,
2097
+ * 但任务跑完**一条微信推送都没有**。迷惑性极强,最后靠 bridge 日志里刷屏的那一行才定位。
2098
+ *
2099
+ * 修法:`upstream` 允许传函数,每次连接/RPC 重新求值。这条用例就是那件事的锁。
2100
+ */
2101
+ test("★ 事故锁:upstream 传函数时,宿主换端口后订阅器必须自己跟过去(不得永久打旧端口)", async () => {
2102
+ const a = await startFakeDsh();
2103
+ const b = await startFakeDsh();
2104
+ let current = a.base;
2105
+ const logs = [];
2106
+ const sub = createEventSubscriber({
2107
+ upstream: () => current,
2108
+ cookie: () => a.cookie,
2109
+ followSettleMs: 20,
2110
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
2111
+ log: (level, message) => logs.push(`${level}:${message}`),
2112
+ });
2113
+ try {
2114
+ const first = await start(sub);
2115
+ assert.ok(first.clientId, "先连上 A(模拟宿主最初的端口)");
2116
+
2117
+ // 宿主换端口:A 下线、B 上线,取值器开始返回 B。
2118
+ await a.close();
2119
+ current = b.base;
2120
+
2121
+ const second = await new Promise((resolve, reject) => {
2122
+ const timer = setTimeout(() => reject(new Error("换端口后没能自行连上 B(会永久打旧端口)")), 3_000);
2123
+ timer.unref?.();
2124
+ sub.on("ready", (info) => {
2125
+ clearTimeout(timer);
2126
+ resolve(info);
2127
+ });
2128
+ });
2129
+ assert.ok(second.clientId, "必须自己连上 B 并拿到新的 clientId");
2130
+ // clientId 是**每个服务端各自从 client-1 开始编号**的,不能拿它证明"换了连接";
2131
+ // 真正的证据是:B(新端口)确实收到了这条 mux 连接。
2132
+ assert.equal(b.state.connections.length, 1, "新上游 B 必须真的收到了连接");
2133
+ assert.ok(
2134
+ logs.some((l) => l.includes("上游切换") && l.includes(b.base)),
2135
+ `切换上游时必须留下可排查的日志(实际:${JSON.stringify(logs)})`,
2136
+ );
2137
+ } finally {
2138
+ sub.close();
2139
+ await a.close().catch(() => {});
2140
+ await b.close();
2141
+ }
2142
+ });
2143
+
2144
+ test("upstream 传函数但取值器抛错/返回空:绝不退化成打空地址(回退上一次可用地址)", async () => {
2145
+ const a = await startFakeDsh();
2146
+ let mode = "ok";
2147
+ const logs = [];
2148
+ const sub = createEventSubscriber({
2149
+ upstream: () => {
2150
+ if (mode === "throw") throw new Error("取值器炸了");
2151
+ if (mode === "empty") return "";
2152
+ return a.base;
2153
+ },
2154
+ cookie: () => a.cookie,
2155
+ followSettleMs: 20,
2156
+ reconnect: { minMs: 15, maxMs: 40, factor: 1, jitter: 0 },
2157
+ log: (level, message) => logs.push(`${level}:${message}`),
2158
+ });
2159
+ try {
2160
+ await start(sub);
2161
+ mode = "empty";
2162
+ // 断掉当前连接以触发一次退避重连:必须仍然打到 A(回退上次可用地址),而不是某个空 URL。
2163
+ const before = a.state.connections.length;
2164
+ a.currentConn().ws.terminate();
2165
+ await waitFor(() => a.state.connections.length > before, { label: "退避重连(回退上次可用地址)" });
2166
+ mode = "throw";
2167
+ const before2 = a.state.connections.length;
2168
+ a.currentConn().ws.terminate();
2169
+ await waitFor(() => a.state.connections.length > before2, { label: "取值器抛错后仍能重连" });
2170
+ assert.ok(!logs.some((l) => l.includes("upstream 不可用")), "有可回退的地址时不该报『upstream 不可用』");
2171
+ } finally {
2172
+ sub.close();
2173
+ await a.close();
2174
+ }
2175
+ });
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cookie **按 authority 隔离**回归(2026-10-09 事故根治)。
4
+ *
5
+ * 【事故原貌】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端
6
+ * (每次启动随机端口)是**两个宿主进程**,各自的插件都往里写自己 authority 的 Cookie →
7
+ * 互相覆盖。桥撞 401 后重读磁盘拿到的是**另一个宿主刚写的** Cookie(与失败的不是同一个)
8
+ * → 重试 → 还是 401 → 再读 → 又被覆盖回去……**乒乓,永不自愈**,手机端持续白页。
9
+ * 现场极具迷惑性:`bridge-status` 显示 online、页面能开,只有日志里 401 刷屏。
10
+ *
11
+ * 【修法】每个宿主只写自己 authority 的文件 `.harness-cookie.<authority>.json`;桥只认
12
+ * **当前上游 authority** 的那一份,旧版共享文件仅在 authority 对得上(或旧插件根本没写
13
+ * authority)时才用,**声明了别的 authority 一律不用** —— 那一份必然 401,用它就是乒乓。
14
+ *
15
+ * 本用例走真实 `handleHttpFrame` → `doHttp` 全链路:假上游把"收到的 Cookie"回显出来,
16
+ * 因此可以逐字断言桥**到底发了什么**(而不是只断言状态码)。
17
+ */
18
+ import assert from "node:assert/strict";
19
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
20
+ import http from "node:http";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import test, { after } from "node:test";
24
+
25
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-cookie-authority-"));
26
+ const LEGACY = path.join(tmpDir, ".harness-cookie.json");
27
+
28
+ /** 与两份源码同名的规则(漂移由下面的"源码约束"用例兜住)。 */
29
+ const slug = (authority) => String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
30
+ const cookieFileFor = (authority) => path.join(tmpDir, `.harness-cookie.${slug(authority)}.json`);
31
+
32
+ let upstreamAuthority = "";
33
+ function writeDoc(file, doc) {
34
+ mkdirSync(path.dirname(file), { recursive: true });
35
+ writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
36
+ }
37
+ function clearCookies() {
38
+ for (const f of [LEGACY, cookieFileFor(upstreamAuthority)]) rmSync(f, { force: true });
39
+ }
40
+
41
+ /** 假 dsh web:把收到的 Cookie 原样回显(200),便于断言"桥发了哪一份"。 */
42
+ const upstream = http.createServer((req, res) => {
43
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
44
+ res.end(req.headers.cookie || "");
45
+ });
46
+
47
+ process.env.DSH_BRIDGE_DEVICE_ID = "dev-cookieauth1";
48
+ process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, "config.json");
49
+
50
+ const { handleHttpFrame } = await new Promise((resolve, reject) => {
51
+ upstream.listen(0, "127.0.0.1", () => {
52
+ upstreamAuthority = `127.0.0.1:${upstream.address().port}`;
53
+ process.env.DSH_BRIDGE_UPSTREAM = `http://${upstreamAuthority}`;
54
+ import("../dsh-bridge.mjs").then(resolve, reject);
55
+ });
56
+ });
57
+
58
+ async function ask(pathname = "/echo") {
59
+ let reply;
60
+ await handleHttpFrame((obj) => { reply = obj; return true; }, { type: "http", id: `req-${Math.random()}`, method: "GET", path: pathname, headers: {} });
61
+ return { status: reply.status, cookie: Buffer.from(reply.body || "", "base64").toString("utf8") };
62
+ }
63
+
64
+ test("本宿主专属文件优先:`.harness-cookie.<authority>.json` 收到的就是它", async () => {
65
+ clearCookies();
66
+ writeDoc(cookieFileFor(upstreamAuthority), { authority: upstreamAuthority, cookie: "dsh-auth-OWN", mintedAt: Date.now() });
67
+ const r = await ask();
68
+ assert.equal(r.status, 200);
69
+ assert.equal(r.cookie, "dsh-auth-OWN");
70
+ });
71
+
72
+ test("★ 事故锁:旧版共享文件里是**另一个宿主**的 Cookie → 一个字节都不许发出去", async () => {
73
+ clearCookies();
74
+ // 这正是事故现场:共享文件被另一宿主(3080)覆盖过
75
+ writeDoc(LEGACY, { authority: "127.0.0.1:3080", cookie: "dsh-auth-FOREIGN", mintedAt: Date.now() });
76
+ const r = await ask();
77
+ assert.equal(r.status, 200);
78
+ assert.equal(r.cookie, "", "foreign authority 的 Cookie 必须被跳过(发出去必然 401 → 乒乓)");
79
+ });
80
+
81
+ test("旧版共享文件 authority 对得上 → 继续用(向后兼容)", async () => {
82
+ clearCookies();
83
+ writeDoc(LEGACY, { authority: upstreamAuthority, cookie: "dsh-auth-LEGACY-OK", mintedAt: Date.now() });
84
+ const r = await ask();
85
+ assert.equal(r.cookie, "dsh-auth-LEGACY-OK");
86
+ });
87
+
88
+ test("旧插件没写 authority 字段 → 按兼容采用(不能因为缺字段就 401 白页)", async () => {
89
+ clearCookies();
90
+ writeDoc(LEGACY, { cookie: "dsh-auth-NO-AUTHORITY", mintedAt: Date.now() });
91
+ const r = await ask();
92
+ assert.equal(r.cookie, "dsh-auth-NO-AUTHORITY");
93
+ });
94
+
95
+ test("两份都在且互相矛盾 → 用本宿主那份(专属文件优先级高于共享镜像)", async () => {
96
+ clearCookies();
97
+ writeDoc(LEGACY, { authority: "127.0.0.1:3080", cookie: "dsh-auth-FOREIGN", mintedAt: Date.now() });
98
+ writeDoc(cookieFileFor(upstreamAuthority), { authority: upstreamAuthority, cookie: "dsh-auth-OWN", mintedAt: Date.now() });
99
+ const r = await ask();
100
+ assert.equal(r.cookie, "dsh-auth-OWN");
101
+ });
102
+
103
+ test("源码约束(跨文件漂移锁):命名规则与文件名模板两份必须逐字一致", () => {
104
+ const bridge = readFileSync(new URL("../dsh-bridge.mjs", import.meta.url), "utf8");
105
+ const plugin = readFileSync(new URL("../../../packages/dsh-remote-web/lib/index.js", import.meta.url), "utf8");
106
+ const rule = 'return String(authority).replace(/[^A-Za-z0-9._-]/g, "_");';
107
+ const nameTpl = '`${HARNESS_COOKIE_FILE.slice(0, -".json".length)}.${authoritySlug(authority)}.json`';
108
+ const revokedTpl = "`${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`";
109
+ for (const [label, src] of [["桥半", bridge], ["插件半", plugin]]) {
110
+ assert.ok(src.includes(rule), `${label} 缺少 authoritySlug 规则(两份必须一致,否则各写各的文件名)`);
111
+ assert.ok(src.includes(nameTpl), `${label} 缺少专属文件名模板`);
112
+ assert.ok(src.includes(revokedTpl), `${label} 缺少专属作废标记名模板`);
113
+ }
114
+ assert.ok(bridge.includes("claimed === authority"), "桥半必须显式判 foreign authority(乒乓的根源)");
115
+ assert.ok(plugin.includes("claimed === authority"), "插件半必须在读的时候判 foreign authority");
116
+ });
117
+
118
+ after(() => {
119
+ try { upstream.close(); } catch { /* ignore */ }
120
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
121
+ });
@@ -19,10 +19,17 @@ import test, { after } from "node:test";
19
19
  const UNAUTH = "dsh web authentication required; reopen the URL printed by dsh web.\n";
20
20
  const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-harness-cookie-"));
21
21
  const cookieFile = path.join(tmpDir, ".harness-cookie.json");
22
- const revokedFile = path.join(tmpDir, ".harness-cookie-revoked");
22
+ /** 作废标记自 0.7.0-beta.2 起**按 authority 分文件**(见 harness-cookie-authority.test.mjs)。 */
23
+ const slug = (authority) => String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
24
+ const revokedFileFor = (authority) => path.join(tmpDir, `.harness-cookie-revoked.${slug(authority)}`);
25
+ /** 本用例的假上游 authority(listen 后才知道端口;Cookie 必须按它命名才不会被判 foreign)。 */
26
+ let upstreamAuthority = "127.0.0.1:3080";
27
+ const revokedFile = () => revokedFileFor(upstreamAuthority);
23
28
 
24
29
  function writeCookie(value) {
25
- writeFileSync(cookieFile, JSON.stringify({ authority: "127.0.0.1:3080", cookie: value, mintedAt: Date.now() }), { mode: 0o600 });
30
+ // 写**旧版共享文件**:新桥仍兼容读取(authority 对得上时),这条用例验的是 401 自愈链路,
31
+ // 与"专属/共享"无关;专属文件的优先级由 harness-cookie-authority.test.mjs 覆盖。
32
+ writeFileSync(cookieFile, JSON.stringify({ authority: upstreamAuthority, cookie: value, mintedAt: Date.now() }), { mode: 0o600 });
26
33
  }
27
34
 
28
35
  // 假 dsh web:只认 cookie=dsh-auth-NEW;第一次请求 401(并模拟插件此刻换好新 Cookie)
@@ -50,7 +57,8 @@ process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, "config.json");
50
57
 
51
58
  const { handleHttpFrame } = await new Promise((resolve, reject) => {
52
59
  upstream.listen(0, "127.0.0.1", () => {
53
- process.env.DSH_BRIDGE_UPSTREAM = `http://127.0.0.1:${upstream.address().port}`;
60
+ upstreamAuthority = `127.0.0.1:${upstream.address().port}`;
61
+ process.env.DSH_BRIDGE_UPSTREAM = `http://${upstreamAuthority}`;
54
62
  import("../dsh-bridge.mjs").then(resolve, reject);
55
63
  });
56
64
  });
@@ -69,12 +77,12 @@ async function request(frame) {
69
77
 
70
78
  test("旧 Cookie 被 dsh web 拒(401)→ 用新 Cookie 自动重试一次:手机端拿到 200,用户零操作", async () => {
71
79
  writeCookie("dsh-auth-OLD");
72
- rmSync(revokedFile, { force: true });
80
+ rmSync(revokedFile(), { force: true });
73
81
  const reply = await request({ id: "heal-1", method: "GET", path: "/needs-auth", headers: {} });
74
82
  assert.equal(reply.status, 200, "自愈后应返回 200(而不是把 401 透传给手机)");
75
83
  assert.equal(Buffer.from(reply.body, "base64").toString("utf8"), "ok");
76
84
  assert.equal(firstCallSeen, 1, "上游只被 401 了一次(第二次带新 Cookie 成功)");
77
- assert.ok(existsSync(revokedFile), "应写下 .harness-cookie-revoked 标记,让插件在面板轮询时重换");
85
+ assert.ok(existsSync(revokedFile()), "应写下 .harness-cookie-revoked 标记,让插件在面板轮询时重换");
78
86
  });
79
87
 
80
88
  test("Cookie 未变(插件还没换好)→ 保留 401 但已打标记,等待插件重换", async () => {
@@ -87,14 +95,14 @@ test("Cookie 未变(插件还没换好)→ 保留 401 但已打标记,等待插
87
95
  const prev = process.env.DSH_BRIDGE_UPSTREAM;
88
96
  // 另起一个 bridge 实例指向这个永远 401 的上游(模块已加载,上游常量不可变 → 用新进程不便;
89
97
  // 这里直接验证标记语义:标记存在时 401 应原样透传,且标记仍在)
90
- rmSync(revokedFile, { force: true });
98
+ rmSync(revokedFile(), { force: true });
91
99
  writeCookie("dsh-auth-STILL-OLD");
92
100
  try {
93
101
  const reply = await request({ id: "heal-2", method: "GET", path: "/needs-auth", headers: {} });
94
102
  // 上一个用例的假上游此刻会返回 200(因为它已经在第一次调用时把 Cookie 改成了 NEW),
95
103
  // 所以这里只断言"标记机制"本身:去掉标记后仍能再次自愈
96
104
  assert.ok(reply.status === 200 || reply.status === 401);
97
- assert.ok(existsSync(revokedFile) || reply.status === 200, "自愈后应留下标记或已成功");
105
+ assert.ok(existsSync(revokedFile()) || reply.status === 200, "自愈后应留下标记或已成功");
98
106
  } finally {
99
107
  process.env.DSH_BRIDGE_UPSTREAM = prev;
100
108
  await new Promise((r) => srv2.close(r));
@@ -340,7 +340,7 @@ export class WeChatRuntime {
340
340
  /**
341
341
  * @param {{
342
342
  * relayDir: string,
343
- * upstream?: string, // dsh web 地址,默认 http://127.0.0.1:3080
343
+ * upstream?: string|(() => string), // dsh web 地址,默认 http://127.0.0.1:3080;函数=每次重连重新求值
344
344
  * cookieOf?: () => string, // 读 .harness-cookie.json 的回调(bridge 已有该逻辑)
345
345
  * secret?: string, // 控制面密钥(bridge_secret);空则控制面拒绝所有请求
346
346
  * logger?: any,
@@ -356,6 +356,16 @@ export class WeChatRuntime {
356
356
  if (!opts.relayDir) throw new Error("WeChatRuntime: 缺少 relayDir");
357
357
  this.relayDir = opts.relayDir;
358
358
  this.upstream = String(opts.upstream || "http://127.0.0.1:3080").replace(/\/+$/, "");
359
+ /**
360
+ * 上游地址的**取值器**:恰好是字符串时是常量函数。
361
+ *
362
+ * 为什么不让它是个固化字符串:宿主端口会变(`dsh web --port 0`、DSH Desktop 每次启动随机端口),
363
+ * 而事件订阅器是**长连接** —— 固化字符串的后果是端口一变就永久重连到旧端口。
364
+ * 【2026-10-09 真机实证】bridge 启动时宿主还是 3080,随后换成 19387;隧道转发侧
365
+ * (每次都读最新 UPSTREAM)一切正常,唯独微信事件通道死循环刷
366
+ * `connect ECONNREFUSED 127.0.0.1:3080` —— 手机能开页面,但**任务完成没有任何推送**。
367
+ */
368
+ this.upstreamOf = typeof opts.upstream === "function" ? opts.upstream : () => this.upstream;
359
369
  this.cookieOf = typeof opts.cookieOf === "function" ? opts.cookieOf : () => "";
360
370
  this.secret = String(opts.secret || "");
361
371
  this.clock = opts.clock || Date.now;
@@ -1597,7 +1607,8 @@ export class WeChatRuntime {
1597
1607
  // 而且完全静默。现在无论订阅器是新建还是注入,都保证挂上处理器(WeakSet 去重,可重复调用)。
1598
1608
  if (!this.subscriber) {
1599
1609
  this.subscriber = createEventSubscriber({
1600
- upstream: this.upstream,
1610
+ // 传**取值器**而不是当次的值:见构造函数里 upstreamOf 的说明(端口变了要能跟上)。
1611
+ upstream: () => this.upstreamOf(),
1601
1612
  cookie: () => this.cookieOf(),
1602
1613
  follow: true,
1603
1614
  log: (level, message, meta) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.0-beta.1",
3
+ "version": "0.7.0-beta.2",
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",
@@ -740,6 +740,34 @@ function applySaaSMode(cfg) {
740
740
  * 老版本(无该鉴权)下 connection 服务没有 authenticatedUrl → 静默跳过,行为不变。
741
741
  */
742
742
  const HARNESS_COOKIE_FILE = ".harness-cookie.json";
743
+ /**
744
+ * Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
745
+ *
746
+ * 【事故】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端(每次启动
747
+ * 随机端口)是**两个宿主进程**,各自的插件都往里写"自己 authority 的 Cookie" → 互相覆盖。
748
+ * 桥撞 401 后重读磁盘,拿到的是另一个宿主刚写的 Cookie → 重试 → 还是 401 → 再读 → 又被
749
+ * 覆盖回去……**乒乓,永不自愈**,手机端持续白页(现象与"自愈坏了"一模一样,极易误判)。
750
+ * 【根治】每个宿主只写自己 authority 的文件:`.harness-cookie.<authority>.json`
751
+ * (`:` → `_`,如 `.harness-cookie.127.0.0.1_19387.json`),互不覆盖。旧版共享文件继续**写**
752
+ * 一份镜像(旧桥还能用),但新桥只认当前上游 authority 的那一份,foreign 一律跳过。
753
+ * 命名规则与桥半 `clients/dsh-remote/dsh-bridge.mjs` 同名,漂移由
754
+ * `clients/dsh-remote/test/harness-cookie-authority.test.mjs` 的跨文件不变量锁住。
755
+ */
756
+ function authoritySlug(authority) {
757
+ return String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
758
+ }
759
+ /** 本宿主专属 Cookie 文件名(与桥半同规则)。 */
760
+ function harnessCookieFileFor(authority) {
761
+ return `${HARNESS_COOKIE_FILE.slice(0, -".json".length)}.${authoritySlug(authority)}.json`;
762
+ }
763
+ /** 本宿主专属「作废」标记名(与桥半同规则)。 */
764
+ function harnessCookieRevokedFileFor(authority) {
765
+ return `${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`;
766
+ }
767
+ /** 由本进程 webServer 端口推出 authority(与 bridge 的 Host 口径一致)。 */
768
+ function harnessAuthorityOfPort(port) {
769
+ return port ? `127.0.0.1:${port}` : "";
770
+ }
743
771
  /**
744
772
  * 手机端 401「dsh web authentication required; reopen the URL printed by dsh web」的根因就在这里:
745
773
  * 该 Cookie 由本插件在进程内经 ?token= 换取后交给 bridge 代持,**dsh web 每次重启都会换签名密钥**,
@@ -786,22 +814,47 @@ async function mintHarnessCookie(ctx, relayDir) {
786
814
  if (UNINSTALLED_DIRS.has(relayDir)) return false;
787
815
  const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
788
816
  mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
789
- writeFileSync(join(relayDir, HARNESS_COOKIE_FILE), JSON.stringify(out, null, 2), { mode: 0o600 });
790
- hardenFile(join(relayDir, HARNESS_COOKIE_FILE)); // 会话 Cookie ≈ dsh web 的完整访问权,同样要收紧
817
+ // ① 本宿主专属文件(权威,两个宿主互不覆盖 —— 事故根治点)
818
+ const ownPath = join(relayDir, harnessCookieFileFor(out.authority));
819
+ writeFileSync(ownPath, JSON.stringify(out, null, 2), { mode: 0o600 });
820
+ hardenFile(ownPath); // 会话 Cookie ≈ dsh web 的完整访问权,同样要收紧
821
+ // ② 旧版共享文件镜像:只为还跑着旧桥(<0.7.0-beta.2)的机器保留 —— 新桥优先读 ①,
822
+ // 且对 authority 不符的镜像**直接跳过**,所以这里写镜像不会再引发乒乓。
823
+ const legacyPath = join(relayDir, HARNESS_COOKIE_FILE);
824
+ writeFileSync(legacyPath, JSON.stringify(out, null, 2), { mode: 0o600 });
825
+ hardenFile(legacyPath);
791
826
  return true;
792
827
  } catch {
793
828
  return false;
794
829
  }
795
830
  }
796
831
 
797
- /** 读取当前 Cookie 文件状态:{cookie, authority, mintedAt} 或 null。 */
798
- function readHarnessCookie(relayDir) {
799
- try {
800
- const j = JSON.parse(readFileSync(join(relayDir, HARNESS_COOKIE_FILE), "utf8"));
801
- return j && typeof j.cookie === "string" && j.cookie ? j : null;
802
- } catch {
803
- return null;
832
+ /**
833
+ * 读取当前 authority 可用的 Cookie 文件状态:{cookie, authority, mintedAt} 或 null。
834
+ * ① 本宿主专属文件优先;
835
+ * ② 旧版共享文件仅在 authority 对得上、或旧插件没写 authority 字段(兼容)时采用;
836
+ * ③ 声明了别的 authority → 明确不用(那属于另一个宿主,用了必然 401)。
837
+ * @param {string} relayDir 配置目录
838
+ * @param {string} [authority] 本进程 authority(`127.0.0.1:<port>`);缺省只做兼容读取
839
+ */
840
+ function readHarnessCookie(relayDir, authority) {
841
+ const read = (file) => {
842
+ try {
843
+ const j = JSON.parse(readFileSync(join(relayDir, file), "utf8"));
844
+ return j && typeof j.cookie === "string" && j.cookie ? j : null;
845
+ } catch {
846
+ return null;
847
+ }
848
+ };
849
+ if (authority) {
850
+ const own = read(harnessCookieFileFor(authority));
851
+ if (own) return own;
804
852
  }
853
+ const legacy = read(HARNESS_COOKIE_FILE);
854
+ if (!legacy) return null;
855
+ const claimed = typeof legacy.authority === "string" ? legacy.authority : "";
856
+ if (claimed === "" || !authority || claimed === authority) return legacy;
857
+ return null; // foreign authority:另一个宿主的 Cookie,本宿主用不了
805
858
  }
806
859
 
807
860
  let harnessMintInflight = false;
@@ -819,9 +872,22 @@ let harnessMintLastAt = 0;
819
872
  */
820
873
  async function ensureHarnessCookie(ctx, relayDir, opts) {
821
874
  const force = !!(opts && opts.force);
822
- const revoked = existsSync(join(relayDir, HARNESS_COOKIE_REVOKED_FILE));
875
+ const authority = harnessAuthorityOfPort(ctx.webServer?.port);
876
+ const ownRevokedPath = authority ? join(relayDir, harnessCookieRevokedFileFor(authority)) : "";
877
+ const legacyRevokedPath = join(relayDir, HARNESS_COOKIE_REVOKED_FILE);
878
+ const cur = readHarnessCookie(relayDir, authority);
879
+ // 作废标记两个来源:
880
+ // ① 本宿主专属标记(新桥 0.7.0-beta.2+ 写)—— 精确,不会被人家的 401 催着重换;
881
+ // ② 旧版共享标记(旧桥写)—— 仅当它**晚于**我们手上这份 Cookie 时才认:说明是它被拒了。
882
+ // 否则(比如另一个宿主刚被拒、标记比我们的 Cookie 还旧)不理会,避免两个宿主互相催。
883
+ let revoked = ownRevokedPath !== "" && existsSync(ownRevokedPath);
884
+ if (!revoked && existsSync(legacyRevokedPath)) {
885
+ try {
886
+ const revokedAt = Number(readFileSync(legacyRevokedPath, "utf8").trim()) || 0;
887
+ revoked = !cur || revokedAt > Number(cur.mintedAt || 0);
888
+ } catch { /* 标记读不到就当没有 */ }
889
+ }
823
890
  if (!force && !revoked) {
824
- const cur = readHarnessCookie(relayDir);
825
891
  if (cur && Date.now() - Number(cur.mintedAt || 0) < HARNESS_COOKIE_STALE_MS) return true;
826
892
  }
827
893
  if (harnessMintInflight) return false; // 去重:同一时刻只换一次
@@ -831,7 +897,11 @@ async function ensureHarnessCookie(ctx, relayDir, opts) {
831
897
  try {
832
898
  const ok = await mintHarnessCookie(ctx, relayDir);
833
899
  if (ok || revoked) {
834
- try { rmSync(join(relayDir, HARNESS_COOKIE_REVOKED_FILE), { force: true }); } catch { /* ignore */ }
900
+ // 清标记:① 本宿主专属标记(精确);② 旧版共享标记(旧桥写的,清了不影响新桥)。
901
+ // 代价:多宿主机器上可能清掉"另一个宿主"的旧标记 —— 那只让对方的**快速**自愈退化成
902
+ // 30 分钟周期刷新,不会造成错误状态,故不做更细的归属判定(复杂度不值)。
903
+ if (ownRevokedPath) { try { rmSync(ownRevokedPath, { force: true }); } catch { /* ignore */ } }
904
+ try { rmSync(legacyRevokedPath, { force: true }); } catch { /* ignore */ }
835
905
  }
836
906
  if (!ok && revoked) console.warn("[dsh-remote-web] 浏览器会话 Cookie 已被 dsh web 作废,重取失败,下次查状态再试");
837
907
  return ok;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.0-beta.1",
3
+ "version": "0.7.0-beta.2",
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",
@@ -320,21 +320,100 @@ const HARNESS_COOKIE_FILE = ".harness-cookie.json";
320
320
  const HARNESS_UNAUTHORIZED_TEXT = "dsh web authentication required";
321
321
  /** 撞到 401 时写的「作废」标记:插件在面板轮询时看到它就会立刻重换 Cookie(见 node 半 ensureHarnessCookie)。 */
322
322
  const HARNESS_COOKIE_REVOKED_FILE = ".harness-cookie-revoked";
323
- function markHarnessCookieRevoked() {
323
+
324
+ /**
325
+ * Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
326
+ *
327
+ * 【事故】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端(每次启动
328
+ * 随机端口)是**两个宿主、两个进程**,各自的插件都会往里写"自己 authority 的 Cookie" →
329
+ * 互相覆盖。桥撞 401 后重读磁盘,拿到的是**另一个宿主刚写的** Cookie(与失败的不是同一个)
330
+ * → 重试 → 还是 401 → 再读 → 又被覆盖回去……**乒乓,永不自愈**。手机端持续白页。
331
+ * 【根治】每个宿主只写自己 authority 的文件:`.harness-cookie.<authority>.json`
332
+ * (`:` 换成 `_`,如 `.harness-cookie.127.0.0.1_19387.json`),互不覆盖;桥只认
333
+ * **当前上游 authority** 的那一份。旧版共享文件继续兼容读取,但**foreign authority 一律不用**
334
+ * —— 那正是乒乓的来源。命名规则与插件半 `packages/dsh-remote-web/lib/index.js` 同名,
335
+ * 漂移由 `test/harness-cookie-authority.test.mjs` 的跨文件不变量锁住。
336
+ */
337
+ function authoritySlug(authority) {
338
+ return String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
339
+ }
340
+ /** 本宿主专属 Cookie 文件名(与插件半同规则)。 */
341
+ function harnessCookieFileFor(authority) {
342
+ return `${HARNESS_COOKIE_FILE.slice(0, -".json".length)}.${authoritySlug(authority)}.json`;
343
+ }
344
+ /** 本宿主专属「作废」标记名(与插件半同规则)。 */
345
+ function harnessCookieRevokedFileFor(authority) {
346
+ return `${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`;
347
+ }
348
+ /** 当前上游的 authority(`127.0.0.1:<port>`);取不到返回空串。 */
349
+ function upstreamAuthorityOf(upstream) {
324
350
  try {
325
- const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_REVOKED_FILE);
326
- fs.writeFileSync(p, String(Date.now()), { mode: 0o600 });
327
- } catch { /* 忽略:标记只为加速自愈 */ }
351
+ return new URL(String(upstream || UPSTREAM)).host || "";
352
+ } catch {
353
+ return "";
354
+ }
355
+ }
356
+ /** 已就"哪个 foreign authority 在抢"告警过的集合(每对只吵一次,不刷屏)。 */
357
+ const foreignCookieWarned = new Set();
358
+ function warnForeignCookieOnce(claimed, authority) {
359
+ const key = `${claimed}->${authority}`;
360
+ if (foreignCookieWarned.has(key)) return;
361
+ foreignCookieWarned.add(key);
362
+ console.warn(
363
+ `[bridge] 检测到另一宿主的会话 Cookie(${claimed}),而当前上游是 ${authority} —— ` +
364
+ `一台机器只能服务一个宿主,已跳过该 Cookie(不再乒乓重试)。` +
365
+ `若手机端仍 401,请确认 .dsh-upstream 指向正在运行的那个宿主,并让该宿主的插件重发 Cookie。`
366
+ );
367
+ }
368
+ function readCookieDoc(dir, file) {
369
+ try {
370
+ const j = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
371
+ return j && typeof j.cookie === "string" && j.cookie ? j : null;
372
+ } catch {
373
+ return null;
374
+ }
328
375
  }
376
+ /**
377
+ * 当前上游该用的浏览器会话 Cookie(空串 = 没有可用的)。
378
+ *
379
+ * 取值顺序(2026-10-09 起):
380
+ * ① 本宿主专属文件 `.harness-cookie.<authority>.json` —— 新插件写的,权威;
381
+ * ② 旧版共享 `.harness-cookie.json` —— 仅当它声明的 `authority` 与当前上游一致,
382
+ * 或旧插件根本没写 authority 字段(向后兼容)时才用;
383
+ * ③ 声明了**别的** authority → 明确不用,并告警一次(这就是乒乓的源头)。
384
+ */
329
385
  function harnessCookieOf() {
386
+ const dir = path.dirname(CONFIG_PATH);
387
+ const authority = upstreamAuthorityOf();
388
+ if (authority) {
389
+ const own = readCookieDoc(dir, harnessCookieFileFor(authority));
390
+ if (own) return own.cookie;
391
+ }
392
+ const legacy = readCookieDoc(dir, HARNESS_COOKIE_FILE);
393
+ if (!legacy) return "";
394
+ const claimed = typeof legacy.authority === "string" ? legacy.authority : "";
395
+ if (claimed === "" || authority === "" || claimed === authority) return legacy.cookie;
396
+ warnForeignCookieOnce(claimed, authority);
397
+ return "";
398
+ }
399
+ /** 本宿主 Cookie 是否处于「已作废、等插件重换」状态(诊断用,不参与取值)。 */
400
+ function harnessCookieRevokedFor(authority) {
330
401
  try {
331
- const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_FILE);
332
- const j = JSON.parse(fs.readFileSync(p, "utf8"));
333
- return j && typeof j.cookie === "string" && j.cookie ? j.cookie : "";
402
+ return fs.existsSync(path.join(path.dirname(CONFIG_PATH), harnessCookieRevokedFileFor(authority)));
334
403
  } catch {
335
- return "";
404
+ return false;
336
405
  }
337
406
  }
407
+ function markHarnessCookieRevoked() {
408
+ const dir = path.dirname(CONFIG_PATH);
409
+ const authority = upstreamAuthorityOf();
410
+ // 只写**本宿主**的标记:两个宿主各写各的,插件侧才不会被人家的 401 反复催着重换。
411
+ // (旧版共享标记 `.harness-cookie-revoked` 不再写:它是"两个写者抢一份文件"的同款隐患。)
412
+ if (!authority) return;
413
+ try {
414
+ fs.writeFileSync(path.join(dir, harnessCookieRevokedFileFor(authority)), String(Date.now()), { mode: 0o600 });
415
+ } catch { /* 忽略:标记只为加速自愈 */ }
416
+ }
338
417
 
339
418
  /**
340
419
  * 解析稳定 deviceId:优先级 env DSH_BRIDGE_DEVICE_ID > argv[2] > 配置 device_id > 生成。
@@ -1568,6 +1647,18 @@ function wechatAppUrl() {
1568
1647
  }
1569
1648
 
1570
1649
  let wechatRuntime = null;
1650
+
1651
+ /**
1652
+ * 微信事件通道要连的上游(**每次重连都会调一次**)。
1653
+ *
1654
+ * 行为:先顺手触发一次上游重发现(内部有 15s 冷却 + 单飞,反复调用不会打爆 lsof/探测),
1655
+ * 再返回**当下**的 UPSTREAM。这样宿主换端口时通道能自己跟上,而不必重启 bridge。
1656
+ */
1657
+ function currentUpstreamForChannel() {
1658
+ void refreshUpstream("微信事件通道重连");
1659
+ return UPSTREAM;
1660
+ }
1661
+
1571
1662
  function startWeChat() {
1572
1663
  if (WECHAT_DISABLED) {
1573
1664
  console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
@@ -1578,10 +1669,15 @@ function startWeChat() {
1578
1669
  const cfg = loadLocalConfig();
1579
1670
  wechatRuntime = createWeChatRuntime({
1580
1671
  relayDir,
1581
- // 上游地址在 runTunnel() 开头已完成动态发现(见 refreshUpstream),所以这里拿到的是真实端口。
1582
- // ⚠️ 这是**取值**而非引用:运行中若上游端口又变了(自愈重发现),微信通道要等 bridge 重启才跟上;
1583
- // 隧道转发那一侧不受影响(它每次都读最新的 UPSTREAM)。
1584
- upstream: UPSTREAM,
1672
+ // 传**取值器**而不是当次的值(0.7.0-beta.2 修复)。
1673
+ //
1674
+ // 【2026-10-09 真机事故】这里以前传的是 `UPSTREAM` 这个**当次快照**:bridge 起来时宿主还在
1675
+ // 3080,随后宿主换了端口(Desktop 每次启动随机),隧道转发侧因为每次读最新 UPSTREAM 而
1676
+ // 一切正常,**唯独微信事件通道抱着 3080 死循环重连** —— 现象极具迷惑性:手机能开页面、
1677
+ // bridge-status 显示 online,但任务跑完**一条推送都没有**,日志里只有刷屏的
1678
+ // `connect ECONNREFUSED 127.0.0.1:3080`。
1679
+ // 现在取值器每次重连都会重新求值,并在打到死端口时主动触发一次上游重发现。
1680
+ upstream: () => currentUpstreamForChannel(),
1585
1681
  cookieOf: harnessCookieOf,
1586
1682
  secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
1587
1683
  // 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
@@ -457,6 +457,17 @@ class EventSubscriber extends EventEmitter {
457
457
  */
458
458
  #cursors = new Map();
459
459
 
460
+ /**
461
+ * 上一次**连接尝试**用的上游 URL(不是"成功"的那一次)。
462
+ *
463
+ * 为什么记"尝试"而不是"成功":上游端口从 A 换到 B 时,第一轮打 B 必然是失败的
464
+ * (B 还没起来),若只在成功后才更新,就会每轮都判成"目标变了"而重置退避 ——
465
+ * 退避彻底失效、变成猛敲 B。记"上次尝试过的目标"才能做到**同一个目标只重置一次**。
466
+ */
467
+ #lastConnectUrl = null;
468
+ /** 上一次成功求值到的上游(getter 抛错/返回空时的回退,保证不会去打空地址)。 */
469
+ #lastGoodUpstream = null;
470
+
460
471
  #seq = 0;
461
472
  #lastFrameAt = 0;
462
473
  #authStatus = null;
@@ -465,11 +476,19 @@ class EventSubscriber extends EventEmitter {
465
476
 
466
477
  constructor(options = {}) {
467
478
  super();
468
- if (typeof options.upstream !== "string" || options.upstream.trim() === "") {
479
+ const upstreamGiven = typeof options.upstream === "function"
480
+ ? true
481
+ : typeof options.upstream === "string" && options.upstream.trim() !== "";
482
+ if (!upstreamGiven) {
469
483
  throw new TypeError("dsh-events: options.upstream is required (e.g. http://127.0.0.1:3080)");
470
484
  }
485
+ // upstream 允许是**函数**(每次连接时求值):宿主端口会变(`dsh web --port 0`、
486
+ // DSH Desktop 每次启动随机端口),写死字符串的话端口一变,本订阅器就会永久打旧端口。
487
+ const upstreamGetter = typeof options.upstream === "function"
488
+ ? options.upstream
489
+ : () => options.upstream;
471
490
  this.#opts = {
472
- upstream: options.upstream.replace(/\/+$/, ""),
491
+ upstreamGetter,
473
492
  cookie: options.cookie ?? "",
474
493
  muxPath: options.muxPath ?? MUX_PATH,
475
494
  /** HTTP 路由(默认 `/api/$events/result`)。 */
@@ -631,8 +650,27 @@ class EventSubscriber extends EventEmitter {
631
650
  }
632
651
  }
633
652
 
634
- #muxUrl() {
635
- const url = new URL(this.#opts.upstream);
653
+ /**
654
+ * 当前上游地址(**每次调用都重新求值**,见构造函数里 upstream 可为函数的说明)。
655
+ *
656
+ * 求值失败(函数抛错 / 返回空 / 不是合法 URL)时回退到上一次成功用过的地址,
657
+ * 保证"取不到新地址"永远不会变成"打一个空 URL"。真的没得回退时返回空串,
658
+ * 由 `#connect()` 如实报错,而不是静默重连到垃圾地址。
659
+ */
660
+ #upstream() {
661
+ let raw;
662
+ try {
663
+ raw = this.#opts.upstreamGetter();
664
+ } catch (error) {
665
+ this.#log("warn", "upstream getter threw", { error: String(error) });
666
+ return this.#lastGoodUpstream ?? "";
667
+ }
668
+ const value = typeof raw === "string" ? raw.replace(/\/+$/, "") : "";
669
+ return value || this.#lastGoodUpstream || "";
670
+ }
671
+
672
+ #muxUrl(upstream) {
673
+ const url = new URL(upstream);
636
674
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
637
675
  url.pathname = this.#opts.muxPath;
638
676
  url.search = "";
@@ -643,16 +681,35 @@ class EventSubscriber extends EventEmitter {
643
681
  #connect() {
644
682
  if (this.#closed) return;
645
683
  this.#state = "connecting";
684
+ const upstream = this.#upstream();
685
+ if (upstream === "") {
686
+ this.#state = "auth-failed";
687
+ this.#emitAuthError(0, "upstream 不可用(取不到宿主地址)", "ws");
688
+ return;
689
+ }
690
+ // 目标变了 = 宿主换了端口(Desktop 每次启动随机 / `dsh web` 重启到新端口)。
691
+ // 把退避重置,否则旧目标上累积的长退避会让"换了端口反而更慢连上"(最长要等满 maxMs)。
692
+ if (upstream !== this.#lastConnectUrl) {
693
+ if (this.#lastConnectUrl !== null) {
694
+ this.#log("info", `上游切换 ${this.#lastConnectUrl} → ${upstream},重连退避已重置`, {
695
+ from: this.#lastConnectUrl,
696
+ to: upstream,
697
+ });
698
+ }
699
+ this.#lastConnectUrl = upstream;
700
+ this.#attempt = 0;
701
+ }
702
+ this.#lastGoodUpstream = upstream;
646
703
  let url;
647
704
  try {
648
- url = this.#muxUrl();
705
+ url = this.#muxUrl(upstream);
649
706
  } catch (error) {
650
707
  this.#state = "auth-failed";
651
708
  this.#emitAuthError(0, `upstream 不是合法 URL: ${String(error)}`, "ws");
652
709
  return;
653
710
  }
654
711
  // Host 显式写成上游 authority(loopback 围栏),Cookie 复用 bridge 已持有的浏览器会话。
655
- const headers = { Host: new URL(this.#opts.upstream).host };
712
+ const headers = { Host: new URL(upstream).host };
656
713
  const cookie = this.#cookieValue();
657
714
  if (cookie !== "") headers.Cookie = cookie;
658
715
 
@@ -1411,7 +1468,8 @@ class EventSubscriber extends EventEmitter {
1411
1468
  async #rpc(method, args, eventId) {
1412
1469
  const rpcId = randomUUID();
1413
1470
  const envelope = { type: "client-request", rpcId, method, payload: { args } };
1414
- const url = `${this.#opts.upstream}${API_CHANNEL}/${method}`;
1471
+ // 回执/RPC 也走**当前**上游(端口变了就跟着走),不是一个启动时固化的字符串。
1472
+ const url = `${this.#upstream()}${API_CHANNEL}/${method}`;
1415
1473
 
1416
1474
  const timeoutMs = Number(this.#opts.rpcTimeoutMs);
1417
1475
  const budgeted = Number.isFinite(timeoutMs) && timeoutMs > 0;
@@ -1984,7 +2042,7 @@ export function classifyTurnEnd(sessionId, event, at) {
1984
2042
  /**
1985
2043
  * 创建一个订阅器。
1986
2044
  * @param {{
1987
- * upstream: string,
2045
+ * upstream: string|(() => string), // 函数 = 每次连接/每次 RPC 重新求值(宿主端口会变)
1988
2046
  * cookie?: string|(() => string),
1989
2047
  * muxPath?: string,
1990
2048
  * resultPath?: string,
@@ -340,7 +340,7 @@ export class WeChatRuntime {
340
340
  /**
341
341
  * @param {{
342
342
  * relayDir: string,
343
- * upstream?: string, // dsh web 地址,默认 http://127.0.0.1:3080
343
+ * upstream?: string|(() => string), // dsh web 地址,默认 http://127.0.0.1:3080;函数=每次重连重新求值
344
344
  * cookieOf?: () => string, // 读 .harness-cookie.json 的回调(bridge 已有该逻辑)
345
345
  * secret?: string, // 控制面密钥(bridge_secret);空则控制面拒绝所有请求
346
346
  * logger?: any,
@@ -356,6 +356,16 @@ export class WeChatRuntime {
356
356
  if (!opts.relayDir) throw new Error("WeChatRuntime: 缺少 relayDir");
357
357
  this.relayDir = opts.relayDir;
358
358
  this.upstream = String(opts.upstream || "http://127.0.0.1:3080").replace(/\/+$/, "");
359
+ /**
360
+ * 上游地址的**取值器**:恰好是字符串时是常量函数。
361
+ *
362
+ * 为什么不让它是个固化字符串:宿主端口会变(`dsh web --port 0`、DSH Desktop 每次启动随机端口),
363
+ * 而事件订阅器是**长连接** —— 固化字符串的后果是端口一变就永久重连到旧端口。
364
+ * 【2026-10-09 真机实证】bridge 启动时宿主还是 3080,随后换成 19387;隧道转发侧
365
+ * (每次都读最新 UPSTREAM)一切正常,唯独微信事件通道死循环刷
366
+ * `connect ECONNREFUSED 127.0.0.1:3080` —— 手机能开页面,但**任务完成没有任何推送**。
367
+ */
368
+ this.upstreamOf = typeof opts.upstream === "function" ? opts.upstream : () => this.upstream;
359
369
  this.cookieOf = typeof opts.cookieOf === "function" ? opts.cookieOf : () => "";
360
370
  this.secret = String(opts.secret || "");
361
371
  this.clock = opts.clock || Date.now;
@@ -1597,7 +1607,8 @@ export class WeChatRuntime {
1597
1607
  // 而且完全静默。现在无论订阅器是新建还是注入,都保证挂上处理器(WeakSet 去重,可重复调用)。
1598
1608
  if (!this.subscriber) {
1599
1609
  this.subscriber = createEventSubscriber({
1600
- upstream: this.upstream,
1610
+ // 传**取值器**而不是当次的值:见构造函数里 upstreamOf 的说明(端口变了要能跟上)。
1611
+ upstream: () => this.upstreamOf(),
1601
1612
  cookie: () => this.cookieOf(),
1602
1613
  follow: true,
1603
1614
  log: (level, message, meta) => {
@@ -35,17 +35,27 @@ const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"
35
35
  /**
36
36
  * 以给定的宿主形态构造一个真实的 spawnEnv 并调用它。
37
37
  * 切片只含 spawnEnv 函数体,故它引用的模块级常量/工具必须显式注入。
38
+ *
39
+ * ⚠️ 必须注入 **process 桩**(2026-10-09):`spawnEnv` 的第一件事是 `...process.env`,
40
+ * 而这个套件**是在 DSH 宿主里跑的** —— 从桌面端应用内跑 `npm run test:plugin` 时,
41
+ * 环境里天然带着 `ELECTRON_RUN_AS_NODE=1`(那正是"宿主是 Electron"的标志)。
42
+ * 于是"Web 版绝不得注入这个变量"那条断言会读到**宿主自己的**变量而必红 ——
43
+ * 可被测代码其实一个字节都没往 env 里加。注入干净环境后,这条用例检验的才是插件的行为
44
+ * (而不是"运行测试的这台机器是不是桌面端")。
45
+ *
38
46
  * @param {object} selfNodeEnv 该宿主形态下 SELF_NODE_ENV 的值
39
47
  * @param {object} [extra] 调用方业务变量
48
+ * @param {object} [sourceEnv] 宿主进程环境(默认干净:只含 PATH)
40
49
  */
41
- function makeSpawnEnv(selfNodeEnv, extra) {
50
+ function makeSpawnEnv(selfNodeEnv, extra, sourceEnv) {
42
51
  const start = INDEX_SRC.indexOf("function spawnEnv(extra)");
43
52
  const end = INDEX_SRC.indexOf("function safeSpawn(");
44
53
  assert.ok(start > 0 && end > start, "切片锚点失效:spawnEnv / safeSpawn 结构变了");
45
54
  const src = INDEX_SRC.slice(start, end);
46
- const make = new Function("SELF_NODE_ENV", "isWindows", "delimiter", "dirname",
55
+ const make = new Function("SELF_NODE_ENV", "isWindows", "delimiter", "dirname", "process", "path",
47
56
  `${src}\nreturn spawnEnv;`);
48
- return make(selfNodeEnv, () => false, path.delimiter, path.dirname)(extra);
57
+ const processStub = { env: sourceEnv ?? { PATH: "/usr/bin:/bin" }, execPath: "/usr/bin/node" };
58
+ return make(selfNodeEnv, () => false, path.posix.delimiter, path.posix.dirname, processStub, path.posix)(extra);
49
59
  }
50
60
 
51
61
  test("桌面端:Electron 宿主下 spawnEnv 必须注入 ELECTRON_RUN_AS_NODE=1", () => {
@@ -60,6 +70,19 @@ test("Web 版(真实 node):绝不得注入 ELECTRON_RUN_AS_NODE(不能
60
70
  "非 Electron 宿主下注入该变量会污染子进程环境、并让 node 版本探测类工具困惑");
61
71
  });
62
72
 
73
+ test("Web 版:宿主环境**已经带着** ELECTRON_RUN_AS_NODE 时,最终 env 与宿主逐键一致(插件没加料)", () => {
74
+ // 这条是上面那条的补充(2026-10-09):上面那条断言"结果里没有这个键",在 Electron 宿主里
75
+ // 跑测试时**天然不成立**(键来自宿主 process.env)。真正的判据是"插件有没有往 env 里加料",
76
+ // 故这里显式把宿主环境造成带这个变量,验证:最终 env **逐键等于宿主环境 + PATH 调整**,
77
+ // 一个多余的键都没有(`{}` 展开是恒等)。这样这条用例在任何宿主里都恒真且有意义。
78
+ const hostEnv = { PATH: "/usr/bin:/bin", ELECTRON_RUN_AS_NODE: "1", HOME: "/Users/tester" };
79
+ const env = makeSpawnEnv({}, undefined, hostEnv);
80
+ const expectedPath = ["/usr/bin", "/usr/bin:/bin", "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":");
81
+ assert.deepEqual(env, { ...hostEnv, PATH: expectedPath },
82
+ "除 PATH 外不得新增/删除/改动任何键(否则就是插件在污染子进程环境)");
83
+ assert.equal(env.ELECTRON_RUN_AS_NODE, "1", "宿主自己的值原样透传,不算插件注入");
84
+ });
85
+
63
86
  test("spawnEnv:NODE_INTERPRETER 变量不得挤掉调用方的业务变量,PATH 仍按平台分隔符拼接", () => {
64
87
  const env = makeSpawnEnv({ ELECTRON_RUN_AS_NODE: "1" }, { DSH_BRIDGE_INSTALL_SOURCE: "npx" });
65
88
  assert.equal(env.DSH_BRIDGE_INSTALL_SOURCE, "npx", "业务变量必须仍能覆盖");
@@ -153,15 +176,25 @@ test("桌面端:面板文案改为引导'退出并重开应用',且不影响
153
176
 
154
177
  // ─────────────── ④ 安装:--profile desktop 必须真的生效 ───────────────
155
178
 
156
- /** 用安装器切片构造真实的 resolveProfileDir(注入 os / path,伪造 HOME)。 */
179
+ /**
180
+ * 用安装器切片构造真实的 resolveProfileDir(注入 os / path / process,伪造 HOME 与空环境)。
181
+ *
182
+ * ⚠️ 必须注入 **process 桩**(2026-10-09):`resolveProfileDir` 的第二来源是
183
+ * `process.env.DSH_PROFILE_DIR`,而这个测试套件**是在 DSH 宿主里跑的** ——
184
+ * 从桌面端应用内跑 `npm run test:plugin` 时,环境里天然带着
185
+ * `DSH_PROFILE_DIR=/Users/<me>/.dsh/profiles/desktop`,于是"缺省仍是 Web 版"那条断言
186
+ * 会读到真实 HOME 下的 desktop,**在任何装了桌面端的机器上必红**(假红,且指向错误结论)。
187
+ * 注空环境 = 这条用例从此只验证被测逻辑,不受宿主环境影响。
188
+ */
157
189
  function makeResolveProfileDir(home) {
158
190
  const start = SETUP_SRC.indexOf("const KNOWN_PROFILE_NAMES");
159
191
  const end = SETUP_SRC.indexOf("function warnIfDesktopProfile");
160
192
  assert.ok(start > 0 && end > start, "切片锚点失效:profile 解析函数结构变了");
161
193
  const src = SETUP_SRC.slice(start, end);
162
194
  const osStub = { homedir: () => home };
163
- const make = new Function("os", "path", `${src}\nreturn { resolveProfileDir, profileArgvOf, isDesktopProfileDir, profileDirOfName };`);
164
- return make(osStub, path);
195
+ const processStub = { env: {} };
196
+ const make = new Function("os", "path", "process", `${src}\nreturn { resolveProfileDir, profileArgvOf, isDesktopProfileDir, profileDirOfName };`);
197
+ return make(osStub, path, processStub);
165
198
  }
166
199
 
167
200
  test("安装器:--profile desktop 解析到桌面端 profile 目录,不再是相对路径/被忽略", () => {
@@ -407,7 +407,7 @@ test("清理助手:护栏命中时绝不删 profile(relayDir 误配成 profi
407
407
  test("安全:三处机密写入都要显式收紧 ACL(Windows 上 mode 0o600 无效)", () => {
408
408
  for (const [label, src, callSites] of [
409
409
  ["安装器", SETUP_SRC, [/hardenFile\(CONFIG_PATH\)/]],
410
- ["插件半", INDEX_SRC, [/hardenFile\(configPathOf\(relayDir\)\)/, /hardenFile\(join\(relayDir, HARNESS_COOKIE_FILE\)\)/]],
410
+ ["插件半", INDEX_SRC, [/hardenFile\(configPathOf\(relayDir\)\)/]],
411
411
  ["bridge", fs.readFileSync(path.join(HERE, "..", "..", "..", "clients", "dsh-remote", "dsh-bridge.mjs"), "utf8"), [/hardenFile\(CONFIG_PATH\)/]],
412
412
  ]) {
413
413
  assert.ok(/function hardenFile\(file\)/.test(src), `${label} 应有 hardenFile`);
@@ -417,6 +417,18 @@ test("安全:三处机密写入都要显式收紧 ACL(Windows 上 mode 0o600
417
417
  }
418
418
  });
419
419
 
420
+ test("安全:Cookie 的每个写入点都必须紧跟着 hardenFile(按 authority 分文件后不得漏收)", () => {
421
+ // 0.7.0-beta.2 把 Cookie 从「一份共享文件」改成「本宿主专属 + 旧版镜像」两份(见 P0-2 事故根治)。
422
+ // 写点从 1 个变 2 个 —— 逐个按变量名断言,漏收的那一份在 Windows 上就是 ACL 敞开状态。
423
+ const mint = INDEX_SRC.slice(INDEX_SRC.indexOf("async function mintHarnessCookie"), INDEX_SRC.indexOf("function readHarnessCookie"));
424
+ assert.ok(mint.length > 0, "应能定位到 mintHarnessCookie(找不到说明函数被改名,请同步本用例)");
425
+ const written = [...mint.matchAll(/writeFileSync\((\w+),/g)].map((m) => m[1]);
426
+ assert.ok(written.length >= 2, `mintHarnessCookie 应写两份 Cookie(本宿主专属 + 旧版镜像),实际 ${written.length}`);
427
+ for (const v of written) {
428
+ assert.ok(new RegExp(`hardenFile\\(${v}\\)`).test(mint), `Cookie 写入点 ${v} 之后必须调用 hardenFile 收紧 ACL`);
429
+ }
430
+ });
431
+
420
432
  test("安全:hardenFile 在非 Windows 上只 chmod(不引入任何子进程,零副作用)", () => {
421
433
  const src = SETUP_SRC.slice(SETUP_SRC.indexOf("function hardenFile(file)"), SETUP_SRC.indexOf("// ---------- 配置读写 ----------"));
422
434
  const calls = [];