@mrrisega/dsh-remote 0.7.0-beta.7 → 0.7.0

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.
Files changed (68) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +17 -17
  2. package/clients/dsh-remote/dsh-events.mjs +6 -6
  3. package/clients/dsh-remote/e2ee-client.mjs +7 -7
  4. package/clients/dsh-remote/e2ee-shim-script.js +9 -9
  5. package/clients/dsh-remote/mobile-adapter.mjs +21 -21
  6. package/clients/dsh-remote/test/bridge-gzip.test.mjs +1 -1
  7. package/clients/dsh-remote/test/device-registration-report.test.mjs +1 -1
  8. package/clients/dsh-remote/test/dsh-events.test.mjs +10 -10
  9. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +4 -4
  10. package/clients/dsh-remote/test/e2ee-client.test.mjs +7 -7
  11. package/clients/dsh-remote/test/e2ee-shim.test.mjs +1 -1
  12. package/clients/dsh-remote/test/e2ee-wecrypto-parity.test.mjs +1 -1
  13. package/clients/dsh-remote/test/harness-cookie-authority.test.mjs +4 -4
  14. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +2 -2
  15. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +6 -6
  16. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +1 -1
  17. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +5 -5
  18. package/clients/dsh-remote/test/upstream-discovery.test.mjs +1 -1
  19. package/clients/dsh-remote/test/wechat-channel.test.mjs +5 -5
  20. package/clients/dsh-remote/test/wechat-runtime.test.mjs +26 -26
  21. package/clients/dsh-remote/test/wechat-tier-source.test.mjs +2 -2
  22. package/clients/dsh-remote/wechat-channel.mjs +9 -9
  23. package/clients/dsh-remote/wechat-runtime.mjs +38 -38
  24. package/docs/telemetry.md +3 -3
  25. package/dsh-setup.mjs +63 -26
  26. package/package.json +2 -2
  27. package/packages/dsh-remote-web/lib/client.js +56 -56
  28. package/packages/dsh-remote-web/lib/fix-prompt.js +1 -1
  29. package/packages/dsh-remote-web/lib/index.js +43 -43
  30. package/packages/dsh-remote-web/lib/tarball-update.js +1 -1
  31. package/packages/dsh-remote-web/package.json +1 -1
  32. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +17 -17
  33. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +6 -6
  34. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +7 -7
  35. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +9 -9
  36. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +21 -21
  37. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +9 -9
  38. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +38 -38
  39. package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +5 -5
  40. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +63 -26
  41. package/packages/dsh-remote-web/test/account-switch-device.test.mjs +2 -2
  42. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +1 -1
  43. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +1 -1
  44. package/packages/dsh-remote-web/test/connect-loop.test.mjs +1 -1
  45. package/packages/dsh-remote-web/test/connect-ui.test.mjs +2 -2
  46. package/packages/dsh-remote-web/test/desktop-compat.test.mjs +4 -4
  47. package/packages/dsh-remote-web/test/diag-report.test.mjs +1 -1
  48. package/packages/dsh-remote-web/test/fix-prompt.test.mjs +2 -2
  49. package/packages/dsh-remote-web/test/harness-restart.test.mjs +1 -1
  50. package/packages/dsh-remote-web/test/install-profile-targets.test.mjs +6 -4
  51. package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
  52. package/packages/dsh-remote-web/test/launchd-domain.test.mjs +2 -2
  53. package/packages/dsh-remote-web/test/patch-activation.test.mjs +2 -2
  54. package/packages/dsh-remote-web/test/picker-pin.test.mjs +1 -1
  55. package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
  56. package/packages/dsh-remote-web/test/quota-absent.test.mjs +4 -4
  57. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +2 -2
  58. package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +6 -6
  59. package/packages/dsh-remote-web/test/settings-entry.test.mjs +2 -2
  60. package/packages/dsh-remote-web/test/settings-open-path.test.mjs +4 -4
  61. package/packages/dsh-remote-web/test/setup-output.test.mjs +32 -9
  62. package/packages/dsh-remote-web/test/tarball-update.test.mjs +1 -1
  63. package/packages/dsh-remote-web/test/telemetry.test.mjs +4 -4
  64. package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +2 -2
  65. package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +1 -1
  66. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +7 -7
  67. package/packages/dsh-remote-web/test/windows-compat.test.mjs +5 -5
  68. package/scripts/test-net-guard.cjs +1 -1
@@ -4,7 +4,7 @@
4
4
  * - MK 派生向量与 enterprise 参考实现(e2ee.js deriveE2eeMasterKey)一致(固定口令/盐 → 固定 hex);
5
5
  * - 信封加解密往返 / 篡改拒绝 / nonce 重放拒绝 / ws 计数重放拒绝;
6
6
  * - 参数端点容错(非 200 / 无 e2ee / enabled=false → disabled 不回退抛错);
7
- * - 桥端控制通道握手(hello→ack、探针好/坏密钥)、http 明文编解码、ws 标签解析。
7
+ * - 桥端控制通道握手(hello→ack、探测好/坏密钥)、http 明文编解码、ws 标签解析。
8
8
  *
9
9
  * 用法: node --test test/e2ee-client.test.mjs
10
10
  */
@@ -216,7 +216,7 @@ function flipB64(b64) {
216
216
  }
217
217
 
