dsh-multi-chat 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-chat",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "DSH 多对话:在官方 DeepSeek Harness Web 界面里并排运行和监控多个对话实例(多窗口墙),并内置带口令认证的手机访问网关。npx dsh-multi-chat install|start|stop",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,7 +6,7 @@ Multi-window wall plugin, browser half + node half: a grid of every running DSH
6
6
 
7
7
  The wall's business state is a single store (`dsh.multi-wall`): the discovered port list and the grid column count, persisted across view switches and reloads. Discovery, liveness, create, and stop all flow through the node half's read-only JSON routes — `/multi/api/ports` (auto-discovery, excluding nothing so the serving instance is also watchable), `/multi/api/status` (liveness of a specific port list), `/multi/api/stop` (terminate a chosen instance), `/multi/api/create` (start a fresh instance, surfacing the child's stderr on failure), and `/multi/api/link` (phone access).
8
8
 
9
- Phone/remote access: the official CLI forbids `--host 0.0.0.0` (it would expose remote code execution), so `/multi/api/link` lazily starts an **inline authenticated gateway** (raw `node:net` reverse proxy with an HMAC-signed session-cookie login, targets `127.0.0.1:<self-port>`, rewrites Host/Origin so the official `/api` browser-trust fence sees a local request, and passes WebSocket upgrades through). The route returns the LAN URLs plus the login token; the gateway falls back to an OS-assigned port when its intended port hits a Windows excluded range or is already bound.
9
+ Phone/remote access: the official CLI forbids `--host 0.0.0.0` (it would expose remote code execution), so `/multi/api/link` lazily starts an **inline authenticated gateway** (raw `node:net` reverse proxy with an HMAC-signed session-cookie login, targets `127.0.0.1:<self-port>`, rewrites Host/Origin so the official `/api` browser-trust fence sees a local request, and passes WebSocket upgrades through). The route returns the LAN URLs plus the login token. The returned addresses drop virtual NICs (VMware/VirtualBox/WSL/Docker/Hyper-V/VPN — unreachable from a phone) and put physical NICs (Wi-Fi/Ethernet) first; the gateway falls back to an OS-assigned port when its intended port hits a Windows excluded range or is already bound.
10
10
 
11
11
  The `/client` exports the plugin body (`apply`/`inject`), the `WallView`/`WallToggle` components, the wall store factory, and the injected probe-face types.
12
12
 
@@ -14,8 +14,8 @@ window.__ModuleLoader__.load({
14
14
  const zh = {
15
15
  "toggle": "多窗口",
16
16
  "toggle.aria": "打开或关闭多窗口",
17
- "view.multiWall": "多窗口墙",
18
- "overlay.title": "多窗口墙",
17
+ "view.multiWall": "多窗口",
18
+ "overlay.title": "多窗口",
19
19
  "overlay.close": "关闭",
20
20
  "scan": "发现实例",
21
21
  "scan.from": "起始端口",
@@ -25,7 +25,7 @@ window.__ModuleLoader__.load({
25
25
  "create.failed": "创建失败:{error}",
26
26
  "create.unknown": "未知原因",
27
27
  "exit": "退出",
28
- "exit.aria": "退出多窗口墙,返回对话",
28
+ "exit.aria": "退出多窗口,返回对话",
29
29
  "link": "手机访问",
30
30
  "link.aria": "获取手机访问链接",
31
31
  "link.fetching": "正在获取链接…",
@@ -60,8 +60,8 @@ window.__ModuleLoader__.load({
60
60
  const en = {
61
61
  "toggle": "Multi-Window",
62
62
  "toggle.aria": "Toggle multi-window",
63
- "view.multiWall": "Multi-Window Wall",
64
- "overlay.title": "Multi-Window Wall",
63
+ "view.multiWall": "Multi-Window",
64
+ "overlay.title": "Multi-Window",
65
65
  "overlay.close": "Close",
66
66
  "scan": "Discover",
67
67
  "scan.from": "Start port",
@@ -71,7 +71,7 @@ window.__ModuleLoader__.load({
71
71
  "create.failed": "Create failed: {error}",
72
72
  "create.unknown": "unknown reason",
73
73
  "exit": "Exit",
74
- "exit.aria": "Exit the wall and return to chat",
74
+ "exit.aria": "Exit multi-window and return to chat",
75
75
  "link": "Phone access",
76
76
  "link.aria": "Get phone access link",
77
77
  "link.fetching": "Fetching link…",
@@ -242,33 +242,33 @@ window.__ModuleLoader__.load({
242
242
  document.head.appendChild(tag);
243
243
  }
244
244
  var WallView_module_css_default = {
245
- "paneTitle": "ouhzTG_paneTitle",
246
- "hint": "ouhzTG_hint",
247
- "toolbar": "ouhzTG_toolbar",
245
+ "danger": "ouhzTG_danger",
246
+ "action": "ouhzTG_action",
248
247
  "confirm": "ouhzTG_confirm",
249
- "paneActions": "ouhzTG_paneActions",
250
- "linkBar": "ouhzTG_linkBar",
251
- "grid": "ouhzTG_grid",
252
- "paneHead": "ouhzTG_paneHead",
253
- "empty": "ouhzTG_empty",
254
- "title": "ouhzTG_title",
255
248
  "field": "ouhzTG_field",
256
- "controls": "ouhzTG_controls",
257
249
  "wall": "ouhzTG_wall",
258
- "zoomed": "ouhzTG_zoomed",
250
+ "grid": "ouhzTG_grid",
251
+ "hint": "ouhzTG_hint",
259
252
  "status": "ouhzTG_status",
260
- "pane": "ouhzTG_pane",
261
- "danger": "ouhzTG_danger",
262
- "paneBody": "ouhzTG_paneBody",
253
+ "paneTitle": "ouhzTG_paneTitle",
254
+ "controls": "ouhzTG_controls",
263
255
  "linkText": "ouhzTG_linkText",
256
+ "paneHead": "ouhzTG_paneHead",
257
+ "paneBody": "ouhzTG_paneBody",
258
+ "empty": "ouhzTG_empty",
264
259
  "dot": "ouhzTG_dot",
265
- "action": "ouhzTG_action"
260
+ "toolbar": "ouhzTG_toolbar",
261
+ "linkBar": "ouhzTG_linkBar",
262
+ "title": "ouhzTG_title",
263
+ "pane": "ouhzTG_pane",
264
+ "paneActions": "ouhzTG_paneActions",
265
+ "zoomed": "ouhzTG_zoomed"
266
266
  };
267
267
  //#endregion
268
268
  //#region src/client/WallView.tsx
269
269
  /**
270
270
  * WallView: the multi-window wall as a `conversation.view` ring entry. When
271
- * the header's "多窗口墙" tab is selected, the right panel swaps from the
271
+ * the header's "多窗口" tab is selected, the right panel swaps from the
272
272
  * chat to this view — a toolbar plus a grid of iframes, one pane per running
273
273
  * DSH instance (127.0.0.1:<port>). Pure additive UI; no existing slot is
274
274
  * replaced.
@@ -469,7 +469,10 @@ window.__ModuleLoader__.load({
469
469
  const copyFirstLink = async () => {
470
470
  if (linkInfo === null || linkInfo.lan.length === 0) return;
471
471
  try {
472
- await navigator.clipboard.writeText(linkInfo.lan[0] ?? "");
472
+ const base = linkInfo.lan[0] ?? "";
473
+ const token = linkInfo.token ?? "";
474
+ const url = token !== "" ? `${base}?token=${encodeURIComponent(token)}` : base;
475
+ await navigator.clipboard.writeText(url);
473
476
  setLinkCopied(true);
474
477
  } catch {
475
478
  setLinkCopied(false);
@@ -657,15 +660,15 @@ window.__ModuleLoader__.load({
657
660
  document.head.appendChild(tag);
658
661
  }
659
662
  var WallToggle_module_css_default = {
660
- "row": "bmjS6q_row",
661
- "label": "bmjS6q_label"
663
+ "label": "bmjS6q_label",
664
+ "row": "bmjS6q_row"
662
665
  };
663
666
  //#endregion
664
667
  //#region src/client/WallToggle.tsx
665
668
  /**
666
669
  * WallToggle: the sidebar-foot shortcut. Clicking it opens the wall view —
667
670
  * the `conversation.view` ring switches to the 'multi-wall' entry, which the
668
- * header renders as the "多窗口墙" tab. The click is a plain user-equivalent
671
+ * header renders as the "多窗口" tab. The click is a plain user-equivalent
669
672
  * activation: it finds the header's view-ring tab for this plugin's label and
670
673
  * clicks it, so the official view-ring state machine (the chat store's active
671
674
  * view field) performs the switch. No store is declared: the ring decides
@@ -119,6 +119,27 @@ cache-control: no-store\r
119
119
  socket.end();
120
120
  }
121
121
  /**
122
+ * Send a 302 that mints a session cookie and drops the `token` query param.
123
+ * A `?token=` URL authenticates exactly one request, so on first arrival the
124
+ * gateway exchanges it for an HMAC-signed cookie and redirects to the same
125
+ * path without `token=` (keeps the secret out of the address bar / history).
126
+ */
127
+ function respondSessionRedirect(socket, parsed, cookie) {
128
+ let location = parsed.target;
129
+ try {
130
+ const url = new URL(parsed.target, "http://gateway.local");
131
+ url.searchParams.delete("token");
132
+ location = url.pathname + url.search;
133
+ } catch {}
134
+ socket.write(`HTTP/1.1 302 Found\r
135
+ location: ${location}\r\nset-cookie: ${cookie}\r\ncontent-length: 0\r
136
+ connection: close\r
137
+ cache-control: no-store\r
138
+ \r
139
+ `);
140
+ socket.end();
141
+ }
142
+ /**
122
143
  * Rebuild the request head for the loopback target. Host and Origin point at
123
144
  * `127.0.0.1:<target-port>` so the DSH `/api` browser-trust fence sees a local
124
145
  * request. Non-upgrade requests get `connection: close` (fresh connection per
@@ -158,17 +179,16 @@ function startGateway(options) {
158
179
  return false;
159
180
  }
160
181
  };
161
- const isAuthed = (parsed) => {
182
+ const authKind = (parsed) => {
162
183
  const cookie = parsed.map.cookie ?? "";
163
184
  const session = /(?:^|;\s*)dsh_gw_session=([^;]+)/.exec(cookie);
164
- if (session !== null && verifySession(session[1] ?? "")) return true;
185
+ if (session !== null && verifySession(session[1] ?? "")) return "cookie";
165
186
  const bearer = parsed.map.authorization ?? "";
166
- if (bearer.startsWith("Bearer ") && bearer.slice(7) === token) return true;
187
+ if (bearer.startsWith("Bearer ") && bearer.slice(7) === token) return "bearer";
167
188
  try {
168
- return new URL(parsed.target, "http://gateway.local").searchParams.get("token") === token;
169
- } catch {
170
- return false;
171
- }
189
+ if (new URL(parsed.target, "http://gateway.local").searchParams.get("token") === token) return "query";
190
+ } catch {}
191
+ return "none";
172
192
  };
173
193
  const attempts = /* @__PURE__ */ new Map();
174
194
  const isBlocked = (ip) => {
@@ -262,10 +282,15 @@ connection: close\r
262
282
  handleLogin(socket, parsed, buffer.subarray(parsed.headBytes));
263
283
  return;
264
284
  }
265
- if (!isAuthed(parsed)) {
285
+ const kind = authKind(parsed);
286
+ if (kind === "none") {
266
287
  respondLogin(socket, name, path === "/" ? "/" : path);
267
288
  return;
268
289
  }
290
+ if (kind === "query") {
291
+ respondSessionRedirect(socket, parsed, `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 36e5)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`);
292
+ return;
293
+ }
269
294
  const target = connect(targetPort, "127.0.0.1");
270
295
  const rest = buffer.subarray(parsed.headBytes);
271
296
  let connected = false;
@@ -607,13 +632,67 @@ async function startInstance(launcher, port, timeoutMs = 2e4) {
607
632
  }
608
633
  }
609
634
  /**
635
+ * Interface-name patterns that mark a *virtual* NIC (VM bridge, WSL,
636
+ * Docker, Hyper-V, VPN adapters, Loopback Pseudo-Instance, etc.). These
637
+ * interfaces are never reachable from a phone on the same LAN, so they are
638
+ * dropped from the link list entirely.
639
+ */
640
+ const VIRTUAL_IFACE_PATTERNS = [
641
+ /vEthernet/i,
642
+ /vmware/i,
643
+ /virtualbox/i,
644
+ /^vbox/i,
645
+ /wsl/i,
646
+ /docker/i,
647
+ /hyper-v/i,
648
+ /tap-windows/i,
649
+ /^tap/i,
650
+ /^tun/i,
651
+ /ppp/i,
652
+ /loopback/i,
653
+ /apipa/i,
654
+ /^utun/i,
655
+ /^awdl/i,
656
+ /^llw/i,
657
+ /^bridge/i,
658
+ /bluetooth/i
659
+ ];
660
+ /**
661
+ * Interface-name patterns that mark a *physical* NIC (Wi-Fi / Ethernet).
662
+ * Matches kept addresses are ordered before any unknown-but-surviving
663
+ * address so the phone-first address is the machine's real NIC.
664
+ */
665
+ const PHYSICAL_IFACE_PATTERNS = [
666
+ /^(wi-?fi|wlan|wireless)/i,
667
+ /^(eth(ernet)?|以太网|以太)/i,
668
+ /^(en|wan)[0-9]/i,
669
+ /^本地连接/i,
670
+ /^e[0-9]+$/i,
671
+ /^w[0-9]+$/i
672
+ ];
673
+ /**
610
674
  * The non-loopback IPv4 addresses of this machine (the LAN reachable URLs).
675
+ * Virtual NICs (VM/WSL/Docker/VPN/loopback pseudo) are filtered out; the
676
+ * remaining addresses are ordered with physical NICs (Wi-Fi/Ethernet) first
677
+ * so the phone shows the actually-reachable LAN address at the top.
611
678
  * @returns the address list (possibly empty).
612
679
  */
613
680
  function lanAddresses() {
614
- const out = [];
615
- for (const ifaces of Object.values(networkInterfaces())) for (const iface of ifaces ?? []) if (iface.family === "IPv4" && !iface.internal) out.push(iface.address);
616
- return out;
681
+ const candidates = [];
682
+ for (const [name, ifaces] of Object.entries(networkInterfaces())) {
683
+ const virtual = VIRTUAL_IFACE_PATTERNS.some((re) => re.test(name));
684
+ const physical = !virtual && PHYSICAL_IFACE_PATTERNS.some((re) => re.test(name));
685
+ for (const iface of ifaces ?? []) {
686
+ if (iface.family !== "IPv4" || iface.internal) continue;
687
+ if (virtual) continue;
688
+ candidates.push({
689
+ address: iface.address,
690
+ physical
691
+ });
692
+ }
693
+ }
694
+ candidates.sort((a, b) => Number(b.physical) - Number(a.physical));
695
+ return candidates.map((c) => c.address);
617
696
  }
618
697
  /**
619
698
  * Register the probe routes. Everything lives under `/multi/api` so the