@mrrisega/dsh-remote 0.7.9 → 0.7.10-beta.10

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 (60) hide show
  1. package/README.md +9 -2
  2. package/clients/dsh-remote/agent/README.md +412 -0
  3. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  4. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  5. package/clients/dsh-remote/agent/codex.mjs +736 -0
  6. package/clients/dsh-remote/agent/detect.mjs +399 -0
  7. package/clients/dsh-remote/agent/events.mjs +511 -0
  8. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  9. package/clients/dsh-remote/agent/index.mjs +339 -0
  10. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  11. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  12. package/clients/dsh-remote/agent/proc.mjs +179 -0
  13. package/clients/dsh-remote/agent/version.mjs +148 -0
  14. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  15. package/clients/dsh-remote/decision-poller.mjs +235 -0
  16. package/clients/dsh-remote/dsh-bridge.mjs +208 -1
  17. package/clients/dsh-remote/mobile-adapter.mjs +775 -11
  18. package/clients/dsh-remote/push-reporter.mjs +533 -0
  19. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  20. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  21. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  22. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  23. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  24. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  25. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  26. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  27. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  28. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  29. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  30. package/clients/dsh-remote/test/bridge-e2ee-reject-observable.test.mjs +90 -0
  31. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  32. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +172 -0
  33. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +188 -4
  34. package/clients/dsh-remote/test/push-reporter.test.mjs +380 -0
  35. package/dsh-setup.mjs +5 -0
  36. package/package.json +2 -2
  37. package/packages/dsh-remote-web/lib/client.js +350 -8
  38. package/packages/dsh-remote-web/lib/index.js +26 -0
  39. package/packages/dsh-remote-web/package.json +1 -1
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  53. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  54. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
  55. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
  56. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  57. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +5 -0
  58. package/packages/dsh-remote-web/test/activation-entry.test.mjs +407 -0
  59. package/packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs +70 -0
  60. package/packages/dsh-remote-web/test/setup-output.test.mjs +16 -0
@@ -74,6 +74,15 @@ class El {
74
74
  prepend() {} focus() {} blur() {}
75
75
  hasAttribute(n) { return n in this.attrs; }
76
76
  appendChild(c) { c.parent = this; this.children.push(c); return c; }
77
+ /* 真实 DOM 里 removeChild/parentNode 是基本能力:适配层的「字号提示」用它把自己摘掉。
78
+ 假 DOM 缺了它 → 提示永远摘不掉 → 用例会把一个正确的实现判成错误(假红)。 */
79
+ removeChild(c) {
80
+ const i = this.children.indexOf(c);
81
+ if (i >= 0) this.children.splice(i, 1);
82
+ if (c) c.parent = null;
83
+ return c;
84
+ }
85
+ get parentNode() { return this.parent || null; }
77
86
  addEventListener(t, fn) { (this._listeners.get(t) || this._listeners.set(t, []).get(t)).push(fn); }
78
87
  removeEventListener() {}
79
88
  dispatch(t, ev = {}) { for (const fn of this._listeners.get(t) || []) fn({ stopPropagation() {}, preventDefault() {}, ...ev }); }
@@ -106,7 +115,9 @@ class El {
106
115
  * 折叠时**没有拖拽把手**——这正是 2026-09-19 线上"整屏遮罩"的现场结构。
107
116
  */
108
117
  function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRect = null, width = 390, withToggle = true,
109
- variant = "rc1", rightbarAttr = undefined, overlayRect = null, rightbarRect = null, fetchImpl = null } = {}) {
118
+ variant = "rc1", rightbarAttr = undefined, overlayRect = null, rightbarRect = null, fetchImpl = null,
119
+ /* 2026-10-2x:给"输入框可用性小修补"用例提供一个官方 Lexical 输入区(默认不提供,旧用例现场不变)。 */
120
+ withComposer = false } = {}) {
110
121
  const html = new El("html");
111
122
  // HOSTISH 门禁会读 documentElement.outerHTML 找官方特征(__ModuleLoader__ / DeepSeek Harness)
112
123
  html.outerHTML = '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
@@ -134,6 +145,9 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
134
145
  }
135
146
  const toggle = new El("button");
136
147
  toggle.setAttribute("aria-label", "打开侧边栏");
148
+ const composer = new El("div", {
149
+ contenteditable: "true", "data-composer-input": "true", role: "textbox", "aria-multiline": "true",
150
+ });
137
151
 
138
152
  const pickByClass = (sel) => {
139
153
  // 只支持本用例需要的极少数选择器(脚本里 querySelector 的用法就这些)
@@ -156,9 +170,28 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
156
170
  if (s2.includes("aria-label=")) return withToggle ? toggle : null;
157
171
  if (s2.includes(".hHd-Xa_toggle")) return withToggle ? toggle : null;
158
172
  if (s2.includes("hHd-Xa")) return sidebar; // 子列识别用(真实页面里这是侧栏)
173
+ if (s2.includes("data-composer-input")) return withComposer ? composer : null;
159
174
  return pickByClass(s2);
160
175
  },
