dsh-multi-chat 0.5.1 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-chat",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "DSH 多对话:在官方 DeepSeek Harness Web 界面里并排运行和监控多个对话实例(多窗口墙),并内置带口令认证的手机访问网关。npx dsh-multi-chat install|start|stop",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -242,27 +242,27 @@ window.__ModuleLoader__.load({
242
242
  document.head.appendChild(tag);
243
243
  }
244
244
  var WallView_module_css_default = {
245
- "danger": "ouhzTG_danger",
246
- "action": "ouhzTG_action",
247
- "confirm": "ouhzTG_confirm",
248
- "field": "ouhzTG_field",
245
+ "paneActions": "ouhzTG_paneActions",
246
+ "linkBar": "ouhzTG_linkBar",
249
247
  "wall": "ouhzTG_wall",
250
- "grid": "ouhzTG_grid",
248
+ "action": "ouhzTG_action",
249
+ "danger": "ouhzTG_danger",
250
+ "paneBody": "ouhzTG_paneBody",
251
+ "title": "ouhzTG_title",
251
252
  "hint": "ouhzTG_hint",
253
+ "empty": "ouhzTG_empty",
254
+ "zoomed": "ouhzTG_zoomed",
255
+ "field": "ouhzTG_field",
256
+ "paneHead": "ouhzTG_paneHead",
252
257
  "status": "ouhzTG_status",
253
258
  "paneTitle": "ouhzTG_paneTitle",
254
- "controls": "ouhzTG_controls",
255
- "linkText": "ouhzTG_linkText",
256
- "paneHead": "ouhzTG_paneHead",
257
- "paneBody": "ouhzTG_paneBody",
258
- "empty": "ouhzTG_empty",
259
+ "grid": "ouhzTG_grid",
259
260
  "dot": "ouhzTG_dot",
260
- "toolbar": "ouhzTG_toolbar",
261
- "linkBar": "ouhzTG_linkBar",
262
- "title": "ouhzTG_title",
263
261
  "pane": "ouhzTG_pane",
264
- "paneActions": "ouhzTG_paneActions",
265
- "zoomed": "ouhzTG_zoomed"
262
+ "confirm": "ouhzTG_confirm",
263
+ "toolbar": "ouhzTG_toolbar",
264
+ "controls": "ouhzTG_controls",
265
+ "linkText": "ouhzTG_linkText"
266
266
  };
267
267
  //#endregion
268
268
  //#region src/client/WallView.tsx
@@ -294,6 +294,27 @@ window.__ModuleLoader__.load({
294
294
  /** Embed flag appended to every pane URL; such pages register no wall UI. */
295
295
  const EMBED_FLAG = "multi-wall=embed";
296
296
  /**
297
+ * Whether this wall is being viewed through the phone gateway (a non-loopback
298
+ * host) rather than on the machine running DSH. When remote, pane iframes must
299
+ * load through the gateway's `/gw/<port>` route — a phone's `127.0.0.1` points
300
+ * at the phone itself, not the host.
301
+ */
302
+ function isRemoteViewer() {
303
+ const host = typeof window !== "undefined" ? window.location.hostname : "";
304
+ return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
305
+ }
306
+ /**
307
+ * The iframe URL for a pane. Local viewers embed the loopback instance
308
+ * directly; remote (phone) viewers route through the gateway that is already
309
+ * serving this page, appending the port so the gateway proxies to it.
310
+ * @param port - target DSH instance port.
311
+ * @returns the pane URL.
312
+ */
313
+ function paneUrl(port) {
314
+ if (isRemoteViewer()) return `${window.location.origin}/gw/${port}/?${EMBED_FLAG}`;
315
+ return `http://127.0.0.1:${port}/?${EMBED_FLAG}`;
316
+ }
317
+ /**
297
318
  * One pane: header (port, liveness dot, zoom/refresh/open/stop/remove) plus
298
319
  * the embedded original DSH UI.
299
320
  */
@@ -338,7 +359,7 @@ window.__ModuleLoader__.load({
338
359
  className: WallView_module_css_default.action,
339
360
  title: t("openTab"),
340
361
  onClick: () => {
341
- window.open(`http://127.0.0.1:${port}/`, "_blank");
362
+ window.open(paneUrl(port), "_blank");
342
363
  },
343
364
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRightUpOutline16, { size: 14 })
344
365
  }),
@@ -363,7 +384,7 @@ window.__ModuleLoader__.load({
363
384
  className: WallView_module_css_default.paneBody,
364
385
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
365
386
  title: `DSH :${port}`,
366
- src: `http://127.0.0.1:${port}/?${EMBED_FLAG}`,
387
+ src: paneUrl(port),
367
388
  loading: "lazy"
368
389
  })
369
390
  })]
