dsh-remote-bridge 2.5.1 → 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/README.en.md +21 -19
- package/README.md +17 -14
- package/docs/api/asyncapi.yaml +51 -2
- package/docs/api/openapi.yaml +187 -4
- package/lib/client.cjs +1101 -509
- package/lib/index.d.ts +409 -13
- package/lib/index.js +1993 -284
- package/package.json +1 -1
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 {
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
6
5
|
import os, { homedir, hostname, networkInterfaces } from "node:os";
|
|
6
|
+
import { createReadStream, mkdirSync } from "node:fs";
|
|
7
7
|
import { DatabaseSync } from "node:sqlite";
|
|
8
8
|
import { pipeline } from "node:stream/promises";
|
|
9
9
|
import { isIP } from "node:net";
|
|
@@ -46,6 +46,23 @@ function asApiError(error) {
|
|
|
46
46
|
//#endregion
|
|
47
47
|
//#region src/host/image-handoff.ts
|
|
48
48
|
/**
|
|
49
|
+
* 图片交接的「识图路径」:目录名从哪来、图片怎么被安全地端到浏览器面前。
|
|
50
|
+
*
|
|
51
|
+
* 两件事合在一个模块里,因为它们是同一个概念的里外两面:
|
|
52
|
+
*
|
|
53
|
+
* 1. **设置**(`识图路径`):纯文本模型发图时,图片落在「会话工作目录 + <识图路径>」下。
|
|
54
|
+
* 出厂默认是 [DEFAULT_HANDOFF_SUBDIR],设置面板里把这一栏**只读**展示(用户要求:
|
|
55
|
+
* 「当前默认(不能改)」),真正可编辑的是那个目录名本身,存在 settings 表里。
|
|
56
|
+
* 2. **只读路由**(从 dsh-image-paths 0.3.1 并进来的那半边):浏览器 `<img>` 不能带
|
|
57
|
+
* Authorization 头,所以走不了版本化 REST 的鉴权通道。这条路由改用「回环 + 同源 +
|
|
58
|
+
* 裸文件名白名单 + 字节自证」的模型;**目录不由客户端给**,而是按会话工作目录由宿主
|
|
59
|
+
* 自己推出(与写盘用的是同一个解析),于是「指一个路径去读本地文件」这件事在参数
|
|
60
|
+
* 层面就不成立 —— 这是比 image-paths 原版更紧的一步。
|
|
61
|
+
*
|
|
62
|
+
* 威胁模型与 image-paths 一致:webserver 绑回环,跨站请求拒绝,返回的每个字节都先过了
|
|
63
|
+
* 「文件名白名单 + 扩展名声明 + 内容自证 + 体积上限」四道闸。
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
49
66
|
* 交接目录的**出厂默认**。
|
|
50
67
|
*
|
|
51
68
|
* 设置面板里「当前默认」那一栏展示的就是这个字面量,且是只读的 —— 它是这份代码的
|
|
@@ -222,6 +239,17 @@ function sameOrigin(req) {
|
|
|
222
239
|
return false;
|
|
223
240
|
}
|
|
224
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* 内容是够与文件名里的哈希一致。
|
|
244
|
+
*
|
|
245
|
+
* 文件名形如 `<64 hex>.<ext>`(`HANDOFF_FILE_NAME` 已保证),取前 64 字符当期望值。
|
|
246
|
+
* `message` 前缀(`sha256:`)不需要处理 —— 白名单正则里没有冒号。
|
|
247
|
+
*/
|
|
248
|
+
function contentMatchesName(bytes, name) {
|
|
249
|
+
const expected = name.slice(0, 64).toLowerCase();
|
|
250
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) return false;
|
|
251
|
+
return createHash("sha256").update(bytes).digest("hex") === expected;
|
|
252
|
+
}
|
|
225
253
|
/** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
|
|
226
254
|
function refuse(res, status, reason) {
|
|
227
255
|
const body = `${reason}\n`;
|
|
@@ -285,6 +313,10 @@ function createImageRouteHandler(options) {
|
|
|
285
313
|
refuse(res, 415, "content does not match the file extension");
|
|
286
314
|
return;
|
|
287
315
|
}
|
|
316
|
+
if (!contentMatchesName(image.bytes, name)) {
|
|
317
|
+
refuse(res, 404, "no such handoff image");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
288
320
|
res.writeHead(200, {
|
|
289
321
|
"content-type": sniffed,
|
|
290
322
|
"content-length": String(image.bytes.byteLength),
|
|
@@ -372,9 +404,12 @@ var FixedWindowLimiter = class {
|
|
|
372
404
|
*
|
|
373
405
|
* @param req - 待判定的请求。
|
|
374
406
|
* @param options.tunnelActive - 是否有我们自管的隧道在运行;缺省视为没有。
|
|
375
|
-
* @param options.allowTunnelMode -
|
|
376
|
-
*
|
|
377
|
-
*
|
|
407
|
+
* @param options.allowTunnelMode - 仅用于「隧道状态」与「停止隧道」端点:让它们豁免判据 0
|
|
408
|
+
* (否则隧道一开,面板连「隧道开着」都读不到,也无法体面地把隧道停掉),判据 1/2/3
|
|
409
|
+
* 仍然全部生效。其中 `cloudflare/stop` 是**唯一**豁免的写操作 —— 它的唯一效果就是把
|
|
410
|
+
* 管理面重新锁上(重新拉回判据 0),且判据 1 仍挡住任何经隧道/代理的调用,所以对真实
|
|
411
|
+
* 隧道来源(必带 cf-* 头)不可利用;放行它不构成任何提权。**其他任何 `/manage` 端点
|
|
412
|
+
* (尤其 config/text)都不许传这个选项。**
|
|
378
413
|
*/
|
|
379
414
|
function adminRefusal(req, options = {}) {
|
|
380
415
|
if (options.tunnelActive === true && options.allowTunnelMode !== true) return {
|
|
@@ -394,6 +429,10 @@ function adminRefusal(req, options = {}) {
|
|
|
394
429
|
message: "This operation is available only from the local machine."
|
|
395
430
|
};
|
|
396
431
|
}
|
|
432
|
+
/** 是否存在任何转发头(`adminRefusal` 判据 1 的公开形态,供测试与文档断言复用)。 */
|
|
433
|
+
function hasForwardingHeader(req) {
|
|
434
|
+
return FORWARDING_HEADERS.some((name) => headerPresent(req, name));
|
|
435
|
+
}
|
|
397
436
|
/**
|
|
398
437
|
* 「这个请求不是本机直连」的**全部**证据头。
|
|
399
438
|
*
|
|
@@ -429,16 +468,63 @@ function headerPresent(req, name) {
|
|
|
429
468
|
if (Array.isArray(raw)) return raw.some((value) => value.trim() !== "");
|
|
430
469
|
return raw.trim() !== "";
|
|
431
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* 判定一条请求属于哪一类对端。纯函数(除 `tunnelActive` 探针外不碰任何状态),
|
|
473
|
+
* 供限流分桶与测试直接调用。
|
|
474
|
+
*
|
|
475
|
+
* @param req - 待分类的请求。
|
|
476
|
+
* @param options.tunnelActive - 隧道是否在跑;缺省视为没有。
|
|
477
|
+
*/
|
|
478
|
+
function classifyPeer(req) {
|
|
479
|
+
if (hasForwardingHeader(req)) return "tunnelled";
|
|
480
|
+
return "local";
|
|
481
|
+
}
|
|
432
482
|
var AuthService = class {
|
|
433
483
|
database;
|
|
434
484
|
options;
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
485
|
+
/**
|
|
486
|
+
* 两个「按对端分类」分开计的桶(键 = `<class>:<remoteAddress>`)。
|
|
487
|
+
*
|
|
488
|
+
* 阈值刻意**两边一致**:分桶的目的是「互不连坐」,不是「给本机开后门」。
|
|
489
|
+
* 如果本机侧更宽,那它就成了一条「怎么把自己伪装成本机」的动机 —— 而伪装是免费的
|
|
490
|
+
* (不发头即可),于是更宽的那一侧等于被取消。
|
|
491
|
+
* 阈值相等时,攻击者伪装成 local 只会消耗 local 配额,**没有额外收益**。
|
|
492
|
+
*
|
|
493
|
+
* `requests`:120/60s。`authFailures`:20/5min。
|
|
494
|
+
*/
|
|
495
|
+
authFailuresByPeer = {
|
|
496
|
+
local: new FixedWindowLimiter(20, 3e5),
|
|
497
|
+
tunnelled: new FixedWindowLimiter(20, 3e5)
|
|
498
|
+
};
|
|
499
|
+
/** 配对兑换:本机与隧道分开。隧道侧公网暴力尝试不该锁死本机的配对。 */
|
|
500
|
+
pairingAttemptsByPeer = {
|
|
501
|
+
local: new FixedWindowLimiter(10, 6e5),
|
|
502
|
+
tunnelled: new FixedWindowLimiter(10, 6e5)
|
|
503
|
+
};
|
|
504
|
+
requestsByPeer = {
|
|
505
|
+
local: new FixedWindowLimiter(120, 6e4),
|
|
506
|
+
tunnelled: new FixedWindowLimiter(120, 6e4)
|
|
507
|
+
};
|
|
438
508
|
constructor(database, options = {}) {
|
|
439
509
|
this.database = database;
|
|
440
510
|
this.options = options;
|
|
441
511
|
}
|
|
512
|
+
/** 这套服务当前的对端分类(`tunnelActive` 走 fail-closed 探针)。 */
|
|
513
|
+
peerOf(req) {
|
|
514
|
+
return classifyPeer(req);
|
|
515
|
+
}
|
|
516
|
+
/** 按对端取请求配额桶。 */
|
|
517
|
+
requestLimiter(req) {
|
|
518
|
+
return this.requestsByPeer[this.peerOf(req)];
|
|
519
|
+
}
|
|
520
|
+
/** 按对端取认证失败桶。 */
|
|
521
|
+
failureLimiter(req) {
|
|
522
|
+
return this.authFailuresByPeer[this.peerOf(req)];
|
|
523
|
+
}
|
|
524
|
+
/** 按对端取配对尝试桶(`router.ts` 的 exchange 端点用)。 */
|
|
525
|
+
pairingLimiter(req) {
|
|
526
|
+
return this.pairingAttemptsByPeer[this.peerOf(req)];
|
|
527
|
+
}
|
|
442
528
|
/**
|
|
443
529
|
* 隧道是否在跑。探针抛错时**按「在跑」处理**(fail closed):探针读的是我们自己
|
|
444
530
|
* 对象上的 getter,它抛错意味着状态不可知;状态不可知时放行管理面正是这次修复要
|
|
@@ -455,24 +541,26 @@ var AuthService = class {
|
|
|
455
541
|
}
|
|
456
542
|
principal(req, options = {}) {
|
|
457
543
|
const remote = requestAddress(req);
|
|
458
|
-
this.
|
|
544
|
+
const peer = this.peerOf(req);
|
|
545
|
+
const key = `${peer}:${remote}`;
|
|
546
|
+
this.requestsByPeer[peer].consume(key);
|
|
459
547
|
if (options.allowAdmin === true && req.headers["x-dsh-workspace-admin"] === "1") {
|
|
460
548
|
const refusal = adminRefusal(req, { tunnelActive: this.tunnelRunning() });
|
|
461
549
|
if (refusal !== void 0) {
|
|
462
|
-
this.
|
|
550
|
+
this.authFailuresByPeer[peer].consume(key);
|
|
463
551
|
throw new ApiError(403, refusal.code, refusal.message);
|
|
464
552
|
}
|
|
465
553
|
return ADMIN_PRINCIPAL;
|
|
466
554
|
}
|
|
467
555
|
const authorization = req.headers.authorization;
|
|
468
556
|
if (authorization === void 0 || !authorization.startsWith("Bearer ")) {
|
|
469
|
-
this.
|
|
557
|
+
this.authFailuresByPeer[peer].consume(key);
|
|
470
558
|
throw new ApiError(401, "AUTH_REQUIRED", "A valid Bearer token is required.");
|
|
471
559
|
}
|
|
472
560
|
const token = authorization.slice(7).trim();
|
|
473
561
|
const device = token === "" ? void 0 : this.database.authenticate(token);
|
|
474
562
|
if (device === void 0) {
|
|
475
|
-
this.
|
|
563
|
+
this.authFailuresByPeer[peer].consume(key);
|
|
476
564
|
throw new ApiError(401, "TOKEN_INVALID", "The device token is invalid or revoked.");
|
|
477
565
|
}
|
|
478
566
|
return {
|
|
@@ -550,14 +638,27 @@ function archSuffix(platform = process.platform, arch = process.arch) {
|
|
|
550
638
|
default: throw new Error(`cloudflared 尚不支持 ${process.arch} 架构。`);
|
|
551
639
|
}
|
|
552
640
|
}
|
|
553
|
-
|
|
641
|
+
/**
|
|
642
|
+
* cloudflared 的**唯一**版本号(导出给测试与配置文本用)。
|
|
643
|
+
*
|
|
644
|
+
* ⚠️ 「钉版本」必须只有一处事实来源:2.5.1 及之前 GitHub 基线钉 `2024.11.1`,
|
|
645
|
+
* 而 deb 兜底 URL 里写死了另一个版本 `2026.10.1` —— 同一个函数对「装哪个版本」
|
|
646
|
+
* 给出两个不同答案,等于**没有钉住**。现在 deb URL 也从这里派生。
|
|
647
|
+
*/
|
|
648
|
+
const CLOUDFLARED_VERSION = "2024.11.1";
|
|
649
|
+
const OFFICIAL_BASE = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-`;
|
|
554
650
|
/**
|
|
555
651
|
* 官方 deb 兜底仓库(GitHub 被墙时回退)。
|
|
652
|
+
*
|
|
556
653
|
* `pkg.cloudflare.com` 是 Cloudflare 官方仓库,实测在 GitHub 直连被墙的机器上可达可用
|
|
557
|
-
* (见 PITFALLS §国内下载 cloudflared
|
|
558
|
-
*
|
|
654
|
+
* (见 PITFALLS §国内下载 cloudflared 必须择源)。仅在官方 GitHub 失败时兜底,
|
|
655
|
+
* 不改变「默认先 GitHub」的语义。
|
|
656
|
+
*
|
|
657
|
+
* 版本**从 `CLOUDFLARED_VERSION` 派生**,不再写死第二个版本号。
|
|
559
658
|
*/
|
|
560
|
-
|
|
659
|
+
function debFallbackUrl(debArch) {
|
|
660
|
+
return `https://pkg.cloudflare.com/cloudflared/pool/main/c/cloudflared/cloudflared_${CLOUDFLARED_VERSION}_${debArch}.deb`;
|
|
661
|
+
}
|
|
561
662
|
async function downloadHttps(url, ms, onProgress) {
|
|
562
663
|
const mod = url.startsWith("http:") ? await import("node:http") : await import("node:https");
|
|
563
664
|
return await new Promise((resolve, reject) => {
|
|
@@ -591,6 +692,60 @@ async function downloadHttps(url, ms, onProgress) {
|
|
|
591
692
|
function statusOk(code) {
|
|
592
693
|
return typeof code === "number" && code >= 200 && code < 300;
|
|
593
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* 校验下载到的字节是否匹配期望的 SHA-256。
|
|
697
|
+
*
|
|
698
|
+
* 大小写不敏感、允许 `sha256:` 前缀(常见发行格式)。**长度不对一律 false** ——
|
|
699
|
+
* 不做「宽松接受」:一个 3 字符的期望值只可能是配置错误,接受它就是放弃校验。
|
|
700
|
+
*/
|
|
701
|
+
function verifySha256(bytes, expectedHex) {
|
|
702
|
+
const normalized = expectedHex.trim().replace(/^sha256:/i, "").toLowerCase();
|
|
703
|
+
if (!/^[0-9a-f]{64}$/.test(normalized)) return false;
|
|
704
|
+
return createHash("sha256").update(bytes).digest("hex") === normalized;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* 期望的校验和:读 `CLOUDFLARED_SHA256` 环境变量。
|
|
708
|
+
*
|
|
709
|
+
* 空串/纯空白视为**没有声明**(返回 undefined)—— 否则「随手 export 一个空值」
|
|
710
|
+
* 会让所有下载都被判成不匹配。
|
|
711
|
+
*/
|
|
712
|
+
function expectedSha256From() {
|
|
713
|
+
const raw = process.env.CLOUDFLARED_SHA256?.trim() ?? "";
|
|
714
|
+
return raw === "" ? void 0 : raw;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* 把下载/解包错误**脱敏**成可以对外(与本机日志)说的一行字。
|
|
718
|
+
*
|
|
719
|
+
* ## 为什么需要它
|
|
720
|
+
*
|
|
721
|
+
* 底层错误有三类敏感残留:
|
|
722
|
+
* 1. **完整 URL** —— `downloadHttps` 的 `` `下载失败:${url}(${cause.message})` ``,
|
|
723
|
+
* 而镜像 URL 可能带 token(`https://mirror/token/...`);
|
|
724
|
+
* 2. **绝对路径** —— `execFile('dpkg-deb', ...)` 的错误形如
|
|
725
|
+
* `Command failed: dpkg-deb -x /home/user/.../x.deb /home/user/.../dir`;
|
|
726
|
+
* 3. 两者都可能被 `router.ts` 原样塞进 502 响应体。
|
|
727
|
+
*
|
|
728
|
+
* ## 取舍
|
|
729
|
+
*
|
|
730
|
+
* 保留**可诊断的线索**(host 名、文件名),去掉**不该外传的部分**(完整 URL、目录)。
|
|
731
|
+
* 一句话:能说「哪个源、哪个文件」;不能说「你家目录长什么样、URL 里那个 token 是什么」。
|
|
732
|
+
*/
|
|
733
|
+
function sanitizeDownloadError(message) {
|
|
734
|
+
let safe = message;
|
|
735
|
+
safe = safe.replace(/https?:\/\/[^\s))(("']+/gi, (raw) => {
|
|
736
|
+
try {
|
|
737
|
+
return `host:${new URL(raw).host}`;
|
|
738
|
+
} catch {
|
|
739
|
+
return "host:unknown";
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
safe = safe.replace(/(?:[A-Za-z]:)?(?:\/[^\s::/()()]+)+\/?/g, (raw) => {
|
|
743
|
+
const trimmed = raw.replace(/[\/]+$/, "");
|
|
744
|
+
const base = trimmed.split(/[\\/]/).pop() ?? trimmed;
|
|
745
|
+
return base === "" ? trimmed : base;
|
|
746
|
+
});
|
|
747
|
+
return safe;
|
|
748
|
+
}
|
|
594
749
|
/** 环境变量注入的下载源配置;空则回退官方。 */
|
|
595
750
|
function mirrorBase() {
|
|
596
751
|
return process.env.CLOUDFLARED_MIRROR?.trim() ?? "";
|
|
@@ -641,14 +796,14 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
641
796
|
const existing = path.join(dataDir, "cloudflared");
|
|
642
797
|
report?.({
|
|
643
798
|
stage: "locating",
|
|
644
|
-
|
|
799
|
+
labelKey: "tunnelStageLocating",
|
|
645
800
|
percent: null
|
|
646
801
|
});
|
|
647
802
|
try {
|
|
648
803
|
if ((await stat(existing)).isFile()) {
|
|
649
804
|
report?.({
|
|
650
805
|
stage: "locating",
|
|
651
|
-
|
|
806
|
+
labelKey: "tunnelStageBinaryFound",
|
|
652
807
|
percent: 100
|
|
653
808
|
});
|
|
654
809
|
return existing;
|
|
@@ -662,14 +817,15 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
662
817
|
url: downloadUrlFor(),
|
|
663
818
|
deb: false
|
|
664
819
|
}, {
|
|
665
|
-
url:
|
|
820
|
+
url: debFallbackUrl("amd64"),
|
|
666
821
|
deb: true
|
|
667
822
|
}];
|
|
823
|
+
const expected = expectedSha256From();
|
|
668
824
|
const errors = [];
|
|
669
825
|
for (const { url, deb } of candidates) {
|
|
670
826
|
report?.({
|
|
671
827
|
stage: "downloading",
|
|
672
|
-
|
|
828
|
+
labelKey: deb ? "tunnelStageDownloadDeb" : "tunnelStageDownloadGithub",
|
|
673
829
|
percent: null
|
|
674
830
|
});
|
|
675
831
|
try {
|
|
@@ -680,7 +836,11 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
680
836
|
const all = (total / 1024 / 1024).toFixed(1);
|
|
681
837
|
report?.({
|
|
682
838
|
stage: "downloading",
|
|
683
|
-
|
|
839
|
+
labelKey: "tunnelStageDownloadProgress",
|
|
840
|
+
labelParams: {
|
|
841
|
+
mb,
|
|
842
|
+
all
|
|
843
|
+
},
|
|
684
844
|
percent
|
|
685
845
|
});
|
|
686
846
|
}
|
|
@@ -689,17 +849,26 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
689
849
|
errors.push(`HTTP ${res.status}`);
|
|
690
850
|
continue;
|
|
691
851
|
}
|
|
852
|
+
if (expected !== void 0 && !verifySha256(res.body, expected)) {
|
|
853
|
+
errors.push("SHA-256 mismatch");
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (expected === void 0) report?.({
|
|
857
|
+
stage: "downloading",
|
|
858
|
+
labelKey: "tunnelStageDownloadUnverified",
|
|
859
|
+
percent: 100
|
|
860
|
+
});
|
|
692
861
|
if (deb) {
|
|
693
862
|
report?.({
|
|
694
863
|
stage: "unpacking",
|
|
695
|
-
|
|
864
|
+
labelKey: "tunnelStageUnpackDeb",
|
|
696
865
|
percent: null
|
|
697
866
|
});
|
|
698
867
|
return writeFromDeb(dataDir, res.body, url);
|
|
699
868
|
}
|
|
700
869
|
report?.({
|
|
701
870
|
stage: "unpacking",
|
|
702
|
-
|
|
871
|
+
labelKey: "tunnelStageWriteBinary",
|
|
703
872
|
percent: null
|
|
704
873
|
});
|
|
705
874
|
const tmp = path.join(dataDir, `.cloudflared.tmp-${process.pid}`);
|
|
@@ -708,15 +877,24 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
708
877
|
await rename(tmp, existing);
|
|
709
878
|
return existing;
|
|
710
879
|
} catch (cause) {
|
|
711
|
-
errors.push(cause instanceof Error ? cause.message : String(cause));
|
|
880
|
+
errors.push(sanitizeDownloadError(cause instanceof Error ? cause.message : String(cause)));
|
|
712
881
|
}
|
|
713
882
|
}
|
|
714
|
-
const
|
|
715
|
-
|
|
883
|
+
const triedHosts = [...new Set(candidates.map((c) => {
|
|
884
|
+
try {
|
|
885
|
+
return new URL(c.url).host;
|
|
886
|
+
} catch {
|
|
887
|
+
return "unknown";
|
|
888
|
+
}
|
|
889
|
+
}))].join(", ");
|
|
890
|
+
throw new Error(`下载 cloudflared 失败(已尝试 ${candidates.length} 个源:${triedHosts})` + (errors.length === 0 ? "" : `;原因:${errors.join(";")}`));
|
|
716
891
|
}
|
|
717
892
|
/**
|
|
718
893
|
* 从 `.deb` 包字节中提取 `usr/bin/cloudflared`,原子落到 `dataDir/cloudflared`。
|
|
719
894
|
* 不调用系统 `dpkg -i`(避免污染包数据库),只做解包 + 提取。
|
|
895
|
+
*
|
|
896
|
+
* 失败时抛出的错误**已脱敏**:`execFile` 的原始错误含两个绝对路径
|
|
897
|
+
* (deb 与解包目录),直接外传会泄露本机目录结构。
|
|
720
898
|
*/
|
|
721
899
|
async function writeFromDeb(dataDir, debBuffer, url) {
|
|
722
900
|
await mkdir(dataDir, { recursive: true });
|
|
@@ -730,11 +908,18 @@ async function writeFromDeb(dataDir, debBuffer, url) {
|
|
|
730
908
|
"-x",
|
|
731
909
|
debPath,
|
|
732
910
|
extractDir
|
|
733
|
-
], (err) =>
|
|
911
|
+
], (err) => {
|
|
912
|
+
if (err === null) {
|
|
913
|
+
resolve();
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
reject(new Error(sanitizeDownloadError(`dpkg-deb 解包失败:${err.message}`)));
|
|
917
|
+
});
|
|
734
918
|
});
|
|
735
919
|
const inner = path.join(extractDir, "usr", "bin", "cloudflared");
|
|
736
920
|
const out = path.join(dataDir, "cloudflared");
|
|
737
921
|
const bin = await readFile(inner);
|
|
922
|
+
if (bin.byteLength === 0) throw new Error("解包后的 cloudflared 为空文件");
|
|
738
923
|
await writeFile(out, bin);
|
|
739
924
|
await chmod(out, 493);
|
|
740
925
|
return out;
|
|
@@ -752,10 +937,12 @@ var CloudflaredTunnelManager = class {
|
|
|
752
937
|
url = null;
|
|
753
938
|
channel = null;
|
|
754
939
|
errorText = void 0;
|
|
940
|
+
errorKey = void 0;
|
|
941
|
+
errorParams = void 0;
|
|
755
942
|
listeners = [];
|
|
756
943
|
activity = {
|
|
757
944
|
stage: "idle",
|
|
758
|
-
|
|
945
|
+
labelKey: "",
|
|
759
946
|
percent: null
|
|
760
947
|
};
|
|
761
948
|
/** 当前进行中任务的阶段进度(无任务时 stage 为 idle)。 */
|
|
@@ -771,7 +958,7 @@ var CloudflaredTunnelManager = class {
|
|
|
771
958
|
clearActivity() {
|
|
772
959
|
this.activity = {
|
|
773
960
|
stage: "idle",
|
|
774
|
-
|
|
961
|
+
labelKey: "",
|
|
775
962
|
percent: null
|
|
776
963
|
};
|
|
777
964
|
this.emit();
|
|
@@ -794,21 +981,31 @@ var CloudflaredTunnelManager = class {
|
|
|
794
981
|
channel: this.channel,
|
|
795
982
|
url: this.url,
|
|
796
983
|
running: this.tunnelActive,
|
|
797
|
-
...this.errorText === void 0 ? {} : { error: this.errorText }
|
|
984
|
+
...this.errorText === void 0 ? {} : { error: this.errorText },
|
|
985
|
+
...this.errorKey === void 0 ? {} : { errorKey: this.errorKey },
|
|
986
|
+
...this.errorParams === void 0 ? {} : { errorParams: this.errorParams }
|
|
798
987
|
};
|
|
799
988
|
}
|
|
800
989
|
/** 订阅状态变化;返回取消函数。 */
|
|
801
990
|
onStatus(cb) {
|
|
802
991
|
this.listeners.push(cb);
|
|
803
|
-
return () =>
|
|
804
|
-
|
|
805
|
-
|
|
992
|
+
return () => this.offStatus(cb);
|
|
993
|
+
}
|
|
994
|
+
/** 取消一个状态订阅(onStatus 返回的函数与 waitForUrl 共用)。 */
|
|
995
|
+
offStatus(cb) {
|
|
996
|
+
this.listeners = this.listeners.filter((fn) => fn !== cb);
|
|
806
997
|
}
|
|
807
998
|
emit() {
|
|
808
999
|
for (const fn of this.listeners) fn();
|
|
809
1000
|
}
|
|
810
|
-
|
|
1001
|
+
/**
|
|
1002
|
+
* 记录一次错误。`error` 保留脱敏后的短消息(兜底/日志),
|
|
1003
|
+
* `errorKey` + `errorParams` 供客户端本地化(优先级 errorKey > error)。
|
|
1004
|
+
*/
|
|
1005
|
+
setError(errorKey, message, errorParams) {
|
|
811
1006
|
this.errorText = message;
|
|
1007
|
+
this.errorKey = errorKey;
|
|
1008
|
+
this.errorParams = errorParams;
|
|
812
1009
|
this.emit();
|
|
813
1010
|
}
|
|
814
1011
|
/**
|
|
@@ -817,11 +1014,13 @@ var CloudflaredTunnelManager = class {
|
|
|
817
1014
|
* @param port bridge 监听端口,cloudflared 把外网请求转发到 localhost:port
|
|
818
1015
|
*/
|
|
819
1016
|
async startQuick(exe, port) {
|
|
820
|
-
if (this.process !== null && !this.process.killed) throw new Error("
|
|
1017
|
+
if (this.process !== null && !this.process.killed) throw new Error("Tunnel is already running; stop it first.");
|
|
821
1018
|
this.errorText = void 0;
|
|
1019
|
+
this.errorKey = void 0;
|
|
1020
|
+
this.errorParams = void 0;
|
|
822
1021
|
this.reportActivity({
|
|
823
1022
|
stage: "starting",
|
|
824
|
-
|
|
1023
|
+
labelKey: "tunnelStageStarting",
|
|
825
1024
|
percent: null
|
|
826
1025
|
});
|
|
827
1026
|
const child = spawn(exe, [
|
|
@@ -853,13 +1052,52 @@ var CloudflaredTunnelManager = class {
|
|
|
853
1052
|
this.channel = null;
|
|
854
1053
|
this.emit();
|
|
855
1054
|
if (wasThis) {
|
|
856
|
-
if (this.url === null) this.setError(`cloudflared
|
|
1055
|
+
if (this.url === null) this.setError("tunnelErrorExited", `cloudflared exited (code=${code ?? "?"}, signal=${signal ?? "?"}) without a tunnel URL.`, {
|
|
1056
|
+
code: String(code ?? "?"),
|
|
1057
|
+
signal: String(signal ?? "?")
|
|
1058
|
+
});
|
|
857
1059
|
this.url = null;
|
|
858
1060
|
if (this.channel === null) this.clearActivity();
|
|
859
1061
|
}
|
|
860
1062
|
});
|
|
861
1063
|
return this.publicState;
|
|
862
1064
|
}
|
|
1065
|
+
/**
|
|
1066
|
+
* 等待子进程把随机域名打印到 stdout(spawn 后 `url` 通常还要几秒才有)。
|
|
1067
|
+
*
|
|
1068
|
+
* 存在的理由(2.5.2):`POST /manage/cloudflare/create` 发生在隧道**起来之前**,
|
|
1069
|
+
* 此刻管理面还没被判据 0 关门;一旦在这次请求里等出地址并顺带出令牌配置文本,
|
|
1070
|
+
* 客户端就不必在隧道运行后再打一次 `/manage/config/text`(那时必然 403,
|
|
1071
|
+
* 这正是「一键开隧道+出令牌」失效的根因)。
|
|
1072
|
+
*
|
|
1073
|
+
* 任一条件满足即结算:地址出现 → resolve(url);进程退出/超时 → resolve(null)
|
|
1074
|
+
* (不抛,调用方据 null 决定要不要让客户端走轮询补一步)。调用方传 AbortSignal 可提前走人。
|
|
1075
|
+
*/
|
|
1076
|
+
async waitForUrl(timeoutMs = 12e3, signal) {
|
|
1077
|
+
if (this.url !== null) return this.url;
|
|
1078
|
+
if (this.process === null || this.process.killed) return null;
|
|
1079
|
+
return await new Promise((resolve) => {
|
|
1080
|
+
let settled = false;
|
|
1081
|
+
const finish = (value) => {
|
|
1082
|
+
if (settled) return;
|
|
1083
|
+
settled = true;
|
|
1084
|
+
clearTimeout(timer);
|
|
1085
|
+
this.offStatus(observer);
|
|
1086
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1087
|
+
resolve(value);
|
|
1088
|
+
};
|
|
1089
|
+
const observer = () => {
|
|
1090
|
+
if (this.url !== null) finish(this.url);
|
|
1091
|
+
else if (this.process === null || this.process.killed) finish(null);
|
|
1092
|
+
};
|
|
1093
|
+
const onAbort = () => finish(null);
|
|
1094
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
1095
|
+
timer.unref?.();
|
|
1096
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1097
|
+
this.onStatus(observer);
|
|
1098
|
+
observer();
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
863
1101
|
/** 停止当前隧道。 */
|
|
864
1102
|
stop() {
|
|
865
1103
|
const child = this.process;
|
|
@@ -879,6 +1117,142 @@ var CloudflaredTunnelManager = class {
|
|
|
879
1117
|
}
|
|
880
1118
|
};
|
|
881
1119
|
//#endregion
|
|
1120
|
+
//#region src/host/metrics.ts
|
|
1121
|
+
/**
|
|
1122
|
+
* 进程内**薄指标收集器**。
|
|
1123
|
+
*
|
|
1124
|
+
* ## 为什么需要它
|
|
1125
|
+
*
|
|
1126
|
+
* 本插件此前只有 9 处启动期日志(`index.ts`),运行期**一条都没有**,也没有任何耗时统计。
|
|
1127
|
+
* 于是「广播是不是串行卡住了」「内核 RPC 是不是超时了」「事件是不是被丢了」这三个问题
|
|
1128
|
+
* 在运行数据里**完全不可见** —— 只能靠读代码推断。这份模块就是让它们变得可观测的最小代价版本。
|
|
1129
|
+
*
|
|
1130
|
+
* ## 为什么是「进程内、无依赖、固定样本环」
|
|
1131
|
+
*
|
|
1132
|
+
* - **不引入 prometheus / otel**:这插件跑在用户的 DSH 进程里,加一个重依赖会拖慢启动、
|
|
1133
|
+
* 也会把「插件」变成「服务」;这里要的只是「出问题时能看一眼」。
|
|
1134
|
+
* - **固定样本环(256)**:P50/P95 需要排序,样本无界增长就是内存泄漏。256 个数字足以
|
|
1135
|
+
* 看出「大多数很快、尾部很慢」,而且分配是一次性的。
|
|
1136
|
+
* - **全部计数都是整数累加**:热路径(每个事件、每个 RPC)调用它只做 `+= 1` 与几次比较,
|
|
1137
|
+
* 不会自己变成瓶颈。
|
|
1138
|
+
*
|
|
1139
|
+
* ## 如何被读到
|
|
1140
|
+
*
|
|
1141
|
+
* `getMetricsSnapshot()` 是**纯读**。挂载点在 `GET /api/v1/debug/host-services` 的响应里
|
|
1142
|
+
* (由 router 负责,本模块不碰 HTTP)—— 那条端点本就是「只读诊断」,且已有同一套鉴权。
|
|
1143
|
+
*
|
|
1144
|
+
* 注意:这些数字**不参与任何业务判断**。它们只用于观测,坏了最坏是「数字不对」,
|
|
1145
|
+
* 不会让功能变化 —— 这是刻意划的边界。
|
|
1146
|
+
*/
|
|
1147
|
+
/** 保留多少个 RPC 耗时样本用于算分位数。 */
|
|
1148
|
+
const SAMPLE_CAPACITY = 256;
|
|
1149
|
+
const rpcBuckets = /* @__PURE__ */ new Map();
|
|
1150
|
+
/** WebSocket 与广播面的计数。 */
|
|
1151
|
+
const counters = {
|
|
1152
|
+
wsConnections: 0,
|
|
1153
|
+
wsRejected: 0,
|
|
1154
|
+
wsTerminated: 0,
|
|
1155
|
+
broadcastEvents: 0,
|
|
1156
|
+
/** 所有连接队列里最长的那条有多长(背压信号)。 */
|
|
1157
|
+
broadcastQueueDepth: 0,
|
|
1158
|
+
/** 因队列满/背压被丢掉的事件数(任何连接)。 */
|
|
1159
|
+
droppedEvents: 0,
|
|
1160
|
+
/** FrameHub 因队列满丢掉的事件数(由 dsh-0.2-bridge 上报)。 */
|
|
1161
|
+
frameHubDropped: 0
|
|
1162
|
+
};
|
|
1163
|
+
/**
|
|
1164
|
+
* 记录一次 RPC 耗时。
|
|
1165
|
+
*
|
|
1166
|
+
* @param name - 稳定的 RPC 名(如 `sessions.history`)。**不要**把参数拼进去,
|
|
1167
|
+
* 否则基数会炸(每个 sessionId 一个桶)。
|
|
1168
|
+
* @param ms - 毫秒耗时。
|
|
1169
|
+
* @param ok - 是否成功;失败单独计数,因为「慢」与「错」是两回事。
|
|
1170
|
+
*/
|
|
1171
|
+
function recordRpc(name, ms, ok = true) {
|
|
1172
|
+
let bucket = rpcBuckets.get(name);
|
|
1173
|
+
if (bucket === void 0) {
|
|
1174
|
+
bucket = {
|
|
1175
|
+
count: 0,
|
|
1176
|
+
failures: 0,
|
|
1177
|
+
totalMs: 0,
|
|
1178
|
+
maxMs: 0,
|
|
1179
|
+
samples: [],
|
|
1180
|
+
index: 0,
|
|
1181
|
+
filled: 0
|
|
1182
|
+
};
|
|
1183
|
+
rpcBuckets.set(name, bucket);
|
|
1184
|
+
}
|
|
1185
|
+
bucket.count += 1;
|
|
1186
|
+
if (!ok) bucket.failures += 1;
|
|
1187
|
+
if (!Number.isFinite(ms) || ms < 0) return;
|
|
1188
|
+
bucket.totalMs += ms;
|
|
1189
|
+
if (ms > bucket.maxMs) bucket.maxMs = ms;
|
|
1190
|
+
bucket.samples[bucket.index] = ms;
|
|
1191
|
+
bucket.index = (bucket.index + 1) % SAMPLE_CAPACITY;
|
|
1192
|
+
if (bucket.filled < SAMPLE_CAPACITY) bucket.filled += 1;
|
|
1193
|
+
}
|
|
1194
|
+
function setWsConnections(value) {
|
|
1195
|
+
counters.wsConnections = value < 0 ? 0 : value;
|
|
1196
|
+
}
|
|
1197
|
+
function recordWsRejected() {
|
|
1198
|
+
counters.wsRejected += 1;
|
|
1199
|
+
}
|
|
1200
|
+
function recordWsTerminated() {
|
|
1201
|
+
counters.wsTerminated += 1;
|
|
1202
|
+
}
|
|
1203
|
+
function recordBroadcastEvent() {
|
|
1204
|
+
counters.broadcastEvents += 1;
|
|
1205
|
+
}
|
|
1206
|
+
/** 更新「最长连接队列」;传入当前所有连接队列长度的最大值。 */
|
|
1207
|
+
function setBroadcastQueueDepth(value) {
|
|
1208
|
+
counters.broadcastQueueDepth = value < 0 ? 0 : value;
|
|
1209
|
+
}
|
|
1210
|
+
function recordDroppedEvent(count = 1) {
|
|
1211
|
+
counters.droppedEvents += count;
|
|
1212
|
+
}
|
|
1213
|
+
/** 由 `FrameHub` 上报:它自己丢了几个事件(与 socket 队列无关的另一处丢弃点)。 */
|
|
1214
|
+
function recordFrameHubDropped(count = 1) {
|
|
1215
|
+
counters.frameHubDropped += count;
|
|
1216
|
+
}
|
|
1217
|
+
/** 从环形样本里问分位数。空样本返回 0(而不是 NaN —— NaN 会让 JSON 变 `null`)。 */
|
|
1218
|
+
function quantile(bucket, f) {
|
|
1219
|
+
if (bucket.filled === 0) return 0;
|
|
1220
|
+
const live = bucket.samples.slice(0, bucket.filled).sort((left, right) => left - right);
|
|
1221
|
+
return live[Math.min(live.length - 1, Math.max(0, Math.floor((live.length - 1) * f)))] ?? 0;
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* 读一份快照。**纯读**:不重置任何计数(重置会让「谁先读谁清空」变成新的坑)。
|
|
1225
|
+
*
|
|
1226
|
+
* 返回的是**新对象**,调用方改它不会污染内部状态。
|
|
1227
|
+
*/
|
|
1228
|
+
function getMetricsSnapshot() {
|
|
1229
|
+
const rpc = [];
|
|
1230
|
+
for (const [name, bucket] of rpcBuckets) rpc.push({
|
|
1231
|
+
name,
|
|
1232
|
+
count: bucket.count,
|
|
1233
|
+
failures: bucket.failures,
|
|
1234
|
+
averageMs: bucket.count === 0 ? 0 : Math.round(bucket.totalMs / Math.max(1, bucket.count) * 100) / 100,
|
|
1235
|
+
p50Ms: quantile(bucket, .5),
|
|
1236
|
+
p95Ms: quantile(bucket, .95),
|
|
1237
|
+
maxMs: bucket.maxMs
|
|
1238
|
+
});
|
|
1239
|
+
rpc.sort((left, right) => right.p95Ms - left.p95Ms);
|
|
1240
|
+
return {
|
|
1241
|
+
ws: {
|
|
1242
|
+
connections: counters.wsConnections,
|
|
1243
|
+
rejected: counters.wsRejected,
|
|
1244
|
+
terminated: counters.wsTerminated
|
|
1245
|
+
},
|
|
1246
|
+
broadcast: {
|
|
1247
|
+
events: counters.broadcastEvents,
|
|
1248
|
+
queueDepth: counters.broadcastQueueDepth,
|
|
1249
|
+
droppedEvents: counters.droppedEvents
|
|
1250
|
+
},
|
|
1251
|
+
frameHub: { dropped: counters.frameHubDropped },
|
|
1252
|
+
rpc
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
//#endregion
|
|
882
1256
|
//#region src/host/path-policy.ts
|
|
883
1257
|
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
884
1258
|
function validateRelativePath(value) {
|
|
@@ -1120,16 +1494,108 @@ function toRpcError(error) {
|
|
|
1120
1494
|
};
|
|
1121
1495
|
}
|
|
1122
1496
|
/** Run one host call and wrap its outcome or failure in the old envelope. */
|
|
1123
|
-
|
|
1497
|
+
/**
|
|
1498
|
+
* 一次内核 RPC 的**统一入口**:耗时统计 + 慢调用/失败日志。
|
|
1499
|
+
*
|
|
1500
|
+
* ## 为什么所有 RPC 都经过这一个函数
|
|
1501
|
+
*
|
|
1502
|
+
* 适配层有二十来条 RPC(sessions.list / history / prompt / models …),逐条埋点
|
|
1503
|
+
* 必然漂(有人加新方法时会忘)。这里是**唯一单点**:加一行就全覆盖。
|
|
1504
|
+
*
|
|
1505
|
+
* ## 为什么必须有一层超时
|
|
1506
|
+
*
|
|
1507
|
+
* 修复前 `call(run)` 不做任何超时,而 `run` 里的每次内核调用都用
|
|
1508
|
+
* `new AbortController().signal`(**永不 abort**)。于是内核侧一旦卡住:
|
|
1509
|
+
*
|
|
1510
|
+
* - 广播路径上那条 `sessions.list` 会把 `broadcastQueue` 永久堵住
|
|
1511
|
+
* (整站事件停摆,`stop()` 也跟着挂);
|
|
1512
|
+
* - 请求路径上那条会让 HTTP 连接一直挂着,客户端先超时、服务端资源不释放。
|
|
1513
|
+
*
|
|
1514
|
+
* 现在统一 10 秒上限。**注意语义**:超时被当作一次 RPC 失败(`code: 'rpc/timeout'`),
|
|
1515
|
+
* 由调用方决定怎么处理 —— 广播路径拿到 false 就「少推一条」,请求路径拿到 504。
|
|
1516
|
+
* 这比「无限等待」在任何情况下都更接近正确。
|
|
1517
|
+
*
|
|
1518
|
+
* @param name - 稳定的 RPC 名(如 `sessions.history`)。**不要**把 sessionId 拼进来,
|
|
1519
|
+
* 否则指标的基数会按会话数增长。
|
|
1520
|
+
* @param run - 真正的调用。它收到的 `signal` 已经带超时,应当传给宿主调用。
|
|
1521
|
+
* @param options.timeoutMs - 覆盖默认上限(流式/长任务可放宽,但**不能**设成无限)。
|
|
1522
|
+
*/
|
|
1523
|
+
async function call(name, run, options = {}) {
|
|
1524
|
+
const startedAt = Date.now();
|
|
1525
|
+
const timeoutMs = options.timeoutMs ?? RPC_TIMEOUT_MS;
|
|
1526
|
+
const controller = new AbortController();
|
|
1527
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`RPC ${name} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
1528
|
+
timer.unref?.();
|
|
1124
1529
|
try {
|
|
1125
|
-
|
|
1530
|
+
const value = await run(controller.signal);
|
|
1531
|
+
const elapsed = Date.now() - startedAt;
|
|
1532
|
+
recordRpc(name, elapsed, true);
|
|
1533
|
+
logSlowRpc(name, elapsed, true);
|
|
1534
|
+
return ok(value);
|
|
1126
1535
|
} catch (error) {
|
|
1536
|
+
const elapsed = Date.now() - startedAt;
|
|
1537
|
+
recordRpc(name, elapsed, false);
|
|
1538
|
+
logSlowRpc(name, elapsed, false);
|
|
1539
|
+
if (controller.signal.aborted && isAbortLike(error)) return { result: {
|
|
1540
|
+
ok: false,
|
|
1541
|
+
error: {
|
|
1542
|
+
code: "rpc/timeout",
|
|
1543
|
+
message: `The host did not answer ${name} within ${timeoutMs}ms.`
|
|
1544
|
+
}
|
|
1545
|
+
} };
|
|
1127
1546
|
return { result: {
|
|
1128
1547
|
ok: false,
|
|
1129
1548
|
error: toRpcError(error)
|
|
1130
1549
|
} };
|
|
1550
|
+
} finally {
|
|
1551
|
+
clearTimeout(timer);
|
|
1131
1552
|
}
|
|
1132
1553
|
}
|
|
1554
|
+
/** 默认 RPC 上限。10 秒:超过它基本可以断定内核卡了,而不是「慢」。 */
|
|
1555
|
+
const RPC_TIMEOUT_MS = 1e4;
|
|
1556
|
+
/**
|
|
1557
|
+
* 「查一下这个会话的工作目录 / 投影」这类**短查询**的上限。
|
|
1558
|
+
*
|
|
1559
|
+
* 比 [RPC_TIMEOUT_MS] 更紧,因为它们在 prompt 的同步前置里:用户按下发送之后
|
|
1560
|
+
* 要等它们回来(图才落盘、模型判定才完成)。让一个辅助查询拖 10 秒,
|
|
1561
|
+
* 用户已经认定「发不出去」了。
|
|
1562
|
+
*/
|
|
1563
|
+
const SESSION_DIR_LOOKUP_TIMEOUT_MS = 3e3;
|
|
1564
|
+
/**
|
|
1565
|
+
* 造一个**会自己到期**的 signal(并让它的定时器随 abort 一起清掉)。
|
|
1566
|
+
*
|
|
1567
|
+
* 为什么不在调用点写 `AbortSignal.timeout(ms)`:两者的过期行为一致,但这里需要
|
|
1568
|
+
* 「定时器不拖住进程退出」—— `AbortSignal.timeout` 内部没有 `unref`,
|
|
1569
|
+
* 在只剩它一个活跃句柄时会让进程多等三秒。这在本仓是已经踩过的纪律
|
|
1570
|
+
* (见 `server.ts` 的 `unref` 注释)。
|
|
1571
|
+
*/
|
|
1572
|
+
function timeoutSignal(ms) {
|
|
1573
|
+
const controller = new AbortController();
|
|
1574
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`timed out after ${ms}ms`)), ms);
|
|
1575
|
+
timer.unref?.();
|
|
1576
|
+
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
|
1577
|
+
return controller.signal;
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* 慢调用 / 失败时打一条日志。
|
|
1581
|
+
*
|
|
1582
|
+
* 阈值 5 秒是刻意的:**正常**的内核调用都在毫秒级,5 秒说明真的出问题了;
|
|
1583
|
+
* 而每次都打日志会让热路径的日志量变成噪声(那反而更难看出问题)。
|
|
1584
|
+
*/
|
|
1585
|
+
function logSlowRpc(name, elapsedMs, okFlag) {
|
|
1586
|
+
if (okFlag && elapsedMs < SLOW_RPC_LOG_MS) return;
|
|
1587
|
+
const message = `dsh-0.2-bridge: rpc ${name} ${okFlag ? "slow" : "failed"}: ${elapsedMs}ms`;
|
|
1588
|
+
console.warn(message);
|
|
1589
|
+
}
|
|
1590
|
+
const SLOW_RPC_LOG_MS = 5e3;
|
|
1591
|
+
/** 判断一个异常是不是「因为我们 abort 才抛」的(不同实现的 message 不一样)。 */
|
|
1592
|
+
function isAbortLike(error) {
|
|
1593
|
+
if (error === null || typeof error !== "object") return false;
|
|
1594
|
+
const candidate = error;
|
|
1595
|
+
if (candidate.name === "AbortError") return true;
|
|
1596
|
+
if (candidate.code === "ABORT_ERR") return true;
|
|
1597
|
+
return typeof candidate.message === "string" && /abort/i.test(candidate.message);
|
|
1598
|
+
}
|
|
1133
1599
|
/**
|
|
1134
1600
|
* Replay-free fan-out of host facts into the `{ rpcId, payload }` frames the
|
|
1135
1601
|
* old `apiProxy` streams produced. Subscribers only observe frames emitted
|
|
@@ -1150,8 +1616,23 @@ var FrameHub = class {
|
|
|
1150
1616
|
const queue = [];
|
|
1151
1617
|
let wake;
|
|
1152
1618
|
let closed = false;
|
|
1619
|
+
/** 因队列满被丢掉的帧数(丢过就要告知消费方「事件流不连续」)。 */
|
|
1620
|
+
let dropped = 0;
|
|
1153
1621
|
const onFrame = (frame) => {
|
|
1154
1622
|
if (closed) return;
|
|
1623
|
+
if (queue.length >= FRAME_HUB_QUEUE_LIMIT) {
|
|
1624
|
+
if (isRebuildableFrame(frame)) {
|
|
1625
|
+
dropped += 1;
|
|
1626
|
+
recordFrameHubDropped();
|
|
1627
|
+
wake?.();
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
const victim = queue.findIndex(isRebuildableFrame);
|
|
1631
|
+
if (victim >= 0) queue.splice(victim, 1);
|
|
1632
|
+
else queue.shift();
|
|
1633
|
+
dropped += 1;
|
|
1634
|
+
recordFrameHubDropped();
|
|
1635
|
+
}
|
|
1155
1636
|
queue.push(frame);
|
|
1156
1637
|
wake?.();
|
|
1157
1638
|
};
|
|
@@ -1166,6 +1647,18 @@ var FrameHub = class {
|
|
|
1166
1647
|
return { async *[Symbol.asyncIterator]() {
|
|
1167
1648
|
try {
|
|
1168
1649
|
while (!closed && !signal.aborted) {
|
|
1650
|
+
if (dropped > 0) {
|
|
1651
|
+
const count = dropped;
|
|
1652
|
+
dropped = 0;
|
|
1653
|
+
yield {
|
|
1654
|
+
rpcId: randomUUID(),
|
|
1655
|
+
payload: {
|
|
1656
|
+
type: "bridge/resync-required",
|
|
1657
|
+
dropped: count,
|
|
1658
|
+
reason: "backpressure"
|
|
1659
|
+
}
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1169
1662
|
if (queue.length === 0) {
|
|
1170
1663
|
await new Promise((resolve) => {
|
|
1171
1664
|
wake = resolve;
|
|
@@ -1182,6 +1675,24 @@ var FrameHub = class {
|
|
|
1182
1675
|
}
|
|
1183
1676
|
};
|
|
1184
1677
|
/**
|
|
1678
|
+
* `FrameHub` 每个消费者的队列上限。
|
|
1679
|
+
*
|
|
1680
|
+
* 与 `server.ts` 的 `DEFAULT_SOCKET_QUEUE_LIMIT` 同量级但**不必相等**:
|
|
1681
|
+
* 这一层是「内核帧 → 插件帧」的缓冲,消费方是本插件的 `startEvents` 循环;
|
|
1682
|
+
* 那一层是「插件事件 → WebSocket 帧」,消费方是网络。两层各有各的慢法。
|
|
1683
|
+
*/
|
|
1684
|
+
const FRAME_HUB_QUEUE_LIMIT = 1e3;
|
|
1685
|
+
/**
|
|
1686
|
+
* 这一帧丢了能不能重建。
|
|
1687
|
+
*
|
|
1688
|
+
* 只有流式文本增量可以(客户端重拉历史即得)。判据看**归一化后的 payload.type**:
|
|
1689
|
+
* `assistant/chunk` 会经 `normalizeDshEvent` 变成 `chat.message.delta`,
|
|
1690
|
+
* 而 `FrameHub` 处在归一化**之前**,所以两边都要认。
|
|
1691
|
+
*/
|
|
1692
|
+
function isRebuildableFrame(frame) {
|
|
1693
|
+
return frame.payload["type"] === "assistant/chunk";
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1185
1696
|
* Relays live session frames into the old `session/event` shape.
|
|
1186
1697
|
*
|
|
1187
1698
|
* 0.2.x has no process-wide session event stream: both durable events and the
|
|
@@ -1195,16 +1706,48 @@ var SessionEventRelay = class {
|
|
|
1195
1706
|
host;
|
|
1196
1707
|
mux;
|
|
1197
1708
|
limit;
|
|
1709
|
+
warn;
|
|
1710
|
+
/**
|
|
1711
|
+
* 活跃订阅。**Map 的插入顺序就是 LRU 顺序**:命中/新建都先 delete 再 set,
|
|
1712
|
+
* 于是最旧(最久没动过)的会话永远在队首 —— 淘汰时取第一个即可,不用另建链表。
|
|
1713
|
+
*/
|
|
1198
1714
|
active = /* @__PURE__ */ new Map();
|
|
1199
|
-
constructor(host, mux, limit = 8) {
|
|
1715
|
+
constructor(host, mux, limit = 8, warn = () => void 0) {
|
|
1200
1716
|
this.host = host;
|
|
1201
1717
|
this.mux = mux;
|
|
1202
1718
|
this.limit = limit;
|
|
1719
|
+
this.warn = warn;
|
|
1203
1720
|
}
|
|
1204
|
-
/**
|
|
1721
|
+
/**
|
|
1722
|
+
* Start following one session; idempotent per session.
|
|
1723
|
+
*
|
|
1724
|
+
* ## 到上限时的行为(修复点)
|
|
1725
|
+
*
|
|
1726
|
+
* 此前是 `if (this.active.size >= this.limit) return` —— **静默**不订阅。
|
|
1727
|
+
* 后果:第 9 个「正在跑」的会话**没有流式事件**,而两侧日志一句话都没有;
|
|
1728
|
+
* 用户看到的现象是「这个会话不实时了」,与网络问题完全无法区分。
|
|
1729
|
+
*
|
|
1730
|
+
* 现在改为 **LRU 淘汰 + warn**:踢掉最久没被触碰的那条订阅,给新会话腾位置。
|
|
1731
|
+
* 这比「拒绝新会话」更接近用户预期 —— 用户切到的那个会话正是他此刻在看的那条,
|
|
1732
|
+
* 而被淘汰的那条通常已经不在屏幕上了(它的下一次 `attach` 会重新订阅,
|
|
1733
|
+
* 代价只是「那段时间的增量要重拉历史」,而不是永久看不到)。
|
|
1734
|
+
*
|
|
1735
|
+
* 淘汰必须**有日志**:不然「我的旧会话为什么突然不流式了」永远查不出来。
|
|
1736
|
+
*/
|
|
1205
1737
|
attach(sessionId) {
|
|
1206
|
-
|
|
1207
|
-
if (
|
|
1738
|
+
const existing = this.active.get(sessionId);
|
|
1739
|
+
if (existing !== void 0) {
|
|
1740
|
+
this.active.delete(sessionId);
|
|
1741
|
+
this.active.set(sessionId, existing);
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
if (this.active.size >= this.limit) {
|
|
1745
|
+
const oldest = this.active.keys().next().value;
|
|
1746
|
+
if (oldest !== void 0) {
|
|
1747
|
+
this.warn(`dsh-0.2-bridge: session event relay is at its limit (${this.limit}); evicting the least recently used subscription (${oldest}) to follow ${sessionId}`);
|
|
1748
|
+
this.detach(oldest);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1208
1751
|
const controller = new AbortController();
|
|
1209
1752
|
this.active.set(sessionId, controller);
|
|
1210
1753
|
this.pump(sessionId, controller.signal).catch(() => {
|
|
@@ -1313,13 +1856,26 @@ var ApprovalBridge = class {
|
|
|
1313
1856
|
const settle = (value) => {
|
|
1314
1857
|
if (done) return;
|
|
1315
1858
|
done = true;
|
|
1859
|
+
clearTimeout(entry.timer);
|
|
1316
1860
|
resolve(value);
|
|
1317
1861
|
};
|
|
1318
|
-
|
|
1862
|
+
const entry = {
|
|
1863
|
+
settle,
|
|
1864
|
+
timer: setTimeout(() => void 0, 0),
|
|
1865
|
+
requestedAt: Date.now()
|
|
1866
|
+
};
|
|
1867
|
+
clearTimeout(entry.timer);
|
|
1868
|
+
entry.timer = setTimeout(() => {
|
|
1869
|
+
this.pending.delete(rpcId);
|
|
1870
|
+
settle("delegate");
|
|
1871
|
+
}, APPROVAL_PENDING_TIMEOUT_MS);
|
|
1872
|
+
entry.timer.unref?.();
|
|
1873
|
+
this.pending.set(rpcId, entry);
|
|
1319
1874
|
req.signal?.addEventListener("abort", () => {
|
|
1320
1875
|
this.pending.delete(rpcId);
|
|
1321
1876
|
if (done) return;
|
|
1322
1877
|
done = true;
|
|
1878
|
+
clearTimeout(entry.timer);
|
|
1323
1879
|
resolve("delegate");
|
|
1324
1880
|
}, { once: true });
|
|
1325
1881
|
this.mux.push({
|
|
@@ -1365,6 +1921,39 @@ var ApprovalBridge = class {
|
|
|
1365
1921
|
*/
|
|
1366
1922
|
const MAX_PENDING_QUESTIONS = 32;
|
|
1367
1923
|
/**
|
|
1924
|
+
* 同时跟踪多少个「正在跑」的会话的事件流。
|
|
1925
|
+
*
|
|
1926
|
+
* 每一条都是一个 `follow()` 订阅,占一个文件描述符与一段内存;无上限的话,
|
|
1927
|
+
* 一个「同时跑 50 个会话」的机器会把 fd 耗光。8 是既有取值,本轮不调 ——
|
|
1928
|
+
* 本轮只把「到顶就静默丢弃」改成「LRU 淘汰 + 日志」。
|
|
1929
|
+
*/
|
|
1930
|
+
const SESSION_RELAY_LIMIT = 8;
|
|
1931
|
+
/**
|
|
1932
|
+
* 一条审批**最多挂起多久**(10 分钟)。
|
|
1933
|
+
*
|
|
1934
|
+
* 为什么必须有这个上限:`approval/request` 是一个等待承诺 —— 客户端通过
|
|
1935
|
+
* `respond()` 结算,宿主通过 `req.signal` 的 abort 结算。**两条都不发生时,
|
|
1936
|
+
* 这个 Promise 永不结算**,内核那一轮就永久阻塞,`pending` 条目也永远留着。
|
|
1937
|
+
*
|
|
1938
|
+
* 而「客户端永远不回答」在真实场景里并不罕见:手机被放进兜里、App 被杀、
|
|
1939
|
+
* 通知被划掉。修复前遇到这种情况,用户看到的是「agent 卡住不动了」,
|
|
1940
|
+
* 除了重启 dsh 没有别的出路。
|
|
1941
|
+
*
|
|
1942
|
+
* 为什么超时是 `delegate` 而**不是** `allowed-once`:把「没人回答」解释成
|
|
1943
|
+
* 「默认批准」等于让安全判据失效于一个定时器。`delegate` 让内核回到下游
|
|
1944
|
+
* (官方 WebUI)继续等,同时推一条 `approval/resolved` 让陈旧卡片消失 ——
|
|
1945
|
+
* 功能上「回到没有这个插件时的行为」,这才是正确的降级方向。
|
|
1946
|
+
*/
|
|
1947
|
+
const APPROVAL_PENDING_TIMEOUT_MS = 6e5;
|
|
1948
|
+
/**
|
|
1949
|
+
* 一条前台提问**最多挂起多久**(10 分钟,与审批同口径)。
|
|
1950
|
+
*
|
|
1951
|
+
* 提问不阻塞内核(Agent 已经继续跑),但它一样会吊着一个 Promise 与一条挂起表条目。
|
|
1952
|
+
* 超时按 `dismissed` 结算:推 `userQuestions/dismissed` 让手机端那张卡片消失,
|
|
1953
|
+
* 同时把 `delegate` 交给调用方 —— 与「用户点了电脑端」同一条收尾路径。
|
|
1954
|
+
*/
|
|
1955
|
+
const QUESTION_PENDING_TIMEOUT_MS = 6e5;
|
|
1956
|
+
/**
|
|
1368
1957
|
* `user-questions/request` 的接管者 —— 形状照 [ApprovalBridge]。
|
|
1369
1958
|
*
|
|
1370
1959
|
* 为什么非接不可:`ask_user_question` 只有在**参数 schema 声明了 `timeout`** 时才走
|
|
@@ -1422,14 +2011,24 @@ var QuestionBridge = class {
|
|
|
1422
2011
|
const settle = (value) => {
|
|
1423
2012
|
if (done) return;
|
|
1424
2013
|
done = true;
|
|
2014
|
+
clearTimeout(entry.timer);
|
|
1425
2015
|
resolve(value);
|
|
1426
2016
|
};
|
|
1427
|
-
|
|
2017
|
+
const entry = {
|
|
1428
2018
|
sessionId,
|
|
1429
2019
|
callId,
|
|
1430
2020
|
questions,
|
|
1431
|
-
settle
|
|
1432
|
-
|
|
2021
|
+
settle,
|
|
2022
|
+
timer: setTimeout(() => void 0, 0),
|
|
2023
|
+
requestedAt: Date.now()
|
|
2024
|
+
};
|
|
2025
|
+
clearTimeout(entry.timer);
|
|
2026
|
+
entry.timer = setTimeout(() => {
|
|
2027
|
+
bridge.pending.delete(rpcId);
|
|
2028
|
+
settle({ kind: "dismissed" });
|
|
2029
|
+
}, QUESTION_PENDING_TIMEOUT_MS);
|
|
2030
|
+
entry.timer.unref?.();
|
|
2031
|
+
bridge.pending.set(rpcId, entry);
|
|
1433
2032
|
request?.signal?.addEventListener("abort", () => {
|
|
1434
2033
|
bridge.pending.delete(rpcId);
|
|
1435
2034
|
settle({ kind: "dismissed" });
|
|
@@ -1489,6 +2088,30 @@ var QuestionBridge = class {
|
|
|
1489
2088
|
};
|
|
1490
2089
|
}
|
|
1491
2090
|
/**
|
|
2091
|
+
* 清扫**已经超期**的挂起项。
|
|
2092
|
+
*
|
|
2093
|
+
* 为什么还需要这个「批量清扫」,既然每条都有自己的定时器:
|
|
2094
|
+
* 定时器是**正常**路径;清扫是**兜底**。两者都存在的理由与 `pending` 表的最小不变式
|
|
2095
|
+
* 有关 —— 表里的每一条都必须「要么会被用户结算、要么会被超时结算、要么会被这里扫掉」。
|
|
2096
|
+
* 少任何一条都会留下永久条目(那正是修复前的状态:只有用户结算这一条路)。
|
|
2097
|
+
*
|
|
2098
|
+
* 它按 `requestedAt` 判断而不是再挂一个计时器:调用方(适配层)可以在任意时机
|
|
2099
|
+
* 顺手调一次,代价是 O(表中条目数),而这个表被 `MAX_PENDING_QUESTIONS` 封在 32。
|
|
2100
|
+
*
|
|
2101
|
+
* @returns 被清掉多少条(供日志/测试断言)。
|
|
2102
|
+
*/
|
|
2103
|
+
sweepExpired(now = Date.now()) {
|
|
2104
|
+
let swept = 0;
|
|
2105
|
+
for (const [rpcId, entry] of this.pending) {
|
|
2106
|
+
if (now - entry.requestedAt < QUESTION_PENDING_TIMEOUT_MS) continue;
|
|
2107
|
+
this.pending.delete(rpcId);
|
|
2108
|
+
clearTimeout(entry.timer);
|
|
2109
|
+
entry.settle({ kind: "dismissed" });
|
|
2110
|
+
swept += 1;
|
|
2111
|
+
}
|
|
2112
|
+
return swept;
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
1492
2115
|
* 某会话当前挂起中的提问(供 `GET /questions` 合并)。
|
|
1493
2116
|
*
|
|
1494
2117
|
* `state` 刻意报 `'continued'`:客户端(dsh-companion 的 HarnessViewModel:1314)
|
|
@@ -1598,6 +2221,14 @@ function sessionIdOf(summary) {
|
|
|
1598
2221
|
* 宁可落到私有目录也不能让「发消息」本身失败。
|
|
1599
2222
|
*/
|
|
1600
2223
|
const DEFAULT_IMAGE_HANDOFF_DIR = join(homedir(), ".dsh", "image-handoff");
|
|
2224
|
+
/**
|
|
2225
|
+
* 交接图片读取的体积上限(32 MiB)。
|
|
2226
|
+
*
|
|
2227
|
+
* 与只读图片路由里的 `MAX_BYTES`(`image-handoff.ts`)**同值但不同职责**:
|
|
2228
|
+
* 那边是「浏览器要不要显示」,这边是「服务端要不要把字节读进内存」。
|
|
2229
|
+
* 这里必须**在读之前**判 —— 先读完再判等于闸门形同虚设。
|
|
2230
|
+
*/
|
|
2231
|
+
const MAX_HANDOFF_BYTES = 33554432;
|
|
1601
2232
|
const IMAGE_EXTENSIONS = {
|
|
1602
2233
|
"image/png": "png",
|
|
1603
2234
|
"image/jpeg": "jpg",
|
|
@@ -1626,16 +2257,23 @@ function handoffExtension(mediaType, bytes) {
|
|
|
1626
2257
|
* `relative` 是相对会话工作目录的短路径,给人看;`absolute` 给读图工具用。
|
|
1627
2258
|
* 目录 0700、文件 0600:这些字节可能包含用户隐私。
|
|
1628
2259
|
*/
|
|
1629
|
-
function writeHandoffFile(dir, workspace, mediaType, data) {
|
|
2260
|
+
async function writeHandoffFile(dir, workspace, mediaType, data) {
|
|
1630
2261
|
const bytes = Buffer.from(data, "base64");
|
|
1631
2262
|
if (bytes.length === 0) throw new Error("EMPTY_IMAGE");
|
|
1632
|
-
|
|
2263
|
+
await mkdir(dir, {
|
|
1633
2264
|
recursive: true,
|
|
1634
2265
|
mode: 448
|
|
1635
2266
|
});
|
|
1636
2267
|
const fileName = `${createHash("sha256").update(bytes).digest("hex")}.${handoffExtension(mediaType, bytes)}`;
|
|
1637
2268
|
const absolute = join(dir, fileName);
|
|
1638
|
-
|
|
2269
|
+
try {
|
|
2270
|
+
await writeFile(absolute, bytes, {
|
|
2271
|
+
mode: 384,
|
|
2272
|
+
flag: "wx"
|
|
2273
|
+
});
|
|
2274
|
+
} catch (error) {
|
|
2275
|
+
if (error.code !== "EEXIST") throw error;
|
|
2276
|
+
}
|
|
1639
2277
|
return {
|
|
1640
2278
|
absolute,
|
|
1641
2279
|
relative: workspace !== void 0 && absolute.startsWith(workspace + sep) ? absolute.slice(workspace.length + 1).split(sep).join("/") : fileName
|
|
@@ -1665,10 +2303,21 @@ async function readHandoffImageFile(host, roots, fallbackDir, subdir, sessionId,
|
|
|
1665
2303
|
const ext = (match[1] ?? "").toLowerCase();
|
|
1666
2304
|
const mediaType = HANDOFF_MEDIA_TYPES[ext] ?? "application/octet-stream";
|
|
1667
2305
|
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1668
|
-
const
|
|
2306
|
+
const candidates = [...workspace !== void 0 ? [join(workspace, subdir, fileName)] : [], join(fallbackDir, fileName)];
|
|
2307
|
+
let target;
|
|
2308
|
+
let size = 0;
|
|
2309
|
+
for (const candidate of candidates) try {
|
|
2310
|
+
const info = await stat(candidate);
|
|
2311
|
+
if (!info.isFile()) continue;
|
|
2312
|
+
target = candidate;
|
|
2313
|
+
size = info.size;
|
|
2314
|
+
break;
|
|
2315
|
+
} catch {}
|
|
1669
2316
|
if (target === void 0) throw new Error("HANDOFF_IMAGE_NOT_FOUND");
|
|
2317
|
+
if (size === 0) throw new Error("HANDOFF_IMAGE_EMPTY");
|
|
2318
|
+
if (size > MAX_HANDOFF_BYTES) throw new Error("HANDOFF_IMAGE_TOO_LARGE");
|
|
1670
2319
|
return {
|
|
1671
|
-
bytes: new Uint8Array(
|
|
2320
|
+
bytes: new Uint8Array(await readFile(target)),
|
|
1672
2321
|
mediaType
|
|
1673
2322
|
};
|
|
1674
2323
|
}
|
|
@@ -1681,7 +2330,7 @@ async function readHandoffImageFile(host, roots, fallbackDir, subdir, sessionId,
|
|
|
1681
2330
|
*/
|
|
1682
2331
|
async function sessionWorkspaceDir(host, roots, sessionId) {
|
|
1683
2332
|
if (roots.length === 0) return void 0;
|
|
1684
|
-
const item = (await host.sessionController.list({},
|
|
2333
|
+
const item = (await host.sessionController.list({}, timeoutSignal(SESSION_DIR_LOOKUP_TIMEOUT_MS))).items?.find((entry) => entry.sessionId === sessionId);
|
|
1685
2334
|
const cwd = typeof item?.cwd === "string" ? item.cwd : void 0;
|
|
1686
2335
|
if (cwd === void 0) return void 0;
|
|
1687
2336
|
for (const root of roots) {
|
|
@@ -1812,7 +2461,7 @@ async function readSessionProjections(host, sessionId, signal) {
|
|
|
1812
2461
|
}
|
|
1813
2462
|
/** 会话当前生效的模型(pending > lastUsed > 目录默认),取不到返回 undefined。 */
|
|
1814
2463
|
async function currentModelSelection(host, sessionId) {
|
|
1815
|
-
const selection = (await host.sessionController.projections({ sessionId },
|
|
2464
|
+
const selection = (await host.sessionController.projections({ sessionId }, timeoutSignal(SESSION_DIR_LOOKUP_TIMEOUT_MS)))?.values?.["modelSelection"];
|
|
1816
2465
|
const catalog = await host.sessionController.modelCatalog();
|
|
1817
2466
|
const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
|
|
1818
2467
|
const provider = current?.provider;
|
|
@@ -1866,16 +2515,22 @@ async function handoffImagesForTextOnlyModel(host, roots, fallbackDir, subdir, l
|
|
|
1866
2515
|
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1867
2516
|
const dir = workspace !== void 0 ? join(workspace, subdir) : fallbackDir;
|
|
1868
2517
|
let index = 0;
|
|
1869
|
-
|
|
1870
|
-
|
|
2518
|
+
const mapped = [];
|
|
2519
|
+
for (const part of content) {
|
|
2520
|
+
if (part.type !== "image") {
|
|
2521
|
+
mapped.push(part);
|
|
2522
|
+
continue;
|
|
2523
|
+
}
|
|
1871
2524
|
index += 1;
|
|
1872
|
-
const { absolute, relative } = writeHandoffFile(dir, workspace, part.mediaType, part.data);
|
|
2525
|
+
const { absolute, relative } = await writeHandoffFile(dir, workspace, part.mediaType, part.data);
|
|
1873
2526
|
const name = typeof part.name === "string" && part.name.trim() !== "" ? part.name.trim() : locale === "en" ? `image ${index}` : `图片 ${index}`;
|
|
1874
|
-
|
|
2527
|
+
const text = [handoffLine(index, name, relative, locale), `[Image: source: ${absolute}]`].join("\n");
|
|
2528
|
+
mapped.push({
|
|
1875
2529
|
type: "text",
|
|
1876
|
-
text
|
|
1877
|
-
};
|
|
1878
|
-
}
|
|
2530
|
+
text
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
return mapped;
|
|
1879
2534
|
} catch {
|
|
1880
2535
|
return content;
|
|
1881
2536
|
}
|
|
@@ -1921,7 +2576,9 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1921
2576
|
approvals.attach();
|
|
1922
2577
|
const questions = new QuestionBridge(host, mux);
|
|
1923
2578
|
questions.attach();
|
|
1924
|
-
const relay = new SessionEventRelay(host, mux)
|
|
2579
|
+
const relay = new SessionEventRelay(host, mux, SESSION_RELAY_LIMIT, (message) => {
|
|
2580
|
+
console.warn(message);
|
|
2581
|
+
});
|
|
1925
2582
|
host.on("api-session/added", (summary) => {
|
|
1926
2583
|
const sessionId = sessionIdOf(summary);
|
|
1927
2584
|
if (sessionId === void 0) return;
|
|
@@ -1983,18 +2640,20 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1983
2640
|
return {
|
|
1984
2641
|
respond: (message) => approvals.respond(message),
|
|
1985
2642
|
sessions: {
|
|
1986
|
-
list: (request) => call(async () => {
|
|
1987
|
-
const page = await host.sessionController.list(request.payload,
|
|
2643
|
+
list: (request) => call("sessions.list", async (signal) => {
|
|
2644
|
+
const page = await host.sessionController.list(request.payload, signal);
|
|
1988
2645
|
const items = Array.isArray(page.items) ? page.items.filter((item) => item.origin !== "subagent") : page.items;
|
|
1989
2646
|
return {
|
|
1990
2647
|
...page,
|
|
1991
2648
|
items
|
|
1992
2649
|
};
|
|
1993
2650
|
}),
|
|
1994
|
-
create: (request) => call(() => host.sessionController.create(request.payload)),
|
|
1995
|
-
history: (request) => call(async () => {
|
|
2651
|
+
create: (request) => call("sessions.create", (signal) => host.sessionController.create(request.payload)),
|
|
2652
|
+
history: (request) => call("sessions.history", async (signal) => {
|
|
1996
2653
|
const { sessionId, maxMessages, beforeSeq } = request.payload;
|
|
1997
2654
|
const controller = new AbortController();
|
|
2655
|
+
if (signal.aborted) controller.abort();
|
|
2656
|
+
else signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
1998
2657
|
try {
|
|
1999
2658
|
const snapshot = (await host.sessionController.follow({
|
|
2000
2659
|
address: {
|
|
@@ -2033,7 +2692,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2033
2692
|
controller.abort();
|
|
2034
2693
|
}
|
|
2035
2694
|
}),
|
|
2036
|
-
prompt: (request) => call(async () => {
|
|
2695
|
+
prompt: (request) => call("sessions.prompt", async (signal) => {
|
|
2037
2696
|
const requestId = randomUUID();
|
|
2038
2697
|
const { locale, ...kernelPayload } = request.payload;
|
|
2039
2698
|
const receiptIds = request.payload.content.filter((part) => part.type === "file").map((part) => part.receiptId);
|
|
@@ -2046,22 +2705,22 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2046
2705
|
requestId,
|
|
2047
2706
|
...kernelPayload,
|
|
2048
2707
|
content
|
|
2049
|
-
},
|
|
2708
|
+
}, signal);
|
|
2050
2709
|
binding?.commit();
|
|
2051
2710
|
return { accepted: true };
|
|
2052
2711
|
}),
|
|
2053
|
-
uploadAttachment: (request) => call(async () => {
|
|
2712
|
+
uploadAttachment: (request) => call("sessions.uploadAttachment", async (signal) => {
|
|
2054
2713
|
const { sessionId, data, name } = request.payload;
|
|
2055
2714
|
if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
|
|
2056
2715
|
return {
|
|
2057
2716
|
receiptId: (await fileUploadService.upload(await agentFor(sessionId), name === void 0 || name === "" ? { data } : {
|
|
2058
2717
|
data,
|
|
2059
2718
|
name
|
|
2060
|
-
},
|
|
2719
|
+
}, signal)).receiptId,
|
|
2061
2720
|
name: name ?? null
|
|
2062
2721
|
};
|
|
2063
2722
|
}),
|
|
2064
|
-
readAttachment: (request) => call(async () => {
|
|
2723
|
+
readAttachment: (request) => call("sessions.readAttachment", async (signal) => {
|
|
2065
2724
|
const { sessionId, attachmentId } = request.payload;
|
|
2066
2725
|
const value = await host.sessionController.attachment({
|
|
2067
2726
|
sessionId,
|
|
@@ -2072,20 +2731,20 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2072
2731
|
mediaType: value.attachment.mediaType
|
|
2073
2732
|
};
|
|
2074
2733
|
}),
|
|
2075
|
-
readHandoffImage: (request) => call(async () => {
|
|
2734
|
+
readHandoffImage: (request) => call("sessions.readHandoffImage", async (signal) => {
|
|
2076
2735
|
const { sessionId, name } = request.payload;
|
|
2077
2736
|
return readHandoffImageFile(host, handoffRootsOf(), imageHandoffDir, handoffSubdirOf(), sessionId, name);
|
|
2078
2737
|
}),
|
|
2079
|
-
cancel: (request) => call(() => host.sessionController.cancel(request.payload)),
|
|
2080
|
-
rename: (request) => call(() => host.sessionController.rename(request.payload)),
|
|
2081
|
-
fork: (request) => call(() => host.sessionController.fork(request.payload)),
|
|
2082
|
-
models: (request) => call(async () => {
|
|
2738
|
+
cancel: (request) => call("sessions.cancel", (signal) => host.sessionController.cancel(request.payload)),
|
|
2739
|
+
rename: (request) => call("sessions.rename", (signal) => host.sessionController.rename(request.payload)),
|
|
2740
|
+
fork: (request) => call("sessions.fork", (signal) => host.sessionController.fork(request.payload)),
|
|
2741
|
+
models: (request) => call("sessions.models", async (signal) => {
|
|
2083
2742
|
const { sessionId } = request.payload;
|
|
2084
2743
|
try {
|
|
2085
2744
|
await host.sessionController.resolveAgent(sessionId);
|
|
2086
2745
|
} catch {}
|
|
2087
2746
|
const catalog = await host.sessionController.modelCatalog();
|
|
2088
|
-
const selection = (await host.sessionController.projections({ sessionId },
|
|
2747
|
+
const selection = (await host.sessionController.projections({ sessionId }, signal))?.values?.["modelSelection"];
|
|
2089
2748
|
return {
|
|
2090
2749
|
current: selection?.next ?? selection?.lastUsed ?? catalog.default,
|
|
2091
2750
|
routable: Array.isArray(catalog.routableProviders) && catalog.routableProviders.length > 0,
|
|
@@ -2096,10 +2755,10 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2096
2755
|
})) : []
|
|
2097
2756
|
};
|
|
2098
2757
|
}),
|
|
2099
|
-
selectModel: (request) => call(() => host.sessionController.selectModel(request.payload))
|
|
2758
|
+
selectModel: (request) => call("sessions.selectModel", (signal) => host.sessionController.selectModel(request.payload))
|
|
2100
2759
|
},
|
|
2101
2760
|
workspace: {
|
|
2102
|
-
list: () => call(async () => {
|
|
2761
|
+
list: () => call("workspace.list", async (signal) => {
|
|
2103
2762
|
return {
|
|
2104
2763
|
items: host.workspaceRegistry.list().map((row) => ({
|
|
2105
2764
|
workspaceId: row.id,
|
|
@@ -2112,16 +2771,16 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2112
2771
|
archivedSessionIds: readArchivedSessionIds(host.workspaceRegistry)
|
|
2113
2772
|
};
|
|
2114
2773
|
}),
|
|
2115
|
-
create: (request) => call(() => host.workspaceController.create(request.payload)),
|
|
2116
|
-
rename: (request) => call(() => host.workspaceController.rename(request.payload)),
|
|
2117
|
-
delete: (request) => call(async () => {
|
|
2774
|
+
create: (request) => call("workspace.create", (signal) => host.workspaceController.create(request.payload)),
|
|
2775
|
+
rename: (request) => call("workspace.rename", (signal) => host.workspaceController.rename(request.payload)),
|
|
2776
|
+
delete: (request) => call("workspace.delete", async (signal) => {
|
|
2118
2777
|
await host.workspaceController.delete(request.payload);
|
|
2119
2778
|
return { deleted: true };
|
|
2120
2779
|
}),
|
|
2121
|
-
archiveSession: (request) => call(() => host.workspaceController.archiveSession(request.payload))
|
|
2780
|
+
archiveSession: (request) => call("workspace.archiveSession", (signal) => host.workspaceController.archiveSession(request.payload))
|
|
2122
2781
|
},
|
|
2123
2782
|
agentPresets: {
|
|
2124
|
-
list: () => call(async () => {
|
|
2783
|
+
list: () => call("agentPresets.list", async (signal) => {
|
|
2125
2784
|
const withTrust = (rows) => rows.map((row) => ({
|
|
2126
2785
|
...row,
|
|
2127
2786
|
trust: row.trust ?? "system"
|
|
@@ -2141,19 +2800,19 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2141
2800
|
hasDocument: false
|
|
2142
2801
|
};
|
|
2143
2802
|
}),
|
|
2144
|
-
select: (request) => call(async () => {
|
|
2803
|
+
select: (request) => call("agentPresets.select", async (signal) => {
|
|
2145
2804
|
const agent = await agentFor(request.payload.sessionId);
|
|
2146
2805
|
return { agentPreset: await host.agentPresets.select(agent, request.payload.agentPreset) };
|
|
2147
2806
|
})
|
|
2148
2807
|
},
|
|
2149
|
-
permissionPresets: { catalog: () => call(async () => {
|
|
2808
|
+
permissionPresets: { catalog: () => call("permissionPresets.catalog", async (signal) => {
|
|
2150
2809
|
const service = optionalService(host, "permissionPresets");
|
|
2151
2810
|
if (service === null || typeof service.catalog !== "function") return FALLBACK_PERMISSION_CATALOG;
|
|
2152
2811
|
return normalizePermissionCatalog(await service.catalog());
|
|
2153
2812
|
}) },
|
|
2154
2813
|
userQuestions: {
|
|
2155
2814
|
pendingAll: () => questions.pendingAll(),
|
|
2156
|
-
list: (request) => call(async () => {
|
|
2815
|
+
list: (request) => call("userQuestions.list", async (signal) => {
|
|
2157
2816
|
const { sessionId } = request.payload;
|
|
2158
2817
|
const bridged = questions.pendingFor(sessionId);
|
|
2159
2818
|
const bridgedIds = new Set(bridged.map((item) => item.callId));
|
|
@@ -2165,7 +2824,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2165
2824
|
const continued = Array.isArray(rows) ? rows.map(toUserQuestionView).filter((item) => item !== void 0) : [];
|
|
2166
2825
|
return { items: [...bridged, ...continued.filter((item) => !bridgedIds.has(item.callId))] };
|
|
2167
2826
|
}),
|
|
2168
|
-
answer: (request) => call(async () => {
|
|
2827
|
+
answer: (request) => call("userQuestions.answer", async (signal) => {
|
|
2169
2828
|
const { sessionId, callId, answer } = request.payload;
|
|
2170
2829
|
const agent = await agentForQuestions(sessionId);
|
|
2171
2830
|
let kernelCouldNotAnswer = false;
|
|
@@ -2212,24 +2871,24 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2212
2871
|
})
|
|
2213
2872
|
},
|
|
2214
2873
|
settings: {
|
|
2215
|
-
describe: () => call(() => host.settingsController.describe()),
|
|
2216
|
-
mutate: (request) => call(() => host.settingsController.mutate(request.payload.ns, request.payload.ops, request.payload.expectedRevision))
|
|
2874
|
+
describe: () => call("settings.describe", (signal) => host.settingsController.describe()),
|
|
2875
|
+
mutate: (request) => call("settings.mutate", (signal) => host.settingsController.mutate(request.payload.ns, request.payload.ops, request.payload.expectedRevision))
|
|
2217
2876
|
},
|
|
2218
2877
|
credentials: {
|
|
2219
|
-
describe: (request) => call(async () => {
|
|
2878
|
+
describe: (request) => call("credentials.describe", async (signal) => {
|
|
2220
2879
|
return { credentials: await host.credentialsController.describe(request.payload.refs) };
|
|
2221
2880
|
}),
|
|
2222
|
-
set: (request) => call(async () => {
|
|
2881
|
+
set: (request) => call("credentials.set", async (signal) => {
|
|
2223
2882
|
await host.credentialsController.set(request.payload.ref, request.payload.value);
|
|
2224
2883
|
return {};
|
|
2225
2884
|
}),
|
|
2226
|
-
unset: (request) => call(async () => {
|
|
2885
|
+
unset: (request) => call("credentials.unset", async (signal) => {
|
|
2227
2886
|
await host.credentialsController.unset(request.payload.ref);
|
|
2228
2887
|
return {};
|
|
2229
2888
|
})
|
|
2230
2889
|
},
|
|
2231
2890
|
llm: {
|
|
2232
|
-
providers: () => call(async () => {
|
|
2891
|
+
providers: () => call("llm.providers", async (signal) => {
|
|
2233
2892
|
const directory = host.llm.listConfigurableProviders();
|
|
2234
2893
|
const live = new Set(host.llm.listProviders().map((entry) => entry.id));
|
|
2235
2894
|
return { providers: directory.map((entry) => ({
|
|
@@ -2247,14 +2906,14 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2247
2906
|
} }
|
|
2248
2907
|
})) };
|
|
2249
2908
|
}),
|
|
2250
|
-
models: () => call(async () => {
|
|
2909
|
+
models: () => call("llm.models", async (signal) => {
|
|
2251
2910
|
const view = await host.sessionController.modelCatalog();
|
|
2252
2911
|
return {
|
|
2253
2912
|
groups: Array.isArray(view.groups) ? view.groups : [],
|
|
2254
2913
|
failures: Array.isArray(view.failures) ? view.failures : []
|
|
2255
2914
|
};
|
|
2256
2915
|
}),
|
|
2257
|
-
discoverModels: (request) => call(async () => {
|
|
2916
|
+
discoverModels: (request) => call("llm.discoverModels", async (signal) => {
|
|
2258
2917
|
const { settingsNs, ...rest } = request.payload;
|
|
2259
2918
|
return { models: [...await host.llm.discoverModels(settingsNs, rest)] };
|
|
2260
2919
|
})
|
|
@@ -2267,7 +2926,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2267
2926
|
}
|
|
2268
2927
|
//#endregion
|
|
2269
2928
|
//#region src/shared/version.ts
|
|
2270
|
-
const PLUGIN_VERSION = "2.5.
|
|
2929
|
+
const PLUGIN_VERSION = "2.5.2";
|
|
2271
2930
|
//#endregion
|
|
2272
2931
|
//#region src/host/capabilities.ts
|
|
2273
2932
|
/**
|
|
@@ -2298,7 +2957,8 @@ const PLUGIN_VERSION = "2.5.1";
|
|
|
2298
2957
|
* `approvals-v1` / `questions-v1`(`dsh-companion` 的 `REQUIRED_CAPABILITIES`:
|
|
2299
2958
|
* 缺了就明说「插件过旧、卡片不会出现」)与 `session-archive-list-v1`
|
|
2300
2959
|
* (决定会话面板是否显示「已归档」入口)。
|
|
2301
|
-
* 2. **对外声明** ——
|
|
2960
|
+
* 2. **对外声明** —— 其余所有能力位(数量以本文件数组为准,勿在注释里写数字,
|
|
2961
|
+
* 加一个位就要改注释的纪律只会让注释过期)。它们回答的是「这台服务端能做什么」,
|
|
2302
2962
|
* **服务的是任何客户端(包括第三方)**,而不是某一处分支。
|
|
2303
2963
|
*
|
|
2304
2964
|
* 所以:**「我方客户端没用到」不等于「没人用」**。删掉第 2 类会破坏对外承诺
|
|
@@ -2351,15 +3011,50 @@ const BRIDGE_CAPABILITIES = [
|
|
|
2351
3011
|
"job-kill-v1"
|
|
2352
3012
|
];
|
|
2353
3013
|
/**
|
|
3014
|
+
* 运行时**不可用**的能力位:服务端此刻做不了这些事,就不该声明。
|
|
3015
|
+
*
|
|
3016
|
+
* ## 为什么必须过滤(这是本节最重要的一条)
|
|
3017
|
+
*
|
|
3018
|
+
* 能力位的全部意义是让客户端把「服务端不支持」与「本来就没有」分开
|
|
3019
|
+
* (见文件头的说明)。而 `jobController` 是**可选宿主服务**:0.2.0-rc.2 上没有它,
|
|
3020
|
+
* 三个 `/jobs` 端点在那套内核上根本不会被挂载(`index.ts` 直接不下发,路由返回 404)。
|
|
3021
|
+
* 若 `connection.ready` 仍然宣告 `job-list-v1`,客户端按位探测后就会去打一个必然
|
|
3022
|
+
* 404 的端点 —— 这恰好是这份清单本身想消灭的那种「静默少一个功能」。
|
|
3023
|
+
*
|
|
3024
|
+
* ## 为什么按「能力位 → 依赖的服务」映射,而不是在调用点手写过滤
|
|
3025
|
+
*
|
|
3026
|
+
* 声明与端点必须**同源**。把「哪个位依赖哪个可选服务」写在一处,将来再加一个
|
|
3027
|
+
* 可选服务只需要在这里补一行;散在调用点就会漂。
|
|
3028
|
+
*/
|
|
3029
|
+
const CAPABILITY_REQUIREMENTS = {
|
|
3030
|
+
"job-list-v1": "jobs",
|
|
3031
|
+
"job-output-v1": "jobs",
|
|
3032
|
+
"job-kill-v1": "jobs"
|
|
3033
|
+
};
|
|
3034
|
+
/**
|
|
3035
|
+
* 这台进程此刻真的能提供的能力位。
|
|
3036
|
+
*
|
|
3037
|
+
* @param flags - 运行时可用性;**缺省当作「全都可用」**。
|
|
3038
|
+
* 缺省必须保守地全给:测试替身与老调用点(不传参)拿到的仍是完整清单,
|
|
3039
|
+
* 行为与加参数之前一字不差 —— 否则这个改动会悄悄改掉所有既有测试的期望。
|
|
3040
|
+
*/
|
|
3041
|
+
function availableCapabilities(flags = {}) {
|
|
3042
|
+
return BRIDGE_CAPABILITIES.filter((capability) => {
|
|
3043
|
+
const requirement = CAPABILITY_REQUIREMENTS[capability];
|
|
3044
|
+
if (requirement === void 0) return true;
|
|
3045
|
+
return flags[requirement] !== false;
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
/**
|
|
2354
3049
|
* `connection.ready` 事件的载荷。
|
|
2355
3050
|
*
|
|
2356
3051
|
* 每次调用返回**新的副本**:调用方(或测试)改它不该影响常量,
|
|
2357
3052
|
* 否则一处手滑就会让整个进程后续所有连接都收到被改过的能力清单。
|
|
2358
3053
|
*/
|
|
2359
|
-
function connectionReadyData() {
|
|
3054
|
+
function connectionReadyData(flags = {}) {
|
|
2360
3055
|
return {
|
|
2361
3056
|
bridgeVersion: PLUGIN_VERSION,
|
|
2362
|
-
capabilities:
|
|
3057
|
+
capabilities: availableCapabilities(flags)
|
|
2363
3058
|
};
|
|
2364
3059
|
}
|
|
2365
3060
|
//#endregion
|
|
@@ -2477,25 +3172,39 @@ function clampStrings(value, limit, seen) {
|
|
|
2477
3172
|
*/
|
|
2478
3173
|
function clampHistoryEvents(events, byteBudget = HISTORY_BYTE_BUDGET, perEventBudget = HISTORY_EVENT_BYTE_BUDGET) {
|
|
2479
3174
|
let clampedStrings = 0;
|
|
2480
|
-
const
|
|
2481
|
-
|
|
3175
|
+
const sizes = new Array(events.length);
|
|
3176
|
+
const prepared = new Array(events.length);
|
|
3177
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
3178
|
+
const entry = events[index];
|
|
3179
|
+
const size = byteLength(entry);
|
|
3180
|
+
if (size <= perEventBudget) {
|
|
3181
|
+
sizes[index] = size;
|
|
3182
|
+
prepared[index] = entry;
|
|
3183
|
+
continue;
|
|
3184
|
+
}
|
|
2482
3185
|
const result = clampStrings(entry, perEventBudget, /* @__PURE__ */ new WeakSet());
|
|
2483
|
-
if (result.clamped === 0)
|
|
3186
|
+
if (result.clamped === 0) {
|
|
3187
|
+
sizes[index] = size;
|
|
3188
|
+
prepared[index] = entry;
|
|
3189
|
+
continue;
|
|
3190
|
+
}
|
|
2484
3191
|
clampedStrings += result.clamped;
|
|
2485
3192
|
const marker = { truncated: true };
|
|
2486
3193
|
const clampedValue = result.value;
|
|
2487
|
-
|
|
3194
|
+
const marked = clampedValue !== null && typeof clampedValue === "object" && !Array.isArray(clampedValue) ? {
|
|
2488
3195
|
...clampedValue,
|
|
2489
3196
|
bridgeTruncated: marker
|
|
2490
3197
|
} : {
|
|
2491
3198
|
value: clampedValue,
|
|
2492
3199
|
bridgeTruncated: marker
|
|
2493
3200
|
};
|
|
2494
|
-
|
|
3201
|
+
prepared[index] = marked;
|
|
3202
|
+
sizes[index] = byteLength(marked);
|
|
3203
|
+
}
|
|
2495
3204
|
const kept = [];
|
|
2496
3205
|
let bytes = 0;
|
|
2497
3206
|
for (let index = prepared.length - 1; index >= 0; index -= 1) {
|
|
2498
|
-
const size =
|
|
3207
|
+
const size = sizes[index] ?? 0;
|
|
2499
3208
|
if (kept.length > 0 && bytes + size > byteBudget) break;
|
|
2500
3209
|
bytes += size;
|
|
2501
3210
|
kept.push(prepared[index]);
|
|
@@ -2531,10 +3240,49 @@ function applyHistoryBudget(raw, byteBudget = HISTORY_BYTE_BUDGET, perEventBudge
|
|
|
2531
3240
|
}
|
|
2532
3241
|
//#endregion
|
|
2533
3242
|
//#region src/host/chat-adapter.ts
|
|
3243
|
+
/** 会话可见性缓存的 TTL(见 `DshChatAdapter.sessionVisibility` 的说明)。 */
|
|
3244
|
+
const SESSION_VISIBILITY_TTL_MS = 2e3;
|
|
3245
|
+
/** 可见性缓存条目上限;到顶就按 TTL 清一遍(见 `rememberVisibility`)。 */
|
|
3246
|
+
const SESSION_VISIBILITY_CACHE_LIMIT = 2048;
|
|
3247
|
+
/**
|
|
3248
|
+
* 把 principal 收敛成一个稳定的缓存键。
|
|
3249
|
+
*
|
|
3250
|
+
* 为什么不用 `JSON.stringify(principal)`:`rootIds` 是 `Set`(序列化出来是 `{}`,
|
|
3251
|
+
* 所有设备会撞成同一个键 —— 那就是越权)。这里只用**身份**部分,
|
|
3252
|
+
* 授权范围的变化由 TTL 与 `sessionRootId` 那条独立缓存兜住。
|
|
3253
|
+
*/
|
|
3254
|
+
function principalKey(principal) {
|
|
3255
|
+
return principal.kind === "admin" ? "admin" : `device:${principal.id}`;
|
|
3256
|
+
}
|
|
2534
3257
|
var DshChatAdapter = class {
|
|
2535
3258
|
api;
|
|
2536
3259
|
database;
|
|
2537
3260
|
commandServices;
|
|
3261
|
+
/**
|
|
3262
|
+
* 会话 → 可见性快照的短 TTL 缓存。
|
|
3263
|
+
*
|
|
3264
|
+
* ## 为什么需要(这是本文件最热的一处浪费)
|
|
3265
|
+
*
|
|
3266
|
+
* `requireSession()` 会在**几乎每个** API 调用里跑一遍 `listSessions()`,
|
|
3267
|
+
* 而 `listSessions` 一次要打两次内核 RPC(`sessions.list` + `workspace.list`)
|
|
3268
|
+
* 加一次 `database.listRoots()`。也就是说 history / commands / approvals /
|
|
3269
|
+
* models / questions / prompt 每条请求都白花两次内核往返 —— 只为回答
|
|
3270
|
+
* 「这个 session 能不能被这台设备看见」。
|
|
3271
|
+
*
|
|
3272
|
+
* ## 为什么 TTL 是 2 秒(短到近乎无感,长到足以省掉重复)
|
|
3273
|
+
*
|
|
3274
|
+
* 客户端在打开一个会话时会**连续**打四五个接口(历史、审批、提问、命令、模型),
|
|
3275
|
+
* 2 秒足以把这一串并成一两次真实查询。再长就会让「刚建的会话看不到」
|
|
3276
|
+
* 变成一个可感受到的延迟 —— 而那正是我们要避免的另一类 bug。
|
|
3277
|
+
*
|
|
3278
|
+
* ## 缓存的是什么(关键:缓存的是**授权结论**,不是会话内容)
|
|
3279
|
+
*
|
|
3280
|
+
* 只缓存「这个 sessionId 对当前设备可见吗」这一个布尔结论,且按 `principal.id`
|
|
3281
|
+
* 分桶。**不缓存会话正文**:正文会被 `rename`/`archive`/新消息改掉,
|
|
3282
|
+
* 缓存它等于制造陈旧数据;而可见性只随「根授权变没变」与「会话删没删」变,
|
|
3283
|
+
* 两者都是低频且由用户操作驱动的。
|
|
3284
|
+
*/
|
|
3285
|
+
sessionVisibility = /* @__PURE__ */ new Map();
|
|
2538
3286
|
sessionRootIds = /* @__PURE__ */ new Map();
|
|
2539
3287
|
sessionRootLookups = /* @__PURE__ */ new Map();
|
|
2540
3288
|
pendingApprovals = /* @__PURE__ */ new Map();
|
|
@@ -3028,8 +3776,13 @@ var DshChatAdapter = class {
|
|
|
3028
3776
|
}
|
|
3029
3777
|
}
|
|
3030
3778
|
async isSessionAuthorized(principal, sessionId) {
|
|
3779
|
+
const key = `${principalKey(principal)}\u0000${sessionId}`;
|
|
3780
|
+
const cached = this.sessionVisibility.get(key);
|
|
3781
|
+
if (cached !== void 0 && Date.now() - cached.visibleAt < SESSION_VISIBILITY_TTL_MS) return cached.visible;
|
|
3031
3782
|
const rootId = await this.sessionRootId(sessionId);
|
|
3032
|
-
|
|
3783
|
+
const allowed = rootId !== void 0 && (principal.rootIds === "all" || principal.rootIds.has(rootId));
|
|
3784
|
+
this.rememberVisibility(key, allowed);
|
|
3785
|
+
return allowed;
|
|
3033
3786
|
}
|
|
3034
3787
|
startEvents(signal, emit) {
|
|
3035
3788
|
const run = async (kind) => {
|
|
@@ -3092,10 +3845,37 @@ var DshChatAdapter = class {
|
|
|
3092
3845
|
return services.agents.get(sessionId);
|
|
3093
3846
|
}
|
|
3094
3847
|
async requireSession(principal, sessionId) {
|
|
3848
|
+
const key = `${principalKey(principal)}\u0000${sessionId}`;
|
|
3849
|
+
const cached = this.sessionVisibility.get(key);
|
|
3850
|
+
if (cached !== void 0 && Date.now() - cached.visibleAt < SESSION_VISIBILITY_TTL_MS) {
|
|
3851
|
+
if (cached.visible) {
|
|
3852
|
+
const session = (await this.listSessions(principal)).find((item) => item.id === sessionId);
|
|
3853
|
+
if (session !== void 0) return session;
|
|
3854
|
+
this.sessionVisibility.delete(key);
|
|
3855
|
+
} else throw new ApiError(404, "SESSION_NOT_FOUND", "The session is not visible to this device.");
|
|
3856
|
+
}
|
|
3095
3857
|
const session = (await this.listSessions(principal)).find((item) => item.id === sessionId);
|
|
3858
|
+
this.rememberVisibility(key, session !== void 0);
|
|
3096
3859
|
if (session === void 0) throw new ApiError(404, "SESSION_NOT_FOUND", "The session is not visible to this device.");
|
|
3097
3860
|
return session;
|
|
3098
3861
|
}
|
|
3862
|
+
/**
|
|
3863
|
+
* 记一条可见性结论,并顺手把过期条目清掉。
|
|
3864
|
+
*
|
|
3865
|
+
* 为什么在这里做清扫而不是定时器:缓存条目数与「设备数 × 会话数」同阶,
|
|
3866
|
+
* 对单机部署来说是千级 —— 每次写入顺手扫一遍完全够,而且**没有定时器泄漏**。
|
|
3867
|
+
* (本仓的纪律:服务端不引入 `setInterval`,见 `server.ts` 的心跳是唯一的例外,
|
|
3868
|
+
* 且它 `unref` 过。)
|
|
3869
|
+
*/
|
|
3870
|
+
rememberVisibility(key, visible) {
|
|
3871
|
+
const now = Date.now();
|
|
3872
|
+
this.sessionVisibility.set(key, {
|
|
3873
|
+
visibleAt: now,
|
|
3874
|
+
visible
|
|
3875
|
+
});
|
|
3876
|
+
if (this.sessionVisibility.size < SESSION_VISIBILITY_CACHE_LIMIT) return;
|
|
3877
|
+
for (const [entryKey, entry] of this.sessionVisibility) if (now - entry.visibleAt >= SESSION_VISIBILITY_TTL_MS) this.sessionVisibility.delete(entryKey);
|
|
3878
|
+
}
|
|
3099
3879
|
async requireWorkspace(principal, workspaceId) {
|
|
3100
3880
|
const workspace = (await this.listWorkspaces(principal)).find((item) => item.id === workspaceId);
|
|
3101
3881
|
if (workspace === void 0) throw new ApiError(404, "WORKSPACE_NOT_FOUND", "The DSH workspace is not visible to this device.");
|
|
@@ -3511,15 +4291,42 @@ var WorkspaceDatabase = class {
|
|
|
3511
4291
|
trashDir;
|
|
3512
4292
|
db;
|
|
3513
4293
|
closed = false;
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
4294
|
+
/**
|
|
4295
|
+
* 「设备集合或设备权限发生过写」的进程内单调计数。
|
|
4296
|
+
*
|
|
4297
|
+
* ## 为什么需要它
|
|
4298
|
+
*
|
|
4299
|
+
* WebSocket 广播的每一条事件都要确认「这条连接背后的设备还有效吗、scope/root 变了吗」
|
|
4300
|
+
* —— 旧实现是**每条事件查一次库**(`getActiveDevice`)。多条连接 × 持续事件
|
|
4301
|
+
* 就是成百次纯浪费的 SQLite 查询。
|
|
4302
|
+
*
|
|
4303
|
+
* 有了这个计数,`server.ts` 可以只在**它变了**的时候重新水化设备
|
|
4304
|
+
* (特性探测:getter 不存在就退回逐事件查库,所以迟到或缺席都不会更差)。
|
|
4305
|
+
*
|
|
4306
|
+
* ## 语义(与 perf-reader 的 PERF-1 契约一致)
|
|
4307
|
+
*
|
|
4308
|
+
* 「任何改变**设备是否存在 / scope / rootIds** 的写」都要 +1。当前四处:
|
|
4309
|
+
* `exchangePairing` / `updateDevice` / `revokeDevice` / `purgeRevokedDevices`。
|
|
4310
|
+
*
|
|
4311
|
+
* 根(roots)的增删**不** +1:设备记录本身没变;`/roots` 那条路径本来就是实时查库。
|
|
4312
|
+
*
|
|
4313
|
+
* 保守性:宁可多 +1(多一次缓存失效,代价是一次查询),不可漏 +1
|
|
4314
|
+
* (漏了就是「撤销后仍能收事件」—— 那正是 `device-revocation` 要防的事)。
|
|
4315
|
+
*/
|
|
4316
|
+
devicesRevisionCounter = 0;
|
|
4317
|
+
constructor(dataDir) {
|
|
4318
|
+
this.dataDir = path.resolve(dataDir);
|
|
4319
|
+
this.trashDir = path.join(this.dataDir, "trash");
|
|
4320
|
+
mkdirSync(this.trashDir, { recursive: true });
|
|
4321
|
+
this.db = new DatabaseSync(path.join(this.dataDir, "workspace.sqlite"));
|
|
4322
|
+
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
|
|
3520
4323
|
this.migrate();
|
|
3521
4324
|
openDatabases.add(new WeakRef(this));
|
|
3522
4325
|
}
|
|
4326
|
+
/** 当前设备状态版本号。用途见字段注释(`server.ts` 的广播缓存失效)。 */
|
|
4327
|
+
get devicesRevision() {
|
|
4328
|
+
return this.devicesRevisionCounter;
|
|
4329
|
+
}
|
|
3523
4330
|
/** 关闭数据库。**幂等** —— 重复调用不抛错。 */
|
|
3524
4331
|
close() {
|
|
3525
4332
|
if (this.closed) return;
|
|
@@ -3543,6 +4350,13 @@ var WorkspaceDatabase = class {
|
|
|
3543
4350
|
setSetting(key, value) {
|
|
3544
4351
|
this.db.prepare("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);
|
|
3545
4352
|
}
|
|
4353
|
+
/**
|
|
4354
|
+
* 登记一条授权根。
|
|
4355
|
+
*
|
|
4356
|
+
* @param options.requestId - 发起这次登记的那条 HTTP 请求的 id。
|
|
4357
|
+
* 审计的用途不只是「记下发生了什么」,还要能回答「**哪一次请求**干的」;
|
|
4358
|
+
* 不传(插件启动期登记内置根)时审计行的 `request_id` 为 null。
|
|
4359
|
+
*/
|
|
3546
4360
|
async addRoot(inputPath, label, options) {
|
|
3547
4361
|
const realPath = await canonicalRoot(inputPath);
|
|
3548
4362
|
if (options?.allowDuplicate !== true) {
|
|
@@ -3553,7 +4367,7 @@ var WorkspaceDatabase = class {
|
|
|
3553
4367
|
const createdAt = Date.now();
|
|
3554
4368
|
const resolvedLabel = label?.trim() || path.basename(realPath) || realPath;
|
|
3555
4369
|
this.db.prepare("INSERT INTO roots(id,label,real_path,created_at,builtin) VALUES(?,?,?,?,0)").run(id, resolvedLabel, realPath, createdAt);
|
|
3556
|
-
this.audit("admin", "loopback-admin", "root.add", id, void 0, { label: resolvedLabel });
|
|
4370
|
+
this.audit("admin", "loopback-admin", "root.add", id, void 0, { label: resolvedLabel }, options?.requestId);
|
|
3557
4371
|
return {
|
|
3558
4372
|
id,
|
|
3559
4373
|
label: resolvedLabel,
|
|
@@ -3611,7 +4425,12 @@ var WorkspaceDatabase = class {
|
|
|
3611
4425
|
if (this.db.prepare("DELETE FROM roots WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "ROOT_NOT_FOUND", "The authorized root does not exist.");
|
|
3612
4426
|
this.audit("admin", "loopback-admin", "root.remove", id);
|
|
3613
4427
|
}
|
|
3614
|
-
|
|
4428
|
+
/**
|
|
4429
|
+
* 生成一枚配对码。
|
|
4430
|
+
*
|
|
4431
|
+
* @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
|
|
4432
|
+
*/
|
|
4433
|
+
createPairing(rootIds, scopes, ttlMs = 6e5, options = {}) {
|
|
3615
4434
|
assertScopes(scopes);
|
|
3616
4435
|
for (const rootId of rootIds) if (this.getRoot(rootId) === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", `Unknown root ${rootId}.`);
|
|
3617
4436
|
const raw = randomBytes(5).toString("hex").toUpperCase();
|
|
@@ -3622,13 +4441,20 @@ var WorkspaceDatabase = class {
|
|
|
3622
4441
|
rootIds,
|
|
3623
4442
|
scopes,
|
|
3624
4443
|
expiresAt
|
|
3625
|
-
});
|
|
4444
|
+
}, options.requestId);
|
|
3626
4445
|
return {
|
|
3627
4446
|
code,
|
|
3628
4447
|
expiresAt
|
|
3629
4448
|
};
|
|
3630
4449
|
}
|
|
3631
|
-
|
|
4450
|
+
/**
|
|
4451
|
+
* 用配对码换一枚设备令牌。
|
|
4452
|
+
*
|
|
4453
|
+
* 审计**在事务内**写(FUNC-5):它是「这台设备被授权了」的唯一留痕,
|
|
4454
|
+
* 与设备行同生共死。审计写不进去 → 整个兑换回滚 → 客户端重试,
|
|
4455
|
+
* 而不是「拿到令牌但库里没有这笔授权的记录」。
|
|
4456
|
+
*/
|
|
4457
|
+
exchangePairing(code, deviceName, options = {}) {
|
|
3632
4458
|
const normalized = normalizePairingCode(code);
|
|
3633
4459
|
const now = Date.now();
|
|
3634
4460
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -3644,18 +4470,18 @@ var WorkspaceDatabase = class {
|
|
|
3644
4470
|
this.db.prepare("INSERT INTO devices(id,name,token_hash,scopes_json,created_at) VALUES(?,?,?,?,?)").run(id, name, hashSecret(token), JSON.stringify(scopes), now);
|
|
3645
4471
|
const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
|
|
3646
4472
|
for (const rootId of rootIds) grant.run(id, rootId);
|
|
4473
|
+
this.audit("device", id, "pairing.exchange", void 0, void 0, { name }, options.requestId);
|
|
3647
4474
|
this.db.exec("COMMIT");
|
|
3648
|
-
|
|
3649
|
-
id,
|
|
3650
|
-
name,
|
|
3651
|
-
scopes,
|
|
3652
|
-
rootIds,
|
|
3653
|
-
createdAt: now
|
|
3654
|
-
};
|
|
3655
|
-
this.audit("device", id, "pairing.exchange", void 0, void 0, { name });
|
|
4475
|
+
this.devicesRevisionCounter += 1;
|
|
3656
4476
|
return {
|
|
3657
4477
|
token,
|
|
3658
|
-
device
|
|
4478
|
+
device: {
|
|
4479
|
+
id,
|
|
4480
|
+
name,
|
|
4481
|
+
scopes,
|
|
4482
|
+
rootIds,
|
|
4483
|
+
createdAt: now
|
|
4484
|
+
}
|
|
3659
4485
|
};
|
|
3660
4486
|
} catch (error) {
|
|
3661
4487
|
this.db.exec("ROLLBACK");
|
|
@@ -3701,7 +4527,16 @@ var WorkspaceDatabase = class {
|
|
|
3701
4527
|
isDeviceActive(id) {
|
|
3702
4528
|
return this.db.prepare("SELECT 1 AS active FROM devices WHERE id = ? AND revoked_at IS NULL").get(id) !== void 0;
|
|
3703
4529
|
}
|
|
3704
|
-
|
|
4530
|
+
/**
|
|
4531
|
+
* 改一台设备的 scope 与 root 授权。
|
|
4532
|
+
*
|
|
4533
|
+
* 审计**在事务内**写(FUNC-5):它是「谁的权限被扩大了」的唯一留痕。
|
|
4534
|
+
* 审计失败 → 整个更新回滚。宁可让用户重试,也不能出现「权限已经变宽、
|
|
4535
|
+
* 但没有任何记录说是谁在什么时候改的」—— 那是不可审计的提权。
|
|
4536
|
+
*
|
|
4537
|
+
* @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
|
|
4538
|
+
*/
|
|
4539
|
+
updateDevice(id, scopes, rootIds, options = {}) {
|
|
3705
4540
|
assertScopes(scopes);
|
|
3706
4541
|
for (const rootId of rootIds) if (this.getRoot(rootId) === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", `Unknown root ${rootId}.`);
|
|
3707
4542
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -3710,16 +4545,17 @@ var WorkspaceDatabase = class {
|
|
|
3710
4545
|
this.db.prepare("DELETE FROM device_roots WHERE device_id = ?").run(id);
|
|
3711
4546
|
const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
|
|
3712
4547
|
for (const rootId of rootIds) grant.run(id, rootId);
|
|
4548
|
+
this.audit("admin", "loopback-admin", "device.update", void 0, void 0, {
|
|
4549
|
+
deviceId: id,
|
|
4550
|
+
scopes,
|
|
4551
|
+
rootIds
|
|
4552
|
+
}, options.requestId);
|
|
3713
4553
|
this.db.exec("COMMIT");
|
|
3714
4554
|
} catch (error) {
|
|
3715
4555
|
this.db.exec("ROLLBACK");
|
|
3716
4556
|
throw error;
|
|
3717
4557
|
}
|
|
3718
|
-
this.
|
|
3719
|
-
deviceId: id,
|
|
3720
|
-
scopes,
|
|
3721
|
-
rootIds
|
|
3722
|
-
});
|
|
4558
|
+
this.devicesRevisionCounter += 1;
|
|
3723
4559
|
const device = this.listDevices().find((item) => item.id === id);
|
|
3724
4560
|
if (device === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The device does not exist.");
|
|
3725
4561
|
return device;
|
|
@@ -3737,14 +4573,31 @@ var WorkspaceDatabase = class {
|
|
|
3737
4573
|
* - `authenticate` 仍保留 `revoked_at IS NULL` 作为兜底,万一有别处留下的标记行,
|
|
3738
4574
|
* 它们依然登不进来 —— 两层保险不冲突。
|
|
3739
4575
|
*/
|
|
3740
|
-
|
|
4576
|
+
/**
|
|
4577
|
+
* 撤销一台设备 = 删除设备行。
|
|
4578
|
+
*
|
|
4579
|
+
* 审计与删除**在同一事务里**(FUNC-5),且顺序是「先留痕、再删除」:
|
|
4580
|
+
* 审计的 `actor_id` 不带外键,删设备不会把记录带走。反过来若先删后写、
|
|
4581
|
+
* 而写审计失败,就会留下「设备已经没了,但没有任何记录说谁撤的」。
|
|
4582
|
+
*
|
|
4583
|
+
* @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
|
|
4584
|
+
*/
|
|
4585
|
+
revokeDevice(id, options = {}) {
|
|
3741
4586
|
const active = this.getActiveDevice(id);
|
|
3742
4587
|
if (active === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
3743
|
-
this.
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
4588
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
4589
|
+
try {
|
|
4590
|
+
this.audit("admin", "loopback-admin", "device.revoke", void 0, void 0, {
|
|
4591
|
+
deviceId: id,
|
|
4592
|
+
name: active.name
|
|
4593
|
+
}, options.requestId);
|
|
4594
|
+
if (this.db.prepare("DELETE FROM devices WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
4595
|
+
this.db.exec("COMMIT");
|
|
4596
|
+
} catch (error) {
|
|
4597
|
+
this.db.exec("ROLLBACK");
|
|
4598
|
+
throw error;
|
|
4599
|
+
}
|
|
4600
|
+
this.devicesRevisionCounter += 1;
|
|
3748
4601
|
}
|
|
3749
4602
|
/**
|
|
3750
4603
|
* 一次性清理历史遗留的「标记式」撤销设备(`revoked_at IS NOT NULL` 的行)。
|
|
@@ -3767,18 +4620,70 @@ var WorkspaceDatabase = class {
|
|
|
3767
4620
|
this.db.prepare("UPDATE devices SET revoked_at = ? WHERE id = ?").run(Date.now(), id);
|
|
3768
4621
|
}
|
|
3769
4622
|
purgeRevokedDevices() {
|
|
3770
|
-
if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 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
|
+
}
|
|
3771
4627
|
this.db.exec("BEGIN IMMEDIATE");
|
|
3772
4628
|
try {
|
|
3773
4629
|
const result = this.db.prepare("DELETE FROM devices WHERE revoked_at IS NOT NULL").run();
|
|
3774
|
-
this.db.exec("COMMIT");
|
|
3775
4630
|
this.audit("admin", "loopback-admin", "device.purge", void 0, void 0, { purged: result.changes });
|
|
4631
|
+
this.db.exec("COMMIT");
|
|
4632
|
+
this.devicesRevisionCounter += 1;
|
|
3776
4633
|
return Number(result.changes);
|
|
3777
4634
|
} catch (error) {
|
|
3778
4635
|
this.db.exec("ROLLBACK");
|
|
3779
4636
|
throw error;
|
|
3780
4637
|
}
|
|
3781
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
|
+
}
|
|
3782
4687
|
insertTrash(record) {
|
|
3783
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);
|
|
3784
4689
|
}
|
|
@@ -3944,6 +4849,14 @@ var WorkspaceEventBus = class {
|
|
|
3944
4849
|
};
|
|
3945
4850
|
//#endregion
|
|
3946
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;
|
|
3947
4860
|
var FileService = class {
|
|
3948
4861
|
database;
|
|
3949
4862
|
emit;
|
|
@@ -3962,18 +4875,21 @@ var FileService = class {
|
|
|
3962
4875
|
const pageSize = Math.min(Math.max(limit, 1), 500);
|
|
3963
4876
|
const selected = names.slice(start, start + pageSize);
|
|
3964
4877
|
const entries = [];
|
|
3965
|
-
for (
|
|
3966
|
-
const
|
|
3967
|
-
const
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
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);
|
|
3977
4893
|
}
|
|
3978
4894
|
const last = selected.at(-1);
|
|
3979
4895
|
return {
|
|
@@ -3987,12 +4903,11 @@ var FileService = class {
|
|
|
3987
4903
|
const resolved = await resolveAuthorizedPath(root.realPath, relativePath);
|
|
3988
4904
|
const info = await lstat(resolved.absolutePath);
|
|
3989
4905
|
if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files have readable content.");
|
|
3990
|
-
const etag = await fileEtag(resolved.absolutePath);
|
|
3991
4906
|
return {
|
|
3992
4907
|
absolutePath: resolved.absolutePath,
|
|
3993
4908
|
size: info.size,
|
|
3994
4909
|
modifiedAt: info.mtimeMs,
|
|
3995
|
-
etag,
|
|
4910
|
+
etag: etagFromStat(info),
|
|
3996
4911
|
contentType: contentTypeFor$1(relativePath)
|
|
3997
4912
|
};
|
|
3998
4913
|
}
|
|
@@ -4010,8 +4925,8 @@ var FileService = class {
|
|
|
4010
4925
|
if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files can be overwritten.");
|
|
4011
4926
|
if (conditions.ifNoneMatch === "*") throw new ApiError(412, "PATH_ALREADY_EXISTS", "The file already exists.");
|
|
4012
4927
|
if (conditions.ifMatch === void 0) throw new ApiError(428, "ETAG_REQUIRED", "If-Match is required when overwriting a file.");
|
|
4013
|
-
const currentEtag =
|
|
4014
|
-
if (conditions.ifMatch
|
|
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 });
|
|
4015
4930
|
mode = info.mode;
|
|
4016
4931
|
} else if (conditions.ifNoneMatch !== "*") throw new ApiError(428, "CREATE_CONDITION_REQUIRED", "If-None-Match: * is required when creating file content.");
|
|
4017
4932
|
const temp = path.join(parent.absolutePath, `.${path.basename(resolved.absolutePath)}.dsh-${randomUUID()}.tmp`);
|
|
@@ -4024,8 +4939,8 @@ var FileService = class {
|
|
|
4024
4939
|
await handle.close();
|
|
4025
4940
|
}
|
|
4026
4941
|
if (resolved.exists) {
|
|
4027
|
-
const currentEtag = await
|
|
4028
|
-
if (conditions.ifMatch
|
|
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 });
|
|
4029
4944
|
}
|
|
4030
4945
|
await replaceFile(temp, resolved.absolutePath, resolved.exists);
|
|
4031
4946
|
} catch (error) {
|
|
@@ -4033,7 +4948,7 @@ var FileService = class {
|
|
|
4033
4948
|
throw error;
|
|
4034
4949
|
}
|
|
4035
4950
|
const info = await stat(resolved.absolutePath);
|
|
4036
|
-
const etag =
|
|
4951
|
+
const etag = etagFromStat(info);
|
|
4037
4952
|
this.notify("file.changed", root.id, relativePath, {
|
|
4038
4953
|
etag,
|
|
4039
4954
|
size: info.size
|
|
@@ -4171,10 +5086,75 @@ var FileService = class {
|
|
|
4171
5086
|
});
|
|
4172
5087
|
}
|
|
4173
5088
|
};
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
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)}"`;
|
|
4178
5158
|
}
|
|
4179
5159
|
async function replaceFile(temp, destination, destinationExists) {
|
|
4180
5160
|
try {
|
|
@@ -4300,12 +5280,31 @@ function contentTypeFor$1(filePath) {
|
|
|
4300
5280
|
function encodeCursor(name) {
|
|
4301
5281
|
return Buffer.from(name, "utf8").toString("base64url");
|
|
4302
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
|
+
*/
|
|
4303
5303
|
function decodeCursor(cursor) {
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
}
|
|
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;
|
|
4309
5308
|
}
|
|
4310
5309
|
function trashView(record) {
|
|
4311
5310
|
return {
|
|
@@ -4449,6 +5448,95 @@ function probeHostServices(host, pluginVersion) {
|
|
|
4449
5448
|
};
|
|
4450
5449
|
}
|
|
4451
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)`);
|
|
5533
|
+
return {
|
|
5534
|
+
ran: true,
|
|
5535
|
+
idempotencyDeleted,
|
|
5536
|
+
auditDeleted
|
|
5537
|
+
};
|
|
5538
|
+
}
|
|
5539
|
+
//#endregion
|
|
4452
5540
|
//#region src/host/job-adapter.ts
|
|
4453
5541
|
/**
|
|
4454
5542
|
* job 适配层的错误。
|
|
@@ -4740,6 +5828,14 @@ function defaultDisplayName() {
|
|
|
4740
5828
|
//#endregion
|
|
4741
5829
|
//#region src/host/plugin-inventory.ts
|
|
4742
5830
|
/**
|
|
5831
|
+
* 读包清单时的并发上限。
|
|
5832
|
+
*
|
|
5833
|
+
* 为什么不是 `Promise.all` 全放:`names` 的长度由用户装了多少插件决定,
|
|
5834
|
+
* 理论上可以几十上百;一把放出去在慢盘/网络盘上反而更慢,而且失败时更难定位。
|
|
5835
|
+
* 6 与 `withUpdateCheck` 的网络并发同值,本仓已经验证过。
|
|
5836
|
+
*/
|
|
5837
|
+
const INVENTORY_READ_CONCURRENCY = 6;
|
|
5838
|
+
/**
|
|
4743
5839
|
* 把 profile 标识归一化成 profile 名。
|
|
4744
5840
|
*
|
|
4745
5841
|
* 内核接受**两种**写法,两者都合法:
|
|
@@ -4883,8 +5979,17 @@ async function readPluginInventory(input = {}) {
|
|
|
4883
5979
|
const bundles = stringArray((typeof dshSection.profile === "object" && dshSection.profile !== null ? dshSection.profile : {}).bundles);
|
|
4884
5980
|
const names = [.../* @__PURE__ */ new Set([...Object.keys(dependencies), ...bundles])].sort();
|
|
4885
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
|
+
}
|
|
4886
5991
|
for (const name of names) {
|
|
4887
|
-
const pkgManifest =
|
|
5992
|
+
const pkgManifest = manifests.get(name) ?? null;
|
|
4888
5993
|
const installedRaw = pkgManifest?.version;
|
|
4889
5994
|
const installed = typeof installedRaw === "string" && installedRaw.trim() ? installedRaw : null;
|
|
4890
5995
|
const loaded = bundles.includes(name);
|
|
@@ -5300,25 +6405,25 @@ function matchJobRoute(pathname) {
|
|
|
5300
6405
|
const jobs = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs$/);
|
|
5301
6406
|
if (jobs !== null) return {
|
|
5302
6407
|
kind: "list",
|
|
5303
|
-
sessionId:
|
|
6408
|
+
sessionId: decodePathSegment(jobs[1])
|
|
5304
6409
|
};
|
|
5305
6410
|
const output = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/output$/);
|
|
5306
6411
|
if (output !== null) return {
|
|
5307
6412
|
kind: "output",
|
|
5308
|
-
sessionId:
|
|
5309
|
-
jobId:
|
|
6413
|
+
sessionId: decodePathSegment(output[1]),
|
|
6414
|
+
jobId: decodePathSegment(output[2])
|
|
5310
6415
|
};
|
|
5311
6416
|
const kill = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/kill$/);
|
|
5312
6417
|
if (kill !== null) return {
|
|
5313
6418
|
kind: "kill",
|
|
5314
|
-
sessionId:
|
|
5315
|
-
jobId:
|
|
6419
|
+
sessionId: decodePathSegment(kill[1]),
|
|
6420
|
+
jobId: decodePathSegment(kill[2])
|
|
5316
6421
|
};
|
|
5317
6422
|
const bare = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)$/);
|
|
5318
6423
|
if (bare !== null) return {
|
|
5319
6424
|
kind: "unknown",
|
|
5320
|
-
sessionId:
|
|
5321
|
-
jobId:
|
|
6425
|
+
sessionId: decodePathSegment(bare[1]),
|
|
6426
|
+
jobId: decodePathSegment(bare[2])
|
|
5322
6427
|
};
|
|
5323
6428
|
return null;
|
|
5324
6429
|
}
|
|
@@ -5338,7 +6443,11 @@ function matchJobRoute(pathname) {
|
|
|
5338
6443
|
* 「远程能读到 running 但读不到 url」这个方案被**否决**了:那需要在判据 1 上再开一个口子,
|
|
5339
6444
|
* 而那正是本次修复要消灭的东西。`tunnelStateFor` 里还留了一层不变量检查作双保险。
|
|
5340
6445
|
*/
|
|
5341
|
-
const TUNNEL_MODE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
|
|
6446
|
+
const TUNNEL_MODE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
|
|
6447
|
+
"/manage/cloudflare/status",
|
|
6448
|
+
"/manage/cloudflare/activity",
|
|
6449
|
+
"/manage/cloudflare/stop"
|
|
6450
|
+
]);
|
|
5342
6451
|
var ApiRouter = class {
|
|
5343
6452
|
options;
|
|
5344
6453
|
constructor(options) {
|
|
@@ -5349,6 +6458,15 @@ var ApiRouter = class {
|
|
|
5349
6458
|
setCors(req, res);
|
|
5350
6459
|
res.setHeader("X-Request-Id", requestId);
|
|
5351
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
|
+
}
|
|
5352
6470
|
res.writeHead(204);
|
|
5353
6471
|
res.end();
|
|
5354
6472
|
return;
|
|
@@ -5383,25 +6501,30 @@ var ApiRouter = class {
|
|
|
5383
6501
|
});
|
|
5384
6502
|
return;
|
|
5385
6503
|
}
|
|
5386
|
-
if (method === "GET" && pathname === "/api/v1/debug/host-services") {
|
|
5387
|
-
const report = this.options.hostServiceReport;
|
|
5388
|
-
if (report === void 0) throw new ApiError(404, "NOT_FOUND", "host service probe is not available in this build.");
|
|
5389
|
-
sendJson(res, 200, report);
|
|
5390
|
-
return;
|
|
5391
|
-
}
|
|
5392
6504
|
if (method === "POST" && pathname === "/api/v1/pairings/exchange") {
|
|
5393
|
-
this.options.auth.
|
|
6505
|
+
this.options.auth.pairingLimiter(req).consume(requestAddress(req));
|
|
5394
6506
|
const body = await readJson(req, 16384);
|
|
5395
6507
|
if (typeof body.code !== "string" || typeof body.deviceName !== "string") throw new ApiError(400, "BODY_INVALID", "code and deviceName are required strings.");
|
|
5396
|
-
sendJson(res, 201, this.options.database.exchangePairing(body.code, body.deviceName));
|
|
6508
|
+
sendJson(res, 201, this.options.database.exchangePairing(body.code, body.deviceName, { requestId }));
|
|
5397
6509
|
return;
|
|
5398
6510
|
}
|
|
5399
|
-
if (pathname.startsWith("/manage")) {
|
|
6511
|
+
if (pathname === "/manage" || pathname.startsWith("/manage/")) {
|
|
5400
6512
|
this.options.auth.requireAdmin(req, { ...TUNNEL_MODE_EXEMPT_PATHS.has(pathname) ? { allowTunnelMode: true } : {} });
|
|
5401
6513
|
await this.dispatchManagement(req, res, url, requestId);
|
|
5402
6514
|
return;
|
|
5403
6515
|
}
|
|
5404
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
|
+
}
|
|
5405
6528
|
if (method === "GET" && pathname === "/api/v1/devices/self") {
|
|
5406
6529
|
if (principal.kind === "admin") sendJson(res, 200, {
|
|
5407
6530
|
id: principal.id,
|
|
@@ -5438,7 +6561,7 @@ var ApiRouter = class {
|
|
|
5438
6561
|
}
|
|
5439
6562
|
const rootRoute = pathname.match(/^\/api\/v1\/roots\/([^/]+)\/(entries|content)$/);
|
|
5440
6563
|
if (rootRoute !== null) {
|
|
5441
|
-
const rootId =
|
|
6564
|
+
const rootId = decodePathSegment(rootRoute[1]);
|
|
5442
6565
|
const resource = rootRoute[2];
|
|
5443
6566
|
const root = this.requireRoot(principal, rootId);
|
|
5444
6567
|
if (resource === "entries") {
|
|
@@ -5457,7 +6580,7 @@ var ApiRouter = class {
|
|
|
5457
6580
|
const restoreRoute = pathname.match(/^\/api\/v1\/trash\/([^/]+)\/restore$/);
|
|
5458
6581
|
if (method === "POST" && restoreRoute !== null) {
|
|
5459
6582
|
this.options.auth.requireScope(principal, "files.delete");
|
|
5460
|
-
const record = this.options.database.getTrash(
|
|
6583
|
+
const record = this.options.database.getTrash(decodePathSegment(restoreRoute[1]));
|
|
5461
6584
|
if (record === void 0) throw new ApiError(404, "TRASH_NOT_FOUND", "The trash item does not exist.");
|
|
5462
6585
|
const root = this.requireRoot(principal, record.rootId);
|
|
5463
6586
|
await this.options.files.restoreTrash(root, record.id);
|
|
@@ -5483,7 +6606,7 @@ var ApiRouter = class {
|
|
|
5483
6606
|
}
|
|
5484
6607
|
const workspaceRoute = pathname.match(/^\/api\/v1\/chat\/workspaces\/([^/]+)$/);
|
|
5485
6608
|
if (workspaceRoute !== null) {
|
|
5486
|
-
const workspaceId =
|
|
6609
|
+
const workspaceId = decodePathSegment(workspaceRoute[1]);
|
|
5487
6610
|
if (method === "PATCH") {
|
|
5488
6611
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5489
6612
|
const body = await readJson(req, 64e3);
|
|
@@ -5571,7 +6694,7 @@ var ApiRouter = class {
|
|
|
5571
6694
|
}
|
|
5572
6695
|
const providerRoute = pathname.match(/^\/api\/v1\/settings\/providers\/([^/]+)$/);
|
|
5573
6696
|
if (providerRoute !== null) {
|
|
5574
|
-
const providerId =
|
|
6697
|
+
const providerId = decodePathSegment(providerRoute[1]);
|
|
5575
6698
|
if (method === "PATCH") {
|
|
5576
6699
|
this.options.auth.requireScope(principal, "settings.write");
|
|
5577
6700
|
const body = await readJson(req, 1e6);
|
|
@@ -5597,7 +6720,7 @@ var ApiRouter = class {
|
|
|
5597
6720
|
const discoverProviderRoute = pathname.match(/^\/api\/v1\/settings\/providers\/([^/]+)\/discover$/);
|
|
5598
6721
|
if (method === "POST" && discoverProviderRoute !== null) {
|
|
5599
6722
|
this.options.auth.requireScope(principal, "settings.write");
|
|
5600
|
-
const providerId =
|
|
6723
|
+
const providerId = decodePathSegment(discoverProviderRoute[1]);
|
|
5601
6724
|
const body = await readJson(req, 64e3);
|
|
5602
6725
|
const draft = {
|
|
5603
6726
|
...typeof body.baseURL === "string" ? { baseURL: body.baseURL } : {},
|
|
@@ -5641,7 +6764,7 @@ var ApiRouter = class {
|
|
|
5641
6764
|
const sessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)$/);
|
|
5642
6765
|
if (method === "PATCH" && sessionRoute !== null) {
|
|
5643
6766
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5644
|
-
const sessionId =
|
|
6767
|
+
const sessionId = decodePathSegment(sessionRoute[1]);
|
|
5645
6768
|
const body = await readJson(req, 64e3);
|
|
5646
6769
|
if (typeof body.title !== "string") throw new ApiError(400, "BODY_INVALID", "title is required.");
|
|
5647
6770
|
const result = await this.options.chat.renameSession(principal, sessionId, body.title);
|
|
@@ -5652,7 +6775,7 @@ var ApiRouter = class {
|
|
|
5652
6775
|
const forkSessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/fork$/);
|
|
5653
6776
|
if (method === "POST" && forkSessionRoute !== null) {
|
|
5654
6777
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5655
|
-
const sessionId =
|
|
6778
|
+
const sessionId = decodePathSegment(forkSessionRoute[1]);
|
|
5656
6779
|
const body = await readJson(req, 16384);
|
|
5657
6780
|
const atSeq = body.atSeq === void 0 ? void 0 : body.atSeq;
|
|
5658
6781
|
if (atSeq !== void 0 && (!Number.isSafeInteger(atSeq) || atSeq < 0)) throw new ApiError(400, "BODY_INVALID", "atSeq must be a non-negative integer.");
|
|
@@ -5667,7 +6790,7 @@ var ApiRouter = class {
|
|
|
5667
6790
|
const archiveSessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/archive$/);
|
|
5668
6791
|
if (method === "POST" && archiveSessionRoute !== null) {
|
|
5669
6792
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5670
|
-
const sessionId =
|
|
6793
|
+
const sessionId = decodePathSegment(archiveSessionRoute[1]);
|
|
5671
6794
|
await this.options.chat.archiveSession(principal, sessionId);
|
|
5672
6795
|
this.audit(principal, "chat.session.archive", void 0, void 0, requestId, { sessionId });
|
|
5673
6796
|
sendJson(res, 200, { archived: true });
|
|
@@ -5704,7 +6827,7 @@ var ApiRouter = class {
|
|
|
5704
6827
|
}
|
|
5705
6828
|
const messagesRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/messages$/);
|
|
5706
6829
|
if (messagesRoute !== null) {
|
|
5707
|
-
const sessionId =
|
|
6830
|
+
const sessionId = decodePathSegment(messagesRoute[1]);
|
|
5708
6831
|
if (method === "GET") {
|
|
5709
6832
|
this.options.auth.requireScope(principal, "chat.read");
|
|
5710
6833
|
const beforeSeq = optionalInteger(url.searchParams.get("beforeSeq"));
|
|
@@ -5739,7 +6862,7 @@ var ApiRouter = class {
|
|
|
5739
6862
|
const attachmentRoute = pathname.match(/^\/api\/v1\/attachments\/([^/]+)$/);
|
|
5740
6863
|
if (attachmentRoute !== null && method === "GET") {
|
|
5741
6864
|
this.options.auth.requireScope(principal, "files.read");
|
|
5742
|
-
const attachmentId =
|
|
6865
|
+
const attachmentId = decodePathSegment(attachmentRoute[1]);
|
|
5743
6866
|
const sessionId = url.searchParams.get("sessionId")?.trim();
|
|
5744
6867
|
if (!sessionId) throw new ApiError(400, "BODY_INVALID", "sessionId query parameter is required.");
|
|
5745
6868
|
const { bytes, mediaType } = await this.options.chat.readAttachment(principal, sessionId, attachmentId);
|
|
@@ -5759,7 +6882,7 @@ var ApiRouter = class {
|
|
|
5759
6882
|
res.statusCode = 200;
|
|
5760
6883
|
res.setHeader("Content-Type", mediaType);
|
|
5761
6884
|
res.setHeader("Content-Length", String(bytes.byteLength));
|
|
5762
|
-
res.setHeader("Cache-Control", "private, max-age=
|
|
6885
|
+
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
|
|
5763
6886
|
res.end(Buffer.from(bytes));
|
|
5764
6887
|
return;
|
|
5765
6888
|
}
|
|
@@ -5769,12 +6892,12 @@ var ApiRouter = class {
|
|
|
5769
6892
|
const body = await readJson(req, 24e6);
|
|
5770
6893
|
if (typeof body.data !== "string" || body.data === "") throw new ApiError(400, "BODY_INVALID", "data (base64) is required.");
|
|
5771
6894
|
const name = typeof body.name === "string" && body.name.trim() !== "" ? body.name.trim() : void 0;
|
|
5772
|
-
sendJson(res, 201, await this.options.chat.uploadAttachment(principal,
|
|
6895
|
+
sendJson(res, 201, await this.options.chat.uploadAttachment(principal, decodePathSegment(attachmentsRoute[1]), body.data, name));
|
|
5773
6896
|
return;
|
|
5774
6897
|
}
|
|
5775
6898
|
const commandsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/commands$/);
|
|
5776
6899
|
if (commandsRoute !== null) {
|
|
5777
|
-
const sessionId =
|
|
6900
|
+
const sessionId = decodePathSegment(commandsRoute[1]);
|
|
5778
6901
|
if (method === "GET") {
|
|
5779
6902
|
this.options.auth.requireScope(principal, "chat.read");
|
|
5780
6903
|
sendJson(res, 200, { items: await this.options.chat.listCommands(principal, sessionId) });
|
|
@@ -5796,15 +6919,15 @@ var ApiRouter = class {
|
|
|
5796
6919
|
const approvalsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/approvals$/);
|
|
5797
6920
|
if (method === "GET" && approvalsRoute !== null) {
|
|
5798
6921
|
this.options.auth.requireScope(principal, "chat.read");
|
|
5799
|
-
const sessionId =
|
|
6922
|
+
const sessionId = decodePathSegment(approvalsRoute[1]);
|
|
5800
6923
|
sendJson(res, 200, { items: await this.options.chat.listPendingApprovals(principal, sessionId) });
|
|
5801
6924
|
return;
|
|
5802
6925
|
}
|
|
5803
6926
|
const approvalDecisionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/approvals\/([^/]+)\/decision$/);
|
|
5804
6927
|
if (method === "POST" && approvalDecisionRoute !== null) {
|
|
5805
6928
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5806
|
-
const sessionId =
|
|
5807
|
-
const approvalId =
|
|
6929
|
+
const sessionId = decodePathSegment(approvalDecisionRoute[1]);
|
|
6930
|
+
const approvalId = decodePathSegment(approvalDecisionRoute[2]);
|
|
5808
6931
|
const body = await readJson(req, 16384);
|
|
5809
6932
|
if (body.outcome !== "allowed-once" && body.outcome !== "rejected") throw new ApiError(400, "BODY_INVALID", "outcome must be allowed-once or rejected.");
|
|
5810
6933
|
const approval = await this.options.chat.decideApproval(principal, sessionId, approvalId, body.outcome);
|
|
@@ -5821,15 +6944,15 @@ var ApiRouter = class {
|
|
|
5821
6944
|
const questionsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions$/);
|
|
5822
6945
|
if (method === "GET" && questionsRoute !== null) {
|
|
5823
6946
|
this.options.auth.requireScope(principal, "chat.read");
|
|
5824
|
-
const sessionId =
|
|
6947
|
+
const sessionId = decodePathSegment(questionsRoute[1]);
|
|
5825
6948
|
sendJson(res, 200, { items: await this.options.chat.listUserQuestions(principal, sessionId) });
|
|
5826
6949
|
return;
|
|
5827
6950
|
}
|
|
5828
6951
|
const questionAnswerRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions\/([^/]+)\/answer$/);
|
|
5829
6952
|
if (method === "POST" && questionAnswerRoute !== null) {
|
|
5830
6953
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5831
|
-
const sessionId =
|
|
5832
|
-
const callId =
|
|
6954
|
+
const sessionId = decodePathSegment(questionAnswerRoute[1]);
|
|
6955
|
+
const callId = decodePathSegment(questionAnswerRoute[2]);
|
|
5833
6956
|
const answer = readUserQuestionAnswer(await readJson(req, 64e3));
|
|
5834
6957
|
await this.options.chat.answerUserQuestion(principal, sessionId, callId, answer);
|
|
5835
6958
|
this.audit(principal, "chat.question.answer", void 0, void 0, requestId, {
|
|
@@ -5843,7 +6966,7 @@ var ApiRouter = class {
|
|
|
5843
6966
|
}
|
|
5844
6967
|
const sessionModelsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/model(?:s)?$/);
|
|
5845
6968
|
if (sessionModelsRoute !== null) {
|
|
5846
|
-
const sessionId =
|
|
6969
|
+
const sessionId = decodePathSegment(sessionModelsRoute[1]);
|
|
5847
6970
|
if (method === "GET") {
|
|
5848
6971
|
this.options.auth.requireScope(principal, "chat.read");
|
|
5849
6972
|
sendJson(res, 200, await this.options.chat.models(principal, sessionId));
|
|
@@ -5871,7 +6994,7 @@ var ApiRouter = class {
|
|
|
5871
6994
|
const sessionAgentPresetRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/agent-preset$/);
|
|
5872
6995
|
if (method === "PUT" && sessionAgentPresetRoute !== null) {
|
|
5873
6996
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5874
|
-
const sessionId =
|
|
6997
|
+
const sessionId = decodePathSegment(sessionAgentPresetRoute[1]);
|
|
5875
6998
|
const body = await readJson(req, 64e3);
|
|
5876
6999
|
if (typeof body.agentPreset !== "string") throw new ApiError(400, "BODY_INVALID", "agentPreset is required.");
|
|
5877
7000
|
const agentPreset = await this.options.chat.selectAgentPreset(principal, sessionId, body.agentPreset);
|
|
@@ -5885,7 +7008,7 @@ var ApiRouter = class {
|
|
|
5885
7008
|
const cancelRoute = pathname.match(/^\/api\/v1\/chat\/runs\/([^/]+)\/cancel$/);
|
|
5886
7009
|
if (method === "POST" && cancelRoute !== null) {
|
|
5887
7010
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5888
|
-
const sessionId =
|
|
7011
|
+
const sessionId = decodePathSegment(cancelRoute[1]);
|
|
5889
7012
|
await this.options.chat.cancel(principal, sessionId);
|
|
5890
7013
|
this.audit(principal, "chat.run.cancel", void 0, void 0, requestId, { sessionId });
|
|
5891
7014
|
sendJson(res, 202, { accepted: true });
|
|
@@ -5912,12 +7035,12 @@ var ApiRouter = class {
|
|
|
5912
7035
|
if (method === "POST" && pathname === "/manage/roots") {
|
|
5913
7036
|
const body = await readJson(req, 64e3);
|
|
5914
7037
|
if (typeof body.path !== "string") throw new ApiError(400, "BODY_INVALID", "path is required.");
|
|
5915
|
-
sendJson(res, 201, await this.options.database.addRoot(body.path, typeof body.label === "string" ? body.label : void 0));
|
|
7038
|
+
sendJson(res, 201, await this.options.database.addRoot(body.path, typeof body.label === "string" ? body.label : void 0, { requestId }));
|
|
5916
7039
|
return;
|
|
5917
7040
|
}
|
|
5918
7041
|
const rootRoute = pathname.match(/^\/manage\/roots\/([^/]+)$/);
|
|
5919
7042
|
if (method === "DELETE" && rootRoute !== null) {
|
|
5920
|
-
this.options.database.removeRoot(
|
|
7043
|
+
this.options.database.removeRoot(decodePathSegment(rootRoute[1]));
|
|
5921
7044
|
res.writeHead(204);
|
|
5922
7045
|
res.end();
|
|
5923
7046
|
return;
|
|
@@ -5931,44 +7054,23 @@ var ApiRouter = class {
|
|
|
5931
7054
|
const body = await readJson(req, 64e3);
|
|
5932
7055
|
const scopes = validateScopes(body.scopes);
|
|
5933
7056
|
const rootIds = validateStringArray(body.rootIds, "rootIds");
|
|
5934
|
-
sendJson(res, 200, this.options.database.updateDevice(
|
|
7057
|
+
sendJson(res, 200, this.options.database.updateDevice(decodePathSegment(deviceRoute[1]), scopes, rootIds, { requestId }));
|
|
5935
7058
|
return;
|
|
5936
7059
|
}
|
|
5937
7060
|
if (deviceRoute !== null && method === "DELETE") {
|
|
5938
|
-
this.options.database.revokeDevice(
|
|
7061
|
+
this.options.database.revokeDevice(decodePathSegment(deviceRoute[1]), { requestId });
|
|
5939
7062
|
res.writeHead(204);
|
|
5940
7063
|
res.end();
|
|
5941
7064
|
return;
|
|
5942
7065
|
}
|
|
5943
7066
|
if (method === "POST" && pathname === "/manage/pairings") {
|
|
5944
7067
|
const body = await readJson(req, 64e3);
|
|
5945
|
-
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 }));
|
|
5946
7069
|
return;
|
|
5947
7070
|
}
|
|
5948
7071
|
if (method === "POST" && pathname === "/manage/config/text") {
|
|
5949
7072
|
const body = await readJson(req, 64e3);
|
|
5950
|
-
|
|
5951
|
-
const endpoints = readRequestedEndpoints(body.endpoints) ?? detectEndpoints(port);
|
|
5952
|
-
if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
|
|
5953
|
-
const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
|
|
5954
|
-
const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
|
|
5955
|
-
const deviceName = readOptionalText(body.deviceName) ?? "Android 设备";
|
|
5956
|
-
const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
|
|
5957
|
-
const pairing = this.options.database.createPairing(rootIds, scopes);
|
|
5958
|
-
const exchanged = this.options.database.exchangePairing(pairing.code, deviceName);
|
|
5959
|
-
sendJson(res, 201, {
|
|
5960
|
-
text: encodeConfigText({
|
|
5961
|
-
displayName,
|
|
5962
|
-
endpoints,
|
|
5963
|
-
token: exchanged.token,
|
|
5964
|
-
deviceName,
|
|
5965
|
-
scopes
|
|
5966
|
-
}),
|
|
5967
|
-
displayName,
|
|
5968
|
-
deviceName,
|
|
5969
|
-
endpoints,
|
|
5970
|
-
deviceId: exchanged.device.id
|
|
5971
|
-
});
|
|
7073
|
+
sendJson(res, 201, this.buildConfigText(body, requestId));
|
|
5972
7074
|
return;
|
|
5973
7075
|
}
|
|
5974
7076
|
if (method === "POST" && pathname === "/manage/remote/enable") {
|
|
@@ -6008,7 +7110,7 @@ var ApiRouter = class {
|
|
|
6008
7110
|
}
|
|
6009
7111
|
const purgeRoute = pathname.match(/^\/manage\/trash\/([^/]+)$/);
|
|
6010
7112
|
if (method === "DELETE" && purgeRoute !== null) {
|
|
6011
|
-
await this.options.files.purgeTrash(
|
|
7113
|
+
await this.options.files.purgeTrash(decodePathSegment(purgeRoute[1]));
|
|
6012
7114
|
this.options.database.audit("admin", "loopback-admin", "trash.purge", void 0, void 0, {}, requestId);
|
|
6013
7115
|
res.writeHead(204);
|
|
6014
7116
|
res.end();
|
|
@@ -6027,6 +7129,8 @@ var ApiRouter = class {
|
|
|
6027
7129
|
}
|
|
6028
7130
|
}
|
|
6029
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;
|
|
6030
7134
|
const binDir = this.options.cloudflaredBinDir;
|
|
6031
7135
|
let exe;
|
|
6032
7136
|
try {
|
|
@@ -6035,13 +7139,37 @@ var ApiRouter = class {
|
|
|
6035
7139
|
});
|
|
6036
7140
|
} catch (cause) {
|
|
6037
7141
|
this.options.tunnels.clearActivity();
|
|
6038
|
-
throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED",
|
|
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
|
+
});
|
|
6039
7146
|
}
|
|
6040
7147
|
const port = this.options.remote.status().configuredPort ?? 3090;
|
|
6041
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
|
+
}
|
|
6042
7163
|
this.options.tunnels.clearActivity();
|
|
6043
7164
|
this.options.database.audit("admin", "loopback-admin", "cloudflare.create", void 0, void 0, {}, requestId);
|
|
6044
|
-
|
|
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
|
+
});
|
|
6045
7173
|
return;
|
|
6046
7174
|
}
|
|
6047
7175
|
if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/stop") {
|
|
@@ -6067,6 +7195,42 @@ var ApiRouter = class {
|
|
|
6067
7195
|
* 评估结论见报告:本轮选择「远程一律 403」而不是「远程读得到但 `url` 为 null」,
|
|
6068
7196
|
* 因为后者需要为状态端点在判据 1 上再开一个口子,而那正是这次修复要消灭的东西。
|
|
6069
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
|
+
}
|
|
6070
7234
|
tunnelStateFor(req) {
|
|
6071
7235
|
const tunnels = this.options.tunnels;
|
|
6072
7236
|
if (tunnels === void 0) throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
|
|
@@ -6118,12 +7282,18 @@ var ApiRouter = class {
|
|
|
6118
7282
|
if (req.method === "GET") {
|
|
6119
7283
|
this.options.auth.requireScope(principal, "files.read");
|
|
6120
7284
|
const descriptor = await this.options.files.inspectContent(root, relativePath);
|
|
6121
|
-
|
|
7285
|
+
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
|
|
6122
7286
|
res.setHeader("Accept-Ranges", "bytes");
|
|
6123
7287
|
res.setHeader("ETag", descriptor.etag);
|
|
6124
7288
|
res.setHeader("Last-Modified", new Date(descriptor.modifiedAt).toUTCString());
|
|
6125
7289
|
res.setHeader("Content-Type", descriptor.contentType);
|
|
6126
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
|
+
}
|
|
6127
7297
|
if (range === void 0) {
|
|
6128
7298
|
res.writeHead(200, { "Content-Length": descriptor.size });
|
|
6129
7299
|
this.options.files.streamContent(descriptor).pipe(res);
|
|
@@ -6208,17 +7378,71 @@ function sendJson(res, status, body) {
|
|
|
6208
7378
|
});
|
|
6209
7379
|
res.end(encoded);
|
|
6210
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
|
+
*/
|
|
6211
7420
|
function setCors(req, res) {
|
|
6212
7421
|
const origin = req.headers.origin;
|
|
6213
|
-
if (origin !== void 0) {
|
|
7422
|
+
if (origin !== void 0 && isLoopbackOriginValue(origin)) {
|
|
6214
7423
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
6215
7424
|
res.setHeader("Vary", "Origin");
|
|
6216
7425
|
}
|
|
6217
|
-
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match, X-Request-Id
|
|
7426
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match, X-Request-Id");
|
|
6218
7427
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
6219
7428
|
res.setHeader("Access-Control-Expose-Headers", "ETag, Content-Range, X-Request-Id");
|
|
6220
7429
|
res.setHeader("Cache-Control", "no-store");
|
|
6221
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
|
+
}
|
|
6222
7446
|
function validateScopes(value) {
|
|
6223
7447
|
const values = validateStringArray(value, "scopes");
|
|
6224
7448
|
if (values.some((item) => !DEVICE_SCOPES.includes(item))) throw new ApiError(400, "SCOPE_INVALID", "The request contains an unknown device scope.");
|
|
@@ -6351,6 +7575,39 @@ function optionalInteger(value) {
|
|
|
6351
7575
|
function commandName(line) {
|
|
6352
7576
|
return /^\/([a-z][a-z0-9_-]*)/u.exec(line)?.[1] ?? "unknown";
|
|
6353
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
|
+
}
|
|
6354
7611
|
function parseRange(value, size) {
|
|
6355
7612
|
if (value === void 0) return void 0;
|
|
6356
7613
|
const match = /^bytes=(\d*)-(\d*)$/.exec(value);
|
|
@@ -6376,8 +7633,72 @@ function header(req, name) {
|
|
|
6376
7633
|
const value = req.headers[name];
|
|
6377
7634
|
return Array.isArray(value) ? value[0] : value;
|
|
6378
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
|
+
}
|
|
6379
7669
|
//#endregion
|
|
6380
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
|
+
}
|
|
6381
7702
|
var RemoteApiServer = class {
|
|
6382
7703
|
options;
|
|
6383
7704
|
server;
|
|
@@ -6388,14 +7709,18 @@ var RemoteApiServer = class {
|
|
|
6388
7709
|
remoteEnabled = false;
|
|
6389
7710
|
operations = /* @__PURE__ */ new Map();
|
|
6390
7711
|
sockets = /* @__PURE__ */ new Map();
|
|
6391
|
-
webSockets
|
|
6392
|
-
noServer: true,
|
|
6393
|
-
clientTracking: false
|
|
6394
|
-
});
|
|
7712
|
+
webSockets;
|
|
6395
7713
|
eventsAbort = new AbortController();
|
|
6396
7714
|
unsubscribeEvents;
|
|
6397
|
-
|
|
7715
|
+
/** 所有连接的 drain 完成信号;`Promise.all` 它们即可等排空。 */
|
|
7716
|
+
pendingDrains = /* @__PURE__ */ new Set();
|
|
7717
|
+
heartbeatTimer;
|
|
7718
|
+
changeTimer;
|
|
6398
7719
|
changing = false;
|
|
7720
|
+
maxConnections;
|
|
7721
|
+
socketQueueLimit;
|
|
7722
|
+
highWaterMarkBytes;
|
|
7723
|
+
heartbeatIntervalMs;
|
|
6399
7724
|
constructor(options) {
|
|
6400
7725
|
this.options = options;
|
|
6401
7726
|
const fallbackHost = normalizeListenerHost(options.remoteHost ?? "0.0.0.0");
|
|
@@ -6403,6 +7728,15 @@ var RemoteApiServer = class {
|
|
|
6403
7728
|
this.configuredHost = readStoredHost(options.database, fallbackHost);
|
|
6404
7729
|
this.configuredPort = readStoredPort(options.database, fallbackPort);
|
|
6405
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
|
+
});
|
|
6406
7740
|
}
|
|
6407
7741
|
async start() {
|
|
6408
7742
|
this.remoteEnabled = this.options.database.getSetting("remote_enabled") === "true";
|
|
@@ -6416,8 +7750,9 @@ var RemoteApiServer = class {
|
|
|
6416
7750
|
await this.bind(loopbackFor(this.configuredHost), this.configuredPort);
|
|
6417
7751
|
}
|
|
6418
7752
|
this.unsubscribeEvents = this.options.events.subscribe((event) => {
|
|
6419
|
-
this.
|
|
7753
|
+
this.enqueueBroadcast(event);
|
|
6420
7754
|
});
|
|
7755
|
+
this.startHeartbeat();
|
|
6421
7756
|
this.options.chat.startEvents(this.eventsAbort.signal, (event) => {
|
|
6422
7757
|
this.options.events.emit(event);
|
|
6423
7758
|
});
|
|
@@ -6426,9 +7761,15 @@ var RemoteApiServer = class {
|
|
|
6426
7761
|
this.eventsAbort.abort();
|
|
6427
7762
|
this.unsubscribeEvents?.();
|
|
6428
7763
|
this.unsubscribeEvents = void 0;
|
|
6429
|
-
|
|
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);
|
|
6430
7770
|
await this.closeCurrent();
|
|
6431
7771
|
this.webSockets.close();
|
|
7772
|
+
setWsConnections(0);
|
|
6432
7773
|
}
|
|
6433
7774
|
status(operationId) {
|
|
6434
7775
|
const operation = operationId === void 0 ? void 0 : this.operations.get(operationId);
|
|
@@ -6481,11 +7822,21 @@ var RemoteApiServer = class {
|
|
|
6481
7822
|
};
|
|
6482
7823
|
this.operations.set(operation.id, operation);
|
|
6483
7824
|
this.changing = true;
|
|
6484
|
-
|
|
7825
|
+
this.clearChangeTimer();
|
|
7826
|
+
this.changeTimer = setTimeout(() => {
|
|
7827
|
+
this.changeTimer = void 0;
|
|
6485
7828
|
this.performChange(operation);
|
|
6486
7829
|
}, 75);
|
|
7830
|
+
this.changeTimer.unref?.();
|
|
6487
7831
|
return publicOperation(operation);
|
|
6488
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
|
+
}
|
|
6489
7840
|
async performChange(operation) {
|
|
6490
7841
|
const previousHost = this.host;
|
|
6491
7842
|
const previousPort = this.actualPort;
|
|
@@ -6543,7 +7894,7 @@ var RemoteApiServer = class {
|
|
|
6543
7894
|
}
|
|
6544
7895
|
async createBoundServer(host, port) {
|
|
6545
7896
|
const server = createServer((req, res) => {
|
|
6546
|
-
this.options.handle(req, res).catch((error) => failResponse(res, error));
|
|
7897
|
+
Promise.resolve().then(() => this.options.handle(req, res)).catch((error) => failResponse(res, error));
|
|
6547
7898
|
});
|
|
6548
7899
|
server.requestTimeout = 3e4;
|
|
6549
7900
|
server.headersTimeout = 1e4;
|
|
@@ -6565,14 +7916,42 @@ var RemoteApiServer = class {
|
|
|
6565
7916
|
port: server.address().port
|
|
6566
7917
|
};
|
|
6567
7918
|
}
|
|
7919
|
+
/**
|
|
7920
|
+
* 关掉当前监听与全部连接,**有界**。
|
|
7921
|
+
*
|
|
7922
|
+
* `server.close()` 的回调要等所有连接结束;而**升级过的 WebSocket 不在
|
|
7923
|
+
* `closeAllConnections()` 的管理范围里**(HTTP 层已经不跟踪它了)。于是只要有一条
|
|
7924
|
+
* 连接读不动(暂停读 / 半开),`close()` 就永远等不到回调 —— 插件卸载会挂死。
|
|
7925
|
+
*
|
|
7926
|
+
* 所以这里的顺序是:先礼后兵 —— 发 1012(客户端会看到「服务端重绑」而不是异常断开),
|
|
7927
|
+
* 等一小段宽限期;宽限期到就 `terminate()`,同时让 `close()` 的等待一起收尾。
|
|
7928
|
+
*/
|
|
6568
7929
|
async closeCurrent() {
|
|
6569
7930
|
const server = this.server;
|
|
6570
7931
|
if (server === void 0) return;
|
|
6571
7932
|
this.server = void 0;
|
|
6572
|
-
|
|
7933
|
+
const pending = [...this.sockets.keys()];
|
|
7934
|
+
for (const socket of pending) try {
|
|
7935
|
+
socket.close(1012, "Listener rebinding");
|
|
7936
|
+
} catch {}
|
|
6573
7937
|
this.sockets.clear();
|
|
7938
|
+
setWsConnections(0);
|
|
6574
7939
|
await new Promise((resolve) => {
|
|
6575
|
-
|
|
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);
|
|
6576
7955
|
server.closeAllConnections();
|
|
6577
7956
|
});
|
|
6578
7957
|
}
|
|
@@ -6585,27 +7964,84 @@ var RemoteApiServer = class {
|
|
|
6585
7964
|
const protocolToken = parseProtocolToken(req.headers["sec-websocket-protocol"]);
|
|
6586
7965
|
if (protocolToken !== void 0) req.headers.authorization = `Bearer ${protocolToken}`;
|
|
6587
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
|
+
}
|
|
6588
7972
|
this.webSockets.handleUpgrade(req, socket, head, (webSocket) => {
|
|
6589
|
-
|
|
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
|
+
});
|
|
6590
7989
|
webSocket.on("close", () => {
|
|
7990
|
+
this.clearSocketTimers(state);
|
|
6591
7991
|
this.sockets.delete(webSocket);
|
|
7992
|
+
setWsConnections(this.sockets.size);
|
|
6592
7993
|
});
|
|
6593
7994
|
webSocket.on("error", () => {
|
|
7995
|
+
this.clearSocketTimers(state);
|
|
6594
7996
|
this.sockets.delete(webSocket);
|
|
7997
|
+
setWsConnections(this.sockets.size);
|
|
6595
7998
|
});
|
|
6596
7999
|
webSocket.send(stringifyWire({
|
|
6597
8000
|
id: randomUUID(),
|
|
6598
8001
|
type: "connection.ready",
|
|
6599
8002
|
time: Date.now(),
|
|
6600
|
-
data: connectionReadyData()
|
|
8003
|
+
data: connectionReadyData(this.options.capabilities ?? {})
|
|
6601
8004
|
}));
|
|
6602
|
-
this.replayPendingInteractions(webSocket,
|
|
8005
|
+
this.replayPendingInteractions(webSocket, state);
|
|
6603
8006
|
});
|
|
6604
8007
|
} catch {
|
|
6605
8008
|
socket.destroy();
|
|
6606
8009
|
}
|
|
6607
8010
|
}
|
|
6608
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
|
+
/**
|
|
6609
8045
|
* 把待处理的**审批与提问**补推给刚建立的这条连接。
|
|
6610
8046
|
*
|
|
6611
8047
|
* 为什么要有这一步:「连接后要不要主动查一次 REST」是**每个客户端自己的选择**。
|
|
@@ -6614,43 +8050,213 @@ var RemoteApiServer = class {
|
|
|
6614
8050
|
* 重连窗口里产生的审批就会静默消失(内核阻塞着等决定、手机上却一片安静)。
|
|
6615
8051
|
* 重放把「新连接一定能拿到仍待处理的审批」变成**服务端保证**,而不是客户端的义务。
|
|
6616
8052
|
*
|
|
6617
|
-
*
|
|
8053
|
+
* 四个刻意的取舍:
|
|
6618
8054
|
* - **不 await**:重放要按会话做授权过滤(异步),握手不能为它排队;
|
|
6619
8055
|
* - **只发这一条连接**:不走 `broadcast`,避免每次有人重连就骚扰所有在线设备;
|
|
6620
8056
|
* - **失败静默**:重放是补救措施,它出错不该影响这条连接正常收事件。
|
|
6621
|
-
*
|
|
8057
|
+
* 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖;
|
|
8058
|
+
* - **走连接自己的队列**:重放帧与随后的广播帧必须保序。直接 `socket.send`
|
|
8059
|
+
* 会让「重放的第 2 帧」插到「广播的第 1 帧」前面去。
|
|
6622
8060
|
*/
|
|
6623
|
-
async replayPendingInteractions(socket,
|
|
8061
|
+
async replayPendingInteractions(socket, state) {
|
|
6624
8062
|
try {
|
|
6625
|
-
const events = [...await this.options.chat.replayPendingApprovals(principal), ...await this.options.chat.replayPendingQuestions(principal)];
|
|
6626
|
-
for (const event of events)
|
|
6627
|
-
if (socket.readyState !== WebSocket.OPEN) return;
|
|
6628
|
-
socket.send(stringifyWire(event));
|
|
6629
|
-
}
|
|
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);
|
|
6630
8065
|
} catch {}
|
|
6631
8066
|
}
|
|
6632
|
-
|
|
6633
|
-
|
|
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) {
|
|
6634
8084
|
if (socket.readyState !== WebSocket.OPEN) continue;
|
|
6635
|
-
|
|
6636
|
-
if (
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
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();
|
|
6650
8115
|
}
|
|
6651
|
-
|
|
6652
|
-
|
|
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;
|
|
8175
|
+
}
|
|
8176
|
+
this.sendFrame(socket, event);
|
|
6653
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;
|
|
6654
8260
|
}
|
|
6655
8261
|
async eventAllowed(principal, event) {
|
|
6656
8262
|
if (event.type.startsWith("file.") && !principal.scopes.has("files.read")) return false;
|
|
@@ -6661,6 +8267,20 @@ var RemoteApiServer = class {
|
|
|
6661
8267
|
if (sessionId !== void 0) return this.options.chat.isSessionAuthorized(principal, sessionId);
|
|
6662
8268
|
return rootId !== void 0 || principal.kind === "admin";
|
|
6663
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
|
+
}
|
|
6664
8284
|
trimOperations() {
|
|
6665
8285
|
while (this.operations.size > 20) {
|
|
6666
8286
|
const first = this.operations.keys().next().value;
|
|
@@ -6670,6 +8290,7 @@ var RemoteApiServer = class {
|
|
|
6670
8290
|
}
|
|
6671
8291
|
};
|
|
6672
8292
|
function attachEmbeddedRoutes(webServer, router, remote, extraRoutes = []) {
|
|
8293
|
+
assertExtraRoutePrefixes(extraRoutes);
|
|
6673
8294
|
const disposers = [webServer.register({
|
|
6674
8295
|
kind: "prefix",
|
|
6675
8296
|
path: "/dsh-workspace-api",
|
|
@@ -6679,6 +8300,29 @@ function attachEmbeddedRoutes(webServer, router, remote, extraRoutes = []) {
|
|
|
6679
8300
|
for (const dispose of disposers.reverse()) dispose();
|
|
6680
8301
|
};
|
|
6681
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
|
+
}
|
|
6682
8326
|
function publicOperation(operation) {
|
|
6683
8327
|
return {
|
|
6684
8328
|
id: operation.id,
|
|
@@ -6723,15 +8367,26 @@ function parseProtocolToken(value) {
|
|
|
6723
8367
|
if (joined === void 0) return void 0;
|
|
6724
8368
|
return joined.split(",").map((item) => item.trim()).find((item) => item.startsWith("bearer."))?.slice(7);
|
|
6725
8369
|
}
|
|
8370
|
+
/**
|
|
8371
|
+
* 500 兜底。
|
|
8372
|
+
*
|
|
8373
|
+
* `requestId` **必须**复用 router 已经写进 `X-Request-Id` 响应头的那个值:
|
|
8374
|
+
* 用户报障时给的是 body 里的 id,而运维翻日志靠的是头里的 id —— 两个不同的
|
|
8375
|
+
* UUID 等于「这条线索永远搜不到」。修复前这里**另生成一个** `randomUUID()`。
|
|
8376
|
+
*
|
|
8377
|
+
* 头不存在时(理论上只有 router 之外的调用方)才新生成,保证字段不为空。
|
|
8378
|
+
*/
|
|
6726
8379
|
function failResponse(res, error) {
|
|
6727
8380
|
if (res.headersSent) {
|
|
6728
8381
|
res.destroy(error instanceof Error ? error : void 0);
|
|
6729
8382
|
return;
|
|
6730
8383
|
}
|
|
8384
|
+
const headerValue = res.getHeader("x-request-id");
|
|
8385
|
+
const requestId = typeof headerValue === "string" && headerValue !== "" ? headerValue : randomUUID();
|
|
6731
8386
|
const body = Buffer.from(JSON.stringify({ error: {
|
|
6732
8387
|
code: "INTERNAL_ERROR",
|
|
6733
8388
|
message: "An internal server error occurred.",
|
|
6734
|
-
requestId
|
|
8389
|
+
requestId
|
|
6735
8390
|
} }));
|
|
6736
8391
|
res.writeHead(500, {
|
|
6737
8392
|
"Content-Type": "application/json",
|
|
@@ -6741,6 +8396,15 @@ function failResponse(res, error) {
|
|
|
6741
8396
|
}
|
|
6742
8397
|
//#endregion
|
|
6743
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;
|
|
6744
8408
|
var DshSettingsAdapter = class {
|
|
6745
8409
|
api;
|
|
6746
8410
|
constructor(api) {
|
|
@@ -6891,6 +8555,7 @@ var DshSettingsAdapter = class {
|
|
|
6891
8555
|
}
|
|
6892
8556
|
}));
|
|
6893
8557
|
}
|
|
8558
|
+
this.invalidateProviderContext(providerId);
|
|
6894
8559
|
}
|
|
6895
8560
|
async createProvider(input) {
|
|
6896
8561
|
const id = input.id.trim();
|
|
@@ -6941,6 +8606,7 @@ var DshSettingsAdapter = class {
|
|
|
6941
8606
|
value: key
|
|
6942
8607
|
}
|
|
6943
8608
|
}));
|
|
8609
|
+
this.invalidateProviderContext(id);
|
|
6944
8610
|
}
|
|
6945
8611
|
async removeProvider(providerId, expectedRevision) {
|
|
6946
8612
|
const context = await this.providerContext(providerId);
|
|
@@ -6957,6 +8623,7 @@ var DshSettingsAdapter = class {
|
|
|
6957
8623
|
...expectedRevision === void 0 ? {} : { expectedRevision }
|
|
6958
8624
|
}
|
|
6959
8625
|
}));
|
|
8626
|
+
this.invalidateProviderContext(providerId);
|
|
6960
8627
|
}
|
|
6961
8628
|
async discover(providerId, draft) {
|
|
6962
8629
|
const payload = {
|
|
@@ -6971,7 +8638,47 @@ var DshSettingsAdapter = class {
|
|
|
6971
8638
|
payload
|
|
6972
8639
|
})).models;
|
|
6973
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();
|
|
6974
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) {
|
|
6975
8682
|
const [directoryResponse, settingsResponse] = await Promise.all([this.api.llm.providers({
|
|
6976
8683
|
rpcId: randomUUID(),
|
|
6977
8684
|
payload: {}
|
|
@@ -7130,6 +8837,7 @@ async function apply(ctx, config = {}) {
|
|
|
7130
8837
|
} catch (error) {
|
|
7131
8838
|
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: could not purge revoked devices: ${String(error)}`));
|
|
7132
8839
|
}
|
|
8840
|
+
runStartupRetention(database, ctx.logger);
|
|
7133
8841
|
await registerBuiltinWorkspaceRoot(ctx, database);
|
|
7134
8842
|
const events = new WorkspaceEventBus();
|
|
7135
8843
|
const tunnels = new CloudflaredTunnelManager();
|
|
@@ -7171,7 +8879,8 @@ async function apply(ctx, config = {}) {
|
|
|
7171
8879
|
auth,
|
|
7172
8880
|
chat,
|
|
7173
8881
|
events,
|
|
7174
|
-
handle: (req, res) => router.handle(req, res)
|
|
8882
|
+
handle: (req, res) => router.handle(req, res),
|
|
8883
|
+
capabilities: { jobs: jobControllerAvailable }
|
|
7175
8884
|
});
|
|
7176
8885
|
router = new ApiRouter({
|
|
7177
8886
|
database,
|