161
- querySelectorAll() { return []; },
176
+ /* 只额外支持 `img`:适配层的「图片按需加载」要扫 document.querySelectorAll("img")。
177
+ 其它选择器仍按旧行为返回 [](不改变既有用例的现场)。 */
178
+ querySelectorAll(sel) {
179
+ if (String(sel) !== "img") return [];
180
+ const out = [];
181
+ const seen = new Set();
182
+ const walk = (el) => {
183
+ if (!el || seen.has(el)) return;
184
+ seen.add(el);
185
+ for (const c of el.children || []) {
186
+ if (String(c.tagName) === "IMG") out.push(c);
187
+ walk(c);
188
+ }
189
+ };
190
+ /* 这套假 DOM 里 body 不一定挂在 documentElement 下(旧用例不需要),所以两个根都走一遍。 */
191
+ walk(doc);
192
+ walk(doc.body);
193
+ return out;
194
+ },
162
195
  createElement: (t) => new El(t),
163
196
  createTextNode: (t) => ({ nodeValue: t }),
164
197
  getElementById: () => null,
@@ -179,7 +212,9 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
179
212
  fetch: fetchImpl || undefined,
180
213
  // unref:同上 —— 适配层的 4 分钟兜底计时器会让测试进程空等到期(本来 240s,只剩 0.2s)。
181
214
  // 用例里的 `_pendingTimers` 仍会被 runAdapter() 主动跑一轮,行为不变。
182
- setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); try { id.unref?.(); } catch { /* 忽略 */ } timers.set(id, fn); return id; },
215
+ // 2026-10-2x:额外把 (fn, ms) 记进 timers,便于 `flushTimers(env, maxMs)` 手动推进
216
+ // 「延迟才做」的首屏后优化(字号提示 1200ms、图片懒加载 600ms),不必真等。
217
+ setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); try { id.unref?.(); } catch { /* 忽略 */ } timers.set(id, { fn, ms }); return id; },
183
218
  clearTimeout: (id) => clearTimeout(id),
184
219
  MutationObserver: class { observe() {} disconnect() {} },
185
220
  ResizeObserver: class { observe() {} disconnect() {} },
@@ -203,7 +238,7 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
203
238
  ctx.console = dbgConsole;
204
239
  ctx.globalThis = ctx;
205
240
  ctx.self = win;
206
- return { ctx, html, body, frame, sidebar, rightbar, overlay, toggle, win };
241
+ return { ctx, html, body, frame, sidebar, rightbar, overlay, toggle, composer, win, timers };
207
242
  }
208
243
 
209
244
  const ADAPTER_DEBUG = process.env.DSH_ADAPTER_DEBUG === "1";
@@ -221,6 +256,16 @@ function runAdapter(env) {
221
256
  return env;
222
257
  }
223
258
 
259
+ /** 手动推进"延迟才做"的定时器(≤ maxMs),模拟时间流逝(不必真等 1.2 秒)。 */
260
+ function flushTimers(env, maxMs = 2000) {
261
+ for (const [, rec] of [...(env.timers || new Map())]) {
262
+ if (!rec || typeof rec.fn !== "function") continue;
263
+ if (rec.ms <= maxMs) {
264
+ try { rec.fn(); } catch { /* 用例里吞掉:被测代码自己的兜底由它自己保证 */ }
265
+ }
266
+ }
267
+ }
268
+
224
269
  const findScrim = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
225
270
  const findHamburger = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
226
271
 
@@ -625,3 +670,142 @@ test("网络延迟:无样本 / 失败请求绝不显示 0ms(显示「测速
625
670
  const menu = findMenu(env);
626
671
  assert.match(menu.innerHTML, /测速中…/, "空态文案必须是「测速中…」");
627
672
  });
