dsh-multi-chat 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-chat",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "DSH 多对话:在官方 DeepSeek Harness Web 界面里并排运行和监控多个对话实例(多窗口墙),并内置带口令认证的手机访问网关。npx dsh-multi-chat install|start|stop",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
@@ -119,6 +122,50 @@ cache-control: no-store\r
119
122
  socket.end();
120
123
  }
121
124
  /**
125
+ * Send a 302 that mints a session cookie and drops the `token` query param.
126
+ * A `?token=` URL authenticates exactly one request, so on first arrival the
127
+ * gateway exchanges it for an HMAC-signed cookie and redirects to the same
128
+ * path without `token=` (keeps the secret out of the address bar / history).
129
+ */
130
+ function respondSessionRedirect(socket, parsed, cookie) {
131
+ let location = parsed.target;
132
+ try {
133
+ const url = new URL(parsed.target, "http://gateway.local");
134
+ url.searchParams.delete("token");
135
+ location = url.pathname + url.search;
136
+ } catch {}
137
+ socket.write(`HTTP/1.1 302 Found\r
138
+ location: ${location}\r\nset-cookie: ${cookie}\r\ncontent-length: 0\r
139
+ connection: close\r
140
+ cache-control: no-store\r
141
+ \r
142
+ `);
143
+ socket.end();
144
+ }
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
+ /**
122
169
  * Rebuild the request head for the loopback target. Host and Origin point at
123
170
  * `127.0.0.1:<target-port>` so the DSH `/api` browser-trust fence sees a local
124
171
  * request. Non-upgrade requests get `connection: close` (fresh connection per
@@ -158,17 +205,16 @@ function startGateway(options) {
158
205
  return false;
159
206
  }
160
207
  };
161
- const isAuthed = (parsed) => {
208
+ const authKind = (parsed) => {
162
209
  const cookie = parsed.map.cookie ?? "";
163
210
  const session = /(?:^|;\s*)dsh_gw_session=([^;]+)/.exec(cookie);
164
- if (session !== null && verifySession(session[1] ?? "")) return true;
211
+ if (session !== null && verifySession(session[1] ?? "")) return "cookie";
165
212
  const bearer = parsed.map.authorization ?? "";
166
- if (bearer.startsWith("Bearer ") && bearer.slice(7) === token) return true;
213
+ if (bearer.startsWith("Bearer ") && bearer.slice(7) === token) return "bearer";
167
214
  try {
168
- return new URL(parsed.target, "http://gateway.local").searchParams.get("token") === token;
169
- } catch {
170
- return false;
171
- }
215
+ if (new URL(parsed.target, "http://gateway.local").searchParams.get("token") === token) return "query";
216
+ } catch {}
217
+ return "none";
172
218
  };
173
219
  const attempts = /* @__PURE__ */ new Map();
174
220
  const isBlocked = (ip) => {
@@ -238,6 +284,110 @@ connection: close\r
238
284
  socket.once("end", done);
239
285
  }
240
286
  };