218
218
  test("★ 0.6.15 二进制明文框架:正文不再 base64(省掉三层里的第一层),且旧 JSON 形态仍能解", () => {
219
- // 业主实测"打开慢几分钟"的直接原因之一:同一条响应被 base64 编了三层(1.333³≈2.37×)。
219
+ // 用户实测"打开慢几分钟"的直接原因之一:同一条响应被 base64 编了三层(1.333³≈2.37×)。
220
220
  // 第一层就是"gzip 正文塞进 JSON 明文"。二进制框架把它去掉,且必须**两种形态都能解**
221
221
  // (老客户端只发旧形态,新客户端声明后收新形态)。
222
222
  const body = Buffer.from("首屏聚合包".repeat(500), "utf8");
@@ -323,7 +323,7 @@ test("信封标记头:封包/识别/解析一致;content-type 与 x-dsh-e2ee 双
323
323
  assert.equal(parseEnvelopeMarker({}), null);
324
324
  });
325
325
 
326
- // ---------- 桥端服务:握手 / 探针 / 开关 ----------
326
+ // ---------- 桥端服务:握手 / 探测 / 开关 ----------
327
327
 
328
328
  test("E2eeService:disabled 状态 caps 为空;enabled 后 caps=['e2ee-v2']", () => {
329
329
  const off = new E2eeService({ enabled: false, reason: "server_disabled" });
@@ -345,7 +345,7 @@ test("E2eeService.introGrant:启用时一次性下发派生 MK(32B b64url);禁
345
345
  assert.equal(g.epoch, 3);
346
346
  assert.equal(typeof g.mk, "string");
347
347
  assert.equal(Buffer.from(g.mk, "base64url").length, 32);
348
- assert.equal(Buffer.from(g.mk, "base64url").toString("hex"), mk.toString("hex"), "引导下发的 MK 应与桥端派生 MK 一致(否则手机无法通过探针)");
348
+ assert.equal(Buffer.from(g.mk, "base64url").toString("hex"), mk.toString("hex"), "引导下发的 MK 应与桥端派生 MK 一致(否则手机无法通过探测)");
349
349
  });
350
350
 
351
351
  test("E2eeService.init:服务端 enabled=false/401/无密码/禁用开关 → 明文回退原因", async () => {
@@ -387,7 +387,7 @@ test("握手:hello→ack(b 随机 32B);salt/profile 不符拒绝;disabled 服务
387
387
  assert.throws(() => off.handleHello({ v: 2, type: "e2ee-hello", role: "phone", s: newSessId(), a: randomB64(32), salt: SALT_B64 }), (e) => e.code === "disabled");
388
388
  });
389
389
 
390
- test("探针:正确 MK 通过并置 verified;错误 MK → bad_key;未解锁会话被拒", () => {
390
+ test("探测:正确 MK 通过并置 verified;错误 MK → bad_key;未解锁会话被拒", () => {
391
391
  const mk = deriveMasterKey("unit-password", SALT_B64);
392
392
  const svc = new E2eeService({ enabled: true, mk, salt: SALT_B64, profile: "pbkdf2-sha256-600k" });
393
393
  const sessId = newSessId();
@@ -398,7 +398,7 @@ test("探针:正确 MK 通过并置 verified;错误 MK → bad_key;未解锁会
398
398
  return ack.b;
399
399
  })();
400
400
  const client = new E2eeSession({ sessId, ...deriveShk(mk, aB64, bB64), profile: "pbkdf2-sha256-600k" });
401
- // 探针(明文 {p,t,c})
401
+ // 探测(明文 {p,t,c})
402
402
  const probe = client.seal({ kind: "ctrl", dir: "p2b", counter: 0, data: JSON.stringify({ p: "dsh-e2ee-probe-v1", t: Date.now(), c: 0 }) });
403
403
  const { reply, session } = svc.handleProbe(sessId, probe);
404
404
  assert.equal(reply.k, "ctrl");
@@ -407,7 +407,7 @@ test("探针:正确 MK 通过并置 verified;错误 MK → bad_key;未解锁会
407
407
  // bridge 回 probe-ok → 手机可开
408
408
  const okPlain = client.open({ kind: "ctrl", dir: "b2p", env: reply, counter: reply.c });
409
409
  assert.equal(JSON.parse(okPlain.data.toString("utf8")).p, "dsh-e2ee-probe-ok");
410
- // 重放同一探针 → 计数重放拒绝
410
+ // 重放同一探测 → 计数重放拒绝
411
411
  assert.throws(() => svc.handleProbe(sessId, probe), (e) => e.code === "replay" || e.code === "bad_counter");
412
412
  // 错误 MK(错误口令)的手机 → bad_key
413
413
  const wrongMk = deriveMasterKey("wrong-password", SALT_B64);
@@ -566,7 +566,7 @@ test("fetch 包装:真实字节往返(shim 密封 → 桥端打开 → 封回
566
566
  // ─────────────── 2026-09-27:P0-1 收尾(设备作用域必须在"没有会话"时也生效) ───────────────
567
567
 
568
568
  test("★ WS 设备作用域:不改写就走放行分支时,也必须用**改写后**的 URL", () => {
569
- // 事故现场:镜像页**没有会话**(未解锁)→ 不加密 → 走放行分支。
569
+ // 故障现场:镜像页**没有会话**(未解锁)→ 不加密 → 走放行分支。
570
570
  // 官方客户端把 mux 拼成根路径 `/api/remote.mux`,中继只认会过期的 dsh_device cookie → 404
571
571
  // (rejectUpgrade 写状态行后立刻 destroy → nginx 只看到 009)→ e2ee-hello 送不到 →
572
572
  // 会话永远建不起来 → 每个受保护请求 unknown_session 502。死循环。
@@ -123,7 +123,7 @@ test("信封逐字段一致:http 请求 / http 响应 / w(文本·二进制)/ ct
123
123
  else assert.equal(Buffer.from(back.data).toString("utf8"), data);
124
124
  }
125
125
 
126
- // ctrl 探针
126
+ // ctrl 探测
127
127
  const probePlain = JSON.stringify({ p: "dsh-e2ee-probe-v1", t: 1700000000000, c: 0 });
128
128
  const envCN = nodeS.seal({ kind: "ctrl", dir: "p2b", counter: 0, data: probePlain, nonce: NONCE_12() });
129
129
  const envCW = await wcS.seal({ kind: "ctrl", dir: "p2b", counter: 0, data: probePlain, nonceBytes: NONCE_12() });
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Cookie **按 authority 隔离**回归(2026-10-09 事故根治)。
3
+ * Cookie **按 authority 隔离**回归(2026-10-09 故障根治)。
4
4
  *
5
- * 【事故原貌】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端
5
+ * 【故障原貌】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端
6
6
  * (每次启动随机端口)是**两个宿主进程**,各自的插件都往里写自己 authority 的 Cookie →
7
7
  * 互相覆盖。桥撞 401 后重读磁盘拿到的是**另一个宿主刚写的** Cookie(与失败的不是同一个)
8
8
  * → 重试 → 还是 401 → 再读 → 又被覆盖回去……**乒乓,永不自愈**,手机端持续白页。
@@ -69,9 +69,9 @@ test("本宿主专属文件优先:`.harness-cookie.<authority>.json` 收到的
69
69
  assert.equal(r.cookie, "dsh-auth-OWN");
70
70
  });
71
71
 
72
- test("★ 事故锁:旧版共享文件里是**另一个宿主**的 Cookie → 一个字节都不许发出去", async () => {
72
+ test("★ 回归锁:旧版共享文件里是**另一个宿主**的 Cookie → 一个字节都不许发出去", async () => {
73
73
  clearCookies();
74
- // 这正是事故现场:共享文件被另一宿主(3080)覆盖过
74
+ // 这正是故障现场:共享文件被另一宿主(3080)覆盖过
75
75
  writeDoc(LEGACY, { authority: "127.0.0.1:3080", cookie: "dsh-auth-FOREIGN", mintedAt: Date.now() });
76
76
  const r = await ask();
77
77
  assert.equal(r.status, 200);
@@ -113,8 +113,8 @@ test("机器指纹:darwin/linux 两条老分支没有被改坏", () => {
113
113
  assert.equal(linux.fn(), "0123456789abcdef", "linux 仍读 /etc/machine-id(并去掉换行)");
114
114
  });
115
115
 
116
- test("★回归:子进程一律用绝对路径优先,不赌 PATH(launchd 的 PATH 不含 /usr/sbin 的真实事故)", () => {
117
- // 事故(2026-09-22 真机日志):bridge 由 launchd 拉起,plist 给的 PATH 是
116
+ test("★回归:子进程一律用绝对路径优先,不赌 PATH(launchd 的 PATH 不含 /usr/sbin 的真实故障)", () => {
117
+ // 故障(2026-09-22 真机日志):bridge 由 launchd 拉起,plist 给的 PATH 是
118
118
  // /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin —— 没有 /usr/sbin,而 ioreg 在那里。
119
119
  // 日志里出现 `/bin/sh: ioreg: command not found`,指纹**静默**退化成 hostname 哈希:
120
120
  // 用户改一次主机名就被服务端当成新设备。这类"PATH 假设"必须用绝对路径堵死。
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * mobile-adapter 手机端两个真实事故的回归测试(假 DOM + 事件传播模型 + vm 跑真实注入脚本)。
3
+ * mobile-adapter 手机端两个真实故障的回归测试(假 DOM + 事件传播模型 + vm 跑真实注入脚本)。
4
4
  *
5
- * 事故 1(2026-09-19,微信内置浏览器实测反馈)
5
+ * 故障 1(2026-09-19,微信内置浏览器实测反馈)
6
6
  * 「用微信语音转文字输入时,它在自动整理文字的过程中会直接触发输入框的发送」——半截话被发出。
7
7
  * 取证:官方输入区是 Lexical contenteditable(根元素带 data-composer-input / role=textbox /
8
8
  * aria-multiline / data-lexical-editor),官方键位图自带的 IME 守卫只看
@@ -13,7 +13,7 @@
13
13
  * (isComposing / keyCode 229 / compositionend 后 60ms 内)一律 stopPropagation;
14
14
  * ② ≤820px 时输入框里的 Enter **只换行不发送**,发送只由官方发送按钮负责。
15
15
  *
16
- * 事故 2(2026-09-19,Windows 电脑通道实测反馈)
16
+ * 故障 2(2026-09-19,Windows 电脑通道实测反馈)
17
17
  * 「展开左边抽屉,无法选择历史会话,点任何东西都没有反应;界面右边有一个白色的框占了点位置」。
18
18
  * 根因:`isDrawer()` 只查 `r.right > 8`,而**向右滑出屏外的第三列**右边坐标是很大的正数
19
19
  * (真机实测 left=400 / right=759 / width=358)→ 完全看不见的详情列被判成"展开的详情列" →
@@ -915,7 +915,7 @@ test("★ 迭代速度护栏:适配层测试的假定时器**必须 unref**(
915
915
  });
916
916
 
917
917
  test("★ 护栏:解析期绝不许往页面塞东西(0.6.14 首屏提示层就是这么做、然后永久盖住 UI 的)", () => {
918
- // 事故 3(2026-09-25,业主实测):「界面都已经加载完了…但这个遮罩一直不消失」,
918
+ // 故障 3(2026-09-25,用户实测):「界面都已经加载完了…但这个遮罩一直不消失」,
919
919
  // 截图里官方 UI 就在 88% 不透明的全屏层下面透出来,秒表走到 187 秒 —— 刷新也一样。
920
920
  //
921
921
  // 根因是**设计错误**:那个提示层是 `position:fixed; inset:0; z-index:2147483000` 的全屏层,
@@ -923,7 +923,7 @@ test("★ 护栏:解析期绝不许往页面塞东西(0.6.14 首屏提示层
923
923
  // 两组信号全不命中 → 层永远留着。当时我写的"pointer-events:none 所以不会挡点击"是自我安慰:
924
924
  // **盖住视线和进不去对用户没区别**。
925
925
  //
926
- // 业主决定:直接删掉。这条护栏守两件事:
926
+ // 用户决定:直接删掉。这条护栏守两件事:
927
927
  // ① `dsh-ma-boot` 不许回来;
928
928
  // ② **解析期**(主机门之前)不许有任何 `appendChild` —— 页面还没确认是官方 dsh web,
929
929
  // 更不该有任何覆盖层;所有注入节点都必须在 boot() 里、过了主机门之后才做。
@@ -1058,7 +1058,7 @@ test("★ 官方右栏里有内容时:适配层**不插手**(交还官方 op
1058
1058
  assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "★ 更不得为它开遮罩");
1059
1059
  });
1060
1060
 
1061
- test("★ 2026-09-19 白框事故的**结构性**回归锁:适配层任何情况下都不滑出第三列", () => {
1061
+ test("★ 2026-09-19 白框故障的**结构性**回归锁:适配层任何情况下都不滑出第三列", () => {
1062
1062
  const { js } = injected();
1063
1063
  // 当年白框的三种触发现场各来一遍,断言"我们什么都不做" ⇒ 白框在结构上不可能再出现
1064
1064
  for (const opts of [
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * mobile-adapter **图片上传兼容**契约测试(假 DOM + vm 执行真实注入脚本)。
4
4
  *
5
- * 业主反馈(2026-09):「手机端无法识别图片」——选了照片发出去,模型看不到图 / 被当成普通文件。
5
+ * 用户反馈(2026-09):「手机端无法识别图片」——选了照片发出去,模型看不到图 / 被当成普通文件。
6
6
  *
7
7
  * 根因(官方 dsh web 客户端 `dsh-client-ui-conversation` 的 createDrafts):
8
8
  * 只有 `file.type ∈ {image/png,image/jpeg,image/webp,image/gif}` 才走**图片**通道
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * mobile-adapter **运行时**契约测试(假 DOM + vm 执行真实注入脚本)。
4
4
  *
5
- * 事故背景(2026-09-15 生产反馈):
5
+ * 故障背景(2026-09-15 生产反馈):
6
6
  * 手机端用户打开后**整屏被阴影覆盖、点哪都没反应**,只有左侧栏(z-index 300,在遮罩之上)能点。
7
7
  * 根因是适配层自己创建的全屏 scrim(div.dsh-ma-scrim, z-index 290):
8
8
  * · 判定「侧栏是否展开」用的是 `!frame.hasAttribute("data-sidebar-collapsed")`,
@@ -274,7 +274,7 @@ test("运行时:判定为展开时不再隐藏汉堡按钮(避免用户没
274
274
  const ham = findHamburger(env);
275
275
  assert.ok(ham, "汉堡按钮应存在");
276
276
  assert.notEqual(ham.style.display, "none",
277
- "抽屉展开时也不应把汉堡隐藏 —— 判定一旦出错,用户会连入口都没有(事故现场就是这个死局)");
277
+ "抽屉展开时也不应把汉堡隐藏 —— 判定一旦出错,用户会连入口都没有(故障现场就是这个死局)");
278
278
  });
279
279
 
280
280
  test("运行时:找不到官方 toggle 时,点遮罩必须能自救(摘掉遮挡类)", () => {
@@ -537,11 +537,11 @@ test("网络延迟分档:500ms 以内=顺畅、1000ms 以内=正常(远程
537
537
  });
538
538
 
539
539
  test("★ 亚毫秒样本(本机/回环自测)也必须出数 —— 不得永远停在「测速中…」", async () => {
540
- // 业主 2026-10-09 实测反馈:「小悬浮窗一直提示『测速中,等待下一次网络请求后开始测速』」。
540
+ // 用户 2026-10-09 实测反馈:「小悬浮窗一直提示『测速中,等待下一次网络请求后开始测速』」。
541
541
  // 根因:旧实现是 `if (r < 1) return;`(注释写"亚毫秒=同帧缓存命中"),但**本机/回环**访问
542
542
  // (桌面端自测、在同一台机器上打开镜像页)一次真实往返往往就是 < 1ms —— 于是**每一个**样本
543
543
  // 都被丢掉,界面永远停在空态。面板侧 chip 从来没这个毛病(`v <= 0` + `Math.max(1, …)`),
544
- // 这条用例把两侧口径钉在一起。
544
+ // 这条用例把两侧约定钉在一起。
545
545
  const env = harness({ width: 390, fetchImpl: async () => ({ status: 200, ok: true }) }); // 零延迟 = 亚毫秒
546
546
  runAdapter(env);
547
547
  const rtt = findFab(env).querySelector('[data-role="rtt"]');
@@ -580,7 +580,7 @@ test("★ 悬浮窗被官方 SPA 重渲染重建后,已积累的样本仍要
580
580
  });
581
581
 
582
582
  test("★ 采样器由 boot 安装(不依赖菜单)+ 空态说得清原因 + 被顶掉能自愈重挂", async () => {
583
- // 2026-10-10 二修:业主第二次反馈「镜像页一直测速中」,而同一时段镜像页打了 69 次
583
+ // 2026-10-10 二修:用户第二次反馈「镜像页一直测速中」,而同一时段镜像页打了 69 次
584
584
  // /api/agentPresets/list 这类**完全合格**的请求 —— 说明真根因是**采样没接上**
585
585
  // (旧实现把包装写在 buildRemoteMenu 内部的一次性分支里),而不是样本被过滤。
586
586
  const env = harness({
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * 上游(dsh web)地址发现契约测试。
4
4
  *
5
- * 背景(两起真实事故,见 upstream-discovery.mjs 顶部注释):
5
+ * 背景(两起真实故障,见 upstream-discovery.mjs 顶部注释):
6
6
  * ① `dsh web --port 8090` / `--port 0`(系统分配端口)→ 写死 3080 的探测永远失败;
7
7
  * ② **DSH Desktop**(Electron 壳 `dsh-plugin-desktop`)默认 43120、占用则 +1,插件 0.6.10
8
8
  * 实测「面板永久停在 starting、设备从未登记」——watcher 探 3080 探不到就永不派生 bridge。
@@ -1768,7 +1768,7 @@ test("完成模板:头一行按 reason 区分,completed 不许读成失败", ()
1768
1768
  });
1769
1769
 
1770
1770
  test("★ 微信不渲染 Markdown:模型产出的表格/加粗/链接必须先转成纯文本", () => {
1771
- // 业主原话:「现在的表格形式看起来很奇怪,整个格式在微信上没有做过任何兼容,体验很差很差」。
1771
+ // 用户原话:「现在的表格形式看起来很奇怪,整个格式在微信上没有做过任何兼容,体验很差很差」。
1772
1772
  // 根因:微信**不渲染** Markdown(腾讯自己的插件在出站路径主动剥离),所以模型的表格会以
1773
1773
  // 字面竖线、加粗会以字面星号出现在气泡里。这儿锁住"发送前必须已转换"。
1774
1774
  const md = [
@@ -1847,7 +1847,7 @@ test("完成模板:结论要**尽量完整**(未超上限不截断),超长才显
1847
1847
  // 结论**独立成段**(不再挤在 "结论:" 前缀后面) —— 压成一行正是"密密麻麻"的来源
1848
1848
  assert.match(withSummary.text, /—— 结论 ——\n结论正文/, "结论要单独成段、原样展示");
1849
1849
 
1850
- // ★ 业主口径(2026-09-22):「结论被大量截断了,加长一些,更完整地展示 —— 这可能是用户关注的内容」
1850
+ // ★ 用户约定(2026-09-22):「结论被大量截断了,加长一些,更完整地展示 —— 这可能是用户关注的内容」
1851
1851
  // 旧上限 400 连一条正常的修复总结(实测 190+ 字)都快保不住。
1852
1852
  const fits = "长".repeat(1200);
1853
1853
  const kept = formatCompletion({ title: "T", reason: "completed", summary: fits });
@@ -1967,10 +1967,10 @@ test("危险工具:真阴性(普通的读/查/测试/构建**不是**破坏性)"
1967
1967
  }
1968
1968
  });
1969
1969
 
1970
- test("危险工具:看不到内容时**不再**降级(业主拍板:DSH 自身有权限控制,别过严),且绝不抛", () => {
1970
+ test("危险工具:看不到内容时**不再**降级(用户拍板:DSH 自身有权限控制,别过严),且绝不抛", () => {
1971
1971
  // ⚠️ 2026-09-22 行为变更:原先「能跑命令 + 完全看不到内容 → 判破坏性」。
1972
1972
  // 那条规则会把**绝大多数正常 Bash 审批**降级成"只能回电脑确认"(DSH 的审批节点
1973
- // 本来就常常没有命令原文),过严且伤体验。业主明确安全边界可以松一点。
1973
+ // 本来就常常没有命令原文),过严且伤体验。用户明确安全边界可以松一点。
1974
1974
  // 现在**只看内容**:内容为空 → 不拦(保留一步回执)。
1975
1975
  assert.equal(isDestructiveTool("Bash"), false, "看不到内容不再降级");
1976
1976
  assert.equal(isDestructiveTool("Bash", {}), false);
@@ -2045,7 +2045,7 @@ test("入站分类:纯函数 + 全量(不改入参、怪输入也不抛)", () =>
2045
2045
  });
2046
2046
 
2047
2047
  test("★ 回归:换绑必须清掉旧 token 的冷却(否则用户刚绑上就一小时不能聊天)", async () => {
2048
- // 实测 bug(业主撞到):旧 token 撞 -14 → 冷却 1 小时 → 用户解绑 + 重新绑定拿到**新** token,
2048
+ // 实测 bug(用户撞到):旧 token 撞 -14 → 冷却 1 小时 → 用户解绑 + 重新绑定拿到**新** token,
2049
2049
  // 却仍被旧 token 的冷却挡着 → "无法跟机器人聊天",而面板只说"通知会延迟"。
2050
2050
  // 新 token 的会话与旧 token 的超时毫无关系,冷却必须清。
2051
2051
  const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-cooldown-"));
@@ -339,12 +339,12 @@ test("★ handleCommand 的字段是 replyText:回 /help 必须真的发出帮
339
339
  test("★ 分层:免费档 = 通知+审批,付费档才给遥控;未知档位按最低档", () => {
340
340
  const { capabilitiesFor, WECHAT_CAPABILITY_TABLE, grantsCap, limitsFor } = rtmod;
341
341
  const free = capabilitiesFor("free");
342
- // 免费档(业主口径):收得到通知、点得了审批、看得到状态、停得下任务 —— 但**遥控不了**。
342
+ // 免费档(用户约定):收得到通知、点得了审批、看得到状态、停得下任务 —— 但**遥控不了**。
343
343
  for (const cap of ["notify", "approve", "status", "stop"]) {
344
344
  assert.ok(free.includes(cap), `免费档必须有 ${cap}`);
345
345
  }
346
346
  for (const cap of ["assign", "sessions", "summary"]) {
347
- assert.ok(!free.includes(cap), `免费档**不得**有 ${cap}(业主:那是付费能力)`);
347
+ assert.ok(!free.includes(cap), `免费档**不得**有 ${cap}(用户:那是付费能力)`);
348
348
  }
349
349
  // 付费档 = 免费档超集 + 遥控
350
350
  const pro = capabilitiesFor("pro");
@@ -355,7 +355,7 @@ test("★ 分层:免费档 = 通知+审批,付费档才给遥控;未知档位按
355
355
  assert.ok(grantsCap(pro, cap), `付费档必须有 ${cap}`);
356
356
  }
357
357
 
358
- // ★★ 业主 2026-09-22 拍板的核心不变量:免费**能续接已有会话、不能开新会话**
358
+ // ★★ 用户 2026-09-22 拍板的核心不变量:免费**能续接已有会话、不能开新会话**
359
359
  assert.ok(grantsCap(free, "assign.continue"), "免费档必须能「继续已有会话」(回复即续接)");
360
360
  assert.ok(!grantsCap(free, "assign.new"), "★免费档**不得**能开新会话(那是会员能力的差异点)");
361
361
  assert.ok(!grantsCap(free, "sessions"), "免费档不得能切换会话");
@@ -368,7 +368,7 @@ test("★ 分层:免费档 = 通知+审批,付费档才给遥控;未知档位按
368
368
  assert.ok(!grantsCap(["assign"], "constructor"), "前缀规则不能被开头相同的无关词命中");
369
369
  assert.ok(!grantsCap(free, "steer") && !grantsCap(["whatever"], "assign.new"), "未知能力不授权");
370
370
 
371
- // 额度(业主:「免费给每月 N 条消息额度,超出再引导升级」;付费档不限)
371
+ // 额度(用户:「免费给每月 N 条消息额度,超出再引导升级」;付费档不限)
372
372
  assert.ok(Number(limitsFor("free").messages_per_month) > 0, "免费档必须有每月消息额度");
373
373
  assert.equal(Number(limitsFor("pro").messages_per_month || 0), 0, "付费档不应限量(0 = 不限)");
374
374
  assert.equal(Number(limitsFor("pro_max").messages_per_month || 0), 0, "最高档同样不限量");
@@ -402,7 +402,7 @@ test("★ 分层:免费档被挡且回复里带 App 链接;付费档放行;未
402
402
  const freeText = lastText(ilink.state);
403
403
  assert.match(freeText, /会员功能/, `要说清为什么被拒,实际:${freeText}`);
404
404
  assert.ok(freeText.includes(APP_URL),
405
- `★回复必须带上可点击的 App 链接(业主:免费用户要去做别的,就给他访问 App 的链接),实际:${freeText}`);
405
+ `★回复必须带上可点击的 App 链接(用户:免费用户要去做别的,就给他访问 App 的链接),实际:${freeText}`);
406
406
  assert.match(freeText, /\/help/, "还要告诉他现在能做什么,而不是死路一条");
407
407
 
408
408
  // ② 付费档:同一句话必须放行
@@ -543,9 +543,9 @@ test("★ v2:有当前会话时纯文本进当前会话(不是每次开新的)",
543
543
  }
544
544
  });
545
545
 
546
- test("★ v2 安全边界(业主拍板放宽):看不到内容照给一键回执,只看得出破坏性才降级", async () => {
546
+ test("★ v2 安全边界(用户拍板放宽):看不到内容照给一键回执,只看得出破坏性才降级", async () => {
547
547
  // 背景:DSH 的审批节点常常只下发 toolName、没有命令原文。原先「看不到内容也算破坏性」
548
- // 会把绝大多数正常 Bash 审批都降级成"只能回电脑确认" —— 过严。业主明确:DSH 自身有
548
+ // 会把绝大多数正常 Bash 审批都降级成"只能回电脑确认" —— 过严。用户明确:DSH 自身有
549
549
  // 权限控制,安全边界可以松一点。所以现在**只看内容**。
550
550
  const ilink = await fakeIlink();
551
551
  try {
@@ -647,7 +647,7 @@ test("★ v2:完成推送带会话名称 + 结论,而不只是「任务已停止
647
647
  });
648
648
 
649
649
  test("★ 0.6.15:完成推送必须把「当前会话」切到刚跑完的那个(否则回话会发进上一次的会话)", async () => {
650
- // 业主实测:B 任务跑完推了结论,他在微信里直接回一句"再改一下",那条消息却发给了
650
+ // 用户实测:B 任务跑完推了结论,他在微信里直接回一句"再改一下",那条消息却发给了
651
651
  // **上一次的当前会话 A** —— A 平白多了一条指令,B 永远收不到。
652
652
  const ilink = await fakeIlink();
653
653
  try {
@@ -819,7 +819,7 @@ test("★★ 日报发送失败时**不能**把当天标记为已发(否则当
819
819
  });
820
820
 
821
821
  test("★ 日报:晚上发(不是早上)、内容是「今天干了啥」、带品牌露出", async () => {
822
- // 业主:「每天早上发『今日干了啥事儿』,这肯定不太对劲。应该是晚上发,比如晚上 6 点,发『今天干了啥』」
822
+ // 用户:「每天早上发『今日干了啥事儿』,这肯定不太对劲。应该是晚上发,比如晚上 6 点,发『今天干了啥』」
823
823
  // 且一天只发一次(它兼作 24h 推送窗口的心跳,早晚各一次就太频繁了)。
824
824
  assert.equal(rtmod.DEFAULT_DIGEST_HOUR, 18, "日报时刻必须是晚上 18 点,不能是早上 9 点");
825
825
  const ilink = await fakeIlink();
@@ -847,7 +847,7 @@ test("★ 日报:晚上发(不是早上)、内容是「今天干了啥」、带
847
847
 
848
848
  test("★★ 会员到期提醒:提前 2 天 / 1 天 / 到期当天各提醒一次,同一天不重复", async () => {
849
849
  // 这条**以前根本不存在**:notifyMembershipExpiring() 只有定义、全仓零调用方 ——
850
- // 业主以为"提前两天发、提前一天也发",实际一次都没发过。这里锁住补上的触发点。
850
+ // 用户以为"提前两天发、提前一天也发",实际一次都没发过。这里锁住补上的触发点。
851
851
  const ilink = await fakeIlink();
852
852
  try {
853
853
  const DAY = 86_400_000;
@@ -888,7 +888,7 @@ test("★★ 会员到期提醒:提前 2 天 / 1 天 / 到期当天各提醒一
888
888
  }
889
889
  });
890
890
 
891
- test("★★ A:免费档「能续接已有会话、不能开新会话」(业主拍板的分级)", async () => {
891
+ test("★★ A:免费档「能续接已有会话、不能开新会话」(用户拍板的分级)", async () => {
892
892
  const ilink = await fakeIlink();
893
893
  try {
894
894
  // ① 免费 + **没有**当前会话 → 纯文本等于开新任务 → 必须被拒
@@ -906,7 +906,7 @@ test("★★ A:免费档「能续接已有会话、不能开新会话」(业主
906
906
  b.rt.currentSessionTitle = "已有任务";
907
907
  await b.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "继续修" } }] });
908
908
  const prompted = subB.calls.filter((c) => c.m === "promptSession");
909
- assert.equal(prompted.length, 1, "★免费 + 有当前会话:必须能继续聊(业主要的就是这个)");
909
+ assert.equal(prompted.length, 1, "★免费 + 有当前会话:必须能继续聊(用户要的就是这个)");
910
910
  assert.equal(prompted[0].text, "继续修", "内容要原样下发");
911
911
 
912
912
  // ③ 免费仍不能开新会话、不能切换会话
@@ -993,7 +993,7 @@ test("★★ 权益包:服务端下发的 caps/limits 覆盖内置表(后台改
993
993
  });
994
994
 
995
995
  test("★★ 多条待审批:回数字必须答**最早那条**(先进先出),答完把下一条顶到底部", async () => {
996
- // 业主报的真 bug:「如果需要用户回应多条消息,而用户一开始只看到第一条审批消息,
996
+ // 用户报的真 bug:「如果需要用户回应多条消息,而用户一开始只看到第一条审批消息,
997
997
  // 回应一个数字之后,流程就结束了。后面的几条消息没有让用户继续回应,直接提示为『未回应』」
998
998
  // 成因:旧实现按**栈顶(最新)**匹配数字,而微信是**从上往下读**的、消息位置一发出去就固定
999
999
  // → 用户看着第 1 条回「1」,系统答了最后一条,第 1 条永远没人回应,DSH 最终标它「未回应」。
@@ -1535,7 +1535,7 @@ test("★ 多审批 + 提醒混排:回执仍按 FIFO 走审批(提醒不参与
1535
1535
  // 原先这条路径透传上游的静态 HELP_TEXT —— 那份清单把 /new、/ls、/use、/summary 与
1536
1536
  // "直接发一句话"一并列成"你能用的"。免费用户敲错一个字,拿到的就是这张**会员清单**,
1537
1537
  // 照着做再吃一次拒绝,于是"打错命令"被理解成"这功能坏了"。
1538
- // 业主红线:话术与权限必须一致、不多承诺。
1538
+ // 用户红线:话术与权限必须一致、不多承诺。
1539
1539
  // ---------------------------------------------------------------------------
1540
1540
 
1541
1541
  test("★ 免费用户敲未知指令:要回显命令 + 给**分层**帮助,不得把会员指令列成『你能用的』", async () => {
@@ -1719,7 +1719,7 @@ test("★ /help 必须逐行分隔:付费档也不许退化成一坨(无换行/
1719
1719
  });
1720
1720
 
1721
1721
  // ---------------------------------------------------------------------------
1722
- // 日报**重复推送**的根治(2026-09-23 业主实测「每日简报推送了两次」)
1722
+ // 日报**重复推送**的根治(2026-09-23 用户实测「每日简报推送了两次」)
1723
1723
  //
1724
1724
  // 根因:`#maybeDigest` 是「读 state → 等网络发送 → 写 state」的**跨 await 读-改-写**。
1725
1725
  // 发送要等网络,这段窗口里第二个执行者会读到"今天还没发" → 两条都发出去。
@@ -1798,7 +1798,7 @@ test("★ 日报占位一天一个,换天时会清掉旧占位(不攒文件)", a
1798
1798
  });
1799
1799
 
1800
1800
  // ---------------------------------------------------------------------------
1801
- // 出站必须回带 context_token(2026-09-23 业主实测「没收到微信通道」)
1801
+ // 出站必须回带 context_token(2026-09-23 用户实测「没收到微信通道」)
1802
1802
  //
1803
1803
  // 平台要求出站消息带回「这条会话的上下文令牌」,而它只出现在**入站消息**里
1804
1804
  // (官方实现:messaging/send.js 的 buildTextMessageReq → `msg.context_token`;
@@ -1831,7 +1831,7 @@ test("★ 没有 context_token 时**完全不带这个键**(而不是空串)", a
1831
1831
  await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "/status" } }] });
1832
1832
  const last = ilink.state.sends.at(-1);
1833
1833
  assert.ok(!("context_token" in last.msg),
1834
- "★拿不到令牌时不得塞空串(与官方 `contextToken ?? undefined` 同口径)");
1834
+ "★拿不到令牌时不得塞空串(与官方 `contextToken ?? undefined` 同约定)");
1835
1835
  } finally {
1836
1836
  await ilink.close();
1837
1837
  }
@@ -1887,14 +1887,14 @@ test("★ extractContextToken 防御式:形状不认识一律当没有,绝不猜
1887
1887
  assert.equal(extractContextToken({ contextToken: "CT-2" }), "CT-2", "兼容驼峰写法");
1888
1888
  });
1889
1889
 
1890
- // ═════════════ 2026-09-25 事故:完成推送"消失" + 回话跑进另一个会话 ═════════════
1890
+ // ═════════════ 2026-09-25 故障:完成推送"消失" + 回话跑进另一个会话 ═════════════
1891
1891
  //
1892
- // 现场(业主):任务跑完一条微信都没收到;他在微信里回了一句,结果**发进了上一个会话**。
1892
+ // 现场(用户):任务跑完一条微信都没收到;他在微信里回了一句,结果**发进了上一个会话**。
1893
1893
  // 日志证据:48 条 `sendmessage 失败:sendMessage: ret=-2 errmsg=prepare failed`
1894
1894
  // —— 平台侧发送是**间歇性**失败的,而旧实现失败即丢弃:那条"任务跑完了"永远消失了。
1895
1895
  // 状态证据:`current_session_id` 停在几天前的旧会话上(因为切换只在推送成功后发生)。
1896
1896
 
1897
- test("★ 事故锁:主动推送失败**不能消失** —— 入队,并在下一次入站时补发(带「补发」标记)", async () => {
1897
+ test("★ 回归锁:主动推送失败**不能消失** —— 入队,并在下一次入站时补发(带「补发」标记)", async () => {
1898
1898
  let failSends = true;
1899
1899
  const ilink = await fakeIlink({
1900
1900
  onSendMessage: (_state, reply) => (failSends
@@ -1926,7 +1926,7 @@ test("★ 事故锁:主动推送失败**不能消失** —— 入队,并在
1926
1926
  }
1927
1927
  });
1928
1928
 
1929
- test("★ 事故锁:静音(/quiet)也要记下「最近完成的任务」,回话不能跑进上一个会话", async () => {
1929
+ test("★ 回归锁:静音(/quiet)也要记下「最近完成的任务」,回话不能跑进上一个会话", async () => {
1930
1930
  const ilink = await fakeIlink();
1931
1931
  try {
1932
1932
  const sub = makeFakeSubscriber();
@@ -1958,7 +1958,7 @@ test("★ 事故锁:静音(/quiet)也要记下「最近完成的任务」
1958
1958
  }
1959
1959
  });
1960
1960
 
1961
- test("★ 事故锁:用户主动 /use 切走的会话不能被「最近完成」抢回去", async () => {
1961
+ test("★ 回归锁:用户主动 /use 切走的会话不能被「最近完成」抢回去", async () => {
1962
1962
  const ilink = await fakeIlink();
1963
1963
  try {
1964
1964
  const sub = makeFakeSubscriber();
@@ -1972,7 +1972,7 @@ test("★ 事故锁:用户主动 /use 切走的会话不能被「最近完成
1972
1972
  assert.equal(rt.currentSessionId, "session-mine", "前置条件:/use 切到了 2 号");
1973
1973
  await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-done", reason: "completed", at: 3 });
1974
1974
 
1975
- // 完成事件在处理时会把它自己设为回复目标(这是产品口径);但用户**之后**再切回去要算数:
1975
+ // 完成事件在处理时会把它自己设为回复目标(这是产品约定);但用户**之后**再切回去要算数:
1976
1976
  await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 2" } }] });
1977
1977
  await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "继续聊我的" } }] });
1978
1978
  const prompt = sub.calls.filter((c) => c.m === "promptSession").pop();
@@ -1984,7 +1984,7 @@ test("★ 事故锁:用户主动 /use 切走的会话不能被「最近完成
1984
1984
  });
1985
1985
 
1986
1986
  test("★ 待补发队列:过期的**不发**(丢弃最早的),且只保留最近几条", async () => {
1987
- // 业主 2026-10-09 口径:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
1987
+ // 用户 2026-10-09 约定:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
1988
1988
  // 两条理由:① 迟到几小时的"任务跑完了"只会误导用户;② 攒到十几条会在平台恢复时一次连打,
1989
1989
  // 实测正是这种突发批量触发 `ret=-2 prepare failed` 被限流 → 补发又失败 → 队列再涨(死循环)。
1990
1990
  const ilink = await fakeIlink();
@@ -2054,7 +2054,7 @@ test("★ 待补发队列:超过上限只留最近几条(丢最早,且如
2054
2054
  });
2055
2055
 
2056
2056
 
2057
- test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 emit、没人接 → 全静默)", async () => {
2057
+ test("★ 回归锁:订阅 fault 必须落到日志 + 状态(旧实现只 emit、没人接 → 全静默)", async () => {
2058
2058
  // 那次「一条推送都没有、日志里也没有任何报错」的根因就在这类静默里:
2059
2059
  // `#emitFault` 只 emit('fault'),而运行时没接这个事件 —— follow 失败/发现失败/流上限
2060
2060
  // 全都不留任何痕迹。现在必须:① 一条 warn 进 bridge 日志;② 非预期的记进状态文件。
@@ -2079,7 +2079,7 @@ test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 em
2079
2079
  const st2 = JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
2080
2080
  assert.equal(st2.last_fault.code, "follow-limit", "expected 的异常不该覆盖真正的故障");
2081
2081
  // ★2026-10-09:预期内的拒绝**不能写成 warn** —— 每生成一个子代理就会来一条,
2082
- // 成片的 `订阅异常 session/agent-busy …` 会把真正的故障淹掉(业主现场看到的就是这个)。
2082
+ // 成片的 `订阅异常 session/agent-busy …` 会把真正的故障淹掉(用户现场看到的就是这个)。
2083
2083
  assert.equal(warns.length, warnCountBefore, `expected 的拒绝不得写 warn,实际:${JSON.stringify(warns)}`);
2084
2084
  assert.ok(infos.some((m) => m.includes("订阅跳过(预期") && m.includes("session=sub-1")),
2085
2085
  `expected 的拒绝要有一条 info 留痕(绝不静默),实际:${JSON.stringify(infos)}`);
@@ -45,7 +45,7 @@ function loadTierResolver({ localKey = "", token = "tok-1", status = 200, body =
45
45
  if (throws) throw new Error("network down");
46
46
  return { ok: status >= 200 && status < 300, status, json: async () => bodyFor() };
47
47
  };
48
- // 多出口两个"内部状态"的只读探针 —— 否则"token 缓存被清空""快照不因缺 user 被覆盖"
48
+ // 多出口两个"内部状态"的只读探测 —— 否则"token 缓存被清空""快照不因缺 user 被覆盖"
49
49
  // 这两条只能用间接行为猜,抓不住回归(它们都在闭包里)。
50
50
  const make = new Function("process", "resolveToken", "API_BASE", "fetch", "AbortSignal", "Date",
51
51
  `${BRIDGE_SRC.slice(from, to)}
@@ -145,7 +145,7 @@ test("★档位来源:启动时必须复用桥接刚拿到的 JWT(否则每
145
145
  assert.ok(seedAt < startAt, "★必须在 startWeChat() **之前**喂,否则档位校准仍会再登录一次");
146
146
  });
147
147
 
148
- test("App 链接:由账号 API 地址推导,与插件半的 DEFAULT_APP_URL 同口径", () => {
148
+ test("App 链接:由账号 API 地址推导,与插件半的 DEFAULT_APP_URL 同约定", () => {
149
149
  assert.equal(loadAppUrl(API_BASE)(), "https://api.example/app/");
150
150
  assert.equal(loadAppUrl("https://n.risegao.cn:13443/relay-api")(), "https://n.risegao.cn:13443/app/",
151
151
  "生产地址必须推导出真实可点的 App 入口");
@@ -83,7 +83,7 @@ export function resolveClientVersion(raw) {
83
83
 
84
84
  /**
85
85
  * 数值协议常量 —— **逐个抄自归档参考实现**,不是猜的:
86
- * /Users/mac/AIWorkSpace/myFreeWork/dsh-relay-internal/reference/openclaw-weixin/plugin-2.4.9/src/api/types.ts
86
+ * OpenClaw 微信插件(第三方实现,仅作字段命名参考)
87
87
  * · MessageType (types.ts:64-68) NONE:0 / USER:1 / BOT:2
88
88
  * · MessageItemType (types.ts:70-79) NONE:0 TEXT:1 IMAGE:2 VOICE:3 FILE:4 VIDEO:5
89
89
  * TOOL_CALL_START:11 TOOL_CALL_RESULT:12
@@ -702,7 +702,7 @@ export class IlinkClient {
702
702
  // **入站消息**里(官方实现见 messaging/send.js 的 buildTextMessageReq:`msg.context_token`;
703
703
  // 腾讯自家插件同样是「从入站取出 → 按 (账号, 用户) 缓存 → 发送时回带」)。
704
704
  // 🔴 我们以前**从不发它** → 真机症状就是日志里的 `ret=-2 errmsg=prepare failed`:
705
- // 消息发不出去(业主 2026-09-23:「没收到微信通道」)。
705
+ // 消息发不出去(用户 2026-09-23:「没收到微信通道」)。
706
706
  // ⚠️ 只在"有令牌且属于同一个对端"时带上,绝不拿 A 的上下文发给 B。
707
707
  const peer = String(to);
708
708
  // 本产品一个 bot 只绑定一个微信用户,所以只要有令牌就回带;`contextTokenPeer` 仅作诊断。
@@ -715,7 +715,7 @@ export class IlinkClient {
715
715
  message_type: MessageType.BOT,
716
716
  message_state: MessageState.FINISH,
717
717
  item_list: text ? [{ type: MessageItemType.TEXT, text_item: { text: String(text) } }] : [],
718
- // 没有就**不出现这个键**(不是空串)—— 与官方 `contextToken ?? undefined` 同口径
718
+ // 没有就**不出现这个键**(不是空串)—— 与官方 `contextToken ?? undefined` 同约定
719
719
  ...(ctxToken ? { context_token: ctxToken } : {})
720
720
  };
721
721
  const { json } = await this.request({
@@ -1774,7 +1774,7 @@ export function markUnbound(relayDir, reason = "", onWarn) {
1774
1774
  export const DEFAULT_REPLY_TTL_MINUTES = 5;
1775
1775
 
1776
1776
  /**
1777
- * 完成通知里「结论」的展示上限(字)。业主口径(2026-09-22):
1777
+ * 完成通知里「结论」的展示上限(字)。用户约定(2026-09-22):
1778
1778
  * 「任务完成通知里有些关键结论被大量截断了,加长一些,更完整地展示最终结论 ——
1779
1779
  * 这可能是用户关注的内容,所以都给他们展示出来」。
1780
1780
  * 原值 400 对真实结论太短(实测一条正常的修复总结就有 190+ 字,稍详细就超)。
@@ -1898,7 +1898,7 @@ export function formatNotification(node = {}, opts = {}) {
1898
1898
  for (const l of Array.isArray(node.lines) ? node.lines : []) lines.push(String(l));
1899
1899
  // 措辞对齐"回顾今天做了什么" —— 日报不是待办清单
1900
1900
  if (!Array.isArray(node.lines) || !node.lines.length) lines.push("今天这台电脑上没有跑任务。");
1901
- // 品牌轻露出(业主:让用户知道我们在给他提供服务)。**克制一行**、放在最底,不喧宾夺主。
1901
+ // 品牌轻露出(用户:让用户知道我们在给他提供服务)。**克制一行**、放在最底,不喧宾夺主。
1902
1902
  lines.push("", "—— DSH 远程控制 · 微信机器人通道");
1903
1903
  // ⚠️ 这句是**功能**不是客套:微信 24h 推送窗口靠用户回消息续期,
1904
1904
  // 简报的产品作用正是每天制造一次互动(§7)。必须出现「回复」字样,否则用户不会回。
@@ -1974,7 +1974,7 @@ function clipText(s, n) {
1974
1974
  * 为什么必须转:微信**不渲染 Markdown**(最强证据是腾讯自己的插件在出站路径跑
1975
1975
  * `StreamingMarkdownFilter` 主动剥离"不支持的 markdown 语法")。所以模型写的表格会以
1976
1976
  * `| a | b |` 的**字面竖线**出现在气泡里、`**加粗**` 会显示成星号。
1977
- * 业主原话:「现在的表格形式看起来很奇怪,整个格式在微信上没有做过任何兼容,体验很差很差」
1977
+ * 用户原话:「现在的表格形式看起来很奇怪,整个格式在微信上没有做过任何兼容,体验很差很差」
1978
1978
  * —— 根因就在这里,不是"排版没调好"。
1979
1979
  *
1980
1980
  * 规则(只改"怎么显示",不改语义):
@@ -2365,10 +2365,10 @@ export function isDestructiveTool(toolName, detail) {
2365
2365
  if (DESTRUCTIVE_TOOL_NAME_RE.test(name)) return true;
2366
2366
 
2367
2367
  const text = flattenToolDetail(detail);
2368
- // ⚠️ 2026-09-22 按业主决定**删掉**了原来的规则③:「能跑命令的工具 + 完全看不到内容 → 算破坏性」。
2368
+ // ⚠️ 2026-09-22 按用户决定**删掉**了原来的规则③:「能跑命令的工具 + 完全看不到内容 → 算破坏性」。
2369
2369
  // 原意是防"盲批",但 DSH 的审批节点本来就只下发 toolName、常常没有命令原文,
2370
2370
  // 那条规则会把**绝大多数正常 Bash 审批**都降级成"只能回电脑确认",过严且伤体验。
2371
- // 业主明确:安全边界松一点没问题,**DSH 自身有权限控制**。
2371
+ // 用户明确:安全边界松一点没问题,**DSH 自身有权限控制**。
2372
2372
  // 所以现在只看**内容**:看得出是 rm -rf / 强推 / DROP TABLE / 批量覆盖这类才拦;
2373
2373
  // 看不到内容时按普通审批处理(保留一步回执)。SHELL_TOOL_NAME_RE 仍保留,
2374
2374
  // 供将来若要恢复"盲批保护"时使用,并由测试固化其语义。
@@ -2834,7 +2834,7 @@ export class WeChatChannel {
2834
2834
  // 冷却原本只按"撞到 -14"计时,但**换绑会拿到全新的 bot_token** ——
2835
2835
  // 旧 token 的会话超时对新 token 毫无意义。不清的后果:用户解绑→重绑拿到新 token,
2836
2836
  // 却仍被旧 token 的 1 小时冷却挡着,表现成"刚绑上就一小时内不能聊天",
2837
- // 而且面板只说"通知会延迟",用户完全不知道为什么。(业主实测撞到)
2837
+ // 而且面板只说"通知会延迟",用户完全不知道为什么。(用户实测撞到)
2838
2838
  this.cooldown.clear();
2839
2839
  return this.uiAccount();
2840
2840
  }