@dsh-remote/relay 3.1.2 → 3.1.4

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @dsh-remote/relay
2
2
 
3
3
  DSH Remote Control 的**零知识 WebSocket 中继**:在宿主插件与已配对的手机之间转发**密封记录**,
4
- 只认帧名 / 会话 id / 能力协商,**从不解析载荷**——密文对它就是一个 base64 字符串。
4
+ 只认帧名 / 会话 id / 协议版本闸,**从不解析载荷**——密文对它就是一个 base64 字符串。
5
5
 
6
6
  生产形态是**单文件产物** `dist/bundle/main.js`(`ws` / `zod` / 协议层全部内联,运行时零依赖);
7
7
  部署只有两种:拷这个单文件 + 起进程(systemd / docker / nginx 配方见下),或从源码构建。
@@ -34,12 +34,12 @@ curl -s http://127.0.0.1:8787/healthz
34
34
 
35
35
  ### 端点
36
36
 
37
- | 路径 | 说明 |
38
- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
- | `GET /healthz` | 运维契约(**二十三个字段**):`ok` / `version` / `uptimeSec` / `hosts` / `clients` / `conversations` / `pendingPairs` / `droppedFrames` / `slowConsumers` / `rejectedPairs` / `shutdownForced` / `persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` / `stateWriteFailures` / `lastPingAgo` / `shuttingDown` / `protocolSelf` / `protocolMin` / `peerProtocolMin` / `peerProtocolMax` / `peersNoProtocol` |
40
- | `GET /api/info` | `{"publicUrl","protocol"}` |
41
- | `GET /api/pair-status` | **默认 404**。无需认证地回答"配对码 N 是否有效"等于给 6 位码空间装了扫描 oracle;确需调试时 `DRC_PAIR_STATUS=1`,用完关掉 |
42
- | WebSocket | 路径不设限,任意路径都能升级 |
37
+ | 路径 | 说明 |
38
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
+ | `GET /healthz` | 运维契约(**二十四个字段**):`ok` / `version` / `uptimeSec` / `hosts` / `clients` / `conversations` / `pendingPairs` / `hostTokens` / `droppedFrames` / `slowConsumers` / `rejectedPairs` / `shutdownForced` / `persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` / `stateWriteFailures` / `lastPingAgo` / `shuttingDown` / `protocolSelf` / `protocolMin` / `peerProtocolMin` / `peerProtocolMax` / `peersNoProtocol` |
40
+ | `GET /api/info` | `{"publicUrl","protocol"}` |
41
+ | `GET /api/pair-status` | **默认 404**。无需认证地回答"配对码 N 是否有效"等于给 6 位码空间装了扫描 oracle;确需调试时 `DRC_PAIR_STATUS=1`,用完关掉 |
42
+ | WebSocket | 路径不设限,任意路径都能升级 |
43
43
 
44
44
  落盘五字段(`persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` /
45
45
  `stateWriteFailures`)是"还在落盘"的唯一读数——换机器先看 `stateWriteFailures`,不是 0 就是没在写。
package/deploy/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  于是生产只有"拷一个文件 + 一个 env 文件",没有 `npm install`,也就没有
21
21
  "服务器上的依赖树和 CI 不一样"这一类故障。
22
22
 
23
- > 完整的环境变量表、日志契约、重启语义、升级回滚见 [`../docs/SELF-HOSTING.md`](../docs/SELF-HOSTING.md)。
23
+ > 完整的环境变量表、日志契约、重启语义、升级见 [`../docs/SELF-HOSTING.md`](../docs/SELF-HOSTING.md)。
24
24
 
25
25
  ## 从零复现
26
26
 
@@ -57,20 +57,17 @@ docker compose … logs relay | jq 'select(.msg=="counters")'
57
57
  json-file 轮转(`max-size: 10m` / `max-file: 5`)。要长期留存就装一个日志驱动转发出去,
58
58
  别依赖 `docker logs`。
59
59
 
60
- ## 升级与回滚
60
+ ## 升级
61
61
 
62
62
  ```sh
63
63
  # 升级:构建 → 重启 → 看版本号变了没有
64
64
  docker compose -f deploy/docker/compose.yaml up -d --build
65
65
  curl -s http://127.0.0.1:8787/healthz | grep -o '"version":"[^"]*"'
66
-
67
- # 回滚:把标签切回去再 up(镜像还在本地)
68
- docker tag dsh-remote-relay:2.0.14 dsh-remote-relay:2.0.15
69
- docker compose -f deploy/docker/compose.yaml up -d
70
66
  ```
71
67
 
72
- **回滚必须成对**(中继产物 + 主机插件产物):两代握手帧名不兼容,只回滚一边会
73
- "永远配不上对"或"静默不上线"。见 `../SELF-HOSTING.md` §10.2 的对照表。
68
+ 三端(中继 / 宿主插件 / 小程序)必须升到**同一协议版本**:落后的一端会在 `hello` 那一刻
69
+ 被明确拒掉(`unsupported_protocol`)。⚠️ 2026-10-11 晚:原来这里还写着一套"成对回滚 +
70
+ 两代兼容矩阵",随能力位与版本协商机制一起删掉了 —— 没有老用户,不需要回滚路径。
74
71
 
75
72
  ## 拉不到 Docker Hub 怎么办
76
73
 
@@ -23394,7 +23394,7 @@ function date4(params) {
23394
23394
  return _coercedDate(ZodDate, params);
23395
23395
  }
23396
23396
 
23397
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/limits.js
23397
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/limits.js
23398
23398
  var MAX_RELAY_MESSAGE_BYTES = 1024 * 1024;
23399
23399
  var MAX_CIPHERTEXT_BYTES = MAX_RELAY_MESSAGE_BYTES - 8 * 1024;
23400
23400
  var MAX_IMAGE_ATTACHMENTS = 4;
@@ -23403,13 +23403,11 @@ var MAX_ATTACHMENT_BYTES = 512 * 1024;
23403
23403
  var MAX_ATTACHMENT_TOTAL_BYTES = 512 * 1024;
23404
23404
  var IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
23405
23405
 
23406
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/frames.js
23406
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/frames.js
23407
23407
  var PROTOCOL_VERSION = 1;
23408
- var MIN_SUPPORTED_PROTOCOL = 1;
23409
23408
  var PAIRING_TOKEN_RE = /^\d{6}$/;
23410
23409
  var nonEmpty = external_exports.string().min(1);
23411
23410
  var pairingToken = external_exports.string().regex(PAIRING_TOKEN_RE);
23412
- var capabilityList = external_exports.array(external_exports.string().min(1).max(64)).max(64);
23413
23411
  var base64Text = external_exports.string().min(1).regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/);