673
+
674
+ // ══════════════════════════════════════════════════════════════════════════════
675
+ // 首屏性能 & 移动端体验(2026-10-2x)
676
+ //
677
+ // 用户原声:「字体太小,因此不用」(字号档位其实早就有,藏在悬浮菜单里,没人知道);
678
+ // 生产遥测 n=8903:首屏 p50 3.9s / p90 21.6s、20.2% ≥12s。
679
+ // 这里锁住适配层**自己**能控制的那部分:
680
+ // ① 首启字号提示:一次性、挂在 FAB 内部(不新增 body 级浮层)、点它直接打开菜单、
681
+ // 延迟 1.2 秒才出现(绝不掺和首屏)、用户调过字号就不再提示;
682
+ // ② 图片按需加载:只给「视口外且没在加载」的图片打 loading=lazy + decoding=async,
683
+ // 首屏附近的图一个字节都不动(该立刻出现的必须立刻出现);
684
+ // ③ 输入体验:输入框字号跟着档位放大但永不低于 16px(防 iOS 聚焦缩放)、只打属性不改结构;
685
+ // ④ 性能可测量:__dshMaDiag.perf 里有首屏里程碑 / 适配层自身 boot 耗时 / 懒加载计数,
686
+ // 且**不新增遥测事件名**(服务端只认白名单)。
687
+ // ══════════════════════════════════════════════════════════════════════════════
688
+
689
+ test("首启字号提示:1.2s 后出现、挂在 FAB 内部、点它直接打开字号菜单、且只提示一次", () => {
690
+ const env = harness({ width: 390 });
691
+ runAdapter(env);
692
+ const fab = findFab(env);
693
+ assert.ok(fab, "前提:悬浮按钮存在");
694
+ assert.equal(fab.querySelector(".dsh-ma-fs-tip"), null,
695
+ "首屏阶段绝不能出现提示 —— 延迟 1.2 秒就是为了不跟首屏抢主线程/抢视线");
696
+ const bodyKids = env.body.children.length;
697
+
698
+ flushTimers(env, 1200); // 推进「首屏之后」的延迟定时器(不必真等 1.2 秒)
699
+
700
+ const tip = fab.querySelector(".dsh-ma-fs-tip");
701
+ assert.ok(tip, "1.2 秒后应出现「字太小?」提示(字号档位就藏在这颗按钮里)");
702
+ assert.equal(env.body.children.length, bodyKids,
703
+ "★ 提示必须挂在 FAB 内部:body 下不得多出任何浮层(适配层曾经用全屏层干过这事)");
704
+ assert.match(String(tip.innerHTML), /点这里调字号/, "文案必须直接告诉用户该点哪里");
705
+ assert.match(String(tip.innerHTML), /✕/, "必须可关闭(✕)");
706
+ assert.equal(env.win.localStorage.getItem("dsh-ma-fs-tip-v1"), "1",
707
+ "显示即记「已提示过」:同一浏览器只提示一次(localStorage)");
708
+ assert.equal(env.win.__dshMaDiag.perf.tipShown, true, "诊断里要能看到提示是否展示过");
709
+
710
+ // 点气泡 = 直接打开字号菜单(不用先学会点按钮),并收起提示
711
+ const menu = findMenu(env);
712
+ assert.equal(menu.hidden, true, "前提:菜单默认收起");
713
+ tip.dispatch("click");
714
+ assert.equal(menu.hidden, false, "★ 点提示必须打开远程控制菜单(里面就是字号滑块)");
715
+ assert.equal(fab.querySelector(".dsh-ma-fs-tip"), null, "点过之后提示必须收起");
716
+ });
717
+
718
+ test("首启字号提示:已提示过 / 用户自己调过字号 → 都不再提示(不打扰)", () => {
719
+ const env = harness({ width: 390 });
720
+ env.win.localStorage.setItem("dsh-ma-fs-tip-v1", "1"); // 上次已经提示过
721
+ runAdapter(env);
722
+ flushTimers(env, 1200);
723
+ assert.equal(findFab(env).querySelector(".dsh-ma-fs-tip"), null, "已提示过 → 不得再出现");
724
+
725
+ const env2 = harness({ width: 390 });
726
+ env2.win.localStorage.setItem("dsh-ma-font-scale", "2"); // 用户自己调过字号
727
+ runAdapter(env2);
728
+ flushTimers(env2, 1200);
729
+ assert.equal(findFab(env2).querySelector(".dsh-ma-fs-tip"), null,
730
+ "用户已经找到并调过字号 → 不必再告知(提示的目的已达成)");
731
+ });
732
+
733
+ test("图片按需加载:视口外的图打 loading=lazy + decoding=async,首屏附近的图一个字节都不动", () => {
734
+ const env = harness({ width: 390 });
735
+ const far = new El("img");
736
+ far.setAttribute("src", "/assets/chat-far.png");
737
+ far._rect = { left: 0, top: 2000, right: 300, bottom: 2200, width: 300, height: 200 };
738
+ const near = new El("img");
739
+ near.setAttribute("src", "/assets/chat-near.png");
740
+ near._rect = { left: 0, top: 10, right: 300, bottom: 210, width: 300, height: 200 };
741
+ env.body.appendChild(far);
742
+ env.body.appendChild(near);
743
+
744
+ assert.equal(far.getAttribute("loading"), null, "前提:官方 <img> 本来没有 loading 属性");
745
+ runAdapter(env);
746
+ flushTimers(env, 600); // boot 后 600ms 的「首屏之后」扫描
747
+
748
+ assert.equal(far.getAttribute("loading"), "lazy", "★ 视口外的图片必须按需加载(首屏少发请求、少抢带宽)");
749
+ assert.equal(far.getAttribute("decoding"), "async", "解码异步,别阻塞主线程");
750
+ assert.equal(near.getAttribute("loading"), null, "★ 首屏附近的图片绝不能被改成 lazy(该立刻出现的必须立刻出现)");
751
+ assert.equal(near.getAttribute("decoding"), null, "首屏附近的图连 decoding 也不动(保持官方原样)");
752
+
753
+ const perf = env.win.__dshMaDiag.perf;
754
+ assert.ok(perf.imgLazy >= 1, "诊断要能量化「省下的图片请求数」(imgLazy)");
755
+ assert.ok(perf.imgKept >= 1, "首屏附近未动的图片也要计数(imgKept)");
756
+ });
757
+
758
+ test("输入体验:只给官方输入框补属性(enterkeyhint/spellcheck),不动 DOM 结构", () => {
759
+ const env = harness({ width: 390, withComposer: true });
760
+ runAdapter(env);
761
+ flushTimers(env, 600);
762
+ const c = env.composer;
763
+ assert.equal(c.getAttribute("enterkeyhint"), "enter",
764
+ "回车键标注「换行」:与适配层「手机 Enter 只换行、发送交给发送按钮」的策略一致");
765
+ assert.equal(c.getAttribute("spellcheck"), "false", "中文会话里别画拼写红线");
766
+ assert.equal(c.getAttribute("contenteditable"), "true", "★ 只打属性:官方原有属性/结构一个都不能改");
767
+ assert.equal(c.getAttribute("data-composer-input"), "true", "★ 官方锚点属性必须原样保留");
768
+ });
769
+
770
+ test("首屏诊断:__dshMaDiag.perf 有首屏里程碑 + 适配层自身耗时,且不新增遥测事件名", () => {
771
+ const env = harness({ width: 390 });
772
+ runAdapter(env);
773
+ flushTimers(env, 3100);
774
+ const perf = env.win.__dshMaDiag && env.win.__dshMaDiag.perf;
775
+ assert.ok(perf, "必须在 __dshMaDiag 里暴露 perf(用户上报现场时能读)");
776
+ for (const k of ["startAt", "bootCalls", "bootMs", "hostishCalls", "hostishSerializations",
777
+ "fcp", "ttfb", "dcl", "loadEnd", "uiReadyMs", "imgLazy", "imgScanPaused", "tipShown", "vvRepositions"]) {
778
+ assert.ok(k in perf, "perf 缺字段: " + k);
779
+ }
780
+ assert.ok(perf.bootCalls >= 1, "boot 至少跑过一次 —— bootMs 才是「适配层自身耗时」");
781
+ assert.ok(perf.hostishSerializations === 0,
782
+ "★ 有官方结构信号时一次全量序列化都不该做(旧实现每次 boot 都先 outerHTML.slice(0,200000))");
783
+ assert.equal(typeof perf.uiReadyMs, "number", "首屏可用时刻(= mirror_load 的 ms,导航开始 → 可用)");
784
+
785
+ // 纪律:不新增遥测事件名(服务端只认白名单,新名字会被丢弃)→ 唯一允许的端点仍是 mirror-load
786
+ const SRC = readFileSync(new URL("../mobile-adapter.mjs", import.meta.url), "utf8");
787
+ const endpoints = [...new Set([...SRC.matchAll(/\/dsh-remote\/telemetry\/[a-z-]+/g)].map((m) => m[0]))];
788
+ assert.deepEqual(endpoints, ["/dsh-remote/telemetry/mirror-load"],
789
+ "★ 不得新增遥测事件名(白名单只有 mirror_load;新指标只进本地 __dshMaDiag.perf)");
790
+ });
791
+
792
+ test("键盘弹出:visualViewport 变化时悬浮按钮重新贴边(按钮不能被键盘顶到看不见的地方)", async () => {
793
+ const env = harness({ width: 390 });
794
+ // 把按钮记忆在右下角:它的 top 与"可视区高度"直接相关
795
+ env.win.localStorage.setItem("dsh-ma-fab-pos", JSON.stringify({ h: "left", v: "bottom", dx: 10, dy: 10 }));
796
+ const vvListeners = new Map();
797
+ env.win.visualViewport = {
798
+ height: 800,
799
+ addEventListener(t, fn) { (vvListeners.get(t) || vvListeners.set(t, []).get(t)).push(fn); },
800
+ };
801
+ runAdapter(env);
802
+ const fab = findFab(env);
803
+ assert.equal(fab.style.top, String(800 - 46 - 10) + "px", "前提:先按可视区高度贴底");
804
+
805
+ env.win.visualViewport.height = 500; // 键盘弹出:可视区矮了 300px
806
+ for (const fn of vvListeners.get("resize") || []) fn();
807
+ await new Promise((r) => setImmediate(r)); // 重算被合到一帧里(rAF)
808
+ assert.equal(fab.style.top, String(500 - 46 - 10) + "px",
809
+ "★ 键盘弹出后必须按 visualViewport 重新贴边(旧实现用 innerHeight,按钮会留在键盘后面点不到)");
810
+ assert.ok(env.win.__dshMaDiag.perf.vvRepositions >= 1, "重定位次数要进诊断");
811
+ });
@@ -0,0 +1,380 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ createPushReporter,
6
+ toNotifyPayload,
7
+ NOTIFY_KINDS,
8
+ MAX_QUEUE,
9
+ MAX_BODY_LEN
10
+ } from "../push-reporter.mjs";
11
+ import { NODE_KINDS } from "../dsh-events.mjs";
12
+
13
+ /** 构造一个审批节点(与 dsh-events classifyWaterfall 的形状一致)。 */
14
+ function approvalNode(over = {}) {
15
+ return {
16
+ kind: NODE_KINDS.APPROVAL_REQUEST,
17
+ specNode: 1,
18
+ answerable: true,
19
+ sessionId: "s_1",
20
+ agentId: "a_1",
21
+ eventId: "evt_approval_1",
22
+ toolName: "Bash",
23
+ reason: "npm run deploy",
24
+ options: [
25
+ { value: "allowed-once", label: "允许一次" },
26
+ { value: "rejected", label: "拒绝" }
27
+ ],
28
+ at: 1_700_000_000_000,
29
+ ...over
30
+ };
31
+ }
32
+
33
+ function turnEndNode(over = {}) {
34
+ return {
35
+ kind: NODE_KINDS.TURN_END,
36
+ specNode: 4,
37
+ sessionId: "s_1",
38
+ turn: 3,
39
+ reason: "completed",
40
+ known: true,
41
+ seq: 42,
42
+ at: 1_700_000_000_000,
43
+ ...over
44
+ };
45
+ }
46
+
47
+ /** 极简 fetch 假实现:按脚本返回,记录每次调用。 */
48
+ function makeFetch(script) {
49
+ const calls = [];
50
+ const fn = async (url, init) => {
51
+ calls.push({ url, init });
52
+ const step = script.shift();
53
+ if (!step) throw new Error("fetch script exhausted");
54
+ if (step instanceof Error) throw step;
55
+ const status = step.status ?? 200;
56
+ const body = step.body ?? { ok: true };
57
+ return {
58
+ status,
59
+ ok: status >= 200 && status < 300,
60
+ async json() { return body; }
61
+ };
62
+ };
63
+ fn.calls = calls;
64
+ return fn;
65
+ }
66
+
67
+ // ───────────────────────── 映射(事件 → 上报体) ─────────────────────────
68
+
69
+ test("① 审批节点 → approval_request,event_id 稳定、带选项、正文不含完整命令(默认隐私模式)", () => {
70
+ const p = toNotifyPayload(approvalNode(), { deviceId: "dev-x", detail: false });
71
+ assert.equal(p.kind, NOTIFY_KINDS.APPROVAL_REQUEST);
72
+ assert.match(p.event_id, /^dev-x:approval:/);
73
+ assert.equal(p.approval.request_id, "evt_approval_1");
74
+ assert.equal(p.approval.options.length, 2);
75
+ assert.equal(p.session.id, "s_1");
76
+ // 默认模式:summary 只留动作名(工具名),绝不外发 "npm run deploy" 这种完整命令
77
+ assert.equal(p.approval.summary, "Bash");
78
+ assert.ok(!JSON.stringify(p).includes("npm run deploy"), "默认模式不得把完整命令外发");
79
+ assert.ok(p.body.length <= MAX_BODY_LEN);
80
+ });
81
+
82
+ test("② detail 模式才带上完整命令摘要(服务端规则引擎据此判风险)", () => {
83
+ const p = toNotifyPayload(approvalNode(), { deviceId: "dev-x", detail: true });
84
+ assert.equal(p.approval.summary, "npm run deploy");
85
+ });
86
+
87
+ test("③ 会话标题进 session.title(超长截断),正文只写一句人话", () => {
88
+ const p = toNotifyPayload(approvalNode(), {
89
+ deviceId: "dev-x",
90
+ sessionTitle: () => "重构登录模块".repeat(20)
91
+ });
92
+ assert.ok(p.session.title.length <= 60);
93
+ });
94
+
95
+ test("④ turn/end 各 reason 映射:completed→turn_done、error→turn_error、blocked→turn_blocked、未知原因如实写", () => {
96
+ const done = toNotifyPayload(turnEndNode(), { deviceId: "dev-x", sessionTitle: () => "重构登录模块" });
97
+ assert.equal(done.kind, NOTIFY_KINDS.TURN_DONE);
98
+ assert.match(done.body, /重构登录模块/);
99
+
100
+ const err = toNotifyPayload(turnEndNode({ reason: "error", known: true }), { deviceId: "dev-x" });
101
+ assert.equal(err.kind, NOTIFY_KINDS.TURN_ERROR);
102
+
103
+ const blocked = toNotifyPayload(turnEndNode({ reason: "blocked", known: true }), { deviceId: "dev-x" });
104
+ assert.equal(blocked.kind, NOTIFY_KINDS.TURN_BLOCKED);
105
+
106
+ const weird = toNotifyPayload(turnEndNode({ reason: "some-new-reason", known: false }), { deviceId: "dev-x" });
107
+ assert.equal(weird.kind, NOTIFY_KINDS.TURN_DONE);
108
+ assert.match(weird.body, /some-new-reason/);
109
+ });
110
+
111
+ test("⑤ gap/fault/简报等节点不上报(返回 null),缺 deviceId 也不上报", () => {
112
+ assert.equal(toNotifyPayload({ kind: NODE_KINDS.GAP, at: 1 }, { deviceId: "dev-x" }), null);
113
+ assert.equal(toNotifyPayload({ kind: NODE_KINDS.FAULT, at: 1 }, { deviceId: "dev-x" }), null);
114
+ assert.equal(toNotifyPayload({ kind: NODE_KINDS.DIGEST_DUE, at: 1 }, { deviceId: "dev-x" }), null);
115
+ assert.equal(toNotifyPayload(approvalNode(), { deviceId: "" }), null);
116
+ assert.equal(toNotifyPayload(null, { deviceId: "dev-x" }), null);
117
+ });
118
+
119
+ test("⑥ 审批节点没有 eventId(无法回执)时不上报", () => {
120
+ assert.equal(toNotifyPayload(approvalNode({ eventId: "" }), { deviceId: "dev-x" }), null);
121
+ });
122
+
123
+ // ───────────────────────── 队列与投递 ─────────────────────────
124
+
125
+ test("⑦ 同一 event_id 重复入队只算一条(幂等第一道)", async () => {
126
+ const fetchImpl = makeFetch([{ status: 200 }]);
127
+ const r = createPushReporter({
128
+ apiBase: "https://api.example.com",
129
+ deviceId: "dev-x",
130
+ tokenProvider: () => "tok",
131
+ upstream: "http://127.0.0.1:3080",
132
+ fetchImpl
133
+ });
134
+ const p = toNotifyPayload(turnEndNode(), { deviceId: "dev-x" });
135
+ assert.equal(r._enqueue(p), true);
136
+ assert.equal(r._enqueue({ ...p }), false);
137
+ assert.equal(r.status().duplicates, 1);
138
+ await r._flush();
139
+ assert.equal(r.status().sent, 1);
140
+ await r.stop();
141
+ });
142
+
143
+ test("⑧ 队列有界:超过上限丢最旧并计数(不让内存无界增长)", async () => {
144
+ const fetchImpl = makeFetch([]);
145
+ const r = createPushReporter({
146
+ apiBase: "https://api.example.com",
147
+ deviceId: "dev-x",
148
+ tokenProvider: () => "tok",
149
+ upstream: "http://127.0.0.1:3080",
150
+ fetchImpl,
151
+ maxQueue: 3
152
+ });
153
+ for (let i = 0; i < 5; i++) {
154
+ r._enqueue({
155
+ event_id: `dev-x:turn:${i}`,
156
+ device_id: "dev-x",
157
+ kind: NOTIFY_KINDS.TURN_DONE,
158
+ at: 1,
159
+ session: { id: "s" },
160
+ title: "任务完成",
161
+ body: "x"
162
+ });
163
+ }
164
+ assert.equal(r.status().queued, 3);
165
+ assert.equal(r.status().dropped, 2);
166
+ await r.stop();
167
+ });
168
+
169
+ test("⑨ 投递成功:带 Bearer、POST 到 /api/notify/event、JSON body", async () => {
170
+ const fetchImpl = makeFetch([{ status: 200, body: { ok: true, notified: 1 } }]);
171
+ const r = createPushReporter({
172
+ apiBase: "https://api.example.com/",
173
+ deviceId: "dev-x",
174
+ tokenProvider: () => "tok-1",
175
+ upstream: "http://127.0.0.1:3080",
176
+ fetchImpl
177
+ });
178
+ r._enqueue(toNotifyPayload(turnEndNode(), { deviceId: "dev-x" }));
179
+ await r._flush();
180
+ const call = fetchImpl.calls[0];
181
+ assert.equal(call.url, "https://api.example.com/api/notify/event");
182
+ assert.equal(call.init.method, "POST");
183
+ assert.equal(call.init.headers.authorization, "Bearer tok-1");
184
+ assert.equal(JSON.parse(call.init.body).device_id, "dev-x");
185
+ assert.equal(r.status().sent, 1);
186
+ await r.stop();
187
+ });
188
+
189
+ test("⑩ 401:丢弃本批(等下次登录刷新),不无限重试", async () => {
190
+ const fetchImpl = makeFetch([{ status: 401 }]);
191
+ const r = createPushReporter({
192
+ apiBase: "https://api.example.com",
193
+ deviceId: "dev-x",
194
+ tokenProvider: () => "expired",
195
+ upstream: "http://127.0.0.1:3080",
196
+ fetchImpl
197
+ });
198
+ r._enqueue(toNotifyPayload(turnEndNode(), { deviceId: "dev-x" }));
199
+ await r._flush();
200
+ assert.equal(r.status().queued, 0, "401 后不应把条目留在队列里反复打");
201
+ assert.equal(r.status().lastError, "auth_failed");
202
+ await r.stop();
203
+ });
204
+
205
+ test("⑪ 400 契约错误:丢弃该条(重试没意义)", async () => {
206
+ const fetchImpl = makeFetch([{ status: 400 }]);
207
+ const r = createPushReporter({
208
+ apiBase: "https://api.example.com",
209
+ deviceId: "dev-x",
210
+ tokenProvider: () => "tok",
211
+ upstream: "http://127.0.0.1:3080",
212
+ fetchImpl
213
+ });
214
+ r._enqueue(toNotifyPayload(turnEndNode(), { deviceId: "dev-x" }));
215
+ await r._flush();
216
+ assert.equal(r.status().queued, 0);
217
+ assert.match(r.status().lastError, /http_400/);
218
+ await r.stop();
219
+ });
220
+
221
+ test("⑫ 5xx:保留条目等待退避重试(不丢事件)", async () => {
222
+ const fetchImpl = makeFetch([{ status: 503 }]);
223
+ const r = createPushReporter({
224
+ apiBase: "https://api.example.com",
225
+ deviceId: "dev-x",
226
+ tokenProvider: () => "tok",
227
+ upstream: "http://127.0.0.1:3080",
228
+ fetchImpl,
229
+ retryDelaysMs: [1, 1, 1]
230
+ });
231
+ r._enqueue(toNotifyPayload(turnEndNode(), { deviceId: "dev-x" }));
232
+ await r._flush();
233
+ assert.equal(r.status().queued, 1, "5xx 应保留重试");
234
+ await r.stop();
235
+ });
236
+
237
+ test("⑬ 没有 token:不发请求、条目留在队列", async () => {
238
+ const fetchImpl = makeFetch([]);
239
+ const r = createPushReporter({
240
+ apiBase: "https://api.example.com",
241
+ deviceId: "dev-x",
242
+ tokenProvider: () => "",
243
+ upstream: "http://127.0.0.1:3080",
244
+ fetchImpl
245
+ });
246
+ r._enqueue(toNotifyPayload(turnEndNode(), { deviceId: "dev-x" }));
247
+ await r._flush();
248
+ assert.equal(fetchImpl.calls.length, 0);
249
+ assert.equal(r.status().queued, 1);
250
+ assert.equal(r.status().lastError, "no_token");
251
+ await r.stop();
252
+ });
253
+
254
+ // ───────────────────────── 启动门禁 ─────────────────────────
255
+
256
+ test("⑭ start():服务端明确关闭推送时**不启动**(默认关)", async () => {
257
+ const fetchImpl = makeFetch([{ status: 200, body: { ok: true, notify: { enabled: false } } }]);
258
+ const r = createPushReporter({
259
+ apiBase: "https://api.example.com",
260
+ deviceId: "dev-x",
261
+ tokenProvider: () => "tok",
262
+ upstream: "http://127.0.0.1:3080",
263
+ fetchImpl
264
+ });
265
+ const st = await r.start();
266
+ assert.equal(st.started, false);
267
+ assert.equal(st.reason, "disabled");
268
+ await r.stop();
269
+ });
270
+
271
+ test("⑮ start():账号没有推送订阅时不启动(没订阅推了也没人收)", async () => {
272
+ const fetchImpl = makeFetch([
273
+ { status: 200, body: { ok: true, notify: { enabled: true } } },
274
+ { status: 200, body: { ok: true, enabled: true, subscribed: false } }
275
+ ]);
276
+ const r = createPushReporter({
277
+ apiBase: "https://api.example.com",
278
+ deviceId: "dev-x",
279
+ tokenProvider: () => "tok",
280
+ upstream: "http://127.0.0.1:3080",
281
+ fetchImpl
282
+ });
283
+ const st = await r.start();
284
+ assert.equal(st.started, false);
285
+ assert.equal(st.reason, "no_subscription");
286
+ await r.stop();
287
+ });
288
+
289
+ test("⑯ start():没有 token 时不启动、不抛异常", async () => {
290
+ const fetchImpl = makeFetch([]);
291
+ const r = createPushReporter({
292
+ apiBase: "https://api.example.com",
293
+ deviceId: "dev-x",
294
+ tokenProvider: () => "",
295
+ upstream: "http://127.0.0.1:3080",
296
+ fetchImpl
297
+ });
298
+ const st = await r.start();
299
+ assert.equal(st.started, false);
300
+ assert.equal(st.reason, "no_token");
301
+ assert.equal(fetchImpl.calls.length, 0);
302
+ await r.stop();
303
+ });
304
+
305
+ test("⑰ 探测失败(网络异常)不抛、不启动(可选增强绝不能拖垮主链路)", async () => {
306
+ const fetchImpl = makeFetch([new Error("ECONNREFUSED")]);
307
+ const r = createPushReporter({
308
+ apiBase: "https://api.example.com",
309
+ deviceId: "dev-x",
310
+ tokenProvider: () => "tok",
311
+ upstream: "http://127.0.0.1:3080",
312
+ fetchImpl
313
+ });
314
+ const st = await r.start();
315
+ assert.equal(st.started, false);
316
+ assert.equal(st.reason, "ECONNREFUSED");
317
+ await r.stop();
318
+ });
319
+
320
+ test("⑱ 事件归一化抛错也不影响(畸形节点被忽略,不冒泡)", async () => {
321
+ const r = createPushReporter({
322
+ apiBase: "https://api.example.com",
323
+ deviceId: "dev-x",
324
+ tokenProvider: () => "tok",
325
+ upstream: "http://127.0.0.1:3080",
326
+ fetchImpl: makeFetch([])
327
+ });
328
+ // 直接喂畸形对象给映射函数:必须返回 null 而不是抛
329
+ assert.equal(toNotifyPayload({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: null, at: "x" }, { deviceId: "dev-x" }), null);
330
+ await r.stop();
331
+ });
332
+
333
+ test("⑲ 正文长度硬上限:超长标题/正文被截断", () => {
334
+ const p = toNotifyPayload(turnEndNode(), {
335
+ deviceId: "dev-x",
336
+ sessionTitle: () => "很长的标题".repeat(100)
337
+ });
338
+ assert.ok(p.body.length <= MAX_BODY_LEN);
339
+ assert.ok(p.session.title.length <= 60);
340
+ });
341
+
342
+ test("⑳ MAX_QUEUE 常量与文档一致(50)", () => {
343
+ assert.equal(MAX_QUEUE, 50);
344
+ });
345
+
346
+ test("㉑ event_id 必须只含 [A-Za-z0-9._:-](服务端契约,问号/空格会被 400)", () => {
347
+ const p = toNotifyPayload(
348
+ { kind: NODE_KINDS.TURN_END, sessionId: "s/1", turn: undefined, seq: undefined, reason: "completed", at: 1 },
349
+ { deviceId: "dev-x" }
350
+ );
351
+ assert.ok(p, "应生成上报体");
352
+ assert.match(p.event_id, /^[A-Za-z0-9._:-]+$/, `event_id 含非法字符:${p.event_id}`);
353
+ // 会话 id 里有斜杠、turn/seq 缺失产生问号 —— 都必须被归一化成安全字符
354
+ const p2 = toNotifyPayload(
355
+ { kind: NODE_KINDS.APPROVAL_REQUEST, sessionId: "s/1", eventId: "evt 1?x", at: 1, toolName: "Bash" },
356
+ { deviceId: "dev-x" }
357
+ );
358
+ assert.match(p2.event_id, /^[A-Za-z0-9._:-]+$/, `event_id 含非法字符:${p2.event_id}`);
359
+ assert.ok(p2.event_id.length <= 128);
360
+ });
361
+
362
+ test("㉒ start():服务端开启且已订阅时才会继续(门禁用 /api/me.notify.enabled)", async () => {
363
+ const fetchImpl = makeFetch([
364
+ { status: 200, body: { ok: true, notify: { enabled: true } } },
365
+ { status: 200, body: { ok: true, enabled: true, subscribed: true } }
366
+ ]);
367
+ const r = createPushReporter({
368
+ apiBase: "https://api.example.com",
369
+ deviceId: "dev-x",
370
+ tokenProvider: () => "tok",
371
+ upstream: "http://127.0.0.1:3080",
372
+ fetchImpl
373
+ });
374
+ const st = await r.start();
375
+ // 订阅器在测试环境没有真实 WS,启动可能失败;关键断言是"门禁通过了"(reason 不再是 disabled/no_subscription)
376
+ assert.notEqual(st.reason, "disabled");
377
+ assert.notEqual(st.reason, "no_subscription");
378
+ assert.equal(fetchImpl.calls[0].url, "https://api.example.com/api/me");
379
+ await r.stop();
380
+ });
package/dsh-setup.mjs CHANGED
@@ -2408,6 +2408,11 @@ async function setup(argv) {
2408
2408
  L.push(` 手机端: 打开 ${cfg.tunnel_url.replace(/^ws/, "https")}/app/ ,用访问密钥登录`);
2409
2409
  } else {
2410
2410
  L.push(` 远程访问地址: ${pub.app_url || DEFAULT_APP_URL}`);
2411
+ // 【0.7.10 / W1-T1】安装引导原来停在「注册/登录手机号」—— 用户以为到此结束,
2412
+ // 于是设备在线了、人却从没去过手机端(生产复盘:129 人 C1)。这里把**手机端下一步**一次说全,
2413
+ // 两条路都写清楚(扫码免登录 / 直接打开地址用同一手机号登录),任一都能到同一台电脑。
2414
+ L.push(" 手机端下一步: 用手机浏览器打开上面这个地址 → 用同一手机号登录 → 在「设备列表」里选这台电脑");
2415
+ L.push(" (或在电脑端登录后,回到 设置 → 「远程访问」,用手机扫那里的二维码:免登录直接进入,30 分钟有效)");
2411
2416
  }
