dsh-remote-bridge 2.5.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -44,6 +44,293 @@ function asApiError(error) {
44
44
  return new ApiError(500, "INTERNAL_ERROR", "An internal server error occurred.");
45
45
  }
46
46
  //#endregion
47
+ //#region src/host/image-handoff.ts
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
+ /**
66
+ * 交接目录的**出厂默认**。
67
+ *
68
+ * 设置面板里「当前默认」那一栏展示的就是这个字面量,且是只读的 —— 它是这份代码的
69
+ * 契约(历史交接消息、识图工具、手机端都按它写过),不是一个可以随风改的偏好项。
70
+ * 用户可以改的是设置的**当前值**(存在数据库里),默认值本身不跟着走。
71
+ */
72
+ const DEFAULT_HANDOFF_SUBDIR = ".dsh-images";
73
+ /** settings 表里的键名。 */
74
+ const IMAGE_SUBDIR_SETTING = "image_handoff_subdir";
75
+ /**
76
+ * 只读图片路由。刻意放在 `/plugin/` 下而不是版本化 REST 前缀里:
77
+ * 它不参与 REST 契约、不需要设备 token,将来 REST 升版时这条也不该跟着动。
78
+ */
79
+ const IMAGE_ROUTE = "/plugin/dsh-remote-bridge/raw";
80
+ /** 目录名上限:它要拼进路径,太长的名字只是拿来坑自己的。 */
81
+ const MAX_SUBDIR_CHARS = 64;
82
+ /**
83
+ * 把用户填的目录名收成一个安全值。
84
+ *
85
+ * 只收**单段**目录名:`/`、`\`、NUL 一律拒绝(否则它就不是「目录名」而是「路径」,
86
+ * 会把落盘位置带出工作目录);`.` 与 `..` 拒绝(前者是当前目录、后者是往上爬);
87
+ * 允许点开头 —— 默认值 `.dsh-images` 自己就是点目录。
88
+ */
89
+ function normalizeHandoffSubdir(value) {
90
+ if (typeof value !== "string") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name must be a string.");
91
+ const trimmed = value.trim();
92
+ if (trimmed === "" || trimmed.length > MAX_SUBDIR_CHARS) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", `The image directory name must be 1-${MAX_SUBDIR_CHARS} characters.`);
93
+ if (trimmed === "." || trimmed === "..") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name cannot be \".\" or \"..\".");
94
+ if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name may only contain letters, digits, dots, underscores, and hyphens.");
95
+ return trimmed;
96
+ }
97
+ /**
98
+ * 读出当前生效的目录名。
99
+ *
100
+ * 数据库里那条坏掉(手改过 / 旧版本写过)就退回默认并**顺手修掉**:一个坏值不该让
101
+ * 「发图」这个动作从此失败,而每次读都只回退不修正会把坏值永远留着。
102
+ */
103
+ function readHandoffSubdir(database) {
104
+ const stored = database.getSetting(IMAGE_SUBDIR_SETTING);
105
+ if (stored === void 0) return DEFAULT_HANDOFF_SUBDIR;
106
+ try {
107
+ return normalizeHandoffSubdir(stored);
108
+ } catch {
109
+ database.setSetting(IMAGE_SUBDIR_SETTING, DEFAULT_HANDOFF_SUBDIR);
110
+ return DEFAULT_HANDOFF_SUBDIR;
111
+ }
112
+ }
113
+ /** 校验并保存,返回落库后的值。 */
114
+ function saveHandoffSubdir(database, value) {
115
+ const normalized = normalizeHandoffSubdir(value);
116
+ database.setSetting(IMAGE_SUBDIR_SETTING, normalized);
117
+ return normalized;
118
+ }
119
+ /** 设置面板要的那三个值。 */
120
+ function imageHandoffView(database, fallbackDir) {
121
+ return {
122
+ subdir: readHandoffSubdir(database),
123
+ defaultSubdir: DEFAULT_HANDOFF_SUBDIR,
124
+ fallbackDir
125
+ };
126
+ }
127
+ /** 交接文件名的白名单:`<sha256>.<图片扩展名>`,路径穿越在名字这一层就死掉。 */
128
+ const HANDOFF_FILE_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
129
+ /** 扩展名 → 该扩展名必须用内容自证的身份。 */
130
+ const HANDOFF_MEDIA_TYPES = {
131
+ png: "image/png",
132
+ jpg: "image/jpeg",
133
+ jpeg: "image/jpeg",
134
+ webp: "image/webp",
135
+ gif: "image/gif",
136
+ heic: "image/heic",
137
+ heif: "image/heif",
138
+ bmp: "image/bmp",
139
+ avif: "image/avif"
140
+ };
141
+ /** 浏览器只会显示预览,超过这个就别端过去了。 */
142
+ const MAX_BYTES = 33554432;
143
+ /** 病态长的入参在碰文件系统之前就退掉。 */
144
+ const MAX_NAME_CHARS = 4096;
145
+ /** 按扩展名取声明的媒体类型;不支持的扩展名返回 undefined。 */
146
+ function declaredMediaType(name) {
147
+ const cut = name.lastIndexOf(".");
148
+ if (cut === -1) return void 0;
149
+ return HANDOFF_MEDIA_TYPES[name.slice(cut + 1).toLowerCase()];
150
+ }
151
+ /**
152
+ * 「扩展名声明」与「字节自证」是否属于同一图片家族。
153
+ *
154
+ * 为什么需要这一层而不是直接 `declared === sniffed`:**HEIC 与 HEIF 在字节层面无法区分**。
155
+ * 两者都是 ISO-BMFF,靠 `ftyp` 后面的 brand 认家族,而 `heic`/`heif`/`mif1`/`hevc`
156
+ * 都在同一套 `hei*` / `hev*` / `mif*` 前缀里 —— 嗅探器只能诚实地报出「这是 HEI 家族」,
157
+ * 也就是 `image/heic`;它**不该**为了迁就某条扩展名而返回一个自己无法自证的值。
158
+ *
159
+ * 于是把「等价」这件事放在一致性校验这一侧:
160
+ * - 写盘表(`dsh-0.2-bridge.ts`)按内核给的 mediaType 原样产 `<hash>.heif`;
161
+ * - 读取表把 `heif` 声明成 `image/heif`;
162
+ * - 嗅探器对同一份字节返回 `image/heic`;
163
+ * - 两者同族 → 放行。
164
+ *
165
+ * 这不是把闸门拆了:**跨家族的错配仍然全部 415**(png 字节配 `.jpg` 名、
166
+ * `image/png` 配 `.avif` 名、heic 字节配 `.avif` 名)。只放开这一对无法在字节层
167
+ * 区分的「同一家族两个名字」。
168
+ */
169
+ function sameImageFamily(declared, sniffed) {
170
+ if (declared === sniffed) return true;
171
+ const heifFamily = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
172
+ return heifFamily.has(declared) && heifFamily.has(sniffed);
173
+ }
174
+ /**
175
+ * 从文件头认出真实格式。扩展名只是**声明**,这里才是内容自证。
176
+ * @param head - 文件前至少 12 个字节。
177
+ */
178
+ function sniffMediaType(head) {
179
+ const ascii = (offset, text) => {
180
+ if (head.length < offset + text.length) return false;
181
+ for (let index = 0; index < text.length; index += 1) if (head[offset + index] !== text.charCodeAt(index)) return false;
182
+ return true;
183
+ };
184
+ const bytes = (...expected) => {
185
+ if (head.length < expected.length) return false;
186
+ return expected.every((byte, index) => head[index] === byte);
187
+ };
188
+ if (bytes(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
189
+ if (bytes(255, 216, 255)) return "image/jpeg";
190
+ if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
191
+ if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
192
+ if (bytes(66, 77)) return "image/bmp";
193
+ if (ascii(4, "ftyp")) {
194
+ const brand = head.length >= 12 ? String.fromCharCode(...head.subarray(8, 12)) : "";
195
+ if (brand.startsWith("avi")) return "image/avif";
196
+ if (brand.startsWith("hei") || brand.startsWith("hev") || brand.startsWith("mif")) return "image/heic";
197
+ }
198
+ }
199
+ /** 请求的 `Host` 是否指向回环(DNS 重绑定防护)。 */ function loopbackHost(req) {
200
+ const host = req.headers.host;
201
+ if (typeof host !== "string" || host === "") return false;
202
+ if (host.startsWith("[")) return host.startsWith("[::1]");
203
+ const name = host.split(":")[0];
204
+ return name === "127.0.0.1" || name === "localhost";
205
+ }
206
+ /**
207
+ * 请求是否来自某个插件自己服务的页面。
208
+ *
209
+ * 沙箱化的画布跑在不透明 origin 里,它自己的请求不带可用的 origin,但会带来源页 URL
210
+ * —— 这是 HTML 产物也能显示工作区图片的原因。
211
+ */
212
+ function pluginPageReferer(req) {
213
+ const referer = req.headers.referer;
214
+ if (typeof referer !== "string" || referer === "") return false;
215
+ try {
216
+ const url = new URL(referer);
217
+ if (url.host !== req.headers.host) return false;
218
+ return url.pathname.startsWith("/plugin/");
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+ /**
224
+ * 请求是否可以被回答。
225
+ *
226
+ * 浏览器一定发 `Sec-Fetch-Site`,跨站一律拒绝;没有这个头的调用方不是浏览器页面
227
+ * (curl、测试),此时回环可达性本身就是访问模型 —— 与本地 GUI 其余部分假定的信任一致。
228
+ */
229
+ function sameOrigin(req) {
230
+ if (!loopbackHost(req)) return false;
231
+ if (pluginPageReferer(req)) return true;
232
+ const site = req.headers["sec-fetch-site"];
233
+ if (typeof site === "string") return site === "same-origin" || site === "none";
234
+ const origin = req.headers.origin;
235
+ if (typeof origin !== "string" || origin === "") return true;
236
+ try {
237
+ return new URL(origin).host === req.headers.host;
238
+ } catch {
239
+ return false;
240
+ }
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
+ }
253
+ /** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
254
+ function refuse(res, status, reason) {
255
+ const body = `${reason}\n`;
256
+ res.writeHead(status, {
257
+ "content-type": "text/plain; charset=utf-8",
258
+ "content-length": String(Buffer.byteLength(body)),
259
+ "cache-control": "no-store",
260
+ "x-content-type-options": "nosniff"
261
+ });
262
+ res.end(body);
263
+ }
264
+ /**
265
+ * 这条路由的处理器工厂。
266
+ * @param options - 取图实现。
267
+ * @returns 一个 `(req, res) => Promise<void>`,可直接交给 `webServer.register`。
268
+ */
269
+ function createImageRouteHandler(options) {
270
+ return async function serveImage(req, res) {
271
+ if (req.method !== "GET" && req.method !== "HEAD") {
272
+ refuse(res, 405, "method not allowed");
273
+ return;
274
+ }
275
+ if (!sameOrigin(req)) {
276
+ refuse(res, 403, "cross-site request refused");
277
+ return;
278
+ }
279
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
280
+ const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
281
+ const rawName = url.searchParams.get("name") ?? "";
282
+ if (sessionId === "" || rawName === "" || rawName.length > MAX_NAME_CHARS) {
283
+ refuse(res, 400, "sessionId and name are required");
284
+ return;
285
+ }
286
+ const name = rawName.split(/[/\\]/).pop() ?? rawName;
287
+ if (HANDOFF_FILE_NAME.exec(name) === null) {
288
+ refuse(res, 404, "no such handoff image");
289
+ return;
290
+ }
291
+ const declared = declaredMediaType(name);
292
+ let image;
293
+ try {
294
+ image = await options.read(sessionId, name);
295
+ } catch {
296
+ refuse(res, 404, "no such handoff image");
297
+ return;
298
+ }
299
+ if (image.bytes.byteLength === 0) {
300
+ refuse(res, 404, "empty file");
301
+ return;
302
+ }
303
+ if (image.bytes.byteLength > MAX_BYTES) {
304
+ refuse(res, 413, "image is too large to display");
305
+ return;
306
+ }
307
+ const sniffed = sniffMediaType(image.bytes.subarray(0, 12));
308
+ if (sniffed === void 0) {
309
+ refuse(res, 415, "content is not a supported image");
310
+ return;
311
+ }
312
+ if (declared !== void 0 && !sameImageFamily(declared, sniffed)) {
313
+ refuse(res, 415, "content does not match the file extension");
314
+ return;
315
+ }
316
+ if (!contentMatchesName(image.bytes, name)) {
317
+ refuse(res, 404, "no such handoff image");
318
+ return;
319
+ }
320
+ res.writeHead(200, {
321
+ "content-type": sniffed,
322
+ "content-length": String(image.bytes.byteLength),
323
+ "cache-control": "private, max-age=300",
324
+ "x-content-type-options": "nosniff"
325
+ });
326
+ if (req.method === "HEAD") {
327
+ res.end();
328
+ return;
329
+ }
330
+ res.end(Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength));
331
+ };
332
+ }
333
+ //#endregion
47
334
  //#region src/host/rate-limit.ts
48
335
  var FixedWindowLimiter = class {
49
336
  limit;
@@ -74,27 +361,206 @@ var FixedWindowLimiter = class {
74
361
  };
75
362
  //#endregion
76
363
  //#region src/host/auth.ts
364
+ /**
365
+ * 判定「这是不是一次真正的本机管理请求」。
366
+ *
367
+ * ## 为什么不能只看 `remoteAddress`
368
+ *
369
+ * 修复前 `requireAdmin` 的全部判据是「TCP 来源是回环 + Origin 是回环」。这条判据在
370
+ * **任何把外部请求转到本机回环的拓扑下都恒真**:插件自带的 cloudflared Quick Tunnel
371
+ * 用 `--url http://127.0.0.1:PORT` 转发(`cloudflared.ts`),公网请求进到 bridge 时
372
+ * `req.socket.remoteAddress` 就是 `127.0.0.1`。于是 `https://xxx.trycloudflare.com/manage/roots`
373
+ * 一个凭据都不带也能读到全部授权根的绝对路径(v2.5.0 实测 200/201)。
374
+ * 同一类拓扑还包括 ngrok、frp、Caddy/Nginx `proxy_pass 127.0.0.1`、SSH 端口转发、
375
+ * Tailscale Serve。见 `docs/project/PITFALLS.md` §隧道让回环判据失效。
376
+ *
377
+ * ## 四层判据(层层收紧,`undefined` = 放行)
378
+ *
379
+ * 0. **隧道运行中一律拒绝**(`tunnelActive === true`)—— 100% 可靠的一条:隧道是我们
380
+ * 自己拉的进程,状态握在自己手里,不依赖任何可伪造的请求头。返回
381
+ * `TUNNEL_MODE_ADMIN_DISABLED`。
382
+ * 1. **出现任何转发头即视为经代理** —— `Forwarded` / `X-Forwarded-For` /
383
+ * `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP`。真正的本机浏览器与
384
+ * `curl 127.0.0.1` 都不会发这些头;发了就说明前面有一层我们不认识的代理。
385
+ * 返回 `ADMIN_PROXY_FORWARDED`。
386
+ * 2. **`Host` 必须是回环**(复用 `image-handoff.ts` 的 `loopbackHost`,它接受
387
+ * `127.0.0.1` / `localhost` / `[::1]`)。缺失 `Host` 也拒。这一条挡两类攻击:
388
+ * 隧道/反代转发进来的公网域名(`Host: xxx.trycloudflare.com`),以及 **DNS 重绑定**
389
+ * (攻击者域名解析到 127.0.0.1,浏览器照样发回环 `Origin`,只有 `Host` 露馅)。
390
+ * 返回 `ADMIN_HOST_NOT_LOOPBACK`。
391
+ * 3. **socket 来源必须是回环,`Origin` 若存在必须是回环**。`Origin` 缺失**仍然放行** ——
392
+ * 这是刻意的取舍,理由见下。
393
+ *
394
+ * ## 为什么 `Origin` 缺失还放行(刻意的取舍,别「顺手修掉」)
395
+ *
396
+ * 翻成「必须带回环 Origin」能多挡一点,但会**同时打死 `curl` 与脚本调试**:它们在
397
+ * 本机跑、天然不带 `Origin`,而本机进程本来就有权读这些文件(它们能直接 `cat`)。
398
+ * 真正的防线不是「请求必须像浏览器」,而是**拓扑与头**:判据 0 管住了我们自己拉的隧道,
399
+ * 判据 1 管住了任何代理,判据 2 管住了公网域名与 DNS 重绑定。隧道场景里公网请求即使
400
+ * 不带 `Origin` 也会先撞上判据 0/1/2 —— 这正是 §3.1 那条攻击链的断点。
401
+ *
402
+ * 换句话说:**允许缺 `Origin` 不再是「可信」,而是「不再有决定权」**。修复前它是有
403
+ * 决定权的(`isLoopbackOrigin(undefined) === true` 直接就给了管理员身份)。
404
+ *
405
+ * @param req - 待判定的请求。
406
+ * @param options.tunnelActive - 是否有我们自管的隧道在运行;缺省视为没有。
407
+ * @param options.allowTunnelMode - 仅用于「隧道状态」与「停止隧道」端点:让它们豁免判据 0
408
+ * (否则隧道一开,面板连「隧道开着」都读不到,也无法体面地把隧道停掉),判据 1/2/3
409
+ * 仍然全部生效。其中 `cloudflare/stop` 是**唯一**豁免的写操作 —— 它的唯一效果就是把
410
+ * 管理面重新锁上(重新拉回判据 0),且判据 1 仍挡住任何经隧道/代理的调用,所以对真实
411
+ * 隧道来源(必带 cf-* 头)不可利用;放行它不构成任何提权。**其他任何 `/manage` 端点
412
+ * (尤其 config/text)都不许传这个选项。**
413
+ */
414
+ function adminRefusal(req, options = {}) {
415
+ if (options.tunnelActive === true && options.allowTunnelMode !== true) return {
416
+ code: "TUNNEL_MODE_ADMIN_DISABLED",
417
+ message: "Management endpoints are disabled while the Cloudflare tunnel is running. Use the local WebUI, or stop the tunnel first."
418
+ };
419
+ for (const name of FORWARDING_HEADERS) if (headerPresent(req, name)) return {
420
+ code: "ADMIN_PROXY_FORWARDED",
421
+ message: "Management endpoints are available only from the local machine, not through a proxy or tunnel."
422
+ };
423
+ if (!loopbackHost(req)) return {
424
+ code: "ADMIN_HOST_NOT_LOOPBACK",
425
+ message: "Management endpoints are available only under a loopback host name (127.0.0.1, localhost or [::1])."
426
+ };
427
+ if (!isLoopbackAddress(requestAddress(req)) || !isLoopbackOrigin(req.headers.origin)) return {
428
+ code: "ADMIN_LOOPBACK_REQUIRED",
429
+ message: "This operation is available only from the local machine."
430
+ };
431
+ }
432
+ /** 是否存在任何转发头(`adminRefusal` 判据 1 的公开形态,供测试与文档断言复用)。 */
433
+ function hasForwardingHeader(req) {
434
+ return FORWARDING_HEADERS.some((name) => headerPresent(req, name));
435
+ }
436
+ /**
437
+ * 「这个请求不是本机直连」的**全部**证据头。
438
+ *
439
+ * 分两组,理由不同:
440
+ *
441
+ * 1. **转发头**(`Forwarded` / `X-Forwarded-*` / `X-Real-IP`):本机浏览器与
442
+ * `curl 127.0.0.1` 都不会发。**存在即拒**,不看值 —— `X-Forwarded-For: 127.0.0.1`
443
+ * 正是最典型的洗白写法(攻击者从隧道进来,塞一个声称自己是本机的头)。
444
+ * 2. **Cloudflare 边缘头**(`Cf-Connecting-IP` / `Cf-Ray` / `Cf-IpCountry` / `Cf-Visitor`):
445
+ * 这一组是**真实隧道场景的兜底**。cloudflared 的 `httpHostHeader` 默认为空,会把
446
+ * `Host` 改写成源站地址(`127.0.0.1:PORT`),于是判据 2 在真实隧道下挡不住;
447
+ * 而 Cloudflare 边缘一定会注入这组头(且请求方无法伪造成「我没有经过边缘」)。
448
+ * 实测(`.local-backup/security-audit/verify-fix-tunnel-admin.mjs`):
449
+ * 只把 Host 改成回环地址就能拿到 200,加上这组头才被挡住。
450
+ *
451
+ * 这两组都不参与「判据 0 的豁免」—— 隧道状态端点仍要求它们为空。
452
+ */
453
+ const FORWARDING_HEADERS = [
454
+ "forwarded",
455
+ "x-forwarded-for",
456
+ "x-forwarded-host",
457
+ "x-forwarded-proto",
458
+ "x-real-ip",
459
+ "cf-connecting-ip",
460
+ "cf-ray",
461
+ "cf-ipcountry",
462
+ "cf-visitor"
463
+ ];
464
+ /** 头存在且非空才算「出现」:空串是某些代理库的产物,不构成声明。 */
465
+ function headerPresent(req, name) {
466
+ const raw = req.headers[name];
467
+ if (raw === void 0) return false;
468
+ if (Array.isArray(raw)) return raw.some((value) => value.trim() !== "");
469
+ return raw.trim() !== "";
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
+ }
77
482
  var AuthService = class {
78
483
  database;
79
- authFailures = new FixedWindowLimiter(20, 3e5);
80
- pairingAttempts = new FixedWindowLimiter(10, 6e5);
81
- requests = new FixedWindowLimiter(120, 6e4);
82
- constructor(database) {
484
+ options;
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
+ };
508
+ constructor(database, options = {}) {
83
509
  this.database = database;
510
+ this.options = options;
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
+ }
528
+ /**
529
+ * 隧道是否在跑。探针抛错时**按「在跑」处理**(fail closed):探针读的是我们自己
530
+ * 对象上的 getter,它抛错意味着状态不可知;状态不可知时放行管理面正是这次修复要
531
+ * 消灭的那类决定。
532
+ */
533
+ tunnelRunning() {
534
+ const probe = this.options.tunnelActive;
535
+ if (probe === void 0) return false;
536
+ try {
537
+ return probe() === true;
538
+ } catch {
539
+ return true;
540
+ }
84
541
  }
85
542
  principal(req, options = {}) {
86
543
  const remote = requestAddress(req);
87
- this.requests.consume(remote);
88
- if (options.allowAdmin === true && req.headers["x-dsh-workspace-admin"] === "1" && isLoopbackAddress(remote) && isLoopbackOrigin(req.headers.origin)) return ADMIN_PRINCIPAL;
544
+ const peer = this.peerOf(req);
545
+ const key = `${peer}:${remote}`;
546
+ this.requestsByPeer[peer].consume(key);
547
+ if (options.allowAdmin === true && req.headers["x-dsh-workspace-admin"] === "1") {
548
+ const refusal = adminRefusal(req, { tunnelActive: this.tunnelRunning() });
549
+ if (refusal !== void 0) {
550
+ this.authFailuresByPeer[peer].consume(key);
551
+ throw new ApiError(403, refusal.code, refusal.message);
552
+ }
553
+ return ADMIN_PRINCIPAL;
554
+ }
89
555
  const authorization = req.headers.authorization;
90
556
  if (authorization === void 0 || !authorization.startsWith("Bearer ")) {
91
- this.authFailures.consume(remote);
557
+ this.authFailuresByPeer[peer].consume(key);
92
558
  throw new ApiError(401, "AUTH_REQUIRED", "A valid Bearer token is required.");
93
559
  }
94
560
  const token = authorization.slice(7).trim();
95
561
  const device = token === "" ? void 0 : this.database.authenticate(token);
96
562
  if (device === void 0) {
97
- this.authFailures.consume(remote);
563
+ this.authFailuresByPeer[peer].consume(key);
98
564
  throw new ApiError(401, "TOKEN_INVALID", "The device token is invalid or revoked.");
99
565
  }
100
566
  return {
@@ -105,8 +571,18 @@ var AuthService = class {
105
571
  rootIds: new Set(device.rootIds)
106
572
  };
107
573
  }
108
- requireAdmin(req) {
109
- if (!isLoopbackAddress(requestAddress(req)) || !isLoopbackOrigin(req.headers.origin)) throw new ApiError(403, "ADMIN_LOOPBACK_REQUIRED", "This operation is available only from the local machine.");
574
+ /**
575
+ * `/manage/**` 的守门人。
576
+ *
577
+ * @param options.allowTunnelMode - 只有「隧道状态下读/停隧道」这几个端点会传 true。
578
+ * 见 `adminRefusal` 的同名说明与 `router.ts` 的 `TUNNEL_MODE_EXEMPT_PATHS`。
579
+ */
580
+ requireAdmin(req, options = {}) {
581
+ const refusal = adminRefusal(req, {
582
+ tunnelActive: this.tunnelRunning(),
583
+ ...options.allowTunnelMode === true ? { allowTunnelMode: true } : {}
584
+ });
585
+ if (refusal !== void 0) throw new ApiError(403, refusal.code, refusal.message);
110
586
  return ADMIN_PRINCIPAL;
111
587
  }
112
588
  requireScope(principal, scope) {
@@ -123,6 +599,13 @@ function isLoopbackAddress(address) {
123
599
  const normalized = address.toLowerCase().split("%")[0];
124
600
  return normalized === "127.0.0.1" || normalized === "::1" || normalized === "::ffff:127.0.0.1";
125
601
  }
602
+ /**
603
+ * `Origin` 是否是回环。
604
+ *
605
+ * `undefined` 返回 `true` 是**刻意保留**的(理由见 `adminRefusal` 的注释):它不再
606
+ * 单独构成「可信」,判据 0/1/2 已经承担了决定性判断。改这一行会让 `curl` 调试
607
+ * 无处可去,而本机进程本来就能直接读这些文件。
608
+ */
126
609
  function isLoopbackOrigin(origin) {
127
610
  if (origin === void 0) return true;
128
611
  try {
@@ -155,14 +638,27 @@ function archSuffix(platform = process.platform, arch = process.arch) {
155
638
  default: throw new Error(`cloudflared 尚不支持 ${process.arch} 架构。`);
156
639
  }
157
640
  }
158
- 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-`;
159
650
  /**
160
651
  * 官方 deb 兜底仓库(GitHub 被墙时回退)。
652
+ *
161
653
  * `pkg.cloudflare.com` 是 Cloudflare 官方仓库,实测在 GitHub 直连被墙的机器上可达可用
162
- * (见 PITFALLS §国内下载 cloudflared 必须择源)。版本比 GitHub 默认新(2026.10.1),
163
- * 仅在官方 GitHub 失败时兜底,不改变「默认先 GitHub」的语义。
654
+ * (见 PITFALLS §国内下载 cloudflared 必须择源)。仅在官方 GitHub 失败时兜底,
655
+ * 不改变「默认先 GitHub」的语义。
656
+ *
657
+ * 版本**从 `CLOUDFLARED_VERSION` 派生**,不再写死第二个版本号。
164
658
  */
165
- 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
+ }
166
662
  async function downloadHttps(url, ms, onProgress) {
167
663
  const mod = url.startsWith("http:") ? await import("node:http") : await import("node:https");
168
664
  return await new Promise((resolve, reject) => {
@@ -196,6 +692,60 @@ async function downloadHttps(url, ms, onProgress) {
196
692
  function statusOk(code) {
197
693
  return typeof code === "number" && code >= 200 && code < 300;
198
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
+ }
199
749
  /** 环境变量注入的下载源配置;空则回退官方。 */
200
750
  function mirrorBase() {
201
751
  return process.env.CLOUDFLARED_MIRROR?.trim() ?? "";
@@ -246,14 +796,14 @@ async function ensureCloudflared(dataDir, report) {
246
796
  const existing = path.join(dataDir, "cloudflared");
247
797
  report?.({
248
798
  stage: "locating",
249
- label: "定位本机二进制…",
799
+ labelKey: "tunnelStageLocating",
250
800
  percent: null
251
801
  });
252
802
  try {
253
803
  if ((await stat(existing)).isFile()) {
254
804
  report?.({
255
805
  stage: "locating",
256
- label: "已找到本机二进制,直接复用",
806
+ labelKey: "tunnelStageBinaryFound",
257
807
  percent: 100
258
808
  });
259
809
  return existing;
@@ -267,14 +817,15 @@ async function ensureCloudflared(dataDir, report) {
267
817
  url: downloadUrlFor(),
268
818
  deb: false
269
819
  }, {
270
- url: DEB_FALLBACK_URL,
820
+ url: debFallbackUrl("amd64"),
271
821
  deb: true
272
822
  }];
823
+ const expected = expectedSha256From();
273
824
  const errors = [];
274
825
  for (const { url, deb } of candidates) {
275
826
  report?.({
276
827
  stage: "downloading",
277
- label: `正在下载 cloudflared(${deb ? "官方 deb 仓库" : "官方 GitHub"})…`,
828
+ labelKey: deb ? "tunnelStageDownloadDeb" : "tunnelStageDownloadGithub",
278
829
  percent: null
279
830
  });
280
831
  try {
@@ -285,7 +836,11 @@ async function ensureCloudflared(dataDir, report) {
285
836
  const all = (total / 1024 / 1024).toFixed(1);
286
837
  report?.({
287
838
  stage: "downloading",
288
- label: `正在下载 cloudflared… ${mb}/${all} MB`,
839
+ labelKey: "tunnelStageDownloadProgress",
840
+ labelParams: {
841
+ mb,
842
+ all
843
+ },
289
844
  percent
290
845
  });
291
846
  }
@@ -294,17 +849,26 @@ async function ensureCloudflared(dataDir, report) {
294
849
  errors.push(`HTTP ${res.status}`);
295
850
  continue;
296
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
+ });
297
861
  if (deb) {
298
862
  report?.({
299
863
  stage: "unpacking",
300
- label: "解包官方 deb 并提取二进制…",
864
+ labelKey: "tunnelStageUnpackDeb",
301
865
  percent: null
302
866
  });
303
867
  return writeFromDeb(dataDir, res.body, url);
304
868
  }
305
869
  report?.({
306
870
  stage: "unpacking",
307
- label: "写入二进制并赋予执行权限…",
871
+ labelKey: "tunnelStageWriteBinary",
308
872
  percent: null
309
873
  });
310
874
  const tmp = path.join(dataDir, `.cloudflared.tmp-${process.pid}`);
@@ -313,15 +877,24 @@ async function ensureCloudflared(dataDir, report) {
313
877
  await rename(tmp, existing);
314
878
  return existing;
315
879
  } catch (cause) {
316
- errors.push(cause instanceof Error ? cause.message : String(cause));
880
+ errors.push(sanitizeDownloadError(cause instanceof Error ? cause.message : String(cause)));
317
881
  }
318
882
  }
319
- const tried = candidates.map((c) => c.url).join("\n ");
320
- 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(";")}`));
321
891
  }
322
892
  /**
323
893
  * 从 `.deb` 包字节中提取 `usr/bin/cloudflared`,原子落到 `dataDir/cloudflared`。
324
894
  * 不调用系统 `dpkg -i`(避免污染包数据库),只做解包 + 提取。
895
+ *
896
+ * 失败时抛出的错误**已脱敏**:`execFile` 的原始错误含两个绝对路径
897
+ * (deb 与解包目录),直接外传会泄露本机目录结构。
325
898
  */
326
899
  async function writeFromDeb(dataDir, debBuffer, url) {
327
900
  await mkdir(dataDir, { recursive: true });
@@ -335,11 +908,18 @@ async function writeFromDeb(dataDir, debBuffer, url) {
335
908
  "-x",
336
909
  debPath,
337
910
  extractDir
338
- ], (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
+ });
339
918
  });
340
919
  const inner = path.join(extractDir, "usr", "bin", "cloudflared");
341
920
  const out = path.join(dataDir, "cloudflared");
342
921
  const bin = await readFile(inner);
922
+ if (bin.byteLength === 0) throw new Error("解包后的 cloudflared 为空文件");
343
923
  await writeFile(out, bin);
344
924
  await chmod(out, 493);
345
925
  return out;
@@ -357,10 +937,12 @@ var CloudflaredTunnelManager = class {
357
937
  url = null;
358
938
  channel = null;
359
939
  errorText = void 0;
940
+ errorKey = void 0;
941
+ errorParams = void 0;
360
942
  listeners = [];
361
943
  activity = {
362
944
  stage: "idle",
363
- label: "",
945
+ labelKey: "",
364
946
  percent: null
365
947
  };
366
948
  /** 当前进行中任务的阶段进度(无任务时 stage 为 idle)。 */
@@ -376,31 +958,54 @@ var CloudflaredTunnelManager = class {
376
958
  clearActivity() {
377
959
  this.activity = {
378
960
  stage: "idle",
379
- label: "",
961
+ labelKey: "",
380
962
  percent: null
381
963
  };
382
964
  this.emit();
383
965
  }
966
+ /**
967
+ * 是否有隧道在运行 —— 管理面鉴权的**判据 0**。
968
+ *
969
+ * 判据取自**进程持有状态**,不是请求头,所以它对「隧道把外部流量转进 127.0.0.1」
970
+ * 这条绕过路径 100% 可靠:不管转发进来的请求长什么样,只要隧道开着,管理面就关门。
971
+ * 见 `auth.ts` 的 `adminRefusal` 与 `PITFALLS.md` §隧道让回环判据失效。
972
+ *
973
+ * `running` 与 `publicState.running` 是同一个表达式(同一份真相),这里单独暴露是为了
974
+ * 让鉴权层不必构造一个含 url 的视图对象 —— 少一处把地址带进意外地方的机会。
975
+ */
976
+ get tunnelActive() {
977
+ return this.process !== null && !this.process.killed;
978
+ }
384
979
  get publicState() {
385
980
  return {
386
981
  channel: this.channel,
387
982
  url: this.url,
388
- running: this.process !== null && !this.process.killed,
389
- ...this.errorText === void 0 ? {} : { error: this.errorText }
983
+ running: this.tunnelActive,
984
+ ...this.errorText === void 0 ? {} : { error: this.errorText },
985
+ ...this.errorKey === void 0 ? {} : { errorKey: this.errorKey },
986
+ ...this.errorParams === void 0 ? {} : { errorParams: this.errorParams }
390
987
  };
391
988
  }
392
989
  /** 订阅状态变化;返回取消函数。 */
393
990
  onStatus(cb) {
394
991
  this.listeners.push(cb);
395
- return () => {
396
- this.listeners = this.listeners.filter((fn) => fn !== cb);
397
- };
992
+ return () => this.offStatus(cb);
993
+ }
994
+ /** 取消一个状态订阅(onStatus 返回的函数与 waitForUrl 共用)。 */
995
+ offStatus(cb) {
996
+ this.listeners = this.listeners.filter((fn) => fn !== cb);
398
997
  }
399
998
  emit() {
400
999
  for (const fn of this.listeners) fn();
401
1000
  }
402
- setError(message) {
1001
+ /**
1002
+ * 记录一次错误。`error` 保留脱敏后的短消息(兜底/日志),
1003
+ * `errorKey` + `errorParams` 供客户端本地化(优先级 errorKey > error)。
1004
+ */
1005
+ setError(errorKey, message, errorParams) {
403
1006
  this.errorText = message;
1007
+ this.errorKey = errorKey;
1008
+ this.errorParams = errorParams;
404
1009
  this.emit();
405
1010
  }
406
1011
  /**
@@ -409,11 +1014,13 @@ var CloudflaredTunnelManager = class {
409
1014
  * @param port bridge 监听端口,cloudflared 把外网请求转发到 localhost:port
410
1015
  */
411
1016
  async startQuick(exe, port) {
412
- 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.");
413
1018
  this.errorText = void 0;
1019
+ this.errorKey = void 0;
1020
+ this.errorParams = void 0;
414
1021
  this.reportActivity({
415
1022
  stage: "starting",
416
- label: "正在启动 cloudflared 隧道…",
1023
+ labelKey: "tunnelStageStarting",
417
1024
  percent: null
418
1025
  });
419
1026
  const child = spawn(exe, [
@@ -445,13 +1052,52 @@ var CloudflaredTunnelManager = class {
445
1052
  this.channel = null;
446
1053
  this.emit();
447
1054
  if (wasThis) {
448
- 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
+ });
449
1059
  this.url = null;
450
1060
  if (this.channel === null) this.clearActivity();
451
1061
  }
452
1062
  });
453
1063
  return this.publicState;
454
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
+ }
455
1101
  /** 停止当前隧道。 */
456
1102
  stop() {
457
1103
  const child = this.process;
@@ -471,236 +1117,139 @@ var CloudflaredTunnelManager = class {
471
1117
  }
472
1118
  };
473
1119
  //#endregion
474
- //#region src/host/image-handoff.ts
1120
+ //#region src/host/metrics.ts
475
1121
  /**
476
- * 交接目录的**出厂默认**。
1122
+ * 进程内**薄指标收集器**。
477
1123
  *
478
- * 设置面板里「当前默认」那一栏展示的就是这个字面量,且是只读的 —— 它是这份代码的
479
- * 契约(历史交接消息、识图工具、手机端都按它写过),不是一个可以随风改的偏好项。
480
- * 用户可以改的是设置的**当前值**(存在数据库里),默认值本身不跟着走。
481
- */
482
- const DEFAULT_HANDOFF_SUBDIR = ".dsh-images";
483
- /** settings 表里的键名。 */
484
- const IMAGE_SUBDIR_SETTING = "image_handoff_subdir";
485
- /**
486
- * 只读图片路由。刻意放在 `/plugin/` 下而不是版本化 REST 前缀里:
487
- * 它不参与 REST 契约、不需要设备 token,将来 REST 升版时这条也不该跟着动。
488
- */
489
- const IMAGE_ROUTE = "/plugin/dsh-remote-bridge/raw";
490
- /** 目录名上限:它要拼进路径,太长的名字只是拿来坑自己的。 */
491
- const MAX_SUBDIR_CHARS = 64;
492
- /**
493
- * 把用户填的目录名收成一个安全值。
1124
+ * ## 为什么需要它
494
1125
  *
495
- * 只收**单段**目录名:`/`、`\`、NUL 一律拒绝(否则它就不是「目录名」而是「路径」,
496
- * 会把落盘位置带出工作目录);`.` 与 `..` 拒绝(前者是当前目录、后者是往上爬);
497
- * 允许点开头 —— 默认值 `.dsh-images` 自己就是点目录。
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
+ * 不会让功能变化 —— 这是刻意划的边界。
498
1146
  */
499
- function normalizeHandoffSubdir(value) {
500
- if (typeof value !== "string") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name must be a string.");
501
- const trimmed = value.trim();
502
- if (trimmed === "" || trimmed.length > MAX_SUBDIR_CHARS) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", `The image directory name must be 1-${MAX_SUBDIR_CHARS} characters.`);
503
- if (trimmed === "." || trimmed === "..") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name cannot be \".\" or \"..\".");
504
- if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name may only contain letters, digits, dots, underscores, and hyphens.");
505
- return trimmed;
506
- }
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
+ };
507
1163
  /**
508
- * 读出当前生效的目录名。
1164
+ * 记录一次 RPC 耗时。
509
1165
  *
510
- * 数据库里那条坏掉(手改过 / 旧版本写过)就退回默认并**顺手修掉**:一个坏值不该让
511
- * 「发图」这个动作从此失败,而每次读都只回退不修正会把坏值永远留着。
1166
+ * @param name - 稳定的 RPC 名(如 `sessions.history`)。**不要**把参数拼进去,
1167
+ * 否则基数会炸(每个 sessionId 一个桶)。
1168
+ * @param ms - 毫秒耗时。
1169
+ * @param ok - 是否成功;失败单独计数,因为「慢」与「错」是两回事。
512
1170
  */
513
- function readHandoffSubdir(database) {
514
- const stored = database.getSetting(IMAGE_SUBDIR_SETTING);
515
- if (stored === void 0) return DEFAULT_HANDOFF_SUBDIR;
516
- try {
517
- return normalizeHandoffSubdir(stored);
518
- } catch {
519
- database.setSetting(IMAGE_SUBDIR_SETTING, DEFAULT_HANDOFF_SUBDIR);
520
- return DEFAULT_HANDOFF_SUBDIR;
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);
521
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;
522
1193
  }
523
- /** 校验并保存,返回落库后的值。 */
524
- function saveHandoffSubdir(database, value) {
525
- const normalized = normalizeHandoffSubdir(value);
526
- database.setSetting(IMAGE_SUBDIR_SETTING, normalized);
527
- return normalized;
1194
+ function setWsConnections(value) {
1195
+ counters.wsConnections = value < 0 ? 0 : value;
528
1196
  }
529
- /** 设置面板要的那三个值。 */
530
- function imageHandoffView(database, fallbackDir) {
531
- return {
532
- subdir: readHandoffSubdir(database),
533
- defaultSubdir: DEFAULT_HANDOFF_SUBDIR,
534
- fallbackDir
535
- };
1197
+ function recordWsRejected() {
1198
+ counters.wsRejected += 1;
536
1199
  }
537
- /** 交接文件名的白名单:`<sha256>.<图片扩展名>`,路径穿越在名字这一层就死掉。 */
538
- const HANDOFF_FILE_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
539
- /** 扩展名 → 该扩展名必须用内容自证的身份。 */
540
- const HANDOFF_MEDIA_TYPES = {
541
- png: "image/png",
542
- jpg: "image/jpeg",
543
- jpeg: "image/jpeg",
544
- webp: "image/webp",
545
- gif: "image/gif",
546
- heic: "image/heic",
547
- heif: "image/heif",
548
- bmp: "image/bmp",
549
- avif: "image/avif"
550
- };
551
- /** 浏览器只会显示预览,超过这个就别端过去了。 */
552
- const MAX_BYTES = 33554432;
553
- /** 病态长的入参在碰文件系统之前就退掉。 */
554
- const MAX_NAME_CHARS = 4096;
555
- /** 按扩展名取声明的媒体类型;不支持的扩展名返回 undefined。 */
556
- function declaredMediaType(name) {
557
- const cut = name.lastIndexOf(".");
558
- if (cut === -1) return void 0;
559
- return HANDOFF_MEDIA_TYPES[name.slice(cut + 1).toLowerCase()];
1200
+ function recordWsTerminated() {
1201
+ counters.wsTerminated += 1;
560
1202
  }
561
- /**
562
- * 从文件头认出真实格式。扩展名只是**声明**,这里才是内容自证。
563
- * @param head - 文件前至少 12 个字节。
564
- */
565
- function sniffMediaType(head) {
566
- const ascii = (offset, text) => {
567
- if (head.length < offset + text.length) return false;
568
- for (let index = 0; index < text.length; index += 1) if (head[offset + index] !== text.charCodeAt(index)) return false;
569
- return true;
570
- };
571
- const bytes = (...expected) => {
572
- if (head.length < expected.length) return false;
573
- return expected.every((byte, index) => head[index] === byte);
574
- };
575
- if (bytes(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
576
- if (bytes(255, 216, 255)) return "image/jpeg";
577
- if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
578
- if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
579
- if (bytes(66, 77)) return "image/bmp";
580
- if (ascii(4, "ftyp")) {
581
- const brand = head.length >= 12 ? String.fromCharCode(...head.subarray(8, 12)) : "";
582
- if (brand.startsWith("avi")) return "image/avif";
583
- if (brand.startsWith("hei") || brand.startsWith("hev") || brand.startsWith("mif")) return "image/heic";
584
- }
1203
+ function recordBroadcastEvent() {
1204
+ counters.broadcastEvents += 1;
585
1205
  }
586
- /** 请求的 `Host` 是否指向回环(DNS 重绑定防护)。 */
587
- function loopbackHost(req) {
588
- const host = req.headers.host;
589
- if (typeof host !== "string" || host === "") return false;
590
- if (host.startsWith("[")) return host.startsWith("[::1]");
591
- const name = host.split(":")[0];
592
- return name === "127.0.0.1" || name === "localhost";
1206
+ /** 更新「最长连接队列」;传入当前所有连接队列长度的最大值。 */
1207
+ function setBroadcastQueueDepth(value) {
1208
+ counters.broadcastQueueDepth = value < 0 ? 0 : value;
593
1209
  }
594
- /**
595
- * 请求是否来自某个插件自己服务的页面。
596
- *
597
- * 沙箱化的画布跑在不透明 origin 里,它自己的请求不带可用的 origin,但会带来源页 URL
598
- * —— 这是 HTML 产物也能显示工作区图片的原因。
599
- */
600
- function pluginPageReferer(req) {
601
- const referer = req.headers.referer;
602
- if (typeof referer !== "string" || referer === "") return false;
603
- try {
604
- const url = new URL(referer);
605
- if (url.host !== req.headers.host) return false;
606
- return url.pathname.startsWith("/plugin/");
607
- } catch {
608
- return false;
609
- }
1210
+ function recordDroppedEvent(count = 1) {
1211
+ counters.droppedEvents += count;
610
1212
  }
611
- /**
612
- * 请求是否可以被回答。
613
- *
614
- * 浏览器一定发 `Sec-Fetch-Site`,跨站一律拒绝;没有这个头的调用方不是浏览器页面
615
- * (curl、测试),此时回环可达性本身就是访问模型 —— 与本地 GUI 其余部分假定的信任一致。
616
- */
617
- function sameOrigin(req) {
618
- if (!loopbackHost(req)) return false;
619
- if (pluginPageReferer(req)) return true;
620
- const site = req.headers["sec-fetch-site"];
621
- if (typeof site === "string") return site === "same-origin" || site === "none";
622
- const origin = req.headers.origin;
623
- if (typeof origin !== "string" || origin === "") return true;
624
- try {
625
- return new URL(origin).host === req.headers.host;
626
- } catch {
627
- return false;
628
- }
1213
+ /** 由 `FrameHub` 上报:它自己丢了几个事件(与 socket 队列无关的另一处丢弃点)。 */
1214
+ function recordFrameHubDropped(count = 1) {
1215
+ counters.frameHubDropped += count;
629
1216
  }
630
- /** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
631
- function refuse(res, status, reason) {
632
- const body = `${reason}\n`;
633
- res.writeHead(status, {
634
- "content-type": "text/plain; charset=utf-8",
635
- "content-length": String(Buffer.byteLength(body)),
636
- "cache-control": "no-store",
637
- "x-content-type-options": "nosniff"
638
- });
639
- res.end(body);
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;
640
1222
  }
641
1223
  /**
642
- * 这条路由的处理器工厂。
643
- * @param options - 取图实现。
644
- * @returns 一个 `(req, res) => Promise<void>`,可直接交给 `webServer.register`。
1224
+ * 读一份快照。**纯读**:不重置任何计数(重置会让「谁先读谁清空」变成新的坑)。
1225
+ *
1226
+ * 返回的是**新对象**,调用方改它不会污染内部状态。
645
1227
  */
646
- function createImageRouteHandler(options) {
647
- return async function serveImage(req, res) {
648
- if (req.method !== "GET" && req.method !== "HEAD") {
649
- refuse(res, 405, "method not allowed");
650
- return;
651
- }
652
- if (!sameOrigin(req)) {
653
- refuse(res, 403, "cross-site request refused");
654
- return;
655
- }
656
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
657
- const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
658
- const rawName = url.searchParams.get("name") ?? "";
659
- if (sessionId === "" || rawName === "" || rawName.length > MAX_NAME_CHARS) {
660
- refuse(res, 400, "sessionId and name are required");
661
- return;
662
- }
663
- const name = rawName.split(/[/\\]/).pop() ?? rawName;
664
- if (HANDOFF_FILE_NAME.exec(name) === null) {
665
- refuse(res, 404, "no such handoff image");
666
- return;
667
- }
668
- const declared = declaredMediaType(name);
669
- let image;
670
- try {
671
- image = await options.read(sessionId, name);
672
- } catch {
673
- refuse(res, 404, "no such handoff image");
674
- return;
675
- }
676
- if (image.bytes.byteLength === 0) {
677
- refuse(res, 404, "empty file");
678
- return;
679
- }
680
- if (image.bytes.byteLength > MAX_BYTES) {
681
- refuse(res, 413, "image is too large to display");
682
- return;
683
- }
684
- const sniffed = sniffMediaType(image.bytes.subarray(0, 12));
685
- if (sniffed === void 0) {
686
- refuse(res, 415, "content is not a supported image");
687
- return;
688
- }
689
- if (declared !== void 0 && sniffed !== declared) {
690
- refuse(res, 415, "content does not match the file extension");
691
- return;
692
- }
693
- res.writeHead(200, {
694
- "content-type": sniffed,
695
- "content-length": String(image.bytes.byteLength),
696
- "cache-control": "private, max-age=300",
697
- "x-content-type-options": "nosniff"
698
- });
699
- if (req.method === "HEAD") {
700
- res.end();
701
- return;
702
- }
703
- res.end(Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength));
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
704
1253
  };
705
1254
  }
706
1255
  //#endregion
@@ -945,16 +1494,108 @@ function toRpcError(error) {
945
1494
  };
946
1495
  }
947
1496
  /** Run one host call and wrap its outcome or failure in the old envelope. */
948
- 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?.();
949
1529
  try {
950
- 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);
951
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
+ } };
952
1546
  return { result: {
953
1547
  ok: false,
954
1548
  error: toRpcError(error)
955
1549
  } };
1550
+ } finally {
1551
+ clearTimeout(timer);
956
1552
  }
957
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
+ }
958
1599
  /**
959
1600
  * Replay-free fan-out of host facts into the `{ rpcId, payload }` frames the
960
1601
  * old `apiProxy` streams produced. Subscribers only observe frames emitted
@@ -975,8 +1616,23 @@ var FrameHub = class {
975
1616
  const queue = [];
976
1617
  let wake;
977
1618
  let closed = false;
1619
+ /** 因队列满被丢掉的帧数(丢过就要告知消费方「事件流不连续」)。 */
1620
+ let dropped = 0;
978
1621
  const onFrame = (frame) => {
979
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
+ }
980
1636
  queue.push(frame);
981
1637
  wake?.();
982
1638
  };
@@ -991,6 +1647,18 @@ var FrameHub = class {
991
1647
  return { async *[Symbol.asyncIterator]() {
992
1648
  try {
993
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
+ }
994
1662
  if (queue.length === 0) {
995
1663
  await new Promise((resolve) => {
996
1664
  wake = resolve;
@@ -1007,6 +1675,24 @@ var FrameHub = class {
1007
1675
  }
1008
1676
  };
1009
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
+ /**
1010
1696
  * Relays live session frames into the old `session/event` shape.
1011
1697
  *
1012
1698
  * 0.2.x has no process-wide session event stream: both durable events and the
@@ -1020,16 +1706,48 @@ var SessionEventRelay = class {
1020
1706
  host;
1021
1707
  mux;
1022
1708
  limit;
1709
+ warn;
1710
+ /**
1711
+ * 活跃订阅。**Map 的插入顺序就是 LRU 顺序**:命中/新建都先 delete 再 set,
1712
+ * 于是最旧(最久没动过)的会话永远在队首 —— 淘汰时取第一个即可,不用另建链表。
1713
+ */
1023
1714
  active = /* @__PURE__ */ new Map();
1024
- constructor(host, mux, limit = 8) {
1715
+ constructor(host, mux, limit = 8, warn = () => void 0) {
1025
1716
  this.host = host;
1026
1717
  this.mux = mux;
1027
1718
  this.limit = limit;
1719
+ this.warn = warn;
1028
1720
  }
1029
- /** 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
+ */
1030
1737
  attach(sessionId) {
1031
- if (this.active.has(sessionId)) return;
1032
- 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
+ }
1033
1751
  const controller = new AbortController();
1034
1752
  this.active.set(sessionId, controller);
1035
1753
  this.pump(sessionId, controller.signal).catch(() => {
@@ -1138,13 +1856,26 @@ var ApprovalBridge = class {
1138
1856
  const settle = (value) => {
1139
1857
  if (done) return;
1140
1858
  done = true;
1859
+ clearTimeout(entry.timer);
1141
1860
  resolve(value);
1142
1861
  };
1143
- 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);
1144
1874
  req.signal?.addEventListener("abort", () => {
1145
1875
  this.pending.delete(rpcId);
1146
1876
  if (done) return;
1147
1877
  done = true;
1878
+ clearTimeout(entry.timer);
1148
1879
  resolve("delegate");
1149
1880
  }, { once: true });
1150
1881
  this.mux.push({
@@ -1190,6 +1921,39 @@ var ApprovalBridge = class {
1190
1921
  */
1191
1922
  const MAX_PENDING_QUESTIONS = 32;
1192
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
+ /**
1193
1957
  * `user-questions/request` 的接管者 —— 形状照 [ApprovalBridge]。
1194
1958
  *
1195
1959
  * 为什么非接不可:`ask_user_question` 只有在**参数 schema 声明了 `timeout`** 时才走
@@ -1247,14 +2011,24 @@ var QuestionBridge = class {
1247
2011
  const settle = (value) => {
1248
2012
  if (done) return;
1249
2013
  done = true;
2014
+ clearTimeout(entry.timer);
1250
2015
  resolve(value);
1251
2016
  };
1252
- bridge.pending.set(rpcId, {
2017
+ const entry = {
1253
2018
  sessionId,
1254
2019
  callId,
1255
2020
  questions,
1256
- settle
1257
- });
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);
1258
2032
  request?.signal?.addEventListener("abort", () => {
1259
2033
  bridge.pending.delete(rpcId);
1260
2034
  settle({ kind: "dismissed" });
@@ -1314,6 +2088,30 @@ var QuestionBridge = class {
1314
2088
  };
1315
2089
  }
1316
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
+ /**
1317
2115
  * 某会话当前挂起中的提问(供 `GET /questions` 合并)。
1318
2116
  *
1319
2117
  * `state` 刻意报 `'continued'`:客户端(dsh-companion 的 HarnessViewModel:1314)
@@ -1423,6 +2221,14 @@ function sessionIdOf(summary) {
1423
2221
  * 宁可落到私有目录也不能让「发消息」本身失败。
1424
2222
  */
1425
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;
1426
2232
  const IMAGE_EXTENSIONS = {
1427
2233
  "image/png": "png",
1428
2234
  "image/jpeg": "jpg",
@@ -1451,16 +2257,23 @@ function handoffExtension(mediaType, bytes) {
1451
2257
  * `relative` 是相对会话工作目录的短路径,给人看;`absolute` 给读图工具用。
1452
2258
  * 目录 0700、文件 0600:这些字节可能包含用户隐私。
1453
2259
  */
1454
- function writeHandoffFile(dir, workspace, mediaType, data) {
2260
+ async function writeHandoffFile(dir, workspace, mediaType, data) {
1455
2261
  const bytes = Buffer.from(data, "base64");
1456
2262
  if (bytes.length === 0) throw new Error("EMPTY_IMAGE");
1457
- mkdirSync(dir, {
2263
+ await mkdir(dir, {
1458
2264
  recursive: true,
1459
2265
  mode: 448
1460
2266
  });
1461
2267
  const fileName = `${createHash("sha256").update(bytes).digest("hex")}.${handoffExtension(mediaType, bytes)}`;
1462
2268
  const absolute = join(dir, fileName);
1463
- 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
+ }
1464
2277
  return {
1465
2278
  absolute,
1466
2279
  relative: workspace !== void 0 && absolute.startsWith(workspace + sep) ? absolute.slice(workspace.length + 1).split(sep).join("/") : fileName
@@ -1490,10 +2303,21 @@ async function readHandoffImageFile(host, roots, fallbackDir, subdir, sessionId,
1490
2303
  const ext = (match[1] ?? "").toLowerCase();
1491
2304
  const mediaType = HANDOFF_MEDIA_TYPES[ext] ?? "application/octet-stream";
1492
2305
  const workspace = await sessionWorkspaceDir(host, roots, sessionId);
1493
- 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 {}
1494
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");
1495
2319
  return {
1496
- bytes: new Uint8Array(readFileSync(target)),
2320
+ bytes: new Uint8Array(await readFile(target)),
1497
2321
  mediaType
1498
2322
  };
1499
2323
  }
@@ -1506,7 +2330,7 @@ async function readHandoffImageFile(host, roots, fallbackDir, subdir, sessionId,
1506
2330
  */
1507
2331
  async function sessionWorkspaceDir(host, roots, sessionId) {
1508
2332
  if (roots.length === 0) return void 0;
1509
- 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);
1510
2334
  const cwd = typeof item?.cwd === "string" ? item.cwd : void 0;
1511
2335
  if (cwd === void 0) return void 0;
1512
2336
  for (const root of roots) {
@@ -1637,7 +2461,7 @@ async function readSessionProjections(host, sessionId, signal) {
1637
2461
  }
1638
2462
  /** 会话当前生效的模型(pending > lastUsed > 目录默认),取不到返回 undefined。 */
1639
2463
  async function currentModelSelection(host, sessionId) {
1640
- 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"];
1641
2465
  const catalog = await host.sessionController.modelCatalog();
1642
2466
  const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
1643
2467
  const provider = current?.provider;
@@ -1691,16 +2515,22 @@ async function handoffImagesForTextOnlyModel(host, roots, fallbackDir, subdir, l
1691
2515
  const workspace = await sessionWorkspaceDir(host, roots, sessionId);
1692
2516
  const dir = workspace !== void 0 ? join(workspace, subdir) : fallbackDir;
1693
2517
  let index = 0;
1694
- return content.map((part) => {
1695
- 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
+ }
1696
2524
  index += 1;
1697
- const { absolute, relative } = writeHandoffFile(dir, workspace, part.mediaType, part.data);
2525
+ const { absolute, relative } = await writeHandoffFile(dir, workspace, part.mediaType, part.data);
1698
2526
  const name = typeof part.name === "string" && part.name.trim() !== "" ? part.name.trim() : locale === "en" ? `image ${index}` : `图片 ${index}`;
1699
- return {
2527
+ const text = [handoffLine(index, name, relative, locale), `[Image: source: ${absolute}]`].join("\n");
2528
+ mapped.push({
1700
2529
  type: "text",
1701
- text: [handoffLine(index, name, relative, locale), `[Image: source: ${absolute}]`].join("\n")
1702
- };
1703
- });
2530
+ text
2531
+ });
2532
+ }
2533
+ return mapped;
1704
2534
  } catch {
1705
2535
  return content;
1706
2536
  }
@@ -1746,7 +2576,9 @@ function createDsh02ApiProxy(host, options = {}) {
1746
2576
  approvals.attach();
1747
2577
  const questions = new QuestionBridge(host, mux);
1748
2578
  questions.attach();
1749
- const relay = new SessionEventRelay(host, mux);
2579
+ const relay = new SessionEventRelay(host, mux, SESSION_RELAY_LIMIT, (message) => {
2580
+ console.warn(message);
2581
+ });
1750
2582
  host.on("api-session/added", (summary) => {
1751
2583
  const sessionId = sessionIdOf(summary);
1752
2584
  if (sessionId === void 0) return;
@@ -1808,18 +2640,20 @@ function createDsh02ApiProxy(host, options = {}) {
1808
2640
  return {
1809
2641
  respond: (message) => approvals.respond(message),
1810
2642
  sessions: {
1811
- list: (request) => call(async () => {
1812
- 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);
1813
2645
  const items = Array.isArray(page.items) ? page.items.filter((item) => item.origin !== "subagent") : page.items;
1814
2646
  return {
1815
2647
  ...page,
1816
2648
  items
1817
2649
  };
1818
2650
  }),
1819
- create: (request) => call(() => host.sessionController.create(request.payload)),
1820
- history: (request) => call(async () => {
2651
+ create: (request) => call("sessions.create", (signal) => host.sessionController.create(request.payload)),
2652
+ history: (request) => call("sessions.history", async (signal) => {
1821
2653
  const { sessionId, maxMessages, beforeSeq } = request.payload;
1822
2654
  const controller = new AbortController();
2655
+ if (signal.aborted) controller.abort();
2656
+ else signal.addEventListener("abort", () => controller.abort(), { once: true });
1823
2657
  try {
1824
2658
  const snapshot = (await host.sessionController.follow({
1825
2659
  address: {
@@ -1858,7 +2692,7 @@ function createDsh02ApiProxy(host, options = {}) {
1858
2692
  controller.abort();
1859
2693
  }
1860
2694
  }),
1861
- prompt: (request) => call(async () => {
2695
+ prompt: (request) => call("sessions.prompt", async (signal) => {
1862
2696
  const requestId = randomUUID();
1863
2697
  const { locale, ...kernelPayload } = request.payload;
1864
2698
  const receiptIds = request.payload.content.filter((part) => part.type === "file").map((part) => part.receiptId);
@@ -1871,22 +2705,22 @@ function createDsh02ApiProxy(host, options = {}) {
1871
2705
  requestId,
1872
2706
  ...kernelPayload,
1873
2707
  content
1874
- }, new AbortController().signal);
2708
+ }, signal);
1875
2709
  binding?.commit();
1876
2710
  return { accepted: true };
1877
2711
  }),
1878
- uploadAttachment: (request) => call(async () => {
2712
+ uploadAttachment: (request) => call("sessions.uploadAttachment", async (signal) => {
1879
2713
  const { sessionId, data, name } = request.payload;
1880
2714
  if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
1881
2715
  return {
1882
2716
  receiptId: (await fileUploadService.upload(await agentFor(sessionId), name === void 0 || name === "" ? { data } : {
1883
2717
  data,
1884
2718
  name
1885
- }, new AbortController().signal)).receiptId,
2719
+ }, signal)).receiptId,
1886
2720
  name: name ?? null
1887
2721
  };
1888
2722
  }),
1889
- readAttachment: (request) => call(async () => {
2723
+ readAttachment: (request) => call("sessions.readAttachment", async (signal) => {
1890
2724
  const { sessionId, attachmentId } = request.payload;
1891
2725
  const value = await host.sessionController.attachment({
1892
2726
  sessionId,
@@ -1897,20 +2731,20 @@ function createDsh02ApiProxy(host, options = {}) {
1897
2731
  mediaType: value.attachment.mediaType
1898
2732
  };
1899
2733
  }),
1900
- readHandoffImage: (request) => call(async () => {
2734
+ readHandoffImage: (request) => call("sessions.readHandoffImage", async (signal) => {
1901
2735
  const { sessionId, name } = request.payload;
1902
2736
  return readHandoffImageFile(host, handoffRootsOf(), imageHandoffDir, handoffSubdirOf(), sessionId, name);
1903
2737
  }),
1904
- cancel: (request) => call(() => host.sessionController.cancel(request.payload)),
1905
- rename: (request) => call(() => host.sessionController.rename(request.payload)),
1906
- fork: (request) => call(() => host.sessionController.fork(request.payload)),
1907
- 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) => {
1908
2742
  const { sessionId } = request.payload;
1909
2743
  try {
1910
2744
  await host.sessionController.resolveAgent(sessionId);
1911
2745
  } catch {}
1912
2746
  const catalog = await host.sessionController.modelCatalog();
1913
- const selection = (await host.sessionController.projections({ sessionId }, new AbortController().signal))?.values?.["modelSelection"];
2747
+ const selection = (await host.sessionController.projections({ sessionId }, signal))?.values?.["modelSelection"];
1914
2748
  return {
1915
2749
  current: selection?.next ?? selection?.lastUsed ?? catalog.default,
1916
2750
  routable: Array.isArray(catalog.routableProviders) && catalog.routableProviders.length > 0,
@@ -1921,10 +2755,10 @@ function createDsh02ApiProxy(host, options = {}) {
1921
2755
  })) : []
1922
2756
  };
1923
2757
  }),
1924
- selectModel: (request) => call(() => host.sessionController.selectModel(request.payload))
2758
+ selectModel: (request) => call("sessions.selectModel", (signal) => host.sessionController.selectModel(request.payload))
1925
2759
  },
1926
2760
  workspace: {
1927
- list: () => call(async () => {
2761
+ list: () => call("workspace.list", async (signal) => {
1928
2762
  return {
1929
2763
  items: host.workspaceRegistry.list().map((row) => ({
1930
2764
  workspaceId: row.id,
@@ -1937,16 +2771,16 @@ function createDsh02ApiProxy(host, options = {}) {
1937
2771
  archivedSessionIds: readArchivedSessionIds(host.workspaceRegistry)
1938
2772
  };
1939
2773
  }),
1940
- create: (request) => call(() => host.workspaceController.create(request.payload)),
1941
- rename: (request) => call(() => host.workspaceController.rename(request.payload)),
1942
- 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) => {
1943
2777
  await host.workspaceController.delete(request.payload);
1944
2778
  return { deleted: true };
1945
2779
  }),
1946
- archiveSession: (request) => call(() => host.workspaceController.archiveSession(request.payload))
2780
+ archiveSession: (request) => call("workspace.archiveSession", (signal) => host.workspaceController.archiveSession(request.payload))
1947
2781
  },
1948
2782
  agentPresets: {
1949
- list: () => call(async () => {
2783
+ list: () => call("agentPresets.list", async (signal) => {
1950
2784
  const withTrust = (rows) => rows.map((row) => ({
1951
2785
  ...row,
1952
2786
  trust: row.trust ?? "system"
@@ -1966,19 +2800,19 @@ function createDsh02ApiProxy(host, options = {}) {
1966
2800
  hasDocument: false
1967
2801
  };
1968
2802
  }),
1969
- select: (request) => call(async () => {
2803
+ select: (request) => call("agentPresets.select", async (signal) => {
1970
2804
  const agent = await agentFor(request.payload.sessionId);
1971
2805
  return { agentPreset: await host.agentPresets.select(agent, request.payload.agentPreset) };
1972
2806
  })
1973
2807
  },
1974
- permissionPresets: { catalog: () => call(async () => {
2808
+ permissionPresets: { catalog: () => call("permissionPresets.catalog", async (signal) => {
1975
2809
  const service = optionalService(host, "permissionPresets");
1976
2810
  if (service === null || typeof service.catalog !== "function") return FALLBACK_PERMISSION_CATALOG;
1977
2811
  return normalizePermissionCatalog(await service.catalog());
1978
2812
  }) },
1979
2813
  userQuestions: {
1980
2814
  pendingAll: () => questions.pendingAll(),
1981
- list: (request) => call(async () => {
2815
+ list: (request) => call("userQuestions.list", async (signal) => {
1982
2816
  const { sessionId } = request.payload;
1983
2817
  const bridged = questions.pendingFor(sessionId);
1984
2818
  const bridgedIds = new Set(bridged.map((item) => item.callId));
@@ -1990,7 +2824,7 @@ function createDsh02ApiProxy(host, options = {}) {
1990
2824
  const continued = Array.isArray(rows) ? rows.map(toUserQuestionView).filter((item) => item !== void 0) : [];
1991
2825
  return { items: [...bridged, ...continued.filter((item) => !bridgedIds.has(item.callId))] };
1992
2826
  }),
1993
- answer: (request) => call(async () => {
2827
+ answer: (request) => call("userQuestions.answer", async (signal) => {
1994
2828
  const { sessionId, callId, answer } = request.payload;
1995
2829
  const agent = await agentForQuestions(sessionId);
1996
2830
  let kernelCouldNotAnswer = false;
@@ -2037,24 +2871,24 @@ function createDsh02ApiProxy(host, options = {}) {
2037
2871
  })
2038
2872
  },
2039
2873
  settings: {
2040
- describe: () => call(() => host.settingsController.describe()),
2041
- 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))
2042
2876
  },
2043
2877
  credentials: {
2044
- describe: (request) => call(async () => {
2878
+ describe: (request) => call("credentials.describe", async (signal) => {
2045
2879
  return { credentials: await host.credentialsController.describe(request.payload.refs) };
2046
2880
  }),
2047
- set: (request) => call(async () => {
2881
+ set: (request) => call("credentials.set", async (signal) => {
2048
2882
  await host.credentialsController.set(request.payload.ref, request.payload.value);
2049
2883
  return {};
2050
2884
  }),
2051
- unset: (request) => call(async () => {
2885
+ unset: (request) => call("credentials.unset", async (signal) => {
2052
2886
  await host.credentialsController.unset(request.payload.ref);
2053
2887
  return {};
2054
2888
  })
2055
2889
  },
2056
2890
  llm: {
2057
- providers: () => call(async () => {
2891
+ providers: () => call("llm.providers", async (signal) => {
2058
2892
  const directory = host.llm.listConfigurableProviders();
2059
2893
  const live = new Set(host.llm.listProviders().map((entry) => entry.id));
2060
2894
  return { providers: directory.map((entry) => ({
@@ -2072,14 +2906,14 @@ function createDsh02ApiProxy(host, options = {}) {
2072
2906
  } }
2073
2907
  })) };
2074
2908
  }),
2075
- models: () => call(async () => {
2909
+ models: () => call("llm.models", async (signal) => {
2076
2910
  const view = await host.sessionController.modelCatalog();
2077
2911
  return {
2078
2912
  groups: Array.isArray(view.groups) ? view.groups : [],
2079
2913
  failures: Array.isArray(view.failures) ? view.failures : []
2080
2914
  };
2081
2915
  }),
2082
- discoverModels: (request) => call(async () => {
2916
+ discoverModels: (request) => call("llm.discoverModels", async (signal) => {
2083
2917
  const { settingsNs, ...rest } = request.payload;
2084
2918
  return { models: [...await host.llm.discoverModels(settingsNs, rest)] };
2085
2919
  })
@@ -2092,7 +2926,7 @@ function createDsh02ApiProxy(host, options = {}) {
2092
2926
  }
2093
2927
  //#endregion
2094
2928
  //#region src/shared/version.ts
2095
- const PLUGIN_VERSION = "2.5.0";
2929
+ const PLUGIN_VERSION = "2.5.2";
2096
2930
  //#endregion
2097
2931
  //#region src/host/capabilities.ts
2098
2932
  /**
@@ -2123,7 +2957,8 @@ const PLUGIN_VERSION = "2.5.0";
2123
2957
  * `approvals-v1` / `questions-v1`(`dsh-companion` 的 `REQUIRED_CAPABILITIES`:
2124
2958
  * 缺了就明说「插件过旧、卡片不会出现」)与 `session-archive-list-v1`
2125
2959
  * (决定会话面板是否显示「已归档」入口)。
2126
- * 2. **对外声明** —— 其余 23 项。它们回答的是「这台服务端能做什么」,
2960
+ * 2. **对外声明** —— 其余所有能力位(数量以本文件数组为准,勿在注释里写数字,
2961
+ * 加一个位就要改注释的纪律只会让注释过期)。它们回答的是「这台服务端能做什么」,
2127
2962
  * **服务的是任何客户端(包括第三方)**,而不是某一处分支。
2128
2963
  *
2129
2964
  * 所以:**「我方客户端没用到」不等于「没人用」**。删掉第 2 类会破坏对外承诺
@@ -2176,15 +3011,50 @@ const BRIDGE_CAPABILITIES = [
2176
3011
  "job-kill-v1"
2177
3012
  ];
2178
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
+ /**
2179
3049
  * `connection.ready` 事件的载荷。
2180
3050
  *
2181
3051
  * 每次调用返回**新的副本**:调用方(或测试)改它不该影响常量,
2182
3052
  * 否则一处手滑就会让整个进程后续所有连接都收到被改过的能力清单。
2183
3053
  */
2184
- function connectionReadyData() {
3054
+ function connectionReadyData(flags = {}) {
2185
3055
  return {
2186
3056
  bridgeVersion: PLUGIN_VERSION,
2187
- capabilities: [...BRIDGE_CAPABILITIES]
3057
+ capabilities: availableCapabilities(flags)
2188
3058
  };
2189
3059
  }
2190
3060
  //#endregion
@@ -2302,25 +3172,39 @@ function clampStrings(value, limit, seen) {
2302
3172
  */
2303
3173
  function clampHistoryEvents(events, byteBudget = HISTORY_BYTE_BUDGET, perEventBudget = HISTORY_EVENT_BYTE_BUDGET) {
2304
3174
  let clampedStrings = 0;
2305
- const prepared = events.map((entry) => {
2306
- 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
+ }
2307
3185
  const result = clampStrings(entry, perEventBudget, /* @__PURE__ */ new WeakSet());
2308
- if (result.clamped === 0) return entry;
3186
+ if (result.clamped === 0) {
3187
+ sizes[index] = size;
3188
+ prepared[index] = entry;
3189
+ continue;
3190
+ }
2309
3191
  clampedStrings += result.clamped;
2310
3192
  const marker = { truncated: true };
2311
3193
  const clampedValue = result.value;
2312
- return clampedValue !== null && typeof clampedValue === "object" && !Array.isArray(clampedValue) ? {
3194
+ const marked = clampedValue !== null && typeof clampedValue === "object" && !Array.isArray(clampedValue) ? {
2313
3195
  ...clampedValue,
2314
3196
  bridgeTruncated: marker
2315
3197
  } : {
2316
3198
  value: clampedValue,
2317
3199
  bridgeTruncated: marker
2318
3200
  };
2319
- });
3201
+ prepared[index] = marked;
3202
+ sizes[index] = byteLength(marked);
3203
+ }
2320
3204
  const kept = [];
2321
3205
  let bytes = 0;
2322
3206
  for (let index = prepared.length - 1; index >= 0; index -= 1) {
2323
- const size = byteLength(prepared[index]);
3207
+ const size = sizes[index] ?? 0;
2324
3208
  if (kept.length > 0 && bytes + size > byteBudget) break;
2325
3209
  bytes += size;
2326
3210
  kept.push(prepared[index]);
@@ -2356,10 +3240,49 @@ function applyHistoryBudget(raw, byteBudget = HISTORY_BYTE_BUDGET, perEventBudge
2356
3240
  }
2357
3241
  //#endregion
2358
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
+ }
2359
3257
  var DshChatAdapter = class {
2360
3258
  api;
2361
3259
  database;
2362
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();
2363
3286
  sessionRootIds = /* @__PURE__ */ new Map();
2364
3287
  sessionRootLookups = /* @__PURE__ */ new Map();
2365
3288
  pendingApprovals = /* @__PURE__ */ new Map();
@@ -2853,8 +3776,13 @@ var DshChatAdapter = class {
2853
3776
  }
2854
3777
  }
2855
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;
2856
3782
  const rootId = await this.sessionRootId(sessionId);
2857
- 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;
2858
3786
  }
2859
3787
  startEvents(signal, emit) {
2860
3788
  const run = async (kind) => {
@@ -2917,10 +3845,37 @@ var DshChatAdapter = class {
2917
3845
  return services.agents.get(sessionId);
2918
3846
  }
2919
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
+ }
2920
3857
  const session = (await this.listSessions(principal)).find((item) => item.id === sessionId);
3858
+ this.rememberVisibility(key, session !== void 0);
2921
3859
  if (session === void 0) throw new ApiError(404, "SESSION_NOT_FOUND", "The session is not visible to this device.");
2922
3860
  return session;
2923
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
+ }
2924
3879
  async requireWorkspace(principal, workspaceId) {
2925
3880
  const workspace = (await this.listWorkspaces(principal)).find((item) => item.id === workspaceId);
2926
3881
  if (workspace === void 0) throw new ApiError(404, "WORKSPACE_NOT_FOUND", "The DSH workspace is not visible to this device.");
@@ -3336,6 +4291,29 @@ var WorkspaceDatabase = class {
3336
4291
  trashDir;
3337
4292
  db;
3338
4293
  closed = false;
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;
3339
4317
  constructor(dataDir) {
3340
4318
  this.dataDir = path.resolve(dataDir);
3341
4319
  this.trashDir = path.join(this.dataDir, "trash");
@@ -3345,6 +4323,10 @@ var WorkspaceDatabase = class {
3345
4323
  this.migrate();
3346
4324
  openDatabases.add(new WeakRef(this));
3347
4325
  }
4326
+ /** 当前设备状态版本号。用途见字段注释(`server.ts` 的广播缓存失效)。 */
4327
+ get devicesRevision() {
4328
+ return this.devicesRevisionCounter;
4329
+ }
3348
4330
  /** 关闭数据库。**幂等** —— 重复调用不抛错。 */
3349
4331
  close() {
3350
4332
  if (this.closed) return;
@@ -3368,6 +4350,13 @@ var WorkspaceDatabase = class {
3368
4350
  setSetting(key, value) {
3369
4351
  this.db.prepare("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);
3370
4352
  }
4353
+ /**
4354
+ * 登记一条授权根。
4355
+ *
4356
+ * @param options.requestId - 发起这次登记的那条 HTTP 请求的 id。
4357
+ * 审计的用途不只是「记下发生了什么」,还要能回答「**哪一次请求**干的」;
4358
+ * 不传(插件启动期登记内置根)时审计行的 `request_id` 为 null。
4359
+ */
3371
4360
  async addRoot(inputPath, label, options) {
3372
4361
  const realPath = await canonicalRoot(inputPath);
3373
4362
  if (options?.allowDuplicate !== true) {
@@ -3378,7 +4367,7 @@ var WorkspaceDatabase = class {
3378
4367
  const createdAt = Date.now();
3379
4368
  const resolvedLabel = label?.trim() || path.basename(realPath) || realPath;
3380
4369
  this.db.prepare("INSERT INTO roots(id,label,real_path,created_at,builtin) VALUES(?,?,?,?,0)").run(id, resolvedLabel, realPath, createdAt);
3381
- 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);
3382
4371
  return {
3383
4372
  id,
3384
4373
  label: resolvedLabel,
@@ -3436,7 +4425,12 @@ var WorkspaceDatabase = class {
3436
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.");
3437
4426
  this.audit("admin", "loopback-admin", "root.remove", id);
3438
4427
  }
3439
- createPairing(rootIds, scopes, ttlMs = 6e5) {
4428
+ /**
4429
+ * 生成一枚配对码。
4430
+ *
4431
+ * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
4432
+ */
4433
+ createPairing(rootIds, scopes, ttlMs = 6e5, options = {}) {
3440
4434
  assertScopes(scopes);
3441
4435
  for (const rootId of rootIds) if (this.getRoot(rootId) === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", `Unknown root ${rootId}.`);
3442
4436
  const raw = randomBytes(5).toString("hex").toUpperCase();
@@ -3447,13 +4441,20 @@ var WorkspaceDatabase = class {
3447
4441
  rootIds,
3448
4442
  scopes,
3449
4443
  expiresAt
3450
- });
4444
+ }, options.requestId);
3451
4445
  return {
3452
4446
  code,
3453
4447
  expiresAt
3454
4448
  };
3455
4449
  }
3456
- exchangePairing(code, deviceName) {
4450
+ /**
4451
+ * 用配对码换一枚设备令牌。
4452
+ *
4453
+ * 审计**在事务内**写(FUNC-5):它是「这台设备被授权了」的唯一留痕,
4454
+ * 与设备行同生共死。审计写不进去 → 整个兑换回滚 → 客户端重试,
4455
+ * 而不是「拿到令牌但库里没有这笔授权的记录」。
4456
+ */
4457
+ exchangePairing(code, deviceName, options = {}) {
3457
4458
  const normalized = normalizePairingCode(code);
3458
4459
  const now = Date.now();
3459
4460
  this.db.exec("BEGIN IMMEDIATE");
@@ -3469,18 +4470,18 @@ var WorkspaceDatabase = class {
3469
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);
3470
4471
  const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
3471
4472
  for (const rootId of rootIds) grant.run(id, rootId);
4473
+ this.audit("device", id, "pairing.exchange", void 0, void 0, { name }, options.requestId);
3472
4474
  this.db.exec("COMMIT");
3473
- const device = {
3474
- id,
3475
- name,
3476
- scopes,
3477
- rootIds,
3478
- createdAt: now
3479
- };
3480
- this.audit("device", id, "pairing.exchange", void 0, void 0, { name });
4475
+ this.devicesRevisionCounter += 1;
3481
4476
  return {
3482
4477
  token,
3483
- device
4478
+ device: {
4479
+ id,
4480
+ name,
4481
+ scopes,
4482
+ rootIds,
4483
+ createdAt: now
4484
+ }
3484
4485
  };
3485
4486
  } catch (error) {
3486
4487
  this.db.exec("ROLLBACK");
@@ -3526,7 +4527,16 @@ var WorkspaceDatabase = class {
3526
4527
  isDeviceActive(id) {
3527
4528
  return this.db.prepare("SELECT 1 AS active FROM devices WHERE id = ? AND revoked_at IS NULL").get(id) !== void 0;
3528
4529
  }
3529
- 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 = {}) {
3530
4540
  assertScopes(scopes);
3531
4541
  for (const rootId of rootIds) if (this.getRoot(rootId) === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", `Unknown root ${rootId}.`);
3532
4542
  this.db.exec("BEGIN IMMEDIATE");
@@ -3535,16 +4545,17 @@ var WorkspaceDatabase = class {
3535
4545
  this.db.prepare("DELETE FROM device_roots WHERE device_id = ?").run(id);
3536
4546
  const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
3537
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);
3538
4553
  this.db.exec("COMMIT");
3539
4554
  } catch (error) {
3540
4555
  this.db.exec("ROLLBACK");
3541
4556
  throw error;
3542
4557
  }
3543
- this.audit("admin", "loopback-admin", "device.update", void 0, void 0, {
3544
- deviceId: id,
3545
- scopes,
3546
- rootIds
3547
- });
4558
+ this.devicesRevisionCounter += 1;
3548
4559
  const device = this.listDevices().find((item) => item.id === id);
3549
4560
  if (device === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The device does not exist.");
3550
4561
  return device;
@@ -3562,14 +4573,31 @@ var WorkspaceDatabase = class {
3562
4573
  * - `authenticate` 仍保留 `revoked_at IS NULL` 作为兜底,万一有别处留下的标记行,
3563
4574
  * 它们依然登不进来 —— 两层保险不冲突。
3564
4575
  */
3565
- revokeDevice(id) {
4576
+ /**
4577
+ * 撤销一台设备 = 删除设备行。
4578
+ *
4579
+ * 审计与删除**在同一事务里**(FUNC-5),且顺序是「先留痕、再删除」:
4580
+ * 审计的 `actor_id` 不带外键,删设备不会把记录带走。反过来若先删后写、
4581
+ * 而写审计失败,就会留下「设备已经没了,但没有任何记录说谁撤的」。
4582
+ *
4583
+ * @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
4584
+ */
4585
+ revokeDevice(id, options = {}) {
3566
4586
  const active = this.getActiveDevice(id);
3567
4587
  if (active === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
3568
- this.audit("admin", "loopback-admin", "device.revoke", void 0, void 0, {
3569
- deviceId: id,
3570
- name: active.name
3571
- });
3572
- 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;
3573
4601
  }
3574
4602
  /**
3575
4603
  * 一次性清理历史遗留的「标记式」撤销设备(`revoked_at IS NOT NULL` 的行)。
@@ -3592,18 +4620,70 @@ var WorkspaceDatabase = class {
3592
4620
  this.db.prepare("UPDATE devices SET revoked_at = ? WHERE id = ?").run(Date.now(), id);
3593
4621
  }
3594
4622
  purgeRevokedDevices() {
3595
- if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 0) return 0;
4623
+ if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 0) {
4624
+ this.devicesRevisionCounter += 1;
4625
+ return 0;
4626
+ }
3596
4627
  this.db.exec("BEGIN IMMEDIATE");
3597
4628
  try {
3598
4629
  const result = this.db.prepare("DELETE FROM devices WHERE revoked_at IS NOT NULL").run();
3599
- this.db.exec("COMMIT");
3600
4630
  this.audit("admin", "loopback-admin", "device.purge", void 0, void 0, { purged: result.changes });
4631
+ this.db.exec("COMMIT");
4632
+ this.devicesRevisionCounter += 1;
3601
4633
  return Number(result.changes);
3602
4634
  } catch (error) {
3603
4635
  this.db.exec("ROLLBACK");
3604
4636
  throw error;
3605
4637
  }
3606
4638
  }
4639
+ /**
4640
+ * 删掉早于 `cutoffMs` 的幂等回放记录,返回删除行数。
4641
+ *
4642
+ * 由 `retention.ts`(perf-reader)按调度调用,本方法只做 SQL —— 不判断策略、
4643
+ * 不写日志,所以它可以被单独测试。
4644
+ *
4645
+ * 为什么用 `created_at` 而不是加一列 `expires_at`:24 小时的过期**完全**由
4646
+ * `created_at` 推导得出,加列会让每次插入多写一个字段,还多一次 migration 风险。
4647
+ */
4648
+ pruneIdempotency(cutoffMs) {
4649
+ const result = this.db.prepare("DELETE FROM idempotency WHERE created_at < ?").run(cutoffMs);
4650
+ return Number(result.changes);
4651
+ }
4652
+ /**
4653
+ * 清理审计:先按时间删,再兜「总数超过 `keepMax` 就删最旧的」。
4654
+ *
4655
+ * ## 为什么是两段而不是一段
4656
+ *
4657
+ * 只按时间删不够:桥可以被一个「短时间高频写入」的场景灌满审计表
4658
+ * (例如每秒几十条 `chat.delta` 对应的写操作),时间窗内的量仍然能压垮库。
4659
+ * 只按数量删也不够:用户可能希望「一年前的都清掉」,而不在乎条数。
4660
+ *
4661
+ * ## `keepMax` 是**下限保护**
4662
+ *
4663
+ * 即使 `cutoffMs` 设成了未来(把全部行都判成过期),也**至少**保留最近
4664
+ * `keepMax` 条 —— 一句写错的调用不该把审计清空。审计被清空本身就等于
4665
+ * 「抹掉证据」,这个方向必须由这一层挡住。
4666
+ *
4667
+ * 返回删除的行数(两段之和)。
4668
+ */
4669
+ pruneAudit(cutoffMs, keepMax) {
4670
+ const floor = Math.max(0, Math.floor(keepMax));
4671
+ this.db.exec("BEGIN IMMEDIATE");
4672
+ try {
4673
+ const total = Number(this.db.prepare("SELECT COUNT(*) AS n FROM audit").get().n);
4674
+ const expired = Number(this.db.prepare("SELECT COUNT(*) AS n FROM audit WHERE time < ?").get(cutoffMs).n);
4675
+ const removableByTime = Math.max(0, Math.min(expired, total - floor));
4676
+ let removed = 0;
4677
+ 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);
4678
+ const over = Number(this.db.prepare("SELECT COUNT(*) AS n FROM audit").get().n) - floor;
4679
+ 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);
4680
+ this.db.exec("COMMIT");
4681
+ return removed;
4682
+ } catch (error) {
4683
+ this.db.exec("ROLLBACK");
4684
+ throw error;
4685
+ }
4686
+ }
3607
4687
  insertTrash(record) {
3608
4688
  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);
3609
4689
  }
@@ -3769,6 +4849,14 @@ var WorkspaceEventBus = class {
3769
4849
  };
3770
4850
  //#endregion
3771
4851
  //#region src/host/file-service.ts
4852
+ /**
4853
+ * 列目录时同时 `lstat` 多少个子项。
4854
+ *
4855
+ * 32 是本仓已经用过、也验证过的并发量级(见 `plugin-inventory.ts` 的
4856
+ * `withUpdateCheck` 用的是 6 个 worker;这里是短促的 stat,可以更宽一点)。
4857
+ * 关键不是这个数字本身,而是**有界**:一把放 500 个在网络盘上会把请求队列打满。
4858
+ */
4859
+ const LIST_STAT_CONCURRENCY = 32;
3772
4860
  var FileService = class {
3773
4861
  database;
3774
4862
  emit;
@@ -3787,18 +4875,21 @@ var FileService = class {
3787
4875
  const pageSize = Math.min(Math.max(limit, 1), 500);
3788
4876
  const selected = names.slice(start, start + pageSize);
3789
4877
  const entries = [];
3790
- for (const name of selected) {
3791
- const absolute = path.join(resolved.absolutePath, name);
3792
- const info = await lstat(absolute);
3793
- const childPath = relativePath === "" ? name : `${relativePath}/${name}`;
3794
- entries.push({
3795
- name,
3796
- path: childPath,
3797
- kind: kindOf(info),
3798
- size: info.isFile() ? info.size : 0,
3799
- modifiedAt: info.mtimeMs,
3800
- writable: (info.mode & 128) !== 0
3801
- });
4878
+ for (let offset = 0; offset < selected.length; offset += LIST_STAT_CONCURRENCY) {
4879
+ const batch = selected.slice(offset, offset + LIST_STAT_CONCURRENCY);
4880
+ const rows = await Promise.all(batch.map(async (name) => {
4881
+ const absolute = path.join(resolved.absolutePath, name);
4882
+ const info = await lstat(absolute);
4883
+ return {
4884
+ name,
4885
+ path: relativePath === "" ? name : `${relativePath}/${name}`,
4886
+ kind: kindOf(info),
4887
+ size: info.isFile() ? info.size : 0,
4888
+ modifiedAt: info.mtimeMs,
4889
+ writable: (info.mode & 128) !== 0
4890
+ };
4891
+ }));
4892
+ entries.push(...rows);
3802
4893
  }
3803
4894
  const last = selected.at(-1);
3804
4895
  return {
@@ -3812,12 +4903,11 @@ var FileService = class {
3812
4903
  const resolved = await resolveAuthorizedPath(root.realPath, relativePath);
3813
4904
  const info = await lstat(resolved.absolutePath);
3814
4905
  if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files have readable content.");
3815
- const etag = await fileEtag(resolved.absolutePath);
3816
4906
  return {
3817
4907
  absolutePath: resolved.absolutePath,
3818
4908
  size: info.size,
3819
4909
  modifiedAt: info.mtimeMs,
3820
- etag,
4910
+ etag: etagFromStat(info),
3821
4911
  contentType: contentTypeFor$1(relativePath)
3822
4912
  };
3823
4913
  }
@@ -3835,8 +4925,8 @@ var FileService = class {
3835
4925
  if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files can be overwritten.");
3836
4926
  if (conditions.ifNoneMatch === "*") throw new ApiError(412, "PATH_ALREADY_EXISTS", "The file already exists.");
3837
4927
  if (conditions.ifMatch === void 0) throw new ApiError(428, "ETAG_REQUIRED", "If-Match is required when overwriting a file.");
3838
- const currentEtag = await fileEtag(resolved.absolutePath);
3839
- if (conditions.ifMatch !== currentEtag) throw new ApiError(412, "ETAG_MISMATCH", "The file changed after it was read.", { currentEtag });
4928
+ const currentEtag = etagFromStat(info);
4929
+ if (!etagEqual(conditions.ifMatch, currentEtag)) throw new ApiError(412, "ETAG_MISMATCH", "The file changed after it was read.", { currentEtag });
3840
4930
  mode = info.mode;
3841
4931
  } else if (conditions.ifNoneMatch !== "*") throw new ApiError(428, "CREATE_CONDITION_REQUIRED", "If-None-Match: * is required when creating file content.");
3842
4932
  const temp = path.join(parent.absolutePath, `.${path.basename(resolved.absolutePath)}.dsh-${randomUUID()}.tmp`);
@@ -3849,8 +4939,8 @@ var FileService = class {
3849
4939
  await handle.close();
3850
4940
  }
3851
4941
  if (resolved.exists) {
3852
- const currentEtag = await fileEtag(resolved.absolutePath);
3853
- if (conditions.ifMatch !== currentEtag) throw new ApiError(412, "ETAG_MISMATCH", "The file changed while the replacement was prepared.", { currentEtag });
4942
+ const currentEtag = etagFromStat(await lstat(resolved.absolutePath));
4943
+ if (!etagEqual(conditions.ifMatch, currentEtag)) throw new ApiError(412, "ETAG_MISMATCH", "The file changed while the replacement was prepared.", { currentEtag });
3854
4944
  }
3855
4945
  await replaceFile(temp, resolved.absolutePath, resolved.exists);
3856
4946
  } catch (error) {
@@ -3858,7 +4948,7 @@ var FileService = class {
3858
4948
  throw error;
3859
4949
  }
3860
4950
  const info = await stat(resolved.absolutePath);
3861
- const etag = await fileEtag(resolved.absolutePath);
4951
+ const etag = etagFromStat(info);
3862
4952
  this.notify("file.changed", root.id, relativePath, {
3863
4953
  etag,
3864
4954
  size: info.size
@@ -3996,10 +5086,75 @@ var FileService = class {
3996
5086
  });
3997
5087
  }
3998
5088
  };
3999
- async function fileEtag(filePath) {
4000
- const hash = createHash("sha256");
4001
- await pipeline(createReadStream(filePath), hash);
4002
- return `"sha256-${hash.digest("base64url")}"`;
5089
+ /**
5090
+ * 一个文件的**弱验证器**:`W/"<size>-<mtimeMs>"`(两者都用 base36 缩短)。
5091
+ *
5092
+ * ## 为什么不再用全文件 sha256
5093
+ *
5094
+ * 修复前是 `sha256(整个文件)`:
5095
+ *
5096
+ * - `inspectContent`(本文件的 `:93`)在**每一个 GET** 都算一次 —— 一个 100 MiB 的
5097
+ * 文件被读一次,服务端就额外读 100 MiB、算一次 sha256;
5098
+ * - `writeContent` 在写前、写后、替换后再算(最多 **3 次**);
5099
+ * - 而 `router.ts` 的 304 判断只需要「这个值变没变」,不需要「内容指纹」。
5100
+ *
5101
+ * 改成 stat-only 之后是 **O(1)**:一个字节都不读。这正是 Range 下载/大文件预览
5102
+ * 场景里最划算的一笔优化 —— 它不是什么高级技巧,只是把「哈希整个文件」换成
5103
+ * 「问一下 stat」。
5104
+ *
5105
+ * ## 为什么必须标 `W/`(弱)
5106
+ *
5107
+ * size + mtime 无法区分「同一毫秒内两次写入、且大小完全不变」。标成强验证器
5108
+ * (不带 `W/`)是在说谎:HTTP 语义里强验证器可用于 `If-Range` 与字节范围续传,
5109
+ * 客户端会据此跳过内容校验,于是**它可能把一个已经变了的内容当成没变**。
5110
+ *
5111
+ * 标弱之后:`If-None-Match` 仍可做协商缓存(弱比较本来就用它),
5112
+ * 而需要强验证的地方(`If-Match` 写冲突、`If-Range`)客户端不会拿它当证据。
5113
+ * 这是诚实的代价,也是这条改动唯一的语义变化,已在报告里记为已知边界。
5114
+ *
5115
+ * @returns 形如 `W/"2f-yx3k9q1"` 的弱 ETag。
5116
+ */
5117
+ /**
5118
+ * 把客户端回传的验证器归一成「去掉所有 `W/` 前缀」的形式,用于**相等比较**。
5119
+ *
5120
+ * ## 为什么写路径也需要归一(不只是读路径的 If-None-Match)
5121
+ *
5122
+ * 服务端现在发的是**弱** ETag(`W/"..."`,见 [etagFromStat]),而客户端做
5123
+ * `If-Match` 时只能把我们发出去的那个值原样回传 —— 于是 `conditions.ifMatch`
5124
+ * 必然带 `W/`,而磁盘上重新算出来的也是 `W/"..."`:**单层**情况下字符串恰好相等,
5125
+ * 看起来没问题。
5126
+ *
5127
+ * 但两层就崩了:某些 HTTP 客户端/代理会把弱验证器再包一层,回传 `W/W/"..."`。
5128
+ * 那时严格 `!==` 会把「同一个文件」判成「文件变了」→ 412 → **用户保存失败且
5129
+ * 重试也不会成功**(因为客户端每次都回传同一个值)。
5130
+ *
5131
+ * ## 为什么这不是「放松安全」
5132
+ *
5133
+ * `If-Match` 在这里的职责是**乐观并发控制**(防止盲写覆盖别人的修改),
5134
+ * 不是身份认证。归一化只消除 `W/` 前缀这层语法噪声,`<size>-<mtime>` 的
5135
+ * 实质比较一字未改 —— 真正变了的文件照样 412。
5136
+ *
5137
+ * RFC 7232 里 `If-Match` 本应用强比较、而弱验证器不能用于它;这条张力是
5138
+ * 「用 stat 而非内容哈希」的已知代价(见 `etagFromStat` 的说明)。我们的取舍是:
5139
+ * **宁可让乐观锁偏松一点,也不让用户的保存无故失败** —— 因为这条路径的失败模式
5140
+ * 是「数据存不进去」,比「两个客户端几乎同时改同一文件」更常见、更痛。
5141
+ */
5142
+ function normalizeValidator(value) {
5143
+ return value.trim().replace(/^(?:W\/)+/i, "");
5144
+ }
5145
+ /**
5146
+ * 两个验证器是否表示同一版本(见 [normalizeValidator])。
5147
+ *
5148
+ * `left` 允许 undefined:调用方在「创建分支」上可能没有 `If-Match`
5149
+ * (那时它已经被 428 拦下过,但第二个比较点在另一个分支里,TS 窄化不到)。
5150
+ * `undefined` 一律视为「不相等」—— 缺失的验证器绝不能匹配上任何真实版本。
5151
+ */
5152
+ function etagEqual(left, right) {
5153
+ if (left === void 0) return false;
5154
+ return normalizeValidator(left) === normalizeValidator(right);
5155
+ }
5156
+ function etagFromStat(info) {
5157
+ return `W/"${Math.max(0, Math.trunc(info.size)).toString(36)}-${Math.max(0, Math.floor(info.mtimeMs)).toString(36)}"`;
4003
5158
  }
4004
5159
  async function replaceFile(temp, destination, destinationExists) {
4005
5160
  try {
@@ -4125,12 +5280,31 @@ function contentTypeFor$1(filePath) {
4125
5280
  function encodeCursor(name) {
4126
5281
  return Buffer.from(name, "utf8").toString("base64url");
4127
5282
  }
5283
+ /** 游标长度上限:它编码的是一个文件名,几百字符足够;几千字符只可能是伪造。 */
5284
+ const MAX_CURSOR_CHARS = 1024;
5285
+ /** base64url 的字符集(无填充)。用正则**先**挡住非法输入,再交给解码器。 */
5286
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
5287
+ /**
5288
+ * 解目录游标(`encodeCursor` 的逆运算)。
5289
+ *
5290
+ * ## 为什么需要显式校验,而不能靠 `try/catch`
5291
+ *
5292
+ * 旧实现写成 `try { Buffer.from(cursor,'base64url') } catch { 400 }`。
5293
+ * 那个 `catch` 是**不可达分支**:Node 的 base64url 解码是**宽松**的 ——
5294
+ * `Buffer.from('!!!','base64url')` 返回**空 Buffer**(不抛),`'@@@'`、`'中文'` 同理。
5295
+ *
5296
+ * 后果不是「报错」,而是**静默的错行为**:非法游标被解成空串 →
5297
+ * `names.findIndex(name => name > '')` 命中第一项 → **从第一页重新开始**。
5298
+ * 客户端看到的是「翻页突然跳回开头」,没有任何错误信号 —— 比报错更难查。
5299
+ *
5300
+ * 所以这里先做两件**能真正失败**的检查:字符集与长度。
5301
+ * 校验通过后才解码;解码结果为空也算非法(空串不是任何文件名)。
5302
+ */
4128
5303
  function decodeCursor(cursor) {
4129
- try {
4130
- return Buffer.from(cursor, "base64url").toString("utf8");
4131
- } catch {
4132
- throw new ApiError(400, "CURSOR_INVALID", "The directory cursor is invalid.");
4133
- }
5304
+ if (cursor.length > MAX_CURSOR_CHARS || !BASE64URL_PATTERN.test(cursor)) throw new ApiError(400, "CURSOR_INVALID", "The directory cursor is invalid.");
5305
+ const decoded = Buffer.from(cursor, "base64url").toString("utf8");
5306
+ if (decoded === "") throw new ApiError(400, "CURSOR_INVALID", "The directory cursor is invalid.");
5307
+ return decoded;
4134
5308
  }
4135
5309
  function trashView(record) {
4136
5310
  return {
@@ -4258,19 +5432,108 @@ function probeHostServices(host, pluginVersion) {
4258
5432
  resolvable,
4259
5433
  ...error === void 0 ? {} : { error }
4260
5434
  };
4261
- };
4262
- const injected = INJECTED_SERVICE_NAMES.map(probeOne);
4263
- const observed = OPTIONAL_SERVICE_NAMES.map(probeOptional);
5435
+ };
5436
+ const injected = INJECTED_SERVICE_NAMES.map(probeOne);
5437
+ const observed = OPTIONAL_SERVICE_NAMES.map(probeOptional);
5438
+ return {
5439
+ pluginVersion,
5440
+ injected,
5441
+ observed,
5442
+ summary: {
5443
+ injectedTotal: injected.length,
5444
+ injectedAvailable: injected.filter((service) => service.available).length,
5445
+ injectedMissing: injected.filter((service) => !service.available).map((service) => service.name),
5446
+ observedAvailable: observed.filter((service) => service.resolvable).map((service) => service.name)
5447
+ }
5448
+ };
5449
+ }
5450
+ //#endregion
5451
+ //#region src/host/retention.ts
5452
+ /**
5453
+ * 数据保留(retention)调度器:把**会无限增长的两张表**按时间清掉。
5454
+ *
5455
+ * ## 为什么需要它(这是唯一一条「随时间必然恶化」的债)
5456
+ *
5457
+ * `audit` 与 `idempotency` 都只增不删:
5458
+ *
5459
+ * - `audit` 在 router 里有 20+ 处写入点(登录、撤销、建根、发消息、审批、命令…),
5460
+ * 单机长期运行会到几十万行;
5461
+ * - `idempotency` 每个带 `clientRequestId` 的请求一行,而且 `response_json` 存的是
5462
+ * **整份响应体** —— 它增长得比审计还快,而且存的是重复数据。
5463
+ *
5464
+ * 两者都没有 TTL 列、也没有清理任务(`src/` 下 `DELETE FROM audit` / `VACUUM` /
5465
+ * `DELETE FROM idempotency` 零命中)。跑三个月之后的表现是:库文件与 WAL 持续变大、
5466
+ * `listAudit` 变慢、备份与迁移成本上升 —— 而这些问题在「刚装上」的时候完全看不到。
5467
+ *
5468
+ * ## 为什么这个模块只做调度、不写 SQL
5469
+ *
5470
+ * SQL 属于 `database.ts` 的所有权(表结构、事务边界、索引都在那边)。这个模块负责的是
5471
+ * **策略**:清多久以前的、最多留多少、什么时候清、清完记什么日志。
5472
+ *
5473
+ * 所以它通过一个**窄端口**([RetentionPort])调用数据库,并在端口缺席时
5474
+ * **静默降级**(只记一次 warn)。理由与可选宿主服务同一条:一个清理任务缺席,
5475
+ * 不该让整个插件激活失败。
5476
+ *
5477
+ * ## 为什么幂等记录只按时间清、不改存的内容
5478
+ *
5479
+ * 有个更"省空间"的方案是把 `response_json` 从整份响应裁成 `{sessionId}`。**否决**:
5480
+ * 幂等重放的语义就是「把当初那次响应原样再给你一次」,形状一变,已发布客户端的
5481
+ * 严格解码器会当场炸。省下的那点体积已经由按时间清理覆盖了,不值得换个破坏性变更。
5482
+ */
5483
+ /** 幂等记录保留多久。24 h 足够覆盖任何「客户端重试」窗口(配对/发消息都是秒级重试)。 */
5484
+ const IDEMPOTENCY_TTL_MS = 864e5;
5485
+ /** 审计保留多久。90 天是「事后追查」与「库不失控」之间的常规取舍。 */
5486
+ const AUDIT_TTL_MS = 7776e6;
5487
+ /** 审计最多留多少行(TTL 之内的兜底:短时间高频写不该把库顶爆)。 */
5488
+ const AUDIT_KEEP_MAX = 5e5;
5489
+ /**
5490
+ * 从数据库对象上取保留端口;没有就返回 null。
5491
+ *
5492
+ * **特性探测**而不是直接调用:`database.ts` 归另一条手术台,两侧必须能分批落地。
5493
+ * 端口缺席时本模块什么也不做 —— 那是「清理任务没上」,不是「插件坏了」。
5494
+ */
5495
+ function resolveRetentionPort(database) {
5496
+ const candidate = database;
5497
+ if (typeof candidate.pruneIdempotency !== "function") return null;
5498
+ if (typeof candidate.pruneAudit !== "function") return null;
5499
+ return database;
5500
+ }
5501
+ /**
5502
+ * 启动清理:一次把两张表都收到阈值以内。
5503
+ *
5504
+ * 放在**插件启动**时跑,因为它要处理的是「上次运行攒下来的量」—— 而那批数据
5505
+ * 可能远超一次请求能承受的规模,所以不该混进请求路径。
5506
+ *
5507
+ * 失败**不抛**(返回 `ran: true` 但两个计数为 0 并记 warn):清理失败只是「这次没清」,
5508
+ * 让它把插件启动打掉是轻重倒置。
5509
+ */
5510
+ function runStartupRetention(database, logger, now = Date.now()) {
5511
+ const port = resolveRetentionPort(database);
5512
+ if (port === null) {
5513
+ logger.warn(/* @__PURE__ */ new Error("dsh-remote-bridge: retention skipped — the database exposes no pruneIdempotency/pruneAudit; audit and idempotency tables will grow without bound"));
5514
+ return {
5515
+ ran: false,
5516
+ idempotencyDeleted: 0,
5517
+ auditDeleted: 0
5518
+ };
5519
+ }
5520
+ let idempotencyDeleted = 0;
5521
+ let auditDeleted = 0;
5522
+ try {
5523
+ idempotencyDeleted = port.pruneIdempotency(now - IDEMPOTENCY_TTL_MS);
5524
+ } catch (error) {
5525
+ logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: pruning idempotency failed: ${String(error)}`));
5526
+ }
5527
+ try {
5528
+ auditDeleted = port.pruneAudit(now - AUDIT_TTL_MS, AUDIT_KEEP_MAX);
5529
+ } catch (error) {
5530
+ logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: pruning audit failed: ${String(error)}`));
5531
+ }
5532
+ if (idempotencyDeleted > 0 || auditDeleted > 0) logger.info(`dsh-remote-bridge: retention removed ${idempotencyDeleted} idempotency row(s) and ${auditDeleted} audit row(s)`);
4264
5533
  return {
4265
- pluginVersion,
4266
- injected,
4267
- observed,
4268
- summary: {
4269
- injectedTotal: injected.length,
4270
- injectedAvailable: injected.filter((service) => service.available).length,
4271
- injectedMissing: injected.filter((service) => !service.available).map((service) => service.name),
4272
- observedAvailable: observed.filter((service) => service.resolvable).map((service) => service.name)
4273
- }
5534
+ ran: true,
5535
+ idempotencyDeleted,
5536
+ auditDeleted
4274
5537
  };
4275
5538
  }
4276
5539
  //#endregion
@@ -4565,6 +5828,14 @@ function defaultDisplayName() {
4565
5828
  //#endregion
4566
5829
  //#region src/host/plugin-inventory.ts
4567
5830
  /**
5831
+ * 读包清单时的并发上限。
5832
+ *
5833
+ * 为什么不是 `Promise.all` 全放:`names` 的长度由用户装了多少插件决定,
5834
+ * 理论上可以几十上百;一把放出去在慢盘/网络盘上反而更慢,而且失败时更难定位。
5835
+ * 6 与 `withUpdateCheck` 的网络并发同值,本仓已经验证过。
5836
+ */
5837
+ const INVENTORY_READ_CONCURRENCY = 6;
5838
+ /**
4568
5839
  * 把 profile 标识归一化成 profile 名。
4569
5840
  *
4570
5841
  * 内核接受**两种**写法,两者都合法:
@@ -4708,8 +5979,17 @@ async function readPluginInventory(input = {}) {
4708
5979
  const bundles = stringArray((typeof dshSection.profile === "object" && dshSection.profile !== null ? dshSection.profile : {}).bundles);
4709
5980
  const names = [.../* @__PURE__ */ new Set([...Object.keys(dependencies), ...bundles])].sort();
4710
5981
  const items = [];
5982
+ const manifests = /* @__PURE__ */ new Map();
5983
+ for (let offset = 0; offset < names.length; offset += INVENTORY_READ_CONCURRENCY) {
5984
+ const batch = names.slice(offset, offset + INVENTORY_READ_CONCURRENCY);
5985
+ const rows = await Promise.all(batch.map(async (name) => ({
5986
+ name,
5987
+ manifest: await readPackageManifest(profilePath, name)
5988
+ })));
5989
+ for (const row of rows) manifests.set(row.name, row.manifest);
5990
+ }
4711
5991
  for (const name of names) {
4712
- const pkgManifest = await readPackageManifest(profilePath, name);
5992
+ const pkgManifest = manifests.get(name) ?? null;
4713
5993
  const installedRaw = pkgManifest?.version;
4714
5994
  const installed = typeof installedRaw === "string" && installedRaw.trim() ? installedRaw : null;
4715
5995
  const loaded = bundles.includes(name);
@@ -5125,28 +6405,49 @@ function matchJobRoute(pathname) {
5125
6405
  const jobs = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs$/);
5126
6406
  if (jobs !== null) return {
5127
6407
  kind: "list",
5128
- sessionId: decodeURIComponent(jobs[1])
6408
+ sessionId: decodePathSegment(jobs[1])
5129
6409
  };
5130
6410
  const output = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/output$/);
5131
6411
  if (output !== null) return {
5132
6412
  kind: "output",
5133
- sessionId: decodeURIComponent(output[1]),
5134
- jobId: decodeURIComponent(output[2])
6413
+ sessionId: decodePathSegment(output[1]),
6414
+ jobId: decodePathSegment(output[2])
5135
6415
  };
5136
6416
  const kill = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/kill$/);
5137
6417
  if (kill !== null) return {
5138
6418
  kind: "kill",
5139
- sessionId: decodeURIComponent(kill[1]),
5140
- jobId: decodeURIComponent(kill[2])
6419
+ sessionId: decodePathSegment(kill[1]),
6420
+ jobId: decodePathSegment(kill[2])
5141
6421
  };
5142
6422
  const bare = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)$/);
5143
6423
  if (bare !== null) return {
5144
6424
  kind: "unknown",
5145
- sessionId: decodeURIComponent(bare[1]),
5146
- jobId: decodeURIComponent(bare[2])
6425
+ sessionId: decodePathSegment(bare[1]),
6426
+ jobId: decodePathSegment(bare[2])
5147
6427
  };
5148
6428
  return null;
5149
6429
  }
6430
+ /**
6431
+ * 隧道运行期间**仍然可达**的管理端点(豁免判据 0)。
6432
+ *
6433
+ * 为什么存在这个集合:判据 0(隧道开着 → 管理面整体关门)会让「隧道状态」这个信号
6434
+ * 本体也 403,于是面板没法告诉用户「隧道开着所以管理面停用」—— 只剩一个没法解读的
6435
+ * 失败。这两个端点进来的目的就是解掉这个矛盾。
6436
+ *
6437
+ * 为什么是安全的:它们**只读、无副作用**(不写库、不发令牌、不改监听),返回的
6438
+ * `running`/`channel`/`error`/阶段进度都不构成攻击面。**唯一敏感的是 `url`** ——
6439
+ * Quick Tunnel 地址本身是一次对外入口。
6440
+ *
6441
+ * 豁免**只针对判据 0**。判据 1/2/3(转发头+Cloudflare 边缘头 / Host / Origin)对它们
6442
+ * 照旧全部生效,所以真实隧道来源的请求连这两个端点也是 403、拿不到隧道地址。
6443
+ * 「远程能读到 running 但读不到 url」这个方案被**否决**了:那需要在判据 1 上再开一个口子,
6444
+ * 而那正是本次修复要消灭的东西。`tunnelStateFor` 里还留了一层不变量检查作双保险。
6445
+ */
6446
+ const TUNNEL_MODE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
6447
+ "/manage/cloudflare/status",
6448
+ "/manage/cloudflare/activity",
6449
+ "/manage/cloudflare/stop"
6450
+ ]);
5150
6451
  var ApiRouter = class {
5151
6452
  options;
5152
6453
  constructor(options) {
@@ -5157,6 +6458,15 @@ var ApiRouter = class {
5157
6458
  setCors(req, res);
5158
6459
  res.setHeader("X-Request-Id", requestId);
5159
6460
  if (req.method === "OPTIONS") {
6461
+ if (preflightRefused(req)) {
6462
+ res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
6463
+ res.end(JSON.stringify({ error: {
6464
+ code: "CORS_ORIGIN_FORBIDDEN",
6465
+ message: "Cross-origin preflight is accepted only from a loopback origin.",
6466
+ requestId
6467
+ } }));
6468
+ return;
6469
+ }
5160
6470
  res.writeHead(204);
5161
6471
  res.end();
5162
6472
  return;
@@ -5191,25 +6501,30 @@ var ApiRouter = class {
5191
6501
  });
5192
6502
  return;
5193
6503
  }
5194
- if (method === "GET" && pathname === "/api/v1/debug/host-services") {
5195
- const report = this.options.hostServiceReport;
5196
- if (report === void 0) throw new ApiError(404, "NOT_FOUND", "host service probe is not available in this build.");
5197
- sendJson(res, 200, report);
5198
- return;
5199
- }
5200
6504
  if (method === "POST" && pathname === "/api/v1/pairings/exchange") {
5201
- this.options.auth.pairingAttempts.consume(requestAddress(req));
6505
+ this.options.auth.pairingLimiter(req).consume(requestAddress(req));
5202
6506
  const body = await readJson(req, 16384);
5203
6507
  if (typeof body.code !== "string" || typeof body.deviceName !== "string") throw new ApiError(400, "BODY_INVALID", "code and deviceName are required strings.");
5204
- sendJson(res, 201, this.options.database.exchangePairing(body.code, body.deviceName));
6508
+ sendJson(res, 201, this.options.database.exchangePairing(body.code, body.deviceName, { requestId }));
5205
6509
  return;
5206
6510
  }
5207
- if (pathname.startsWith("/manage")) {
5208
- this.options.auth.requireAdmin(req);
6511
+ if (pathname === "/manage" || pathname.startsWith("/manage/")) {
6512
+ this.options.auth.requireAdmin(req, { ...TUNNEL_MODE_EXEMPT_PATHS.has(pathname) ? { allowTunnelMode: true } : {} });
5209
6513
  await this.dispatchManagement(req, res, url, requestId);
5210
6514
  return;
5211
6515
  }
5212
6516
  const principal = this.options.auth.principal(req, { allowAdmin: true });
6517
+ if (method === "GET" && pathname === "/api/v1/debug/host-services") {
6518
+ this.options.auth.requireScope(principal, "settings.read");
6519
+ const report = this.options.hostServiceReport;
6520
+ if (report === void 0) throw new ApiError(404, "NOT_FOUND", "host service probe is not available in this build.");
6521
+ const metrics = await readMetricsSnapshot();
6522
+ sendJson(res, 200, metrics === void 0 ? report : {
6523
+ ...report,
6524
+ metrics
6525
+ });
6526
+ return;
6527
+ }
5213
6528
  if (method === "GET" && pathname === "/api/v1/devices/self") {
5214
6529
  if (principal.kind === "admin") sendJson(res, 200, {
5215
6530
  id: principal.id,
@@ -5246,7 +6561,7 @@ var ApiRouter = class {
5246
6561
  }
5247
6562
  const rootRoute = pathname.match(/^\/api\/v1\/roots\/([^/]+)\/(entries|content)$/);
5248
6563
  if (rootRoute !== null) {
5249
- const rootId = decodeURIComponent(rootRoute[1]);
6564
+ const rootId = decodePathSegment(rootRoute[1]);
5250
6565
  const resource = rootRoute[2];
5251
6566
  const root = this.requireRoot(principal, rootId);
5252
6567
  if (resource === "entries") {
@@ -5265,7 +6580,7 @@ var ApiRouter = class {
5265
6580
  const restoreRoute = pathname.match(/^\/api\/v1\/trash\/([^/]+)\/restore$/);
5266
6581
  if (method === "POST" && restoreRoute !== null) {
5267
6582
  this.options.auth.requireScope(principal, "files.delete");
5268
- const record = this.options.database.getTrash(decodeURIComponent(restoreRoute[1]));
6583
+ const record = this.options.database.getTrash(decodePathSegment(restoreRoute[1]));
5269
6584
  if (record === void 0) throw new ApiError(404, "TRASH_NOT_FOUND", "The trash item does not exist.");
5270
6585
  const root = this.requireRoot(principal, record.rootId);
5271
6586
  await this.options.files.restoreTrash(root, record.id);
@@ -5291,7 +6606,7 @@ var ApiRouter = class {
5291
6606
  }
5292
6607
  const workspaceRoute = pathname.match(/^\/api\/v1\/chat\/workspaces\/([^/]+)$/);
5293
6608
  if (workspaceRoute !== null) {
5294
- const workspaceId = decodeURIComponent(workspaceRoute[1]);
6609
+ const workspaceId = decodePathSegment(workspaceRoute[1]);
5295
6610
  if (method === "PATCH") {
5296
6611
  this.options.auth.requireScope(principal, "chat.write");
5297
6612
  const body = await readJson(req, 64e3);
@@ -5379,7 +6694,7 @@ var ApiRouter = class {
5379
6694
  }
5380
6695
  const providerRoute = pathname.match(/^\/api\/v1\/settings\/providers\/([^/]+)$/);
5381
6696
  if (providerRoute !== null) {
5382
- const providerId = decodeURIComponent(providerRoute[1]);
6697
+ const providerId = decodePathSegment(providerRoute[1]);
5383
6698
  if (method === "PATCH") {
5384
6699
  this.options.auth.requireScope(principal, "settings.write");
5385
6700
  const body = await readJson(req, 1e6);
@@ -5405,7 +6720,7 @@ var ApiRouter = class {
5405
6720
  const discoverProviderRoute = pathname.match(/^\/api\/v1\/settings\/providers\/([^/]+)\/discover$/);
5406
6721
  if (method === "POST" && discoverProviderRoute !== null) {
5407
6722
  this.options.auth.requireScope(principal, "settings.write");
5408
- const providerId = decodeURIComponent(discoverProviderRoute[1]);
6723
+ const providerId = decodePathSegment(discoverProviderRoute[1]);
5409
6724
  const body = await readJson(req, 64e3);
5410
6725
  const draft = {
5411
6726
  ...typeof body.baseURL === "string" ? { baseURL: body.baseURL } : {},
@@ -5449,7 +6764,7 @@ var ApiRouter = class {
5449
6764
  const sessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)$/);
5450
6765
  if (method === "PATCH" && sessionRoute !== null) {
5451
6766
  this.options.auth.requireScope(principal, "chat.write");
5452
- const sessionId = decodeURIComponent(sessionRoute[1]);
6767
+ const sessionId = decodePathSegment(sessionRoute[1]);
5453
6768
  const body = await readJson(req, 64e3);
5454
6769
  if (typeof body.title !== "string") throw new ApiError(400, "BODY_INVALID", "title is required.");
5455
6770
  const result = await this.options.chat.renameSession(principal, sessionId, body.title);
@@ -5460,7 +6775,7 @@ var ApiRouter = class {
5460
6775
  const forkSessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/fork$/);
5461
6776
  if (method === "POST" && forkSessionRoute !== null) {
5462
6777
  this.options.auth.requireScope(principal, "chat.write");
5463
- const sessionId = decodeURIComponent(forkSessionRoute[1]);
6778
+ const sessionId = decodePathSegment(forkSessionRoute[1]);
5464
6779
  const body = await readJson(req, 16384);
5465
6780
  const atSeq = body.atSeq === void 0 ? void 0 : body.atSeq;
5466
6781
  if (atSeq !== void 0 && (!Number.isSafeInteger(atSeq) || atSeq < 0)) throw new ApiError(400, "BODY_INVALID", "atSeq must be a non-negative integer.");
@@ -5475,7 +6790,7 @@ var ApiRouter = class {
5475
6790
  const archiveSessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/archive$/);
5476
6791
  if (method === "POST" && archiveSessionRoute !== null) {
5477
6792
  this.options.auth.requireScope(principal, "chat.write");
5478
- const sessionId = decodeURIComponent(archiveSessionRoute[1]);
6793
+ const sessionId = decodePathSegment(archiveSessionRoute[1]);
5479
6794
  await this.options.chat.archiveSession(principal, sessionId);
5480
6795
  this.audit(principal, "chat.session.archive", void 0, void 0, requestId, { sessionId });
5481
6796
  sendJson(res, 200, { archived: true });
@@ -5512,7 +6827,7 @@ var ApiRouter = class {
5512
6827
  }
5513
6828
  const messagesRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/messages$/);
5514
6829
  if (messagesRoute !== null) {
5515
- const sessionId = decodeURIComponent(messagesRoute[1]);
6830
+ const sessionId = decodePathSegment(messagesRoute[1]);
5516
6831
  if (method === "GET") {
5517
6832
  this.options.auth.requireScope(principal, "chat.read");
5518
6833
  const beforeSeq = optionalInteger(url.searchParams.get("beforeSeq"));
@@ -5547,7 +6862,7 @@ var ApiRouter = class {
5547
6862
  const attachmentRoute = pathname.match(/^\/api\/v1\/attachments\/([^/]+)$/);
5548
6863
  if (attachmentRoute !== null && method === "GET") {
5549
6864
  this.options.auth.requireScope(principal, "files.read");
5550
- const attachmentId = decodeURIComponent(attachmentRoute[1]);
6865
+ const attachmentId = decodePathSegment(attachmentRoute[1]);
5551
6866
  const sessionId = url.searchParams.get("sessionId")?.trim();
5552
6867
  if (!sessionId) throw new ApiError(400, "BODY_INVALID", "sessionId query parameter is required.");
5553
6868
  const { bytes, mediaType } = await this.options.chat.readAttachment(principal, sessionId, attachmentId);
@@ -5567,7 +6882,7 @@ var ApiRouter = class {
5567
6882
  res.statusCode = 200;
5568
6883
  res.setHeader("Content-Type", mediaType);
5569
6884
  res.setHeader("Content-Length", String(bytes.byteLength));
5570
- res.setHeader("Cache-Control", "private, max-age=3600");
6885
+ res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
5571
6886
  res.end(Buffer.from(bytes));
5572
6887
  return;
5573
6888
  }
@@ -5577,12 +6892,12 @@ var ApiRouter = class {
5577
6892
  const body = await readJson(req, 24e6);
5578
6893
  if (typeof body.data !== "string" || body.data === "") throw new ApiError(400, "BODY_INVALID", "data (base64) is required.");
5579
6894
  const name = typeof body.name === "string" && body.name.trim() !== "" ? body.name.trim() : void 0;
5580
- sendJson(res, 201, await this.options.chat.uploadAttachment(principal, decodeURIComponent(attachmentsRoute[1]), body.data, name));
6895
+ sendJson(res, 201, await this.options.chat.uploadAttachment(principal, decodePathSegment(attachmentsRoute[1]), body.data, name));
5581
6896
  return;
5582
6897
  }
5583
6898
  const commandsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/commands$/);
5584
6899
  if (commandsRoute !== null) {
5585
- const sessionId = decodeURIComponent(commandsRoute[1]);
6900
+ const sessionId = decodePathSegment(commandsRoute[1]);
5586
6901
  if (method === "GET") {
5587
6902
  this.options.auth.requireScope(principal, "chat.read");
5588
6903
  sendJson(res, 200, { items: await this.options.chat.listCommands(principal, sessionId) });
@@ -5604,15 +6919,15 @@ var ApiRouter = class {
5604
6919
  const approvalsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/approvals$/);
5605
6920
  if (method === "GET" && approvalsRoute !== null) {
5606
6921
  this.options.auth.requireScope(principal, "chat.read");
5607
- const sessionId = decodeURIComponent(approvalsRoute[1]);
6922
+ const sessionId = decodePathSegment(approvalsRoute[1]);
5608
6923
  sendJson(res, 200, { items: await this.options.chat.listPendingApprovals(principal, sessionId) });
5609
6924
  return;
5610
6925
  }
5611
6926
  const approvalDecisionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/approvals\/([^/]+)\/decision$/);
5612
6927
  if (method === "POST" && approvalDecisionRoute !== null) {
5613
6928
  this.options.auth.requireScope(principal, "chat.write");
5614
- const sessionId = decodeURIComponent(approvalDecisionRoute[1]);
5615
- const approvalId = decodeURIComponent(approvalDecisionRoute[2]);
6929
+ const sessionId = decodePathSegment(approvalDecisionRoute[1]);
6930
+ const approvalId = decodePathSegment(approvalDecisionRoute[2]);
5616
6931
  const body = await readJson(req, 16384);
5617
6932
  if (body.outcome !== "allowed-once" && body.outcome !== "rejected") throw new ApiError(400, "BODY_INVALID", "outcome must be allowed-once or rejected.");
5618
6933
  const approval = await this.options.chat.decideApproval(principal, sessionId, approvalId, body.outcome);
@@ -5629,15 +6944,15 @@ var ApiRouter = class {
5629
6944
  const questionsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions$/);
5630
6945
  if (method === "GET" && questionsRoute !== null) {
5631
6946
  this.options.auth.requireScope(principal, "chat.read");
5632
- const sessionId = decodeURIComponent(questionsRoute[1]);
6947
+ const sessionId = decodePathSegment(questionsRoute[1]);
5633
6948
  sendJson(res, 200, { items: await this.options.chat.listUserQuestions(principal, sessionId) });
5634
6949
  return;
5635
6950
  }
5636
6951
  const questionAnswerRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions\/([^/]+)\/answer$/);
5637
6952
  if (method === "POST" && questionAnswerRoute !== null) {
5638
6953
  this.options.auth.requireScope(principal, "chat.write");
5639
- const sessionId = decodeURIComponent(questionAnswerRoute[1]);
5640
- const callId = decodeURIComponent(questionAnswerRoute[2]);
6954
+ const sessionId = decodePathSegment(questionAnswerRoute[1]);
6955
+ const callId = decodePathSegment(questionAnswerRoute[2]);
5641
6956
  const answer = readUserQuestionAnswer(await readJson(req, 64e3));
5642
6957
  await this.options.chat.answerUserQuestion(principal, sessionId, callId, answer);
5643
6958
  this.audit(principal, "chat.question.answer", void 0, void 0, requestId, {
@@ -5651,7 +6966,7 @@ var ApiRouter = class {
5651
6966
  }
5652
6967
  const sessionModelsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/model(?:s)?$/);
5653
6968
  if (sessionModelsRoute !== null) {
5654
- const sessionId = decodeURIComponent(sessionModelsRoute[1]);
6969
+ const sessionId = decodePathSegment(sessionModelsRoute[1]);
5655
6970
  if (method === "GET") {
5656
6971
  this.options.auth.requireScope(principal, "chat.read");
5657
6972
  sendJson(res, 200, await this.options.chat.models(principal, sessionId));
@@ -5679,7 +6994,7 @@ var ApiRouter = class {
5679
6994
  const sessionAgentPresetRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/agent-preset$/);
5680
6995
  if (method === "PUT" && sessionAgentPresetRoute !== null) {
5681
6996
  this.options.auth.requireScope(principal, "chat.write");
5682
- const sessionId = decodeURIComponent(sessionAgentPresetRoute[1]);
6997
+ const sessionId = decodePathSegment(sessionAgentPresetRoute[1]);
5683
6998
  const body = await readJson(req, 64e3);
5684
6999
  if (typeof body.agentPreset !== "string") throw new ApiError(400, "BODY_INVALID", "agentPreset is required.");
5685
7000
  const agentPreset = await this.options.chat.selectAgentPreset(principal, sessionId, body.agentPreset);
@@ -5693,7 +7008,7 @@ var ApiRouter = class {
5693
7008
  const cancelRoute = pathname.match(/^\/api\/v1\/chat\/runs\/([^/]+)\/cancel$/);
5694
7009
  if (method === "POST" && cancelRoute !== null) {
5695
7010
  this.options.auth.requireScope(principal, "chat.write");
5696
- const sessionId = decodeURIComponent(cancelRoute[1]);
7011
+ const sessionId = decodePathSegment(cancelRoute[1]);
5697
7012
  await this.options.chat.cancel(principal, sessionId);
5698
7013
  this.audit(principal, "chat.run.cancel", void 0, void 0, requestId, { sessionId });
5699
7014
  sendJson(res, 202, { accepted: true });
@@ -5720,12 +7035,12 @@ var ApiRouter = class {
5720
7035
  if (method === "POST" && pathname === "/manage/roots") {
5721
7036
  const body = await readJson(req, 64e3);
5722
7037
  if (typeof body.path !== "string") throw new ApiError(400, "BODY_INVALID", "path is required.");
5723
- sendJson(res, 201, await this.options.database.addRoot(body.path, typeof body.label === "string" ? body.label : void 0));
7038
+ sendJson(res, 201, await this.options.database.addRoot(body.path, typeof body.label === "string" ? body.label : void 0, { requestId }));
5724
7039
  return;
5725
7040
  }
5726
7041
  const rootRoute = pathname.match(/^\/manage\/roots\/([^/]+)$/);
5727
7042
  if (method === "DELETE" && rootRoute !== null) {
5728
- this.options.database.removeRoot(decodeURIComponent(rootRoute[1]));
7043
+ this.options.database.removeRoot(decodePathSegment(rootRoute[1]));
5729
7044
  res.writeHead(204);
5730
7045
  res.end();
5731
7046
  return;
@@ -5739,44 +7054,23 @@ var ApiRouter = class {
5739
7054
  const body = await readJson(req, 64e3);
5740
7055
  const scopes = validateScopes(body.scopes);
5741
7056
  const rootIds = validateStringArray(body.rootIds, "rootIds");
5742
- sendJson(res, 200, this.options.database.updateDevice(decodeURIComponent(deviceRoute[1]), scopes, rootIds));
7057
+ sendJson(res, 200, this.options.database.updateDevice(decodePathSegment(deviceRoute[1]), scopes, rootIds, { requestId }));
5743
7058
  return;
5744
7059
  }
5745
7060
  if (deviceRoute !== null && method === "DELETE") {
5746
- this.options.database.revokeDevice(decodeURIComponent(deviceRoute[1]));
7061
+ this.options.database.revokeDevice(decodePathSegment(deviceRoute[1]), { requestId });
5747
7062
  res.writeHead(204);
5748
7063
  res.end();
5749
7064
  return;
5750
7065
  }
5751
7066
  if (method === "POST" && pathname === "/manage/pairings") {
5752
7067
  const body = await readJson(req, 64e3);
5753
- sendJson(res, 201, this.options.database.createPairing(validateStringArray(body.rootIds, "rootIds"), validateScopes(body.scopes)));
7068
+ sendJson(res, 201, this.options.database.createPairing(validateStringArray(body.rootIds, "rootIds"), validateScopes(body.scopes), void 0, { requestId }));
5754
7069
  return;
5755
7070
  }
5756
7071
  if (method === "POST" && pathname === "/manage/config/text") {
5757
7072
  const body = await readJson(req, 64e3);
5758
- const port = Number.isInteger(body.port) && Number(body.port) > 0 && Number(body.port) < 65536 ? Number(body.port) : DEFAULT_PORT;
5759
- const endpoints = readRequestedEndpoints(body.endpoints) ?? detectEndpoints(port);
5760
- if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
5761
- const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
5762
- const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
5763
- const deviceName = readOptionalText(body.deviceName) ?? "Android 设备";
5764
- const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
5765
- const pairing = this.options.database.createPairing(rootIds, scopes);
5766
- const exchanged = this.options.database.exchangePairing(pairing.code, deviceName);
5767
- sendJson(res, 201, {
5768
- text: encodeConfigText({
5769
- displayName,
5770
- endpoints,
5771
- token: exchanged.token,
5772
- deviceName,
5773
- scopes
5774
- }),
5775
- displayName,
5776
- deviceName,
5777
- endpoints,
5778
- deviceId: exchanged.device.id
5779
- });
7073
+ sendJson(res, 201, this.buildConfigText(body, requestId));
5780
7074
  return;
5781
7075
  }
5782
7076
  if (method === "POST" && pathname === "/manage/remote/enable") {
@@ -5816,7 +7110,7 @@ var ApiRouter = class {
5816
7110
  }
5817
7111
  const purgeRoute = pathname.match(/^\/manage\/trash\/([^/]+)$/);
5818
7112
  if (method === "DELETE" && purgeRoute !== null) {
5819
- await this.options.files.purgeTrash(decodeURIComponent(purgeRoute[1]));
7113
+ await this.options.files.purgeTrash(decodePathSegment(purgeRoute[1]));
5820
7114
  this.options.database.audit("admin", "loopback-admin", "trash.purge", void 0, void 0, {}, requestId);
5821
7115
  res.writeHead(204);
5822
7116
  res.end();
@@ -5824,7 +7118,7 @@ var ApiRouter = class {
5824
7118
  }
5825
7119
  if (this.options.tunnels !== void 0 && pathname === "/manage/cloudflare/status") {
5826
7120
  if (method === "GET" || method === "HEAD") {
5827
- sendJson(res, 200, this.options.tunnels.publicState);
7121
+ sendJson(res, 200, this.tunnelStateFor(req));
5828
7122
  return;
5829
7123
  }
5830
7124
  }
@@ -5835,6 +7129,8 @@ var ApiRouter = class {
5835
7129
  }
5836
7130
  }
5837
7131
  if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/create") {
7132
+ const createBody = await readJson(req, 64e3).catch(() => ({}));
7133
+ const wantConfig = createBody.configText === true;
5838
7134
  const binDir = this.options.cloudflaredBinDir;
5839
7135
  let exe;
5840
7136
  try {
@@ -5843,13 +7139,37 @@ var ApiRouter = class {
5843
7139
  });
5844
7140
  } catch (cause) {
5845
7141
  this.options.tunnels.clearActivity();
5846
- throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED", cause instanceof Error ? cause.message : String(cause));
7142
+ throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED", "Failed to download or unpack cloudflared. Check the network and retry, or set CLOUDFLARED_MIRROR.", {
7143
+ errorKey: "tunnelErrorDownload",
7144
+ ...cause instanceof Error ? { reason: sanitizeDownloadError(cause.message) } : {}
7145
+ });
5847
7146
  }
5848
7147
  const port = this.options.remote.status().configuredPort ?? 3090;
5849
7148
  const state = await this.options.tunnels.startQuick(exe, port);
7149
+ let configText;
7150
+ let resolvedUrl = state.url;
7151
+ if (wantConfig) {
7152
+ const tunnelUrl = state.url ?? await this.options.tunnels.waitForUrl();
7153
+ resolvedUrl = tunnelUrl;
7154
+ if (tunnelUrl !== null) configText = this.buildConfigText({
7155
+ endpoints: [{
7156
+ label: readOptionalText(createBody.endpointLabel) ?? "云隧道",
7157
+ baseUrl: tunnelUrl
7158
+ }],
7159
+ ...createBody.rootIds === void 0 ? {} : { rootIds: createBody.rootIds },
7160
+ ...createBody.deviceName === void 0 ? {} : { deviceName: createBody.deviceName }
7161
+ }, requestId);
7162
+ }
5850
7163
  this.options.tunnels.clearActivity();
5851
7164
  this.options.database.audit("admin", "loopback-admin", "cloudflare.create", void 0, void 0, {}, requestId);
5852
- sendJson(res, 201, state);
7165
+ const responseState = resolvedUrl === state.url ? state : {
7166
+ ...state,
7167
+ url: resolvedUrl
7168
+ };
7169
+ sendJson(res, 201, configText === void 0 ? responseState : {
7170
+ ...responseState,
7171
+ configText
7172
+ });
5853
7173
  return;
5854
7174
  }
5855
7175
  if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/stop") {
@@ -5860,6 +7180,66 @@ var ApiRouter = class {
5860
7180
  }
5861
7181
  throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
5862
7182
  }
7183
+ /**
7184
+ * 隧道状态视图。
7185
+ *
7186
+ * **这里不做脱敏,因为用不上**:判据 1 已经把 Cloudflare 边缘头(`Cf-Connecting-IP` /
7187
+ * `Cf-Ray` / …)算作「前面有代理」的证据,于是任何真实隧道来源的请求在
7188
+ * `requireAdmin` 就被 403 挡住了,根本到不了这个方法。能走到这里的只有真·本机请求
7189
+ * (判据 1/2/3 全过),所以 `url` 可以照常给它 —— 那是桌面端「用隧道地址生成配置文本」
7190
+ * 的依据。
7191
+ *
7192
+ * 为什么仍然保留这个方法而不是直接 `publicState`:脱敏的位置要留在一个**语义明确**的
7193
+ * 出口上。将来若有人为了「让远程客户端也能读到隧道是否开着」而放宽判据 1,
7194
+ * 必须在这里重新定义脱敏,而不是顺手把 `url` 一起放出去。
7195
+ * 评估结论见报告:本轮选择「远程一律 403」而不是「远程读得到但 `url` 为 null」,
7196
+ * 因为后者需要为状态端点在判据 1 上再开一个口子,而那正是这次修复要消灭的东西。
7197
+ */
7198
+ /**
7199
+ * 生成一行可粘贴的 `DSH1:` 配置文本(POST /manage/config/text 与「一键开隧道并出令牌」
7200
+ * 的 create 端点共用,避免两处各写一套配对/兑换/编码逻辑而漂移)。
7201
+ *
7202
+ * 入参字段与 /manage/config/text 的窄格式一致:
7203
+ * - endpoints:显式给出则不再做本机网卡探测(隧道场景必须只塞隧道地址,不能混入 LAN);
7204
+ * - rootIds / scopes / deviceName / displayName / port:全部可选,缺省沿用原端点口径;
7205
+ * - scopes 缺省是**五个非破坏性默认权限**(不含 files.delete / settings.write),
7206
+ * 调用方若显式传 DEVICE_SCOPES 会放大手机权限,create 一键路径不传。
7207
+ *
7208
+ * 刻意不返回 pairing.expiresAt:那是配对码的十分钟有效期,设备令牌本身不过期。
7209
+ */
7210
+ buildConfigText(body, requestId) {
7211
+ const port = Number.isInteger(body.port) && Number(body.port) > 0 && Number(body.port) < 65536 ? Number(body.port) : DEFAULT_PORT;
7212
+ const endpoints = readRequestedEndpoints(body.endpoints) ?? detectEndpoints(port);
7213
+ if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
7214
+ const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
7215
+ const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
7216
+ const deviceName = readOptionalText(body.deviceName) ?? "Android 设备";
7217
+ const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
7218
+ const pairing = this.options.database.createPairing(rootIds, scopes, void 0, { requestId });
7219
+ const exchanged = this.options.database.exchangePairing(pairing.code, deviceName, { requestId });
7220
+ return {
7221
+ text: encodeConfigText({
7222
+ displayName,
7223
+ endpoints,
7224
+ token: exchanged.token,
7225
+ deviceName,
7226
+ scopes
7227
+ }),
7228
+ displayName,
7229
+ deviceName,
7230
+ endpoints,
7231
+ deviceId: exchanged.device.id
7232
+ };
7233
+ }
7234
+ tunnelStateFor(req) {
7235
+ const tunnels = this.options.tunnels;
7236
+ if (tunnels === void 0) throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
7237
+ const state = tunnels.publicState;
7238
+ return adminRefusal(req, { tunnelActive: false }) === void 0 ? state : {
7239
+ ...state,
7240
+ url: null
7241
+ };
7242
+ }
5863
7243
  async dispatchEntries(req, res, url, principal, root, requestId) {
5864
7244
  const method = req.method ?? "GET";
5865
7245
  if (method === "GET") {
@@ -5902,12 +7282,18 @@ var ApiRouter = class {
5902
7282
  if (req.method === "GET") {
5903
7283
  this.options.auth.requireScope(principal, "files.read");
5904
7284
  const descriptor = await this.options.files.inspectContent(root, relativePath);
5905
- const range = parseRange(req.headers.range, descriptor.size);
7285
+ res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
5906
7286
  res.setHeader("Accept-Ranges", "bytes");
5907
7287
  res.setHeader("ETag", descriptor.etag);
5908
7288
  res.setHeader("Last-Modified", new Date(descriptor.modifiedAt).toUTCString());
5909
7289
  res.setHeader("Content-Type", descriptor.contentType);
5910
7290
  res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(relativePath.split("/").at(-1) ?? "file")}`);
7291
+ const range = parseRange(req.headers.range, descriptor.size);
7292
+ if (range === void 0 && etagMatches(req.headers["if-none-match"], descriptor.etag)) {
7293
+ res.writeHead(304, { ETag: descriptor.etag });
7294
+ res.end();
7295
+ return;
7296
+ }
5911
7297
  if (range === void 0) {
5912
7298
  res.writeHead(200, { "Content-Length": descriptor.size });
5913
7299
  this.options.files.streamContent(descriptor).pipe(res);
@@ -5992,17 +7378,71 @@ function sendJson(res, status, body) {
5992
7378
  });
5993
7379
  res.end(encoded);
5994
7380
  }
7381
+ /**
7382
+ * 这个 `Origin` 是否指向回环(`127.0.0.1` / `localhost` / `[::1]`)。
7383
+ *
7384
+ * 与 `auth.ts` 的 `isLoopbackOrigin` **同一个判据**,只是这里需要「是否回环」这个
7385
+ * 布尔值本身(那边是「放不放行」)。两处若各写一套,就会出现「CORS 放行、
7386
+ * 鉴权拒绝」或反之的漂移 —— 2.5.1 的 CORS 缺陷正是这种漂移的一种。
7387
+ */
7388
+ function isLoopbackOriginValue(origin) {
7389
+ try {
7390
+ const hostname = new URL(origin).hostname.toLowerCase();
7391
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
7392
+ } catch {
7393
+ return false;
7394
+ }
7395
+ }
7396
+ /**
7397
+ * 写 CORS 响应头。**只对回环 Origin 回显**(2.5.2 修复)。
7398
+ *
7399
+ * ## 修复前
7400
+ *
7401
+ * 只要请求带 `Origin` 就原样回显 `Access-Control-Allow-Origin: <它>`,
7402
+ * 且 `Allow-Headers` 里含 `X-Dsh-Workspace-Admin`。那等于在**协议层**对任意网站宣告:
7403
+ * 「你可以跨站调这个服务,连管理头都可以带」。
7404
+ *
7405
+ * 当时没有立刻变成提权,是因为 `adminRefusal` 的判据 3 会看 `Origin` 并 403 ——
7406
+ * **判据替 CORS 兜住了**。但纵深只剩一层:以后任何一次「为兼容放宽判据 3」的改动
7407
+ * (比如允许 `Origin: null`)都会立刻变成 CSRF 敞口。
7408
+ *
7409
+ * ## 修复后
7410
+ *
7411
+ * - 回环 Origin → 回显它(桌面 WebUI 在 `127.0.0.1:3080` 起,同源或跨端口都照常);
7412
+ * - 非回环 Origin → **不发** ACAO(浏览器自然拒绝跨站读取,不需要服务端 403 也算安全);
7413
+ * - 无 Origin → 不发(本机 `curl` 不需要 CORS);
7414
+ * - `Allow-Headers` **去掉** `X-Dsh-Workspace-Admin`:桌面面板是同源的,
7415
+ * 它带这个头根本不需要预检许可,把它写进协议只会误导后人。
7416
+ *
7417
+ * `Cache-Control: no-store` 仍在这里设默认值;少数可缓存的三条端点
7418
+ * (`/roots/:id/content`、`/attachments/:id`、`/image-handoff`)在各自的处理分支里覆盖。
7419
+ */
5995
7420
  function setCors(req, res) {
5996
7421
  const origin = req.headers.origin;
5997
- if (origin !== void 0) {
7422
+ if (origin !== void 0 && isLoopbackOriginValue(origin)) {
5998
7423
  res.setHeader("Access-Control-Allow-Origin", origin);
5999
7424
  res.setHeader("Vary", "Origin");
6000
7425
  }
6001
- res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match, X-Request-Id, X-Dsh-Workspace-Admin");
7426
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match, X-Request-Id");
6002
7427
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
6003
7428
  res.setHeader("Access-Control-Expose-Headers", "ETag, Content-Range, X-Request-Id");
6004
7429
  res.setHeader("Cache-Control", "no-store");
6005
7430
  }
7431
+ /**
7432
+ * 预检(`OPTIONS`)是否该被回答。
7433
+ *
7434
+ * 浏览器预检一定带 `Origin`。带**非回环** Origin 的预检直接 403 ——
7435
+ * 与「不发 ACAO」相比这条更明确:让攻击者立刻知道跨站不被接受,
7436
+ * 而不是让它在一个「看起来成功但读不到内容」的响应里猜。
7437
+ *
7438
+ * 没有 `Origin` 的 `OPTIONS` 不是浏览器预检(`curl` 常这么探活),照旧 204 ——
7439
+ * 与 `adminRefusal` 放行「缺 Origin」是同一个口径(缺 Origin 不构成声明)。
7440
+ */
7441
+ function preflightRefused(req) {
7442
+ const origin = req.headers.origin;
7443
+ if (typeof origin !== "string" || origin === "") return false;
7444
+ return !isLoopbackOriginValue(origin);
7445
+ }
6006
7446
  function validateScopes(value) {
6007
7447
  const values = validateStringArray(value, "scopes");
6008
7448
  if (values.some((item) => !DEVICE_SCOPES.includes(item))) throw new ApiError(400, "SCOPE_INVALID", "The request contains an unknown device scope.");
@@ -6135,6 +7575,39 @@ function optionalInteger(value) {
6135
7575
  function commandName(line) {
6136
7576
  return /^\/([a-z][a-z0-9_-]*)/u.exec(line)?.[1] ?? "unknown";
6137
7577
  }
7578
+ /**
7579
+ * 读一份运行期指标快照;读取本身抛错时返回 `undefined`(诊断端点绝不能反过来炸掉)。
7580
+ *
7581
+ * 历史:这里曾是动态 `import('./metrics.ts')`,因为 metrics.ts 与本文件由不同手术台并行落地,
7582
+ * 静态依赖会造成编译顺序耦合。metrics.ts 落地后该动态导入既不省包(server/bridge 已静态引用,
7583
+ * tsdown 会打 INEFFECTIVE_DYNAMIC_IMPORT 警告)也不提供容错,改为静态导入。
7584
+ */
7585
+ async function readMetricsSnapshot() {
7586
+ try {
7587
+ return getMetricsSnapshot();
7588
+ } catch {
7589
+ return;
7590
+ }
7591
+ }
7592
+ /**
7593
+ * `If-None-Match` 是否命中当前 ETag。**带弱比较**(忽略 `W/` 前缀)。
7594
+ *
7595
+ * 为什么不用严格相等:ETag 的强弱前缀(`W/`)属于缓存语义的**修饰**,
7596
+ * 不改变「这份内容是不是同一份」。而两端(路由器 / `file-service`)谁加 `W/`
7597
+ * 都可能随实现变化(本仓的 ETag 格式正在被改成 stat-only 的弱校验器),
7598
+ * 写死严格相等会让「格式一改,304 静默失效」—— 那种失效没有任何报错,
7599
+ * 只是客户端永远拿不到 304,悄悄地退回每次重下整份。
7600
+ *
7601
+ * 支持逗号分隔的多值(`If-None-Match: W/"a", "b"`)与通配 `*`。
7602
+ */
7603
+ function etagMatches(headerValue, etag) {
7604
+ const raw = Array.isArray(headerValue) ? headerValue.join(",") : headerValue;
7605
+ if (raw === void 0 || raw === "") return false;
7606
+ if (raw.trim() === "*") return true;
7607
+ const normalize = (value) => value.trim().replace(/^(?:W\/)+/i, "");
7608
+ const target = normalize(etag);
7609
+ return raw.split(",").some((candidate) => normalize(candidate) === target);
7610
+ }
6138
7611
  function parseRange(value, size) {
6139
7612
  if (value === void 0) return void 0;
6140
7613
  const match = /^bytes=(\d*)-(\d*)$/.exec(value);
@@ -6160,8 +7633,72 @@ function header(req, name) {
6160
7633
  const value = req.headers[name];
6161
7634
  return Array.isArray(value) ? value[0] : value;
6162
7635
  }
7636
+ /**
7637
+ * 解码一个 URL **路径段**(不是 query 参数)。
7638
+ *
7639
+ * ## 为什么要收口成一个函数
7640
+ *
7641
+ * `decodeURIComponent` 对非法百分号(`%ZZ`、`%E0%A4%A`、孤零零的 `%`)抛 `URIError`。
7642
+ * 而 `new URL()` **不会**提前拒绝它们(实测 `pathname` 保留字面 `%ZZ`),
7643
+ * 于是请求真的会走到解码这一步 —— 抛出后一路冒到 `router.handle` 的兜底 `catch`,
7644
+ * 被 `asApiError` 包成 **500 `INTERNAL_ERROR`**。
7645
+ *
7646
+ * 那是**错误的分类**:客户端把 URL 打错,不是桥内部炸了。两层代价:
7647
+ * 1. 排障时把「你 URL 写错了」读成「服务端故障」,方向全错;
7648
+ * 2. 污染 `INTERNAL_ERROR` 这个码 —— 真出 500 时没人能再靠码区分。
7649
+ *
7650
+ * 所以这里把「客户端输入非法」固定成 **400 `PATH_INVALID`**,
7651
+ * 与 `path-policy.ts` 里其它非法路径的码保持一致(客户端只需认一个码)。
7652
+ *
7653
+ * ## 为什么不用 `URLSearchParams` 或 `decodeURI`
7654
+ *
7655
+ * - `URLSearchParams.get()` 会把 `+` 解成空格(那是 **query** 的
7656
+ * `application/x-www-form-urlencoded` 语义)。路径段里的 `+` 是字面量 ——
7657
+ * 用它会把 `a+b` 这类合法 id 静默改成 `a b`。
7658
+ * - `decodeURI` 不处理保留字符(`%3A` 之类解不出来),语义不对。
7659
+ *
7660
+ * 所以:`decodeURIComponent` + 严格 catch,什么都不额外做。
7661
+ */
7662
+ function decodePathSegment(value) {
7663
+ try {
7664
+ return decodeURIComponent(value);
7665
+ } catch {
7666
+ throw new ApiError(400, "PATH_INVALID", "A path segment contains malformed percent-encoding.");
7667
+ }
7668
+ }
6163
7669
  //#endregion
6164
7670
  //#region src/host/server.ts
7671
+ /** 默认同时连接数。比手机端可能的重连风暴宽,又不至于让一个坏客户端吃满。 */
7672
+ const DEFAULT_MAX_CONNECTIONS = 32;
7673
+ /** 默认单帧上限(`ws` 的 `maxPayload`)。 */
7674
+ const DEFAULT_MAX_PAYLOAD_BYTES = 1048576;
7675
+ /** 默认心跳间隔。 */
7676
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
7677
+ /** 连接内事件队列上限。 */
7678
+ const DEFAULT_SOCKET_QUEUE_LIMIT = 512;
7679
+ /** 发送缓冲高水位:`ws` 的 `bufferedAmount` 越过它说明对端读不动。 */
7680
+ const DEFAULT_HIGH_WATER_MARK_BYTES = 4194304;
7681
+ /** 卸载时等广播排空的硬上限(不让插件卡住进程退出)。 */
7682
+ const STOP_DRAIN_TIMEOUT_MS = 1e3;
7683
+ /** 背压复查间隔:越线后等这么久再看一眼缓冲降没降。 */
7684
+ const HIGH_WATER_RECHECK_MS = 250;
7685
+ /**
7686
+ * 关闭连接时的宽限期。
7687
+ *
7688
+ * 升级过的 WebSocket 不受 `closeAllConnections()` 管理,所以「一条读不动的连接」
7689
+ * 能让 `server.close()` 的回调永远不来。宽限期后一律 `terminate()`。
7690
+ */
7691
+ const CLOSE_GRACE_MS = 500;
7692
+ /**
7693
+ * 这条事件「丢了能不能重建」。
7694
+ *
7695
+ * 只有流式文本增量是可重建的(客户端重拉历史即可)。状态类帧(`turn.*` /
7696
+ * `approval.*` / `deliverables.*` / `connection.ready`)丢了会让客户端停在错误状态,
7697
+ * 所以溢出时**优先牺牲增量**。
7698
+ */
7699
+ function isRebuildable(event) {
7700
+ return event.type === "chat.message.delta";
7701
+ }
6165
7702
  var RemoteApiServer = class {
6166
7703
  options;
6167
7704
  server;
@@ -6172,14 +7709,18 @@ var RemoteApiServer = class {
6172
7709
  remoteEnabled = false;
6173
7710
  operations = /* @__PURE__ */ new Map();
6174
7711
  sockets = /* @__PURE__ */ new Map();
6175
- webSockets = new WebSocketServer({
6176
- noServer: true,
6177
- clientTracking: false
6178
- });
7712
+ webSockets;
6179
7713
  eventsAbort = new AbortController();
6180
7714
  unsubscribeEvents;
6181
- broadcastQueue = Promise.resolve();
7715
+ /** 所有连接的 drain 完成信号;`Promise.all` 它们即可等排空。 */
7716
+ pendingDrains = /* @__PURE__ */ new Set();
7717
+ heartbeatTimer;
7718
+ changeTimer;
6182
7719
  changing = false;
7720
+ maxConnections;
7721
+ socketQueueLimit;
7722
+ highWaterMarkBytes;
7723
+ heartbeatIntervalMs;
6183
7724
  constructor(options) {
6184
7725
  this.options = options;
6185
7726
  const fallbackHost = normalizeListenerHost(options.remoteHost ?? "0.0.0.0");
@@ -6187,6 +7728,15 @@ var RemoteApiServer = class {
6187
7728
  this.configuredHost = readStoredHost(options.database, fallbackHost);
6188
7729
  this.configuredPort = readStoredPort(options.database, fallbackPort);
6189
7730
  this.actualPort = this.configuredPort;
7731
+ this.maxConnections = Math.max(1, options.maxConnections ?? DEFAULT_MAX_CONNECTIONS);
7732
+ this.socketQueueLimit = Math.max(1, options.socketQueueLimit ?? DEFAULT_SOCKET_QUEUE_LIMIT);
7733
+ this.highWaterMarkBytes = Math.max(1, options.highWaterMarkBytes ?? DEFAULT_HIGH_WATER_MARK_BYTES);
7734
+ this.heartbeatIntervalMs = Math.max(1, options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS);
7735
+ this.webSockets = new WebSocketServer({
7736
+ noServer: true,
7737
+ clientTracking: false,
7738
+ maxPayload: Math.max(1, options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES)
7739
+ });
6190
7740
  }
6191
7741
  async start() {
6192
7742
  this.remoteEnabled = this.options.database.getSetting("remote_enabled") === "true";
@@ -6200,8 +7750,9 @@ var RemoteApiServer = class {
6200
7750
  await this.bind(loopbackFor(this.configuredHost), this.configuredPort);
6201
7751
  }
6202
7752
  this.unsubscribeEvents = this.options.events.subscribe((event) => {
6203
- this.broadcastQueue = this.broadcastQueue.then(() => this.broadcast(event)).catch(() => void 0);
7753
+ this.enqueueBroadcast(event);
6204
7754
  });
7755
+ this.startHeartbeat();
6205
7756
  this.options.chat.startEvents(this.eventsAbort.signal, (event) => {
6206
7757
  this.options.events.emit(event);
6207
7758
  });
@@ -6210,9 +7761,15 @@ var RemoteApiServer = class {
6210
7761
  this.eventsAbort.abort();
6211
7762
  this.unsubscribeEvents?.();
6212
7763
  this.unsubscribeEvents = void 0;
6213
- await this.broadcastQueue;
7764
+ this.clearChangeTimer();
7765
+ if (this.heartbeatTimer !== void 0) {
7766
+ clearInterval(this.heartbeatTimer);
7767
+ this.heartbeatTimer = void 0;
7768
+ }
7769
+ await this.drainAll(STOP_DRAIN_TIMEOUT_MS);
6214
7770
  await this.closeCurrent();
6215
7771
  this.webSockets.close();
7772
+ setWsConnections(0);
6216
7773
  }
6217
7774
  status(operationId) {
6218
7775
  const operation = operationId === void 0 ? void 0 : this.operations.get(operationId);
@@ -6265,11 +7822,21 @@ var RemoteApiServer = class {
6265
7822
  };
6266
7823
  this.operations.set(operation.id, operation);
6267
7824
  this.changing = true;
6268
- setTimeout(() => {
7825
+ this.clearChangeTimer();
7826
+ this.changeTimer = setTimeout(() => {
7827
+ this.changeTimer = void 0;
6269
7828
  this.performChange(operation);
6270
7829
  }, 75);
7830
+ this.changeTimer.unref?.();
6271
7831
  return publicOperation(operation);
6272
7832
  }
7833
+ /** 取消还没触发的监听变更。幂等:没定时器就什么都不做。 */
7834
+ clearChangeTimer() {
7835
+ if (this.changeTimer === void 0) return;
7836
+ clearTimeout(this.changeTimer);
7837
+ this.changeTimer = void 0;
7838
+ this.changing = false;
7839
+ }
6273
7840
  async performChange(operation) {
6274
7841
  const previousHost = this.host;
6275
7842
  const previousPort = this.actualPort;
@@ -6327,7 +7894,7 @@ var RemoteApiServer = class {
6327
7894
  }
6328
7895
  async createBoundServer(host, port) {
6329
7896
  const server = createServer((req, res) => {
6330
- this.options.handle(req, res).catch((error) => failResponse(res, error));
7897
+ Promise.resolve().then(() => this.options.handle(req, res)).catch((error) => failResponse(res, error));
6331
7898
  });
6332
7899
  server.requestTimeout = 3e4;
6333
7900
  server.headersTimeout = 1e4;
@@ -6349,14 +7916,42 @@ var RemoteApiServer = class {
6349
7916
  port: server.address().port
6350
7917
  };
6351
7918
  }
7919
+ /**
7920
+ * 关掉当前监听与全部连接,**有界**。
7921
+ *
7922
+ * `server.close()` 的回调要等所有连接结束;而**升级过的 WebSocket 不在
7923
+ * `closeAllConnections()` 的管理范围里**(HTTP 层已经不跟踪它了)。于是只要有一条
7924
+ * 连接读不动(暂停读 / 半开),`close()` 就永远等不到回调 —— 插件卸载会挂死。
7925
+ *
7926
+ * 所以这里的顺序是:先礼后兵 —— 发 1012(客户端会看到「服务端重绑」而不是异常断开),
7927
+ * 等一小段宽限期;宽限期到就 `terminate()`,同时让 `close()` 的等待一起收尾。
7928
+ */
6352
7929
  async closeCurrent() {
6353
7930
  const server = this.server;
6354
7931
  if (server === void 0) return;
6355
7932
  this.server = void 0;
6356
- for (const socket of this.sockets.keys()) socket.close(1012, "Listener rebinding");
7933
+ const pending = [...this.sockets.keys()];
7934
+ for (const socket of pending) try {
7935
+ socket.close(1012, "Listener rebinding");
7936
+ } catch {}
6357
7937
  this.sockets.clear();
7938
+ setWsConnections(0);
6358
7939
  await new Promise((resolve) => {
6359
- server.close(() => resolve());
7940
+ let settled = false;
7941
+ const done = () => {
7942
+ if (settled) return;
7943
+ settled = true;
7944
+ clearTimeout(grace);
7945
+ resolve();
7946
+ };
7947
+ const grace = setTimeout(() => {
7948
+ for (const socket of pending) try {
7949
+ socket.terminate();
7950
+ } catch {}
7951
+ done();
7952
+ }, CLOSE_GRACE_MS);
7953
+ grace.unref?.();
7954
+ server.close(done);
6360
7955
  server.closeAllConnections();
6361
7956
  });
6362
7957
  }
@@ -6369,27 +7964,84 @@ var RemoteApiServer = class {
6369
7964
  const protocolToken = parseProtocolToken(req.headers["sec-websocket-protocol"]);
6370
7965
  if (protocolToken !== void 0) req.headers.authorization = `Bearer ${protocolToken}`;
6371
7966
  const principal = this.options.auth.principal(req, { allowAdmin: true });
7967
+ if (this.sockets.size >= this.maxConnections) {
7968
+ recordWsRejected();
7969
+ socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nRetry-After: 5\r\nContent-Length: 0\r\n\r\n");
7970
+ return;
7971
+ }
6372
7972
  this.webSockets.handleUpgrade(req, socket, head, (webSocket) => {
6373
- this.sockets.set(webSocket, principal);
7973
+ const state = {
7974
+ principal,
7975
+ queue: [],
7976
+ draining: false,
7977
+ dropped: 0,
7978
+ resyncPending: false,
7979
+ isAlive: true,
7980
+ overHighWater: 0,
7981
+ devicesRevision: this.devicesRevisionOf(),
7982
+ recheckTimer: void 0
7983
+ };
7984
+ this.sockets.set(webSocket, state);
7985
+ setWsConnections(this.sockets.size);
7986
+ webSocket.on("pong", () => {
7987
+ state.isAlive = true;
7988
+ });
6374
7989
  webSocket.on("close", () => {
7990
+ this.clearSocketTimers(state);
6375
7991
  this.sockets.delete(webSocket);
7992
+ setWsConnections(this.sockets.size);
6376
7993
  });
6377
7994
  webSocket.on("error", () => {
7995
+ this.clearSocketTimers(state);
6378
7996
  this.sockets.delete(webSocket);
7997
+ setWsConnections(this.sockets.size);
6379
7998
  });
6380
7999
  webSocket.send(stringifyWire({
6381
8000
  id: randomUUID(),
6382
8001
  type: "connection.ready",
6383
8002
  time: Date.now(),
6384
- data: connectionReadyData()
8003
+ data: connectionReadyData(this.options.capabilities ?? {})
6385
8004
  }));
6386
- this.replayPendingInteractions(webSocket, principal);
8005
+ this.replayPendingInteractions(webSocket, state);
6387
8006
  });
6388
8007
  } catch {
6389
8008
  socket.destroy();
6390
8009
  }
6391
8010
  }
6392
8011
  /**
8012
+ * 心跳循环:每 `heartbeatIntervalMs` 检查一次存活标记。
8013
+ *
8014
+ * 时序要看清:`isAlive` **先读后置假**,并且只在本轮「活着」时才补一次 `ping`。
8015
+ * 于是:
8016
+ * - 本轮活着 → 标记为可疑,再 ping 一次;
8017
+ * - 本轮仍可疑(上一轮 ping 没换来 pong)→ `terminate()`。
8018
+ *
8019
+ * 结果是**连续错过两个周期**才踢人。偶发丢一个包不会误杀在线设备,真正断掉的
8020
+ * (手机切网 / 熄屏后 NAT 表项过期)最多两个周期就清掉 —— 而修复前它只能等
8021
+ * TCP 自己超时,等于「半开连接长期占着额度,还在每轮广播里被白查一次库」。
8022
+ *
8023
+ * `unref()` 必需:这个定时器永不结束,不 unref 会让进程退不掉。
8024
+ */
8025
+ startHeartbeat() {
8026
+ if (this.heartbeatTimer !== void 0) return;
8027
+ this.heartbeatTimer = setInterval(() => {
8028
+ for (const [socket, state] of this.sockets) {
8029
+ if (!state.isAlive) {
8030
+ recordWsTerminated();
8031
+ this.sockets.delete(socket);
8032
+ setWsConnections(this.sockets.size);
8033
+ socket.terminate();
8034
+ continue;
8035
+ }
8036
+ state.isAlive = false;
8037
+ try {
8038
+ socket.ping();
8039
+ } catch {}
8040
+ }
8041
+ }, this.heartbeatIntervalMs);
8042
+ this.heartbeatTimer.unref?.();
8043
+ }
8044
+ /**
6393
8045
  * 把待处理的**审批与提问**补推给刚建立的这条连接。
6394
8046
  *
6395
8047
  * 为什么要有这一步:「连接后要不要主动查一次 REST」是**每个客户端自己的选择**。
@@ -6398,43 +8050,213 @@ var RemoteApiServer = class {
6398
8050
  * 重连窗口里产生的审批就会静默消失(内核阻塞着等决定、手机上却一片安静)。
6399
8051
  * 重放把「新连接一定能拿到仍待处理的审批」变成**服务端保证**,而不是客户端的义务。
6400
8052
  *
6401
- * 三个刻意的取舍:
8053
+ * 四个刻意的取舍:
6402
8054
  * - **不 await**:重放要按会话做授权过滤(异步),握手不能为它排队;
6403
8055
  * - **只发这一条连接**:不走 `broadcast`,避免每次有人重连就骚扰所有在线设备;
6404
8056
  * - **失败静默**:重放是补救措施,它出错不该影响这条连接正常收事件。
6405
- * 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖。
8057
+ * 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖;
8058
+ * - **走连接自己的队列**:重放帧与随后的广播帧必须保序。直接 `socket.send`
8059
+ * 会让「重放的第 2 帧」插到「广播的第 1 帧」前面去。
6406
8060
  */
6407
- async replayPendingInteractions(socket, principal) {
8061
+ async replayPendingInteractions(socket, state) {
6408
8062
  try {
6409
- const events = [...await this.options.chat.replayPendingApprovals(principal), ...await this.options.chat.replayPendingQuestions(principal)];
6410
- for (const event of events) {
6411
- if (socket.readyState !== WebSocket.OPEN) return;
6412
- socket.send(stringifyWire(event));
6413
- }
8063
+ const events = [...await this.options.chat.replayPendingApprovals(state.principal), ...await this.options.chat.replayPendingQuestions(state.principal)];
8064
+ for (const event of events) this.enqueue(socket, state, event);
6414
8065
  } catch {}
6415
8066
  }
6416
- async broadcast(event) {
6417
- for (const [socket, principal] of this.sockets) {
8067
+ /** 当前设备授权版本;`database` 还没提供就返回 undefined(退回逐事件查库的旧路径)。 */
8068
+ devicesRevisionOf() {
8069
+ const value = this.options.database.devicesRevision;
8070
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
8071
+ }
8072
+ /**
8073
+ * 广播入口:**只做快照 + 入队**,全程同步。
8074
+ *
8075
+ * 这里是宿主事件循环上的热路径 —— 订阅回调里的任何 `await`(查库、查内核、
8076
+ * 发网络)都会反过来把宿主的 `push` 拖住。修复前正是这样:一条全局 promise 链
8077
+ * 串起「事件 × 连接」,一个慢判定让所有事件、所有客户端一起排队,`stop()`
8078
+ * 还得 `await` 那条链。
8079
+ */
8080
+ enqueueBroadcast(event) {
8081
+ recordBroadcastEvent();
8082
+ let deepest = 0;
8083
+ for (const [socket, state] of this.sockets) {
6418
8084
  if (socket.readyState !== WebSocket.OPEN) continue;
6419
- let currentPrincipal = principal;
6420
- if (principal.kind === "device") {
6421
- const device = this.options.database.getActiveDevice(principal.id);
6422
- if (device === void 0) {
6423
- socket.close(4001, "Device revoked");
6424
- continue;
6425
- }
6426
- currentPrincipal = {
6427
- kind: "device",
6428
- id: device.id,
6429
- name: device.name,
6430
- scopes: new Set(device.scopes),
6431
- rootIds: new Set(device.rootIds)
6432
- };
6433
- this.sockets.set(socket, currentPrincipal);
8085
+ this.enqueue(socket, state, event);
8086
+ if (state.queue.length > deepest) deepest = state.queue.length;
8087
+ }
8088
+ setBroadcastQueueDepth(deepest);
8089
+ }
8090
+ /**
8091
+ * 单连接入队:溢出时按「先丢可重建帧」处理,并给客户端一个 resync 信号。
8092
+ *
8093
+ * 为什么溢出不断连:手机端解码慢一次不该被踢下线(那会触发一条重连风暴,
8094
+ * 而重连风暴又会把刚建好的连接再挤掉)。但「静默丢帧」更糟 —— 客户端会以为
8095
+ * 事件流还连续,于是它手里的状态永远停在丢帧之前。所以丢的时候记一笔,
8096
+ * 随后补发 `connection.resyncRequired`,把「静默」变成「告知」。
8097
+ */
8098
+ enqueue(socket, state, event) {
8099
+ if (state.queue.length >= this.socketQueueLimit) {
8100
+ if (isRebuildable(event)) {
8101
+ state.dropped += 1;
8102
+ recordDroppedEvent();
8103
+ state.resyncPending = true;
8104
+ return;
8105
+ }
8106
+ const victimIndex = state.queue.findIndex(isRebuildable);
8107
+ if (victimIndex >= 0) {
8108
+ state.queue.splice(victimIndex, 1);
8109
+ state.dropped += 1;
8110
+ recordDroppedEvent();
8111
+ } else {
8112
+ state.queue.shift();
8113
+ state.dropped += 1;
8114
+ recordDroppedEvent();
8115
+ }
8116
+ state.resyncPending = true;
8117
+ }
8118
+ state.queue.push(event);
8119
+ this.startDrain(socket, state);
8120
+ }
8121
+ /** 保证一条连接只有一个 drain 循环(否则同一连接内的顺序会被打乱)。 */
8122
+ startDrain(socket, state) {
8123
+ if (state.draining) return;
8124
+ state.draining = true;
8125
+ const task = this.drain(socket, state).catch(() => void 0).finally(() => {
8126
+ state.draining = false;
8127
+ this.pendingDrains.delete(task);
8128
+ if (state.recheckTimer !== void 0) return;
8129
+ if (state.queue.length > 0 && this.sockets.get(socket) === state) this.startDrain(socket, state);
8130
+ });
8131
+ this.pendingDrains.add(task);
8132
+ }
8133
+ /**
8134
+ * 一条连接的发送循环。
8135
+ *
8136
+ * `await` 只发生在「这条连接自己的准备工作」上(刷新设备身份、判断会话授权)。
8137
+ * 于是慢连接只慢它自己 —— 这正是与修复前的根本区别。
8138
+ */
8139
+ async drain(socket, state) {
8140
+ while (state.queue.length > 0) {
8141
+ if (socket.readyState !== WebSocket.OPEN) {
8142
+ state.queue.length = 0;
8143
+ return;
8144
+ }
8145
+ if (socket.bufferedAmount > this.highWaterMarkBytes) {
8146
+ if (this.noteHighWater(socket, state)) return;
8147
+ return;
8148
+ }
8149
+ state.overHighWater = 0;
8150
+ if (state.resyncPending) {
8151
+ state.resyncPending = false;
8152
+ this.sendFrame(socket, {
8153
+ id: randomUUID(),
8154
+ type: "connection.resyncRequired",
8155
+ time: Date.now(),
8156
+ data: {
8157
+ dropped: state.dropped,
8158
+ reason: "backpressure"
8159
+ }
8160
+ });
8161
+ state.dropped = 0;
8162
+ continue;
8163
+ }
8164
+ const event = state.queue.shift();
8165
+ const principal = await this.refreshPrincipal(socket, state);
8166
+ if (principal === void 0) {
8167
+ state.queue.length = 0;
8168
+ socket.close(4001, "Device revoked");
8169
+ return;
8170
+ }
8171
+ if (!await this.eventAllowed(principal, event)) continue;
8172
+ if (socket.readyState !== WebSocket.OPEN) {
8173
+ state.queue.length = 0;
8174
+ return;
6434
8175
  }
6435
- if (!await this.eventAllowed(currentPrincipal, event)) continue;
6436
- socket.send(stringifyWire(event));
8176
+ this.sendFrame(socket, event);
6437
8177
  }
8178
+ if (socket.readyState === WebSocket.OPEN && socket.bufferedAmount > this.highWaterMarkBytes) this.noteHighWater(socket, state);
8179
+ }
8180
+ /**
8181
+ * 记录一次「越过发送高水位」,并安排复查。
8182
+ *
8183
+ * @returns 这条连接是否已经被关掉(调用方据此决定还要不要继续 drain)。
8184
+ *
8185
+ * 两拍才关是刻意的:一次越线可能只是客户端的短暂抖动(切网、GC),
8186
+ * 立刻踢下线会制造重连风暴。但**必须有复查** —— 否则「第二拍」永远不会发生,
8187
+ * 连接就卡在「不发也不关」的僵尸态,比直接断掉更难排查。
8188
+ */
8189
+ noteHighWater(socket, state) {
8190
+ state.overHighWater += 1;
8191
+ if (state.overHighWater >= 2) {
8192
+ this.closeBackpressured(socket, state);
8193
+ return true;
8194
+ }
8195
+ if (state.recheckTimer !== void 0) return false;
8196
+ state.recheckTimer = setTimeout(() => {
8197
+ state.recheckTimer = void 0;
8198
+ if (this.sockets.get(socket) !== state) return;
8199
+ if (socket.readyState === WebSocket.OPEN) this.startDrain(socket, state);
8200
+ }, HIGH_WATER_RECHECK_MS);
8201
+ return false;
8202
+ }
8203
+ sendFrame(socket, payload) {
8204
+ try {
8205
+ socket.send(stringifyWire(payload));
8206
+ } catch {}
8207
+ }
8208
+ /** 清掉这条连接自己的定时器。**必须**在连接被摘掉时调用,否则定时器会拖住进程。 */
8209
+ clearSocketTimers(state) {
8210
+ if (state.recheckTimer === void 0) return;
8211
+ clearTimeout(state.recheckTimer);
8212
+ state.recheckTimer = void 0;
8213
+ }
8214
+ closeBackpressured(socket, state) {
8215
+ this.clearSocketTimers(state);
8216
+ const dropped = state.dropped;
8217
+ state.queue.length = 0;
8218
+ state.dropped = 0;
8219
+ state.resyncPending = false;
8220
+ recordWsTerminated();
8221
+ this.sockets.delete(socket);
8222
+ setWsConnections(this.sockets.size);
8223
+ this.sendFrame(socket, {
8224
+ id: randomUUID(),
8225
+ type: "connection.resyncRequired",
8226
+ time: Date.now(),
8227
+ data: {
8228
+ dropped,
8229
+ reason: "backpressure"
8230
+ }
8231
+ });
8232
+ socket.close(1013, "Backpressure");
8233
+ }
8234
+ /**
8235
+ * 按需刷新这条连接的身份快照。
8236
+ *
8237
+ * 有 `devicesRevision` 时:只有版本变了才查库 —— 正常情况**一件事都不做**,
8238
+ * 而修复前是「每个事件、每条连接都查一次 `getActiveDevice`」。
8239
+ * 没有该 getter 时退回旧行为(逐事件查库):老数据库上功能不变,只是慢一点。
8240
+ * 这比「广播路径依赖一个可能不存在的 API」安全得多,也让两侧改动可以分批落地。
8241
+ *
8242
+ * @returns 最新身份;设备已被撤销时返回 undefined。
8243
+ */
8244
+ async refreshPrincipal(socket, state) {
8245
+ if (state.principal.kind !== "device") return state.principal;
8246
+ const revision = this.devicesRevisionOf();
8247
+ if (revision !== void 0 && revision === state.devicesRevision) return state.principal;
8248
+ const device = this.options.database.getActiveDevice(state.principal.id);
8249
+ if (device === void 0) return void 0;
8250
+ const next = {
8251
+ kind: "device",
8252
+ id: device.id,
8253
+ name: device.name,
8254
+ scopes: new Set(device.scopes),
8255
+ rootIds: new Set(device.rootIds)
8256
+ };
8257
+ state.principal = next;
8258
+ state.devicesRevision = revision;
8259
+ return next;
6438
8260
  }
6439
8261
  async eventAllowed(principal, event) {
6440
8262
  if (event.type.startsWith("file.") && !principal.scopes.has("files.read")) return false;
@@ -6445,6 +8267,20 @@ var RemoteApiServer = class {
6445
8267
  if (sessionId !== void 0) return this.options.chat.isSessionAuthorized(principal, sessionId);
6446
8268
  return rootId !== void 0 || principal.kind === "admin";
6447
8269
  }
8270
+ /**
8271
+ * 等所有连接排空,**有上限**。
8272
+ *
8273
+ * `stop()` 不能无限等:一条读不动的 socket 会让插件卸载挂死,进而拖住进程退出。
8274
+ * 超时后照样会断开它们(`closeCurrent` 负责),只是不再尝试把队列里的东西送出去 ——
8275
+ * 卸载路径上「送达」本来就不是目标。
8276
+ */
8277
+ async drainAll(timeoutMs) {
8278
+ if (this.pendingDrains.size === 0) return;
8279
+ const all = Promise.all([...this.pendingDrains]).then(() => void 0);
8280
+ await Promise.race([all, new Promise((resolve) => {
8281
+ setTimeout(resolve, timeoutMs).unref?.();
8282
+ })]);
8283
+ }
6448
8284
  trimOperations() {
6449
8285
  while (this.operations.size > 20) {
6450
8286
  const first = this.operations.keys().next().value;
@@ -6454,6 +8290,7 @@ var RemoteApiServer = class {
6454
8290
  }
6455
8291
  };
6456
8292
  function attachEmbeddedRoutes(webServer, router, remote, extraRoutes = []) {
8293
+ assertExtraRoutePrefixes(extraRoutes);
6457
8294
  const disposers = [webServer.register({
6458
8295
  kind: "prefix",
6459
8296
  path: "/dsh-workspace-api",
@@ -6463,6 +8300,29 @@ function attachEmbeddedRoutes(webServer, router, remote, extraRoutes = []) {
6463
8300
  for (const dispose of disposers.reverse()) dispose();
6464
8301
  };
6465
8302
  }
8303
+ /**
8304
+ * `extraRoutes` 只许挂在 `/plugin/` 下 —— **启动即断言**,违例直接抛。
8305
+ *
8306
+ * ## 为什么这是一条启动断言,而不是一句注释
8307
+ *
8308
+ * `extraRoutes` 的全部理由是「浏览器 `<img src>` 带不了 `Authorization` 头」,
8309
+ * 所以那条路由不校验设备 token,改用「回环 + 同源 + 裸文件名白名单 + 字节自证」。
8310
+ * 那是一套**为只读图片量身定的**信任模型。
8311
+ *
8312
+ * 只要有人往这个座位里塞一条别的东西(比如一个能读文件的接口),那套模型就
8313
+ * 立刻成了漏洞:免令牌 + 只有回环判据,等于任何能从本机发起请求的页面都能用它。
8314
+ * 而这种事**不会在 dev 环境暴露** —— 本机回环一切正常,装到用户机器上才变成
8315
+ * 「谁都能读」。
8316
+ *
8317
+ * 所以把「只许 `/plugin/`」写成断言而不是注释:注释管不住下一个人,
8318
+ * 断言会在插件**启动时**立刻红,且在 CI 里红在测试而不是用户机器上。
8319
+ *
8320
+ * `/dsh-workspace-api` 这个**既有**前缀不受影响:它是上面那条固定路由,
8321
+ * 不经过 `extraRoutes`。
8322
+ */
8323
+ function assertExtraRoutePrefixes(extraRoutes) {
8324
+ 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.`);
8325
+ }
6466
8326
  function publicOperation(operation) {
6467
8327
  return {
6468
8328
  id: operation.id,
@@ -6507,15 +8367,26 @@ function parseProtocolToken(value) {
6507
8367
  if (joined === void 0) return void 0;
6508
8368
  return joined.split(",").map((item) => item.trim()).find((item) => item.startsWith("bearer."))?.slice(7);
6509
8369
  }
8370
+ /**
8371
+ * 500 兜底。
8372
+ *
8373
+ * `requestId` **必须**复用 router 已经写进 `X-Request-Id` 响应头的那个值:
8374
+ * 用户报障时给的是 body 里的 id,而运维翻日志靠的是头里的 id —— 两个不同的
8375
+ * UUID 等于「这条线索永远搜不到」。修复前这里**另生成一个** `randomUUID()`。
8376
+ *
8377
+ * 头不存在时(理论上只有 router 之外的调用方)才新生成,保证字段不为空。
8378
+ */
6510
8379
  function failResponse(res, error) {
6511
8380
  if (res.headersSent) {
6512
8381
  res.destroy(error instanceof Error ? error : void 0);
6513
8382
  return;
6514
8383
  }
8384
+ const headerValue = res.getHeader("x-request-id");
8385
+ const requestId = typeof headerValue === "string" && headerValue !== "" ? headerValue : randomUUID();
6515
8386
  const body = Buffer.from(JSON.stringify({ error: {
6516
8387
  code: "INTERNAL_ERROR",
6517
8388
  message: "An internal server error occurred.",
6518
- requestId: randomUUID()
8389
+ requestId
6519
8390
  } }));
6520
8391
  res.writeHead(500, {
6521
8392
  "Content-Type": "application/json",
@@ -6525,6 +8396,15 @@ function failResponse(res, error) {
6525
8396
  }
6526
8397
  //#endregion
6527
8398
  //#region src/host/settings-adapter.ts
8399
+ /**
8400
+ * provider 上下文的复用窗口。
8401
+ *
8402
+ * 1 秒足以覆盖「同一个 PATCH 请求里的连续两次 context 查询」,又短到不会让
8403
+ * 用户可感知地读到旧值(真正的并发控制由 `expectedRevision` 负责)。
8404
+ */
8405
+ const PROVIDER_CONTEXT_TTL_MS = 1e3;
8406
+ /** 缓存条目上限;到顶就整表清空(条目数与 provider 数量同阶,很小)。 */
8407
+ const PROVIDER_CONTEXT_CACHE_LIMIT = 128;
6528
8408
  var DshSettingsAdapter = class {
6529
8409
  api;
6530
8410
  constructor(api) {
@@ -6675,6 +8555,7 @@ var DshSettingsAdapter = class {
6675
8555
  }
6676
8556
  }));
6677
8557
  }
8558
+ this.invalidateProviderContext(providerId);
6678
8559
  }
6679
8560
  async createProvider(input) {
6680
8561
  const id = input.id.trim();
@@ -6725,6 +8606,7 @@ var DshSettingsAdapter = class {
6725
8606
  value: key
6726
8607
  }
6727
8608
  }));
8609
+ this.invalidateProviderContext(id);
6728
8610
  }
6729
8611
  async removeProvider(providerId, expectedRevision) {
6730
8612
  const context = await this.providerContext(providerId);
@@ -6741,6 +8623,7 @@ var DshSettingsAdapter = class {
6741
8623
  ...expectedRevision === void 0 ? {} : { expectedRevision }
6742
8624
  }
6743
8625
  }));
8626
+ this.invalidateProviderContext(providerId);
6744
8627
  }
6745
8628
  async discover(providerId, draft) {
6746
8629
  const payload = {
@@ -6755,7 +8638,47 @@ var DshSettingsAdapter = class {
6755
8638
  payload
6756
8639
  })).models;
6757
8640
  }
8641
+ /**
8642
+ * 取一个 provider 的上下文(目录条目 + 设置命名空间 + 当前 profile)。
8643
+ *
8644
+ * ## 为什么加一层「单请求内复用」
8645
+ *
8646
+ * 修复前 `updateProvider()` 会**连续**调它两次(一次在开头做校验、一次在
8647
+ * `createProvider`/`removeProvider` 里),而每次 `providerContext` 都要打
8648
+ * **两次**内核 RPC(`llm.providers` + `settings.describe`)。于是一次 PATCH
8649
+ * 实际可能产生 4~5 次内核往返 —— 而它们问的是**同一份数据**。
8650
+ *
8651
+ * 这里用一个极短的 TTL(默认 1 秒,覆盖「同一请求内」的连续调用)缓存结果。
8652
+ * 为什么不用「请求作用域」的显式传参:那要改每个方法的签名,而调用点散在
8653
+ * router 与 adapter 两处 —— 收益远不抵这次改动。1 秒 TTL 的副作用是:
8654
+ * 同一秒内两次改同一个 provider 时,第二次读到的是第一次之前的快照 ——
8655
+ * 而 `settings.mutate` 有 `expectedRevision` 做真正的并发控制,所以不会写坏。
8656
+ */
8657
+ providerContextCache = /* @__PURE__ */ new Map();
6758
8658
  async providerContext(providerId) {
8659
+ const cached = this.providerContextCache.get(providerId);
8660
+ const now = Date.now();
8661
+ if (cached !== void 0 && now - cached.at < PROVIDER_CONTEXT_TTL_MS) return cached.value;
8662
+ const value = await this.loadProviderContext(providerId);
8663
+ if (this.providerContextCache.size >= PROVIDER_CONTEXT_CACHE_LIMIT) this.providerContextCache.clear();
8664
+ this.providerContextCache.set(providerId, {
8665
+ at: now,
8666
+ value
8667
+ });
8668
+ return value;
8669
+ }
8670
+ /**
8671
+ * 写操作之后**必须**让缓存失效。
8672
+ *
8673
+ * 为什么不能只靠 TTL:`updateProvider` 改完 baseURL 之后,同一个请求尾部的
8674
+ * `listProviders()` 要给出新值。靠 TTL 等 1 秒等于把一个「刚刚改成功」的
8675
+ * 界面回滚成旧值 —— 那会被用户当成「改了没生效」。
8676
+ */
8677
+ invalidateProviderContext(providerId) {
8678
+ if (providerId === void 0) this.providerContextCache.clear();
8679
+ else this.providerContextCache.delete(providerId);
8680
+ }
8681
+ async loadProviderContext(providerId) {
6759
8682
  const [directoryResponse, settingsResponse] = await Promise.all([this.api.llm.providers({
6760
8683
  rpcId: randomUUID(),
6761
8684
  payload: {}
@@ -6914,9 +8837,11 @@ async function apply(ctx, config = {}) {
6914
8837
  } catch (error) {
6915
8838
  ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: could not purge revoked devices: ${String(error)}`));
6916
8839
  }
8840
+ runStartupRetention(database, ctx.logger);
6917
8841
  await registerBuiltinWorkspaceRoot(ctx, database);
6918
8842
  const events = new WorkspaceEventBus();
6919
- const auth = new AuthService(database);
8843
+ const tunnels = new CloudflaredTunnelManager();
8844
+ const auth = new AuthService(database, { tunnelActive: () => tunnels.tunnelActive });
6920
8845
  const files = new FileService(database, (event) => events.emit(event));
6921
8846
  const imageHandoffDir = path.join(dataDir, "image-handoff");
6922
8847
  const handoffRootsOf = () => database.listRoots().map((root) => ({
@@ -6954,7 +8879,8 @@ async function apply(ctx, config = {}) {
6954
8879
  auth,
6955
8880
  chat,
6956
8881
  events,
6957
- handle: (req, res) => router.handle(req, res)
8882
+ handle: (req, res) => router.handle(req, res),
8883
+ capabilities: { jobs: jobControllerAvailable }
6958
8884
  });
6959
8885
  router = new ApiRouter({
6960
8886
  database,
@@ -6963,7 +8889,7 @@ async function apply(ctx, config = {}) {
6963
8889
  chat,
6964
8890
  settings,
6965
8891
  remote,
6966
- tunnels: new CloudflaredTunnelManager(),
8892
+ tunnels,
6967
8893
  cloudflaredBinDir: path.join(dataDir, "cloudflared-bin"),
6968
8894
  maxUploadBytes,
6969
8895
  imageFallbackDir: imageHandoffDir,