@@ -660,8 +681,8 @@ window.__ModuleLoader__.load({
660
681
  document.head.appendChild(tag);
661
682
  }
662
683
  var WallToggle_module_css_default = {
663
- "label": "bmjS6q_label",
664
- "row": "bmjS6q_row"
684
+ "row": "bmjS6q_row",
685
+ "label": "bmjS6q_label"
665
686
  };
666
687
  //#endregion
667
688
  //#region src/client/WallToggle.tsx
@@ -18,6 +18,9 @@ import { connect, createServer } from "node:net";
18
18
  * `127.0.0.1:<target-port>`, rewriting Host/Origin so the official `/api`
19
19
  * browser-trust fence treats it as a local request. WebSocket upgrades pass
20
20
  * through untouched because the socket is piped byte-for-byte after the head.
21
+ * The HTML document response is buffered once so a `crypto.randomUUID`
22
+ * polyfill can be injected for phones (insecure-origin HTTP); all other
23
+ * responses stream through unchanged.
21
24
  *
22
25
  * Zero dependencies: node:net + node:crypto only.
23
26
  * @module @deepseek-ai/dsh-client-ui-multi-wall/gateway
@@ -140,15 +143,40 @@ cache-control: no-store\r
140
143
  socket.end();
141
144
  }
142
145
  /**
146
+ * `crypto.randomUUID` exists only in a *secure context* (HTTPS or localhost).
147
+ * A phone reaching the instance via `http://<LAN-IP>:<gateway-port>` is an
148
+ * insecure origin, so the official DSH client throws
149
+ * `crypto.randomUUID is not a function` on its first RPC and renders blank.
150
+ * This polyfill supplies the same RFC-4122 v4 form using
151
+ * `crypto.getRandomValues`, which insecure origins still expose.
152
+ */
153
+ const POLYFILL_SCRIPT = "<script>(function(){if(typeof crypto!==\"undefined\"&&typeof crypto.randomUUID===\"undefined\"&&typeof crypto.getRandomValues===\"function\"){crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h=Array.prototype.map.call(b,function(x){return x.toString(16).padStart(2,\"0\")}).join(\"\");return h.slice(0,8)+\"-\"+h.slice(8,12)+\"-\"+h.slice(12,16)+\"-\"+h.slice(16,20)+\"-\"+h.slice(20);};}})();<\/script>";
154
+ /**
155
+ * Rewrite a buffered HTML response body to inject the `randomUUID` polyfill
156
+ * into the top of `<head>` (before the DSH bootstrap script runs). Returns the
157
+ * new body, or `null` when the polyfill is not needed (already present, or the
158
+ * response carries no `<head>` to inject into).
159
+ */
160
+ function injectPolyfill(body) {
161
+ const html = body.toString("utf8");
162
+ if (html.includes("randomUUID")) return null;
163
+ const headMatch = /<head[^>]*>/i.exec(html);
164
+ if (headMatch === null) return null;
165
+ const insertAt = headMatch.index + headMatch[0].length;
166
+ return Buffer.from(html.slice(0, insertAt) + POLYFILL_SCRIPT + html.slice(insertAt), "utf8");
167
+ }
168
+ /**
143
169
  * Rebuild the request head for the loopback target. Host and Origin point at
144
170
  * `127.0.0.1:<target-port>` so the DSH `/api` browser-trust fence sees a local
145
171
  * request. Non-upgrade requests get `connection: close` (fresh connection per
146
172
  * request keeps keep-alive from smuggling unrewritten Host headers); upgrades
147
- * (WebSocket) keep `connection: Upgrade`.
173
+ * (WebSocket) keep `connection: Upgrade`. When `pathOverride` is set (a
174
+ * `/gw/<port>` route), the request line's path becomes the de-prefixed path.
148
175
  */