23414
23412
  var clientMeta = external_exports.object({
23415
23413
  /**
@@ -23424,12 +23422,8 @@ var clientMeta = external_exports.object({
23424
23422
  var helloFrame = external_exports.object({
23425
23423
  t: external_exports.literal("hello"),
23426
23424
  role: external_exports.enum(["host", "client"]),
23427
- protocol: external_exports.number().int().positive().optional(),
23428
- /**
23429
- * 本端支持的能力(规范 §5.3)。**可选**:老对端不带,老中继也照旧工作——
23430
- * 缺省按"只支持基线"处理,而不是按"什么都不支持"拒绝。
23431
- */
23432
- capabilities: capabilityList.optional(),
23425
+ /** **必填**:两端必须同版本(单版本制,见 `negotiate.ts`)。 */
23426
+ protocol: external_exports.number().int().positive(),
23433
23427
  /** host 的出站认证凭据,与中继的 `DRC_HOST_TOKEN` 定时安全比较。上限防超长串白占内存。 */
23434
23428
  token: external_exports.string().min(1).max(512).optional(),
23435
23429
  hostId: nonEmpty.optional(),
@@ -23494,9 +23488,7 @@ var helloOkFrame = external_exports.object({
23494
23488
  /** 权威 clientId:小程序会采纳它并覆盖本地安装 id(F2/§3.6)。 */
23495
23489
  clientId: external_exports.string().min(1).max(128).optional(),
23496
23490
  hostId: nonEmpty.optional(),
23497
- protocol: external_exports.number().int().positive().optional(),
23498
- /** 中继自己支持的能力(规范 §5.3)。端点据此知道自己能向它要什么。 */
23499
- capabilities: capabilityList.optional()
23491
+ protocol: external_exports.number().int().positive()
23500
23492
  });
23501
23493
  var pairReadyFrame = external_exports.object({
23502
23494
  t: external_exports.literal("pair-ready"),
@@ -23541,11 +23533,11 @@ var pairFailFrame = external_exports.object({
23541
23533
  * 症状不是"多扫一次码":B 的 PSK 被丢弃意味着那条配对通道作废,
23542
23534
  * 而这正是文件头引用的那起「取错 PSK 全线解不开」事故的前置条件。
23543
23535
  *
23544
- * ## 兼容
23536
+ * ## 为什么可选
23545
23537
  *
23546
- * **可选**:更老的中继不发它,主机侧必须继续按"当前展示的那张"兜底
23547
- * (`onPairFail` 的注释里写着这条兜底的原因)。发它的是中继、读它的是主机,
23548
- * 同一时刻两端版本必然一致——所以"新主机 + 老中继"才是需要兜底的那一侧。
23538
+ * **中继不总能知道是哪一张**:帧本身坏掉那条分支(`server.ts` 里 `bad_frame` 那条)
23539
+ * 手上没有 token 可点名。缺省时主机按"当前展示的那张"兜底
23540
+ * (`onPairFail` 的注释里写着这条兜底的原因)。
23549
23541
  */
23550
23542
  pairingToken: external_exports.string().min(1).max(16).optional()
23551
23543
  });
@@ -23568,11 +23560,12 @@ var peerLeftFrame = external_exports.object({
23568
23560
  * **再也不会带这个 convId 回来**。主机留着这条会话就是一条永远清不掉的幽灵。
23569
23561
  * - 手机 socket 断了(切后台、断网):D3 要求会话留着,回前台还要用它。
23570
23562
  *
23571
- * 不带这个标记 = 断线(老中继行为,保持 D3);带 true = 主动解配,主机应当把
23572
- * 会话一并作废,于是 pill 从「手机离线」回到「未配对」(2026-10-05 用户报:
23573
- * 「mp 端解除配对,dsh 端执行的是手机离线」)。
23563
+ * 不带这个标记 = **断线**(D3:会话留着,回前台还要用);带 true = 主动解配,
23564
+ * 主机应当把会话一并作废,于是 pill 从「手机离线」回到「未配对」
23565
+ * (2026-10-05 用户报:「mp 端解除配对,dsh 端执行的是手机离线」)。
23574
23566
  *
23575
- * 可选是刻意的:老中继不会带这个字段,主机必须仍按「断线」处理,不能假定。
23567
+ * 可选是**语义**上的:它表达"这次离开是断线还是解配",缺省必须是断线
23568
+ * (主机不许假定是解配 —— 那会把用户的一条会话凭空作废)。
23576
23569
  */
23577
23570
  unpaired: external_exports.boolean().optional()
23578
23571
  });
@@ -23609,12 +23602,12 @@ var errorCodes = [
23609
23602
  */
23610
23603
  "history_window_exceeded",
23611
23604
  /**
23612
- * 对端的对端版本落在 `[MIN_SUPPORTED_PROTOCOL, 本端]` 之外(规范 §5.2 V3)。
23605
+ * 对端报的协议版本与本端**不相等**(含没报,规范 §5.2 V3)。
23613
23606
  *
23614
23607
  * 为什么不复用 `bad_frame`:它是**版本问题**而不是形状问题,而它唯一该触发的
23615
23608
  * 动作是"把版本说清楚"——一句"帧形状非法"会让用户去查一个根本不存在的问题。
23616
- * 加它不破坏任何老实现:老 client 见到不认识的码会走兜底展示(`message || code`),
23617
- * 所以中继 MUST 同时带上一句中文 message(规范 §12.3 E1)。
23609
+ * 不认识的码在客户端会走兜底展示(`message || code`),所以中继 MUST 同时带上一句
23610
+ * 中文 message(规范 §12.3 E1)—— 反过来,"没带 message"就是把英文码弹给用户。
23618
23611
  */
23619
23612
  "unsupported_protocol",
23620
23613
  "internal"
@@ -23725,7 +23718,19 @@ var BackpressureGate = class {
23725
23718
  var LOG_LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: 99 };
23726
23719
  var levelWeight = (level) => LOG_LEVELS[level];
23727
23720
  var DECIMAL_INTEGER_RE = /^[0-9]+$/;
23728
- function integer2(raw, fallback, name, problems) {
23721
+ function claimMinIntervalMs(env, problems) {
23722
+ const value = integer2(env.DRC_CLAIM_MIN_INTERVAL_MS, 4e3, "DRC_CLAIM_MIN_INTERVAL_MS", problems, {
23723
+ allowZero: true
23724
+ });
23725
+ if (value === 0) {
23726
+ problems.push({
23727
+ level: "warn",
23728
+ message: "DRC_CLAIM_MIN_INTERVAL_MS=0\uFF1A\u8BA4\u9886\u63A5\u53E3\u7684\u9650\u9891**\u5DF2\u5173\u95ED**\uFF08\u540C IP \u53EF\u4EE5\u65E0\u9650\u8FDE\u6253\uFF09\u3002\u53EA\u5728\u6D4B\u8BD5\u6216\u5B8C\u5168\u5185\u7F51\u81EA\u7528\u65F6\u8FD9\u4E48\u8BBE\uFF1B\u516C\u7F51\u4E0A\u7684\u8FD9\u53F0\u4E2D\u7EE7\u8BF7\u7559 4000\u3002"
23729
+ });
23730
+ }
23731
+ return value;
23732
+ }
23733
+ function integer2(raw, fallback, name, problems, options = {}) {
23729
23734
  if (raw === void 0) return fallback;
23730
23735
  if (raw === "") {
23731
23736
  problems.push({
@@ -23739,8 +23744,9 @@ function integer2(raw, fallback, name, problems) {
23739
23744
  return fallback;
23740
23745
  }
23741
23746
  const n = Number(raw.trim());
23742
- if (!Number.isSafeInteger(n) || n <= 0) {
23743
- problems.push({ level: "error", message: `${name} \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF0C\u6536\u5230 ${JSON.stringify(raw)}` });
23747
+ if (!Number.isSafeInteger(n) || n < 0 || n === 0 && options.allowZero !== true) {
23748
+ const shape = options.allowZero === true ? "\u975E\u8D1F\u6574\u6570" : "\u6B63\u6574\u6570";
23749
+ problems.push({ level: "error", message: `${name} \u5FC5\u987B\u662F${shape}\uFF0C\u6536\u5230 ${JSON.stringify(raw)}` });
23744
23750
  return fallback;
23745
23751
  }
23746
23752
  return n;
@@ -23804,6 +23810,8 @@ function loadConfig(env, version2) {
23804
23810
  pairGlobalBudgetPerSec: integer2(env.DRC_PAIR_GLOBAL_PER_SEC, 20, "DRC_PAIR_GLOBAL_PER_SEC", problems),
23805
23811
  pairStatusBudgetPerSec: integer2(env.DRC_PAIR_STATUS_PER_SEC, 5, "DRC_PAIR_STATUS_PER_SEC", problems),
23806
23812
  maxPendingPairs: integer2(env.DRC_MAX_PENDING_PAIRS, 1e3, "DRC_MAX_PENDING_PAIRS", problems),
23813
+ maxHostTokens: integer2(env.DRC_MAX_HOST_TOKENS, 500, "DRC_MAX_HOST_TOKENS", problems),
23814
+ claimMinIntervalMs: claimMinIntervalMs(env, problems),
23807
23815
  conversationIdleTtlMs: integer2(env.DRC_CONV_IDLE_TTL_MS, 7 * 24 * 3600 * 1e3, "DRC_CONV_IDLE_TTL_MS", problems),
23808
23816
  // P2-⑤(2026-10-04 用户拍板 30 分钟):远小于 7 天的空闲 TTL。
23809
23817
  // 删的代价是手机再扫一次码;不删的代价是 conversations 计数虚高、排障对不上。
@@ -23867,7 +23875,7 @@ var import_subprotocol = __toESM(require_subprotocol(), 1);
23867
23875
  var import_websocket = __toESM(require_websocket(), 1);
23868
23876
  var import_websocket_server = __toESM(require_websocket_server(), 1);
23869
23877
 
23870
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/identity/ids.js
23878
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/identity/ids.js
23871
23879
  import { randomBytes, randomUUID } from "node:crypto";
23872
23880
  var CONVERSATION_ID_PREFIX = "c_";
23873
23881
  function newConversationId() {
@@ -23877,10 +23885,52 @@ function newHostId() {
23877
23885
  return randomBytes(4).toString("hex");
23878
23886
  }
23879
23887
 
23880
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/platform/sleep.js
23888
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
23889
+ function negotiateProtocol(peerProtocol, own2 = PROTOCOL_VERSION) {
23890
+ if (peerProtocol === void 0 || peerProtocol === null) {
23891
+ return {
23892
+ ok: false,
23893
+ reason: "missing",
23894
+ peer: peerProtocol,
23895
+ message: `\u5BF9\u65B9\u6CA1\u6709\u62A5\u534F\u8BAE\u7248\u672C\uFF08\u672C\u7AEF\u4E3A ${own2}\uFF09\uFF1A\u628A\u5BF9\u7AEF\u5347\u5230\u540C\u4E00\u7248\u672C\u518D\u8BD5`
23896
+ };
23897
+ }
23898
+ if (typeof peerProtocol !== "number" || !Number.isInteger(peerProtocol) || peerProtocol <= 0) {
23899
+ return {
23900
+ ok: false,
23901
+ reason: "invalid",
23902
+ peer: peerProtocol,
23903
+ message: `\u5BF9\u65B9\u62A5\u544A\u7684\u534F\u8BAE\u7248\u672C\u4E0D\u662F\u6B63\u6574\u6570\uFF08\u6536\u5230 ${JSON.stringify(peerProtocol) ?? "undefined"}\uFF09`
23904
+ };
23905
+ }
23906
+ if (peerProtocol !== own2) {
23907
+ return {
23908
+ ok: false,
23909
+ reason: "mismatch",
23910
+ peer: peerProtocol,
23911
+ /**
23912
+ * ⚠️ 这句话**会被原样弹到用户手机上**(中继 `acceptProtocol` 显式带 message,
23913
+ * 而客户端的处理是 `f.message || f.code`)。所以人称必须从**读它的人**出发:
23914
+ * 收这条错的那端才是要被升级的那端。写"请把本端升级"时,手机用户读到的是
23915
+ * "请升级我的手机",而真正落后的是中继/主机那一侧 —— 两个方向都试过,
23916
+ * 都会指错人。⇒ 只陈述事实(本端 X、对方 Y),由人来判断升哪一端。
23917
+ */
23918
+ message: `\u534F\u8BAE\u7248\u672C\u5BF9\u4E0D\u4E0A\uFF1A\u8FD9\u4E00\u7AEF\u662F ${own2}\uFF0C\u5BF9\u65B9\u662F ${peerProtocol}\u3002\u4E24\u7AEF\u5FC5\u987B\u662F\u540C\u4E00\u7248\u672C`
23919
+ };
23920
+ }
23921
+ return { ok: true, version: peerProtocol };
23922
+ }
23923
+
23924
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
23925
+ var WAIT_HINTABLE = /* @__PURE__ */ new Set(["rate_limited", "pair_table_full"]);
23926
+ function wantsRetryAfter(code) {
23927
+ return typeof code === "string" && WAIT_HINTABLE.has(code);
23928
+ }
23929
+
23930
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/platform/sleep.js
23881
23931
  var SLEEP_BACKENDS = ["caffeinate", "systemd-inhibit", "unsupported"];
23882
23932
 
23883
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/payloads.js
23933
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/payloads.js
23884
23934
  var SESSION_STATES = [
23885
23935
  "idle",
23886
23936
  "running",
@@ -23950,9 +24000,18 @@ var evPermissionRequest = external_exports.object({
23950
24000
  var evPermissionResolved = external_exports.object({
23951
24001
  t: external_exports.literal("ev.permission_resolved"),
23952
24002
  requestId: nonEmpty2,
23953
- sessionId: nonEmpty2.optional(),
23954
- /** 谁收的场。手机只按"要不要收掉这张卡"读它,所以不做必填。 */
23955
- by: external_exports.enum(["desktop", "cancelled"]).optional()
24003
+ /**
24004
+ * ⚠️ **必填**(2026-10-11 晚):句柄在生产端(`runtime.ts` 的 `voidStaleCard`)
24005
+ * 从来都是给全的,而"可选"在这里只有一个后果 —— 一次串台的收场会把别的会话的卡收掉。
24006
+ */
24007
+ sessionId: nonEmpty2,
24008
+ /**
24009
+ * 谁收的场。
24010
+ *
24011
+ * ⚠️ **必填**(2026-10-11 晚):原来写的是"手机只按'要不要收掉这张卡'读它,所以不做必填",
24012
+ * 而生产端一直都给 —— 形状只有一种,那就不留"缺了也算合法"的第二条路。
24013
+ */
24014
+ by: external_exports.enum(["desktop", "cancelled"])
23956
24015
  });
23957
24016
  var questionItem = external_exports.object({
23958
24017
  id: nonEmpty2,
@@ -23969,19 +24028,26 @@ var evQuestionRequest = external_exports.object({
23969
24028
  /**
23970
24029
  * 这张提问卡什么时候作废(ISO 字符串)。
23971
24030
  *
23972
- * 为什么审批有而提问没有过:主机侧的提问等待本来就带超时(`questionTimeoutMs`,默认 300s),
23973
- * 但手机上那张卡**没有任何倒计时**——用户看不见自己按的按钮什么时候失效,
23974
- * 而这违反伞仓 `docs/PRODUCT.md` §3 第 3 条「超时必须可见」。
23975
- * 与审批那条同一个形状:可选、ISO 字符串、由主机每次发问时现算。
24031
+ * ⚠️ **2026-10-11 起:这个字段由宿主决定,我们不自己算。**
24032
+ *
24033
+ * 主机侧追问题时**带不带期限,由那一代内核说了算**(实测它给的是"不带")。
24034
+ * 带来就填、不带就**省掉这个键** —— 手机据此决定要不要起倒计时,
24035
+ * 而它只在主机真的会到点结算时才有意义。
24036
+ *
24037
+ * 曾经这里写的是"主机侧的提问等待本来就带超时(默认 300s)",而那个上限是
24038
+ * **插件自己加的**(`approvalTimeoutSec` 那个旋钮同批删除)。
24039
+ * 它造成过真实故障:页面退后台 → 事件退订 → 卡帧到了 client 没到界面 →
24040
+ * 用户回到页面时主机早已判超时,于是"手机上点不动"。
23976
24041
  */
23977
24042
  expiresAt: external_exports.string().optional()
23978
24043
  });
23979
24044
  var evQuestionResolved = external_exports.object({
23980
24045
  t: external_exports.literal("ev.question_resolved"),
23981
24046
  requestId: nonEmpty2,
23982
- sessionId: nonEmpty2.optional(),
23983
- /** 谁收的场。手机只按"要不要收掉这张卡"读它,所以不做必填。 */
23984
- by: external_exports.enum(["desktop", "cancelled"]).optional()
24047
+ /** 必填,理由同 `evPermissionResolved.sessionId`(收场必须精确到会话)。 */
24048
+ sessionId: nonEmpty2,
24049
+ /** 必填,理由同 `evPermissionResolved.by`。 */
24050
+ by: external_exports.enum(["desktop", "cancelled"])
23985
24051
  });
23986
24052
  var evRunState = external_exports.object({
23987
24053
  t: external_exports.literal("ev.run_state"),
@@ -24039,8 +24105,8 @@ var evHostInfo = external_exports.object({
24039
24105
  t: external_exports.literal("ev.host_info"),
24040
24106
  /**
24041
24107
  * 主机自述。这里**没有** `sessionId`,将来也不许加(与 `ev.session_changed` /
24042
- * `ev.keep_awake_state` 同一条 F11):能力面是主机级的,按会话分装会把
24043
- * "这台主机支持什么"变成"这条通道支持什么",而手机只想知道前者。
24108
+ * `ev.keep_awake_state` 同一条 F11):限额是主机级的,按会话分装会把
24109
+ * "这台主机能收多少"变成"这条通道能收多少",而手机只想知道前者。
24044
24110
  */
24045
24111
  host: external_exports.object({
24046
24112
  platform: nonEmpty2,
@@ -24051,8 +24117,6 @@ var evHostInfo = external_exports.object({
24051
24117
  carrier: external_exports.string().optional(),
24052
24118
  dshVersion: external_exports.string().optional()
24053
24119
  }),
24054
- /** §17.4 的能力 id 列表。生产端取自 `CAPABILITY_IDS`(L1)。 */
24055
- caps: capabilityList,
24056
24120
  limits: hostLimits,
24057
24121
  state: external_exports.object({
24058
24122
  /** 防休眠走哪条系统后端;`unsupported` 是如实上报,不是错误。 */
@@ -24076,7 +24140,8 @@ var evModel = external_exports.object({
24076
24140
  * 模型是**按会话**的,不带 sessionId 就无法过滤——任何一条别的会话的
24077
24141
  * 模型帧都会覆盖本会话的显示。
24078
24142
  *
24079
- * 必填:老主机不带这个字段时 mp 端宁可忽略这一帧,也不显示错的。
24143
+ * **必填**(2026-10-11 晚):不带会话归属的模型帧在 mp 端一律丢弃 ——
24144
+ * 宁可这一帧不算数,也不能把别的会话的模型名显示到本会话顶栏。
24080
24145
  */
24081
24146
  sessionId: nonEmpty2,
24082
24147
  provider: nonEmpty2.optional(),
@@ -24111,8 +24176,9 @@ var evSessionHistory = external_exports.object({
24111
24176
  * 而空页没有任何 seq 可取——于是手机要么记一个陈旧的值(下次重复收到已看过的条目),
24112
24177
  * 要么记 0(下次把整段历史重拉一遍)。这个字段让"你现在到哪了"始终有答案。
24113
24178
  *
24114
- * ⚠️ 它**不是**能力位那样的"可选支持":老主机不发这个字段时,手机看到 `undefined`
24115
- * 就退回 v2 的重拉一页(能力位 `drc.payload.resume` 决定走哪条路)。
24179
+ * ⚠️ 可选的含义是"**这一页报不出游标**"(内核这一代给不出来),不是"对端可能不支持":
24180
+ * 手机拿到 `undefined` 就按"已经到顶"收工(`client.js` 的 `next === null` 那一支),
24181
+ * 不记一个错的游标。(原来这里还提能力位 `drc.payload.resume` —— 那套机制已删除。)
24116
24182
  */
24117
24183
  latestSeq: external_exports.number().int().nonnegative().optional()
24118
24184
  });
@@ -24159,8 +24225,7 @@ var cmdSendPrompt = external_exports.object({
24159
24225
  sessionId: nonEmpty2,
24160
24226
  text: external_exports.string(),
24161
24227
  /**
24162
- * 随消息的图片附件。**可选**:老版本手机不带这个字段,老主机看到也不认
24163
- * (zod 默认 strip 未知键,而 text 照旧)——所以这是向后兼容的增补,不升协议版本。
24228
+ * 随消息的图片附件。**可选**:不带 = 这条消息没有图片(一个真实状态)。
24164
24229
  * 上限取 `limits.ts` 的 `MAX_IMAGE_ATTACHMENTS`(4):再多对"看清楚这张图"没有帮助,
24165
24230
  * 只是把帧撑爆。**同一个数**还在小程序(`MAX_ATTACH`)与主机侧(`uploads.ts`)各写一遍,
24166
24231
  * 靠 `e2e/wire-surface.test.mjs` 逐处比对钉住——改动必须三处同时发生。
@@ -24291,145 +24356,7 @@ var PAYLOAD_TYPES = {
24291
24356
  evSessionHistory: "ev.session_history"
24292
24357
  };
24293
24358
 
24294
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/registry.js
24295
- var ENDPOINT_FRAME_NAMES = [
24296
- "hello",
24297
- "pair-begin",
24298
- "pair-begin-client",
24299
- "resync",
24300
- "enc",
24301
- "enc-batch",
24302
- "session-leave",
24303
- "ping"
24304
- ];
24305
- var RELAY_FRAME_NAMES = [
24306
- "hello-ok",
24307
- "pair-ready",
24308
- "paired",
24309
- "pair-fail",
24310
- "peer-joined",
24311
- "peer-left",
24312
- "error",
24313
- "pong",
24314
- "enc",
24315
- "enc-batch"
24316
- ];
24317
- var FRAME_TYPES = [
24318
- .../* @__PURE__ */ new Set([...ENDPOINT_FRAME_NAMES, ...RELAY_FRAME_NAMES])
24319
- ];
24320
- var FRAME_TYPE_SET = new Set(FRAME_TYPES);
24321
- function isKnownFrameType(name) {
24322
- return typeof name === "string" && FRAME_TYPE_SET.has(name);
24323
- }
24324
- var CMD_TYPES = [
24325
- PAYLOAD_TYPES.cmdSendPrompt,
24326
- PAYLOAD_TYPES.cmdAnswer,
24327
- PAYLOAD_TYPES.cmdResolvePermission,
24328
- PAYLOAD_TYPES.cmdInterrupt,
24329
- PAYLOAD_TYPES.cmdListSessions,
24330
- PAYLOAD_TYPES.cmdKeepAwake,
24331
- PAYLOAD_TYPES.cmdSessionHistory,
24332
- PAYLOAD_TYPES.cmdNewSession,
24333
- PAYLOAD_TYPES.cmdGetPending,
24334
- PAYLOAD_TYPES.cmdArchiveSession
24335
- ];
24336
- var EV_TYPES = [
24337
- PAYLOAD_TYPES.evSessionChanged,
24338
- PAYLOAD_TYPES.evMessageDelta,
24339
- PAYLOAD_TYPES.evToolEvent,
24340
- PAYLOAD_TYPES.evPermissionRequest,
24341
- PAYLOAD_TYPES.evPermissionResolved,
24342
- PAYLOAD_TYPES.evQuestionRequest,
24343
- PAYLOAD_TYPES.evQuestionResolved,
24344
- PAYLOAD_TYPES.evRunState,
24345
- PAYLOAD_TYPES.evTodo,
24346
- PAYLOAD_TYPES.evRetry,
24347
- PAYLOAD_TYPES.evCompaction,
24348
- PAYLOAD_TYPES.evKeepAwakeState,
24349
- PAYLOAD_TYPES.evHostInfo,
24350
- PAYLOAD_TYPES.evModel,
24351
- PAYLOAD_TYPES.evResult,
24352
- PAYLOAD_TYPES.evSessionHistory
24353
- ];
24354
- var PAYLOAD_TYPE_SET = /* @__PURE__ */ new Set([...CMD_TYPES, ...EV_TYPES]);
24355
- var CAPABILITY_IDS = [
24356
- "drc.v1",
24357
- "drc.pairing.qr",
24358
- "drc.pairing.token",
24359
- "drc.payload.attachments",
24360
- "drc.payload.history",
24361
- /**
24362
- * 支持 `cmd.session_history.since` 向前补差量 + `ev.session_history.latestSeq`。
24363
- *
24364
- * **为什么是能力位而不是版本号**:协议 v1 的帧名与载荷名冻结(规范 §5.2 V1),
24365
- * 加这个字段属"加性变更";而手机要知道**对端是否真的会回 `latestSeq`**,
24366
- * 才能决定走补差量还是退回重拉一页。版本号表达不了"这一项有没有"。
24367
- *
24368
- * 老主机缺这一位时,手机**必须**退回重拉一页而不是发 `since`——
24369
- * 那是可验证的降级路径(反向判据见 `tests/resume-capability.test.ts`)。
24370
- */
24371
- "drc.payload.resume",
24372
- "drc.payload.new-session.workspace",
24373
- "drc.payload.get-pending",
24374
- "drc.payload.keep-awake",
24375
- "drc.payload.archive-session",
24376
- "drc.payload.model",
24377
- "drc.payload.retry-compaction",
24378
- "drc.cmd.idempotency",
24379
- "drc.data.enc-batch",
24380
- "drc.host.resync",
24381
- "drc.host.info",
24382
- "drc.crypto.v2"
24383
- ];
24384
- var CAPABILITY_SET = new Set(CAPABILITY_IDS);
24385
-
24386
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
24387
- function negotiateProtocol(peerProtocol, own2 = PROTOCOL_VERSION, min = MIN_SUPPORTED_PROTOCOL) {
24388
- if (peerProtocol === void 0 || peerProtocol === null) {
24389
- return verdictFor(1, own2, min, true);
24390
- }
24391
- if (typeof peerProtocol !== "number" || !Number.isInteger(peerProtocol) || peerProtocol <= 0) {
24392
- return {
24393
- ok: false,
24394
- reason: "invalid",
24395
- peer: peerProtocol,
24396
- message: `\u5BF9\u65B9\u62A5\u544A\u7684\u534F\u8BAE\u7248\u672C\u4E0D\u662F\u6B63\u6574\u6570\uFF08\u6536\u5230 ${JSON.stringify(peerProtocol) ?? "undefined"}\uFF09`
24397
- };
24398
- }
24399
- return verdictFor(peerProtocol, own2, min, false);
24400
- }
24401
- function verdictFor(peer, own2, min, missing) {
24402
- if (peer < min) {
24403
- return {
24404
- ok: false,
24405
- reason: "too_old",
24406
- peer,
24407
- message: `\u5BF9\u65B9\u534F\u8BAE\u7248\u672C ${peer} \u592A\u65E7\uFF0C\u672C\u7AEF\u6700\u4F4E\u652F\u6301 ${min}\uFF1B\u8BF7\u628A\u5BF9\u7AEF\u5347\u7EA7\u540E\u518D\u8BD5`
24408
- };
24409
- }
24410
- if (peer > own2) {
24411
- return {
24412
- ok: false,
24413
- reason: "too_new",
24414
- peer,
24415
- // ⚠️ 这句话**会被原样弹到用户手机上**(中继 `acceptProtocol` 显式带 message,
24416
- // 而客户端的处理是 `f.message || f.code`)。所以人称必须从**读它的人**出发:
24417
- // 收这条错的一端是"对方",而被要求升级的是它自己 —— 写"请把本端升级"时,
24418
- // 手机用户读到的是"请升级我的手机",而真正要升级的是中继/主机那一侧。
24419
- // 同理"对方"在中继的日志里指的是手机。两个方向都试过,读起来都指错了人。
24420
- message: `\u672C\u7AEF\u534F\u8BAE\u7248\u672C ${own2} \u6BD4\u5BF9\u65B9\u65E7\uFF08\u5BF9\u65B9\u4E3A ${peer}\uFF09\uFF1B\u8BF7\u628A\u672C\u7AEF\u5347\u7EA7\u5230 ${peer} \u6216\u66F4\u9AD8\u540E\u518D\u8BD5`
24421
- };
24422
- }
24423
- return { ok: true, version: peer, missing };
24424
- }
24425
-
24426
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
24427
- var WAIT_HINTABLE = /* @__PURE__ */ new Set(["rate_limited", "pair_table_full"]);
24428
- function wantsRetryAfter(code) {
24429
- return typeof code === "string" && WAIT_HINTABLE.has(code);
24430
- }
24431
-
24432
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/outbound.js
24359
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/outbound.js
24433
24360
  function helloOkForClient(clientId, protocol) {
24434
24361
  return { t: "hello-ok", role: "client", clientId, ...protocol === void 0 ? {} : { protocol } };
24435
24362
  }
@@ -24507,7 +24434,69 @@ function encBatchToRelay(sessionId, items) {
24507
24434
  return { t: "enc-batch", sessionId, items: items.map((item) => ({ ...item })) };
24508
24435
  }
24509
24436
 
24510
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/classify.js
24437
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/registry.js
24438
+ var ENDPOINT_FRAME_NAMES = [
24439
+ "hello",
24440
+ "pair-begin",
24441
+ "pair-begin-client",
24442
+ "resync",
24443
+ "enc",
24444
+ "enc-batch",
24445
+ "session-leave",
24446
+ "ping"
24447
+ ];
24448
+ var RELAY_FRAME_NAMES = [
24449
+ "hello-ok",
24450
+ "pair-ready",
24451
+ "paired",
24452
+ "pair-fail",
24453
+ "peer-joined",
24454
+ "peer-left",
24455
+ "error",
24456
+ "pong",
24457
+ "enc",
24458
+ "enc-batch"
24459
+ ];
24460
+ var FRAME_TYPES = [
24461
+ .../* @__PURE__ */ new Set([...ENDPOINT_FRAME_NAMES, ...RELAY_FRAME_NAMES])
24462
+ ];
24463
+ var FRAME_TYPE_SET = new Set(FRAME_TYPES);
24464
+ function isKnownFrameType(name) {
24465
+ return typeof name === "string" && FRAME_TYPE_SET.has(name);
24466
+ }
24467
+ var CMD_TYPES = [
24468
+ PAYLOAD_TYPES.cmdSendPrompt,
24469
+ PAYLOAD_TYPES.cmdAnswer,
24470
+ PAYLOAD_TYPES.cmdResolvePermission,
24471
+ PAYLOAD_TYPES.cmdInterrupt,
24472
+ PAYLOAD_TYPES.cmdListSessions,
24473
+ PAYLOAD_TYPES.cmdKeepAwake,
24474
+ PAYLOAD_TYPES.cmdSessionHistory,
24475
+ PAYLOAD_TYPES.cmdNewSession,
24476
+ PAYLOAD_TYPES.cmdGetPending,
24477
+ PAYLOAD_TYPES.cmdArchiveSession
24478
+ ];
24479
+ var EV_TYPES = [
24480
+ PAYLOAD_TYPES.evSessionChanged,
24481
+ PAYLOAD_TYPES.evMessageDelta,
24482
+ PAYLOAD_TYPES.evToolEvent,
24483
+ PAYLOAD_TYPES.evPermissionRequest,
24484
+ PAYLOAD_TYPES.evPermissionResolved,
24485
+ PAYLOAD_TYPES.evQuestionRequest,
24486
+ PAYLOAD_TYPES.evQuestionResolved,
24487
+ PAYLOAD_TYPES.evRunState,
24488
+ PAYLOAD_TYPES.evTodo,
24489
+ PAYLOAD_TYPES.evRetry,
24490
+ PAYLOAD_TYPES.evCompaction,
24491
+ PAYLOAD_TYPES.evKeepAwakeState,
24492
+ PAYLOAD_TYPES.evHostInfo,
24493
+ PAYLOAD_TYPES.evModel,
24494
+ PAYLOAD_TYPES.evResult,
24495
+ PAYLOAD_TYPES.evSessionHistory
24496
+ ];
24497
+ var PAYLOAD_TYPE_SET = /* @__PURE__ */ new Set([...CMD_TYPES, ...EV_TYPES]);
24498
+
24499
+ // node_modules/.pnpm/@dsh-remote+protocol@3.1.4/node_modules/@dsh-remote/protocol/dist/src/wire/classify.js
24511
24500
  function frameTypeOf(value) {
24512
24501
  if (typeof value !== "object" || value === null || Array.isArray(value))
24513
24502
  return void 0;
@@ -24600,8 +24589,34 @@ import {
24600
24589
  writeFileSync
24601
24590
  } from "node:fs";
24602
24591
  import { dirname } from "node:path";
24603
- var STATE_FILE_VERSION = 1;
24592
+ var STATE_FILE_VERSION = 2;
24604
24593
  var CONVERSATION_ID_RE = new RegExp(`^${CONVERSATION_ID_PREFIX}[0-9a-f]{12}$`);
24594
+ var HOST_TOKEN_RE = /^[0-9a-f]{48}$/;
24595
+ var MAX_HOST_ID_LEN = 200;
24596
+ function sanitizeHostTokens(raw) {
24597
+ const hostTokens = [];
24598
+ const seen = /* @__PURE__ */ new Set();
24599
+ for (const item of raw) {
24600
+ if (typeof item !== "object" || item === null) {
24601
+ continue;
24602
+ }
24603
+ const entry = item;
24604
+ if (typeof entry.hostId !== "string" || // ⚠️ **空 hostId 一律丢**(2026-10-11 晚):那曾是"未绑定槽位"的键,而它的令牌
24605
+ // 对任意主机都算数。旧状态文件里可能真有一条这样的记录 —— 读进来就等于把
24606
+ // 那把万能钥匙又装回内存,所以在这一层直接筛掉(不是靠 `hostTokenAccepted` 单独兜)。
24607
+ entry.hostId === "" || entry.hostId.length > MAX_HOST_ID_LEN || entry.hostId.trim() !== entry.hostId || !isNonEmptyString(entry.token) || !HOST_TOKEN_RE.test(entry.token) || !isTimestamp(entry.issuedAt) || !isTimestamp(entry.lastUsedAt) || seen.has(entry.hostId)) {
24608
+ continue;
24609
+ }
24610
+ seen.add(entry.hostId);
24611
+ hostTokens.push({
24612
+ hostId: entry.hostId,
24613
+ token: entry.token,
24614
+ issuedAt: entry.issuedAt,
24615
+ lastUsedAt: entry.lastUsedAt
24616
+ });
24617
+ }
24618
+ return { hostTokens, dropped: raw.length - hostTokens.length };
24619
+ }
24605
24620
  function isNonEmptyString(value) {
24606
24621
  return typeof value === "string" && value.length > 0;
24607
24622
  }
@@ -24664,8 +24679,30 @@ function readStateFile(path, log) {
24664
24679
  }
24665
24680
  const { conversations, dropped } = sanitize(file2.conversations);
24666
24681
  if (dropped > 0) log.warn("state file entries dropped", { path, dropped, kept: conversations.length });
24667
- log.info("state file loaded", { path, conversations: conversations.length, dropped });
24668
- return { version: STATE_FILE_VERSION, savedAt: isTimestamp(file2.savedAt) ? file2.savedAt : 0, conversations };
24682
+ if (!Array.isArray(file2.hostTokens)) {
24683
+ log.warn("state file has no hostTokens array, starting empty", { path });
24684
+ return void 0;
24685
+ }
24686
+ const hostTokenResult = sanitizeHostTokens(file2.hostTokens);
24687
+ if (hostTokenResult.dropped > 0) {
24688
+ log.warn("state file host tokens dropped", {
24689
+ path,
24690
+ dropped: hostTokenResult.dropped,
24691
+ kept: hostTokenResult.hostTokens.length
24692
+ });
24693
+ }
24694
+ log.info("state file loaded", {
24695
+ path,
24696
+ conversations: conversations.length,
24697
+ dropped,
24698
+ hostTokens: hostTokenResult.hostTokens.length
24699
+ });
24700
+ return {
24701
+ version: STATE_FILE_VERSION,
24702
+ savedAt: isTimestamp(file2.savedAt) ? file2.savedAt : 0,
24703
+ conversations,
24704
+ hostTokens: hostTokenResult.hostTokens
24705
+ };
24669
24706
  }
24670
24707
  function snapshotState(state, now) {
24671
24708
  const conversations = [];
@@ -24680,7 +24717,11 @@ function snapshotState(state, now) {
24680
24717
  if (conv.emptySince !== void 0) conversation.emptySince = conv.emptySince;
24681
24718
  conversations.push(conversation);
24682
24719
  }
24683
- return { version: STATE_FILE_VERSION, savedAt: now, conversations };
24720
+ const hostTokens = [];
24721
+ for (const [hostId, entry] of state.hostTokens) {
24722
+ hostTokens.push({ hostId, token: entry.token, issuedAt: entry.issuedAt, lastUsedAt: entry.lastUsedAt });
24723
+ }
24724
+ return { version: STATE_FILE_VERSION, savedAt: now, conversations, hostTokens };
24684
24725
  }
24685
24726
  function restoreState(path, state, log, now) {
24686
24727
  const file2 = readStateFile(path, log);
@@ -24702,7 +24743,18 @@ function restoreState(path, state, log, now) {
24702
24743
  if (clamped > 0) {
24703
24744
  log.warn("state file timestamps in the future, clamped to now", { path, clamped });
24704
24745
  }
24705
- log.info("state restored", { path, conversations: state.conversations.size });
24746
+ for (const entry of file2.hostTokens) {
24747
+ state.hostTokens.set(entry.hostId, {
24748
+ token: entry.token,
24749
+ issuedAt: entry.issuedAt,
24750
+ lastUsedAt: entry.lastUsedAt
24751
+ });
24752
+ }
24753
+ log.info("state restored", {
24754
+ path,
24755
+ conversations: state.conversations.size,
24756
+ hostTokens: state.hostTokens.size
24757
+ });
24706
24758
  return state.conversations.size;
24707
24759
  }
24708
24760
  function syncPath(target, log, strict = true) {
@@ -24756,6 +24808,8 @@ var RelayState = class {
24756
24808
  clients = /* @__PURE__ */ new Map();
24757
24809
  pendingPairs = /* @__PURE__ */ new Map();
24758
24810
  conversations = /* @__PURE__ */ new Map();
24811
+ /** 按 hostId 一机一枚(键一定是非空 hostId,见 `HostTokenEntry`)。 */
24812
+ hostTokens = /* @__PURE__ */ new Map();
24759
24813
  /** 测试可注入固定时钟;默认用真实时间。 */
24760
24814
  clock;
24761
24815
  maxPendingPairsValue;
@@ -25120,12 +25174,56 @@ var RelayState = class {
25120
25174
  return dropped;
25121
25175
  }
25122
25176
  /** 测试与诊断:把内部计数摊平成 `/healthz` 需要的形状。 */
25177
+ // ── host token(按 hostId 一机一枚,2026-10-11 起)────────────────────
25178
+ /**
25179
+ * 签发/覆盖 `hostId` 的令牌。**只动这一条**,别人那枚不受影响。
25180
+ *
25181
+ * `lastUsedAt` 归零:这一枚刚签出来还没被用来鉴权过。它只影响诊断与裁剪顺序
25182
+ * (见 `pruneHostTokens`),不参与鉴权判定——鉴权只看 `token` 本身。
25183
+ */
25184
+ issueHostToken(hostId, token, now) {
25185
+ const entry = { token, issuedAt: now, lastUsedAt: 0 };
25186
+ this.hostTokens.set(hostId, entry);
25187
+ return entry;
25188
+ }
25189
+ /** 记下"这枚令牌刚被用来**成功**鉴权"。没这一条时裁剪顺序就退化成"签得早的先丢"。 */
25190
+ markHostTokenUsed(hostId, now) {
25191
+ const entry = this.hostTokens.get(hostId);
25192
+ if (entry) entry.lastUsedAt = now;
25193
+ }
25194
+ /**
25195
+ * 令牌表有界:超出 `max` 时,按 `lastUsedAt || issuedAt` **从旧到新**丢。
25196
+ *
25197
+ * 为什么不是"按签发时刻丢最老":一台一直在用的机器(签得早、但一直在用)会被
25198
+ * 优先丢掉,而它**不会自愈**——下次它连上来只会拿到 `bad_token`,而它盘上有令牌
25199
+ * 就不会重新认领。所以分两档:**没用过的先丢**,同一档里再按最近活动从旧到新。
25200
+ *
25201
+ * 返回丢掉几条(调用方去记一条 warn)。
25202
+ */
25203
+ pruneHostTokens(max) {
25204
+ if (!Number.isFinite(max) || max <= 0) return 0;
25205
+ if (this.hostTokens.size <= max) return 0;
25206
+ const order = [...this.hostTokens.entries()].sort((a, b) => {
25207
+ const usedA = a[1].lastUsedAt > 0 ? 1 : 0;
25208
+ const usedB = b[1].lastUsedAt > 0 ? 1 : 0;
25209
+ if (usedA !== usedB) return usedA - usedB;
25210
+ return (a[1].lastUsedAt || a[1].issuedAt) - (b[1].lastUsedAt || b[1].issuedAt);
25211
+ });
25212
+ let dropped = 0;
25213
+ for (const [key] of order) {
25214
+ if (this.hostTokens.size <= max) break;
25215
+ this.hostTokens.delete(key);
25216
+ dropped += 1;
25217
+ }
25218
+ return dropped;
25219
+ }
25123
25220
  counts() {
25124
25221
  return {
25125
25222
  hosts: this.hosts.size,
25126
25223
  clients: this.clients.size,
25127
25224
  conversations: this.conversations.size,
25128
- pendingPairs: this.pendingPairs.size
25225
+ pendingPairs: this.pendingPairs.size,
25226
+ hostTokens: this.hostTokens.size
25129
25227
  };
25130
25228
  }
25131
25229
  /** 全量清空(优雅停机或测试重置)。 */
@@ -25134,6 +25232,7 @@ var RelayState = class {
25134
25232
  this.clients.clear();
25135
25233
  this.pendingPairs.clear();
25136
25234
  this.conversations.clear();
25235
+ this.hostTokens.clear();
25137
25236
  }
25138
25237
  };
25139
25238
 
@@ -25147,8 +25246,9 @@ function tokenMatches(expected, got) {
25147
25246
  if (a.length !== b.length) return false;
25148
25247
  return timingSafeEqual(a, b);
25149
25248
  }
25150
- var CLAIM_MIN_INTERVAL_MS = 4e3;
25151
25249
  var CLAIM_TRACK_MAX = 1e4;
25250
+ var MAX_HOST_ID_LEN2 = 200;
25251
+ var HOST_ID_RE = /^[A-Za-z0-9._:-]+$/;
25152
25252
  function createRelay(config2) {
25153
25253
  const log = new Log(config2.logLevel);
25154
25254
  const state = new RelayState({ maxPendingPairs: config2.maxPendingPairs });
@@ -25157,23 +25257,32 @@ function createRelay(config2) {
25157
25257
  const pairStatusBudget = new Budget(config2.pairStatusBudgetPerSec);
25158
25258
  const peers = /* @__PURE__ */ new WeakMap();
25159
25259
  let shuttingDown = false;
25160
- let currentHostToken = config2.hostToken;
25161
25260
  const lastClaimAt = /* @__PURE__ */ new Map();
25162
25261
  let sweepTimer;
25163
25262
  let pingTimer;
25164
25263
  let lastPingAt = 0;
25165
25264
  let lastCountersAt = 0;
25166
- function rotateHostToken() {
25167
- currentHostToken = randomBytes2(24).toString("hex");
25168
- if (lastClaimAt.size >= CLAIM_TRACK_MAX) {
25169
- const drop = lastClaimAt.size - CLAIM_TRACK_MAX + 1;
25170
- let i = 0;
25171
- for (const key of lastClaimAt.keys()) {
25172
- lastClaimAt.delete(key);
25173
- if (++i >= drop) break;
25174
- }
25265
+ function newHostToken() {
25266
+ return randomBytes2(24).toString("hex");
25267
+ }
25268
+ function rememberClaim(ip, now) {
25269
+ lastClaimAt.set(ip, now);
25270
+ if (lastClaimAt.size <= CLAIM_TRACK_MAX) return;
25271
+ const drop = lastClaimAt.size - CLAIM_TRACK_MAX;
25272
+ let i = 0;
25273
+ for (const key of lastClaimAt.keys()) {
25274
+ lastClaimAt.delete(key);
25275
+ if (++i >= drop) break;
25276
+ }
25277
+ }
25278
+ function hostTokenAccepted(hostId, got) {
25279
+ if (!got) return false;
25280
+ const perHost = state.hostTokens.get(hostId);
25281
+ if (perHost && tokenMatches(perHost.token, got)) {
25282
+ state.markHostTokenUsed(hostId, Date.now());
25283
+ return true;
25175
25284
  }
25176
- return currentHostToken;
25285
+ return config2.hostToken !== "" && tokenMatches(config2.hostToken, got);
25177
25286
  }
25178
25287
  const persistence = { enabled: config2.stateFile !== "", restored: 0, savedAt: 0, writes: 0, failures: 0 };
25179
25288
  if (persistence.enabled) {
@@ -25284,35 +25393,20 @@ function createRelay(config2) {
25284
25393
  peer.hostId = void 0;
25285
25394
  }
25286
25395
  function handleHello(peer, frame) {
25287
- if (peer.protocol === void 0 && !peer.countedProtocol) {
25288
- peer.countedProtocol = true;
25289
- if (frame.protocol === void 0) peersWithoutProtocol++;
25290
- else peerProtocols.set(frame.protocol, (peerProtocols.get(frame.protocol) ?? 0) + 1);
25291
- } else if (peer.protocol !== frame.protocol) {
25292
- if (peer.protocol === void 0) peersWithoutProtocol = Math.max(0, peersWithoutProtocol - 1);
25293
- else {
25294
- const left = (peerProtocols.get(peer.protocol) ?? 1) - 1;
25295
- if (left > 0) peerProtocols.set(peer.protocol, left);
25296
- else peerProtocols.delete(peer.protocol);
25297
- }
25298
- if (frame.protocol === void 0) peersWithoutProtocol++;
25299
- else peerProtocols.set(frame.protocol, (peerProtocols.get(frame.protocol) ?? 0) + 1);
25300
- }
25301
- peer.protocol = frame.protocol;
25302
25396
  if (frame.role === "host") {
25303
- if (!currentHostToken) {
25397
+ const hostId = frame.hostId?.trim() || newHostId();
25398
+ if (!config2.hostToken && state.hostTokens.size === 0) {
25304
25399
  sendError(peer, "internal", "relay has no host token configured");
25305
25400
  peer.ws.close(1011);
25306
25401
  return;
25307
25402
  }
25308
- if (!frame.token || !tokenMatches(currentHostToken, frame.token)) {
25403
+ if (!hostTokenAccepted(hostId, frame.token)) {
25309
25404
  peer.authAttempts += 1;
25310
25405
  log.warn("host auth failed", { attempts: peer.authAttempts });
25311
25406
  sendError(peer, "bad_token");
25312
25407
  if (peer.authAttempts >= config2.maxHostAuthAttempts) peer.ws.close(4001, "too_many_auth_attempts");
25313
25408
  return;
25314
25409
  }
25315
- const hostId = frame.hostId?.trim() || newHostId();
25316
25410
  releaseIdentityFor(peer, { role: "host", id: hostId });
25317
25411
  const { replaced } = state.attachHost(hostId, peer.ws, frame.label ?? "");
25318
25412
  peer.role = "host";
@@ -25468,8 +25562,6 @@ function createRelay(config2) {
25468
25562
  for (const sock of clients) sendTo(sock, encBatchToClient(frame.sessionId, items));
25469
25563
  }
25470
25564
  let lastResyncPersistAt = 0;
25471
- const peerProtocols = /* @__PURE__ */ new Map();
25472
- let peersWithoutProtocol = 0;
25473
25565
  function acceptProtocol(peer, peerProtocol) {
25474
25566
  const verdict = negotiateProtocol(peerProtocol);
25475
25567
  if (verdict.ok) return true;
@@ -25585,8 +25677,6 @@ function createRelay(config2) {
25585
25677
  pairBudgetWarnedAt: 0,
25586
25678
  backpressure: new BackpressureGate(),
25587
25679
  alive: true,
25588
- // 观测面计数的闩锁(见 Peer.countedProtocol):连接为单位,不是 hello 为单位。
25589
- countedProtocol: false,
25590
25680
  // 轮转序号取模分配:**均匀是刻意的**。哪怕重启风暴里 10k 条连接在几十秒内
25591
25681
  // 全建起来,它们也会摊到所有桶上;若改成"分给下一个将要轮到的桶",
25592
25682
  // 风暴会把它们全塞进同一两个桶,等于把刚拆掉的突发又造回来(C7 的场景)。
@@ -25661,12 +25751,6 @@ function createRelay(config2) {
25661
25751
  });
25662
25752
  ws.on("close", () => {
25663
25753
  peers.delete(ws);
25664
- if (peer.protocol === void 0) peersWithoutProtocol = Math.max(0, peersWithoutProtocol - 1);
25665
- else {
25666
- const left = (peerProtocols.get(peer.protocol) ?? 1) - 1;
25667
- if (left > 0) peerProtocols.set(peer.protocol, left);
25668
- else peerProtocols.delete(peer.protocol);
25669
- }
25670
25754
  bucketAt(peer.pingBucket).delete(peer);
25671
25755
  if (peer.role === "client" && peer.clientId) {
25672
25756
  for (const notice of state.clientGone(peer.clientId, ws)) {
@@ -25760,18 +25844,98 @@ function createRelay(config2) {
25760
25844
  stateWriteFailures: persistence.failures,
25761
25845
  lastPingAgo: lastPingAt === 0 ? -1 : Math.round((Date.now() - lastPingAt) / 1e3),
25762
25846
  shuttingDown,
25763
- // 协议版本协商的观测面(2026-10-07)。有了版本闸之后,"为什么这个对端连不上"
25764
- // 就成了新的排障问题,而没有这一项时它只能去翻日志。
25765
- //
25766
- // 报的是**对端报的最小/最大版本**与"没报过版本"的连接数,两个方向缺一不可:
25767
- // 只报最大值的话,一个全是老小程序的环境看起来与新版本一样正常。
25768
- protocolSelf: PROTOCOL_VERSION,
25769
- protocolMin: MIN_SUPPORTED_PROTOCOL,
25770
- peerProtocolMin: peerProtocols.size === 0 ? -1 : Math.min(...peerProtocols.keys()),
25771
- peerProtocolMax: peerProtocols.size === 0 ? -1 : Math.max(...peerProtocols.keys()),
25772
- peersNoProtocol: peersWithoutProtocol
25847
+ // 本端说的协议版本(单版本制:它是一个常量,但排障时要能一眼看到"这一个中继
25848
+ // 说的是哪一版"——版本对不上的现场表现是"连上了、什么都不发生",没有任何一层会报错)。
25849
+ protocolSelf: PROTOCOL_VERSION
25773
25850
  };
25774
25851
  }
25852
+ const BODY_TOO_LARGE = "body_too_large";
25853
+ function readBody(req, limit) {
25854
+ return new Promise((resolve, reject) => {
25855
+ const chunks = [];
25856
+ let size = 0;
25857
+ req.on("data", (chunk) => {
25858
+ size += chunk.length;
25859
+ if (size > limit) {
25860
+ req.pause();
25861
+ reject(Object.assign(new Error(BODY_TOO_LARGE), { code: BODY_TOO_LARGE }));
25862
+ return;
25863
+ }
25864
+ chunks.push(chunk);
25865
+ });
25866
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
25867
+ req.on("error", reject);
25868
+ });
25869
+ }
25870
+ const CLAIM_BODY_MAX_BYTES = 1024;
25871
+ async function handleClaimToken(req, res) {
25872
+ if (req.method !== "POST") {
25873
+ res.statusCode = 405;
25874
+ res.end(JSON.stringify({ error: "use POST" }));
25875
+ return;
25876
+ }
25877
+ if (shuttingDown) {
25878
+ res.statusCode = 503;
25879
+ res.end(JSON.stringify({ error: "shutting_down" }));
25880
+ return;
25881
+ }
25882
+ const now = Date.now();
25883
+ const ip = req.socket.remoteAddress ?? "unknown";
25884
+ const last = lastClaimAt.get(ip) ?? 0;
25885
+ if (now - last < config2.claimMinIntervalMs) {
25886
+ res.statusCode = 429;
25887
+ res.end(JSON.stringify({ error: "too_soon", retryAfterMs: config2.claimMinIntervalMs - (now - last) }));
25888
+ return;
25889
+ }
25890
+ rememberClaim(ip, now);
25891
+ let body = {};
25892
+ let raw;
25893
+ try {
25894
+ raw = await readBody(req, CLAIM_BODY_MAX_BYTES);
25895
+ } catch (error62) {
25896
+ if (error62?.code === BODY_TOO_LARGE) {
25897
+ res.statusCode = 413;
25898
+ res.setHeader("connection", "close");
25899
+ res.end(JSON.stringify({ error: "body_too_large", limitBytes: CLAIM_BODY_MAX_BYTES }));
25900
+ return;
25901
+ }
25902
+ throw error62;
25903
+ }
25904
+ if (raw.trim() !== "") {
25905
+ try {
25906
+ const parsed = JSON.parse(raw);
25907
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
25908
+ body = parsed;
25909
+ }
25910
+ } catch {
25911
+ }
25912
+ }
25913
+ const rawHostId = typeof body.hostId === "string" ? body.hostId.trim() : "";
25914
+ if (rawHostId === "") {
25915
+ res.statusCode = 400;
25916
+ res.end(
25917
+ JSON.stringify({
25918
+ error: "host_id_required",
25919
+ hint: 'POST {"hostId":"<\u672C\u673A\u8EAB\u4EFD>"} \u2014\u2014 2026-10-11 \u8D77\u4EE4\u724C\u6309 hostId \u4E00\u673A\u4E00\u679A\uFF0C\u6CA1\u6709\u8EAB\u4EFD\u5C31\u4E0D\u7B7E'
25920
+ })
25921
+ );
25922
+ return;
25923
+ }
25924
+ if (rawHostId.length > MAX_HOST_ID_LEN2 || !HOST_ID_RE.test(rawHostId)) {
25925
+ res.statusCode = 400;
25926
+ res.end(
25927
+ JSON.stringify({ error: "bad_host_id", hint: `hostId \u6700\u591A ${MAX_HOST_ID_LEN2} \u5B57\u7B26\uFF0C\u53EA\u5141\u8BB8\u5B57\u6BCD\u6570\u5B57\u4E0E ._:-` })
25928
+ );
25929
+ return;
25930
+ }
25931
+ const token = newHostToken();
25932
+ state.issueHostToken(rawHostId, token, now);
25933
+ const pruned = state.pruneHostTokens(config2.maxHostTokens);
25934
+ if (pruned > 0) log.warn("host tokens pruned", { dropped: pruned, kept: state.hostTokens.size });
25935
+ markStateChanged();
25936
+ log.info("host token claimed", { ip, hostId: rawHostId, hostTokens: state.hostTokens.size });
25937
+ res.end(JSON.stringify({ token, hostId: rawHostId, rotatedAt: new Date(now).toISOString() }));
25938
+ }
25775
25939
  function httpHandler(req, res) {
25776
25940
  try {
25777
25941
  const url2 = new URL(req.url ?? "/", "http://relay.invalid");
@@ -25782,28 +25946,13 @@ function createRelay(config2) {
25782
25946
  return;
25783
25947
  }
25784
25948
  if (url2.pathname === "/api/claim-token") {
25785
- if (req.method !== "POST") {
25786
- res.statusCode = 405;
25787
- res.end(JSON.stringify({ error: "use POST" }));
25788
- return;
25789
- }
25790
- if (shuttingDown) {
25791
- res.statusCode = 503;
25792
- res.end(JSON.stringify({ error: "shutting_down" }));
25793
- return;
25794
- }
25795
- const now = Date.now();
25796
- const ip = req.socket.remoteAddress ?? "unknown";
25797
- const last = lastClaimAt.get(ip) ?? 0;
25798
- if (now - last < CLAIM_MIN_INTERVAL_MS) {
25799
- res.statusCode = 429;
25800
- res.end(JSON.stringify({ error: "too_soon", retryAfterMs: CLAIM_MIN_INTERVAL_MS - (now - last) }));
25801
- return;
25802
- }
25803
- lastClaimAt.set(ip, now);
25804
- const token = rotateHostToken();
25805
- log.info("host token claimed", { ip });
25806
- res.end(JSON.stringify({ token, rotatedAt: new Date(now).toISOString() }));
25949
+ void handleClaimToken(req, res).catch((e) => {
25950
+ log.warn("claim handler failed", { error: String(e) });
25951
+ if (!res.headersSent) {
25952
+ res.statusCode = 500;
25953
+ res.end(JSON.stringify({ error: "internal" }));
25954
+ }
25955
+ });
25807
25956
  return;
25808
25957
  }
25809
25958
  if (url2.pathname === "/api/info") {
@@ -25992,7 +26141,7 @@ function registerSignalHandlers(shutdown) {
25992
26141
 
25993
26142
  // src/main.ts
25994
26143
  function readVersion() {
25995
- if ("3.1.2") return "3.1.2";
26144
+ if ("3.1.4") return "3.1.4";
25996
26145
  try {
25997
26146
  const pkg = JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8"));
25998
26147
  if (typeof pkg.version === "string") return pkg.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-remote/relay",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
4
4
  "description": "DSH Remote Control 的零知识 WebSocket 中继:只做路由,不碰载荷明文。可自托管的单文件产物。",
5
5
  "type": "module",
6
6
  "license": "MIT",