287
+ /**
288
+ * Decode an HTTP/1.1 `transfer-encoding: chunked` body back to its raw
289
+ * bytes. Returns `null` on malformed framing (caller then relays verbatim).
290
+ */
291
+ const decodeChunked = (raw) => {
292
+ const out = [];
293
+ let i = 0;
294
+ const text = raw.toString("latin1");
295
+ while (true) {
296
+ const crlf = text.indexOf("\r\n", i);
297
+ if (crlf === -1) return null;
298
+ const sizeHex = text.slice(i, crlf).trim();
299
+ const size = parseInt(sizeHex, 16);
300
+ if (!Number.isFinite(size) || size < 0) return null;
301
+ i = crlf + 2;
302
+ if (size === 0) return Buffer.concat(out);
303
+ if (i + size > raw.length) return null;
304
+ out.push(raw.subarray(i, i + size));
305
+ i += size + 2;
306
+ }
307
+ };
308
+ /**
309
+ * Relay the DSH target's response to the phone client. HTML document
310
+ * responses (the DSH shell) are buffered so the `randomUUID` polyfill can be
311
+ * injected before the bootstrap script runs; every other response
312
+ * (assets, API JSON, SSE streams, WebSocket upgrades) is piped byte-for-byte
313
+ * so streaming and framing stay untouched.
314
+ */
315
+ const relayTarget = (socket, target) => {
316
+ let head = Buffer.alloc(0);
317
+ let bodyParts = null;
318
+ let relayed = false;
319
+ const pipeThrough = () => {
320
+ if (relayed) return;
321
+ relayed = true;
322
+ if (head.length > 0) socket.write(head);
323
+ if (bodyParts !== null && bodyParts.length > 0) {
324
+ socket.write(Buffer.concat(bodyParts));
325
+ bodyParts = null;
326
+ }
327
+ target.pipe(socket);
328
+ };
329
+ const finishInjection = (fullHead, fullBody) => {
330
+ const rewritten = injectPolyfill(fullBody);
331
+ if (rewritten === null) {
332
+ socket.write(fullHead);
333
+ socket.write(fullBody);
334
+ socket.end();
335
+ return;
336
+ }
337
+ const lines = fullHead.toString("latin1").split("\r\n");
338
+ const filtered = [];
339
+ for (const line of lines) {
340
+ const lower = line.toLowerCase();
341
+ if (lower.startsWith("transfer-encoding:")) continue;
342
+ if (lower.startsWith("content-length:")) continue;
343
+ if (lower.startsWith("connection:")) continue;
344
+ filtered.push(line);
345
+ }
346
+ while (filtered.length > 0 && filtered[filtered.length - 1] === "") filtered.pop();
347
+ filtered.push(`content-length: ${rewritten.length}`, "connection: close", "", "");
348
+ socket.write(Buffer.from(filtered.join("\r\n"), "latin1"));
349
+ socket.write(rewritten);
350
+ socket.end();
351
+ };
352
+ target.on("data", (chunk) => {
353
+ if (relayed) return;
354
+ if (head.indexOf("\r\n\r\n") === -1) {
355
+ head = head.length === 0 ? chunk : Buffer.concat([head, chunk]);
356
+ const newIdx = head.indexOf("\r\n\r\n");
357
+ if (newIdx === -1) {
358
+ if (head.length > 65536) pipeThrough();
359
+ return;
360
+ }
361
+ const headBuf = head.subarray(0, newIdx + 4);
362
+ const bodyStart = head.subarray(newIdx + 4);
363
+ const headText = headBuf.toString("latin1");
364
+ const status = /^HTTP\/\d\.\d (\d{3})/.exec(headText)?.[1] ?? "";
365
+ const contentType = /^content-type:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
366
+ const ce = /^content-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
367
+ const isHtml = status === "200" && /text\/html/i.test(contentType);
368
+ const compressed = ce !== "" && !/identity/i.test(ce);
369
+ if (!isHtml || compressed) {
370
+ pipeThrough();
371
+ return;
372
+ }
373
+ bodyParts = bodyStart.length > 0 ? [bodyStart] : [];
374
+ return;
375
+ }
376
+ if (bodyParts !== null) bodyParts.push(chunk);
377
+ });
378
+ target.once("end", () => {
379
+ if (relayed || bodyParts === null) {
380
+ if (!relayed) socket.end();
381
+ return;
382
+ }
383
+ const headBuf = head.subarray(0, head.indexOf("\r\n\r\n") + 4);
384
+ const headText = headBuf.toString("latin1");
385
+ const rawBody = Buffer.concat(bodyParts);
386
+ const te = /^transfer-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
387
+ finishInjection(headBuf, /chunked/i.test(te) ? decodeChunked(rawBody) ?? rawBody : rawBody);
388
+ });
389
+ target.once("error", () => socket.destroy());
390
+ };
241
391
  const handleSocket = (socket) => {
242
392
  let buffer = Buffer.alloc(0);
243
393
  let headDone = false;
@@ -262,10 +412,15 @@ connection: close\r
262
412
  handleLogin(socket, parsed, buffer.subarray(parsed.headBytes));
263
413
  return;
264
414
  }
265
- if (!isAuthed(parsed)) {
415
+ const kind = authKind(parsed);
416
+ if (kind === "none") {
266
417
  respondLogin(socket, name, path === "/" ? "/" : path);
267
418
  return;
268
419
  }
420
+ if (kind === "query") {
421
+ respondSessionRedirect(socket, parsed, `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 36e5)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`);
422
+ return;
423
+ }
269
424
  const target = connect(targetPort, "127.0.0.1");
270
425
  const rest = buffer.subarray(parsed.headBytes);
271
426
  let connected = false;
@@ -275,7 +430,7 @@ connection: close\r
275
430
  if (rest.length > 0) target.write(rest);
276
431
  proxying = true;
277
432
  socket.pipe(target);
278
- target.pipe(socket);
433
+ relayTarget(socket, target);
279
434
  });
280
435
  target.on("error", (error) => {
281
436
  if (!connected) respondPlain(socket, "502", `cannot reach target 127.0.0.1:${targetPort}: ${error.message}`);