149
- function rewriteHead(parsed, targetPort) {
176
+ function rewriteHead(parsed, targetPort, pathOverride) {
150
177
  const upgrade = parsed.map.upgrade !== void 0;
151
- const lines = [`${parsed.method} ${parsed.target} ${parsed.version}`];
178
+ const requestTarget = pathOverride !== void 0 ? pathOverride : parsed.target;
179
+ const lines = [`${parsed.method} ${requestTarget} ${parsed.version}`];
152
180
  for (const [name, value] of parsed.headers) if (name === "host") lines.push(`host: 127.0.0.1:${targetPort}`);
153
181
  else if (name === "origin") lines.push(`origin: http://127.0.0.1:${targetPort}`);
154
182
  else if (name === "connection" || name === "proxy-connection") {} else lines.push(`${name}: ${value}`);
@@ -161,7 +189,8 @@ function rewriteHead(parsed, targetPort) {
161
189
  * @returns a handle with the assigned port and token, and a close().
162
190
  */
163
191
  function startGateway(options) {
164
- const { targetPort, port: requestedPort, token, name, maxAgeHours = 12, log = () => {} } = options;
192
+ const { targetPort, port: requestedPort, token, name, maxAgeHours = 12, routedPorts, log = () => {} } = options;
193
+ const allowedRouted = new Set(routedPorts ?? []);
165
194
  const hmac = (data) => createHmac("sha256", token).update(data).digest("hex");
166
195
  const signSession = (exp) => {
167
196
  const payload = Buffer.from(JSON.stringify({ exp })).toString("base64url");
@@ -258,6 +287,110 @@ connection: close\r
258
287
  socket.once("end", done);
259
288
  }
260
289
  };
290
+ /**
291
+ * Decode an HTTP/1.1 `transfer-encoding: chunked` body back to its raw
292
+ * bytes. Returns `null` on malformed framing (caller then relays verbatim).
293
+ */
294
+ const decodeChunked = (raw) => {
295
+ const out = [];
296
+ let i = 0;
297
+ const text = raw.toString("latin1");
298
+ while (true) {
299
+ const crlf = text.indexOf("\r\n", i);
300
+ if (crlf === -1) return null;
301
+ const sizeHex = text.slice(i, crlf).trim();
302
+ const size = parseInt(sizeHex, 16);
303
+ if (!Number.isFinite(size) || size < 0) return null;
304
+ i = crlf + 2;
305
+ if (size === 0) return Buffer.concat(out);
306
+ if (i + size > raw.length) return null;
307
+ out.push(raw.subarray(i, i + size));
308
+ i += size + 2;
309
+ }
310
+ };
311
+ /**
312
+ * Relay the DSH target's response to the phone client. HTML document
313
+ * responses (the DSH shell) are buffered so the `randomUUID` polyfill can be
314
+ * injected before the bootstrap script runs; every other response
315
+ * (assets, API JSON, SSE streams, WebSocket upgrades) is piped byte-for-byte
316
+ * so streaming and framing stay untouched.
317
+ */
318
+ const relayTarget = (socket, target) => {
319
+ let head = Buffer.alloc(0);
320
+ let bodyParts = null;
321
+ let relayed = false;
322
+ const pipeThrough = () => {
323
+ if (relayed) return;
324
+ relayed = true;
325
+ if (head.length > 0) socket.write(head);
326
+ if (bodyParts !== null && bodyParts.length > 0) {
327
+ socket.write(Buffer.concat(bodyParts));
328
+ bodyParts = null;
329
+ }
330
+ target.pipe(socket);
331
+ };
332
+ const finishInjection = (fullHead, fullBody) => {
333
+ const rewritten = injectPolyfill(fullBody);
334
+ if (rewritten === null) {
335
+ socket.write(fullHead);
336
+ socket.write(fullBody);
337
+ socket.end();
338
+ return;
339
+ }
340
+ const lines = fullHead.toString("latin1").split("\r\n");
341
+ const filtered = [];
342
+ for (const line of lines) {
343
+ const lower = line.toLowerCase();
344
+ if (lower.startsWith("transfer-encoding:")) continue;
345
+ if (lower.startsWith("content-length:")) continue;
346
+ if (lower.startsWith("connection:")) continue;
347
+ filtered.push(line);
348
+ }
349
+ while (filtered.length > 0 && filtered[filtered.length - 1] === "") filtered.pop();
350
+ filtered.push(`content-length: ${rewritten.length}`, "connection: close", "", "");
351
+ socket.write(Buffer.from(filtered.join("\r\n"), "latin1"));
352
+ socket.write(rewritten);
353
+ socket.end();
354
+ };
355
+ target.on("data", (chunk) => {
356
+ if (relayed) return;
357
+ if (head.indexOf("\r\n\r\n") === -1) {
358
+ head = head.length === 0 ? chunk : Buffer.concat([head, chunk]);
359
+ const newIdx = head.indexOf("\r\n\r\n");
360
+ if (newIdx === -1) {
361
+ if (head.length > 65536) pipeThrough();
362
+ return;
363
+ }
364
+ const headBuf = head.subarray(0, newIdx + 4);
365
+ const bodyStart = head.subarray(newIdx + 4);
366
+ const headText = headBuf.toString("latin1");
367
+ const status = /^HTTP\/\d\.\d (\d{3})/.exec(headText)?.[1] ?? "";
368
+ const contentType = /^content-type:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
369
+ const ce = /^content-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
370
+ const isHtml = status === "200" && /text\/html/i.test(contentType);
371
+ const compressed = ce !== "" && !/identity/i.test(ce);
372
+ if (!isHtml || compressed) {
373
+ pipeThrough();
374
+ return;
375
+ }
376
+ bodyParts = bodyStart.length > 0 ? [bodyStart] : [];
377
+ return;
378
+ }
379
+ if (bodyParts !== null) bodyParts.push(chunk);
380
+ });
381
+ target.once("end", () => {
382
+ if (relayed || bodyParts === null) {
383
+ if (!relayed) socket.end();
384
+ return;
385
+ }
386
+ const headBuf = head.subarray(0, head.indexOf("\r\n\r\n") + 4);
387
+ const headText = headBuf.toString("latin1");
388
+ const rawBody = Buffer.concat(bodyParts);
389
+ const te = /^transfer-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
390
+ finishInjection(headBuf, /chunked/i.test(te) ? decodeChunked(rawBody) ?? rawBody : rawBody);
391
+ });
392
+ target.once("error", () => socket.destroy());
393
+ };
261
394
  const handleSocket = (socket) => {
262
395
  let buffer = Buffer.alloc(0);
263
396
  let headDone = false;
@@ -282,6 +415,19 @@ connection: close\r
282
415
  handleLogin(socket, parsed, buffer.subarray(parsed.headBytes));
283
416
  return;
284
417
  }
418
+ let routePort = null;
419
+ let routePath;
420
+ const routeMatch = /^\/gw\/(\d+)(\/.*)?$/.exec(path);
421
+ if (routeMatch !== null) {
422
+ const candidate = Number(routeMatch[1]);
423
+ if (!Number.isInteger(candidate) || candidate <= 0 || !allowedRouted.has(candidate)) {
424
+ respondPlain(socket, "403", "port not routable");
425
+ return;
426
+ }
427
+ routePort = candidate;
428
+ routePath = routeMatch[2] && routeMatch[2] !== "" ? routeMatch[2] : "/";
429
+ }
430
+ const effectivePort = routePort ?? targetPort;
285
431
  const kind = authKind(parsed);
286
432
  if (kind === "none") {
287
433
  respondLogin(socket, name, path === "/" ? "/" : path);
@@ -291,19 +437,19 @@ connection: close\r
291
437
  respondSessionRedirect(socket, parsed, `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 36e5)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`);
292
438
  return;
293
439
  }
294
- const target = connect(targetPort, "127.0.0.1");
440
+ const target = connect(effectivePort, "127.0.0.1");
295
441
  const rest = buffer.subarray(parsed.headBytes);
296
442
  let connected = false;
297
443
  target.on("connect", () => {
298
444
  connected = true;
299
- target.write(rewriteHead(parsed, targetPort));
445
+ target.write(rewriteHead(parsed, effectivePort, routePath));
300
446
  if (rest.length > 0) target.write(rest);
301
447
  proxying = true;
302
448
  socket.pipe(target);
303
- target.pipe(socket);
449
+ relayTarget(socket, target);
304
450
  });
305
451
  target.on("error", (error) => {
306
- if (!connected) respondPlain(socket, "502", `cannot reach target 127.0.0.1:${targetPort}: ${error.message}`);
452
+ if (!connected) respondPlain(socket, "502", `cannot reach target 127.0.0.1:${effectivePort}: ${error.message}`);
307
453
  socket.destroy();
308
454
  });
309
455
  socket.on("error", () => target.destroy());
@@ -687,7 +833,8 @@ function lanAddresses() {
687
833
  if (virtual) continue;
688
834
  candidates.push({
689
835
  address: iface.address,
690
- physical
836
+ physical,
837
+ virtual: false
691
838
  });
692
839
  }
693
840
  }
@@ -830,11 +977,15 @@ function apply(ctx, config = {}) {
830
977
  const token = config.gatewayToken && config.gatewayToken !== "" ? config.gatewayToken : randomBytes(6).toString("hex");
831
978
  const gatewayPort = config.gatewayPort && config.gatewayPort !== 0 ? config.gatewayPort : port + 5e3;
832
979
  gatewayTargetPort = port;
980
+ const routed = [];
981
+ for (let p = scanFrom; p <= scanTo; p++) routed.push(p);
982
+ for (const p of fixedPorts) if (!routed.includes(p)) routed.push(p);
833
983
  return startGateway({
834
984
  targetPort: port,
835
985
  port: gatewayPort,
836
986
  token,
837
987
  name: "DSH",
988
+ routedPorts: routed,
838
989
  log: (msg) => ctx.logger.info(`multi-wall gateway: ${msg}`)
839
990
  }).then((handle) => {
840
991
  gateway = handle;