dsh-remote-bridge 2.5.1 → 2.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/lib/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import path, { join, sep } from "node:path";
2
2
  import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
3
- import { execFile, spawn } from "node:child_process";
4
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
- import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { execFile, spawn } from "node:child_process";
6
5
  import os, { homedir, hostname, networkInterfaces } from "node:os";
6
+ import { createReadStream, mkdirSync } from "node:fs";
7
7
  import { DatabaseSync } from "node:sqlite";
8
8
  import { pipeline } from "node:stream/promises";
9
9
  import { isIP } from "node:net";
@@ -46,6 +46,23 @@ function asApiError(error) {
46
46
  //#endregion
47
47
  //#region src/host/image-handoff.ts
48
48
  /**
49
+ * 图片交接的「识图路径」:目录名从哪来、图片怎么被安全地端到浏览器面前。
50
+ *
51
+ * 两件事合在一个模块里,因为它们是同一个概念的里外两面:
52
+ *
53
+ * 1. **设置**(`识图路径`):纯文本模型发图时,图片落在「会话工作目录 + <识图路径>」下。
54
+ * 出厂默认是 [DEFAULT_HANDOFF_SUBDIR],设置面板里把这一栏**只读**展示(用户要求:
55
+ * 「当前默认(不能改)」),真正可编辑的是那个目录名本身,存在 settings 表里。
56
+ * 2. **只读路由**(从 dsh-image-paths 0.3.1 并进来的那半边):浏览器 `<img>` 不能带
57
+ * Authorization 头,所以走不了版本化 REST 的鉴权通道。这条路由改用「回环 + 同源 +
58
+ * 裸文件名白名单 + 字节自证」的模型;**目录不由客户端给**,而是按会话工作目录由宿主
59
+ * 自己推出(与写盘用的是同一个解析),于是「指一个路径去读本地文件」这件事在参数
60
+ * 层面就不成立 —— 这是比 image-paths 原版更紧的一步。
61
+ *
62
+ * 威胁模型与 image-paths 一致:webserver 绑回环,跨站请求拒绝,返回的每个字节都先过了
63
+ * 「文件名白名单 + 扩展名声明 + 内容自证 + 体积上限」四道闸。
64
+ */
65
+ /**
49
66
  * 交接目录的**出厂默认**。
50
67
  *
51
68
  * 设置面板里「当前默认」那一栏展示的就是这个字面量,且是只读的 —— 它是这份代码的
@@ -222,6 +239,17 @@ function sameOrigin(req) {
222
239
  return false;
223
240
  }
224
241
  }
242
+ /**
243
+ * 内容是够与文件名里的哈希一致。
244
+ *
245
+ * 文件名形如 `<64 hex>.<ext>`(`HANDOFF_FILE_NAME` 已保证),取前 64 字符当期望值。
246
+ * `message` 前缀(`sha256:`)不需要处理 —— 白名单正则里没有冒号。
247
+ */
248
+ function contentMatchesName(bytes, name) {
249
+ const expected = name.slice(0, 64).toLowerCase();
250
+ if (!/^[0-9a-f]{64}$/.test(expected)) return false;
251
+ return createHash("sha256").update(bytes).digest("hex") === expected;
252
+ }
225
253
  /** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
226
254
  function refuse(res, status, reason) {
227
255
  const body = `${reason}\n`;
@@ -285,6 +313,10 @@ function createImageRouteHandler(options) {
285
313
  refuse(res, 415, "content does not match the file extension");
286
314
  return;
287
315
  }
316
+ if (!contentMatchesName(image.bytes, name)) {
317
+ refuse(res, 404, "no such handoff image");
318
+ return;
319
+ }
288
320
  res.writeHead(200, {
289
321
  "content-type": sniffed,
290
322
  "content-length": String(image.bytes.byteLength),
@@ -372,9 +404,12 @@ var FixedWindowLimiter = class {
372
404
  *
373
405
  * @param req - 待判定的请求。
374
406
  * @param options.tunnelActive - 是否有我们自管的隧道在运行;缺省视为没有。
375
- * @param options.allowTunnelMode - 仅用于「隧道状态」这类**只读、无副作用**的端点:
376
- * 让它们豁免判据 0(否则隧道一开,面板连「隧道开着」这件事都读不到),
377
- * 判据 1/2/3 仍然全部生效。**其他任何 `/manage` 端点都不许传这个选项。**
407
+ * @param options.allowTunnelMode - 仅用于「隧道状态」与「停止隧道」端点:让它们豁免判据 0
408
+ * (否则隧道一开,面板连「隧道开着」都读不到,也无法体面地把隧道停掉),判据 1/2/3
409
+ * 仍然全部生效。其中 `cloudflare/stop` 是**唯一**豁免的写操作 —— 它的唯一效果就是把
410
+ * 管理面重新锁上(重新拉回判据 0),且判据 1 仍挡住任何经隧道/代理的调用,所以对真实
411
+ * 隧道来源(必带 cf-* 头)不可利用;放行它不构成任何提权。**其他任何 `/manage` 端点
412
+ * (尤其 config/text)都不许传这个选项。**
378
413
  */
379
414
  function adminRefusal(req, options = {}) {
380
415
  if (options.tunnelActive === true && options.allowTunnelMode !== true) return {
@@ -394,6 +429,10 @@ function adminRefusal(req, options = {}) {
394
429
  message: "This operation is available only from the local machine."
395
430
  };
396
431
  }
432
+ /** 是否存在任何转发头(`adminRefusal` 判据 1 的公开形态,供测试与文档断言复用)。 */
433
+ function hasForwardingHeader(req) {
434
+ return FORWARDING_HEADERS.some((name) => headerPresent(req, name));
435
+ }
397
436
  /**
398
437
  * 「这个请求不是本机直连」的**全部**证据头。
399
438
  *
@@ -429,16 +468,63 @@ function headerPresent(req, name) {
429
468
  if (Array.isArray(raw)) return raw.some((value) => value.trim() !== "");
430
469
  return raw.trim() !== "";
431
470
  }
471
+ /**
472
+ * 判定一条请求属于哪一类对端。纯函数(除 `tunnelActive` 探针外不碰任何状态),
473
+ * 供限流分桶与测试直接调用。
474
+ *
475
+ * @param req - 待分类的请求。
476
+ * @param options.tunnelActive - 隧道是否在跑;缺省视为没有。
477
+ */
478
+ function classifyPeer(req) {
479
+ if (hasForwardingHeader(req)) return "tunnelled";
480
+ return "local";
481
+ }
432
482
  var AuthService = class {
433
483
  database;
434
484
  options;
435
- authFailures = new FixedWindowLimiter(20, 3e5);
436
- pairingAttempts = new FixedWindowLimiter(10, 6e5);
437
- requests = new FixedWindowLimiter(120, 6e4);
485
+ /**
486
+ * 两个「按对端分类」分开计的桶(键 = `<class>:<remoteAddress>`)。
487
+ *
488
+ * 阈值刻意**两边一致**:分桶的目的是「互不连坐」,不是「给本机开后门」。
489
+ * 如果本机侧更宽,那它就成了一条「怎么把自己伪装成本机」的动机 —— 而伪装是免费的
490
+ * (不发头即可),于是更宽的那一侧等于被取消。
491
+ * 阈值相等时,攻击者伪装成 local 只会消耗 local 配额,**没有额外收益**。
492
+ *
493
+ * `requests`:120/60s。`authFailures`:20/5min。
494
+ */
495
+ authFailuresByPeer = {
496
+ local: new FixedWindowLimiter(20, 3e5),
497
+ tunnelled: new FixedWindowLimiter(20, 3e5)
498
+ };
499
+ /** 配对兑换:本机与隧道分开。隧道侧公网暴力尝试不该锁死本机的配对。 */
500
+ pairingAttemptsByPeer = {
501
+ local: new FixedWindowLimiter(10, 6e5),
502
+ tunnelled: new FixedWindowLimiter(10, 6e5)
503
+ };
504
+ requestsByPeer = {
505
+ local: new FixedWindowLimiter(120, 6e4),
506
+ tunnelled: new FixedWindowLimiter(120, 6e4)
507
+ };
438
508
  constructor(database, options = {}) {
439
509
  this.database = database;
440
510
  this.options = options;
441
511
  }
512
+ /** 这套服务当前的对端分类(`tunnelActive` 走 fail-closed 探针)。 */
513
+ peerOf(req) {
514
+ return classifyPeer(req);
515
+ }
516
+ /** 按对端取请求配额桶。 */
517
+ requestLimiter(req) {
518
+ return this.requestsByPeer[this.peerOf(req)];
519
+ }
520
+ /** 按对端取认证失败桶。 */
521
+ failureLimiter(req) {
522
+ return this.authFailuresByPeer[this.peerOf(req)];
523
+ }
524
+ /** 按对端取配对尝试桶(`router.ts` 的 exchange 端点用)。 */
525
+ pairingLimiter(req) {
526
+ return this.pairingAttemptsByPeer[this.peerOf(req)];
527
+ }
442
528
  /**
443
529
  * 隧道是否在跑。探针抛错时**按「在跑」处理**(fail closed):探针读的是我们自己
444
530
  * 对象上的 getter,它抛错意味着状态不可知;状态不可知时放行管理面正是这次修复要
@@ -455,24 +541,26 @@ var AuthService = class {
455
541
  }
456
542
  principal(req, options = {}) {
457
543
  const remote = requestAddress(req);
458
- this.requests.consume(remote);
544
+ const peer = this.peerOf(req);
545
+ const key = `${peer}:${remote}`;
546
+ this.requestsByPeer[peer].consume(key);
459
547
  if (options.allowAdmin === true && req.headers["x-dsh-workspace-admin"] === "1") {
460
548
  const refusal = adminRefusal(req, { tunnelActive: this.tunnelRunning() });
461
549
  if (refusal !== void 0) {
462
- this.authFailures.consume(remote);
550
+ this.authFailuresByPeer[peer].consume(key);
463
551
  throw new ApiError(403, refusal.code, refusal.message);
464
552
  }
465
553
  return ADMIN_PRINCIPAL;
466
554
  }
467
555
  const authorization = req.headers.authorization;
468
556
  if (authorization === void 0 || !authorization.startsWith("Bearer ")) {
469
- this.authFailures.consume(remote);
557
+ this.authFailuresByPeer[peer].consume(key);
470
558
  throw new ApiError(401, "AUTH_REQUIRED", "A valid Bearer token is required.");
471
559
  }
472
560
  const token = authorization.slice(7).trim();
473
561
  const device = token === "" ? void 0 : this.database.authenticate(token);
474
562
  if (device === void 0) {
475
- this.authFailures.consume(remote);
563
+ this.authFailuresByPeer[peer].consume(key);
476
564
  throw new ApiError(401, "TOKEN_INVALID", "The device token is invalid or revoked.");
477
565
  }
478
566
  return {
@@ -550,14 +638,27 @@ function archSuffix(platform = process.platform, arch = process.arch) {
550
638
  default: throw new Error(`cloudflared 尚不支持 ${process.arch} 架构。`);
551
639
  }
552
640
  }
553
- const OFFICIAL_BASE = `https://github.com/cloudflare/cloudflared/releases/download/2024.11.1/cloudflared-`;
641
+ /**
642
+ * cloudflared 的**唯一**版本号(导出给测试与配置文本用)。
643
+ *
644
+ * ⚠️ 「钉版本」必须只有一处事实来源:2.5.1 及之前 GitHub 基线钉 `2024.11.1`,
645
+ * 而 deb 兜底 URL 里写死了另一个版本 `2026.10.1` —— 同一个函数对「装哪个版本」
646
+ * 给出两个不同答案,等于**没有钉住**。现在 deb URL 也从这里派生。
647
+ */
648
+ const CLOUDFLARED_VERSION = "2024.11.1";
649
+ const OFFICIAL_BASE = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-`;
554
650
  /**
555
651
  * 官方 deb 兜底仓库(GitHub 被墙时回退)。
652
+ *
556
653
  * `pkg.cloudflare.com` 是 Cloudflare 官方仓库,实测在 GitHub 直连被墙的机器上可达可用
557
- * (见 PITFALLS §国内下载 cloudflared 必须择源)。版本比 GitHub 默认新(2026.10.1),
558
- * 仅在官方 GitHub 失败时兜底,不改变「默认先 GitHub」的语义。
654
+ * (见 PITFALLS §国内下载 cloudflared 必须择源)。仅在官方 GitHub 失败时兜底,
655
+ * 不改变「默认先 GitHub」的语义。
656
+ *
657
+ * 版本**从 `CLOUDFLARED_VERSION` 派生**,不再写死第二个版本号。
559
658
  */
560
- const DEB_FALLBACK_URL = "https://pkg.cloudflare.com/cloudflared/pool/main/c/cloudflared/cloudflared_2026.10.1_amd64.deb";
659
+ function debFallbackUrl(debArch) {
660
+ return `https://pkg.cloudflare.com/cloudflared/pool/main/c/cloudflared/cloudflared_${CLOUDFLARED_VERSION}_${debArch}.deb`;
661
+ }
561
662
  async function downloadHttps(url, ms, onProgress) {
562
663
  const mod = url.startsWith("http:") ? await import("node:http") : await import("node:https");
563
664
  return await new Promise((resolve, reject) => {
@@ -591,6 +692,60 @@ async function downloadHttps(url, ms, onProgress) {
591
692
  function statusOk(code) {
592
693
  return typeof code === "number" && code >= 200 && code < 300;
593
694
  }
695
+ /**
696
+ * 校验下载到的字节是否匹配期望的 SHA-256。
697
+ *
698
+ * 大小写不敏感、允许 `sha256:` 前缀(常见发行格式)。**长度不对一律 false** ——
699
+ * 不做「宽松接受」:一个 3 字符的期望值只可能是配置错误,接受它就是放弃校验。
700
+ */
701
+ function verifySha256(bytes, expectedHex) {
702
+ const normalized = expectedHex.trim().replace(/^sha256:/i, "").toLowerCase();
703
+ if (!/^[0-9a-f]{64}$/.test(normalized)) return false;
704
+ return createHash("sha256").update(bytes).digest("hex") === normalized;
705
+ }
706
+ /**
707
+ * 期望的校验和:读 `CLOUDFLARED_SHA256` 环境变量。
708
+ *
709
+ * 空串/纯空白视为**没有声明**(返回 undefined)—— 否则「随手 export 一个空值」
710
+ * 会让所有下载都被判成不匹配。
711
+ */
712
+ function expectedSha256From() {
713
+ const raw = process.env.CLOUDFLARED_SHA256?.trim() ?? "";
714
+ return raw === "" ? void 0 : raw;
715
+ }
716
+ /**
717
+ * 把下载/解包错误**脱敏**成可以对外(与本机日志)说的一行字。
718
+ *
719
+ * ## 为什么需要它
720
+ *
721
+ * 底层错误有三类敏感残留:
722
+ * 1. **完整 URL** —— `downloadHttps` 的 `` `下载失败:${url}(${cause.message})` ``,
723
+ * 而镜像 URL 可能带 token(`https://mirror/token/...`);
724
+ * 2. **绝对路径** —— `execFile('dpkg-deb', ...)` 的错误形如
725
+ * `Command failed: dpkg-deb -x /home/user/.../x.deb /home/user/.../dir`;
726
+ * 3. 两者都可能被 `router.ts` 原样塞进 502 响应体。
727
+ *
728
+ * ## 取舍
729
+ *
730
+ * 保留**可诊断的线索**(host 名、文件名),去掉**不该外传的部分**(完整 URL、目录)。
731
+ * 一句话:能说「哪个源、哪个文件」;不能说「你家目录长什么样、URL 里那个 token 是什么」。
732
+ */
733
+ function sanitizeDownloadError(message) {
734
+ let safe = message;
735
+ safe = safe.replace(/https?:\/\/[^\s))(("']+/gi, (raw) => {
736
+ try {
737
+ return `host:${new URL(raw).host}`;
738
+ } catch {
739
+ return "host:unknown";
740
+ }
741
+ });
742
+ safe = safe.replace(/(?:[A-Za-z]:)?(?:\/[^\s::/()()]+)+\/?/g, (raw) => {
743
+ const trimmed = raw.replace(/[\/]+$/, "");
744
+ const base = trimmed.split(/[\\/]/).pop() ?? trimmed;
745
+ return base === "" ? trimmed : base;
746
+ });
747
+ return safe;
748
+ }
594
749
  /** 环境变量注入的下载源配置;空则回退官方。 */
595
750
  function mirrorBase() {
596
751
  return process.env.CLOUDFLARED_MIRROR?.trim() ?? "";
@@ -641,14 +796,14 @@ async function ensureCloudflared(dataDir, report) {
641
796
  const existing = path.join(dataDir, "cloudflared");
642
797
  report?.({
643
798
  stage: "locating",
644
- label: "定位本机二进制…",
799
+ labelKey: "tunnelStageLocating",
645
800
  percent: null
646
801
  });
647
802
  try {
648
803
  if ((await stat(existing)).isFile()) {
649
804
  report?.({
650
805
  stage: "locating",
651
- label: "已找到本机二进制,直接复用",
806
+ labelKey: "tunnelStageBinaryFound",
652
807
  percent: 100
653
808
  });
654
809
  return existing;
@@ -662,14 +817,15 @@ async function ensureCloudflared(dataDir, report) {
662
817
  url: downloadUrlFor(),
663
818
  deb: false
664
819
  }, {
665
- url: DEB_FALLBACK_URL,
820
+ url: debFallbackUrl("amd64"),
666
821
  deb: true
667
822
  }];
823
+ const expected = expectedSha256From();
668
824
  const errors = [];
669
825
  for (const { url, deb } of candidates) {
670
826
  report?.({
671
827
  stage: "downloading",
672
- label: `正在下载 cloudflared(${deb ? "官方 deb 仓库" : "官方 GitHub"})…`,
828
+ labelKey: deb ? "tunnelStageDownloadDeb" : "tunnelStageDownloadGithub",
673
829
  percent: null
674
830
  });
675
831
  try {
@@ -680,7 +836,11 @@ async function ensureCloudflared(dataDir, report) {
680
836
  const all = (total / 1024 / 1024).toFixed(1);
681
837
  report?.({
682
838
  stage: "downloading",
683
- label: `正在下载 cloudflared… ${mb}/${all} MB`,
839
+ labelKey: "tunnelStageDownloadProgress",
840
+ labelParams: {
841
+ mb,
842
+ all
843
+ },
684
844
  percent
685
845
  });
686
846
  }
@@ -689,17 +849,26 @@ async function ensureCloudflared(dataDir, report) {
689
849
  errors.push(`HTTP ${res.status}`);
690
850
  continue;
691
851
  }
852
+ if (expected !== void 0 && !verifySha256(res.body, expected)) {
853
+ errors.push("SHA-256 mismatch");
854
+ continue;
855
+ }
856
+ if (expected === void 0) report?.({
857
+ stage: "downloading",
858
+ labelKey: "tunnelStageDownloadUnverified",
859
+ percent: 100
860
+ });
692
861
  if (deb) {
693
862
  report?.({
694
863
  stage: "unpacking",
695
- label: "解包官方 deb 并提取二进制…",
864
+ labelKey: "tunnelStageUnpackDeb",
696
865
  percent: null
697
866
  });
698
867
  return writeFromDeb(dataDir, res.body, url);
699
868
  }
700
869
  report?.({
701
870
  stage: "unpacking",
702
- label: "写入二进制并赋予执行权限…",
871
+ labelKey: "tunnelStageWriteBinary",
703
872
  percent: null
704
873
  });
705
874
  const tmp = path.join(dataDir, `.cloudflared.tmp-${process.pid}`);
@@ -708,15 +877,24 @@ async function ensureCloudflared(dataDir, report) {
708
877
  await rename(tmp, existing);
709
878
  return existing;
710
879
  } catch (cause) {
711
- errors.push(cause instanceof Error ? cause.message : String(cause));
880
+ errors.push(sanitizeDownloadError(cause instanceof Error ? cause.message : String(cause)));
712
881
  }
713
882
  }
714
- const tried = candidates.map((c) => c.url).join("\n ");
715
- throw new Error(`下载 cloudflared 失败:\n ${tried}\n原因:${errors.join(";")}`);
883
+ const triedHosts = [...new Set(candidates.map((c) => {
884
+ try {
885
+ return new URL(c.url).host;
886
+ } catch {
887
+ return "unknown";
888
+ }
889
+ }))].join(", ");
890
+ throw new Error(`下载 cloudflared 失败(已尝试 ${candidates.length} 个源:${triedHosts})` + (errors.length === 0 ? "" : `;原因:${errors.join(";")}`));
716
891
  }
717
892
  /**
718
893
  * 从 `.deb` 包字节中提取 `usr/bin/cloudflared`,原子落到 `dataDir/cloudflared`。
719
894
  * 不调用系统 `dpkg -i`(避免污染包数据库),只做解包 + 提取。
895
+ *
896
+ * 失败时抛出的错误**已脱敏**:`execFile` 的原始错误含两个绝对路径
897
+ * (deb 与解包目录),直接外传会泄露本机目录结构。
720
898
  */
721
899
  async function writeFromDeb(dataDir, debBuffer, url) {
722
900
  await mkdir(dataDir, { recursive: true });
@@ -730,11 +908,18 @@ async function writeFromDeb(dataDir, debBuffer, url) {
730
908
  "-x",
731
909
  debPath,
732
910
  extractDir
733
- ], (err) => err ? reject(err) : resolve());
911
+ ], (err) => {
912
+ if (err === null) {
913
+ resolve();
914
+ return;
915
+ }
916
+ reject(new Error(sanitizeDownloadError(`dpkg-deb 解包失败:${err.message}`)));
917
+ });
734
918
  });
735
919
  const inner = path.join(extractDir, "usr", "bin", "cloudflared");
736
920
  const out = path.join(dataDir, "cloudflared");
737
921
  const bin = await readFile(inner);
922
+ if (bin.byteLength === 0) throw new Error("解包后的 cloudflared 为空文件");
738
923
  await writeFile(out, bin);
739
924
  await chmod(out, 493);
740
925
  return out;
@@ -752,10 +937,12 @@ var CloudflaredTunnelManager = class {
752
937
  url = null;
753
938
  channel = null;
754
939
  errorText = void 0;
940
+ errorKey = void 0;
941
+ errorParams = void 0;
755
942
  listeners = [];
756
943
  activity = {
757
944
  stage: "idle",
758
- label: "",
945
+ labelKey: "",
759
946
  percent: null
760
947
  };
761
948
  /** 当前进行中任务的阶段进度(无任务时 stage 为 idle)。 */
@@ -771,7 +958,7 @@ var CloudflaredTunnelManager = class {
771
958
  clearActivity() {
772
959
  this.activity = {
773
960
  stage: "idle",
774
- label: "",
961
+ labelKey: "",
775
962
  percent: null
776
963
  };
777
964
  this.emit();
@@ -794,21 +981,31 @@ var CloudflaredTunnelManager = class {
794
981
  channel: this.channel,
795
982
  url: this.url,
796
983
  running: this.tunnelActive,
797
- ...this.errorText === void 0 ? {} : { error: this.errorText }
984
+ ...this.errorText === void 0 ? {} : { error: this.errorText },
985
+ ...this.errorKey === void 0 ? {} : { errorKey: this.errorKey },
986
+ ...this.errorParams === void 0 ? {} : { errorParams: this.errorParams }
798
987
  };
799
988
  }
800
989
  /** 订阅状态变化;返回取消函数。 */
801
990
  onStatus(cb) {
802
991
  this.listeners.push(cb);
803
- return () => {
804
- this.listeners = this.listeners.filter((fn) => fn !== cb);
805
- };
992
+ return () => this.offStatus(cb);
993
+ }
994
+ /** 取消一个状态订阅(onStatus 返回的函数与 waitForUrl 共用)。 */
995
+ offStatus(cb) {
996
+ this.listeners = this.listeners.filter((fn) => fn !== cb);
806
997
  }
807
998
  emit() {
808
999
  for (const fn of this.listeners) fn();
809
1000
  }
810
- setError(message) {
1001
+ /**
1002
+ * 记录一次错误。`error` 保留脱敏后的短消息(兜底/日志),
1003
+ * `errorKey` + `errorParams` 供客户端本地化(优先级 errorKey > error)。
1004
+ */
1005
+ setError(errorKey, message, errorParams) {
811
1006
  this.errorText = message;
1007
+ this.errorKey = errorKey;
1008
+ this.errorParams = errorParams;
812
1009
  this.emit();
813
1010
  }
814
1011
  /**
@@ -817,11 +1014,13 @@ var CloudflaredTunnelManager = class {
817
1014
  * @param port bridge 监听端口,cloudflared 把外网请求转发到 localhost:port
818
1015
  */
819
1016
  async startQuick(exe, port) {
820
- if (this.process !== null && !this.process.killed) throw new Error("隧道已经在运行中,请先停止。");
1017
+ if (this.process !== null && !this.process.killed) throw new Error("Tunnel is already running; stop it first.");
821
1018
  this.errorText = void 0;
1019
+ this.errorKey = void 0;
1020
+ this.errorParams = void 0;
822
1021
  this.reportActivity({
823
1022
  stage: "starting",
824
- label: "正在启动 cloudflared 隧道…",
1023
+ labelKey: "tunnelStageStarting",
825
1024
  percent: null
826
1025
  });
827
1026
  const child = spawn(exe, [
@@ -853,13 +1052,52 @@ var CloudflaredTunnelManager = class {
853
1052
  this.channel = null;
854
1053
  this.emit();
855
1054
  if (wasThis) {
856
- if (this.url === null) this.setError(`cloudflared 退出(code=${code ?? "?"}, signal=${signal ?? "?"}),未拿到隧道地址。`);
1055
+ if (this.url === null) this.setError("tunnelErrorExited", `cloudflared exited (code=${code ?? "?"}, signal=${signal ?? "?"}) without a tunnel URL.`, {
1056
+ code: String(code ?? "?"),
1057
+ signal: String(signal ?? "?")
1058
+ });
857
1059
  this.url = null;
858
1060
  if (this.channel === null) this.clearActivity();
859
1061
  }
860
1062
  });
861
1063
  return this.publicState;
862
1064
  }
1065
+ /**
1066
+ * 等待子进程把随机域名打印到 stdout(spawn 后 `url` 通常还要几秒才有)。
1067
+ *
1068
+ * 存在的理由(2.5.2):`POST /manage/cloudflare/create` 发生在隧道**起来之前**,
1069
+ * 此刻管理面还没被判据 0 关门;一旦在这次请求里等出地址并顺带出令牌配置文本,
1070
+ * 客户端就不必在隧道运行后再打一次 `/manage/config/text`(那时必然 403,
1071
+ * 这正是「一键开隧道+出令牌」失效的根因)。
1072
+ *
1073
+ * 任一条件满足即结算:地址出现 → resolve(url);进程退出/超时 → resolve(null)
1074
+ * (不抛,调用方据 null 决定要不要让客户端走轮询补一步)。调用方传 AbortSignal 可提前走人。
1075
+ */
1076
+ async waitForUrl(timeoutMs = 12e3, signal) {
1077
+ if (this.url !== null) return this.url;
1078
+ if (this.process === null || this.process.killed) return null;
1079
+ return await new Promise((resolve) => {
1080
+ let settled = false;
1081
+ const finish = (value) => {
1082
+ if (settled) return;
1083
+ settled = true;
1084
+ clearTimeout(timer);
1085
+ this.offStatus(observer);
1086
+ signal?.removeEventListener("abort", onAbort);
1087
+ resolve(value);
1088
+ };
1089
+ const observer = () => {
1090
+ if (this.url !== null) finish(this.url);
1091
+ else if (this.process === null || this.process.killed) finish(null);
1092
+ };
1093
+ const onAbort = () => finish(null);
1094
+ const timer = setTimeout(() => finish(null), timeoutMs);
1095
+ timer.unref?.();
1096
+ signal?.addEventListener("abort", onAbort, { once: true });
1097
+ this.onStatus(observer);
1098
+ observer();
1099
+ });
1100
+ }
863
1101
  /** 停止当前隧道。 */
864
1102
  stop() {
865
1103
  const child = this.process;
@@ -879,6 +1117,142 @@ var CloudflaredTunnelManager = class {
879
1117
  }
880
1118
  };
881
1119
  //#endregion
1120
+ //#region src/host/metrics.ts
1121
+ /**
1122
+ * 进程内**薄指标收集器**。
1123
+ *
1124
+ * ## 为什么需要它
1125
+ *
1126
+ * 本插件此前只有 9 处启动期日志(`index.ts`),运行期**一条都没有**,也没有任何耗时统计。
1127
+ * 于是「广播是不是串行卡住了」「内核 RPC 是不是超时了」「事件是不是被丢了」这三个问题
1128
+ * 在运行数据里**完全不可见** —— 只能靠读代码推断。这份模块就是让它们变得可观测的最小代价版本。
1129
+ *
1130
+ * ## 为什么是「进程内、无依赖、固定样本环」
1131
+ *
1132
+ * - **不引入 prometheus / otel**:这插件跑在用户的 DSH 进程里,加一个重依赖会拖慢启动、
1133
+ * 也会把「插件」变成「服务」;这里要的只是「出问题时能看一眼」。
1134
+ * - **固定样本环(256)**:P50/P95 需要排序,样本无界增长就是内存泄漏。256 个数字足以
1135
+ * 看出「大多数很快、尾部很慢」,而且分配是一次性的。
1136
+ * - **全部计数都是整数累加**:热路径(每个事件、每个 RPC)调用它只做 `+= 1` 与几次比较,
1137
+ * 不会自己变成瓶颈。
1138
+ *
1139
+ * ## 如何被读到
1140
+ *
1141
+ * `getMetricsSnapshot()` 是**纯读**。挂载点在 `GET /api/v1/debug/host-services` 的响应里
1142
+ * (由 router 负责,本模块不碰 HTTP)—— 那条端点本就是「只读诊断」,且已有同一套鉴权。
1143
+ *
1144
+ * 注意:这些数字**不参与任何业务判断**。它们只用于观测,坏了最坏是「数字不对」,
1145
+ * 不会让功能变化 —— 这是刻意划的边界。
1146
+ */
1147
+ /** 保留多少个 RPC 耗时样本用于算分位数。 */
1148
+ const SAMPLE_CAPACITY = 256;
1149
+ const rpcBuckets = /* @__PURE__ */ new Map();
1150
+ /** WebSocket 与广播面的计数。 */
1151
+ const counters = {
1152
+ wsConnections: 0,
1153
+ wsRejected: 0,
1154
+ wsTerminated: 0,
1155
+ broadcastEvents: 0,
1156
+ /** 所有连接队列里最长的那条有多长(背压信号)。 */
1157
+ broadcastQueueDepth: 0,
1158
+ /** 因队列满/背压被丢掉的事件数(任何连接)。 */
1159
+ droppedEvents: 0,
1160
+ /** FrameHub 因队列满丢掉的事件数(由 dsh-0.2-bridge 上报)。 */
1161
+ frameHubDropped: 0
1162
+ };
1163
+ /**
1164
+ * 记录一次 RPC 耗时。
1165
+ *
1166
+ * @param name - 稳定的 RPC 名(如 `sessions.history`)。**不要**把参数拼进去,
1167
+ * 否则基数会炸(每个 sessionId 一个桶)。
1168
+ * @param ms - 毫秒耗时。
1169
+ * @param ok - 是否成功;失败单独计数,因为「慢」与「错」是两回事。
1170
+ */
1171
+ function recordRpc(name, ms, ok = true) {
1172
+ let bucket = rpcBuckets.get(name);
1173
+ if (bucket === void 0) {
1174
+ bucket = {
1175
+ count: 0,
1176
+ failures: 0,
1177
+ totalMs: 0,
1178
+ maxMs: 0,
1179
+ samples: [],
1180
+ index: 0,
1181
+ filled: 0
1182
+ };
1183
+ rpcBuckets.set(name, bucket);
1184
+ }
1185
+ bucket.count += 1;
1186
+ if (!ok) bucket.failures += 1;
1187
+ if (!Number.isFinite(ms) || ms < 0) return;
1188
+ bucket.totalMs += ms;
1189
+ if (ms > bucket.maxMs) bucket.maxMs = ms;
1190
+ bucket.samples[bucket.index] = ms;
1191
+ bucket.index = (bucket.index + 1) % SAMPLE_CAPACITY;
1192
+ if (bucket.filled < SAMPLE_CAPACITY) bucket.filled += 1;
1193
+ }
1194
+ function setWsConnections(value) {
1195
+ counters.wsConnections = value < 0 ? 0 : value;
1196
+ }
1197
+ function recordWsRejected() {
1198
+ counters.wsRejected += 1;
1199
+ }
1200
+ function recordWsTerminated() {
1201
+ counters.wsTerminated += 1;
1202
+ }
1203
+ function recordBroadcastEvent() {
1204
+ counters.broadcastEvents += 1;
1205
+ }
1206
+ /** 更新「最长连接队列」;传入当前所有连接队列长度的最大值。 */
1207
+ function setBroadcastQueueDepth(value) {
1208
+ counters.broadcastQueueDepth = value < 0 ? 0 : value;
1209
+ }
1210
+ function recordDroppedEvent(count = 1) {
1211
+ counters.droppedEvents += count;
1212
+ }
1213
+ /** 由 `FrameHub` 上报:它自己丢了几个事件(与 socket 队列无关的另一处丢弃点)。 */
1214
+ function recordFrameHubDropped(count = 1) {
1215
+ counters.frameHubDropped += count;
1216
+ }
1217
+ /** 从环形样本里问分位数。空样本返回 0(而不是 NaN —— NaN 会让 JSON 变 `null`)。 */
1218
+ function quantile(bucket, f) {
1219
+ if (bucket.filled === 0) return 0;
1220
+ const live = bucket.samples.slice(0, bucket.filled).sort((left, right) => left - right);
1221
+ return live[Math.min(live.length - 1, Math.max(0, Math.floor((live.length - 1) * f)))] ?? 0;
1222
+ }
1223
+ /**
1224
+ * 读一份快照。**纯读**:不重置任何计数(重置会让「谁先读谁清空」变成新的坑)。
1225
+ *
1226
+ * 返回的是**新对象**,调用方改它不会污染内部状态。
1227
+ */
1228
+ function getMetricsSnapshot() {
1229
+ const rpc = [];
1230
+ for (const [name, bucket] of rpcBuckets) rpc.push({
1231
+ name,
1232
+ count: bucket.count,
1233
+ failures: bucket.failures,
1234
+ averageMs: bucket.count === 0 ? 0 : Math.round(bucket.totalMs / Math.max(1, bucket.count) * 100) / 100,
1235
+ p50Ms: quantile(bucket, .5),
1236
+ p95Ms: quantile(bucket, .95),
1237
+ maxMs: bucket.maxMs
1238
+ });
1239
+ rpc.sort((left, right) => right.p95Ms - left.p95Ms);
1240
+ return {
1241
+ ws: {
1242
+ connections: counters.wsConnections,
1243
+ rejected: counters.wsRejected,
1244
+ terminated: counters.wsTerminated
1245
+ },
1246
+ broadcast: {
1247
+ events: counters.broadcastEvents,
1248
+ queueDepth: counters.broadcastQueueDepth,
1249
+ droppedEvents: counters.droppedEvents
1250
+ },
1251
+ frameHub: { dropped: counters.frameHubDropped },
1252
+ rpc
1253
+ };
1254
+ }
1255
+ //#endregion
882
1256
  //#region src/host/path-policy.ts
883
1257
  const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
884
1258
  function validateRelativePath(value) {
@@ -1120,16 +1494,108 @@ function toRpcError(error) {
1120
1494
  };
1121
1495
  }
1122
1496
  /** Run one host call and wrap its outcome or failure in the old envelope. */
1123
- async function call(run) {
1497
+ /**
1498
+ * 一次内核 RPC 的**统一入口**:耗时统计 + 慢调用/失败日志。
1499
+ *
1500
+ * ## 为什么所有 RPC 都经过这一个函数
1501
+ *
1502
+ * 适配层有二十来条 RPC(sessions.list / history / prompt / models …),逐条埋点
1503
+ * 必然漂(有人加新方法时会忘)。这里是**唯一单点**:加一行就全覆盖。
1504
+ *
1505
+ * ## 为什么必须有一层超时
1506
+ *
1507
+ * 修复前 `call(run)` 不做任何超时,而 `run` 里的每次内核调用都用
1508
+ * `new AbortController().signal`(**永不 abort**)。于是内核侧一旦卡住:
1509
+ *
1510
+ * - 广播路径上那条 `sessions.list` 会把 `broadcastQueue` 永久堵住
1511
+ * (整站事件停摆,`stop()` 也跟着挂);
1512
+ * - 请求路径上那条会让 HTTP 连接一直挂着,客户端先超时、服务端资源不释放。
1513
+ *
1514
+ * 现在统一 10 秒上限。**注意语义**:超时被当作一次 RPC 失败(`code: 'rpc/timeout'`),
1515
+ * 由调用方决定怎么处理 —— 广播路径拿到 false 就「少推一条」,请求路径拿到 504。
1516
+ * 这比「无限等待」在任何情况下都更接近正确。
1517
+ *
1518
+ * @param name - 稳定的 RPC 名(如 `sessions.history`)。**不要**把 sessionId 拼进来,
1519
+ * 否则指标的基数会按会话数增长。
1520
+ * @param run - 真正的调用。它收到的 `signal` 已经带超时,应当传给宿主调用。
1521
+ * @param options.timeoutMs - 覆盖默认上限(流式/长任务可放宽,但**不能**设成无限)。
1522
+ */
1523
+ async function call(name, run, options = {}) {
1524
+ const startedAt = Date.now();
1525
+ const timeoutMs = options.timeoutMs ?? RPC_TIMEOUT_MS;
1526
+ const controller = new AbortController();
1527
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`RPC ${name} timed out after ${timeoutMs}ms`)), timeoutMs);
1528
+ timer.unref?.();
1124
1529
  try {
1125
- return ok(await run());
1530
+ const value = await run(controller.signal);
1531
+ const elapsed = Date.now() - startedAt;
1532
+ recordRpc(name, elapsed, true);
1533
+ logSlowRpc(name, elapsed, true);
1534
+ return ok(value);
1126
1535
  } catch (error) {
1536
+ const elapsed = Date.now() - startedAt;
1537
+ recordRpc(name, elapsed, false);
1538
+ logSlowRpc(name, elapsed, false);
1539
+ if (controller.signal.aborted && isAbortLike(error)) return { result: {
1540
+ ok: false,
1541
+ error: {
1542
+ code: "rpc/timeout",
1543
+ message: `The host did not answer ${name} within ${timeoutMs}ms.`
1544
+ }
1545
+ } };
1127
1546
  return { result: {
1128
1547
  ok: false,
1129
1548
  error: toRpcError(error)
1130
1549
  } };
1550
+ } finally {
1551
+ clearTimeout(timer);
1131
1552
  }
1132
1553
  }
1554
+ /** 默认 RPC 上限。10 秒:超过它基本可以断定内核卡了,而不是「慢」。 */
1555
+ const RPC_TIMEOUT_MS = 1e4;
1556
+ /**
1557
+ * 「查一下这个会话的工作目录 / 投影」这类**短查询**的上限。
1558
+ *
1559
+ * 比 [RPC_TIMEOUT_MS] 更紧,因为它们在 prompt 的同步前置里:用户按下发送之后
1560
+ * 要等它们回来(图才落盘、模型判定才完成)。让一个辅助查询拖 10 秒,
1561
+ * 用户已经认定「发不出去」了。
1562
+ */
1563
+ const SESSION_DIR_LOOKUP_TIMEOUT_MS = 3e3;
1564
+ /**
1565
+ * 造一个**会自己到期**的 signal(并让它的定时器随 abort 一起清掉)。
1566
+ *
1567
+ * 为什么不在调用点写 `AbortSignal.timeout(ms)`:两者的过期行为一致,但这里需要
1568
+ * 「定时器不拖住进程退出」—— `AbortSignal.timeout` 内部没有 `unref`,
1569
+ * 在只剩它一个活跃句柄时会让进程多等三秒。这在本仓是已经踩过的纪律
1570
+ * (见 `server.ts` 的 `unref` 注释)。
1571
+ */
1572
+ function timeoutSignal(ms) {
1573
+ const controller = new AbortController();
1574
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`timed out after ${ms}ms`)), ms);
1575
+ timer.unref?.();
1576
+ controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
1577
+ return controller.signal;
1578
+ }
1579
+ /**
1580
+ * 慢调用 / 失败时打一条日志。
1581
+ *
1582
+ * 阈值 5 秒是刻意的:**正常**的内核调用都在毫秒级,5 秒说明真的出问题了;
1583
+ * 而每次都打日志会让热路径的日志量变成噪声(那反而更难看出问题)。
1584
+ */
1585
+ function logSlowRpc(name, elapsedMs, okFlag) {
1586
+ if (okFlag && elapsedMs < SLOW_RPC_LOG_MS) return;
1587
+ const message = `dsh-0.2-bridge: rpc ${name} ${okFlag ? "slow" : "failed"}: ${elapsedMs}ms`;
1588
+ console.warn(message);
1589
+ }
1590
+ const SLOW_RPC_LOG_MS = 5e3;
1591
+ /** 判断一个异常是不是「因为我们 abort 才抛」的(不同实现的 message 不一样)。 */
1592
+ function isAbortLike(error) {
1593
+ if (error === null || typeof error !== "object") return false;
1594
+ const candidate = error;
1595
+ if (candidate.name === "AbortError") return true;
1596
+ if (candidate.code === "ABORT_ERR") return true;
1597
+ return typeof candidate.message === "string" && /abort/i.test(candidate.message);
1598
+ }
1133
1599
  /**
1134
1600
  * Replay-free fan-out of host facts into the `{ rpcId, payload }` frames the
1135
1601
  * old `apiProxy` streams produced. Subscribers only observe frames emitted
@@ -1150,8 +1616,23 @@ var FrameHub = class {
1150
1616
  const queue = [];
1151
1617
  let wake;
1152
1618
  let closed = false;
1619
+ /** 因队列满被丢掉的帧数(丢过就要告知消费方「事件流不连续」)。 */
1620
+ let dropped = 0;
1153
1621
  const onFrame = (frame) => {
1154
1622
  if (closed) return;
1623
+ if (queue.length >= FRAME_HUB_QUEUE_LIMIT) {
1624
+ if (isRebuildableFrame(frame)) {
1625
+ dropped += 1;
1626
+ recordFrameHubDropped();
1627
+ wake?.();
1628
+ return;
1629
+ }
1630
+ const victim = queue.findIndex(isRebuildableFrame);
1631
+ if (victim >= 0) queue.splice(victim, 1);
1632
+ else queue.shift();
1633
+ dropped += 1;
1634
+ recordFrameHubDropped();
1635
+ }
1155
1636
  queue.push(frame);
1156
1637
  wake?.();
1157
1638
  };
@@ -1166,6 +1647,18 @@ var FrameHub = class {
1166
1647
  return { async *[Symbol.asyncIterator]() {
1167
1648
  try {
1168
1649
  while (!closed && !signal.aborted) {
1650
+ if (dropped > 0) {
1651
+ const count = dropped;
1652
+ dropped = 0;
1653
+ yield {
1654
+ rpcId: randomUUID(),
1655
+ payload: {
1656
+ type: "bridge/resync-required",
1657
+ dropped: count,
1658
+ reason: "backpressure"
1659
+ }
1660
+ };
1661
+ }
1169
1662
  if (queue.length === 0) {
1170
1663
  await new Promise((resolve) => {
1171
1664
  wake = resolve;
@@ -1182,6 +1675,24 @@ var FrameHub = class {
1182
1675
  }
1183
1676
  };
1184
1677
  /**
1678
+ * `FrameHub` 每个消费者的队列上限。
1679
+ *
1680
+ * 与 `server.ts` 的 `DEFAULT_SOCKET_QUEUE_LIMIT` 同量级但**不必相等**:
1681
+ * 这一层是「内核帧 → 插件帧」的缓冲,消费方是本插件的 `startEvents` 循环;
1682
+ * 那一层是「插件事件 → WebSocket 帧」,消费方是网络。两层各有各的慢法。
1683
+ */
1684
+ const FRAME_HUB_QUEUE_LIMIT = 1e3;
1685
+ /**
1686
+ * 这一帧丢了能不能重建。
1687
+ *
1688
+ * 只有流式文本增量可以(客户端重拉历史即得)。判据看**归一化后的 payload.type**:
1689
+ * `assistant/chunk` 会经 `normalizeDshEvent` 变成 `chat.message.delta`,
1690
+ * 而 `FrameHub` 处在归一化**之前**,所以两边都要认。
1691
+ */
1692
+ function isRebuildableFrame(frame) {
1693
+ return frame.payload["type"] === "assistant/chunk";
1694
+ }
1695
+ /**
1185
1696
  * Relays live session frames into the old `session/event` shape.
1186
1697
  *
1187
1698
  * 0.2.x has no process-wide session event stream: both durable events and the
@@ -1195,16 +1706,48 @@ var SessionEventRelay = class {
1195
1706
  host;
1196
1707
  mux;
1197
1708
  limit;
1709
+ warn;
1710
+ /**
1711
+ * 活跃订阅。**Map 的插入顺序就是 LRU 顺序**:命中/新建都先 delete 再 set,
1712
+ * 于是最旧(最久没动过)的会话永远在队首 —— 淘汰时取第一个即可,不用另建链表。
1713
+ */
1198
1714
  active = /* @__PURE__ */ new Map();
1199
- constructor(host, mux, limit = 8) {
1715
+ constructor(host, mux, limit = 8, warn = () => void 0) {
1200
1716
  this.host = host;
1201
1717
  this.mux = mux;
1202
1718
  this.limit = limit;
1719
+ this.warn = warn;
1203
1720
  }
1204
- /** Start following one session; idempotent per session. */
1721
+ /**
1722
+ * Start following one session; idempotent per session.
1723
+ *
1724
+ * ## 到上限时的行为(修复点)
1725
+ *
1726
+ * 此前是 `if (this.active.size >= this.limit) return` —— **静默**不订阅。
1727
+ * 后果:第 9 个「正在跑」的会话**没有流式事件**,而两侧日志一句话都没有;
1728
+ * 用户看到的现象是「这个会话不实时了」,与网络问题完全无法区分。
1729
+ *
1730
+ * 现在改为 **LRU 淘汰 + warn**:踢掉最久没被触碰的那条订阅,给新会话腾位置。
1731
+ * 这比「拒绝新会话」更接近用户预期 —— 用户切到的那个会话正是他此刻在看的那条,
1732
+ * 而被淘汰的那条通常已经不在屏幕上了(它的下一次 `attach` 会重新订阅,
1733
+ * 代价只是「那段时间的增量要重拉历史」,而不是永久看不到)。
1734
+ *
1735
+ * 淘汰必须**有日志**:不然「我的旧会话为什么突然不流式了」永远查不出来。
1736
+ */
1205
1737
  attach(sessionId) {
1206
- if (this.active.has(sessionId)) return;
1207
- if (this.active.size >= this.limit) return;
1738
+ const existing = this.active.get(sessionId);
1739
+ if (existing !== void 0) {
1740
+ this.active.delete(sessionId);
1741
+ this.active.set(sessionId, existing);
1742
+ return;
1743
+ }
1744
+ if (this.active.size >= this.limit) {
1745
+ const oldest = this.active.keys().next().value;
1746
+ if (oldest !== void 0) {
1747
+ this.warn(`dsh-0.2-bridge: session event relay is at its limit (${this.limit}); evicting the least recently used subscription (${oldest}) to follow ${sessionId}`);
1748
+ this.detach(oldest);
1749
+ }
1750
+ }
1208
1751
  const controller = new AbortController();
1209
1752
  this.active.set(sessionId, controller);
1210
1753
  this.pump(sessionId, controller.signal).catch(() => {
@@ -1313,13 +1856,26 @@ var ApprovalBridge = class {
1313
1856
  const settle = (value) => {
1314
1857
  if (done) return;
1315
1858
  done = true;
1859
+ clearTimeout(entry.timer);
1316
1860
  resolve(value);
1317
1861
  };
1318
- this.pending.set(rpcId, { settle });
1862
+ const entry = {
1863
+ settle,
1864
+ timer: setTimeout(() => void 0, 0),
1865
+ requestedAt: Date.now()
1866
+ };
1867
+ clearTimeout(entry.timer);
1868
+ entry.timer = setTimeout(() => {
1869
+ this.pending.delete(rpcId);
1870
+ settle("delegate");
1871
+ }, APPROVAL_PENDING_TIMEOUT_MS);
1872
+ entry.timer.unref?.();
1873
+ this.pending.set(rpcId, entry);
1319
1874
  req.signal?.addEventListener("abort", () => {
1320
1875
  this.pending.delete(rpcId);
1321
1876
  if (done) return;
1322
1877
  done = true;
1878
+ clearTimeout(entry.timer);
1323
1879
  resolve("delegate");
1324
1880
  }, { once: true });
1325
1881
  this.mux.push({
@@ -1365,6 +1921,39 @@ var ApprovalBridge = class {
1365
1921
  */
1366
1922
  const MAX_PENDING_QUESTIONS = 32;
1367
1923
  /**
1924
+ * 同时跟踪多少个「正在跑」的会话的事件流。
1925
+ *
1926
+ * 每一条都是一个 `follow()` 订阅,占一个文件描述符与一段内存;无上限的话,
1927
+ * 一个「同时跑 50 个会话」的机器会把 fd 耗光。8 是既有取值,本轮不调 ——
1928
+ * 本轮只把「到顶就静默丢弃」改成「LRU 淘汰 + 日志」。
1929
+ */
1930
+ const SESSION_RELAY_LIMIT = 8;
1931
+ /**
1932
+ * 一条审批**最多挂起多久**(10 分钟)。
1933
+ *
1934
+ * 为什么必须有这个上限:`approval/request` 是一个等待承诺 —— 客户端通过
1935
+ * `respond()` 结算,宿主通过 `req.signal` 的 abort 结算。**两条都不发生时,
1936
+ * 这个 Promise 永不结算**,内核那一轮就永久阻塞,`pending` 条目也永远留着。
1937
+ *
1938
+ * 而「客户端永远不回答」在真实场景里并不罕见:手机被放进兜里、App 被杀、
1939
+ * 通知被划掉。修复前遇到这种情况,用户看到的是「agent 卡住不动了」,
1940
+ * 除了重启 dsh 没有别的出路。
1941
+ *
1942
+ * 为什么超时是 `delegate` 而**不是** `allowed-once`:把「没人回答」解释成
1943
+ * 「默认批准」等于让安全判据失效于一个定时器。`delegate` 让内核回到下游
1944
+ * (官方 WebUI)继续等,同时推一条 `approval/resolved` 让陈旧卡片消失 ——
1945
+ * 功能上「回到没有这个插件时的行为」,这才是正确的降级方向。
1946
+ */
1947
+ const APPROVAL_PENDING_TIMEOUT_MS = 6e5;
1948
+ /**
1949
+ * 一条前台提问**最多挂起多久**(10 分钟,与审批同口径)。
1950
+ *
1951
+ * 提问不阻塞内核(Agent 已经继续跑),但它一样会吊着一个 Promise 与一条挂起表条目。
1952
+ * 超时按 `dismissed` 结算:推 `userQuestions/dismissed` 让手机端那张卡片消失,
1953
+ * 同时把 `delegate` 交给调用方 —— 与「用户点了电脑端」同一条收尾路径。
1954
+ */
1955
+ const QUESTION_PENDING_TIMEOUT_MS = 6e5;
1956
+ /**
1368
1957
  * `user-questions/request` 的接管者 —— 形状照 [ApprovalBridge]。
1369
1958
  *
1370
1959
  * 为什么非接不可:`ask_user_question` 只有在**参数 schema 声明了 `timeout`** 时才走
@@ -1422,14 +2011,24 @@ var QuestionBridge = class {
1422
2011
  const settle = (value) => {
1423
2012
  if (done) return;
1424
2013
  done = true;
2014
+ clearTimeout(entry.timer);
1425
2015
  resolve(value);
1426
2016
  };
1427
- bridge.pending.set(rpcId, {
2017
+ const entry = {
1428
2018
  sessionId,
1429
2019
  callId,
1430
2020
  questions,
1431
- settle
1432
- });
2021
+ settle,
2022
+ timer: setTimeout(() => void 0, 0),
2023
+ requestedAt: Date.now()
2024
+ };
2025
+ clearTimeout(entry.timer);
2026
+ entry.timer = setTimeout(() => {
2027
+ bridge.pending.delete(rpcId);
2028
+ settle({ kind: "dismissed" });
2029
+ }, QUESTION_PENDING_TIMEOUT_MS);
2030
+ entry.timer.unref?.();
2031
+ bridge.pending.set(rpcId, entry);
1433
2032
  request?.signal?.addEventListener("abort", () => {
1434
2033
  bridge.pending.delete(rpcId);
1435
2034
  settle({ kind: "dismissed" });
@@ -1489,6 +2088,30 @@ var QuestionBridge = class {
1489
2088
  };
1490
2089
  }
1491
2090
  /**
2091
+ * 清扫**已经超期**的挂起项。
2092
+ *
2093
+ * 为什么还需要这个「批量清扫」,既然每条都有自己的定时器:
2094
+ * 定时器是**正常**路径;清扫是**兜底**。两者都存在的理由与 `pending` 表的最小不变式
2095
+ * 有关 —— 表里的每一条都必须「要么会被用户结算、要么会被超时结算、要么会被这里扫掉」。
2096
+ * 少任何一条都会留下永久条目(那正是修复前的状态:只有用户结算这一条路)。
2097
+ *
2098
+ * 它按 `requestedAt` 判断而不是再挂一个计时器:调用方(适配层)可以在任意时机
2099
+ * 顺手调一次,代价是 O(表中条目数),而这个表被 `MAX_PENDING_QUESTIONS` 封在 32。
2100
+ *
2101
+ * @returns 被清掉多少条(供日志/测试断言)。
2102
+ */
2103
+ sweepExpired(now = Date.now()) {
2104
+ let swept = 0;
2105
+ for (const [rpcId, entry] of this.pending) {
2106
+ if (now - entry.requestedAt < QUESTION_PENDING_TIMEOUT_MS) continue;
2107
+ this.pending.delete(rpcId);
2108
+ clearTimeout(entry.timer);
2109
+ entry.settle({ kind: "dismissed" });
2110
+ swept += 1;
2111
+ }
2112
+ return swept;
2113
+ }
2114
+ /**
1492
2115
  * 某会话当前挂起中的提问(供 `GET /questions` 合并)。
1493
2116
  *
1494
2117
  * `state` 刻意报 `'continued'`:客户端(dsh-companion 的 HarnessViewModel:1314)
@@ -1598,6 +2221,14 @@ function sessionIdOf(summary) {
1598
2221
  * 宁可落到私有目录也不能让「发消息」本身失败。
1599
2222
  */
1600
2223
  const DEFAULT_IMAGE_HANDOFF_DIR = join(homedir(), ".dsh", "image-handoff");
2224
+ /**
2225
+ * 交接图片读取的体积上限(32 MiB)。
2226
+ *
2227
+ * 与只读图片路由里的 `MAX_BYTES`(`image-handoff.ts`)**同值但不同职责**:
2228
+ * 那边是「浏览器要不要显示」,这边是「服务端要不要把字节读进内存」。
2229
+ * 这里必须**在读之前**判 —— 先读完再判等于闸门形同虚设。
2230
+ */
2231
+ const MAX_HANDOFF_BYTES = 33554432;
1601
2232
  const IMAGE_EXTENSIONS = {
1602
2233
  "image/png": "png",
1603
2234
  "image/jpeg": "jpg",
@@ -1626,16 +2257,23 @@ function handoffExtension(mediaType, bytes) {
1626
2257
  * `relative` 是相对会话工作目录的短路径,给人看;`absolute` 给读图工具用。
1627
2258
  * 目录 0700、文件 0600:这些字节可能包含用户隐私。
1628
2259
  */
1629
- function writeHandoffFile(dir, workspace, mediaType, data) {
2260
+ async function writeHandoffFile(dir, workspace, mediaType, data) {
1630
2261
  const bytes = Buffer.from(data, "base64");
1631
2262
  if (bytes.length === 0) throw new Error("EMPTY_IMAGE");
1632
- mkdirSync(dir, {
2263
+ await mkdir(dir, {
1633
2264
  recursive: true,
1634
2265
  mode: 448
1635
2266
  });
1636
2267
  const fileName = `${createHash("sha256").update(bytes).digest("hex")}.${handoffExtension(mediaType, bytes)}`;
1637
2268
  const absolute = join(dir, fileName);
1638
- if (!existsSync(absolute)) writeFileSync(absolute, bytes, { mode: 384 });
2269
+ try {
2270
+ await writeFile(absolute, bytes, {
2271
+ mode: 384,
2272
+ flag: "wx"
2273
+ });
2274
+ } catch (error) {
2275
+ if (error.code !== "EEXIST") throw error;
2276
+ }
1639
2277
  return {
1640
2278
  absolute,
1641
2279
  relative: workspace !== void 0 && absolute.startsWith(workspace + sep) ? absolute.slice(workspace.length + 1).split(sep).join("/") : fileName
@@ -1665,10 +2303,21 @@ async function readHandoffImageFile(host, roots, fallbackDir, subdir, sessionId,
1665
2303
  const ext = (match[1] ?? "").toLowerCase();
1666
2304
  const mediaType = HANDOFF_MEDIA_TYPES[ext] ?? "application/octet-stream";
1667
2305
  const workspace = await sessionWorkspaceDir(host, roots, sessionId);
1668
- const target = [...workspace !== void 0 ? [join(workspace, subdir, fileName)] : [], join(fallbackDir, fileName)].find((candidate) => existsSync(candidate));
2306
+ const candidates = [...workspace !== void 0 ? [join(workspace, subdir, fileName)] : [], join(fallbackDir, fileName)];
2307
+ let target;
2308
+ let size = 0;
2309
+ for (const candidate of candidates) try {
2310
+ const info = await stat(candidate);
2311
+ if (!info.isFile()) continue;
2312
+ target = candidate;
2313
+ size = info.size;
2314
+ break;
2315
+ } catch {}
1669
2316
  if (target === void 0) throw new Error("HANDOFF_IMAGE_NOT_FOUND");
2317
+ if (size === 0) throw new Error("HANDOFF_IMAGE_EMPTY");
2318
+ if (size > MAX_HANDOFF_BYTES) throw new Error("HANDOFF_IMAGE_TOO_LARGE");
1670
2319
  return {
1671
- bytes: new Uint8Array(readFileSync(target)),
2320
+ bytes: new Uint8Array(await readFile(target)),
1672
2321
  mediaType
1673
2322
  };
1674
2323
  }
@@ -1681,7 +2330,7 @@ async function readHandoffImageFile(host, roots, fallbackDir, subdir, sessionId,
1681
2330
  */
1682
2331
  async function sessionWorkspaceDir(host, roots, sessionId) {
1683
2332
  if (roots.length === 0) return void 0;
1684
- const item = (await host.sessionController.list({}, new AbortController().signal)).items?.find((entry) => entry.sessionId === sessionId);
2333
+ const item = (await host.sessionController.list({}, timeoutSignal(SESSION_DIR_LOOKUP_TIMEOUT_MS))).items?.find((entry) => entry.sessionId === sessionId);
1685
2334
  const cwd = typeof item?.cwd === "string" ? item.cwd : void 0;
1686
2335
  if (cwd === void 0) return void 0;
1687
2336
  for (const root of roots) {
@@ -1812,7 +2461,7 @@ async function readSessionProjections(host, sessionId, signal) {
1812
2461
  }
1813
2462
  /** 会话当前生效的模型(pending > lastUsed > 目录默认),取不到返回 undefined。 */
1814
2463
  async function currentModelSelection(host, sessionId) {
1815
- const selection = (await host.sessionController.projections({ sessionId }, new AbortController().signal))?.values?.["modelSelection"];
2464
+ const selection = (await host.sessionController.projections({ sessionId }, timeoutSignal(SESSION_DIR_LOOKUP_TIMEOUT_MS)))?.values?.["modelSelection"];
1816
2465
  const catalog = await host.sessionController.modelCatalog();
1817
2466
  const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
1818
2467
  const provider = current?.provider;
@@ -1866,16 +2515,22 @@ async function handoffImagesForTextOnlyModel(host, roots, fallbackDir, subdir, l
1866
2515
  const workspace = await sessionWorkspaceDir(host, roots, sessionId);
1867
2516
  const dir = workspace !== void 0 ? join(workspace, subdir) : fallbackDir;
1868
2517
  let index = 0;
1869
- return content.map((part) => {
1870
- if (part.type !== "image") return part;
2518
+ const mapped = [];
2519
+ for (const part of content) {
2520
+ if (part.type !== "image") {
2521
+ mapped.push(part);
2522
+ continue;
2523
+ }
1871
2524
  index += 1;
1872
- const { absolute, relative } = writeHandoffFile(dir, workspace, part.mediaType, part.data);
2525
+ const { absolute, relative } = await writeHandoffFile(dir, workspace, part.mediaType, part.data);
1873
2526
  const name = typeof part.name === "string" && part.name.trim() !== "" ? part.name.trim() : locale === "en" ? `image ${index}` : `图片 ${index}`;
1874
- return {
2527
+ const text = [handoffLine(index, name, relative, locale), `[Image: source: ${absolute}]`].join("\n");
2528
+ mapped.push({
1875
2529
  type: "text",
1876
- text: [handoffLine(index, name, relative, locale), `[Image: source: ${absolute}]`].join("\n")
1877
- };
1878
- });
2530
+ text
2531
+ });
2532
+ }
2533
+ return mapped;
1879
2534
  } catch {
1880
2535
  return content;
1881
2536
  }
@@ -1921,7 +2576,9 @@ function createDsh02ApiProxy(host, options = {}) {
1921
2576
  approvals.attach();
1922
2577
  const questions = new QuestionBridge(host, mux);
1923
2578
  questions.attach();
1924
- const relay = new SessionEventRelay(host, mux);
2579
+ const relay = new SessionEventRelay(host, mux, SESSION_RELAY_LIMIT, (message) => {
2580
+ console.warn(message);
2581
+ });
1925
2582
  host.on("api-session/added", (summary) => {
1926
2583
  const sessionId = sessionIdOf(summary);
1927
2584
  if (sessionId === void 0) return;
@@ -1983,18 +2640,20 @@ function createDsh02ApiProxy(host, options = {}) {
1983
2640
  return {
1984
2641
  respond: (message) => approvals.respond(message),
1985
2642
  sessions: {
1986
- list: (request) => call(async () => {
1987
- const page = await host.sessionController.list(request.payload, new AbortController().signal);
2643
+ list: (request) => call("sessions.list", async (signal) => {
2644
+ const page = await host.sessionController.list(request.payload, signal);
1988
2645
  const items = Array.isArray(page.items) ? page.items.filter((item) => item.origin !== "subagent") : page.items;
1989
2646
  return {
1990
2647
  ...page,
1991
2648
  items
1992
2649
  };
1993
2650
  }),
1994
- create: (request) => call(() => host.sessionController.create(request.payload)),
1995
- history: (request) => call(async () => {
2651
+ create: (request) => call("sessions.create", (signal) => host.sessionController.create(request.payload)),
2652
+ history: (request) => call("sessions.history", async (signal) => {
1996
2653
  const { sessionId, maxMessages, beforeSeq } = request.payload;
1997
2654
  const controller = new AbortController();
2655
+ if (signal.aborted) controller.abort();
2656
+ else signal.addEventListener("abort", () => controller.abort(), { once: true });
1998
2657
  try {
1999
2658
  const snapshot = (await host.sessionController.follow({
2000
2659
  address: {
@@ -2033,7 +2692,7 @@ function createDsh02ApiProxy(host, options = {}) {
2033
2692
  controller.abort();
2034
2693
  }
2035
2694
  }),
2036
- prompt: (request) => call(async () => {
2695
+ prompt: (request) => call("sessions.prompt", async (signal) => {
2037
2696
  const requestId = randomUUID();
2038
2697
  const { locale, ...kernelPayload } = request.payload;
2039
2698
  const receiptIds = request.payload.content.filter((part) => part.type === "file").map((part) => part.receiptId);
@@ -2046,22 +2705,22 @@ function createDsh02ApiProxy(host, options = {}) {
2046
2705
  requestId,
2047
2706
  ...kernelPayload,
2048
2707
  content
2049
- }, new AbortController().signal);
2708
+ }, signal);
2050
2709
  binding?.commit();
2051
2710
  return { accepted: true };
2052
2711
  }),
2053
- uploadAttachment: (request) => call(async () => {
2712
+ uploadAttachment: (request) => call("sessions.uploadAttachment", async (signal) => {
2054
2713
  const { sessionId, data, name } = request.payload;
2055
2714
  if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
2056
2715
  return {
2057
2716
  receiptId: (await fileUploadService.upload(await agentFor(sessionId), name === void 0 || name === "" ? { data } : {
2058
2717
  data,
2059
2718
  name
2060
- }, new AbortController().signal)).receiptId,
2719
+ }, signal)).receiptId,
2061
2720
  name: name ?? null
2062
2721
  };
2063
2722
  }),
2064
- readAttachment: (request) => call(async () => {
2723
+ readAttachment: (request) => call("sessions.readAttachment", async (signal) => {
2065
2724
  const { sessionId, attachmentId } = request.payload;
2066
2725
  const value = await host.sessionController.attachment({
2067
2726
  sessionId,
@@ -2072,20 +2731,20 @@ function createDsh02ApiProxy(host, options = {}) {
2072
2731
  mediaType: value.attachment.mediaType
2073
2732
  };
2074
2733
  }),
2075
- readHandoffImage: (request) => call(async () => {
2734
+ readHandoffImage: (request) => call("sessions.readHandoffImage", async (signal) => {
2076
2735
  const { sessionId, name } = request.payload;
2077
2736
  return readHandoffImageFile(host, handoffRootsOf(), imageHandoffDir, handoffSubdirOf(), sessionId, name);
2078
2737
  }),
2079
- cancel: (request) => call(() => host.sessionController.cancel(request.payload)),
2080
- rename: (request) => call(() => host.sessionController.rename(request.payload)),
2081
- fork: (request) => call(() => host.sessionController.fork(request.payload)),
2082
- models: (request) => call(async () => {
2738
+ cancel: (request) => call("sessions.cancel", (signal) => host.sessionController.cancel(request.payload)),
2739
+ rename: (request) => call("sessions.rename", (signal) => host.sessionController.rename(request.payload)),
2740
+ fork: (request) => call("sessions.fork", (signal) => host.sessionController.fork(request.payload)),
2741
+ models: (request) => call("sessions.models", async (signal) => {
2083
2742
  const { sessionId } = request.payload;
2084
2743
  try {
2085
2744
  await host.sessionController.resolveAgent(sessionId);
2086
2745
  } catch {}
2087
2746
  const catalog = await host.sessionController.modelCatalog();
2088
- const selection = (await host.sessionController.projections({ sessionId }, new AbortController().signal))?.values?.["modelSelection"];
2747
+ const selection = (await host.sessionController.projections({ sessionId }, signal))?.values?.["modelSelection"];
2089
2748
  return {
2090
2749
  current: selection?.next ?? selection?.lastUsed ?? catalog.default,
2091
2750
  routable: Array.isArray(catalog.routableProviders) && catalog.routableProviders.length > 0,
@@ -2096,10 +2755,10 @@ function createDsh02ApiProxy(host, options = {}) {
2096
2755
  })) : []
2097
2756
  };
2098
2757
  }),
2099
- selectModel: (request) => call(() => host.sessionController.selectModel(request.payload))
2758
+ selectModel: (request) => call("sessions.selectModel", (signal) => host.sessionController.selectModel(request.payload))
2100
2759
  },
2101
2760
  workspace: {
2102
- list: () => call(async () => {
2761
+ list: () => call("workspace.list", async (signal) => {
2103
2762
  return {
2104
2763
  items: host.workspaceRegistry.list().map((row) => ({
2105
2764
  workspaceId: row.id,
@@ -2112,16 +2771,16 @@ function createDsh02ApiProxy(host, options = {}) {
2112
2771
  archivedSessionIds: readArchivedSessionIds(host.workspaceRegistry)
2113
2772
  };
2114
2773
  }),
2115
- create: (request) => call(() => host.workspaceController.create(request.payload)),
2116
- rename: (request) => call(() => host.workspaceController.rename(request.payload)),
2117
- delete: (request) => call(async () => {
2774
+ create: (request) => call("workspace.create", (signal) => host.workspaceController.create(request.payload)),
2775
+ rename: (request) => call("workspace.rename", (signal) => host.workspaceController.rename(request.payload)),
2776
+ delete: (request) => call("workspace.delete", async (signal) => {
2118
2777
  await host.workspaceController.delete(request.payload);
2119
2778
  return { deleted: true };
2120
2779
  }),
2121
- archiveSession: (request) => call(() => host.workspaceController.archiveSession(request.payload))
2780
+ archiveSession: (request) => call("workspace.archiveSession", (signal) => host.workspaceController.archiveSession(request.payload))
2122
2781
  },
2123
2782
  agentPresets: {
2124
- list: () => call(async () => {
2783
+ list: () => call("agentPresets.list", async (signal) => {
2125
2784
  const withTrust = (rows) => rows.map((row) => ({
2126
2785
  ...row,
2127
2786
  trust: row.trust ?? "system"
@@ -2141,19 +2800,19 @@ function createDsh02ApiProxy(host, options = {}) {
2141
2800
  hasDocument: false
2142
2801
  };
2143
2802
  }),
2144
- select: (request) => call(async () => {
2803
+ select: (request) => call("agentPresets.select", async (signal) => {
2145
2804
  const agent = await agentFor(request.payload.sessionId);
2146
2805
  return { agentPreset: await host.agentPresets.select(agent, request.payload.agentPreset) };
2147
2806
  })
2148
2807
  },
2149
- permissionPresets: { catalog: () => call(async () => {
2808
+ permissionPresets: { catalog: () => call("permissionPresets.catalog", async (signal) => {
2150
2809
  const service = optionalService(host, "permissionPresets");
2151
2810
  if (service === null || typeof service.catalog !== "function") return FALLBACK_PERMISSION_CATALOG;
2152
2811
  return normalizePermissionCatalog(await service.catalog());
2153
2812
  }) },
2154
2813
  userQuestions: {
2155
2814
  pendingAll: () => questions.pendingAll(),
2156
- list: (request) => call(async () => {
2815
+ list: (request) => call("userQuestions.list", async (signal) => {
2157
2816
  const { sessionId } = request.payload;
2158
2817
  const bridged = questions.pendingFor(sessionId);
2159
2818
  const bridgedIds = new Set(bridged.map((item) => item.callId));
@@ -2165,7 +2824,7 @@ function createDsh02ApiProxy(host, options = {}) {
2165
2824
  const continued = Array.isArray(rows) ? rows.map(toUserQuestionView).filter((item) => item !== void 0) : [];
2166
2825
  return { items: [...bridged, ...continued.filter((item) => !bridgedIds.has(item.callId))] };
2167
2826
  }),
2168
- answer: (request) => call(async () => {
2827
+ answer: (request) => call("userQuestions.answer", async (signal) => {
2169
2828
  const { sessionId, callId, answer } = request.payload;
2170
2829
  const agent = await agentForQuestions(sessionId);
2171
2830
  let kernelCouldNotAnswer = false;
@@ -2212,24 +2871,24 @@ function createDsh02ApiProxy(host, options = {}) {
2212
2871
  })
2213
2872
  },
2214
2873
  settings: {
2215
- describe: () => call(() => host.settingsController.describe()),
2216
- mutate: (request) => call(() => host.settingsController.mutate(request.payload.ns, request.payload.ops, request.payload.expectedRevision))
2874
+ describe: () => call("settings.describe", (signal) => host.settingsController.describe()),
2875
+ mutate: (request) => call("settings.mutate", (signal) => host.settingsController.mutate(request.payload.ns, request.payload.ops, request.payload.expectedRevision))
2217
2876
  },
2218
2877
  credentials: {
2219
- describe: (request) => call(async () => {
2878
+ describe: (request) => call("credentials.describe", async (signal) => {
2220
2879
  return { credentials: await host.credentialsController.describe(request.payload.refs) };
2221
2880
  }),
2222
- set: (request) => call(async () => {
2881
+ set: (request) => call("credentials.set", async (signal) => {
2223
2882
  await host.credentialsController.set(request.payload.ref, request.payload.value);
2224
2883
  return {};
2225
2884
  }),
2226
- unset: (request) => call(async () => {
2885
+ unset: (request) => call("credentials.unset", async (signal) => {
2227
2886
  await host.credentialsController.unset(request.payload.ref);
2228
2887
  return {};
2229
2888
  })
2230
2889
  },
2231
2890
  llm: {
2232
- providers: () => call(async () => {
2891
+ providers: () => call("llm.providers", async (signal) => {
2233
2892
  const directory = host.llm.listConfigurableProviders();
2234
2893
  const live = new Set(host.llm.listProviders().map((entry) => entry.id));
2235
2894
  return { providers: directory.map((entry) => ({
@@ -2247,14 +2906,14 @@ function createDsh02ApiProxy(host, options = {}) {
2247
2906
  } }
2248
2907
  })) };
2249
2908
  }),
2250
- models: () => call(async () => {
2909
+ models: () => call("llm.models", async (signal) => {
2251
2910
  const view = await host.sessionController.modelCatalog();
2252
2911
  return {
2253
2912
  groups: Array.isArray(view.groups) ? view.groups : [],
2254
2913
  failures: Array.isArray(view.failures) ? view.failures : []
2255
2914
  };
2256
2915
  }),
2257
- discoverModels: (request) => call(async () => {
2916
+ discoverModels: (request) => call("llm.discoverModels", async (signal) => {
2258
2917
  const { settingsNs, ...rest } = request.payload;
2259
2918
  return { models: [...await host.llm.discoverModels(settingsNs, rest)] };
2260
2919
  })
@@ -2267,7 +2926,7 @@ function createDsh02ApiProxy(host, options = {}) {
2267
2926
  }
2268
2927
  //#endregion
2269
2928
  //#region src/shared/version.ts
2270
- const PLUGIN_VERSION = "2.5.1";
2929
+ const PLUGIN_VERSION = "2.6.0";
2271
2930
  //#endregion
2272
2931
  //#region src/host/capabilities.ts
2273
2932
  /**
@@ -2298,7 +2957,8 @@ const PLUGIN_VERSION = "2.5.1";
2298
2957
  * `approvals-v1` / `questions-v1`(`dsh-companion` 的 `REQUIRED_CAPABILITIES`:
2299
2958
  * 缺了就明说「插件过旧、卡片不会出现」)与 `session-archive-list-v1`
2300
2959
  * (决定会话面板是否显示「已归档」入口)。
2301
- * 2. **对外声明** —— 其余 23 项。它们回答的是「这台服务端能做什么」,
2960
+ * 2. **对外声明** —— 其余所有能力位(数量以本文件数组为准,勿在注释里写数字,
2961
+ * 加一个位就要改注释的纪律只会让注释过期)。它们回答的是「这台服务端能做什么」,
2302
2962
  * **服务的是任何客户端(包括第三方)**,而不是某一处分支。
2303
2963
  *
2304
2964
  * 所以:**「我方客户端没用到」不等于「没人用」**。删掉第 2 类会破坏对外承诺
@@ -2351,15 +3011,50 @@ const BRIDGE_CAPABILITIES = [
2351
3011
  "job-kill-v1"
2352
3012
  ];
2353
3013
  /**
3014
+ * 运行时**不可用**的能力位:服务端此刻做不了这些事,就不该声明。
3015
+ *
3016
+ * ## 为什么必须过滤(这是本节最重要的一条)
3017
+ *
3018
+ * 能力位的全部意义是让客户端把「服务端不支持」与「本来就没有」分开
3019
+ * (见文件头的说明)。而 `jobController` 是**可选宿主服务**:0.2.0-rc.2 上没有它,
3020
+ * 三个 `/jobs` 端点在那套内核上根本不会被挂载(`index.ts` 直接不下发,路由返回 404)。
3021
+ * 若 `connection.ready` 仍然宣告 `job-list-v1`,客户端按位探测后就会去打一个必然
3022
+ * 404 的端点 —— 这恰好是这份清单本身想消灭的那种「静默少一个功能」。
3023
+ *
3024
+ * ## 为什么按「能力位 → 依赖的服务」映射,而不是在调用点手写过滤
3025
+ *
3026
+ * 声明与端点必须**同源**。把「哪个位依赖哪个可选服务」写在一处,将来再加一个
3027
+ * 可选服务只需要在这里补一行;散在调用点就会漂。
3028
+ */
3029
+ const CAPABILITY_REQUIREMENTS = {
3030
+ "job-list-v1": "jobs",
3031
+ "job-output-v1": "jobs",
3032
+ "job-kill-v1": "jobs"
3033
+ };
3034
+ /**
3035
+ * 这台进程此刻真的能提供的能力位。
3036
+ *
3037
+ * @param flags - 运行时可用性;**缺省当作「全都可用」**。
3038
+ * 缺省必须保守地全给:测试替身与老调用点(不传参)拿到的仍是完整清单,
3039
+ * 行为与加参数之前一字不差 —— 否则这个改动会悄悄改掉所有既有测试的期望。
3040
+ */
3041
+ function availableCapabilities(flags = {}) {
3042
+ return BRIDGE_CAPABILITIES.filter((capability) => {
3043
+ const requirement = CAPABILITY_REQUIREMENTS[capability];
3044
+ if (requirement === void 0) return true;
3045
+ return flags[requirement] !== false;
3046
+ });
3047
+ }
3048
+ /**
2354
3049
  * `connection.ready` 事件的载荷。
2355
3050
  *
2356
3051
  * 每次调用返回**新的副本**:调用方(或测试)改它不该影响常量,
2357
3052
  * 否则一处手滑就会让整个进程后续所有连接都收到被改过的能力清单。
2358
3053
  */
2359
- function connectionReadyData() {
3054
+ function connectionReadyData(flags = {}) {
2360
3055
  return {
2361
3056
  bridgeVersion: PLUGIN_VERSION,
2362
- capabilities: [...BRIDGE_CAPABILITIES]
3057
+ capabilities: availableCapabilities(flags)
2363
3058
  };
2364
3059
  }
2365
3060
  //#endregion
@@ -2477,25 +3172,39 @@ function clampStrings(value, limit, seen) {
2477
3172
  */
2478
3173
  function clampHistoryEvents(events, byteBudget = HISTORY_BYTE_BUDGET, perEventBudget = HISTORY_EVENT_BYTE_BUDGET) {
2479
3174
  let clampedStrings = 0;
2480
- const prepared = events.map((entry) => {
2481
- if (byteLength(entry) <= perEventBudget) return entry;
3175
+ const sizes = new Array(events.length);
3176
+ const prepared = new Array(events.length);
3177
+ for (let index = 0; index < events.length; index += 1) {
3178
+ const entry = events[index];
3179
+ const size = byteLength(entry);
3180
+ if (size <= perEventBudget) {
3181
+ sizes[index] = size;
3182
+ prepared[index] = entry;
3183
+ continue;
3184
+ }
2482
3185
  const result = clampStrings(entry, perEventBudget, /* @__PURE__ */ new WeakSet());
2483
- if (result.clamped === 0) return entry;
3186
+ if (result.clamped === 0) {
3187
+ sizes[index] = size;
3188
+ prepared[index] = entry;
3189
+ continue;
3190
+ }
2484
3191
  clampedStrings += result.clamped;
2485
3192
  const marker = { truncated: true };
2486
3193
  const clampedValue = result.value;
2487
- return clampedValue !== null && typeof clampedValue === "object" && !Array.isArray(clampedValue) ? {
3194
+ const marked = clampedValue !== null && typeof clampedValue === "object" && !Array.isArray(clampedValue) ? {
2488
3195
  ...clampedValue,
2489
3196
  bridgeTruncated: marker
2490
3197
  } : {
2491
3198
  value: clampedValue,
2492
3199
  bridgeTruncated: marker
2493
3200
  };
2494
- });
3201
+ prepared[index] = marked;
3202
+ sizes[index] = byteLength(marked);
3203
+ }
2495
3204
  const kept = [];
2496
3205
  let bytes = 0;
2497
3206
  for (let index = prepared.length - 1; index >= 0; index -= 1) {
2498
- const size = byteLength(prepared[index]);
3207
+ const size = sizes[index] ?? 0;
2499
3208
  if (kept.length > 0 && bytes + size > byteBudget) break;
2500
3209
  bytes += size;
2501
3210
  kept.push(prepared[index]);
@@ -2531,10 +3240,49 @@ function applyHistoryBudget(raw, byteBudget = HISTORY_BYTE_BUDGET, perEventBudge
2531
3240
  }
2532
3241
  //#endregion
2533
3242
  //#region src/host/chat-adapter.ts
3243
+ /** 会话可见性缓存的 TTL(见 `DshChatAdapter.sessionVisibility` 的说明)。 */
3244
+ const SESSION_VISIBILITY_TTL_MS = 2e3;
3245
+ /** 可见性缓存条目上限;到顶就按 TTL 清一遍(见 `rememberVisibility`)。 */
3246
+ const SESSION_VISIBILITY_CACHE_LIMIT = 2048;
3247
+ /**
3248
+ * 把 principal 收敛成一个稳定的缓存键。
3249
+ *
3250
+ * 为什么不用 `JSON.stringify(principal)`:`rootIds` 是 `Set`(序列化出来是 `{}`,
3251
+ * 所有设备会撞成同一个键 —— 那就是越权)。这里只用**身份**部分,
3252
+ * 授权范围的变化由 TTL 与 `sessionRootId` 那条独立缓存兜住。
3253
+ */
3254
+ function principalKey(principal) {
3255
+ return principal.kind === "admin" ? "admin" : `device:${principal.id}`;
3256
+ }
2534
3257
  var DshChatAdapter = class {
2535
3258
  api;
2536
3259
  database;
2537
3260
  commandServices;
3261
+ /**
3262
+ * 会话 → 可见性快照的短 TTL 缓存。
3263
+ *
3264
+ * ## 为什么需要(这是本文件最热的一处浪费)
3265
+ *
3266
+ * `requireSession()` 会在**几乎每个** API 调用里跑一遍 `listSessions()`,
3267
+ * 而 `listSessions` 一次要打两次内核 RPC(`sessions.list` + `workspace.list`)
3268
+ * 加一次 `database.listRoots()`。也就是说 history / commands / approvals /
3269
+ * models / questions / prompt 每条请求都白花两次内核往返 —— 只为回答
3270
+ * 「这个 session 能不能被这台设备看见」。
3271
+ *
3272
+ * ## 为什么 TTL 是 2 秒(短到近乎无感,长到足以省掉重复)
3273
+ *
3274
+ * 客户端在打开一个会话时会**连续**打四五个接口(历史、审批、提问、命令、模型),
3275
+ * 2 秒足以把这一串并成一两次真实查询。再长就会让「刚建的会话看不到」
3276
+ * 变成一个可感受到的延迟 —— 而那正是我们要避免的另一类 bug。
3277
+ *
3278
+ * ## 缓存的是什么(关键:缓存的是**授权结论**,不是会话内容)
3279
+ *
3280
+ * 只缓存「这个 sessionId 对当前设备可见吗」这一个布尔结论,且按 `principal.id`
3281
+ * 分桶。**不缓存会话正文**:正文会被 `rename`/`archive`/新消息改掉,
3282
+ * 缓存它等于制造陈旧数据;而可见性只随「根授权变没变」与「会话删没删」变,
3283
+ * 两者都是低频且由用户操作驱动的。
3284
+ */
3285
+ sessionVisibility = /* @__PURE__ */ new Map();
2538
3286
  sessionRootIds = /* @__PURE__ */ new Map();
2539
3287
  sessionRootLookups = /* @__PURE__ */ new Map();
2540
3288
  pendingApprovals = /* @__PURE__ */ new Map();
@@ -3028,8 +3776,13 @@ var DshChatAdapter = class {
3028
3776
  }
3029
3777
  }
3030
3778
  async isSessionAuthorized(principal, sessionId) {
3779
+ const key = `${principalKey(principal)}\u0000${sessionId}`;
3780
+ const cached = this.sessionVisibility.get(key);
3781
+ if (cached !== void 0 && Date.now() - cached.visibleAt < SESSION_VISIBILITY_TTL_MS) return cached.visible;
3031
3782
  const rootId = await this.sessionRootId(sessionId);
3032
- return rootId !== void 0 && (principal.rootIds === "all" || principal.rootIds.has(rootId));
3783
+ const allowed = rootId !== void 0 && (principal.rootIds === "all" || principal.rootIds.has(rootId));
3784
+ this.rememberVisibility(key, allowed);
3785
+ return allowed;
3033
3786
  }
3034
3787
  startEvents(signal, emit) {
3035
3788
  const run = async (kind) => {
@@ -3092,10 +3845,37 @@ var DshChatAdapter = class {
3092
3845
  return services.agents.get(sessionId);
3093
3846
  }
3094
3847
  async requireSession(principal, sessionId) {
3848
+ const key = `${principalKey(principal)}\u0000${sessionId}`;
3849
+ const cached = this.sessionVisibility.get(key);
3850
+ if (cached !== void 0 && Date.now() - cached.visibleAt < SESSION_VISIBILITY_TTL_MS) {
3851
+ if (cached.visible) {
3852
+ const session = (await this.listSessions(principal)).find((item) => item.id === sessionId);
3853
+ if (session !== void 0) return session;
3854
+ this.sessionVisibility.delete(key);
3855
+ } else throw new ApiError(404, "SESSION_NOT_FOUND", "The session is not visible to this device.");
3856
+ }
3095
3857
  const session = (await this.listSessions(principal)).find((item) => item.id === sessionId);
3858
+ this.rememberVisibility(key, session !== void 0);
3096
3859
  if (session === void 0) throw new ApiError(404, "SESSION_NOT_FOUND", "The session is not visible to this device.");
3097
3860
  return session;
3098
3861
  }
3862
+ /**
3863
+ * 记一条可见性结论,并顺手把过期条目清掉。
3864
+ *
3865
+ * 为什么在这里做清扫而不是定时器:缓存条目数与「设备数 × 会话数」同阶,
3866
+ * 对单机部署来说是千级 —— 每次写入顺手扫一遍完全够,而且**没有定时器泄漏**。
3867
+ * (本仓的纪律:服务端不引入 `setInterval`,见 `server.ts` 的心跳是唯一的例外,
3868
+ * 且它 `unref` 过。)
3869
+ */
3870
+ rememberVisibility(key, visible) {
3871
+ const now = Date.now();
3872
+ this.sessionVisibility.set(key, {
3873
+ visibleAt: now,
3874
+ visible
3875
+ });
3876
+ if (this.sessionVisibility.size < SESSION_VISIBILITY_CACHE_LIMIT) return;
3877
+ for (const [entryKey, entry] of this.sessionVisibility) if (now - entry.visibleAt >= SESSION_VISIBILITY_TTL_MS) this.sessionVisibility.delete(entryKey);
3878
+ }
3099
3879
  async requireWorkspace(principal, workspaceId) {
3100
3880
  const workspace = (await this.listWorkspaces(principal)).find((item) => item.id === workspaceId);
3101
3881
  if (workspace === void 0) throw new ApiError(404, "WORKSPACE_NOT_FOUND", "The DSH workspace is not visible to this device.");
@@ -3198,7 +3978,7 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
3198
3978
  }
3199
3979
  };
3200
3980
  }
3201
- if (payloadType === "session/event" && typeof payload.sessionId === "string" && isRecord$1(payload.event)) {
3981
+ if (payloadType === "session/event" && typeof payload.sessionId === "string" && isRecord$2(payload.event)) {
3202
3982
  const event = payload.event;
3203
3983
  const eventType = typeof event.type === "string" ? event.type : "unknown";
3204
3984
  const seq = typeof event.seq === "number" ? event.seq : void 0;
@@ -3208,9 +3988,9 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
3208
3988
  ...seq === void 0 ? {} : { seq }
3209
3989
  };
3210
3990
  const assistantData = event.data;
3211
- if (eventType === "assistant/chunk" && isRecord$1(assistantData)) {
3991
+ if (eventType === "assistant/chunk" && isRecord$2(assistantData)) {
3212
3992
  const rawChunk = assistantData.chunk;
3213
- if (isRecord$1(rawChunk)) {
3993
+ if (isRecord$2(rawChunk)) {
3214
3994
  const kind = rawChunk.type === "text-delta" ? "text" : rawChunk.type === "reasoning-delta" ? "reasoning" : void 0;
3215
3995
  if (kind !== void 0 && typeof rawChunk.text === "string" && typeof rawChunk.index === "number") return {
3216
3996
  id: frame.rpcId,
@@ -3228,7 +4008,7 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
3228
4008
  }
3229
4009
  }
3230
4010
  if (eventType === "user/message" || eventType === "assistant/message") {
3231
- const eventData = isRecord$1(event.data) ? event.data : {};
4011
+ const eventData = isRecord$2(event.data) ? event.data : {};
3232
4012
  return {
3233
4013
  id: frame.rpcId,
3234
4014
  type: "chat.message.committed",
@@ -3242,8 +4022,8 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
3242
4022
  };
3243
4023
  }
3244
4024
  if (eventType === "deliverables/presented") {
3245
- const data = isRecord$1(payload.event) ? payload.event.data : void 0;
3246
- const files = isRecord$1(data) && Array.isArray(data.files) ? data.files : [];
4025
+ const data = isRecord$2(payload.event) ? payload.event.data : void 0;
4026
+ const files = isRecord$2(data) && Array.isArray(data.files) ? data.files : [];
3247
4027
  return {
3248
4028
  id: `${payload.sessionId}:deliverables:${seq ?? files.length}`,
3249
4029
  type: "chat.deliverables.presented",
@@ -3271,7 +4051,7 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
3271
4051
  };
3272
4052
  }
3273
4053
  }
3274
- const turnReason = eventType === "turn/end" && isRecord$1(event.data) ? event.data.reason : void 0;
4054
+ const turnReason = eventType === "turn/end" && isRecord$2(event.data) ? event.data.reason : void 0;
3275
4055
  return {
3276
4056
  id: frame.rpcId,
3277
4057
  type: eventType === "turn/start" ? "chat.turn.start" : "chat.turn.end",
@@ -3331,12 +4111,12 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
3331
4111
  * 用本地化文案「API 密钥无效」顶上。这里保持原样转发,不替客户端猜。
3332
4112
  */
3333
4113
  function turnFailureOf(event) {
3334
- const reason = isRecord$1(event.data) ? event.data.reason : void 0;
3335
- if (!isRecord$1(reason)) return void 0;
4114
+ const reason = isRecord$2(event.data) ? event.data.reason : void 0;
4115
+ if (!isRecord$2(reason)) return void 0;
3336
4116
  const kind = reason.kind;
3337
4117
  if (kind === "error") {
3338
4118
  const error = reason.error;
3339
- if (!isRecord$1(error)) return { message: String(error ?? "本轮运行失败") };
4119
+ if (!isRecord$2(error)) return { message: String(error ?? "本轮运行失败") };
3340
4120
  const code = typeof error.code === "string" ? error.code : void 0;
3341
4121
  const message = typeof error.message === "string" ? error.message : JSON.stringify(error);
3342
4122
  return code === void 0 ? { message } : {
@@ -3344,13 +4124,13 @@ function turnFailureOf(event) {
3344
4124
  code
3345
4125
  };
3346
4126
  }
3347
- if (kind === "aborted" && isRecord$1(reason.reason) && reason.reason.kind === "hook" && reason.reason.reason === "deepseek-account/signed-out") return {
4127
+ if (kind === "aborted" && isRecord$2(reason.reason) && reason.reason.kind === "hook" && reason.reason.reason === "deepseek-account/signed-out") return {
3348
4128
  message: "Stopped because you signed out of DeepSeek.",
3349
4129
  code: "ACCOUNT_SIGNED_OUT",
3350
4130
  stopped: true
3351
4131
  };
3352
4132
  }
3353
- function isRecord$1(value) {
4133
+ function isRecord$2(value) {
3354
4134
  return typeof value === "object" && value !== null && !Array.isArray(value);
3355
4135
  }
3356
4136
  function matchRoot(roots, cwd) {
@@ -3436,13 +4216,13 @@ function findToolCallDetail(events, callId) {
3436
4216
  if (callId === void 0) return void 0;
3437
4217
  for (let index = events.length - 1; index >= 0; index -= 1) {
3438
4218
  const candidate = events[index];
3439
- if (!isRecord$1(candidate)) continue;
3440
- const event = isRecord$1(candidate.event) ? candidate.event : candidate;
3441
- if (event.type !== "tool/call" || !isRecord$1(event.data)) continue;
4219
+ if (!isRecord$2(candidate)) continue;
4220
+ const event = isRecord$2(candidate.event) ? candidate.event : candidate;
4221
+ if (event.type !== "tool/call" || !isRecord$2(event.data)) continue;
3442
4222
  const data = event.data;
3443
4223
  if ((typeof data.callId === "string" ? data.callId : typeof data.id === "string" ? data.id : void 0) !== callId) continue;
3444
4224
  const args = decodeToolArguments(data.arguments);
3445
- if (isRecord$1(args) && typeof args.command === "string") return args.command;
4225
+ if (isRecord$2(args) && typeof args.command === "string") return args.command;
3446
4226
  if (typeof args === "string") return args;
3447
4227
  if (args !== void 0) return JSON.stringify(args, null, 2);
3448
4228
  }
@@ -3511,15 +4291,42 @@ var WorkspaceDatabase = class {
3511
4291
  trashDir;
3512
4292
  db;
3513
4293
  closed = false;
3514
- constructor(dataDir) {
3515
- this.dataDir = path.resolve(dataDir);
3516
- this.trashDir = path.join(this.dataDir, "trash");
3517
- mkdirSync(this.trashDir, { recursive: true });
3518
- this.db = new DatabaseSync(path.join(this.dataDir, "workspace.sqlite"));
4294
+ /**
4295
+ * 「设备集合或设备权限发生过写」的进程内单调计数。
4296
+ *
4297
+ * ## 为什么需要它
4298
+ *
4299
+ * WebSocket 广播的每一条事件都要确认「这条连接背后的设备还有效吗、scope/root 变了吗」
4300
+ * —— 旧实现是**每条事件查一次库**(`getActiveDevice`)。多条连接 × 持续事件
4301
+ * 就是成百次纯浪费的 SQLite 查询。
4302
+ *
4303
+ * 有了这个计数,`server.ts` 可以只在**它变了**的时候重新水化设备
4304
+ * (特性探测:getter 不存在就退回逐事件查库,所以迟到或缺席都不会更差)。
4305
+ *
4306
+ * ## 语义(与 perf-reader 的 PERF-1 契约一致)
4307
+ *
4308
+ * 「任何改变**设备是否存在 / scope / rootIds** 的写」都要 +1。当前四处:
4309
+ * `exchangePairing` / `updateDevice` / `revokeDevice` / `purgeRevokedDevices`。
4310
+ *
4311
+ * 根(roots)的增删**不** +1:设备记录本身没变;`/roots` 那条路径本来就是实时查库。
4312
+ *
4313
+ * 保守性:宁可多 +1(多一次缓存失效,代价是一次查询),不可漏 +1
4314
+ * (漏了就是「撤销后仍能收事件」—— 那正是 `device-revocation` 要防的事)。
4315
+ */
4316
+ devicesRevisionCounter = 0;
4317
+ constructor(dataDir) {
4318
+ this.dataDir = path.resolve(dataDir);
4319
+ this.trashDir = path.join(this.dataDir, "trash");
4320
+ mkdirSync(this.trashDir, { recursive: true });
4321
+ this.db = new DatabaseSync(path.join(this.dataDir, "workspace.sqlite"));
3519
4322
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
3520
4323
  this.migrate();
3521
4324
  openDatabases.add(new WeakRef(this));
3522
4325
  }
4326
+ /** 当前设备状态版本号。用途见字段注释(`server.ts` 的广播缓存失效)。 */
4327
+ get devicesRevision() {
4328
+ return this.devicesRevisionCounter;
4329
+ }
3523
4330
  /** 关闭数据库。**幂等** —— 重复调用不抛错。 */
3524
4331
  close() {
3525
4332
  if (this.closed) return;
@@ -3543,6 +4350,13 @@ var WorkspaceDatabase = class {
3543
4350
  setSetting(key, value) {
3544
4351
  this.db.prepare("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);
3545
4352
  }
4353
+ /**
4354
+ * 登记一条授权根。
4355
+ *
4356
+ * @param options.requestId - 发起这次登记的那条 HTTP 请求的 id。
4357
+ * 审计的用途不只是「记下发生了什么」,还要能回答「**哪一次请求**干的」;
4358
+ * 不传(插件启动期登记内置根)时审计行的 `request_id` 为 null。
4359
+ */
3546
4360
  async addRoot(inputPath, label, options) {
3547
4361
  const realPath = await canonicalRoot(inputPath);
3548
4362
  if (options?.allowDuplicate !== true) {
@@ -3553,7 +4367,7 @@ var WorkspaceDatabase = class {
3553
4367
  const createdAt = Date.now();
3554
4368
  const resolvedLabel = label?.trim() || path.basename(realPath) || realPath;
3555
4369
  this.db.prepare("INSERT INTO roots(id,label,real_path,created_at,builtin) VALUES(?,?,?,?,0)").run(id, resolvedLabel, realPath, createdAt);
3556
- this.audit("admin", "loopback-admin", "root.add", id, void 0, { label: resolvedLabel });
4370
+ this.audit("admin", "loopback-admin", "root.add", id, void 0, { label: resolvedLabel }, options?.requestId);
3557
4371
  return {
3558
4372
  id,
3559
4373
  label: resolvedLabel,
@@ -3611,7 +4425,12 @@ var WorkspaceDatabase = class {
3611
4425
  if (this.db.prepare("DELETE FROM roots WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "ROOT_NOT_FOUND", "The authorized root does not exist.");
3612
4426
  this.audit("admin", "loopback-admin", "root.remove", id);
3613
4427
  }
3614
- createPairing(rootIds, scopes, ttlMs = 6e5) {
4428
+ /**
4429
+ * 生成一枚配对码。
4430
+ *
4431
+ * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
4432
+ */
4433
+ createPairing(rootIds, scopes, ttlMs = 6e5, options = {}) {
3615
4434
  assertScopes(scopes);
3616
4435
  for (const rootId of rootIds) if (this.getRoot(rootId) === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", `Unknown root ${rootId}.`);
3617
4436
  const raw = randomBytes(5).toString("hex").toUpperCase();
@@ -3622,13 +4441,20 @@ var WorkspaceDatabase = class {
3622
4441
  rootIds,
3623
4442
  scopes,
3624
4443
  expiresAt
3625
- });
4444
+ }, options.requestId);
3626
4445
  return {
3627
4446
  code,
3628
4447
  expiresAt
3629
4448
  };
3630
4449
  }
3631
- exchangePairing(code, deviceName) {
4450
+ /**
4451
+ * 用配对码换一枚设备令牌。
4452
+ *
4453
+ * 审计**在事务内**写(FUNC-5):它是「这台设备被授权了」的唯一留痕,
4454
+ * 与设备行同生共死。审计写不进去 → 整个兑换回滚 → 客户端重试,
4455
+ * 而不是「拿到令牌但库里没有这笔授权的记录」。
4456
+ */
4457
+ exchangePairing(code, deviceName, options = {}) {
3632
4458
  const normalized = normalizePairingCode(code);
3633
4459
  const now = Date.now();
3634
4460
  this.db.exec("BEGIN IMMEDIATE");
@@ -3644,18 +4470,18 @@ var WorkspaceDatabase = class {
3644
4470
  this.db.prepare("INSERT INTO devices(id,name,token_hash,scopes_json,created_at) VALUES(?,?,?,?,?)").run(id, name, hashSecret(token), JSON.stringify(scopes), now);
3645
4471
  const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
3646
4472
  for (const rootId of rootIds) grant.run(id, rootId);
4473
+ this.audit("device", id, "pairing.exchange", void 0, void 0, { name }, options.requestId);
3647
4474
  this.db.exec("COMMIT");
3648
- const device = {
3649
- id,
3650
- name,
3651
- scopes,
3652
- rootIds,
3653
- createdAt: now
3654
- };
3655
- this.audit("device", id, "pairing.exchange", void 0, void 0, { name });
4475
+ this.devicesRevisionCounter += 1;
3656
4476
  return {
3657
4477
  token,
3658
- device
4478
+ device: {
4479
+ id,
4480
+ name,
4481
+ scopes,
4482
+ rootIds,
4483
+ createdAt: now
4484
+ }
3659
4485
  };
3660
4486
  } catch (error) {
3661
4487
  this.db.exec("ROLLBACK");
@@ -3701,7 +4527,16 @@ var WorkspaceDatabase = class {
3701
4527
  isDeviceActive(id) {
3702
4528
  return this.db.prepare("SELECT 1 AS active FROM devices WHERE id = ? AND revoked_at IS NULL").get(id) !== void 0;
3703
4529
  }
3704
- updateDevice(id, scopes, rootIds) {
4530
+ /**
4531
+ * 改一台设备的 scope 与 root 授权。
4532
+ *
4533
+ * 审计**在事务内**写(FUNC-5):它是「谁的权限被扩大了」的唯一留痕。
4534
+ * 审计失败 → 整个更新回滚。宁可让用户重试,也不能出现「权限已经变宽、
4535
+ * 但没有任何记录说是谁在什么时候改的」—— 那是不可审计的提权。
4536
+ *
4537
+ * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
4538
+ */
4539
+ updateDevice(id, scopes, rootIds, options = {}) {
3705
4540
  assertScopes(scopes);
3706
4541
  for (const rootId of rootIds) if (this.getRoot(rootId) === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", `Unknown root ${rootId}.`);
3707
4542
  this.db.exec("BEGIN IMMEDIATE");
@@ -3710,16 +4545,17 @@ var WorkspaceDatabase = class {
3710
4545
  this.db.prepare("DELETE FROM device_roots WHERE device_id = ?").run(id);
3711
4546
  const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
3712
4547
  for (const rootId of rootIds) grant.run(id, rootId);
4548
+ this.audit("admin", "loopback-admin", "device.update", void 0, void 0, {
4549
+ deviceId: id,
4550
+ scopes,
4551
+ rootIds
4552
+ }, options.requestId);
3713
4553
  this.db.exec("COMMIT");
3714
4554
  } catch (error) {
3715
4555
  this.db.exec("ROLLBACK");
3716
4556
  throw error;
3717
4557
  }
3718
- this.audit("admin", "loopback-admin", "device.update", void 0, void 0, {
3719
- deviceId: id,
3720
- scopes,
3721
- rootIds
3722
- });
4558
+ this.devicesRevisionCounter += 1;
3723
4559
  const device = this.listDevices().find((item) => item.id === id);
3724
4560
  if (device === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The device does not exist.");
3725
4561
  return device;
@@ -3737,14 +4573,31 @@ var WorkspaceDatabase = class {
3737
4573
  * - `authenticate` 仍保留 `revoked_at IS NULL` 作为兜底,万一有别处留下的标记行,
3738
4574
  * 它们依然登不进来 —— 两层保险不冲突。
3739
4575
  */
3740
- revokeDevice(id) {
4576
+ /**
4577
+ * 撤销一台设备 = 删除设备行。
4578
+ *
4579
+ * 审计与删除**在同一事务里**(FUNC-5),且顺序是「先留痕、再删除」:
4580
+ * 审计的 `actor_id` 不带外键,删设备不会把记录带走。反过来若先删后写、
4581
+ * 而写审计失败,就会留下「设备已经没了,但没有任何记录说谁撤的」。
4582
+ *
4583
+ * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
4584
+ */
4585
+ revokeDevice(id, options = {}) {
3741
4586
  const active = this.getActiveDevice(id);
3742
4587
  if (active === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
3743
- this.audit("admin", "loopback-admin", "device.revoke", void 0, void 0, {
3744
- deviceId: id,
3745
- name: active.name
3746
- });
3747
- if (this.db.prepare("DELETE FROM devices WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
4588
+ this.db.exec("BEGIN IMMEDIATE");
4589
+ try {
4590
+ this.audit("admin", "loopback-admin", "device.revoke", void 0, void 0, {
4591
+ deviceId: id,
4592
+ name: active.name
4593
+ }, options.requestId);
4594
+ if (this.db.prepare("DELETE FROM devices WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
4595
+ this.db.exec("COMMIT");
4596
+ } catch (error) {
4597
+ this.db.exec("ROLLBACK");
4598
+ throw error;
4599
+ }
4600
+ this.devicesRevisionCounter += 1;
3748
4601
  }
3749
4602
  /**
3750
4603
  * 一次性清理历史遗留的「标记式」撤销设备(`revoked_at IS NOT NULL` 的行)。
@@ -3763,22 +4616,127 @@ var WorkspaceDatabase = class {
3763
4616
  * 干净的办法制造这种行,而它不该走生产代码路径 —— 所以显式开一个测试专用入口,
3764
4617
  * 并起一个自见其明的名字,免得有人把它当成正常 API。
3765
4618
  */
4619
+ /**
4620
+ * 仅供测试:把某台设备的「最后使用时间/创建时间」拨到过去。
4621
+ *
4622
+ * 生产路径没法造出「30 天没动过」的设备(认证一次就刷新 `last_seen_at`),而
4623
+ * `pruneInactiveDevices` 的核心语义恰恰只在这种时间跨度上才看得见。与
4624
+ * `markRevokedForTest` 同样的理由:显式开一个自见其明的测试入口,别让人误当 API。
4625
+ */
4626
+ backdateDeviceForTest(id, options) {
4627
+ if (options.lastSeenAt !== void 0) this.db.prepare("UPDATE devices SET last_seen_at = ? WHERE id = ?").run(options.lastSeenAt, id);
4628
+ if (options.createdAt !== void 0) this.db.prepare("UPDATE devices SET created_at = ? WHERE id = ?").run(options.createdAt, id);
4629
+ }
3766
4630
  markRevokedForTest(id) {
3767
4631
  this.db.prepare("UPDATE devices SET revoked_at = ? WHERE id = ?").run(Date.now(), id);
3768
4632
  }
4633
+ /**
4634
+ * 按「最后使用时间」批量清掉长期没动过的设备。
4635
+ *
4636
+ * 「最后使用」取 `last_seen_at`(每次令牌认证成功都会刷新),从没认证过的设备退回
4637
+ * `created_at` —— 否则一条配好就再没开过的设备会永远躲过清理,而那恰恰是用户最想
4638
+ * 一键清掉的那类(换手机、临时配对、旧测试探针)。
4639
+ *
4640
+ * 与 `purgeRevokedDevices` 的区别:后者清的是历史遗留的「标记式撤销」脏行,这里清的是
4641
+ * **仍然有效、但已经不用了**的设备 —— 用户的原话是「一键清理多少天未使用设备」。
4642
+ *
4643
+ * 语义边界(故意这么定):
4644
+ * - 只删设备行 + 级联的 `device_roots`,不碰审计(审计的 `actor_id` 不是外键);
4645
+ * - **不删本机正在用的那台**(`keepDeviceId`):调用方就是它,删掉自己会让当前
4646
+ * 客户端立刻 401,看起来像「点一下清理把手机踢下线了」;
4647
+ * - 天数被夹在 1..3650,避免 `0` 或负数变成「清空所有设备」;
4648
+ * - 返回真正删掉的条数,0 也是一次合法结果。
4649
+ */
4650
+ pruneInactiveDevices(olderThanDays, options = {}) {
4651
+ const days = Math.min(3650, Math.max(1, Math.trunc(olderThanDays)));
4652
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
4653
+ const rows = this.db.prepare("SELECT id, name FROM devices WHERE COALESCE(last_seen_at, created_at) < ? AND id IS NOT ?").all(cutoff, options.keepDeviceId ?? null);
4654
+ if (rows.length === 0) {
4655
+ this.devicesRevisionCounter += 1;
4656
+ return 0;
4657
+ }
4658
+ this.db.exec("BEGIN IMMEDIATE");
4659
+ try {
4660
+ const statement = this.db.prepare("DELETE FROM devices WHERE id = ?");
4661
+ for (const row of rows) statement.run(row.id);
4662
+ this.audit("admin", "loopback-admin", "device.prune", void 0, void 0, {
4663
+ olderThanDays: days,
4664
+ pruned: rows.length,
4665
+ names: rows.map((row) => row.name)
4666
+ }, options.requestId);
4667
+ this.db.exec("COMMIT");
4668
+ } catch (error) {
4669
+ this.db.exec("ROLLBACK");
4670
+ throw error;
4671
+ }
4672
+ this.devicesRevisionCounter += 1;
4673
+ return rows.length;
4674
+ }
3769
4675
  purgeRevokedDevices() {
3770
- if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 0) return 0;
4676
+ if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 0) {
4677
+ this.devicesRevisionCounter += 1;
4678
+ return 0;
4679
+ }
3771
4680
  this.db.exec("BEGIN IMMEDIATE");
3772
4681
  try {
3773
4682
  const result = this.db.prepare("DELETE FROM devices WHERE revoked_at IS NOT NULL").run();
3774
- this.db.exec("COMMIT");
3775
4683
  this.audit("admin", "loopback-admin", "device.purge", void 0, void 0, { purged: result.changes });
4684
+ this.db.exec("COMMIT");
4685
+ this.devicesRevisionCounter += 1;
3776
4686
  return Number(result.changes);
3777
4687
  } catch (error) {
3778
4688
  this.db.exec("ROLLBACK");
3779
4689
  throw error;
3780
4690
  }
3781
4691
  }
4692
+ /**
4693
+ * 删掉早于 `cutoffMs` 的幂等回放记录,返回删除行数。
4694
+ *
4695
+ * 由 `retention.ts`(perf-reader)按调度调用,本方法只做 SQL —— 不判断策略、
4696
+ * 不写日志,所以它可以被单独测试。
4697
+ *
4698
+ * 为什么用 `created_at` 而不是加一列 `expires_at`:24 小时的过期**完全**由
4699
+ * `created_at` 推导得出,加列会让每次插入多写一个字段,还多一次 migration 风险。
4700
+ */
4701
+ pruneIdempotency(cutoffMs) {
4702
+ const result = this.db.prepare("DELETE FROM idempotency WHERE created_at < ?").run(cutoffMs);
4703
+ return Number(result.changes);
4704
+ }
4705
+ /**
4706
+ * 清理审计:先按时间删,再兜「总数超过 `keepMax` 就删最旧的」。
4707
+ *
4708
+ * ## 为什么是两段而不是一段
4709
+ *
4710
+ * 只按时间删不够:桥可以被一个「短时间高频写入」的场景灌满审计表
4711
+ * (例如每秒几十条 `chat.delta` 对应的写操作),时间窗内的量仍然能压垮库。
4712
+ * 只按数量删也不够:用户可能希望「一年前的都清掉」,而不在乎条数。
4713
+ *
4714
+ * ## `keepMax` 是**下限保护**
4715
+ *
4716
+ * 即使 `cutoffMs` 设成了未来(把全部行都判成过期),也**至少**保留最近
4717
+ * `keepMax` 条 —— 一句写错的调用不该把审计清空。审计被清空本身就等于
4718
+ * 「抹掉证据」,这个方向必须由这一层挡住。
4719
+ *
4720
+ * 返回删除的行数(两段之和)。
4721
+ */
4722
+ pruneAudit(cutoffMs, keepMax) {
4723
+ const floor = Math.max(0, Math.floor(keepMax));
4724
+ this.db.exec("BEGIN IMMEDIATE");
4725
+ try {
4726
+ const total = Number(this.db.prepare("SELECT COUNT(*) AS n FROM audit").get().n);
4727
+ const expired = Number(this.db.prepare("SELECT COUNT(*) AS n FROM audit WHERE time < ?").get(cutoffMs).n);
4728
+ const removableByTime = Math.max(0, Math.min(expired, total - floor));
4729
+ let removed = 0;
4730
+ if (removableByTime > 0) removed += Number(this.db.prepare("DELETE FROM audit WHERE id IN (SELECT id FROM audit ORDER BY time ASC, id ASC LIMIT ?)").run(removableByTime).changes);
4731
+ const over = Number(this.db.prepare("SELECT COUNT(*) AS n FROM audit").get().n) - floor;
4732
+ if (over > 0) removed += Number(this.db.prepare("DELETE FROM audit WHERE id IN (SELECT id FROM audit ORDER BY time ASC, id ASC LIMIT ?)").run(over).changes);
4733
+ this.db.exec("COMMIT");
4734
+ return removed;
4735
+ } catch (error) {
4736
+ this.db.exec("ROLLBACK");
4737
+ throw error;
4738
+ }
4739
+ }
3782
4740
  insertTrash(record) {
3783
4741
  this.db.prepare("INSERT INTO trash(id,root_id,relative_path,payload_path,kind,size,digest,created_at,status) VALUES(?,?,?,?,?,?,?,?,?)").run(record.id, record.rootId, record.relativePath, record.payloadPath, record.kind, record.size, record.digest, record.createdAt, record.status);
3784
4742
  }
@@ -3944,6 +4902,14 @@ var WorkspaceEventBus = class {
3944
4902
  };
3945
4903
  //#endregion
3946
4904
  //#region src/host/file-service.ts
4905
+ /**
4906
+ * 列目录时同时 `lstat` 多少个子项。
4907
+ *
4908
+ * 32 是本仓已经用过、也验证过的并发量级(见 `plugin-inventory.ts` 的
4909
+ * `withUpdateCheck` 用的是 6 个 worker;这里是短促的 stat,可以更宽一点)。
4910
+ * 关键不是这个数字本身,而是**有界**:一把放 500 个在网络盘上会把请求队列打满。
4911
+ */
4912
+ const LIST_STAT_CONCURRENCY = 32;
3947
4913
  var FileService = class {
3948
4914
  database;
3949
4915
  emit;
@@ -3962,18 +4928,21 @@ var FileService = class {
3962
4928
  const pageSize = Math.min(Math.max(limit, 1), 500);
3963
4929
  const selected = names.slice(start, start + pageSize);
3964
4930
  const entries = [];
3965
- for (const name of selected) {
3966
- const absolute = path.join(resolved.absolutePath, name);
3967
- const info = await lstat(absolute);
3968
- const childPath = relativePath === "" ? name : `${relativePath}/${name}`;
3969
- entries.push({
3970
- name,
3971
- path: childPath,
3972
- kind: kindOf(info),
3973
- size: info.isFile() ? info.size : 0,
3974
- modifiedAt: info.mtimeMs,
3975
- writable: (info.mode & 128) !== 0
3976
- });
4931
+ for (let offset = 0; offset < selected.length; offset += LIST_STAT_CONCURRENCY) {
4932
+ const batch = selected.slice(offset, offset + LIST_STAT_CONCURRENCY);
4933
+ const rows = await Promise.all(batch.map(async (name) => {
4934
+ const absolute = path.join(resolved.absolutePath, name);
4935
+ const info = await lstat(absolute);
4936
+ return {
4937
+ name,
4938
+ path: relativePath === "" ? name : `${relativePath}/${name}`,
4939
+ kind: kindOf(info),
4940
+ size: info.isFile() ? info.size : 0,
4941
+ modifiedAt: info.mtimeMs,
4942
+ writable: (info.mode & 128) !== 0
4943
+ };
4944
+ }));
4945
+ entries.push(...rows);
3977
4946
  }
3978
4947
  const last = selected.at(-1);
3979
4948
  return {
@@ -3987,12 +4956,11 @@ var FileService = class {
3987
4956
  const resolved = await resolveAuthorizedPath(root.realPath, relativePath);
3988
4957
  const info = await lstat(resolved.absolutePath);
3989
4958
  if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files have readable content.");
3990
- const etag = await fileEtag(resolved.absolutePath);
3991
4959
  return {
3992
4960
  absolutePath: resolved.absolutePath,
3993
4961
  size: info.size,
3994
4962
  modifiedAt: info.mtimeMs,
3995
- etag,
4963
+ etag: etagFromStat(info),
3996
4964
  contentType: contentTypeFor$1(relativePath)
3997
4965
  };
3998
4966
  }
@@ -4010,8 +4978,8 @@ var FileService = class {
4010
4978
  if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files can be overwritten.");
4011
4979
  if (conditions.ifNoneMatch === "*") throw new ApiError(412, "PATH_ALREADY_EXISTS", "The file already exists.");
4012
4980
  if (conditions.ifMatch === void 0) throw new ApiError(428, "ETAG_REQUIRED", "If-Match is required when overwriting a file.");
4013
- const currentEtag = await fileEtag(resolved.absolutePath);
4014
- if (conditions.ifMatch !== currentEtag) throw new ApiError(412, "ETAG_MISMATCH", "The file changed after it was read.", { currentEtag });
4981
+ const currentEtag = etagFromStat(info);
4982
+ if (!etagEqual(conditions.ifMatch, currentEtag)) throw new ApiError(412, "ETAG_MISMATCH", "The file changed after it was read.", { currentEtag });
4015
4983
  mode = info.mode;
4016
4984
  } else if (conditions.ifNoneMatch !== "*") throw new ApiError(428, "CREATE_CONDITION_REQUIRED", "If-None-Match: * is required when creating file content.");
4017
4985
  const temp = path.join(parent.absolutePath, `.${path.basename(resolved.absolutePath)}.dsh-${randomUUID()}.tmp`);
@@ -4024,8 +4992,8 @@ var FileService = class {
4024
4992
  await handle.close();
4025
4993
  }
4026
4994
  if (resolved.exists) {
4027
- const currentEtag = await fileEtag(resolved.absolutePath);
4028
- if (conditions.ifMatch !== currentEtag) throw new ApiError(412, "ETAG_MISMATCH", "The file changed while the replacement was prepared.", { currentEtag });
4995
+ const currentEtag = etagFromStat(await lstat(resolved.absolutePath));
4996
+ if (!etagEqual(conditions.ifMatch, currentEtag)) throw new ApiError(412, "ETAG_MISMATCH", "The file changed while the replacement was prepared.", { currentEtag });
4029
4997
  }
4030
4998
  await replaceFile(temp, resolved.absolutePath, resolved.exists);
4031
4999
  } catch (error) {
@@ -4033,7 +5001,7 @@ var FileService = class {
4033
5001
  throw error;
4034
5002
  }
4035
5003
  const info = await stat(resolved.absolutePath);
4036
- const etag = await fileEtag(resolved.absolutePath);
5004
+ const etag = etagFromStat(info);
4037
5005
  this.notify("file.changed", root.id, relativePath, {
4038
5006
  etag,
4039
5007
  size: info.size
@@ -4171,10 +5139,75 @@ var FileService = class {
4171
5139
  });
4172
5140
  }
4173
5141
  };
4174
- async function fileEtag(filePath) {
4175
- const hash = createHash("sha256");
4176
- await pipeline(createReadStream(filePath), hash);
4177
- return `"sha256-${hash.digest("base64url")}"`;
5142
+ /**
5143
+ * 一个文件的**弱验证器**:`W/"<size>-<mtimeMs>"`(两者都用 base36 缩短)。
5144
+ *
5145
+ * ## 为什么不再用全文件 sha256
5146
+ *
5147
+ * 修复前是 `sha256(整个文件)`:
5148
+ *
5149
+ * - `inspectContent`(本文件的 `:93`)在**每一个 GET** 都算一次 —— 一个 100 MiB 的
5150
+ * 文件被读一次,服务端就额外读 100 MiB、算一次 sha256;
5151
+ * - `writeContent` 在写前、写后、替换后再算(最多 **3 次**);
5152
+ * - 而 `router.ts` 的 304 判断只需要「这个值变没变」,不需要「内容指纹」。
5153
+ *
5154
+ * 改成 stat-only 之后是 **O(1)**:一个字节都不读。这正是 Range 下载/大文件预览
5155
+ * 场景里最划算的一笔优化 —— 它不是什么高级技巧,只是把「哈希整个文件」换成
5156
+ * 「问一下 stat」。
5157
+ *
5158
+ * ## 为什么必须标 `W/`(弱)
5159
+ *
5160
+ * size + mtime 无法区分「同一毫秒内两次写入、且大小完全不变」。标成强验证器
5161
+ * (不带 `W/`)是在说谎:HTTP 语义里强验证器可用于 `If-Range` 与字节范围续传,
5162
+ * 客户端会据此跳过内容校验,于是**它可能把一个已经变了的内容当成没变**。
5163
+ *
5164
+ * 标弱之后:`If-None-Match` 仍可做协商缓存(弱比较本来就用它),
5165
+ * 而需要强验证的地方(`If-Match` 写冲突、`If-Range`)客户端不会拿它当证据。
5166
+ * 这是诚实的代价,也是这条改动唯一的语义变化,已在报告里记为已知边界。
5167
+ *
5168
+ * @returns 形如 `W/"2f-yx3k9q1"` 的弱 ETag。
5169
+ */
5170
+ /**
5171
+ * 把客户端回传的验证器归一成「去掉所有 `W/` 前缀」的形式,用于**相等比较**。
5172
+ *
5173
+ * ## 为什么写路径也需要归一(不只是读路径的 If-None-Match)
5174
+ *
5175
+ * 服务端现在发的是**弱** ETag(`W/"..."`,见 [etagFromStat]),而客户端做
5176
+ * `If-Match` 时只能把我们发出去的那个值原样回传 —— 于是 `conditions.ifMatch`
5177
+ * 必然带 `W/`,而磁盘上重新算出来的也是 `W/"..."`:**单层**情况下字符串恰好相等,
5178
+ * 看起来没问题。
5179
+ *
5180
+ * 但两层就崩了:某些 HTTP 客户端/代理会把弱验证器再包一层,回传 `W/W/"..."`。
5181
+ * 那时严格 `!==` 会把「同一个文件」判成「文件变了」→ 412 → **用户保存失败且
5182
+ * 重试也不会成功**(因为客户端每次都回传同一个值)。
5183
+ *
5184
+ * ## 为什么这不是「放松安全」
5185
+ *
5186
+ * `If-Match` 在这里的职责是**乐观并发控制**(防止盲写覆盖别人的修改),
5187
+ * 不是身份认证。归一化只消除 `W/` 前缀这层语法噪声,`<size>-<mtime>` 的
5188
+ * 实质比较一字未改 —— 真正变了的文件照样 412。
5189
+ *
5190
+ * RFC 7232 里 `If-Match` 本应用强比较、而弱验证器不能用于它;这条张力是
5191
+ * 「用 stat 而非内容哈希」的已知代价(见 `etagFromStat` 的说明)。我们的取舍是:
5192
+ * **宁可让乐观锁偏松一点,也不让用户的保存无故失败** —— 因为这条路径的失败模式
5193
+ * 是「数据存不进去」,比「两个客户端几乎同时改同一文件」更常见、更痛。
5194
+ */
5195
+ function normalizeValidator(value) {
5196
+ return value.trim().replace(/^(?:W\/)+/i, "");
5197
+ }
5198
+ /**
5199
+ * 两个验证器是否表示同一版本(见 [normalizeValidator])。
5200
+ *
5201
+ * `left` 允许 undefined:调用方在「创建分支」上可能没有 `If-Match`
5202
+ * (那时它已经被 428 拦下过,但第二个比较点在另一个分支里,TS 窄化不到)。
5203
+ * `undefined` 一律视为「不相等」—— 缺失的验证器绝不能匹配上任何真实版本。
5204
+ */
5205
+ function etagEqual(left, right) {
5206
+ if (left === void 0) return false;
5207
+ return normalizeValidator(left) === normalizeValidator(right);
5208
+ }
5209
+ function etagFromStat(info) {
5210
+ return `W/"${Math.max(0, Math.trunc(info.size)).toString(36)}-${Math.max(0, Math.floor(info.mtimeMs)).toString(36)}"`;
4178
5211
  }
4179
5212
  async function replaceFile(temp, destination, destinationExists) {
4180
5213
  try {
@@ -4300,12 +5333,31 @@ function contentTypeFor$1(filePath) {
4300
5333
  function encodeCursor(name) {
4301
5334
  return Buffer.from(name, "utf8").toString("base64url");
4302
5335
  }
5336
+ /** 游标长度上限:它编码的是一个文件名,几百字符足够;几千字符只可能是伪造。 */
5337
+ const MAX_CURSOR_CHARS = 1024;
5338
+ /** base64url 的字符集(无填充)。用正则**先**挡住非法输入,再交给解码器。 */
5339
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
5340
+ /**
5341
+ * 解目录游标(`encodeCursor` 的逆运算)。
5342
+ *
5343
+ * ## 为什么需要显式校验,而不能靠 `try/catch`
5344
+ *
5345
+ * 旧实现写成 `try { Buffer.from(cursor,'base64url') } catch { 400 }`。
5346
+ * 那个 `catch` 是**不可达分支**:Node 的 base64url 解码是**宽松**的 ——
5347
+ * `Buffer.from('!!!','base64url')` 返回**空 Buffer**(不抛),`'@@@'`、`'中文'` 同理。
5348
+ *
5349
+ * 后果不是「报错」,而是**静默的错行为**:非法游标被解成空串 →
5350
+ * `names.findIndex(name => name > '')` 命中第一项 → **从第一页重新开始**。
5351
+ * 客户端看到的是「翻页突然跳回开头」,没有任何错误信号 —— 比报错更难查。
5352
+ *
5353
+ * 所以这里先做两件**能真正失败**的检查:字符集与长度。
5354
+ * 校验通过后才解码;解码结果为空也算非法(空串不是任何文件名)。
5355
+ */
4303
5356
  function decodeCursor(cursor) {
4304
- try {
4305
- return Buffer.from(cursor, "base64url").toString("utf8");
4306
- } catch {
4307
- throw new ApiError(400, "CURSOR_INVALID", "The directory cursor is invalid.");
4308
- }
5357
+ if (cursor.length > MAX_CURSOR_CHARS || !BASE64URL_PATTERN.test(cursor)) throw new ApiError(400, "CURSOR_INVALID", "The directory cursor is invalid.");
5358
+ const decoded = Buffer.from(cursor, "base64url").toString("utf8");
5359
+ if (decoded === "") throw new ApiError(400, "CURSOR_INVALID", "The directory cursor is invalid.");
5360
+ return decoded;
4309
5361
  }
4310
5362
  function trashView(record) {
4311
5363
  return {
@@ -4449,6 +5501,265 @@ function probeHostServices(host, pluginVersion) {
4449
5501
  };
4450
5502
  }
4451
5503
  //#endregion
5504
+ //#region src/host/im-command.ts
5505
+ /**
5506
+ * `/tunnel` —— 从即时通讯(微信 / 飞书 / 钉钉 / 企业微信 …)里远程开隧道并取回配置文本。
5507
+ *
5508
+ * ## 为什么需要它
5509
+ *
5510
+ * 用户常见的困境是「人已经出门,才发现电脑上的 Cloudflare 隧道没开,配置文本也没发给自己」。
5511
+ * 此时公网入口不存在,WebUI 与 App 都够不着这台机器 —— 唯一的反向通道是 IM 机器人的
5512
+ * **长连接**(手机/IM 云 → 本机主动连出去,不需要任何入站端口)。
5513
+ *
5514
+ * 本模块只做一件事:注册一条 `/tunnel` 命令,把「开隧道 + 生成 DSH1 配置文本」的结果
5515
+ * 直接作为聊天消息回给发送者。**不做停止、不做改配置、不读任何文件** —— 攻击面刻意压到
5516
+ * 「最多被人开一次隧道」,而隧道本身不授予任何数据面权限(数据面仍要设备令牌)。
5517
+ *
5518
+ * ## 门禁在哪里(重要)
5519
+ *
5520
+ * 本命令**自己不做身份校验**,它依赖 IM 插件在派发命令之前执行的访问策略:
5521
+ * `@xmanrui/dsh-im` 的每个机器人都有 `canExecuteCommands` / `defaultCanExecuteCommands`,
5522
+ * 非白名单发送者的 `/xxx` 根本到不了这里(`lib/index.js` 的 access policy 判定)。因此
5523
+ * 部署侧的必要条件是:**机器人保持 allowlist 模式,且只把自己加进白名单**。
5524
+ * 这条约束写在 README「远程开隧道」一节与 SECURITY.md 里,别只在代码注释里说。
5525
+ *
5526
+ * ## 为什么直接调宿主 API 而不是走 HTTP 自调用
5527
+ *
5528
+ * 「已开隧道」这一状态下,`/manage` 管理面被判据 0 整体关门(`auth.ts` 的
5529
+ * `TUNNEL_MODE_ADMIN_DISABLED`),`/manage/config/text` 必然 403 —— 走 HTTP 就取不到
5530
+ * 配置文本。命令在插件进程内执行,与路由共用同一个隧道管理器与同一套建配置逻辑,
5531
+ * 天然绕开这个自锁,且不需要为它额外豁免任何端点。
5532
+ *
5533
+ * @module dsh-remote-bridge/im-command
5534
+ */
5535
+ /** 命令名(不带斜杠)。 */
5536
+ const TUNNEL_COMMAND_NAME = "tunnel";
5537
+ /** 命令描述:会出现在 IM 的 `/help` 里。 */
5538
+ const TUNNEL_COMMAND_DESCRIPTION = "开启 Cloudflare 隧道并返回一行配置文本(DSH1:…)";
5539
+ /** 把结果拼成聊天消息。 */
5540
+ function formatTunnelReply(result) {
5541
+ return [
5542
+ "云隧道已开启。",
5543
+ `地址:${result.url}`,
5544
+ `设备名:${result.deviceName}`,
5545
+ "配置文本(一行,复制后粘贴到 App 的「导入配置」):",
5546
+ result.text
5547
+ ].join("\n");
5548
+ }
5549
+ /**
5550
+ * 创建 `/tunnel` 的命令定义(不注册,纯数据,便于单测)。
5551
+ *
5552
+ * 参数只接受空串:本命令刻意**不接受**任何开关,避免出现「`/tunnel stop`」这种需要
5553
+ * 额外鉴权语义的入口。带参数一律回用法说明。
5554
+ */
5555
+ function createTunnelCommand(deps) {
5556
+ /** 同一时刻只允许一次开隧道(IM 侧重发、双端同时发都可能撞上)。 */
5557
+ let inFlight = false;
5558
+ return {
5559
+ name: TUNNEL_COMMAND_NAME,
5560
+ description: TUNNEL_COMMAND_DESCRIPTION,
5561
+ input: { hint: "(无需参数)" },
5562
+ async handler(invocation) {
5563
+ if (invocation.rawInput.trim().length > 0) return {
5564
+ kind: "error",
5565
+ text: "用法:/tunnel(不需要参数)"
5566
+ };
5567
+ if (inFlight) return {
5568
+ kind: "error",
5569
+ text: "上一次开隧道还没结束,请稍等几秒再发一次。"
5570
+ };
5571
+ inFlight = true;
5572
+ try {
5573
+ const result = await deps.openTunnel();
5574
+ deps.logger?.info(`[dsh-remote-bridge] /tunnel 已开启隧道 ${result.url}`);
5575
+ return {
5576
+ kind: "success",
5577
+ text: formatTunnelReply(result)
5578
+ };
5579
+ } catch (cause) {
5580
+ const message = cause instanceof Error ? cause.message : String(cause);
5581
+ deps.logger?.warn(cause instanceof Error ? cause : new Error(message));
5582
+ return {
5583
+ kind: "error",
5584
+ text: `开隧道失败:${message}`
5585
+ };
5586
+ } finally {
5587
+ inFlight = false;
5588
+ }
5589
+ }
5590
+ };
5591
+ }
5592
+ /**
5593
+ * 注册 `/tunnel` 并返回注销函数。
5594
+ *
5595
+ * 注册是**全局层**(不是 agent 子层),所以任何 IM 会话、任何工作区里都能用 ——
5596
+ * 这正是本命令的用途:开机时人不在电脑边。是否允许某个人执行,由 IM 插件的
5597
+ * 访问策略决定(见模块头注释)。
5598
+ */
5599
+ function registerTunnelCommand(registry, deps) {
5600
+ return registry.register(createTunnelCommand(deps));
5601
+ }
5602
+ /**
5603
+ * ## 为什么除命令之外还要一个工具
5604
+ *
5605
+ * `@xmanrui/dsh-im` 5.4.0 的斜杠命令表是**封闭的**:内置 31 条,另外只把
5606
+ * `/compact` `/clear` `/permission` 三条委派给宿主(`lib/index.js` 里
5607
+ * `executeCommand("/compact")` 等固定字面量),**没有**「未知命令转交宿主命令表」的通用派发,
5608
+ * 也从不调用 `commands.list`。所以在 IM 里发 `/tunnel` 到不了上面那个命令处理函数 ——
5609
+ * 它会当成一句普通消息交给智能体。
5610
+ *
5611
+ * 于是 IM 侧真正可用的入口是**工具**:用户用自然语言说一句(「帮我开隧道,把配置发我」),
5612
+ * 智能体调用本工具,结果直接回到聊天里。命令与工具共用同一个 `openTunnel()`,
5613
+ * 语义完全一致;两者的差别只是「谁来触发」。
5614
+ */
5615
+ /** 工具名(模型可见)。 */
5616
+ const TUNNEL_TOOL_NAME = "open_tunnel_config";
5617
+ /**
5618
+ * 模型可见描述。
5619
+ *
5620
+ * 写英文是惯例(工具描述面向模型,与仓内其他描述同语言),并显式写清
5621
+ * 「用户说开隧道/取配置文本时调用」,否则模型倾向于自己用 bash 拼一条 curl。
5622
+ */
5623
+ const TUNNEL_TOOL_DESCRIPTION = "Open the Cloudflare quick tunnel for this DSH host and return a one-line DSH1: config text that a phone app can paste to connect. Use it whenever the user asks to start the tunnel, to get the config text, or says they are away from the computer and need remote access. It never stops the tunnel and never touches files.";
5624
+ /** 创建工具定义(不注册,便于单测)。 */
5625
+ function createTunnelTool(deps) {
5626
+ return {
5627
+ name: TUNNEL_TOOL_NAME,
5628
+ description: TUNNEL_TOOL_DESCRIPTION,
5629
+ parameters: {},
5630
+ output: {
5631
+ schema: {
5632
+ type: "object",
5633
+ additionalProperties: false,
5634
+ properties: {
5635
+ url: {
5636
+ type: "string",
5637
+ required: true
5638
+ },
5639
+ text: {
5640
+ type: "string",
5641
+ required: true
5642
+ },
5643
+ deviceName: {
5644
+ type: "string",
5645
+ required: true
5646
+ }
5647
+ },
5648
+ required: true
5649
+ },
5650
+ render: (_args, value) => [{
5651
+ type: "text",
5652
+ text: `Tunnel is up at ${value.url}`
5653
+ }, {
5654
+ type: "text",
5655
+ text: value.text
5656
+ }]
5657
+ },
5658
+ async execute() {
5659
+ const result = await deps.openTunnel();
5660
+ deps.logger?.info(`[dsh-remote-bridge] ${TUNNEL_TOOL_NAME} 已开启隧道 ${result.url}`);
5661
+ return {
5662
+ url: result.url,
5663
+ text: result.text,
5664
+ deviceName: result.deviceName
5665
+ };
5666
+ }
5667
+ };
5668
+ }
5669
+ /** 注册工具并返回注销函数。注册是全局层:任何会话(含 IM 会话)都能调用。 */
5670
+ function registerTunnelTool(registry, deps) {
5671
+ return registry.register(createTunnelTool(deps));
5672
+ }
5673
+ //#endregion
5674
+ //#region src/host/retention.ts
5675
+ /**
5676
+ * 数据保留(retention)调度器:把**会无限增长的两张表**按时间清掉。
5677
+ *
5678
+ * ## 为什么需要它(这是唯一一条「随时间必然恶化」的债)
5679
+ *
5680
+ * `audit` 与 `idempotency` 都只增不删:
5681
+ *
5682
+ * - `audit` 在 router 里有 20+ 处写入点(登录、撤销、建根、发消息、审批、命令…),
5683
+ * 单机长期运行会到几十万行;
5684
+ * - `idempotency` 每个带 `clientRequestId` 的请求一行,而且 `response_json` 存的是
5685
+ * **整份响应体** —— 它增长得比审计还快,而且存的是重复数据。
5686
+ *
5687
+ * 两者都没有 TTL 列、也没有清理任务(`src/` 下 `DELETE FROM audit` / `VACUUM` /
5688
+ * `DELETE FROM idempotency` 零命中)。跑三个月之后的表现是:库文件与 WAL 持续变大、
5689
+ * `listAudit` 变慢、备份与迁移成本上升 —— 而这些问题在「刚装上」的时候完全看不到。
5690
+ *
5691
+ * ## 为什么这个模块只做调度、不写 SQL
5692
+ *
5693
+ * SQL 属于 `database.ts` 的所有权(表结构、事务边界、索引都在那边)。这个模块负责的是
5694
+ * **策略**:清多久以前的、最多留多少、什么时候清、清完记什么日志。
5695
+ *
5696
+ * 所以它通过一个**窄端口**([RetentionPort])调用数据库,并在端口缺席时
5697
+ * **静默降级**(只记一次 warn)。理由与可选宿主服务同一条:一个清理任务缺席,
5698
+ * 不该让整个插件激活失败。
5699
+ *
5700
+ * ## 为什么幂等记录只按时间清、不改存的内容
5701
+ *
5702
+ * 有个更"省空间"的方案是把 `response_json` 从整份响应裁成 `{sessionId}`。**否决**:
5703
+ * 幂等重放的语义就是「把当初那次响应原样再给你一次」,形状一变,已发布客户端的
5704
+ * 严格解码器会当场炸。省下的那点体积已经由按时间清理覆盖了,不值得换个破坏性变更。
5705
+ */
5706
+ /** 幂等记录保留多久。24 h 足够覆盖任何「客户端重试」窗口(配对/发消息都是秒级重试)。 */
5707
+ const IDEMPOTENCY_TTL_MS = 864e5;
5708
+ /** 审计保留多久。90 天是「事后追查」与「库不失控」之间的常规取舍。 */
5709
+ const AUDIT_TTL_MS = 7776e6;
5710
+ /** 审计最多留多少行(TTL 之内的兜底:短时间高频写不该把库顶爆)。 */
5711
+ const AUDIT_KEEP_MAX = 5e5;
5712
+ /**
5713
+ * 从数据库对象上取保留端口;没有就返回 null。
5714
+ *
5715
+ * **特性探测**而不是直接调用:`database.ts` 归另一条手术台,两侧必须能分批落地。
5716
+ * 端口缺席时本模块什么也不做 —— 那是「清理任务没上」,不是「插件坏了」。
5717
+ */
5718
+ function resolveRetentionPort(database) {
5719
+ const candidate = database;
5720
+ if (typeof candidate.pruneIdempotency !== "function") return null;
5721
+ if (typeof candidate.pruneAudit !== "function") return null;
5722
+ return database;
5723
+ }
5724
+ /**
5725
+ * 启动清理:一次把两张表都收到阈值以内。
5726
+ *
5727
+ * 放在**插件启动**时跑,因为它要处理的是「上次运行攒下来的量」—— 而那批数据
5728
+ * 可能远超一次请求能承受的规模,所以不该混进请求路径。
5729
+ *
5730
+ * 失败**不抛**(返回 `ran: true` 但两个计数为 0 并记 warn):清理失败只是「这次没清」,
5731
+ * 让它把插件启动打掉是轻重倒置。
5732
+ */
5733
+ function runStartupRetention(database, logger, now = Date.now()) {
5734
+ const port = resolveRetentionPort(database);
5735
+ if (port === null) {
5736
+ logger.warn(/* @__PURE__ */ new Error("dsh-remote-bridge: retention skipped — the database exposes no pruneIdempotency/pruneAudit; audit and idempotency tables will grow without bound"));
5737
+ return {
5738
+ ran: false,
5739
+ idempotencyDeleted: 0,
5740
+ auditDeleted: 0
5741
+ };
5742
+ }
5743
+ let idempotencyDeleted = 0;
5744
+ let auditDeleted = 0;
5745
+ try {
5746
+ idempotencyDeleted = port.pruneIdempotency(now - IDEMPOTENCY_TTL_MS);
5747
+ } catch (error) {
5748
+ logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: pruning idempotency failed: ${String(error)}`));
5749
+ }
5750
+ try {
5751
+ auditDeleted = port.pruneAudit(now - AUDIT_TTL_MS, AUDIT_KEEP_MAX);
5752
+ } catch (error) {
5753
+ logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: pruning audit failed: ${String(error)}`));
5754
+ }
5755
+ if (idempotencyDeleted > 0 || auditDeleted > 0) logger.info(`dsh-remote-bridge: retention removed ${idempotencyDeleted} idempotency row(s) and ${auditDeleted} audit row(s)`);
5756
+ return {
5757
+ ran: true,
5758
+ idempotencyDeleted,
5759
+ auditDeleted
5760
+ };
5761
+ }
5762
+ //#endregion
4452
5763
  //#region src/host/job-adapter.ts
4453
5764
  /**
4454
5765
  * job 适配层的错误。
@@ -4740,6 +6051,14 @@ function defaultDisplayName() {
4740
6051
  //#endregion
4741
6052
  //#region src/host/plugin-inventory.ts
4742
6053
  /**
6054
+ * 读包清单时的并发上限。
6055
+ *
6056
+ * 为什么不是 `Promise.all` 全放:`names` 的长度由用户装了多少插件决定,
6057
+ * 理论上可以几十上百;一把放出去在慢盘/网络盘上反而更慢,而且失败时更难定位。
6058
+ * 6 与 `withUpdateCheck` 的网络并发同值,本仓已经验证过。
6059
+ */
6060
+ const INVENTORY_READ_CONCURRENCY = 6;
6061
+ /**
4743
6062
  * 把 profile 标识归一化成 profile 名。
4744
6063
  *
4745
6064
  * 内核接受**两种**写法,两者都合法:
@@ -4883,8 +6202,17 @@ async function readPluginInventory(input = {}) {
4883
6202
  const bundles = stringArray((typeof dshSection.profile === "object" && dshSection.profile !== null ? dshSection.profile : {}).bundles);
4884
6203
  const names = [.../* @__PURE__ */ new Set([...Object.keys(dependencies), ...bundles])].sort();
4885
6204
  const items = [];
6205
+ const manifests = /* @__PURE__ */ new Map();
6206
+ for (let offset = 0; offset < names.length; offset += INVENTORY_READ_CONCURRENCY) {
6207
+ const batch = names.slice(offset, offset + INVENTORY_READ_CONCURRENCY);
6208
+ const rows = await Promise.all(batch.map(async (name) => ({
6209
+ name,
6210
+ manifest: await readPackageManifest(profilePath, name)
6211
+ })));
6212
+ for (const row of rows) manifests.set(row.name, row.manifest);
6213
+ }
4886
6214
  for (const name of names) {
4887
- const pkgManifest = await readPackageManifest(profilePath, name);
6215
+ const pkgManifest = manifests.get(name) ?? null;
4888
6216
  const installedRaw = pkgManifest?.version;
4889
6217
  const installed = typeof installedRaw === "string" && installedRaw.trim() ? installedRaw : null;
4890
6218
  const loaded = bundles.includes(name);
@@ -5203,37 +6531,160 @@ async function inspectResolvedPath(root, wirePath) {
5203
6531
  };
5204
6532
  }
5205
6533
  //#endregion
5206
- //#region src/host/wire-json.ts
6534
+ //#region src/host/settings-namespace.ts
5207
6535
  /**
5208
- * 出网 JSON 的安全序列化:把**孤立 UTF-16 代理项**换成 U+FFFD。
6536
+ * 通用设置命名空间的纯逻辑:写允许策略、密钥脱敏、ops 校验与响应投影。
5209
6537
  *
5210
- * ## 为什么需要这一层(实发场景)
5211
- *
5212
- * DSH 内核在生成事件摘要时会按**字符数**截断字符串。若截断点正好落在
5213
- * 一个代理对(emoji、部分 CJK 扩展字、某些工具输出的花括号表情)中间,
5214
- * 字符串里就只剩**半个**代理项。`JSON.stringify` 会把它老实转义成 `\udXXX`,
5215
- * 于是线上出现一份「语法合法、语义非法」的 JSON:
5216
- * JavaScript 侧照常解析,而 **Swift/Foundation 的 JSONDecoder 会直接拒绝整帧**。
5217
- * 症状极具误导性 —— 一条工具输出的半个 emoji,把整条 WebSocket 连接打死,
5218
- * 客户端表现为「突然不再更新」,日志里只有一句解码失败。
6538
+ * 为什么单独成文件(2.6.0):这一层不碰任何内核 RPC,全部可以纯单测;
6539
+ * 而 `settings-adapter.ts` 已贴着 `AGENTS.md` §3.2 的 600 行单文件预算。
6540
+ * 拆出来之后两边都不再挤,adapter 只保留「调内核 + 映射错误」的那几行。
5219
6541
  *
5220
- * ## 为什么在传输层做,而不是在产生摘要的地方做
6542
+ * 契约见 `docs/api/openapi.yaml` 的 SettingsNamespace* 与 `docs/project/API.md`
6543
+ * 的「通用设置命名空间」一节;安全判据的理由见 `docs/project/SECURITY.md`。
6544
+ */
6545
+ const MAX_OPS = 32;
6546
+ /**
6547
+ * 键名兜底脱敏正则。
5221
6548
  *
5222
- * 摘要不只一处(chat-adapter 的工具输出、内核自己拼的标题、历史重放…),
5223
- * 逐个补是打地鼠;而**出网**只有两个口(HTTP JSON 响应与 WebSocket 帧),
5224
- * 在这里做一次就等于全覆盖,且未来新增事件类型自动受益。
6549
+ * **不是主要机制**:内核 `settings.describe()` 已经用 `redactSecrets: true` 读过一遍,
6550
+ * role('secret') 的字段不会随响应出网(见 dsh-api-settings-controller 的
6551
+ * `namespaceView`/`describe`)。这里是第二道保险 —— 万一某个自定义 schema 漏标了
6552
+ * role('secret'),或者凭据被写进了普通字段,桥也不会把它端给手机。
5225
6553
  *
5226
- * ## 为什么不用正则
6554
+ * `apiKeyEnv` 是环境变量**名**不是密钥值,一并遮蔽是刻意的:名字本身能暴露
6555
+ * 「这台机器用哪家 key」,而 App 读设置根本不需要这个名字。
6556
+ */
6557
+ const SECRET_KEY = /^(apiKey|secret|token|password|credential|apiKeyEnv)$/i;
6558
+ /**
6559
+ * 写允许策略:`ui-*`(界面类插件设置)与 `*locale`(语言/区域)默认可写。
5227
6560
  *
5228
- * 「孤立代理项」的判定需要看**相邻字符**。带后向断言的正则在超长字符串上
5229
- * 有性能与兼容两个问题(KMP/Server 端行为不一致),而这是一条会被每个事件
5230
- * 走到的热路径。这里用单遍字符扫描:O(n)、无递归、无回溯,且对合法代理对
5231
- * 原样放行(emoji 不会被吃掉)。
6561
+ * 其余一律默认只读,尤其是能改「模型、权限、子代理」的命名空间 —— 手机一旦
6562
+ * 丢了或被恶意 App 拿到令牌,改模型等于把请求引到攻击者的端点,改权限等于
6563
+ * 把审批闸门拆掉。需要放行时由部署者显式配 `settingsWriteNamespaces`。
6564
+ */
6565
+ function isSettingsNamespaceWriteAllowed(ns, extra = []) {
6566
+ if (ns.startsWith("ui-") || ns.endsWith("locale")) return true;
6567
+ return extra.some((pattern) => pattern.endsWith("*") ? ns.startsWith(pattern.slice(0, -1)) : ns === pattern);
6568
+ }
6569
+ /**
6570
+ * 单个命名空间不可写的原因。
5232
6571
  *
5233
- * 对标:第三方移动网关 `dsh-plugin-mobile-gateway` 的 `lib/wire-json.mjs` 做了同一件事
5234
- * (它只用了 7 行 —— 我们这边多出来的部分全是给「为什么不那样做」留的注释)。
6572
+ * 宿主设置文档整体只读优先报 `HOST_READONLY`:那与 ns 策略无关,修策略也没用。
5235
6573
  */
5236
- /** 孤儿代理项的统一替身:Unicode 的「替换字符」。 */
6574
+ function settingsNamespaceReason(hostWritable, ns, extra = []) {
6575
+ if (!hostWritable) return "HOST_READONLY";
6576
+ if (!isSettingsNamespaceWriteAllowed(ns, extra)) return "HIGH_RISK_NAMESPACE";
6577
+ }
6578
+ /**
6579
+ * 递归遮蔽密钥:键名命中 {@link SECRET_KEY},或路径整体命中内核给出的
6580
+ * `secrets[].path`(前者是兜底,后者才是权威)。
6581
+ *
6582
+ * 路径按 `\u0000` 拼接比较,避免 `['a.b']` 与 `['a','b']` 混淆;
6583
+ * 数组下标也进路径,密钥恰好是数组元素时同样能命中。
6584
+ */
6585
+ function redactSettingsSecrets(value, secretPaths = []) {
6586
+ const exact = new Set(secretPaths.map((path) => path.join("\0")));
6587
+ return walk(value, []);
6588
+ function walk(current, path) {
6589
+ if (path.length > 0 && exact.has(path.join("\0"))) return "***";
6590
+ if (typeof current === "string") {
6591
+ const key = path.at(-1);
6592
+ return key !== void 0 && SECRET_KEY.test(key) ? "***" : current;
6593
+ }
6594
+ if (Array.isArray(current)) return current.map((item, index) => walk(item, [...path, String(index)]));
6595
+ if (isRecord$1(current)) return Object.fromEntries(Object.entries(current).map(([key, item]) => [key, walk(item, [...path, key])]));
6596
+ return current;
6597
+ }
6598
+ }
6599
+ /** 校验并规范化请求里的 `ops`;结构不合法一律 400 `SETTINGS_OPS_INVALID`。 */
6600
+ function parseSettingsNamespaceOps(input) {
6601
+ if (!Array.isArray(input) || input.length === 0 || input.length > MAX_OPS) throw new ApiError(400, "SETTINGS_OPS_INVALID", `ops must be an array of 1 to ${MAX_OPS} operations.`);
6602
+ return input.map((raw, index) => {
6603
+ const where = `ops[${index}]`;
6604
+ if (!isRecord$1(raw)) throw invalid(`${where} must be an object.`);
6605
+ if (raw.op !== "set" && raw.op !== "unset") throw invalid(`${where}.op must be "set" or "unset".`);
6606
+ if (!Array.isArray(raw.path) || raw.path.some((segment) => typeof segment !== "string")) throw invalid(`${where}.path must be an array of strings.`);
6607
+ const path = [...raw.path];
6608
+ if (raw.op === "unset") return {
6609
+ op: "unset",
6610
+ path
6611
+ };
6612
+ if (!Object.hasOwn(raw, "value")) throw invalid(`${where}.value is required for "set".`);
6613
+ return {
6614
+ op: "set",
6615
+ path,
6616
+ value: raw.value
6617
+ };
6618
+ });
6619
+ }
6620
+ /** 审计用字段清单:只记路径,**绝不记值**。根路径(空数组)不产生字段名。 */
6621
+ function settingsNamespaceOpFields(ops) {
6622
+ return [...new Set(ops.map((op) => op.path.join(".")).filter((field) => field !== ""))];
6623
+ }
6624
+ /** 本次写入是否碰到了密钥位(审计 `credentialChanged`);只看路径,不看值。 */
6625
+ function settingsNamespaceCredentialChanged(ops, secrets) {
6626
+ if (secrets === void 0 || secrets.length === 0) return false;
6627
+ const exact = new Set(secrets.map((secret) => secret.path.join("\0")));
6628
+ return ops.some((op) => exact.has(op.path.join("\0")));
6629
+ }
6630
+ /** 内核视图 → 对外视图:叠加写策略并脱敏。 */
6631
+ function projectSettingsNamespace(view, hostWritable, extra = []) {
6632
+ const writable = hostWritable && isSettingsNamespaceWriteAllowed(view.ns, extra);
6633
+ const secretPaths = (view.secrets ?? []).map((secret) => secret.path);
6634
+ const project = (value) => redactSettingsSecrets(value, secretPaths);
6635
+ const reason = settingsNamespaceReason(hostWritable, view.ns, extra);
6636
+ return {
6637
+ ns: view.ns,
6638
+ revision: view.revision,
6639
+ ...view.schema === void 0 ? {} : { schema: view.schema },
6640
+ value: project(view.value),
6641
+ ...view.base === void 0 ? {} : { base: project(view.base) },
6642
+ ...view.user === void 0 ? {} : { user: project(view.user) },
6643
+ ...view.autoGenerate === void 0 ? {} : { autoGenerate: view.autoGenerate },
6644
+ ...view.applies === void 0 ? {} : { applies: view.applies },
6645
+ ...view.secrets === void 0 ? {} : { secrets: view.secrets },
6646
+ writable,
6647
+ ...reason === void 0 ? {} : { reason }
6648
+ };
6649
+ }
6650
+ function invalid(message) {
6651
+ return new ApiError(400, "SETTINGS_OPS_INVALID", message);
6652
+ }
6653
+ function isRecord$1(value) {
6654
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6655
+ }
6656
+ //#endregion
6657
+ //#region src/host/wire-json.ts
6658
+ /**
6659
+ * 出网 JSON 的安全序列化:把**孤立 UTF-16 代理项**换成 U+FFFD。
6660
+ *
6661
+ * ## 为什么需要这一层(实发场景)
6662
+ *
6663
+ * DSH 内核在生成事件摘要时会按**字符数**截断字符串。若截断点正好落在
6664
+ * 一个代理对(emoji、部分 CJK 扩展字、某些工具输出的花括号表情)中间,
6665
+ * 字符串里就只剩**半个**代理项。`JSON.stringify` 会把它老实转义成 `\udXXX`,
6666
+ * 于是线上出现一份「语法合法、语义非法」的 JSON:
6667
+ * JavaScript 侧照常解析,而 **Swift/Foundation 的 JSONDecoder 会直接拒绝整帧**。
6668
+ * 症状极具误导性 —— 一条工具输出的半个 emoji,把整条 WebSocket 连接打死,
6669
+ * 客户端表现为「突然不再更新」,日志里只有一句解码失败。
6670
+ *
6671
+ * ## 为什么在传输层做,而不是在产生摘要的地方做
6672
+ *
6673
+ * 摘要不只一处(chat-adapter 的工具输出、内核自己拼的标题、历史重放…),
6674
+ * 逐个补是打地鼠;而**出网**只有两个口(HTTP JSON 响应与 WebSocket 帧),
6675
+ * 在这里做一次就等于全覆盖,且未来新增事件类型自动受益。
6676
+ *
6677
+ * ## 为什么不用正则
6678
+ *
6679
+ * 「孤立代理项」的判定需要看**相邻字符**。带后向断言的正则在超长字符串上
6680
+ * 有性能与兼容两个问题(KMP/Server 端行为不一致),而这是一条会被每个事件
6681
+ * 走到的热路径。这里用单遍字符扫描:O(n)、无递归、无回溯,且对合法代理对
6682
+ * 原样放行(emoji 不会被吃掉)。
6683
+ *
6684
+ * 对标:第三方移动网关 `dsh-plugin-mobile-gateway` 的 `lib/wire-json.mjs` 做了同一件事
6685
+ * (它只用了 7 行 —— 我们这边多出来的部分全是给「为什么不那样做」留的注释)。
6686
+ */
6687
+ /** 孤儿代理项的统一替身:Unicode 的「替换字符」。 */
5237
6688
  const REPLACEMENT = "�";
5238
6689
  const HIGH_SURROGATE_START = 55296;
5239
6690
  const HIGH_SURROGATE_END = 56319;
@@ -5300,25 +6751,25 @@ function matchJobRoute(pathname) {
5300
6751
  const jobs = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs$/);
5301
6752
  if (jobs !== null) return {
5302
6753
  kind: "list",
5303
- sessionId: decodeURIComponent(jobs[1])
6754
+ sessionId: decodePathSegment(jobs[1])
5304
6755
  };
5305
6756
  const output = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/output$/);
5306
6757
  if (output !== null) return {
5307
6758
  kind: "output",
5308
- sessionId: decodeURIComponent(output[1]),
5309
- jobId: decodeURIComponent(output[2])
6759
+ sessionId: decodePathSegment(output[1]),
6760
+ jobId: decodePathSegment(output[2])
5310
6761
  };
5311
6762
  const kill = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/kill$/);
5312
6763
  if (kill !== null) return {
5313
6764
  kind: "kill",
5314
- sessionId: decodeURIComponent(kill[1]),
5315
- jobId: decodeURIComponent(kill[2])
6765
+ sessionId: decodePathSegment(kill[1]),
6766
+ jobId: decodePathSegment(kill[2])
5316
6767
  };
5317
6768
  const bare = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)$/);
5318
6769
  if (bare !== null) return {
5319
6770
  kind: "unknown",
5320
- sessionId: decodeURIComponent(bare[1]),
5321
- jobId: decodeURIComponent(bare[2])
6771
+ sessionId: decodePathSegment(bare[1]),
6772
+ jobId: decodePathSegment(bare[2])
5322
6773
  };
5323
6774
  return null;
5324
6775
  }
@@ -5338,7 +6789,11 @@ function matchJobRoute(pathname) {
5338
6789
  * 「远程能读到 running 但读不到 url」这个方案被**否决**了:那需要在判据 1 上再开一个口子,
5339
6790
  * 而那正是本次修复要消灭的东西。`tunnelStateFor` 里还留了一层不变量检查作双保险。
5340
6791
  */
5341
- const TUNNEL_MODE_EXEMPT_PATHS = /* @__PURE__ */ new Set(["/manage/cloudflare/status", "/manage/cloudflare/activity"]);
6792
+ const TUNNEL_MODE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
6793
+ "/manage/cloudflare/status",
6794
+ "/manage/cloudflare/activity",
6795
+ "/manage/cloudflare/stop"
6796
+ ]);
5342
6797
  var ApiRouter = class {
5343
6798
  options;
5344
6799
  constructor(options) {
@@ -5349,6 +6804,15 @@ var ApiRouter = class {
5349
6804
  setCors(req, res);
5350
6805
  res.setHeader("X-Request-Id", requestId);
5351
6806
  if (req.method === "OPTIONS") {
6807
+ if (preflightRefused(req)) {
6808
+ res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
6809
+ res.end(JSON.stringify({ error: {
6810
+ code: "CORS_ORIGIN_FORBIDDEN",
6811
+ message: "Cross-origin preflight is accepted only from a loopback origin.",
6812
+ requestId
6813
+ } }));
6814
+ return;
6815
+ }
5352
6816
  res.writeHead(204);
5353
6817
  res.end();
5354
6818
  return;
@@ -5383,25 +6847,30 @@ var ApiRouter = class {
5383
6847
  });
5384
6848
  return;
5385
6849
  }
5386
- if (method === "GET" && pathname === "/api/v1/debug/host-services") {
5387
- const report = this.options.hostServiceReport;
5388
- if (report === void 0) throw new ApiError(404, "NOT_FOUND", "host service probe is not available in this build.");
5389
- sendJson(res, 200, report);
5390
- return;
5391
- }
5392
6850
  if (method === "POST" && pathname === "/api/v1/pairings/exchange") {
5393
- this.options.auth.pairingAttempts.consume(requestAddress(req));
6851
+ this.options.auth.pairingLimiter(req).consume(requestAddress(req));
5394
6852
  const body = await readJson(req, 16384);
5395
6853
  if (typeof body.code !== "string" || typeof body.deviceName !== "string") throw new ApiError(400, "BODY_INVALID", "code and deviceName are required strings.");
5396
- sendJson(res, 201, this.options.database.exchangePairing(body.code, body.deviceName));
6854
+ sendJson(res, 201, this.options.database.exchangePairing(body.code, body.deviceName, { requestId }));
5397
6855
  return;
5398
6856
  }
5399
- if (pathname.startsWith("/manage")) {
6857
+ if (pathname === "/manage" || pathname.startsWith("/manage/")) {
5400
6858
  this.options.auth.requireAdmin(req, { ...TUNNEL_MODE_EXEMPT_PATHS.has(pathname) ? { allowTunnelMode: true } : {} });
5401
6859
  await this.dispatchManagement(req, res, url, requestId);
5402
6860
  return;
5403
6861
  }
5404
6862
  const principal = this.options.auth.principal(req, { allowAdmin: true });
6863
+ if (method === "GET" && pathname === "/api/v1/debug/host-services") {
6864
+ this.options.auth.requireScope(principal, "settings.read");
6865
+ const report = this.options.hostServiceReport;
6866
+ if (report === void 0) throw new ApiError(404, "NOT_FOUND", "host service probe is not available in this build.");
6867
+ const metrics = await readMetricsSnapshot();
6868
+ sendJson(res, 200, metrics === void 0 ? report : {
6869
+ ...report,
6870
+ metrics
6871
+ });
6872
+ return;
6873
+ }
5405
6874
  if (method === "GET" && pathname === "/api/v1/devices/self") {
5406
6875
  if (principal.kind === "admin") sendJson(res, 200, {
5407
6876
  id: principal.id,
@@ -5438,7 +6907,7 @@ var ApiRouter = class {
5438
6907
  }
5439
6908
  const rootRoute = pathname.match(/^\/api\/v1\/roots\/([^/]+)\/(entries|content)$/);
5440
6909
  if (rootRoute !== null) {
5441
- const rootId = decodeURIComponent(rootRoute[1]);
6910
+ const rootId = decodePathSegment(rootRoute[1]);
5442
6911
  const resource = rootRoute[2];
5443
6912
  const root = this.requireRoot(principal, rootId);
5444
6913
  if (resource === "entries") {
@@ -5457,7 +6926,7 @@ var ApiRouter = class {
5457
6926
  const restoreRoute = pathname.match(/^\/api\/v1\/trash\/([^/]+)\/restore$/);
5458
6927
  if (method === "POST" && restoreRoute !== null) {
5459
6928
  this.options.auth.requireScope(principal, "files.delete");
5460
- const record = this.options.database.getTrash(decodeURIComponent(restoreRoute[1]));
6929
+ const record = this.options.database.getTrash(decodePathSegment(restoreRoute[1]));
5461
6930
  if (record === void 0) throw new ApiError(404, "TRASH_NOT_FOUND", "The trash item does not exist.");
5462
6931
  const root = this.requireRoot(principal, record.rootId);
5463
6932
  await this.options.files.restoreTrash(root, record.id);
@@ -5483,7 +6952,7 @@ var ApiRouter = class {
5483
6952
  }
5484
6953
  const workspaceRoute = pathname.match(/^\/api\/v1\/chat\/workspaces\/([^/]+)$/);
5485
6954
  if (workspaceRoute !== null) {
5486
- const workspaceId = decodeURIComponent(workspaceRoute[1]);
6955
+ const workspaceId = decodePathSegment(workspaceRoute[1]);
5487
6956
  if (method === "PATCH") {
5488
6957
  this.options.auth.requireScope(principal, "chat.write");
5489
6958
  const body = await readJson(req, 64e3);
@@ -5544,6 +7013,27 @@ var ApiRouter = class {
5544
7013
  sendJson(res, 200, await this.options.settings.catalog());
5545
7014
  return;
5546
7015
  }
7016
+ if (method === "GET" && pathname === "/api/v1/settings/namespaces") {
7017
+ this.options.auth.requireScope(principal, "settings.read");
7018
+ sendJson(res, 200, await this.options.settings.describeNamespaces());
7019
+ return;
7020
+ }
7021
+ const settingsNamespaceRoute = pathname.match(/^\/api\/v1\/settings\/namespaces\/([^/]+)$/);
7022
+ if (settingsNamespaceRoute !== null && method === "PATCH") {
7023
+ this.options.auth.requireScope(principal, "settings.write");
7024
+ const ns = decodePathSegment(settingsNamespaceRoute[1]);
7025
+ const body = await readJson(req, 256e3);
7026
+ const record = typeof body === "object" && body !== null ? body : {};
7027
+ const ops = parseSettingsNamespaceOps(record.ops);
7028
+ const entry = await this.options.settings.mutateNamespace(ns, ops, settingsExpectedRevision(record.expectedRevision));
7029
+ this.audit(principal, "settings.namespace.mutate", void 0, void 0, requestId, {
7030
+ ns,
7031
+ fields: settingsNamespaceOpFields(ops),
7032
+ credentialChanged: settingsNamespaceCredentialChanged(ops, entry.secrets)
7033
+ });
7034
+ sendJson(res, 200, entry);
7035
+ return;
7036
+ }
5547
7037
  if (method === "GET" && pathname === "/api/v1/settings/plugins") {
5548
7038
  this.options.auth.requireScope(principal, "settings.read");
5549
7039
  const inventory = await readPluginInventory();
@@ -5571,7 +7061,7 @@ var ApiRouter = class {
5571
7061
  }
5572
7062
  const providerRoute = pathname.match(/^\/api\/v1\/settings\/providers\/([^/]+)$/);
5573
7063
  if (providerRoute !== null) {
5574
- const providerId = decodeURIComponent(providerRoute[1]);
7064
+ const providerId = decodePathSegment(providerRoute[1]);
5575
7065
  if (method === "PATCH") {
5576
7066
  this.options.auth.requireScope(principal, "settings.write");
5577
7067
  const body = await readJson(req, 1e6);
@@ -5597,7 +7087,7 @@ var ApiRouter = class {
5597
7087
  const discoverProviderRoute = pathname.match(/^\/api\/v1\/settings\/providers\/([^/]+)\/discover$/);
5598
7088
  if (method === "POST" && discoverProviderRoute !== null) {
5599
7089
  this.options.auth.requireScope(principal, "settings.write");
5600
- const providerId = decodeURIComponent(discoverProviderRoute[1]);
7090
+ const providerId = decodePathSegment(discoverProviderRoute[1]);
5601
7091
  const body = await readJson(req, 64e3);
5602
7092
  const draft = {
5603
7093
  ...typeof body.baseURL === "string" ? { baseURL: body.baseURL } : {},
@@ -5641,7 +7131,7 @@ var ApiRouter = class {
5641
7131
  const sessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)$/);
5642
7132
  if (method === "PATCH" && sessionRoute !== null) {
5643
7133
  this.options.auth.requireScope(principal, "chat.write");
5644
- const sessionId = decodeURIComponent(sessionRoute[1]);
7134
+ const sessionId = decodePathSegment(sessionRoute[1]);
5645
7135
  const body = await readJson(req, 64e3);
5646
7136
  if (typeof body.title !== "string") throw new ApiError(400, "BODY_INVALID", "title is required.");
5647
7137
  const result = await this.options.chat.renameSession(principal, sessionId, body.title);
@@ -5652,7 +7142,7 @@ var ApiRouter = class {
5652
7142
  const forkSessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/fork$/);
5653
7143
  if (method === "POST" && forkSessionRoute !== null) {
5654
7144
  this.options.auth.requireScope(principal, "chat.write");
5655
- const sessionId = decodeURIComponent(forkSessionRoute[1]);
7145
+ const sessionId = decodePathSegment(forkSessionRoute[1]);
5656
7146
  const body = await readJson(req, 16384);
5657
7147
  const atSeq = body.atSeq === void 0 ? void 0 : body.atSeq;
5658
7148
  if (atSeq !== void 0 && (!Number.isSafeInteger(atSeq) || atSeq < 0)) throw new ApiError(400, "BODY_INVALID", "atSeq must be a non-negative integer.");
@@ -5667,7 +7157,7 @@ var ApiRouter = class {
5667
7157
  const archiveSessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/archive$/);
5668
7158
  if (method === "POST" && archiveSessionRoute !== null) {
5669
7159
  this.options.auth.requireScope(principal, "chat.write");
5670
- const sessionId = decodeURIComponent(archiveSessionRoute[1]);
7160
+ const sessionId = decodePathSegment(archiveSessionRoute[1]);
5671
7161
  await this.options.chat.archiveSession(principal, sessionId);
5672
7162
  this.audit(principal, "chat.session.archive", void 0, void 0, requestId, { sessionId });
5673
7163
  sendJson(res, 200, { archived: true });
@@ -5704,7 +7194,7 @@ var ApiRouter = class {
5704
7194
  }
5705
7195
  const messagesRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/messages$/);
5706
7196
  if (messagesRoute !== null) {
5707
- const sessionId = decodeURIComponent(messagesRoute[1]);
7197
+ const sessionId = decodePathSegment(messagesRoute[1]);
5708
7198
  if (method === "GET") {
5709
7199
  this.options.auth.requireScope(principal, "chat.read");
5710
7200
  const beforeSeq = optionalInteger(url.searchParams.get("beforeSeq"));
@@ -5739,7 +7229,7 @@ var ApiRouter = class {
5739
7229
  const attachmentRoute = pathname.match(/^\/api\/v1\/attachments\/([^/]+)$/);
5740
7230
  if (attachmentRoute !== null && method === "GET") {
5741
7231
  this.options.auth.requireScope(principal, "files.read");
5742
- const attachmentId = decodeURIComponent(attachmentRoute[1]);
7232
+ const attachmentId = decodePathSegment(attachmentRoute[1]);
5743
7233
  const sessionId = url.searchParams.get("sessionId")?.trim();
5744
7234
  if (!sessionId) throw new ApiError(400, "BODY_INVALID", "sessionId query parameter is required.");
5745
7235
  const { bytes, mediaType } = await this.options.chat.readAttachment(principal, sessionId, attachmentId);
@@ -5759,7 +7249,7 @@ var ApiRouter = class {
5759
7249
  res.statusCode = 200;
5760
7250
  res.setHeader("Content-Type", mediaType);
5761
7251
  res.setHeader("Content-Length", String(bytes.byteLength));
5762
- res.setHeader("Cache-Control", "private, max-age=3600");
7252
+ res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
5763
7253
  res.end(Buffer.from(bytes));
5764
7254
  return;
5765
7255
  }
@@ -5769,12 +7259,12 @@ var ApiRouter = class {
5769
7259
  const body = await readJson(req, 24e6);
5770
7260
  if (typeof body.data !== "string" || body.data === "") throw new ApiError(400, "BODY_INVALID", "data (base64) is required.");
5771
7261
  const name = typeof body.name === "string" && body.name.trim() !== "" ? body.name.trim() : void 0;
5772
- sendJson(res, 201, await this.options.chat.uploadAttachment(principal, decodeURIComponent(attachmentsRoute[1]), body.data, name));
7262
+ sendJson(res, 201, await this.options.chat.uploadAttachment(principal, decodePathSegment(attachmentsRoute[1]), body.data, name));
5773
7263
  return;
5774
7264
  }
5775
7265
  const commandsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/commands$/);
5776
7266
  if (commandsRoute !== null) {
5777
- const sessionId = decodeURIComponent(commandsRoute[1]);
7267
+ const sessionId = decodePathSegment(commandsRoute[1]);
5778
7268
  if (method === "GET") {
5779
7269
  this.options.auth.requireScope(principal, "chat.read");
5780
7270
  sendJson(res, 200, { items: await this.options.chat.listCommands(principal, sessionId) });
@@ -5796,15 +7286,15 @@ var ApiRouter = class {
5796
7286
  const approvalsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/approvals$/);
5797
7287
  if (method === "GET" && approvalsRoute !== null) {
5798
7288
  this.options.auth.requireScope(principal, "chat.read");
5799
- const sessionId = decodeURIComponent(approvalsRoute[1]);
7289
+ const sessionId = decodePathSegment(approvalsRoute[1]);
5800
7290
  sendJson(res, 200, { items: await this.options.chat.listPendingApprovals(principal, sessionId) });
5801
7291
  return;
5802
7292
  }
5803
7293
  const approvalDecisionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/approvals\/([^/]+)\/decision$/);
5804
7294
  if (method === "POST" && approvalDecisionRoute !== null) {
5805
7295
  this.options.auth.requireScope(principal, "chat.write");
5806
- const sessionId = decodeURIComponent(approvalDecisionRoute[1]);
5807
- const approvalId = decodeURIComponent(approvalDecisionRoute[2]);
7296
+ const sessionId = decodePathSegment(approvalDecisionRoute[1]);
7297
+ const approvalId = decodePathSegment(approvalDecisionRoute[2]);
5808
7298
  const body = await readJson(req, 16384);
5809
7299
  if (body.outcome !== "allowed-once" && body.outcome !== "rejected") throw new ApiError(400, "BODY_INVALID", "outcome must be allowed-once or rejected.");
5810
7300
  const approval = await this.options.chat.decideApproval(principal, sessionId, approvalId, body.outcome);
@@ -5821,15 +7311,15 @@ var ApiRouter = class {
5821
7311
  const questionsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions$/);
5822
7312
  if (method === "GET" && questionsRoute !== null) {
5823
7313
  this.options.auth.requireScope(principal, "chat.read");
5824
- const sessionId = decodeURIComponent(questionsRoute[1]);
7314
+ const sessionId = decodePathSegment(questionsRoute[1]);
5825
7315
  sendJson(res, 200, { items: await this.options.chat.listUserQuestions(principal, sessionId) });
5826
7316
  return;
5827
7317
  }
5828
7318
  const questionAnswerRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions\/([^/]+)\/answer$/);
5829
7319
  if (method === "POST" && questionAnswerRoute !== null) {
5830
7320
  this.options.auth.requireScope(principal, "chat.write");
5831
- const sessionId = decodeURIComponent(questionAnswerRoute[1]);
5832
- const callId = decodeURIComponent(questionAnswerRoute[2]);
7321
+ const sessionId = decodePathSegment(questionAnswerRoute[1]);
7322
+ const callId = decodePathSegment(questionAnswerRoute[2]);
5833
7323
  const answer = readUserQuestionAnswer(await readJson(req, 64e3));
5834
7324
  await this.options.chat.answerUserQuestion(principal, sessionId, callId, answer);
5835
7325
  this.audit(principal, "chat.question.answer", void 0, void 0, requestId, {
@@ -5843,7 +7333,7 @@ var ApiRouter = class {
5843
7333
  }
5844
7334
  const sessionModelsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/model(?:s)?$/);
5845
7335
  if (sessionModelsRoute !== null) {
5846
- const sessionId = decodeURIComponent(sessionModelsRoute[1]);
7336
+ const sessionId = decodePathSegment(sessionModelsRoute[1]);
5847
7337
  if (method === "GET") {
5848
7338
  this.options.auth.requireScope(principal, "chat.read");
5849
7339
  sendJson(res, 200, await this.options.chat.models(principal, sessionId));
@@ -5871,7 +7361,7 @@ var ApiRouter = class {
5871
7361
  const sessionAgentPresetRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/agent-preset$/);
5872
7362
  if (method === "PUT" && sessionAgentPresetRoute !== null) {
5873
7363
  this.options.auth.requireScope(principal, "chat.write");
5874
- const sessionId = decodeURIComponent(sessionAgentPresetRoute[1]);
7364
+ const sessionId = decodePathSegment(sessionAgentPresetRoute[1]);
5875
7365
  const body = await readJson(req, 64e3);
5876
7366
  if (typeof body.agentPreset !== "string") throw new ApiError(400, "BODY_INVALID", "agentPreset is required.");
5877
7367
  const agentPreset = await this.options.chat.selectAgentPreset(principal, sessionId, body.agentPreset);
@@ -5885,7 +7375,7 @@ var ApiRouter = class {
5885
7375
  const cancelRoute = pathname.match(/^\/api\/v1\/chat\/runs\/([^/]+)\/cancel$/);
5886
7376
  if (method === "POST" && cancelRoute !== null) {
5887
7377
  this.options.auth.requireScope(principal, "chat.write");
5888
- const sessionId = decodeURIComponent(cancelRoute[1]);
7378
+ const sessionId = decodePathSegment(cancelRoute[1]);
5889
7379
  await this.options.chat.cancel(principal, sessionId);
5890
7380
  this.audit(principal, "chat.run.cancel", void 0, void 0, requestId, { sessionId });
5891
7381
  sendJson(res, 202, { accepted: true });
@@ -5912,12 +7402,12 @@ var ApiRouter = class {
5912
7402
  if (method === "POST" && pathname === "/manage/roots") {
5913
7403
  const body = await readJson(req, 64e3);
5914
7404
  if (typeof body.path !== "string") throw new ApiError(400, "BODY_INVALID", "path is required.");
5915
- sendJson(res, 201, await this.options.database.addRoot(body.path, typeof body.label === "string" ? body.label : void 0));
7405
+ sendJson(res, 201, await this.options.database.addRoot(body.path, typeof body.label === "string" ? body.label : void 0, { requestId }));
5916
7406
  return;
5917
7407
  }
5918
7408
  const rootRoute = pathname.match(/^\/manage\/roots\/([^/]+)$/);
5919
7409
  if (method === "DELETE" && rootRoute !== null) {
5920
- this.options.database.removeRoot(decodeURIComponent(rootRoute[1]));
7410
+ this.options.database.removeRoot(decodePathSegment(rootRoute[1]));
5921
7411
  res.writeHead(204);
5922
7412
  res.end();
5923
7413
  return;
@@ -5926,49 +7416,34 @@ var ApiRouter = class {
5926
7416
  sendJson(res, 200, { items: this.options.database.listDevices() });
5927
7417
  return;
5928
7418
  }
7419
+ if (method === "POST" && pathname === "/manage/devices/prune") {
7420
+ const raw = (await readJson(req, 64e3)).olderThanDays;
7421
+ if (typeof raw !== "number" || !Number.isFinite(raw) || !Number.isInteger(raw) || raw < 1 || raw > 3650) throw new ApiError(400, "INVALID_OLDER_THAN_DAYS", "olderThanDays must be an integer between 1 and 3650.");
7422
+ sendJson(res, 200, { pruned: this.options.database.pruneInactiveDevices(raw, { requestId }) });
7423
+ return;
7424
+ }
5929
7425
  const deviceRoute = pathname.match(/^\/manage\/devices\/([^/]+)$/);
5930
7426
  if (deviceRoute !== null && method === "PATCH") {
5931
7427
  const body = await readJson(req, 64e3);
5932
7428
  const scopes = validateScopes(body.scopes);
5933
7429
  const rootIds = validateStringArray(body.rootIds, "rootIds");
5934
- sendJson(res, 200, this.options.database.updateDevice(decodeURIComponent(deviceRoute[1]), scopes, rootIds));
7430
+ sendJson(res, 200, this.options.database.updateDevice(decodePathSegment(deviceRoute[1]), scopes, rootIds, { requestId }));
5935
7431
  return;
5936
7432
  }
5937
7433
  if (deviceRoute !== null && method === "DELETE") {
5938
- this.options.database.revokeDevice(decodeURIComponent(deviceRoute[1]));
7434
+ this.options.database.revokeDevice(decodePathSegment(deviceRoute[1]), { requestId });
5939
7435
  res.writeHead(204);
5940
7436
  res.end();
5941
7437
  return;
5942
7438
  }
5943
7439
  if (method === "POST" && pathname === "/manage/pairings") {
5944
7440
  const body = await readJson(req, 64e3);
5945
- sendJson(res, 201, this.options.database.createPairing(validateStringArray(body.rootIds, "rootIds"), validateScopes(body.scopes)));
7441
+ sendJson(res, 201, this.options.database.createPairing(validateStringArray(body.rootIds, "rootIds"), validateScopes(body.scopes), void 0, { requestId }));
5946
7442
  return;
5947
7443
  }
5948
7444
  if (method === "POST" && pathname === "/manage/config/text") {
5949
7445
  const body = await readJson(req, 64e3);
5950
- const port = Number.isInteger(body.port) && Number(body.port) > 0 && Number(body.port) < 65536 ? Number(body.port) : DEFAULT_PORT;
5951
- const endpoints = readRequestedEndpoints(body.endpoints) ?? detectEndpoints(port);
5952
- if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
5953
- const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
5954
- const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
5955
- const deviceName = readOptionalText(body.deviceName) ?? "Android 设备";
5956
- const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
5957
- const pairing = this.options.database.createPairing(rootIds, scopes);
5958
- const exchanged = this.options.database.exchangePairing(pairing.code, deviceName);
5959
- sendJson(res, 201, {
5960
- text: encodeConfigText({
5961
- displayName,
5962
- endpoints,
5963
- token: exchanged.token,
5964
- deviceName,
5965
- scopes
5966
- }),
5967
- displayName,
5968
- deviceName,
5969
- endpoints,
5970
- deviceId: exchanged.device.id
5971
- });
7446
+ sendJson(res, 201, this.buildConfigText(body, requestId));
5972
7447
  return;
5973
7448
  }
5974
7449
  if (method === "POST" && pathname === "/manage/remote/enable") {
@@ -6008,7 +7483,7 @@ var ApiRouter = class {
6008
7483
  }
6009
7484
  const purgeRoute = pathname.match(/^\/manage\/trash\/([^/]+)$/);
6010
7485
  if (method === "DELETE" && purgeRoute !== null) {
6011
- await this.options.files.purgeTrash(decodeURIComponent(purgeRoute[1]));
7486
+ await this.options.files.purgeTrash(decodePathSegment(purgeRoute[1]));
6012
7487
  this.options.database.audit("admin", "loopback-admin", "trash.purge", void 0, void 0, {}, requestId);
6013
7488
  res.writeHead(204);
6014
7489
  res.end();
@@ -6027,21 +7502,12 @@ var ApiRouter = class {
6027
7502
  }
6028
7503
  }
6029
7504
  if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/create") {
6030
- const binDir = this.options.cloudflaredBinDir;
6031
- let exe;
6032
- try {
6033
- exe = await ensureCloudflared(binDir ?? this.options.database.dataDir, (a) => {
6034
- this.options.tunnels.reportActivity(a);
6035
- });
6036
- } catch (cause) {
6037
- this.options.tunnels.clearActivity();
6038
- throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED", cause instanceof Error ? cause.message : String(cause));
6039
- }
6040
- const port = this.options.remote.status().configuredPort ?? 3090;
6041
- const state = await this.options.tunnels.startQuick(exe, port);
6042
- this.options.tunnels.clearActivity();
6043
- this.options.database.audit("admin", "loopback-admin", "cloudflare.create", void 0, void 0, {}, requestId);
6044
- sendJson(res, 201, state);
7505
+ const createBody = await readJson(req, 64e3).catch(() => ({}));
7506
+ const { state, configText } = await this.createTunnel(createBody, requestId, "loopback-admin");
7507
+ sendJson(res, 201, configText === void 0 ? state : {
7508
+ ...state,
7509
+ configText
7510
+ });
6045
7511
  return;
6046
7512
  }
6047
7513
  if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/stop") {
@@ -6053,6 +7519,99 @@ var ApiRouter = class {
6053
7519
  throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
6054
7520
  }
6055
7521
  /**
7522
+ * 打开隧道并(可选)顺带产出配置文本 —— HTTP `POST /manage/cloudflare/create` 与
7523
+ * IM `/tunnel` 命令**共用**这一条实现。
7524
+ *
7525
+ * 抽出来的原因:两条入口的前半段完全一致(确保 cloudflared → startQuick → 等随机域名 →
7526
+ * 建配置文本),差别只在「谁在调用」——HTTP 侧要先把请求体读干净(流会随时间关闭),
7527
+ * 命令侧没有请求体。复制一份会让「一键出令牌」在两条路径上慢慢漂移。
7528
+ *
7529
+ * @param createBody - 已解析的请求体(HTTP 侧)或 `{ configText: true }`(命令侧)。
7530
+ * @param requestId - 审计与配对记录共用的请求 ID。
7531
+ * @param actorLabel - 审计里的操作者标签:`loopback-admin`(本机管理面)或 `im-command`。
7532
+ * @returns 合并了「等到地址」的 state,以及(wantConfig 且拿到地址时)配置文本。
7533
+ */
7534
+ async createTunnel(createBody, requestId, actorLabel) {
7535
+ const wantConfig = createBody.configText === true;
7536
+ const binDir = this.options.cloudflaredBinDir;
7537
+ let exe;
7538
+ try {
7539
+ exe = await ensureCloudflared(binDir ?? this.options.database.dataDir, (a) => {
7540
+ this.options.tunnels.reportActivity(a);
7541
+ });
7542
+ } catch (cause) {
7543
+ this.options.tunnels.clearActivity();
7544
+ throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED", "Failed to download or unpack cloudflared. Check the network and retry, or set CLOUDFLARED_MIRROR.", {
7545
+ errorKey: "tunnelErrorDownload",
7546
+ ...cause instanceof Error ? { reason: sanitizeDownloadError(cause.message) } : {}
7547
+ });
7548
+ }
7549
+ const port = this.options.remote.status().configuredPort ?? 3090;
7550
+ const state = await this.options.tunnels.startQuick(exe, port);
7551
+ let configText;
7552
+ let resolvedUrl = state.url;
7553
+ if (wantConfig) {
7554
+ const tunnelUrl = state.url ?? await this.options.tunnels.waitForUrl();
7555
+ resolvedUrl = tunnelUrl;
7556
+ if (tunnelUrl !== null) configText = this.buildConfigText({
7557
+ endpoints: [{
7558
+ label: readOptionalText(createBody.endpointLabel) ?? "云隧道",
7559
+ baseUrl: tunnelUrl
7560
+ }],
7561
+ ...createBody.rootIds === void 0 ? {} : { rootIds: createBody.rootIds },
7562
+ ...createBody.deviceName === void 0 ? {} : { deviceName: createBody.deviceName }
7563
+ }, requestId);
7564
+ }
7565
+ this.options.tunnels.clearActivity();
7566
+ this.options.database.audit("admin", actorLabel, "cloudflare.create", void 0, void 0, {}, requestId);
7567
+ return {
7568
+ state: resolvedUrl === state.url ? state : {
7569
+ ...state,
7570
+ url: resolvedUrl
7571
+ },
7572
+ ...configText === void 0 ? {} : { configText }
7573
+ };
7574
+ }
7575
+ /**
7576
+ * IM `/tunnel` 命令的宿主侧入口:开隧道并返回一行可粘贴的配置文本。
7577
+ *
7578
+ * 与 HTTP 路径的两点差别:
7579
+ *
7580
+ * 1. **隧道已在运行时直接复用当前地址**,不重复 spawn(第二次 `startQuick` 会撞
7581
+ * `TUNNEL_ALREADY_RUNNING`,而用户在 IM 里连发两条是很常见的)。
7582
+ * 2. 配置文本的 endpoint 只用隧道地址,设备名缺省由 `buildConfigText` 决定 —— 命令
7583
+ * 不接受参数,所以没有 `endpointLabel` / `rootIds` 的透传面。
7584
+ *
7585
+ * @throws 当 12 秒内没拿到公网地址,或 cloudflared 下载失败时。
7586
+ */
7587
+ async openTunnelForCommand() {
7588
+ const tunnels = this.options.tunnels;
7589
+ if (tunnels === void 0) throw new Error("隧道功能未启用。");
7590
+ const requestId = randomUUID();
7591
+ const current = tunnels.publicState;
7592
+ if (current.running === true && current.url !== null) {
7593
+ const reused = this.buildConfigText({ endpoints: [{
7594
+ label: "云隧道",
7595
+ baseUrl: current.url
7596
+ }] }, requestId);
7597
+ this.options.database.audit("admin", "im-command", "cloudflare.create", void 0, void 0, { reused: true }, requestId);
7598
+ return {
7599
+ url: current.url,
7600
+ text: reused.text,
7601
+ deviceName: reused.deviceName
7602
+ };
7603
+ }
7604
+ const { state, configText } = await this.createTunnel({ configText: true }, requestId, "im-command");
7605
+ if (configText === void 0 || state.url === null) throw new Error("隧道已启动,但 12 秒内没拿到公网地址。稍等片刻再发一次 /tunnel 可复用当前地址。");
7606
+ const text = typeof configText.text === "string" ? configText.text : "";
7607
+ const deviceName = typeof configText.deviceName === "string" ? configText.deviceName : "Android 设备";
7608
+ return {
7609
+ url: state.url,
7610
+ text,
7611
+ deviceName
7612
+ };
7613
+ }
7614
+ /**
6056
7615
  * 隧道状态视图。
6057
7616
  *
6058
7617
  * **这里不做脱敏,因为用不上**:判据 1 已经把 Cloudflare 边缘头(`Cf-Connecting-IP` /
@@ -6067,6 +7626,42 @@ var ApiRouter = class {
6067
7626
  * 评估结论见报告:本轮选择「远程一律 403」而不是「远程读得到但 `url` 为 null」,
6068
7627
  * 因为后者需要为状态端点在判据 1 上再开一个口子,而那正是这次修复要消灭的东西。
6069
7628
  */
7629
+ /**
7630
+ * 生成一行可粘贴的 `DSH1:` 配置文本(POST /manage/config/text 与「一键开隧道并出令牌」
7631
+ * 的 create 端点共用,避免两处各写一套配对/兑换/编码逻辑而漂移)。
7632
+ *
7633
+ * 入参字段与 /manage/config/text 的窄格式一致:
7634
+ * - endpoints:显式给出则不再做本机网卡探测(隧道场景必须只塞隧道地址,不能混入 LAN);
7635
+ * - rootIds / scopes / deviceName / displayName / port:全部可选,缺省沿用原端点口径;
7636
+ * - scopes 缺省是**五个非破坏性默认权限**(不含 files.delete / settings.write),
7637
+ * 调用方若显式传 DEVICE_SCOPES 会放大手机权限,create 一键路径不传。
7638
+ *
7639
+ * 刻意不返回 pairing.expiresAt:那是配对码的十分钟有效期,设备令牌本身不过期。
7640
+ */
7641
+ buildConfigText(body, requestId) {
7642
+ const port = Number.isInteger(body.port) && Number(body.port) > 0 && Number(body.port) < 65536 ? Number(body.port) : DEFAULT_PORT;
7643
+ const endpoints = readRequestedEndpoints(body.endpoints) ?? detectEndpoints(port);
7644
+ if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
7645
+ const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
7646
+ const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
7647
+ const deviceName = readOptionalText(body.deviceName) ?? "Android 设备";
7648
+ const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
7649
+ const pairing = this.options.database.createPairing(rootIds, scopes, void 0, { requestId });
7650
+ const exchanged = this.options.database.exchangePairing(pairing.code, deviceName, { requestId });
7651
+ return {
7652
+ text: encodeConfigText({
7653
+ displayName,
7654
+ endpoints,
7655
+ token: exchanged.token,
7656
+ deviceName,
7657
+ scopes
7658
+ }),
7659
+ displayName,
7660
+ deviceName,
7661
+ endpoints,
7662
+ deviceId: exchanged.device.id
7663
+ };
7664
+ }
6070
7665
  tunnelStateFor(req) {
6071
7666
  const tunnels = this.options.tunnels;
6072
7667
  if (tunnels === void 0) throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
@@ -6118,12 +7713,18 @@ var ApiRouter = class {
6118
7713
  if (req.method === "GET") {
6119
7714
  this.options.auth.requireScope(principal, "files.read");
6120
7715
  const descriptor = await this.options.files.inspectContent(root, relativePath);
6121
- const range = parseRange(req.headers.range, descriptor.size);
7716
+ res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
6122
7717
  res.setHeader("Accept-Ranges", "bytes");
6123
7718
  res.setHeader("ETag", descriptor.etag);
6124
7719
  res.setHeader("Last-Modified", new Date(descriptor.modifiedAt).toUTCString());
6125
7720
  res.setHeader("Content-Type", descriptor.contentType);
6126
7721
  res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(relativePath.split("/").at(-1) ?? "file")}`);
7722
+ const range = parseRange(req.headers.range, descriptor.size);
7723
+ if (range === void 0 && etagMatches(req.headers["if-none-match"], descriptor.etag)) {
7724
+ res.writeHead(304, { ETag: descriptor.etag });
7725
+ res.end();
7726
+ return;
7727
+ }
6127
7728
  if (range === void 0) {
6128
7729
  res.writeHead(200, { "Content-Length": descriptor.size });
6129
7730
  this.options.files.streamContent(descriptor).pipe(res);
@@ -6208,17 +7809,71 @@ function sendJson(res, status, body) {
6208
7809
  });
6209
7810
  res.end(encoded);
6210
7811
  }
7812
+ /**
7813
+ * 这个 `Origin` 是否指向回环(`127.0.0.1` / `localhost` / `[::1]`)。
7814
+ *
7815
+ * 与 `auth.ts` 的 `isLoopbackOrigin` **同一个判据**,只是这里需要「是否回环」这个
7816
+ * 布尔值本身(那边是「放不放行」)。两处若各写一套,就会出现「CORS 放行、
7817
+ * 鉴权拒绝」或反之的漂移 —— 2.5.1 的 CORS 缺陷正是这种漂移的一种。
7818
+ */
7819
+ function isLoopbackOriginValue(origin) {
7820
+ try {
7821
+ const hostname = new URL(origin).hostname.toLowerCase();
7822
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
7823
+ } catch {
7824
+ return false;
7825
+ }
7826
+ }
7827
+ /**
7828
+ * 写 CORS 响应头。**只对回环 Origin 回显**(2.5.2 修复)。
7829
+ *
7830
+ * ## 修复前
7831
+ *
7832
+ * 只要请求带 `Origin` 就原样回显 `Access-Control-Allow-Origin: <它>`,
7833
+ * 且 `Allow-Headers` 里含 `X-Dsh-Workspace-Admin`。那等于在**协议层**对任意网站宣告:
7834
+ * 「你可以跨站调这个服务,连管理头都可以带」。
7835
+ *
7836
+ * 当时没有立刻变成提权,是因为 `adminRefusal` 的判据 3 会看 `Origin` 并 403 ——
7837
+ * **判据替 CORS 兜住了**。但纵深只剩一层:以后任何一次「为兼容放宽判据 3」的改动
7838
+ * (比如允许 `Origin: null`)都会立刻变成 CSRF 敞口。
7839
+ *
7840
+ * ## 修复后
7841
+ *
7842
+ * - 回环 Origin → 回显它(桌面 WebUI 在 `127.0.0.1:3080` 起,同源或跨端口都照常);
7843
+ * - 非回环 Origin → **不发** ACAO(浏览器自然拒绝跨站读取,不需要服务端 403 也算安全);
7844
+ * - 无 Origin → 不发(本机 `curl` 不需要 CORS);
7845
+ * - `Allow-Headers` **去掉** `X-Dsh-Workspace-Admin`:桌面面板是同源的,
7846
+ * 它带这个头根本不需要预检许可,把它写进协议只会误导后人。
7847
+ *
7848
+ * `Cache-Control: no-store` 仍在这里设默认值;少数可缓存的三条端点
7849
+ * (`/roots/:id/content`、`/attachments/:id`、`/image-handoff`)在各自的处理分支里覆盖。
7850
+ */
6211
7851
  function setCors(req, res) {
6212
7852
  const origin = req.headers.origin;
6213
- if (origin !== void 0) {
7853
+ if (origin !== void 0 && isLoopbackOriginValue(origin)) {
6214
7854
  res.setHeader("Access-Control-Allow-Origin", origin);
6215
7855
  res.setHeader("Vary", "Origin");
6216
7856
  }
6217
- res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match, X-Request-Id, X-Dsh-Workspace-Admin");
7857
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match, X-Request-Id");
6218
7858
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
6219
7859
  res.setHeader("Access-Control-Expose-Headers", "ETag, Content-Range, X-Request-Id");
6220
7860
  res.setHeader("Cache-Control", "no-store");
6221
7861
  }
7862
+ /**
7863
+ * 预检(`OPTIONS`)是否该被回答。
7864
+ *
7865
+ * 浏览器预检一定带 `Origin`。带**非回环** Origin 的预检直接 403 ——
7866
+ * 与「不发 ACAO」相比这条更明确:让攻击者立刻知道跨站不被接受,
7867
+ * 而不是让它在一个「看起来成功但读不到内容」的响应里猜。
7868
+ *
7869
+ * 没有 `Origin` 的 `OPTIONS` 不是浏览器预检(`curl` 常这么探活),照旧 204 ——
7870
+ * 与 `adminRefusal` 放行「缺 Origin」是同一个口径(缺 Origin 不构成声明)。
7871
+ */
7872
+ function preflightRefused(req) {
7873
+ const origin = req.headers.origin;
7874
+ if (typeof origin !== "string" || origin === "") return false;
7875
+ return !isLoopbackOriginValue(origin);
7876
+ }
6222
7877
  function validateScopes(value) {
6223
7878
  const values = validateStringArray(value, "scopes");
6224
7879
  if (values.some((item) => !DEVICE_SCOPES.includes(item))) throw new ApiError(400, "SCOPE_INVALID", "The request contains an unknown device scope.");
@@ -6348,9 +8003,53 @@ function optionalInteger(value) {
6348
8003
  if (!Number.isSafeInteger(parsed) || parsed < 0) throw new ApiError(400, "NUMBER_INVALID", "A query parameter must be a non-negative integer.");
6349
8004
  return parsed;
6350
8005
  }
8006
+ /**
8007
+ * 请求体里的 `expectedRevision`。
8008
+ *
8009
+ * 不复用 `optionalInteger()`:它读的是查询串(字符串)且错误码是 `NUMBER_INVALID`;
8010
+ * 这里是 JSON 数字,非法时属于「ops 请求体不合法」,契约要求 `SETTINGS_OPS_INVALID`。
8011
+ */
8012
+ function settingsExpectedRevision(value) {
8013
+ if (value === void 0) return void 0;
8014
+ if (!Number.isSafeInteger(value) || value < 0) throw new ApiError(400, "SETTINGS_OPS_INVALID", "expectedRevision must be a non-negative integer.");
8015
+ return value;
8016
+ }
6351
8017
  function commandName(line) {
6352
8018
  return /^\/([a-z][a-z0-9_-]*)/u.exec(line)?.[1] ?? "unknown";
6353
8019
  }
8020
+ /**
8021
+ * 读一份运行期指标快照;读取本身抛错时返回 `undefined`(诊断端点绝不能反过来炸掉)。
8022
+ *
8023
+ * 历史:这里曾是动态 `import('./metrics.ts')`,因为 metrics.ts 与本文件由不同手术台并行落地,
8024
+ * 静态依赖会造成编译顺序耦合。metrics.ts 落地后该动态导入既不省包(server/bridge 已静态引用,
8025
+ * tsdown 会打 INEFFECTIVE_DYNAMIC_IMPORT 警告)也不提供容错,改为静态导入。
8026
+ */
8027
+ async function readMetricsSnapshot() {
8028
+ try {
8029
+ return getMetricsSnapshot();
8030
+ } catch {
8031
+ return;
8032
+ }
8033
+ }
8034
+ /**
8035
+ * `If-None-Match` 是否命中当前 ETag。**带弱比较**(忽略 `W/` 前缀)。
8036
+ *
8037
+ * 为什么不用严格相等:ETag 的强弱前缀(`W/`)属于缓存语义的**修饰**,
8038
+ * 不改变「这份内容是不是同一份」。而两端(路由器 / `file-service`)谁加 `W/`
8039
+ * 都可能随实现变化(本仓的 ETag 格式正在被改成 stat-only 的弱校验器),
8040
+ * 写死严格相等会让「格式一改,304 静默失效」—— 那种失效没有任何报错,
8041
+ * 只是客户端永远拿不到 304,悄悄地退回每次重下整份。
8042
+ *
8043
+ * 支持逗号分隔的多值(`If-None-Match: W/"a", "b"`)与通配 `*`。
8044
+ */
8045
+ function etagMatches(headerValue, etag) {
8046
+ const raw = Array.isArray(headerValue) ? headerValue.join(",") : headerValue;
8047
+ if (raw === void 0 || raw === "") return false;
8048
+ if (raw.trim() === "*") return true;
8049
+ const normalize = (value) => value.trim().replace(/^(?:W\/)+/i, "");
8050
+ const target = normalize(etag);
8051
+ return raw.split(",").some((candidate) => normalize(candidate) === target);
8052
+ }
6354
8053
  function parseRange(value, size) {
6355
8054
  if (value === void 0) return void 0;
6356
8055
  const match = /^bytes=(\d*)-(\d*)$/.exec(value);
@@ -6376,8 +8075,72 @@ function header(req, name) {
6376
8075
  const value = req.headers[name];
6377
8076
  return Array.isArray(value) ? value[0] : value;
6378
8077
  }
8078
+ /**
8079
+ * 解码一个 URL **路径段**(不是 query 参数)。
8080
+ *
8081
+ * ## 为什么要收口成一个函数
8082
+ *
8083
+ * `decodeURIComponent` 对非法百分号(`%ZZ`、`%E0%A4%A`、孤零零的 `%`)抛 `URIError`。
8084
+ * 而 `new URL()` **不会**提前拒绝它们(实测 `pathname` 保留字面 `%ZZ`),
8085
+ * 于是请求真的会走到解码这一步 —— 抛出后一路冒到 `router.handle` 的兜底 `catch`,
8086
+ * 被 `asApiError` 包成 **500 `INTERNAL_ERROR`**。
8087
+ *
8088
+ * 那是**错误的分类**:客户端把 URL 打错,不是桥内部炸了。两层代价:
8089
+ * 1. 排障时把「你 URL 写错了」读成「服务端故障」,方向全错;
8090
+ * 2. 污染 `INTERNAL_ERROR` 这个码 —— 真出 500 时没人能再靠码区分。
8091
+ *
8092
+ * 所以这里把「客户端输入非法」固定成 **400 `PATH_INVALID`**,
8093
+ * 与 `path-policy.ts` 里其它非法路径的码保持一致(客户端只需认一个码)。
8094
+ *
8095
+ * ## 为什么不用 `URLSearchParams` 或 `decodeURI`
8096
+ *
8097
+ * - `URLSearchParams.get()` 会把 `+` 解成空格(那是 **query** 的
8098
+ * `application/x-www-form-urlencoded` 语义)。路径段里的 `+` 是字面量 ——
8099
+ * 用它会把 `a+b` 这类合法 id 静默改成 `a b`。
8100
+ * - `decodeURI` 不处理保留字符(`%3A` 之类解不出来),语义不对。
8101
+ *
8102
+ * 所以:`decodeURIComponent` + 严格 catch,什么都不额外做。
8103
+ */
8104
+ function decodePathSegment(value) {
8105
+ try {
8106
+ return decodeURIComponent(value);
8107
+ } catch {
8108
+ throw new ApiError(400, "PATH_INVALID", "A path segment contains malformed percent-encoding.");
8109
+ }
8110
+ }
6379
8111
  //#endregion
6380
8112
  //#region src/host/server.ts
8113
+ /** 默认同时连接数。比手机端可能的重连风暴宽,又不至于让一个坏客户端吃满。 */
8114
+ const DEFAULT_MAX_CONNECTIONS = 32;
8115
+ /** 默认单帧上限(`ws` 的 `maxPayload`)。 */
8116
+ const DEFAULT_MAX_PAYLOAD_BYTES = 1048576;
8117
+ /** 默认心跳间隔。 */
8118
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
8119
+ /** 连接内事件队列上限。 */
8120
+ const DEFAULT_SOCKET_QUEUE_LIMIT = 512;
8121
+ /** 发送缓冲高水位:`ws` 的 `bufferedAmount` 越过它说明对端读不动。 */
8122
+ const DEFAULT_HIGH_WATER_MARK_BYTES = 4194304;
8123
+ /** 卸载时等广播排空的硬上限(不让插件卡住进程退出)。 */
8124
+ const STOP_DRAIN_TIMEOUT_MS = 1e3;
8125
+ /** 背压复查间隔:越线后等这么久再看一眼缓冲降没降。 */
8126
+ const HIGH_WATER_RECHECK_MS = 250;
8127
+ /**
8128
+ * 关闭连接时的宽限期。
8129
+ *
8130
+ * 升级过的 WebSocket 不受 `closeAllConnections()` 管理,所以「一条读不动的连接」
8131
+ * 能让 `server.close()` 的回调永远不来。宽限期后一律 `terminate()`。
8132
+ */
8133
+ const CLOSE_GRACE_MS = 500;
8134
+ /**
8135
+ * 这条事件「丢了能不能重建」。
8136
+ *
8137
+ * 只有流式文本增量是可重建的(客户端重拉历史即可)。状态类帧(`turn.*` /
8138
+ * `approval.*` / `deliverables.*` / `connection.ready`)丢了会让客户端停在错误状态,
8139
+ * 所以溢出时**优先牺牲增量**。
8140
+ */
8141
+ function isRebuildable(event) {
8142
+ return event.type === "chat.message.delta";
8143
+ }
6381
8144
  var RemoteApiServer = class {
6382
8145
  options;
6383
8146
  server;
@@ -6388,14 +8151,18 @@ var RemoteApiServer = class {
6388
8151
  remoteEnabled = false;
6389
8152
  operations = /* @__PURE__ */ new Map();
6390
8153
  sockets = /* @__PURE__ */ new Map();
6391
- webSockets = new WebSocketServer({
6392
- noServer: true,
6393
- clientTracking: false
6394
- });
8154
+ webSockets;
6395
8155
  eventsAbort = new AbortController();
6396
8156
  unsubscribeEvents;
6397
- broadcastQueue = Promise.resolve();
8157
+ /** 所有连接的 drain 完成信号;`Promise.all` 它们即可等排空。 */
8158
+ pendingDrains = /* @__PURE__ */ new Set();
8159
+ heartbeatTimer;
8160
+ changeTimer;
6398
8161
  changing = false;
8162
+ maxConnections;
8163
+ socketQueueLimit;
8164
+ highWaterMarkBytes;
8165
+ heartbeatIntervalMs;
6399
8166
  constructor(options) {
6400
8167
  this.options = options;
6401
8168
  const fallbackHost = normalizeListenerHost(options.remoteHost ?? "0.0.0.0");
@@ -6403,6 +8170,15 @@ var RemoteApiServer = class {
6403
8170
  this.configuredHost = readStoredHost(options.database, fallbackHost);
6404
8171
  this.configuredPort = readStoredPort(options.database, fallbackPort);
6405
8172
  this.actualPort = this.configuredPort;
8173
+ this.maxConnections = Math.max(1, options.maxConnections ?? DEFAULT_MAX_CONNECTIONS);
8174
+ this.socketQueueLimit = Math.max(1, options.socketQueueLimit ?? DEFAULT_SOCKET_QUEUE_LIMIT);
8175
+ this.highWaterMarkBytes = Math.max(1, options.highWaterMarkBytes ?? DEFAULT_HIGH_WATER_MARK_BYTES);
8176
+ this.heartbeatIntervalMs = Math.max(1, options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS);
8177
+ this.webSockets = new WebSocketServer({
8178
+ noServer: true,
8179
+ clientTracking: false,
8180
+ maxPayload: Math.max(1, options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES)
8181
+ });
6406
8182
  }
6407
8183
  async start() {
6408
8184
  this.remoteEnabled = this.options.database.getSetting("remote_enabled") === "true";
@@ -6416,8 +8192,9 @@ var RemoteApiServer = class {
6416
8192
  await this.bind(loopbackFor(this.configuredHost), this.configuredPort);
6417
8193
  }
6418
8194
  this.unsubscribeEvents = this.options.events.subscribe((event) => {
6419
- this.broadcastQueue = this.broadcastQueue.then(() => this.broadcast(event)).catch(() => void 0);
8195
+ this.enqueueBroadcast(event);
6420
8196
  });
8197
+ this.startHeartbeat();
6421
8198
  this.options.chat.startEvents(this.eventsAbort.signal, (event) => {
6422
8199
  this.options.events.emit(event);
6423
8200
  });
@@ -6426,9 +8203,15 @@ var RemoteApiServer = class {
6426
8203
  this.eventsAbort.abort();
6427
8204
  this.unsubscribeEvents?.();
6428
8205
  this.unsubscribeEvents = void 0;
6429
- await this.broadcastQueue;
8206
+ this.clearChangeTimer();
8207
+ if (this.heartbeatTimer !== void 0) {
8208
+ clearInterval(this.heartbeatTimer);
8209
+ this.heartbeatTimer = void 0;
8210
+ }
8211
+ await this.drainAll(STOP_DRAIN_TIMEOUT_MS);
6430
8212
  await this.closeCurrent();
6431
8213
  this.webSockets.close();
8214
+ setWsConnections(0);
6432
8215
  }
6433
8216
  status(operationId) {
6434
8217
  const operation = operationId === void 0 ? void 0 : this.operations.get(operationId);
@@ -6481,11 +8264,21 @@ var RemoteApiServer = class {
6481
8264
  };
6482
8265
  this.operations.set(operation.id, operation);
6483
8266
  this.changing = true;
6484
- setTimeout(() => {
8267
+ this.clearChangeTimer();
8268
+ this.changeTimer = setTimeout(() => {
8269
+ this.changeTimer = void 0;
6485
8270
  this.performChange(operation);
6486
8271
  }, 75);
8272
+ this.changeTimer.unref?.();
6487
8273
  return publicOperation(operation);
6488
8274
  }
8275
+ /** 取消还没触发的监听变更。幂等:没定时器就什么都不做。 */
8276
+ clearChangeTimer() {
8277
+ if (this.changeTimer === void 0) return;
8278
+ clearTimeout(this.changeTimer);
8279
+ this.changeTimer = void 0;
8280
+ this.changing = false;
8281
+ }
6489
8282
  async performChange(operation) {
6490
8283
  const previousHost = this.host;
6491
8284
  const previousPort = this.actualPort;
@@ -6543,7 +8336,7 @@ var RemoteApiServer = class {
6543
8336
  }
6544
8337
  async createBoundServer(host, port) {
6545
8338
  const server = createServer((req, res) => {
6546
- this.options.handle(req, res).catch((error) => failResponse(res, error));
8339
+ Promise.resolve().then(() => this.options.handle(req, res)).catch((error) => failResponse(res, error));
6547
8340
  });
6548
8341
  server.requestTimeout = 3e4;
6549
8342
  server.headersTimeout = 1e4;
@@ -6565,14 +8358,42 @@ var RemoteApiServer = class {
6565
8358
  port: server.address().port
6566
8359
  };
6567
8360
  }
8361
+ /**
8362
+ * 关掉当前监听与全部连接,**有界**。
8363
+ *
8364
+ * `server.close()` 的回调要等所有连接结束;而**升级过的 WebSocket 不在
8365
+ * `closeAllConnections()` 的管理范围里**(HTTP 层已经不跟踪它了)。于是只要有一条
8366
+ * 连接读不动(暂停读 / 半开),`close()` 就永远等不到回调 —— 插件卸载会挂死。
8367
+ *
8368
+ * 所以这里的顺序是:先礼后兵 —— 发 1012(客户端会看到「服务端重绑」而不是异常断开),
8369
+ * 等一小段宽限期;宽限期到就 `terminate()`,同时让 `close()` 的等待一起收尾。
8370
+ */
6568
8371
  async closeCurrent() {
6569
8372
  const server = this.server;
6570
8373
  if (server === void 0) return;
6571
8374
  this.server = void 0;
6572
- for (const socket of this.sockets.keys()) socket.close(1012, "Listener rebinding");
8375
+ const pending = [...this.sockets.keys()];
8376
+ for (const socket of pending) try {
8377
+ socket.close(1012, "Listener rebinding");
8378
+ } catch {}
6573
8379
  this.sockets.clear();
8380
+ setWsConnections(0);
6574
8381
  await new Promise((resolve) => {
6575
- server.close(() => resolve());
8382
+ let settled = false;
8383
+ const done = () => {
8384
+ if (settled) return;
8385
+ settled = true;
8386
+ clearTimeout(grace);
8387
+ resolve();
8388
+ };
8389
+ const grace = setTimeout(() => {
8390
+ for (const socket of pending) try {
8391
+ socket.terminate();
8392
+ } catch {}
8393
+ done();
8394
+ }, CLOSE_GRACE_MS);
8395
+ grace.unref?.();
8396
+ server.close(done);
6576
8397
  server.closeAllConnections();
6577
8398
  });
6578
8399
  }
@@ -6585,27 +8406,84 @@ var RemoteApiServer = class {
6585
8406
  const protocolToken = parseProtocolToken(req.headers["sec-websocket-protocol"]);
6586
8407
  if (protocolToken !== void 0) req.headers.authorization = `Bearer ${protocolToken}`;
6587
8408
  const principal = this.options.auth.principal(req, { allowAdmin: true });
8409
+ if (this.sockets.size >= this.maxConnections) {
8410
+ recordWsRejected();
8411
+ socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nRetry-After: 5\r\nContent-Length: 0\r\n\r\n");
8412
+ return;
8413
+ }
6588
8414
  this.webSockets.handleUpgrade(req, socket, head, (webSocket) => {
6589
- this.sockets.set(webSocket, principal);
8415
+ const state = {
8416
+ principal,
8417
+ queue: [],
8418
+ draining: false,
8419
+ dropped: 0,
8420
+ resyncPending: false,
8421
+ isAlive: true,
8422
+ overHighWater: 0,
8423
+ devicesRevision: this.devicesRevisionOf(),
8424
+ recheckTimer: void 0
8425
+ };
8426
+ this.sockets.set(webSocket, state);
8427
+ setWsConnections(this.sockets.size);
8428
+ webSocket.on("pong", () => {
8429
+ state.isAlive = true;
8430
+ });
6590
8431
  webSocket.on("close", () => {
8432
+ this.clearSocketTimers(state);
6591
8433
  this.sockets.delete(webSocket);
8434
+ setWsConnections(this.sockets.size);
6592
8435
  });
6593
8436
  webSocket.on("error", () => {
8437
+ this.clearSocketTimers(state);
6594
8438
  this.sockets.delete(webSocket);
8439
+ setWsConnections(this.sockets.size);
6595
8440
  });
6596
8441
  webSocket.send(stringifyWire({
6597
8442
  id: randomUUID(),
6598
8443
  type: "connection.ready",
6599
8444
  time: Date.now(),
6600
- data: connectionReadyData()
8445
+ data: connectionReadyData(this.options.capabilities ?? {})
6601
8446
  }));
6602
- this.replayPendingInteractions(webSocket, principal);
8447
+ this.replayPendingInteractions(webSocket, state);
6603
8448
  });
6604
8449
  } catch {
6605
8450
  socket.destroy();
6606
8451
  }
6607
8452
  }
6608
8453
  /**
8454
+ * 心跳循环:每 `heartbeatIntervalMs` 检查一次存活标记。
8455
+ *
8456
+ * 时序要看清:`isAlive` **先读后置假**,并且只在本轮「活着」时才补一次 `ping`。
8457
+ * 于是:
8458
+ * - 本轮活着 → 标记为可疑,再 ping 一次;
8459
+ * - 本轮仍可疑(上一轮 ping 没换来 pong)→ `terminate()`。
8460
+ *
8461
+ * 结果是**连续错过两个周期**才踢人。偶发丢一个包不会误杀在线设备,真正断掉的
8462
+ * (手机切网 / 熄屏后 NAT 表项过期)最多两个周期就清掉 —— 而修复前它只能等
8463
+ * TCP 自己超时,等于「半开连接长期占着额度,还在每轮广播里被白查一次库」。
8464
+ *
8465
+ * `unref()` 必需:这个定时器永不结束,不 unref 会让进程退不掉。
8466
+ */
8467
+ startHeartbeat() {
8468
+ if (this.heartbeatTimer !== void 0) return;
8469
+ this.heartbeatTimer = setInterval(() => {
8470
+ for (const [socket, state] of this.sockets) {
8471
+ if (!state.isAlive) {
8472
+ recordWsTerminated();
8473
+ this.sockets.delete(socket);
8474
+ setWsConnections(this.sockets.size);
8475
+ socket.terminate();
8476
+ continue;
8477
+ }
8478
+ state.isAlive = false;
8479
+ try {
8480
+ socket.ping();
8481
+ } catch {}
8482
+ }
8483
+ }, this.heartbeatIntervalMs);
8484
+ this.heartbeatTimer.unref?.();
8485
+ }
8486
+ /**
6609
8487
  * 把待处理的**审批与提问**补推给刚建立的这条连接。
6610
8488
  *
6611
8489
  * 为什么要有这一步:「连接后要不要主动查一次 REST」是**每个客户端自己的选择**。
@@ -6614,43 +8492,213 @@ var RemoteApiServer = class {
6614
8492
  * 重连窗口里产生的审批就会静默消失(内核阻塞着等决定、手机上却一片安静)。
6615
8493
  * 重放把「新连接一定能拿到仍待处理的审批」变成**服务端保证**,而不是客户端的义务。
6616
8494
  *
6617
- * 三个刻意的取舍:
8495
+ * 四个刻意的取舍:
6618
8496
  * - **不 await**:重放要按会话做授权过滤(异步),握手不能为它排队;
6619
8497
  * - **只发这一条连接**:不走 `broadcast`,避免每次有人重连就骚扰所有在线设备;
6620
8498
  * - **失败静默**:重放是补救措施,它出错不该影响这条连接正常收事件。
6621
- * 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖。
8499
+ * 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖;
8500
+ * - **走连接自己的队列**:重放帧与随后的广播帧必须保序。直接 `socket.send`
8501
+ * 会让「重放的第 2 帧」插到「广播的第 1 帧」前面去。
6622
8502
  */
6623
- async replayPendingInteractions(socket, principal) {
8503
+ async replayPendingInteractions(socket, state) {
6624
8504
  try {
6625
- const events = [...await this.options.chat.replayPendingApprovals(principal), ...await this.options.chat.replayPendingQuestions(principal)];
6626
- for (const event of events) {
6627
- if (socket.readyState !== WebSocket.OPEN) return;
6628
- socket.send(stringifyWire(event));
6629
- }
8505
+ const events = [...await this.options.chat.replayPendingApprovals(state.principal), ...await this.options.chat.replayPendingQuestions(state.principal)];
8506
+ for (const event of events) this.enqueue(socket, state, event);
6630
8507
  } catch {}
6631
8508
  }
6632
- async broadcast(event) {
6633
- for (const [socket, principal] of this.sockets) {
8509
+ /** 当前设备授权版本;`database` 还没提供就返回 undefined(退回逐事件查库的旧路径)。 */
8510
+ devicesRevisionOf() {
8511
+ const value = this.options.database.devicesRevision;
8512
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
8513
+ }
8514
+ /**
8515
+ * 广播入口:**只做快照 + 入队**,全程同步。
8516
+ *
8517
+ * 这里是宿主事件循环上的热路径 —— 订阅回调里的任何 `await`(查库、查内核、
8518
+ * 发网络)都会反过来把宿主的 `push` 拖住。修复前正是这样:一条全局 promise 链
8519
+ * 串起「事件 × 连接」,一个慢判定让所有事件、所有客户端一起排队,`stop()`
8520
+ * 还得 `await` 那条链。
8521
+ */
8522
+ enqueueBroadcast(event) {
8523
+ recordBroadcastEvent();
8524
+ let deepest = 0;
8525
+ for (const [socket, state] of this.sockets) {
6634
8526
  if (socket.readyState !== WebSocket.OPEN) continue;
6635
- let currentPrincipal = principal;
6636
- if (principal.kind === "device") {
6637
- const device = this.options.database.getActiveDevice(principal.id);
6638
- if (device === void 0) {
6639
- socket.close(4001, "Device revoked");
6640
- continue;
6641
- }
6642
- currentPrincipal = {
6643
- kind: "device",
6644
- id: device.id,
6645
- name: device.name,
6646
- scopes: new Set(device.scopes),
6647
- rootIds: new Set(device.rootIds)
6648
- };
6649
- this.sockets.set(socket, currentPrincipal);
8527
+ this.enqueue(socket, state, event);
8528
+ if (state.queue.length > deepest) deepest = state.queue.length;
8529
+ }
8530
+ setBroadcastQueueDepth(deepest);
8531
+ }
8532
+ /**
8533
+ * 单连接入队:溢出时按「先丢可重建帧」处理,并给客户端一个 resync 信号。
8534
+ *
8535
+ * 为什么溢出不断连:手机端解码慢一次不该被踢下线(那会触发一条重连风暴,
8536
+ * 而重连风暴又会把刚建好的连接再挤掉)。但「静默丢帧」更糟 —— 客户端会以为
8537
+ * 事件流还连续,于是它手里的状态永远停在丢帧之前。所以丢的时候记一笔,
8538
+ * 随后补发 `connection.resyncRequired`,把「静默」变成「告知」。
8539
+ */
8540
+ enqueue(socket, state, event) {
8541
+ if (state.queue.length >= this.socketQueueLimit) {
8542
+ if (isRebuildable(event)) {
8543
+ state.dropped += 1;
8544
+ recordDroppedEvent();
8545
+ state.resyncPending = true;
8546
+ return;
8547
+ }
8548
+ const victimIndex = state.queue.findIndex(isRebuildable);
8549
+ if (victimIndex >= 0) {
8550
+ state.queue.splice(victimIndex, 1);
8551
+ state.dropped += 1;
8552
+ recordDroppedEvent();
8553
+ } else {
8554
+ state.queue.shift();
8555
+ state.dropped += 1;
8556
+ recordDroppedEvent();
8557
+ }
8558
+ state.resyncPending = true;
8559
+ }
8560
+ state.queue.push(event);
8561
+ this.startDrain(socket, state);
8562
+ }
8563
+ /** 保证一条连接只有一个 drain 循环(否则同一连接内的顺序会被打乱)。 */
8564
+ startDrain(socket, state) {
8565
+ if (state.draining) return;
8566
+ state.draining = true;
8567
+ const task = this.drain(socket, state).catch(() => void 0).finally(() => {
8568
+ state.draining = false;
8569
+ this.pendingDrains.delete(task);
8570
+ if (state.recheckTimer !== void 0) return;
8571
+ if (state.queue.length > 0 && this.sockets.get(socket) === state) this.startDrain(socket, state);
8572
+ });
8573
+ this.pendingDrains.add(task);
8574
+ }
8575
+ /**
8576
+ * 一条连接的发送循环。
8577
+ *
8578
+ * `await` 只发生在「这条连接自己的准备工作」上(刷新设备身份、判断会话授权)。
8579
+ * 于是慢连接只慢它自己 —— 这正是与修复前的根本区别。
8580
+ */
8581
+ async drain(socket, state) {
8582
+ while (state.queue.length > 0) {
8583
+ if (socket.readyState !== WebSocket.OPEN) {
8584
+ state.queue.length = 0;
8585
+ return;
8586
+ }
8587
+ if (socket.bufferedAmount > this.highWaterMarkBytes) {
8588
+ if (this.noteHighWater(socket, state)) return;
8589
+ return;
8590
+ }
8591
+ state.overHighWater = 0;
8592
+ if (state.resyncPending) {
8593
+ state.resyncPending = false;
8594
+ this.sendFrame(socket, {
8595
+ id: randomUUID(),
8596
+ type: "connection.resyncRequired",
8597
+ time: Date.now(),
8598
+ data: {
8599
+ dropped: state.dropped,
8600
+ reason: "backpressure"
8601
+ }
8602
+ });
8603
+ state.dropped = 0;
8604
+ continue;
6650
8605
  }
6651
- if (!await this.eventAllowed(currentPrincipal, event)) continue;
6652
- socket.send(stringifyWire(event));
8606
+ const event = state.queue.shift();
8607
+ const principal = await this.refreshPrincipal(socket, state);
8608
+ if (principal === void 0) {
8609
+ state.queue.length = 0;
8610
+ socket.close(4001, "Device revoked");
8611
+ return;
8612
+ }
8613
+ if (!await this.eventAllowed(principal, event)) continue;
8614
+ if (socket.readyState !== WebSocket.OPEN) {
8615
+ state.queue.length = 0;
8616
+ return;
8617
+ }
8618
+ this.sendFrame(socket, event);
8619
+ }
8620
+ if (socket.readyState === WebSocket.OPEN && socket.bufferedAmount > this.highWaterMarkBytes) this.noteHighWater(socket, state);
8621
+ }
8622
+ /**
8623
+ * 记录一次「越过发送高水位」,并安排复查。
8624
+ *
8625
+ * @returns 这条连接是否已经被关掉(调用方据此决定还要不要继续 drain)。
8626
+ *
8627
+ * 两拍才关是刻意的:一次越线可能只是客户端的短暂抖动(切网、GC),
8628
+ * 立刻踢下线会制造重连风暴。但**必须有复查** —— 否则「第二拍」永远不会发生,
8629
+ * 连接就卡在「不发也不关」的僵尸态,比直接断掉更难排查。
8630
+ */
8631
+ noteHighWater(socket, state) {
8632
+ state.overHighWater += 1;
8633
+ if (state.overHighWater >= 2) {
8634
+ this.closeBackpressured(socket, state);
8635
+ return true;
6653
8636
  }
8637
+ if (state.recheckTimer !== void 0) return false;
8638
+ state.recheckTimer = setTimeout(() => {
8639
+ state.recheckTimer = void 0;
8640
+ if (this.sockets.get(socket) !== state) return;
8641
+ if (socket.readyState === WebSocket.OPEN) this.startDrain(socket, state);
8642
+ }, HIGH_WATER_RECHECK_MS);
8643
+ return false;
8644
+ }
8645
+ sendFrame(socket, payload) {
8646
+ try {
8647
+ socket.send(stringifyWire(payload));
8648
+ } catch {}
8649
+ }
8650
+ /** 清掉这条连接自己的定时器。**必须**在连接被摘掉时调用,否则定时器会拖住进程。 */
8651
+ clearSocketTimers(state) {
8652
+ if (state.recheckTimer === void 0) return;
8653
+ clearTimeout(state.recheckTimer);
8654
+ state.recheckTimer = void 0;
8655
+ }
8656
+ closeBackpressured(socket, state) {
8657
+ this.clearSocketTimers(state);
8658
+ const dropped = state.dropped;
8659
+ state.queue.length = 0;
8660
+ state.dropped = 0;
8661
+ state.resyncPending = false;
8662
+ recordWsTerminated();
8663
+ this.sockets.delete(socket);
8664
+ setWsConnections(this.sockets.size);
8665
+ this.sendFrame(socket, {
8666
+ id: randomUUID(),
8667
+ type: "connection.resyncRequired",
8668
+ time: Date.now(),
8669
+ data: {
8670
+ dropped,
8671
+ reason: "backpressure"
8672
+ }
8673
+ });
8674
+ socket.close(1013, "Backpressure");
8675
+ }
8676
+ /**
8677
+ * 按需刷新这条连接的身份快照。
8678
+ *
8679
+ * 有 `devicesRevision` 时:只有版本变了才查库 —— 正常情况**一件事都不做**,
8680
+ * 而修复前是「每个事件、每条连接都查一次 `getActiveDevice`」。
8681
+ * 没有该 getter 时退回旧行为(逐事件查库):老数据库上功能不变,只是慢一点。
8682
+ * 这比「广播路径依赖一个可能不存在的 API」安全得多,也让两侧改动可以分批落地。
8683
+ *
8684
+ * @returns 最新身份;设备已被撤销时返回 undefined。
8685
+ */
8686
+ async refreshPrincipal(socket, state) {
8687
+ if (state.principal.kind !== "device") return state.principal;
8688
+ const revision = this.devicesRevisionOf();
8689
+ if (revision !== void 0 && revision === state.devicesRevision) return state.principal;
8690
+ const device = this.options.database.getActiveDevice(state.principal.id);
8691
+ if (device === void 0) return void 0;
8692
+ const next = {
8693
+ kind: "device",
8694
+ id: device.id,
8695
+ name: device.name,
8696
+ scopes: new Set(device.scopes),
8697
+ rootIds: new Set(device.rootIds)
8698
+ };
8699
+ state.principal = next;
8700
+ state.devicesRevision = revision;
8701
+ return next;
6654
8702
  }
6655
8703
  async eventAllowed(principal, event) {
6656
8704
  if (event.type.startsWith("file.") && !principal.scopes.has("files.read")) return false;
@@ -6661,6 +8709,20 @@ var RemoteApiServer = class {
6661
8709
  if (sessionId !== void 0) return this.options.chat.isSessionAuthorized(principal, sessionId);
6662
8710
  return rootId !== void 0 || principal.kind === "admin";
6663
8711
  }
8712
+ /**
8713
+ * 等所有连接排空,**有上限**。
8714
+ *
8715
+ * `stop()` 不能无限等:一条读不动的 socket 会让插件卸载挂死,进而拖住进程退出。
8716
+ * 超时后照样会断开它们(`closeCurrent` 负责),只是不再尝试把队列里的东西送出去 ——
8717
+ * 卸载路径上「送达」本来就不是目标。
8718
+ */
8719
+ async drainAll(timeoutMs) {
8720
+ if (this.pendingDrains.size === 0) return;
8721
+ const all = Promise.all([...this.pendingDrains]).then(() => void 0);
8722
+ await Promise.race([all, new Promise((resolve) => {
8723
+ setTimeout(resolve, timeoutMs).unref?.();
8724
+ })]);
8725
+ }
6664
8726
  trimOperations() {
6665
8727
  while (this.operations.size > 20) {
6666
8728
  const first = this.operations.keys().next().value;
@@ -6670,6 +8732,7 @@ var RemoteApiServer = class {
6670
8732
  }
6671
8733
  };
6672
8734
  function attachEmbeddedRoutes(webServer, router, remote, extraRoutes = []) {
8735
+ assertExtraRoutePrefixes(extraRoutes);
6673
8736
  const disposers = [webServer.register({
6674
8737
  kind: "prefix",
6675
8738
  path: "/dsh-workspace-api",
@@ -6679,6 +8742,29 @@ function attachEmbeddedRoutes(webServer, router, remote, extraRoutes = []) {
6679
8742
  for (const dispose of disposers.reverse()) dispose();
6680
8743
  };
6681
8744
  }
8745
+ /**
8746
+ * `extraRoutes` 只许挂在 `/plugin/` 下 —— **启动即断言**,违例直接抛。
8747
+ *
8748
+ * ## 为什么这是一条启动断言,而不是一句注释
8749
+ *
8750
+ * `extraRoutes` 的全部理由是「浏览器 `<img src>` 带不了 `Authorization` 头」,
8751
+ * 所以那条路由不校验设备 token,改用「回环 + 同源 + 裸文件名白名单 + 字节自证」。
8752
+ * 那是一套**为只读图片量身定的**信任模型。
8753
+ *
8754
+ * 只要有人往这个座位里塞一条别的东西(比如一个能读文件的接口),那套模型就
8755
+ * 立刻成了漏洞:免令牌 + 只有回环判据,等于任何能从本机发起请求的页面都能用它。
8756
+ * 而这种事**不会在 dev 环境暴露** —— 本机回环一切正常,装到用户机器上才变成
8757
+ * 「谁都能读」。
8758
+ *
8759
+ * 所以把「只许 `/plugin/`」写成断言而不是注释:注释管不住下一个人,
8760
+ * 断言会在插件**启动时**立刻红,且在 CI 里红在测试而不是用户机器上。
8761
+ *
8762
+ * `/dsh-workspace-api` 这个**既有**前缀不受影响:它是上面那条固定路由,
8763
+ * 不经过 `extraRoutes`。
8764
+ */
8765
+ function assertExtraRoutePrefixes(extraRoutes) {
8766
+ for (const route of extraRoutes) if (!route.path.startsWith("/plugin/")) throw new Error(`dsh-remote-bridge: extraRoutes may only be registered under /plugin/ (got "${route.path}"). Routes on this seat bypass the device-token check, so they are restricted to the read-only plugin surface.`);
8767
+ }
6682
8768
  function publicOperation(operation) {
6683
8769
  return {
6684
8770
  id: operation.id,
@@ -6723,15 +8809,26 @@ function parseProtocolToken(value) {
6723
8809
  if (joined === void 0) return void 0;
6724
8810
  return joined.split(",").map((item) => item.trim()).find((item) => item.startsWith("bearer."))?.slice(7);
6725
8811
  }
8812
+ /**
8813
+ * 500 兜底。
8814
+ *
8815
+ * `requestId` **必须**复用 router 已经写进 `X-Request-Id` 响应头的那个值:
8816
+ * 用户报障时给的是 body 里的 id,而运维翻日志靠的是头里的 id —— 两个不同的
8817
+ * UUID 等于「这条线索永远搜不到」。修复前这里**另生成一个** `randomUUID()`。
8818
+ *
8819
+ * 头不存在时(理论上只有 router 之外的调用方)才新生成,保证字段不为空。
8820
+ */
6726
8821
  function failResponse(res, error) {
6727
8822
  if (res.headersSent) {
6728
8823
  res.destroy(error instanceof Error ? error : void 0);
6729
8824
  return;
6730
8825
  }
8826
+ const headerValue = res.getHeader("x-request-id");
8827
+ const requestId = typeof headerValue === "string" && headerValue !== "" ? headerValue : randomUUID();
6731
8828
  const body = Buffer.from(JSON.stringify({ error: {
6732
8829
  code: "INTERNAL_ERROR",
6733
8830
  message: "An internal server error occurred.",
6734
- requestId: randomUUID()
8831
+ requestId
6735
8832
  } }));
6736
8833
  res.writeHead(500, {
6737
8834
  "Content-Type": "application/json",
@@ -6741,10 +8838,82 @@ function failResponse(res, error) {
6741
8838
  }
6742
8839
  //#endregion
6743
8840
  //#region src/host/settings-adapter.ts
8841
+ /**
8842
+ * provider 上下文的复用窗口。
8843
+ *
8844
+ * 1 秒足以覆盖「同一个 PATCH 请求里的连续两次 context 查询」,又短到不会让
8845
+ * 用户可感知地读到旧值(真正的并发控制由 `expectedRevision` 负责)。
8846
+ */
8847
+ const PROVIDER_CONTEXT_TTL_MS = 1e3;
8848
+ /** 缓存条目上限;到顶就整表清空(条目数与 provider 数量同阶,很小)。 */
8849
+ const PROVIDER_CONTEXT_CACHE_LIMIT = 128;
6744
8850
  var DshSettingsAdapter = class {
6745
8851
  api;
6746
- constructor(api) {
8852
+ options;
8853
+ /**
8854
+ * @param options.writeNamespaces 额外允许手机端写入的设置命名空间(精确 ns 或
8855
+ * `前缀*`),与内置策略(`ui-*` / 以 `locale` 结尾)叠加;默认为空。
8856
+ */
8857
+ constructor(api, options = {}) {
6747
8858
  this.api = api;
8859
+ this.options = options;
8860
+ }
8861
+ /**
8862
+ * 列出全部设置命名空间(通用设置面的读侧)。
8863
+ *
8864
+ * 与 `listProviders()` 的区别:它不理解任何 provider 语义,原样透出内核按插件
8865
+ * 划分的命名空间,由手机端按 `schema` 自己长表单 —— 新装插件不必等 App 发版。
8866
+ */
8867
+ async describeNamespaces() {
8868
+ const described = unwrapDsh(await this.settingsService().describe({
8869
+ rpcId: randomUUID(),
8870
+ payload: {}
8871
+ }));
8872
+ return {
8873
+ writable: described.writable,
8874
+ namespaces: described.namespaces.map((view) => projectSettingsNamespace(view, described.writable, this.writeNamespaces))
8875
+ };
8876
+ }
8877
+ /**
8878
+ * 按路径改一个命名空间(通用设置面的写侧)。
8879
+ *
8880
+ * 校验顺序是刻意的:ns 策略 → ops 结构 → 内核 describe(host 可写性)→ mutate。
8881
+ * 先验策略既省一次内核往返,也保证「高危命名空间」无论 ops 怎么错都先得到 403。
8882
+ */
8883
+ async mutateNamespace(ns, ops, expectedRevision) {
8884
+ if (!isSettingsNamespaceWriteAllowed(ns, this.writeNamespaces)) throw new ApiError(403, "SETTINGS_NAMESPACE_READONLY", `The settings namespace "${ns}" is not writable from this bridge.`, {
8885
+ ns,
8886
+ reason: "HIGH_RISK_NAMESPACE"
8887
+ });
8888
+ const normalized = parseSettingsNamespaceOps(ops);
8889
+ const described = unwrapDsh(await this.settingsService().describe({
8890
+ rpcId: randomUUID(),
8891
+ payload: {}
8892
+ }));
8893
+ if (!described.writable) throw new ApiError(409, "SETTINGS_READ_ONLY", "DSH settings are read-only.");
8894
+ let mutated;
8895
+ try {
8896
+ mutated = unwrapDsh(await this.settingsService().mutate({
8897
+ rpcId: randomUUID(),
8898
+ payload: {
8899
+ ns,
8900
+ ops: normalized,
8901
+ ...expectedRevision === void 0 ? {} : { expectedRevision }
8902
+ }
8903
+ }));
8904
+ } catch (error) {
8905
+ throw mapSettingsMutationError(error);
8906
+ }
8907
+ return projectSettingsNamespace(mutated, described.writable, this.writeNamespaces);
8908
+ }
8909
+ get writeNamespaces() {
8910
+ return this.options.writeNamespaces ?? [];
8911
+ }
8912
+ /** 内核 settings 服务缺失时给 503(而不是把 `TypeError` 漏成 500)。 */
8913
+ settingsService() {
8914
+ const settings = this.api.settings;
8915
+ if (settings === void 0) throw new ApiError(503, "SETTINGS_UNAVAILABLE", "The DSH settings service is not available.");
8916
+ return settings;
6748
8917
  }
6749
8918
  async listProviders() {
6750
8919
  const [directoryResponse, settingsResponse, catalogResponse] = await Promise.all([
@@ -6891,6 +9060,7 @@ var DshSettingsAdapter = class {
6891
9060
  }
6892
9061
  }));
6893
9062
  }
9063
+ this.invalidateProviderContext(providerId);
6894
9064
  }
6895
9065
  async createProvider(input) {
6896
9066
  const id = input.id.trim();
@@ -6941,6 +9111,7 @@ var DshSettingsAdapter = class {
6941
9111
  value: key
6942
9112
  }
6943
9113
  }));
9114
+ this.invalidateProviderContext(id);
6944
9115
  }
6945
9116
  async removeProvider(providerId, expectedRevision) {
6946
9117
  const context = await this.providerContext(providerId);
@@ -6957,6 +9128,7 @@ var DshSettingsAdapter = class {
6957
9128
  ...expectedRevision === void 0 ? {} : { expectedRevision }
6958
9129
  }
6959
9130
  }));
9131
+ this.invalidateProviderContext(providerId);
6960
9132
  }
6961
9133
  async discover(providerId, draft) {
6962
9134
  const payload = {
@@ -6971,7 +9143,47 @@ var DshSettingsAdapter = class {
6971
9143
  payload
6972
9144
  })).models;
6973
9145
  }
9146
+ /**
9147
+ * 取一个 provider 的上下文(目录条目 + 设置命名空间 + 当前 profile)。
9148
+ *
9149
+ * ## 为什么加一层「单请求内复用」
9150
+ *
9151
+ * 修复前 `updateProvider()` 会**连续**调它两次(一次在开头做校验、一次在
9152
+ * `createProvider`/`removeProvider` 里),而每次 `providerContext` 都要打
9153
+ * **两次**内核 RPC(`llm.providers` + `settings.describe`)。于是一次 PATCH
9154
+ * 实际可能产生 4~5 次内核往返 —— 而它们问的是**同一份数据**。
9155
+ *
9156
+ * 这里用一个极短的 TTL(默认 1 秒,覆盖「同一请求内」的连续调用)缓存结果。
9157
+ * 为什么不用「请求作用域」的显式传参:那要改每个方法的签名,而调用点散在
9158
+ * router 与 adapter 两处 —— 收益远不抵这次改动。1 秒 TTL 的副作用是:
9159
+ * 同一秒内两次改同一个 provider 时,第二次读到的是第一次之前的快照 ——
9160
+ * 而 `settings.mutate` 有 `expectedRevision` 做真正的并发控制,所以不会写坏。
9161
+ */
9162
+ providerContextCache = /* @__PURE__ */ new Map();
6974
9163
  async providerContext(providerId) {
9164
+ const cached = this.providerContextCache.get(providerId);
9165
+ const now = Date.now();
9166
+ if (cached !== void 0 && now - cached.at < PROVIDER_CONTEXT_TTL_MS) return cached.value;
9167
+ const value = await this.loadProviderContext(providerId);
9168
+ if (this.providerContextCache.size >= PROVIDER_CONTEXT_CACHE_LIMIT) this.providerContextCache.clear();
9169
+ this.providerContextCache.set(providerId, {
9170
+ at: now,
9171
+ value
9172
+ });
9173
+ return value;
9174
+ }
9175
+ /**
9176
+ * 写操作之后**必须**让缓存失效。
9177
+ *
9178
+ * 为什么不能只靠 TTL:`updateProvider` 改完 baseURL 之后,同一个请求尾部的
9179
+ * `listProviders()` 要给出新值。靠 TTL 等 1 秒等于把一个「刚刚改成功」的
9180
+ * 界面回滚成旧值 —— 那会被用户当成「改了没生效」。
9181
+ */
9182
+ invalidateProviderContext(providerId) {
9183
+ if (providerId === void 0) this.providerContextCache.clear();
9184
+ else this.providerContextCache.delete(providerId);
9185
+ }
9186
+ async loadProviderContext(providerId) {
6975
9187
  const [directoryResponse, settingsResponse] = await Promise.all([this.api.llm.providers({
6976
9188
  rpcId: randomUUID(),
6977
9189
  payload: {}
@@ -6992,6 +9204,20 @@ var DshSettingsAdapter = class {
6992
9204
  };
6993
9205
  }
6994
9206
  };
9207
+ /**
9208
+ * 把内核 `settings.mutate` 的错误翻成契约错误码。
9209
+ *
9210
+ * `unwrapDsh()` 的通用映射只认 `busy` / `locked` 为 409,所以内核的
9211
+ * `settings/conflict` 会落到 400 `DSH_SETTINGS_CONFLICT` —— 这里按契约改回
9212
+ * 409 `SETTINGS_CONFLICT`(客户端据此提示「刷新后重试」而不是「参数不对」)。
9213
+ * `settings/rejected` 是 ops 在 schema 上不合法,落回 400 `SETTINGS_OPS_INVALID`。
9214
+ */
9215
+ function mapSettingsMutationError(error) {
9216
+ if (!(error instanceof ApiError)) return error;
9217
+ if (error.code.includes("SETTINGS") && error.code.includes("CONFLICT")) return new ApiError(409, "SETTINGS_CONFLICT", "Settings changed since they were read; refresh and retry.", error.details);
9218
+ if (error.code.includes("SETTINGS") && error.code.includes("REJECTED")) return new ApiError(400, "SETTINGS_OPS_INVALID", error.message, error.details);
9219
+ return error;
9220
+ }
6995
9221
  function projectConfig(profile, inheritedModels) {
6996
9222
  const baseURL = text(profile?.baseURL);
6997
9223
  const api = text(profile?.api);
@@ -7130,6 +9356,7 @@ async function apply(ctx, config = {}) {
7130
9356
  } catch (error) {
7131
9357
  ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: could not purge revoked devices: ${String(error)}`));
7132
9358
  }
9359
+ runStartupRetention(database, ctx.logger);
7133
9360
  await registerBuiltinWorkspaceRoot(ctx, database);
7134
9361
  const events = new WorkspaceEventBus();
7135
9362
  const tunnels = new CloudflaredTunnelManager();
@@ -7149,7 +9376,7 @@ async function apply(ctx, config = {}) {
7149
9376
  agents: ctx.agents,
7150
9377
  commands: ctx.commands
7151
9378
  });
7152
- const settings = new DshSettingsAdapter(apiProxy);
9379
+ const settings = new DshSettingsAdapter(apiProxy, { writeNamespaces: config.settingsWriteNamespaces ?? [] });
7153
9380
  let hostServiceReport;
7154
9381
  try {
7155
9382
  hostServiceReport = probeHostServices(ctx, PLUGIN_VERSION);
@@ -7171,7 +9398,8 @@ async function apply(ctx, config = {}) {
7171
9398
  auth,
7172
9399
  chat,
7173
9400
  events,
7174
- handle: (req, res) => router.handle(req, res)
9401
+ handle: (req, res) => router.handle(req, res),
9402
+ capabilities: { jobs: jobControllerAvailable }
7175
9403
  });
7176
9404
  router = new ApiRouter({
7177
9405
  database,
@@ -7199,6 +9427,34 @@ async function apply(ctx, config = {}) {
7199
9427
  path: IMAGE_ROUTE,
7200
9428
  handler: imageRoute
7201
9429
  }]);
9430
+ try {
9431
+ ctx.effect(() => registerTunnelCommand(ctx.commands, {
9432
+ openTunnel: () => router.openTunnelForCommand(),
9433
+ logger: {
9434
+ info: (message) => ctx.logger.info(message),
9435
+ warn: (error) => ctx.logger.warn(error)
9436
+ }
9437
+ }), "dsh-remote-bridge /tunnel command");
9438
+ } catch (error) {
9439
+ ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: /tunnel command registration failed (ignored): ${String(error)}`));
9440
+ }
9441
+ let toolsService;
9442
+ try {
9443
+ toolsService = reflect?.get?.("tools", false);
9444
+ } catch {
9445
+ toolsService = void 0;
9446
+ }
9447
+ if (toolsService !== void 0) try {
9448
+ ctx.effect(() => registerTunnelTool(toolsService, {
9449
+ openTunnel: () => router.openTunnelForCommand(),
9450
+ logger: {
9451
+ info: (message) => ctx.logger.info(message),
9452
+ warn: (error) => ctx.logger.warn(error)
9453
+ }
9454
+ }), "dsh-remote-bridge open_tunnel_config tool");
9455
+ } catch (error) {
9456
+ ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: open_tunnel_config registration failed (ignored): ${String(error)}`));
9457
+ }
7202
9458
  ctx.effect(() => async () => {
7203
9459
  disposeEmbedded();
7204
9460
  await remote.stop();