2412
2417
  L.push(svcSkipped
2413
2418
  ? ` 自启动服务: ${svcState},可用 \`dsh-remote run\` 手动运行 bridge`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.9",
3
+ "version": "0.7.10-beta.10",
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",
@@ -56,7 +56,7 @@
56
56
  "test:plugin": "DSH_PROFILE_DIR= DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
57
57
  "_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
58
58
  "test:bridge": "DSH_PROFILE_DIR= DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 clients/dsh-remote/test/*.test.mjs",
59
- "check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
59
+ "check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/agent/index.mjs && node --check clients/dsh-remote/agent/version.mjs && node --check clients/dsh-remote/agent/detect.mjs && node --check clients/dsh-remote/agent/permission-bridge.mjs && node --check clients/dsh-remote/agent/proc.mjs && node --check clients/dsh-remote/agent/events.mjs && node --check clients/dsh-remote/agent/codex.mjs && node --check clients/dsh-remote/agent/gemini.mjs && node --check clients/dsh-remote/agent/opencode.mjs && node --check clients/dsh-remote/agent/agent-e2e-codex.mjs && node --check clients/dsh-remote/agent/agent-e2e-real.mjs && node --check clients/dsh-remote/push-reporter.mjs && node --check clients/dsh-remote/decision-poller.mjs && node --check clients/dsh-remote/approval-coordinator.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
60
60
  "release:tarballs": "bash scripts/release-tarballs.sh",
61
61
  "sync:local": "node scripts/sync-local.mjs"
62
62
  },