dsh-deeppilot 0.9.8 → 0.9.11
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/COMPATIBILITY.md +78 -5
- package/README.md +34 -10
- package/README.zh-CN.md +24 -8
- package/bin/SHA256SUMS +6 -6
- package/bin/darwin-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/darwin-arm64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-arm64/dsh-deeppilot-tunnel +0 -0
- package/bin/windows-amd64/dsh-deeppilot-tunnel.exe +0 -0
- package/bin/windows-arm64/dsh-deeppilot-tunnel.exe +0 -0
- package/docs/DSH_RELEASE_MEMORY.md +148 -9
- package/docs/NPM_PUBLISHING.md +30 -0
- package/lib/client.js +152 -4
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +8 -1
- package/lib/index.js +690 -54
- package/lib/index.js.map +1 -1
- package/package.json +13 -12
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
|
|
3
|
-
import { access, chmod, mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { access, appendFile, chmod, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { WebSocketServer } from "ws";
|
|
6
6
|
import { homedir, networkInterfaces } from "node:os";
|
|
@@ -394,6 +394,333 @@ var AuthRateLimiter = class {
|
|
|
394
394
|
}
|
|
395
395
|
};
|
|
396
396
|
//#endregion
|
|
397
|
+
//#region src/diagnostics.ts
|
|
398
|
+
/**
|
|
399
|
+
* 连接诊断事件模型(Diagnostics)——插件唯一的「失败可观测」面。
|
|
400
|
+
*
|
|
401
|
+
* 为什么需要独立模块:本插件的日志出口原先是 `apply()` 里的一个闭包
|
|
402
|
+
* (`console.log('[deeppilot] ' + message)`),无时间戳、无级别、无来源;
|
|
403
|
+
* 连接被拒、认证失败、TLS 握手失败都各自静默结束,用户在设置页只看到
|
|
404
|
+
* 「连不上」三个字。把事件模型、节流、环形缓冲与落盘收在这里,目的有三:
|
|
405
|
+
*
|
|
406
|
+
* 1. 一次失败 = 一条结构化事件(时间戳 + kind + reason + 来源哈希 +
|
|
407
|
+
* 可选 attemptId / 关闭码 / 耗时),微信、tailscale、扫描器刷屏时仍
|
|
408
|
+
* 只留可读的十几行;
|
|
409
|
+
* 2. index.ts / connection-gate.ts / phone-server.ts / connection.ts 四个
|
|
410
|
+
* 入口共用同一个 `record(kind, reason, extra)`,失败路径不再各自发明
|
|
411
|
+
* 日志文案;
|
|
412
|
+
* 3. 环形 256 条 + 按天落盘(保留 7 天、目录上限 5MB)让「刚刚那次连不上」
|
|
413
|
+
* 在重启前后都能查;`recent()` / `lastFailure()` 同时供 `/phone/health`
|
|
414
|
+
* 与设置页诊断区块读取。
|
|
415
|
+
*
|
|
416
|
+
* 硬约束(与 AGENTS.md 一致):
|
|
417
|
+
* - 事件里只出现 auditLabel 产生的来源哈希,绝不出现原始 IP;
|
|
418
|
+
* - attemptId 只记录、不校验、不参与任何准入判断;长度 ≤16,字符集
|
|
419
|
+
* `[A-Za-z0-9_-]`,来源不可信(WS query `at` / HTTP header
|
|
420
|
+
* `X-DeepPilot-Attempt`);
|
|
421
|
+
* - 不打印配对码、公钥原文、APNs token、relay 凭据与消息正文。detail
|
|
422
|
+
* 字段只接受代码生成的短文本(如 socket 错误码),最长 160 字符。
|
|
423
|
+
*
|
|
424
|
+
* 与 `diagnostics.debug` 的关系:本模块的事件**无条件记录**。那个开关只
|
|
425
|
+
* 控制现有的 per-frame / 推送日志,失败事件若也绑到它上面,用户不开调试
|
|
426
|
+
* 开关就永远看不到为什么连不上——这正是本轮要修的 P0。
|
|
427
|
+
*/
|
|
428
|
+
/**
|
|
429
|
+
* 统一日志行格式:`[deeppilot] <ISO 时间戳> <LEVEL> <message>`。
|
|
430
|
+
*
|
|
431
|
+
* 时间戳是 P0 的可观测性基线:没有它,用户贴回来的日志无法与手机端时间
|
|
432
|
+
* 对齐,也无法判断「刚才那次」是哪一次。ISO-8601(UTC)避免时区误读。
|
|
433
|
+
*/
|
|
434
|
+
function formatLogLine(level, message, at = Date.now()) {
|
|
435
|
+
return `[deeppilot] ${new Date(at).toISOString()} ${level.toUpperCase()} ${message}`;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* 插件日志函数。单参调用即 info;失败路径显式传 'warn'。
|
|
439
|
+
* 返回值的双参签名可以安全地传给只接受 `(message: string) => void` 的
|
|
440
|
+
* 既有依赖(PushGateway / HostBridge / 两个 transport 行)。
|
|
441
|
+
*/
|
|
442
|
+
function createLogger() {
|
|
443
|
+
return (message, level = "info") => {
|
|
444
|
+
console.log(formatLogLine(level, message));
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const MAX_DETAIL_CHARS = 160;
|
|
448
|
+
const MAX_REASON_CHARS = 64;
|
|
449
|
+
const DEFAULT_CAPACITY$1 = 256;
|
|
450
|
+
/** 同 kind + reason + source 在 60 秒内只输出一行。 */
|
|
451
|
+
const DEFAULT_THROTTLE_MS = 6e4;
|
|
452
|
+
const INFO_KINDS = /* @__PURE__ */ new Set(["connection-opened"]);
|
|
453
|
+
function defaultLevel(kind) {
|
|
454
|
+
return INFO_KINDS.has(kind) ? "info" : "warn";
|
|
455
|
+
}
|
|
456
|
+
/** 事件文本一律单行:换成空格并去掉控制字符,避免日志被注入换行。 */
|
|
457
|
+
function sanitizeText(value, maxChars) {
|
|
458
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ").replace(/\s+/g, " ").trim().slice(0, maxChars);
|
|
459
|
+
}
|
|
460
|
+
function normalizeReason(reason) {
|
|
461
|
+
return sanitizeText(reason, MAX_REASON_CHARS).replace(/\s+/g, "-");
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* 来源只允许短标识(auditLabel 的 12 位十六进制)。原始 IP 含 `.` / `:`,
|
|
465
|
+
* 会在这一步被丢弃——即便某个调用方忘了哈希,也不会有 IP 落进日志。
|
|
466
|
+
*/
|
|
467
|
+
function normalizeSource(value) {
|
|
468
|
+
if (typeof value !== "string") return void 0;
|
|
469
|
+
return /^[A-Za-z0-9_-]{1,64}$/.test(value) ? value : void 0;
|
|
470
|
+
}
|
|
471
|
+
const ATTEMPT_ID_HEADER = "x-deeppilot-attempt";
|
|
472
|
+
/** 只允许 URL 安全字符,最长 16:记录用,永不回显或参与判定。 */
|
|
473
|
+
const ATTEMPT_ID_PATTERN = /^[A-Za-z0-9_-]{1,16}$/;
|
|
474
|
+
/** 归一化一个客户端提供的 attemptId;不合法即丢弃(不报错、不拒绝请求)。 */
|
|
475
|
+
function normalizeAttemptId(value) {
|
|
476
|
+
if (typeof value !== "string") return void 0;
|
|
477
|
+
return ATTEMPT_ID_PATTERN.test(value) ? value : void 0;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* 从一条 HTTP / WS 升级请求里取 attemptId。
|
|
481
|
+
*
|
|
482
|
+
* 优先 HTTP header(真实 HTTP 客户端可自由设置),其次 WS 升级 URL 的
|
|
483
|
+
* `?at=`(浏览器 / URLSession 的 WS 握手无法自定义 header)。两个来源都
|
|
484
|
+
* 经过同一份长度与字符集校验;任何异常(畸形 URL、数组头)都按「没有
|
|
485
|
+
* attemptId」处理——这是纯观测字段,绝不能因为它让握手失败。
|
|
486
|
+
*/
|
|
487
|
+
function attemptIdFromRequest(request) {
|
|
488
|
+
const header = request.headers?.[ATTEMPT_ID_HEADER];
|
|
489
|
+
const fromHeader = normalizeAttemptId(Array.isArray(header) ? header[0] : header);
|
|
490
|
+
if (fromHeader !== void 0) return fromHeader;
|
|
491
|
+
if (typeof request.url !== "string" || request.url.length === 0) return void 0;
|
|
492
|
+
try {
|
|
493
|
+
return normalizeAttemptId(new URL(request.url, "http://phone.local").searchParams.get("at"));
|
|
494
|
+
} catch {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* 内存环形 + 节流日志 + 按天落盘。
|
|
500
|
+
*
|
|
501
|
+
* 职责边界:它不做任何策略判断(不决定是否拒绝连接),只负责把调用方
|
|
502
|
+
* 已经判定的失败事实记下来。因此它可以在四个入口之间安全共用。
|
|
503
|
+
*/
|
|
504
|
+
var DiagnosticsStore = class {
|
|
505
|
+
events = [];
|
|
506
|
+
throttles = /* @__PURE__ */ new Map();
|
|
507
|
+
capacity;
|
|
508
|
+
throttleMs;
|
|
509
|
+
now;
|
|
510
|
+
write;
|
|
511
|
+
dir;
|
|
512
|
+
tail = Promise.resolve();
|
|
513
|
+
/** 已统计过的文件大小,避免每次 append 前都 stat。 */
|
|
514
|
+
fileSizes = /* @__PURE__ */ new Map();
|
|
515
|
+
directoryBytes = 0;
|
|
516
|
+
/** 最近一次执行过保留期清理的 UTC 日期(YYYY-MM-DD)。 */
|
|
517
|
+
prunedDay;
|
|
518
|
+
/** 落盘自身失败只报一次:否则磁盘满会把日志刷成新的刷屏源。 */
|
|
519
|
+
writeFailureReported = false;
|
|
520
|
+
constructor(options = {}) {
|
|
521
|
+
this.capacity = options.capacity ?? DEFAULT_CAPACITY$1;
|
|
522
|
+
this.throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
|
|
523
|
+
this.now = options.now ?? Date.now;
|
|
524
|
+
this.write = options.write ?? ((line) => {
|
|
525
|
+
console.log(line);
|
|
526
|
+
});
|
|
527
|
+
this.dir = options.dir;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* 记录一条事件。事件总是进入内存环形;终端日志与落盘按
|
|
531
|
+
* kind + reason + source 节流(60s 一行,恢复时标注 suppressed 次数)。
|
|
532
|
+
* 返回完整事件,方便调用方测试与断言。
|
|
533
|
+
*/
|
|
534
|
+
record(kind, reason, extra = {}) {
|
|
535
|
+
const ts = this.now();
|
|
536
|
+
const attemptId = normalizeAttemptId(extra.attemptId);
|
|
537
|
+
const source = normalizeSource(extra.source);
|
|
538
|
+
const event = {
|
|
539
|
+
ts,
|
|
540
|
+
kind,
|
|
541
|
+
reason: normalizeReason(reason),
|
|
542
|
+
level: extra.level ?? defaultLevel(kind),
|
|
543
|
+
...attemptId !== void 0 ? { attemptId } : {},
|
|
544
|
+
...source !== void 0 ? { source } : {},
|
|
545
|
+
...extra.closeCode !== void 0 ? { closeCode: extra.closeCode } : {},
|
|
546
|
+
...extra.durationMs !== void 0 ? { durationMs: Math.max(0, Math.round(extra.durationMs)) } : {},
|
|
547
|
+
...extra.authenticated !== void 0 ? { authenticated: extra.authenticated } : {},
|
|
548
|
+
...extra.detail !== void 0 ? { detail: sanitizeText(extra.detail, MAX_DETAIL_CHARS) } : {}
|
|
549
|
+
};
|
|
550
|
+
this.events.push(event);
|
|
551
|
+
while (this.events.length > this.capacity) this.events.shift();
|
|
552
|
+
const throttleKey = `${kind}\u0000${event.reason}\u0000${event.source ?? "-"}`;
|
|
553
|
+
const previous = this.throttles.get(throttleKey);
|
|
554
|
+
let suppressed = 0;
|
|
555
|
+
if (previous !== void 0 && ts - previous.lastTs < this.throttleMs) {
|
|
556
|
+
previous.suppressed += 1;
|
|
557
|
+
return event;
|
|
558
|
+
}
|
|
559
|
+
if (previous !== void 0) suppressed = previous.suppressed;
|
|
560
|
+
this.throttles.set(throttleKey, {
|
|
561
|
+
lastTs: ts,
|
|
562
|
+
suppressed: 0
|
|
563
|
+
});
|
|
564
|
+
this.write(formatEventLine(event, suppressed));
|
|
565
|
+
this.persist(event, suppressed);
|
|
566
|
+
return event;
|
|
567
|
+
}
|
|
568
|
+
/** 最近 `limit` 条事件(时间升序),供 health / report / 测试读取。 */
|
|
569
|
+
recent(limit = 20) {
|
|
570
|
+
const size = Math.max(0, Math.trunc(limit));
|
|
571
|
+
return this.events.slice(-size);
|
|
572
|
+
}
|
|
573
|
+
/** 最近 `limit` 条失败事件(warn 级),新→旧排序前的升序切片。 */
|
|
574
|
+
recentFailures(limit = 10) {
|
|
575
|
+
const failures = this.events.filter((event) => event.level === "warn");
|
|
576
|
+
const size = Math.max(0, Math.trunc(limit));
|
|
577
|
+
return failures.slice(-size);
|
|
578
|
+
}
|
|
579
|
+
/** 最近一条失败事件;没有失败时 undefined。 */
|
|
580
|
+
lastFailure() {
|
|
581
|
+
for (let index = this.events.length - 1; index >= 0; index -= 1) {
|
|
582
|
+
const event = this.events[index];
|
|
583
|
+
if (event !== void 0 && event.level === "warn") return event;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/** 等价于「诊断面板复制」/health 需要的最近失败摘要。 */
|
|
587
|
+
lastFailureSummary() {
|
|
588
|
+
const failure = this.lastFailure();
|
|
589
|
+
return failure === void 0 ? void 0 : {
|
|
590
|
+
kind: failure.kind,
|
|
591
|
+
reason: failure.reason,
|
|
592
|
+
ts: failure.ts
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/** 等待所有排队中的落盘完成(测试与进程收尾用)。 */
|
|
596
|
+
async flush() {
|
|
597
|
+
await this.tail;
|
|
598
|
+
}
|
|
599
|
+
/** 进程收尾:不再有新的写入队列,等待既有写入落盘。 */
|
|
600
|
+
async dispose() {
|
|
601
|
+
await this.flush();
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* 追加一行 JSONL。写入串行、失败静默(只报一次),绝不阻塞调用方——
|
|
605
|
+
* 诊断日志不能反过来拖慢或搞崩握手路径。
|
|
606
|
+
*/
|
|
607
|
+
persist(event, suppressed) {
|
|
608
|
+
const dir = this.dir;
|
|
609
|
+
if (dir === void 0) return;
|
|
610
|
+
const day = utcDay(event.ts);
|
|
611
|
+
const path = join(dir, `diagnostics-${day}.jsonl`);
|
|
612
|
+
const payload = suppressed > 0 ? {
|
|
613
|
+
...event,
|
|
614
|
+
suppressed
|
|
615
|
+
} : event;
|
|
616
|
+
const line = JSON.stringify(payload) + "\n";
|
|
617
|
+
const bytes = Buffer.byteLength(line);
|
|
618
|
+
this.tail = this.tail.then(async () => {
|
|
619
|
+
try {
|
|
620
|
+
await this.prepareDirectory(dir, day);
|
|
621
|
+
const currentSize = this.fileSizes.get(path) ?? await stat(path).then((info) => info.size).catch(() => 0);
|
|
622
|
+
this.fileSizes.set(path, currentSize);
|
|
623
|
+
if (this.directoryBytes + bytes > 5242880) return;
|
|
624
|
+
await mkdir(dir, {
|
|
625
|
+
recursive: true,
|
|
626
|
+
mode: 448
|
|
627
|
+
});
|
|
628
|
+
await appendFile(path, line, { mode: 384 });
|
|
629
|
+
this.fileSizes.set(path, currentSize + bytes);
|
|
630
|
+
this.directoryBytes += bytes;
|
|
631
|
+
} catch (error) {
|
|
632
|
+
this.reportWriteFailure(error);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
this.tail = this.tail.catch(() => {});
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* 每天第一次落盘时清理保留期之外的文件并统计目录大小;同时限制目录
|
|
639
|
+
* 总量(5MB)。清理失败不影响当次写入。
|
|
640
|
+
*/
|
|
641
|
+
async prepareDirectory(dir, day) {
|
|
642
|
+
if (this.prunedDay === day) return;
|
|
643
|
+
const names = await readdir(dir).catch(() => []);
|
|
644
|
+
const cutoffDay = utcDayMinus(day, 6);
|
|
645
|
+
let total = 0;
|
|
646
|
+
for (const name of names) {
|
|
647
|
+
const match = /^diagnostics-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
|
|
648
|
+
if (match === null) continue;
|
|
649
|
+
const full = join(dir, name);
|
|
650
|
+
if (match[1] !== void 0 && match[1] < cutoffDay) {
|
|
651
|
+
await unlink(full).catch(() => {});
|
|
652
|
+
this.fileSizes.delete(full);
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const size = await stat(full).then((info) => info.size).catch(() => 0);
|
|
656
|
+
this.fileSizes.set(full, size);
|
|
657
|
+
total += size;
|
|
658
|
+
}
|
|
659
|
+
this.directoryBytes = total;
|
|
660
|
+
this.prunedDay = day;
|
|
661
|
+
}
|
|
662
|
+
reportWriteFailure(error) {
|
|
663
|
+
if (this.writeFailureReported) return;
|
|
664
|
+
this.writeFailureReported = true;
|
|
665
|
+
this.write(formatLogLine("warn", "diagnostics write failed: " + sanitizeText(String(error), MAX_DETAIL_CHARS)));
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
/** 一条事件的终端日志文本(不含前缀时间戳,由 formatLogLine 统一加)。 */
|
|
669
|
+
function formatEventLine(event, suppressed) {
|
|
670
|
+
const parts = [event.kind, event.reason];
|
|
671
|
+
if (event.source !== void 0) parts.push("src=" + event.source);
|
|
672
|
+
if (event.attemptId !== void 0) parts.push("attempt=" + event.attemptId);
|
|
673
|
+
if (event.closeCode !== void 0) parts.push("code=" + String(event.closeCode));
|
|
674
|
+
if (event.durationMs !== void 0) parts.push("durationMs=" + String(event.durationMs));
|
|
675
|
+
if (event.authenticated !== void 0) parts.push("auth=" + (event.authenticated ? "yes" : "no"));
|
|
676
|
+
if (suppressed > 0) parts.push("suppressed=" + String(suppressed));
|
|
677
|
+
if (event.detail !== void 0) parts.push("detail=" + event.detail);
|
|
678
|
+
return formatLogLine(event.level, parts.join(" "), event.ts);
|
|
679
|
+
}
|
|
680
|
+
function utcDay(ts) {
|
|
681
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
682
|
+
}
|
|
683
|
+
/** 日期字符串(YYYY-MM-DD)往前推 `days` 天;保留期计算用。 */
|
|
684
|
+
function utcDayMinus(day, days) {
|
|
685
|
+
const date = /* @__PURE__ */ new Date(day + "T00:00:00.000Z");
|
|
686
|
+
date.setUTCDate(date.getUTCDate() - days);
|
|
687
|
+
return date.toISOString().slice(0, 10);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* `/phone/health` 的响应体:**只加不删**。
|
|
691
|
+
*
|
|
692
|
+
* 原有 `ok/enabled/protocolVersion/serverVersion/dataPlane` 保持原义;
|
|
693
|
+
* 新增字段只描述「这台电脑此刻能不能被连上」,不含 debug 开关状态,
|
|
694
|
+
* 也不返回局域网地址候选(那是配对页的职责,health 不该成为扫描器
|
|
695
|
+
* 的地址字典):
|
|
696
|
+
* - `local`:LAN 传输此刻的相位与端口;
|
|
697
|
+
* - `dataPlanePhase`:数据面 active / inactive;
|
|
698
|
+
* - `tlsRebuilt`:本次运行是否重建过 TLS 身份(手机据此判断是否需要重新配对);
|
|
699
|
+
* - `startedAt`:插件启动时刻;
|
|
700
|
+
* - `lastFailure`:最近一次失败的类型、分因与时间。
|
|
701
|
+
*/
|
|
702
|
+
function buildHealthPayload(input) {
|
|
703
|
+
return {
|
|
704
|
+
ok: true,
|
|
705
|
+
enabled: input.enabled,
|
|
706
|
+
protocolVersion: input.protocolVersion,
|
|
707
|
+
serverVersion: input.serverVersion,
|
|
708
|
+
dataPlane: input.dataPlaneActive,
|
|
709
|
+
dataPlanePhase: input.dataPlaneActive ? "active" : "inactive",
|
|
710
|
+
local: {
|
|
711
|
+
phase: input.localPhase,
|
|
712
|
+
port: input.localPort
|
|
713
|
+
},
|
|
714
|
+
tlsRebuilt: input.tlsIdentityRegenerated,
|
|
715
|
+
startedAt: input.startedAt,
|
|
716
|
+
lastFailure: input.lastFailure === void 0 ? null : {
|
|
717
|
+
kind: input.lastFailure.kind,
|
|
718
|
+
reason: input.lastFailure.reason,
|
|
719
|
+
ts: input.lastFailure.ts
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
//#endregion
|
|
397
724
|
//#region src/device-auth.ts
|
|
398
725
|
function getPairingCodeTtlMs() {
|
|
399
726
|
return Number(process.env.DEEPPILOT_PAIRING_TTL_MS) || 3e5;
|
|
@@ -419,6 +746,7 @@ async function loadOrCreateHostAudience(path) {
|
|
|
419
746
|
const audience = "deeppilot:" + randomBytes(16).toString("base64url");
|
|
420
747
|
await mkdir(dirname(path), { recursive: true });
|
|
421
748
|
await writeFile(path, audience + "\n", { mode: 384 });
|
|
749
|
+
console.log(formatLogLine("info", "host identity created: " + path + " (previously paired devices must pair again)"));
|
|
422
750
|
return audience;
|
|
423
751
|
}
|
|
424
752
|
function b64urlText(value) {
|
|
@@ -683,6 +1011,11 @@ var DeviceStore = class DeviceStore {
|
|
|
683
1011
|
filePath;
|
|
684
1012
|
devices = /* @__PURE__ */ new Map();
|
|
685
1013
|
flushTail = Promise.resolve();
|
|
1014
|
+
/**
|
|
1015
|
+
* 注册表写失败的日志节流:磁盘只读 / 空间不足时每次 flush 都会失败,
|
|
1016
|
+
* 不节流会把「写不进去」变成新的刷屏源。同一条错误 60s 只报一次。
|
|
1017
|
+
*/
|
|
1018
|
+
lastWriteFailureLogAt = 0;
|
|
686
1019
|
constructor(filePath) {
|
|
687
1020
|
this.filePath = filePath;
|
|
688
1021
|
}
|
|
@@ -692,7 +1025,7 @@ var DeviceStore = class DeviceStore {
|
|
|
692
1025
|
const raw = JSON.parse(await readFile(expandHome(filePath), "utf8"));
|
|
693
1026
|
for (const rec of raw.devices ?? []) if (typeof rec.deviceId === "string") store.devices.set(rec.deviceId, rec);
|
|
694
1027
|
} catch (error) {
|
|
695
|
-
if (error?.code === "ENOENT") {} else console.log("
|
|
1028
|
+
if (error?.code === "ENOENT") {} else console.log(formatLogLine("warn", "device registry unreadable, starting empty: " + String(error)));
|
|
696
1029
|
}
|
|
697
1030
|
return store;
|
|
698
1031
|
}
|
|
@@ -725,6 +1058,18 @@ var DeviceStore = class DeviceStore {
|
|
|
725
1058
|
if (!record?.publicKey || record.revokedAt !== void 0) return void 0;
|
|
726
1059
|
return record;
|
|
727
1060
|
}
|
|
1061
|
+
/**
|
|
1062
|
+
* 认证分因用:把「为什么 authorized() 返回 undefined」拆成三种情形。
|
|
1063
|
+
* 只给日志使用,准入判定仍然只走 authorized()——wire 响应不会因为
|
|
1064
|
+
* 这个方法泄露设备是否注册过(4401 仍只有一个码)。
|
|
1065
|
+
*/
|
|
1066
|
+
statusOf(deviceId) {
|
|
1067
|
+
const record = this.devices.get(deviceId);
|
|
1068
|
+
if (record === void 0) return "unknown";
|
|
1069
|
+
if (record.revokedAt !== void 0) return "revoked";
|
|
1070
|
+
if (record.publicKey === void 0) return "unknown";
|
|
1071
|
+
return "active";
|
|
1072
|
+
}
|
|
728
1073
|
markAuthenticated(deviceId, deviceName, appVersion, now) {
|
|
729
1074
|
const record = this.authorized(deviceId);
|
|
730
1075
|
if (!record) return;
|
|
@@ -889,7 +1234,18 @@ var DeviceStore = class DeviceStore {
|
|
|
889
1234
|
const temp = `${full}.${randomBytes(6).toString("hex")}.tmp`;
|
|
890
1235
|
await writeFile(temp, body + "\n", { mode: 384 });
|
|
891
1236
|
await rename(temp, full);
|
|
892
|
-
} catch {
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
this.logWriteFailure(error);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/** 写失败日志按 60s 节流:磁盘满时 flush 会被反复触发,不能让它刷屏。 */
|
|
1242
|
+
logWriteFailure(error) {
|
|
1243
|
+
const now = Date.now();
|
|
1244
|
+
if (now - this.lastWriteFailureLogAt < 6e4) return;
|
|
1245
|
+
this.lastWriteFailureLogAt = now;
|
|
1246
|
+
const code = error?.code;
|
|
1247
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1248
|
+
console.log(formatLogLine("warn", "device registry write failed" + (typeof code === "string" ? " code=" + code : "") + ": " + message.slice(0, 160)));
|
|
893
1249
|
}
|
|
894
1250
|
};
|
|
895
1251
|
//#endregion
|
|
@@ -2504,6 +2860,27 @@ function registryRowFor(type) {
|
|
|
2504
2860
|
* (成功/证明无效/超时/关闭)都经 settleAuthentication 的 exactly-once
|
|
2505
2861
|
* 守卫释放一次。
|
|
2506
2862
|
*/
|
|
2863
|
+
/**
|
|
2864
|
+
* 把一次认证失败归一到一个原因。判定顺序就是语义优先级:
|
|
2865
|
+
*
|
|
2866
|
+
* 1. 注册表状态先行——设备不存在 / 已撤销时,后面任何字段都不可信;
|
|
2867
|
+
* 2. nonce / audience 不匹配:挑战对象不是这一次(重放或串线);
|
|
2868
|
+
* 3. 服务器时钟已过挑战有效期:挑战过期;
|
|
2869
|
+
* 4. 客户端回显的 issuedAt / expiresAt 与挑战不同:客户端时钟或状态偏差;
|
|
2870
|
+
* 5. 其余(签名缺失 / 验签失败):bad-signature。
|
|
2871
|
+
*
|
|
2872
|
+
* 纯函数,连接门与单测共用同一份判定。
|
|
2873
|
+
*/
|
|
2874
|
+
function classifyAuthFailure(input) {
|
|
2875
|
+
if (input.device === "revoked") return "revoked";
|
|
2876
|
+
if (input.device === "unknown") return "unknown-device";
|
|
2877
|
+
const { proof, challenge, now } = input;
|
|
2878
|
+
if (proof.nonce !== challenge.nonce) return "nonce-mismatch";
|
|
2879
|
+
if (proof.audience !== challenge.audience) return "audience-mismatch";
|
|
2880
|
+
if (now > challenge.expiresAt) return "challenge-expired";
|
|
2881
|
+
if (proof.issuedAt !== challenge.issuedAt || proof.expiresAt !== challenge.expiresAt) return "clock-skew";
|
|
2882
|
+
return "bad-signature";
|
|
2883
|
+
}
|
|
2507
2884
|
/** upgrade 正常是毫秒级;5 秒已极宽,只为「回调根本不执行」兜底。 */
|
|
2508
2885
|
const DEFAULT_ATTACH_TIMEOUT_MS = 5e3;
|
|
2509
2886
|
var ConnectionGate = class {
|
|
@@ -2564,6 +2941,7 @@ var ConnectionGate = class {
|
|
|
2564
2941
|
this.helloTimer = void 0;
|
|
2565
2942
|
if (this.authenticated) return;
|
|
2566
2943
|
this.settle(false, "timeout");
|
|
2944
|
+
this.reportAuthFailure("timeout", 4402);
|
|
2567
2945
|
this.host?.close(4402, "auth timeout");
|
|
2568
2946
|
}, AUTH_TIMEOUT_MS);
|
|
2569
2947
|
this.helloTimer.unref();
|
|
@@ -2653,18 +3031,21 @@ var ConnectionGate = class {
|
|
|
2653
3031
|
const p = env.payload ?? {};
|
|
2654
3032
|
if (!p.deviceId) {
|
|
2655
3033
|
host.fail(env.id, "E_PROTOCOL", "deviceId required");
|
|
3034
|
+
this.reportAuthFailure("device-id-missing", 4403);
|
|
2656
3035
|
host.close(4403, "deviceId required");
|
|
2657
3036
|
return;
|
|
2658
3037
|
}
|
|
2659
3038
|
const deviceId = sanitizeDeviceField(p.deviceId, 128);
|
|
2660
3039
|
if (!deviceId) {
|
|
2661
3040
|
host.fail(env.id, "E_PROTOCOL", "deviceId required");
|
|
3041
|
+
this.reportAuthFailure("device-id-missing", 4403);
|
|
2662
3042
|
host.close(4403, "deviceId required");
|
|
2663
3043
|
return;
|
|
2664
3044
|
}
|
|
2665
3045
|
const deviceName = sanitizeDeviceField(p.deviceName, 64) || "unknown";
|
|
2666
3046
|
const appVersion = sanitizeDeviceField(p.appVersion, 32) || "unknown";
|
|
2667
|
-
const
|
|
3047
|
+
const deviceState = this.options.devices.statusOf(deviceId);
|
|
3048
|
+
const record = deviceState === "active" ? this.options.devices.authorized(deviceId) : void 0;
|
|
2668
3049
|
const resumeCursor = typeof p.resumeCursor === "number" && Number.isInteger(p.resumeCursor) && p.resumeCursor >= 0 ? p.resumeCursor : void 0;
|
|
2669
3050
|
const challengeMatches = p.nonce === this.challenge.nonce && p.audience === this.challenge.audience && p.issuedAt === this.challenge.issuedAt && p.expiresAt === this.challenge.expiresAt && Date.now() <= this.challenge.expiresAt;
|
|
2670
3051
|
if (!(record?.publicKey !== void 0 && typeof p.signature === "string" && challengeMatches && verifyAuthProof(record.publicKey, {
|
|
@@ -2674,7 +3055,14 @@ var ConnectionGate = class {
|
|
|
2674
3055
|
resumeCursor,
|
|
2675
3056
|
...this.challenge
|
|
2676
3057
|
}, p.signature)) || record === void 0) {
|
|
3058
|
+
const reason = classifyAuthFailure({
|
|
3059
|
+
device: deviceState,
|
|
3060
|
+
proof: p,
|
|
3061
|
+
challenge: this.challenge,
|
|
3062
|
+
now: Date.now()
|
|
3063
|
+
});
|
|
2677
3064
|
host.fail(env.id, "E_AUTH", "device proof missing or invalid");
|
|
3065
|
+
this.reportAuthFailure(reason, 4401);
|
|
2678
3066
|
this.settle(false, "invalid-proof");
|
|
2679
3067
|
host.close(4401, "invalid device proof");
|
|
2680
3068
|
return;
|
|
@@ -2696,6 +3084,19 @@ var ConnectionGate = class {
|
|
|
2696
3084
|
appVersion
|
|
2697
3085
|
});
|
|
2698
3086
|
}
|
|
3087
|
+
/**
|
|
3088
|
+
* 认证失败事件。来源只写 auditLabel 哈希(没有哈希函数时宁可不写,
|
|
3089
|
+
* 也不把原始 IP 落盘);attemptId 原样带出,便于把同一次握手的
|
|
3090
|
+
* upgrade / auth 两条日志串起来。
|
|
3091
|
+
*/
|
|
3092
|
+
reportAuthFailure(reason, closeCode) {
|
|
3093
|
+
const source = this.options.auditLabel?.(this.options.source);
|
|
3094
|
+
this.options.diagnostics?.record("auth-failed", reason, {
|
|
3095
|
+
...source !== void 0 ? { source } : {},
|
|
3096
|
+
...this.options.attemptId !== void 0 ? { attemptId: this.options.attemptId } : {},
|
|
3097
|
+
closeCode
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
2699
3100
|
/** 限流记账与名额释放只此一处;exactly-once 由 settled 守卫保证。 */
|
|
2700
3101
|
settle(ok, reason) {
|
|
2701
3102
|
if (this.settled) return;
|
|
@@ -2782,6 +3183,10 @@ var BridgeConnection = class {
|
|
|
2782
3183
|
pendingLiveActivityId;
|
|
2783
3184
|
lastActivity = Date.now();
|
|
2784
3185
|
streamAbort = new AbortController();
|
|
3186
|
+
/** 建连时刻:关闭事件用它算存活时长。 */
|
|
3187
|
+
openedAt = Date.now();
|
|
3188
|
+
/** 来源的审计哈希;没有 auditLabel 时留空,绝不记录原始 IP。 */
|
|
3189
|
+
sourceLabel;
|
|
2785
3190
|
/** True when no inbound frame arrived within maxIdleMs. */
|
|
2786
3191
|
isStale(now, maxIdleMs) {
|
|
2787
3192
|
return now - this.lastActivity > maxIdleMs;
|
|
@@ -2796,11 +3201,15 @@ var BridgeConnection = class {
|
|
|
2796
3201
|
audience: deps.audience,
|
|
2797
3202
|
limiter: deps.rateLimiter ?? new AuthRateLimiter(),
|
|
2798
3203
|
log: (message) => deps.log(message),
|
|
2799
|
-
...deps.auditLabel ? { auditLabel: deps.auditLabel } : {}
|
|
3204
|
+
...deps.auditLabel ? { auditLabel: deps.auditLabel } : {},
|
|
3205
|
+
...deps.diagnostics ? { diagnostics: deps.diagnostics } : {},
|
|
3206
|
+
...deps.attemptId !== void 0 ? { attemptId: deps.attemptId } : {}
|
|
2800
3207
|
})) {
|
|
2801
3208
|
this.ws = ws;
|
|
2802
3209
|
this.deps = deps;
|
|
2803
3210
|
const connection = this;
|
|
3211
|
+
const sourceLabel = deps.auditLabel?.(deps.source ?? "local");
|
|
3212
|
+
this.sourceLabel = sourceLabel;
|
|
2804
3213
|
const host = {
|
|
2805
3214
|
signal: this.streamAbort.signal,
|
|
2806
3215
|
waitForWritable: async () => {
|
|
@@ -2862,12 +3271,23 @@ var BridgeConnection = class {
|
|
|
2862
3271
|
this.terminate();
|
|
2863
3272
|
});
|
|
2864
3273
|
});
|
|
2865
|
-
ws.on("close", () => {
|
|
2866
|
-
this.onClose();
|
|
3274
|
+
ws.on("close", (code) => {
|
|
3275
|
+
this.onClose(code);
|
|
2867
3276
|
deps.onClosed?.(this);
|
|
2868
3277
|
});
|
|
2869
|
-
ws.on("error", () => {
|
|
3278
|
+
ws.on("error", (error) => {
|
|
3279
|
+
this.deps.diagnostics?.record("connection-error", "socket-error", {
|
|
3280
|
+
...this.sourceLabel !== void 0 ? { source: this.sourceLabel } : {},
|
|
3281
|
+
...this.deps.attemptId !== void 0 ? { attemptId: this.deps.attemptId } : {},
|
|
3282
|
+
authenticated: this.identity !== void 0,
|
|
3283
|
+
detail: error instanceof Error ? error.message : String(error ?? "unknown")
|
|
3284
|
+
});
|
|
3285
|
+
});
|
|
2870
3286
|
gate.attach(host);
|
|
3287
|
+
this.deps.diagnostics?.record("connection-opened", "socket-open", {
|
|
3288
|
+
...sourceLabel !== void 0 ? { source: sourceLabel } : {},
|
|
3289
|
+
...deps.attemptId !== void 0 ? { attemptId: deps.attemptId } : {}
|
|
3290
|
+
});
|
|
2871
3291
|
}
|
|
2872
3292
|
/** Hard-drop the socket (server-side stale sweep). */
|
|
2873
3293
|
terminate() {
|
|
@@ -2952,9 +3372,17 @@ var BridgeConnection = class {
|
|
|
2952
3372
|
else this.resync();
|
|
2953
3373
|
}
|
|
2954
3374
|
}
|
|
2955
|
-
onClose() {
|
|
3375
|
+
onClose(code) {
|
|
2956
3376
|
if (this.closed) return;
|
|
2957
3377
|
this.closed = true;
|
|
3378
|
+
const closeCode = code ?? 1005;
|
|
3379
|
+
if (closeCode !== 1e3 && closeCode !== 1001) this.deps.diagnostics?.record("connection-closed", "abnormal-close", {
|
|
3380
|
+
...this.sourceLabel !== void 0 ? { source: this.sourceLabel } : {},
|
|
3381
|
+
...this.deps.attemptId !== void 0 ? { attemptId: this.deps.attemptId } : {},
|
|
3382
|
+
closeCode,
|
|
3383
|
+
durationMs: Date.now() - this.openedAt,
|
|
3384
|
+
authenticated: this.identity !== void 0
|
|
3385
|
+
});
|
|
2958
3386
|
this.streamAbort.abort();
|
|
2959
3387
|
this.gate.onClose();
|
|
2960
3388
|
for (const id of this.openSessions) this.deps.bridge.markSinkClosed(this, id);
|
|
@@ -3609,7 +4037,7 @@ function apnsPayload(notification) {
|
|
|
3609
4037
|
return alertPayload(notification);
|
|
3610
4038
|
}
|
|
3611
4039
|
function alertPayload(notification) {
|
|
3612
|
-
const timeSensitive = notification.category === "approval.required" || notification.category === "question.asked";
|
|
4040
|
+
const timeSensitive = notification.category === "approval.required" || notification.category === "question.asked" || notification.category === "session.error";
|
|
3613
4041
|
return {
|
|
3614
4042
|
aps: {
|
|
3615
4043
|
alert: {
|
|
@@ -3618,13 +4046,18 @@ function alertPayload(notification) {
|
|
|
3618
4046
|
},
|
|
3619
4047
|
sound: "default",
|
|
3620
4048
|
category: notification.category,
|
|
4049
|
+
"mutable-content": 1,
|
|
3621
4050
|
"thread-id": notification.hostAudience ? createHash("sha256").update(`${notification.hostAudience}:${notification.sessionId}`).digest("hex") : notification.sessionId.slice(0, 64),
|
|
3622
4051
|
...timeSensitive ? { "interruption-level": "time-sensitive" } : {}
|
|
3623
4052
|
},
|
|
3624
4053
|
...notification.hostAudience ? { hostAudience: notification.hostAudience } : {},
|
|
3625
4054
|
sessionId: notification.sessionId,
|
|
3626
4055
|
notificationId: notification.notificationId,
|
|
3627
|
-
kind: notification.category
|
|
4056
|
+
kind: notification.category,
|
|
4057
|
+
...notification.category === "approval.required" && notification.toolName ? {
|
|
4058
|
+
toolName: notification.toolName.slice(0, 80),
|
|
4059
|
+
...notification.detail ? { detail: notification.detail.slice(0, 600) } : {}
|
|
4060
|
+
} : {}
|
|
3628
4061
|
};
|
|
3629
4062
|
}
|
|
3630
4063
|
function pushHeaders(bundleId, notification) {
|
|
@@ -3945,7 +4378,7 @@ function liveActivityState(session) {
|
|
|
3945
4378
|
task: Array.from(session?.activity?.trim() || task?.content || "").slice(0, 160).join(""),
|
|
3946
4379
|
done,
|
|
3947
4380
|
total,
|
|
3948
|
-
phase: !session || total === 0 ? "unavailable" : session.pendingApproval ? "approval" : session.pendingQuestion ? "question" : session.status === "running" ? "running" : session.status === "idle" ? "ended" : "unavailable"
|
|
4381
|
+
phase: !session || total === 0 ? "unavailable" : session.pendingApproval ? "approval" : session.pendingQuestion ? "question" : session.status === "running" ? "running" : session.status === "idle" ? "ended" : session.status === "error" ? "failed" : "unavailable"
|
|
3949
4382
|
};
|
|
3950
4383
|
}
|
|
3951
4384
|
/** Persist terminal state before coalescing so a rapid next round cannot revive an activity. */
|
|
@@ -3966,7 +4399,7 @@ var LiveActivityPushManager = class {
|
|
|
3966
4399
|
const r = device.liveActivity;
|
|
3967
4400
|
if (!r) continue;
|
|
3968
4401
|
const state = liveActivityState(this.latest.get(r.sessionId));
|
|
3969
|
-
if (state.phase === "ended" || state.phase === "unavailable") this.devices()?.endLiveActivity(device.deviceId, r.token, state);
|
|
4402
|
+
if (state.phase === "ended" || state.phase === "failed" || state.phase === "unavailable") this.devices()?.endLiveActivity(device.deviceId, r.token, state);
|
|
3970
4403
|
}
|
|
3971
4404
|
this.scheduler.changed();
|
|
3972
4405
|
}
|
|
@@ -4030,6 +4463,8 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
|
|
|
4030
4463
|
/** Apply before either transport receives the payload, including the Relay. */
|
|
4031
4464
|
function pushContent(notification, mode) {
|
|
4032
4465
|
if (mode !== "generic") return notification;
|
|
4466
|
+
const { toolName: _toolName, detail: _detail, ...rest } = notification;
|
|
4467
|
+
notification = rest;
|
|
4033
4468
|
const bodies = {
|
|
4034
4469
|
"turn.completed": "A task has completed.",
|
|
4035
4470
|
"approval.required": "An approval needs your attention.",
|
|
@@ -4671,7 +5106,7 @@ var PushGateway = class {
|
|
|
4671
5106
|
* it the listener is plain HTTP, which is reserved for the loopback Funnel
|
|
4672
5107
|
* origin where tailscaled terminates TLS.
|
|
4673
5108
|
*/
|
|
4674
|
-
function createPhoneServer(handlers, tls) {
|
|
5109
|
+
function createPhoneServer(handlers, tls, options) {
|
|
4675
5110
|
const onRequest = (req, res) => {
|
|
4676
5111
|
const path = requestPath(req);
|
|
4677
5112
|
if (path === "/phone/health") handlers.health(req, res);
|
|
@@ -4693,6 +5128,25 @@ function createPhoneServer(handlers, tls) {
|
|
|
4693
5128
|
}
|
|
4694
5129
|
handlers.upgrade(req, socket, head);
|
|
4695
5130
|
});
|
|
5131
|
+
const onTransportError = options?.onTransportError;
|
|
5132
|
+
if (onTransportError !== void 0) {
|
|
5133
|
+
if (tls !== void 0) server.on("tlsClientError", (error, socket) => {
|
|
5134
|
+
onTransportError({
|
|
5135
|
+
kind: "tls-client-error",
|
|
5136
|
+
error,
|
|
5137
|
+
remoteAddress: socket.remoteAddress
|
|
5138
|
+
});
|
|
5139
|
+
socket.destroy();
|
|
5140
|
+
});
|
|
5141
|
+
server.on("clientError", (error, socket) => {
|
|
5142
|
+
onTransportError({
|
|
5143
|
+
kind: "client-error",
|
|
5144
|
+
error,
|
|
5145
|
+
remoteAddress: socket.remoteAddress
|
|
5146
|
+
});
|
|
5147
|
+
socket.destroy();
|
|
5148
|
+
});
|
|
5149
|
+
}
|
|
4696
5150
|
return server;
|
|
4697
5151
|
}
|
|
4698
5152
|
function requestPath(req) {
|
|
@@ -4793,7 +5247,7 @@ var TransportReconciler = class {
|
|
|
4793
5247
|
this.instance = void 0;
|
|
4794
5248
|
if (partial !== void 0) await this.spec.stop(partial).catch(() => {});
|
|
4795
5249
|
this.currentStatus = this.spec.errorStatus(error, target);
|
|
4796
|
-
this.log(this.spec.failureMessage?.(error) ?? "transport failed: " + String(error));
|
|
5250
|
+
this.log(this.spec.failureMessage?.(error, target) ?? "transport failed: " + String(error));
|
|
4797
5251
|
}
|
|
4798
5252
|
}
|
|
4799
5253
|
/** 进程收尾:标记销毁、拆掉实例、等在途的那一次完成。 */
|
|
@@ -4834,7 +5288,7 @@ function createLocalTransport(deps) {
|
|
|
4834
5288
|
const server = createPhoneServer(deps.handlers, {
|
|
4835
5289
|
key: tls.key,
|
|
4836
5290
|
cert: tls.cert
|
|
4837
|
-
});
|
|
5291
|
+
}, deps.onTransportError !== void 0 ? { onTransportError: deps.onTransportError } : void 0);
|
|
4838
5292
|
instance.server = server;
|
|
4839
5293
|
instance.tlsFingerprint = tls.fingerprint;
|
|
4840
5294
|
await listen(server, port, "0.0.0.0");
|
|
@@ -4872,7 +5326,7 @@ function createLocalTransport(deps) {
|
|
|
4872
5326
|
updatedAt: Date.now()
|
|
4873
5327
|
}),
|
|
4874
5328
|
disabledMessage: "local transport disabled",
|
|
4875
|
-
failureMessage: (error) => "local transport failed: " + localListenError(error,
|
|
5329
|
+
failureMessage: (error, target) => "local transport failed: " + localListenError(error, normalizeLocalPort(target.port)),
|
|
4876
5330
|
statusOf: (instance) => {
|
|
4877
5331
|
if (instance === void 0 || instance.server === void 0) return void 0;
|
|
4878
5332
|
return {
|
|
@@ -5384,6 +5838,50 @@ function scheduleMutationCodec(persistValues) {
|
|
|
5384
5838
|
};
|
|
5385
5839
|
}
|
|
5386
5840
|
//#endregion
|
|
5841
|
+
//#region src/notification-text.ts
|
|
5842
|
+
/**
|
|
5843
|
+
* 通知正文的纯文本投影。
|
|
5844
|
+
*
|
|
5845
|
+
* 为什么需要:s2c.notify 与 APNs 的 body 是"可直接展示"的文本(PROTOCOL §6),
|
|
5846
|
+
* 客户端不得改写;而来源(助手回复、审批原因、提问)都是 Host 给的 Markdown。
|
|
5847
|
+
* 直接截原文会把链接地址、URL 编码的中文路径、代码块符号带进锁屏横幅,
|
|
5848
|
+
* 所以 Bridge 出站前在这里统一转成纯文本。
|
|
5849
|
+
*
|
|
5850
|
+
* 只做"去标记、留可读文字",不追求完整 Markdown 语义;任何无法识别的写法原样保留。
|
|
5851
|
+
*/
|
|
5852
|
+
/** 把 Markdown 拆成段落并逐段转为单行纯文本;清洗后为空的段落被丢弃。 */
|
|
5853
|
+
function notificationParagraphs(markdown) {
|
|
5854
|
+
return stripCodeFences(markdown.replace(/\r\n?/g, "\n")).split(/\n[ \t]*\n/).map(plainParagraph).filter((paragraph) => paragraph.length > 0);
|
|
5855
|
+
}
|
|
5856
|
+
/**
|
|
5857
|
+
* 通知正文:`last` 取最后一个非空段落(任务完成 / 异常结束——回复结尾通常是结论),
|
|
5858
|
+
* `all` 把全部段落合成一行(审批原因、提问原文)。长度上限由调用方负责。
|
|
5859
|
+
*/
|
|
5860
|
+
function notificationPlainText(markdown, pick) {
|
|
5861
|
+
const paragraphs = notificationParagraphs(markdown);
|
|
5862
|
+
return pick === "last" ? paragraphs.at(-1) ?? "" : paragraphs.join(" ");
|
|
5863
|
+
}
|
|
5864
|
+
/** 围栏代码块整体丢弃:横幅里放不下代码,留一半反而更难读。未闭合的围栏丢到文末。 */
|
|
5865
|
+
function stripCodeFences(text) {
|
|
5866
|
+
return text.replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^[ \t]*\1[ \t]*$|(?![\s\S]))/gm, "\n");
|
|
5867
|
+
}
|
|
5868
|
+
function plainParagraph(paragraph) {
|
|
5869
|
+
return decodePercentRuns(stripInline(paragraph.split("\n").filter((line) => !(line.includes("|") && line.includes("-") && /^[\s|:-]+$/.test(line))).filter((line) => !/^\s*([*_-])(\s*\1){2,}\s*$/.test(line)).map((line) => line.replace(/^\s*(>\s?)+/, "").replace(/^\s*#{1,6}\s+/, "").replace(/^\s*([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?/, "").replace(/^\s*\|(.*?)\|?\s*$/, (_row, cells) => cells.replace(/\s*\|\s*/g, " "))).join(" "))).replace(/\s+/g, " ").trim();
|
|
5870
|
+
}
|
|
5871
|
+
function stripInline(text) {
|
|
5872
|
+
return text.replace(/!\[[^\]]*\]\((?:[^()]|\([^()]*\))*\)/g, "").replace(/\[([^\]]+)\]\((?:[^()]|\([^()]*\))*\)/g, "$1").replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1").replace(/<((?:https?|mailto):[^>\s]+)>/g, "$1").replace(/<\/?[A-Za-z][^>]*>/g, "").replace(/(`+)([^`]+?)\1/g, "$2").replace(/(\*\*|__|~~)(?=\S)([\s\S]*?\S)\1/g, "$2").replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, "$1$2").replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, "$1$2");
|
|
5873
|
+
}
|
|
5874
|
+
/** 正文里残留的 %E6%8F%90 这类编码串解码成原字符;解不开(非法 UTF-8)就保持原样。 */
|
|
5875
|
+
function decodePercentRuns(text) {
|
|
5876
|
+
return text.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
|
|
5877
|
+
try {
|
|
5878
|
+
return decodeURIComponent(run);
|
|
5879
|
+
} catch {
|
|
5880
|
+
return run;
|
|
5881
|
+
}
|
|
5882
|
+
});
|
|
5883
|
+
}
|
|
5884
|
+
//#endregion
|
|
5387
5885
|
//#region src/host-api.ts
|
|
5388
5886
|
/**
|
|
5389
5887
|
* Subagent sessions are host-internal workers of a parent conversation.
|
|
@@ -5524,7 +6022,7 @@ var HostBridge = class {
|
|
|
5524
6022
|
return capabilityBits(this.apiProxy, this.pushOutlet);
|
|
5525
6023
|
}
|
|
5526
6024
|
diagnostic(message) {
|
|
5527
|
-
console.log("
|
|
6025
|
+
console.log(formatLogLine("info", message));
|
|
5528
6026
|
}
|
|
5529
6027
|
currentCursor() {
|
|
5530
6028
|
return this.cursor;
|
|
@@ -5566,14 +6064,21 @@ var HostBridge = class {
|
|
|
5566
6064
|
* PROTOCOL §6 + §7), then fan the same payload out to offline devices
|
|
5567
6065
|
* holding an APNs token. */
|
|
5568
6066
|
emitNotify(args) {
|
|
5569
|
-
|
|
5570
|
-
|
|
6067
|
+
const turnEnd = args.category === "turn.completed" || args.category === "session.error";
|
|
6068
|
+
if (turnEnd && this.subagentSessionIds.has(args.sessionId)) return;
|
|
6069
|
+
const plain = notificationPlainText(args.body, turnEnd ? "last" : "all") || (args.fallbackBody ?? "");
|
|
6070
|
+
const body = plain.length > 120 ? plain.slice(0, 119) + "…" : plain;
|
|
6071
|
+
const approvalExtras = args.category === "approval.required" && args.toolName ? {
|
|
6072
|
+
toolName: args.toolName.slice(0, 80),
|
|
6073
|
+
...args.detail ? { detail: args.detail.slice(0, 600) } : {}
|
|
6074
|
+
} : {};
|
|
5571
6075
|
this.record("s2c.notify", {
|
|
5572
6076
|
notificationId: args.notificationId,
|
|
5573
6077
|
category: args.category,
|
|
5574
6078
|
sessionId: args.sessionId,
|
|
5575
6079
|
title: args.title,
|
|
5576
6080
|
body,
|
|
6081
|
+
...approvalExtras,
|
|
5577
6082
|
ts: Date.now()
|
|
5578
6083
|
}, (sink) => this.isViewedBy(sink, args.sessionId));
|
|
5579
6084
|
this.fanOutPush({
|
|
@@ -5581,7 +6086,8 @@ var HostBridge = class {
|
|
|
5581
6086
|
category: args.category,
|
|
5582
6087
|
sessionId: args.sessionId,
|
|
5583
6088
|
title: args.title,
|
|
5584
|
-
body
|
|
6089
|
+
body,
|
|
6090
|
+
...approvalExtras
|
|
5585
6091
|
});
|
|
5586
6092
|
}
|
|
5587
6093
|
/** F-9: when a turn completes, notify every device not viewing the session. */
|
|
@@ -5589,12 +6095,12 @@ var HostBridge = class {
|
|
|
5589
6095
|
if (this.subagentSessionIds.has(sessionId)) return;
|
|
5590
6096
|
const row = this.summaries.get(sessionId);
|
|
5591
6097
|
const title = ok ? "任务完成" : "任务异常结束";
|
|
5592
|
-
const body = this.lastAssistantText.get(sessionId) ?? row?.title ?? "";
|
|
5593
6098
|
this.emitNotify({
|
|
5594
6099
|
sessionId,
|
|
5595
6100
|
category: ok ? "turn.completed" : "session.error",
|
|
5596
6101
|
title,
|
|
5597
|
-
body,
|
|
6102
|
+
body: this.lastAssistantText.get(sessionId) ?? "",
|
|
6103
|
+
fallbackBody: row?.title ?? "",
|
|
5598
6104
|
notificationId: "n-" + (this.cursor + 1)
|
|
5599
6105
|
});
|
|
5600
6106
|
}
|
|
@@ -5607,11 +6113,15 @@ var HostBridge = class {
|
|
|
5607
6113
|
this.pushOutlet?.fanOut(notification);
|
|
5608
6114
|
} catch {}
|
|
5609
6115
|
}
|
|
5610
|
-
/**
|
|
6116
|
+
/**
|
|
6117
|
+
* Remember the latest assistant text so notifications can quote it. 存完整原文:
|
|
6118
|
+
* 按字符数截尾会从链接地址中间切开(横幅只剩 URL 编码的路径),段落选择与
|
|
6119
|
+
* Markdown 清洗统一留到 emitNotify。条目随 sessions.list / 归档清理。
|
|
6120
|
+
*/
|
|
5611
6121
|
captureAssistantText(sessionId, event) {
|
|
5612
6122
|
if (event.type !== "assistant/message") return;
|
|
5613
6123
|
const text = messageText(event.data).trim();
|
|
5614
|
-
if (text.length > 0) this.lastAssistantText.set(sessionId, text
|
|
6124
|
+
if (text.length > 0) this.lastAssistantText.set(sessionId, text);
|
|
5615
6125
|
}
|
|
5616
6126
|
/**
|
|
5617
6127
|
* Replay buffered pushes after the given cursor; false when the gap is
|
|
@@ -5811,7 +6321,9 @@ var HostBridge = class {
|
|
|
5811
6321
|
category: "approval.required",
|
|
5812
6322
|
title: "需要批准",
|
|
5813
6323
|
body: toolName + ": " + summary,
|
|
5814
|
-
notificationId: "apr-" + p.approvalId
|
|
6324
|
+
notificationId: "apr-" + p.approvalId,
|
|
6325
|
+
toolName,
|
|
6326
|
+
detail: summary
|
|
5815
6327
|
});
|
|
5816
6328
|
this.bumpPendingFlags(sessionId);
|
|
5817
6329
|
break;
|
|
@@ -8266,6 +8778,40 @@ function parsePushTestResult(value) {
|
|
|
8266
8778
|
results
|
|
8267
8779
|
};
|
|
8268
8780
|
}
|
|
8781
|
+
function parseDiagnosticFailure(value) {
|
|
8782
|
+
const s = rec(value, "diagnostics.recentFailures[]");
|
|
8783
|
+
const kind = str(s, "kind", "diagnostics.kind");
|
|
8784
|
+
if (!/^[a-z][a-z0-9-]{0,47}$/.test(kind)) reject("diagnostics.kind");
|
|
8785
|
+
const reason = str(s, "reason", "diagnostics.reason");
|
|
8786
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(reason)) reject("diagnostics.reason");
|
|
8787
|
+
const attemptId = s.attemptId;
|
|
8788
|
+
if (attemptId !== void 0 && (typeof attemptId !== "string" || !/^[A-Za-z0-9_-]{1,16}$/.test(attemptId))) reject("diagnostics.attemptId");
|
|
8789
|
+
const source = s.source;
|
|
8790
|
+
if (source !== void 0 && (typeof source !== "string" || !/^[0-9a-f]{8,64}$/.test(source))) reject("diagnostics.source");
|
|
8791
|
+
const closeCode = s.closeCode;
|
|
8792
|
+
if (closeCode !== void 0 && (typeof closeCode !== "number" || !Number.isInteger(closeCode))) reject("diagnostics.closeCode");
|
|
8793
|
+
const durationMs = s.durationMs;
|
|
8794
|
+
if (durationMs !== void 0 && (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs < 0)) reject("diagnostics.durationMs");
|
|
8795
|
+
return {
|
|
8796
|
+
ts: int(s, "ts", "diagnostics.ts"),
|
|
8797
|
+
kind,
|
|
8798
|
+
reason,
|
|
8799
|
+
...typeof attemptId === "string" ? { attemptId } : {},
|
|
8800
|
+
...typeof source === "string" ? { source } : {},
|
|
8801
|
+
...typeof closeCode === "number" ? { closeCode } : {},
|
|
8802
|
+
...typeof durationMs === "number" ? { durationMs: Math.round(durationMs) } : {}
|
|
8803
|
+
};
|
|
8804
|
+
}
|
|
8805
|
+
function parseDiagnostics(value) {
|
|
8806
|
+
const s = rec(value, "diagnostics");
|
|
8807
|
+
const failures = s.recentFailures;
|
|
8808
|
+
if (!Array.isArray(failures)) reject("diagnostics.recentFailures");
|
|
8809
|
+
if (failures.length > 20) reject("diagnostics.recentFailures");
|
|
8810
|
+
return {
|
|
8811
|
+
startedAt: int(s, "startedAt", "diagnostics.startedAt"),
|
|
8812
|
+
recentFailures: failures.map(parseDiagnosticFailure)
|
|
8813
|
+
};
|
|
8814
|
+
}
|
|
8269
8815
|
function parseReport(value) {
|
|
8270
8816
|
const s = rec(value, "report");
|
|
8271
8817
|
const devices = s.devices;
|
|
@@ -8287,7 +8833,8 @@ function parseReport(value) {
|
|
|
8287
8833
|
lanAddresses,
|
|
8288
8834
|
local: parseLocal(s.local),
|
|
8289
8835
|
remote: parseRemote(s.remote),
|
|
8290
|
-
devices: devices.map(parseDevice)
|
|
8836
|
+
devices: devices.map(parseDevice),
|
|
8837
|
+
...s.diagnostics !== void 0 ? { diagnostics: parseDiagnostics(s.diagnostics) } : {}
|
|
8291
8838
|
};
|
|
8292
8839
|
}
|
|
8293
8840
|
const reportSchema = { parse: parseReport };
|
|
@@ -8756,13 +9303,17 @@ async function loadOrCreateLanTlsIdentity(dir, now = Date.now()) {
|
|
|
8756
9303
|
});
|
|
8757
9304
|
const keyPath = join(dir, LAN_TLS_KEY_FILE);
|
|
8758
9305
|
const certPath = join(dir, LAN_TLS_CERT_FILE);
|
|
8759
|
-
let key = await readPem(keyPath)
|
|
9306
|
+
let key = await readPem(keyPath, (error) => {
|
|
9307
|
+
console.log(formatLogLine("warn", "LAN TLS private key unreadable, regenerating identity: " + describeFileError(error)));
|
|
9308
|
+
});
|
|
8760
9309
|
let regenerated = false;
|
|
8761
9310
|
if (key === void 0 || !validPrivateKey(key)) {
|
|
8762
9311
|
regenerated = true;
|
|
8763
9312
|
key = void 0;
|
|
8764
9313
|
}
|
|
8765
|
-
let cert = regenerated ? void 0 : await readPem(certPath)
|
|
9314
|
+
let cert = regenerated ? void 0 : await readPem(certPath, (error) => {
|
|
9315
|
+
console.log(formatLogLine("warn", "LAN TLS certificate unreadable, re-signing with the existing key: " + describeFileError(error)));
|
|
9316
|
+
});
|
|
8766
9317
|
let resigned = false;
|
|
8767
9318
|
const existing = key !== void 0 && cert !== void 0 ? inspectCertificate(cert, key, now) : void 0;
|
|
8768
9319
|
if (existing === void 0) {
|
|
@@ -8784,15 +9335,22 @@ async function loadOrCreateLanTlsIdentity(dir, now = Date.now()) {
|
|
|
8784
9335
|
resigned
|
|
8785
9336
|
};
|
|
8786
9337
|
}
|
|
8787
|
-
async function readPem(path) {
|
|
9338
|
+
async function readPem(path, onError) {
|
|
8788
9339
|
try {
|
|
8789
9340
|
const text = await readFile(path, "utf8");
|
|
8790
9341
|
return text.trim().length > 0 ? text : void 0;
|
|
8791
9342
|
} catch (error) {
|
|
8792
9343
|
if (error.code === "ENOENT") return void 0;
|
|
9344
|
+
onError?.(error);
|
|
8793
9345
|
return;
|
|
8794
9346
|
}
|
|
8795
9347
|
}
|
|
9348
|
+
/** 读失败的安全描述:文件名 + errno,不带文件内容。 */
|
|
9349
|
+
function describeFileError(error) {
|
|
9350
|
+
const code = error?.code;
|
|
9351
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
9352
|
+
return (typeof code === "string" ? "code=" + code + " " : "") + message.slice(0, 140);
|
|
9353
|
+
}
|
|
8796
9354
|
function validPrivateKey(pem) {
|
|
8797
9355
|
try {
|
|
8798
9356
|
return createPrivateKey(pem).asymmetricKeyType === "ec";
|
|
@@ -8935,9 +9493,7 @@ function readOwnPackageVersion() {
|
|
|
8935
9493
|
}
|
|
8936
9494
|
function apply(ctx, options) {
|
|
8937
9495
|
const cfg = normalizeOptions(options);
|
|
8938
|
-
const log = (
|
|
8939
|
-
console.log("[deeppilot] " + message);
|
|
8940
|
-
};
|
|
9496
|
+
const log = createLogger();
|
|
8941
9497
|
const auditSalt = randomBytes(32);
|
|
8942
9498
|
const auditLabel = (value) => createHash("sha256").update(auditSalt).update(value).digest("hex").slice(0, 12);
|
|
8943
9499
|
let scheduleRemoteReconcile;
|
|
@@ -8950,6 +9506,30 @@ function apply(ctx, options) {
|
|
|
8950
9506
|
}));
|
|
8951
9507
|
if (currentConfig().enabled !== true) log("disabled via settings; bridge stays inactive (rumors of /phone below are skipped)");
|
|
8952
9508
|
const dataDir = bridgeDataDir();
|
|
9509
|
+
/** 插件启动时刻:`/phone/health` 与设置页报告都读它。 */
|
|
9510
|
+
const startedAt = Date.now();
|
|
9511
|
+
/**
|
|
9512
|
+
* 连接诊断事件:内存环形 256 条 + 按天落盘(插件数据目录 diagnostics/,
|
|
9513
|
+
* 保留 7 天、目录上限 5MB)。与 `diagnostics.debug` 完全解耦——失败事件
|
|
9514
|
+
* 无条件记录,否则用户不开调试开关就永远看不到「为什么连不上」。
|
|
9515
|
+
*/
|
|
9516
|
+
const diagnostics = new DiagnosticsStore({
|
|
9517
|
+
dir: join(dataDir, "diagnostics"),
|
|
9518
|
+
write: (line) => {
|
|
9519
|
+
console.log(line);
|
|
9520
|
+
}
|
|
9521
|
+
});
|
|
9522
|
+
/**
|
|
9523
|
+
* 传输层错误(TLS 握手失败 / HTTP 报文解析失败)的统一入口。来源先经
|
|
9524
|
+
* auditLabel 哈希再记录;原始 IP 绝不进入事件或日志。
|
|
9525
|
+
*/
|
|
9526
|
+
const recordTransportError = (event) => {
|
|
9527
|
+
const code = event.error?.code;
|
|
9528
|
+
diagnostics.record("connection-error", event.kind, {
|
|
9529
|
+
...event.remoteAddress !== void 0 ? { source: auditLabel(event.remoteAddress) } : {},
|
|
9530
|
+
detail: typeof code === "string" ? code : event.error instanceof Error ? event.error.message : String(event.error)
|
|
9531
|
+
});
|
|
9532
|
+
};
|
|
8953
9533
|
const pushGateway = new PushGateway({
|
|
8954
9534
|
config: currentConfig,
|
|
8955
9535
|
dataDir,
|
|
@@ -8970,7 +9550,7 @@ function apply(ctx, options) {
|
|
|
8970
9550
|
const migratedFrom = await migrateLegacyBridgeDataDir();
|
|
8971
9551
|
if (migratedFrom !== null) log(`migrated legacy plugin state from ${migratedFrom} to ${dataDir}`);
|
|
8972
9552
|
} catch (error) {
|
|
8973
|
-
log("legacy plugin-state migration skipped: " + String(error));
|
|
9553
|
+
log("legacy plugin-state migration skipped: " + String(error), "warn");
|
|
8974
9554
|
}
|
|
8975
9555
|
await ensurePrivateBridgeDataDir();
|
|
8976
9556
|
auth.audience = await loadOrCreateHostAudience(join(dataDir, "host-id"));
|
|
@@ -8982,7 +9562,7 @@ function apply(ctx, options) {
|
|
|
8982
9562
|
}
|
|
8983
9563
|
await pushGateway.restore();
|
|
8984
9564
|
} catch (error) {
|
|
8985
|
-
log("auth material unavailable, bridge degraded: " + String(error));
|
|
9565
|
+
log("auth material unavailable, bridge degraded: " + String(error), "warn");
|
|
8986
9566
|
return {
|
|
8987
9567
|
audience: null,
|
|
8988
9568
|
devices: null
|
|
@@ -9109,7 +9689,19 @@ function apply(ctx, options) {
|
|
|
9109
9689
|
lanAddresses,
|
|
9110
9690
|
local: localStatus(lanAddresses),
|
|
9111
9691
|
remote: remoteStatus(),
|
|
9112
|
-
devices
|
|
9692
|
+
devices,
|
|
9693
|
+
diagnostics: {
|
|
9694
|
+
startedAt,
|
|
9695
|
+
recentFailures: diagnostics.recentFailures(20).map((event) => ({
|
|
9696
|
+
ts: event.ts,
|
|
9697
|
+
kind: event.kind,
|
|
9698
|
+
reason: event.reason,
|
|
9699
|
+
...event.attemptId !== void 0 ? { attemptId: event.attemptId } : {},
|
|
9700
|
+
...event.source !== void 0 ? { source: event.source } : {},
|
|
9701
|
+
...event.closeCode !== void 0 ? { closeCode: event.closeCode } : {},
|
|
9702
|
+
...event.durationMs !== void 0 ? { durationMs: event.durationMs } : {}
|
|
9703
|
+
}))
|
|
9704
|
+
}
|
|
9113
9705
|
};
|
|
9114
9706
|
}, beginPairing, async (deviceId) => {
|
|
9115
9707
|
return await revokeDevice(deviceId);
|
|
@@ -9149,7 +9741,16 @@ function apply(ctx, options) {
|
|
|
9149
9741
|
};
|
|
9150
9742
|
const handlePair = async (req, res) => {
|
|
9151
9743
|
res.setHeader("Content-Type", "application/json");
|
|
9744
|
+
const source = requestClientIdentity(req);
|
|
9745
|
+
const attemptId = attemptIdFromRequest(req);
|
|
9746
|
+
const reportPairRejected = (reason) => {
|
|
9747
|
+
diagnostics.record("pair-rejected", reason, {
|
|
9748
|
+
source: auditLabel(source),
|
|
9749
|
+
...attemptId !== void 0 ? { attemptId } : {}
|
|
9750
|
+
});
|
|
9751
|
+
};
|
|
9152
9752
|
if (!enabledNow()) {
|
|
9753
|
+
reportPairRejected("bridge-disabled");
|
|
9153
9754
|
res.statusCode = 503;
|
|
9154
9755
|
res.end(JSON.stringify({
|
|
9155
9756
|
ok: false,
|
|
@@ -9158,6 +9759,7 @@ function apply(ctx, options) {
|
|
|
9158
9759
|
return;
|
|
9159
9760
|
}
|
|
9160
9761
|
if (req.method !== "POST") {
|
|
9762
|
+
reportPairRejected("method-not-allowed");
|
|
9161
9763
|
res.statusCode = 405;
|
|
9162
9764
|
res.setHeader("Allow", "POST");
|
|
9163
9765
|
res.end(JSON.stringify({
|
|
@@ -9166,9 +9768,9 @@ function apply(ctx, options) {
|
|
|
9166
9768
|
}));
|
|
9167
9769
|
return;
|
|
9168
9770
|
}
|
|
9169
|
-
const source = requestClientIdentity(req);
|
|
9170
9771
|
const admission = authRateLimiter.admit(source);
|
|
9171
9772
|
if (!admission.ok) {
|
|
9773
|
+
reportPairRejected("rate-limited");
|
|
9172
9774
|
res.statusCode = 429;
|
|
9173
9775
|
res.setHeader("Retry-After", String(Math.max(1, Math.ceil(admission.retryAfterMs / 1e3))));
|
|
9174
9776
|
res.end(JSON.stringify({
|
|
@@ -9188,6 +9790,7 @@ function apply(ctx, options) {
|
|
|
9188
9790
|
const appVersion = sanitizeDeviceField(raw.appVersion, 32) || "unknown";
|
|
9189
9791
|
const deviceId = deviceIdForPublicKey(publicKey);
|
|
9190
9792
|
if (devices.list().length >= 64 && devices.authorized(deviceId) === void 0) {
|
|
9793
|
+
reportPairRejected("registry-full");
|
|
9191
9794
|
res.statusCode = 409;
|
|
9192
9795
|
res.end(JSON.stringify({
|
|
9193
9796
|
ok: false,
|
|
@@ -9197,6 +9800,7 @@ function apply(ctx, options) {
|
|
|
9197
9800
|
}
|
|
9198
9801
|
if (!pairingCodes.consume(code)) {
|
|
9199
9802
|
const failure = authRateLimiter.recordFailure(source);
|
|
9803
|
+
reportPairRejected("invalid-code");
|
|
9200
9804
|
res.statusCode = failure.blocked ? 429 : 401;
|
|
9201
9805
|
if (failure.retryAfterMs > 0) res.setHeader("Retry-After", String(Math.max(1, Math.ceil(failure.retryAfterMs / 1e3))));
|
|
9202
9806
|
res.end(JSON.stringify({
|
|
@@ -9224,7 +9828,9 @@ function apply(ctx, options) {
|
|
|
9224
9828
|
...tlsFingerprint ? { tlsFingerprint } : {}
|
|
9225
9829
|
}));
|
|
9226
9830
|
} catch (error) {
|
|
9227
|
-
|
|
9831
|
+
const badRequest = error instanceof SyntaxError || error instanceof TypeError;
|
|
9832
|
+
reportPairRejected(badRequest ? "bad-request" : "internal-error");
|
|
9833
|
+
res.statusCode = badRequest ? 400 : 503;
|
|
9228
9834
|
res.end(JSON.stringify({
|
|
9229
9835
|
ok: false,
|
|
9230
9836
|
error: error instanceof Error ? error.message : "pairing failed"
|
|
@@ -9234,14 +9840,24 @@ function apply(ctx, options) {
|
|
|
9234
9840
|
}
|
|
9235
9841
|
};
|
|
9236
9842
|
const handleUpgrade = (req, socket, head) => {
|
|
9843
|
+
const source = requestClientIdentity(req);
|
|
9844
|
+
const sourceLabel = auditLabel(source);
|
|
9845
|
+
const attemptId = attemptIdFromRequest(req);
|
|
9846
|
+
const reject = (status, reason) => {
|
|
9847
|
+
diagnostics.record("upgrade-rejected", reason, {
|
|
9848
|
+
source: sourceLabel,
|
|
9849
|
+
...attemptId !== void 0 ? { attemptId } : {}
|
|
9850
|
+
});
|
|
9851
|
+
rejectUpgrade(socket, status, reason);
|
|
9852
|
+
};
|
|
9237
9853
|
(async () => {
|
|
9238
9854
|
try {
|
|
9239
9855
|
if (!enabledNow()) {
|
|
9240
|
-
|
|
9856
|
+
reject(503, "bridge disabled");
|
|
9241
9857
|
return;
|
|
9242
9858
|
}
|
|
9243
9859
|
if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
|
|
9244
|
-
|
|
9860
|
+
reject(429, "too many connections");
|
|
9245
9861
|
return;
|
|
9246
9862
|
}
|
|
9247
9863
|
pendingUpgrades += 1;
|
|
@@ -9249,30 +9865,36 @@ function apply(ctx, options) {
|
|
|
9249
9865
|
try {
|
|
9250
9866
|
const { devices, audience } = await ready;
|
|
9251
9867
|
if (!audience || !devices) {
|
|
9252
|
-
|
|
9868
|
+
reject(503, "bridge degraded");
|
|
9253
9869
|
return;
|
|
9254
9870
|
}
|
|
9255
9871
|
const bridge = state.bridge;
|
|
9256
9872
|
if (!bridge) {
|
|
9257
|
-
|
|
9873
|
+
reject(503, "bridge not ready");
|
|
9258
9874
|
return;
|
|
9259
9875
|
}
|
|
9260
9876
|
gate = new ConnectionGate({
|
|
9261
|
-
source
|
|
9877
|
+
source,
|
|
9262
9878
|
devices,
|
|
9263
9879
|
audience,
|
|
9264
9880
|
limiter: authRateLimiter,
|
|
9265
9881
|
log,
|
|
9266
|
-
auditLabel
|
|
9882
|
+
auditLabel,
|
|
9883
|
+
diagnostics,
|
|
9884
|
+
...attemptId !== void 0 ? { attemptId } : {}
|
|
9267
9885
|
});
|
|
9268
9886
|
if (!gate.admitted) {
|
|
9269
|
-
|
|
9887
|
+
reject(429, "authentication rate limited");
|
|
9270
9888
|
return;
|
|
9271
9889
|
}
|
|
9272
9890
|
const live = gate;
|
|
9273
9891
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
9274
9892
|
if (auth.audience !== audience || state.bridge !== bridge) {
|
|
9275
9893
|
live.markDead();
|
|
9894
|
+
diagnostics.record("upgrade-rejected", "bridge-changed", {
|
|
9895
|
+
source: sourceLabel,
|
|
9896
|
+
...attemptId !== void 0 ? { attemptId } : {}
|
|
9897
|
+
});
|
|
9276
9898
|
ws.close(1012, "bridge changed");
|
|
9277
9899
|
return;
|
|
9278
9900
|
}
|
|
@@ -9287,6 +9909,8 @@ function apply(ctx, options) {
|
|
|
9287
9909
|
source: live.source,
|
|
9288
9910
|
rateLimiter: authRateLimiter,
|
|
9289
9911
|
auditLabel,
|
|
9912
|
+
diagnostics,
|
|
9913
|
+
...attemptId !== void 0 ? { attemptId } : {},
|
|
9290
9914
|
onClosed: (closed) => connections.delete(closed),
|
|
9291
9915
|
onDeviceAuthenticated: (deviceId) => {
|
|
9292
9916
|
log(`device authenticated id=${auditLabel(deviceId)} source=${auditLabel(live.source)}`);
|
|
@@ -9306,7 +9930,12 @@ function apply(ctx, options) {
|
|
|
9306
9930
|
pendingUpgrades -= 1;
|
|
9307
9931
|
}
|
|
9308
9932
|
} catch (error) {
|
|
9309
|
-
log("upgrade failed: " + String(error));
|
|
9933
|
+
log("upgrade failed: " + String(error), "warn");
|
|
9934
|
+
diagnostics.record("upgrade-rejected", "internal-error", {
|
|
9935
|
+
source: sourceLabel,
|
|
9936
|
+
...attemptId !== void 0 ? { attemptId } : {},
|
|
9937
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
9938
|
+
});
|
|
9310
9939
|
rejectUpgrade(socket, 500, "internal error");
|
|
9311
9940
|
}
|
|
9312
9941
|
})();
|
|
@@ -9323,14 +9952,19 @@ function apply(ctx, options) {
|
|
|
9323
9952
|
}));
|
|
9324
9953
|
return;
|
|
9325
9954
|
}
|
|
9955
|
+
const local = localTransport.status();
|
|
9326
9956
|
res.statusCode = 200;
|
|
9327
|
-
res.end(JSON.stringify({
|
|
9328
|
-
|
|
9957
|
+
res.end(JSON.stringify(buildHealthPayload({
|
|
9958
|
+
startedAt,
|
|
9329
9959
|
enabled: enabledNow(),
|
|
9330
9960
|
protocolVersion: 2,
|
|
9331
9961
|
serverVersion: SERVER_VERSION,
|
|
9332
|
-
|
|
9333
|
-
|
|
9962
|
+
dataPlaneActive: Boolean(state.bridge),
|
|
9963
|
+
localPhase: local.phase,
|
|
9964
|
+
localPort: local.port,
|
|
9965
|
+
tlsIdentityRegenerated,
|
|
9966
|
+
lastFailure: diagnostics.lastFailureSummary()
|
|
9967
|
+
})));
|
|
9334
9968
|
} catch {
|
|
9335
9969
|
res.statusCode = 500;
|
|
9336
9970
|
res.end(JSON.stringify({ ok: false }));
|
|
@@ -9345,7 +9979,8 @@ function apply(ctx, options) {
|
|
|
9345
9979
|
handlers: phoneHandlers,
|
|
9346
9980
|
config: currentConfig,
|
|
9347
9981
|
tls: loadLanTls,
|
|
9348
|
-
log
|
|
9982
|
+
log,
|
|
9983
|
+
onTransportError: recordTransportError
|
|
9349
9984
|
});
|
|
9350
9985
|
const remoteTransport = createRemoteTransport({
|
|
9351
9986
|
handlers: phoneHandlers,
|
|
@@ -9363,7 +9998,7 @@ function apply(ctx, options) {
|
|
|
9363
9998
|
};
|
|
9364
9999
|
};
|
|
9365
10000
|
const remoteStatus = () => remoteTransport.status();
|
|
9366
|
-
const originServer = createPhoneServer(phoneHandlers);
|
|
10001
|
+
const originServer = createPhoneServer(phoneHandlers, void 0, { onTransportError: recordTransportError });
|
|
9367
10002
|
let originURL;
|
|
9368
10003
|
scheduleLocalReconcile = () => localTransport.scheduleReconcile();
|
|
9369
10004
|
scheduleRemoteReconcile = () => remoteTransport.scheduleReconcile();
|
|
@@ -9375,7 +10010,7 @@ function apply(ctx, options) {
|
|
|
9375
10010
|
originURL = `http://127.0.0.1:${address.port}`;
|
|
9376
10011
|
scheduleRemoteReconcile?.();
|
|
9377
10012
|
}
|
|
9378
|
-
}, (error) => log("remote origin failed: " + String(error)));
|
|
10013
|
+
}, (error) => log("remote origin failed: " + String(error), "warn"));
|
|
9379
10014
|
return async () => {
|
|
9380
10015
|
scheduleLocalReconcile = void 0;
|
|
9381
10016
|
scheduleRemoteReconcile = void 0;
|
|
@@ -9419,7 +10054,7 @@ function apply(ctx, options) {
|
|
|
9419
10054
|
try {
|
|
9420
10055
|
proxy = new DshApiProxy(apiCtx, { shouldSurfaceInteraction: hasPairedPhoneSurface });
|
|
9421
10056
|
} catch (error) {
|
|
9422
|
-
log("DSH session bridge unavailable: " + String(error));
|
|
10057
|
+
log("DSH session bridge unavailable: " + String(error), "warn");
|
|
9423
10058
|
return;
|
|
9424
10059
|
}
|
|
9425
10060
|
const bridge = new HostBridge(proxy, cfg.historyBufferMax, join(dataDir, "prompt-deliveries-v1.json"), join(dataDir, "schedule-mutations-v1.json"), join(dataDir, "fork-mutations-v1.json"), log);
|
|
@@ -9442,6 +10077,7 @@ function apply(ctx, options) {
|
|
|
9442
10077
|
await pushGateway.dispose();
|
|
9443
10078
|
const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
|
|
9444
10079
|
await Promise.allSettled([wssClosed]);
|
|
10080
|
+
await diagnostics.dispose();
|
|
9445
10081
|
}, "deeppilot: process resources");
|
|
9446
10082
|
}
|
|
9447
10083
|
//#endregion
|