dsh-deeppilot 0.9.8 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/README.zh-CN.md +15 -2
- 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 +30 -8
- package/docs/NPM_PUBLISHING.md +11 -0
- package/lib/client.js +152 -4
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +3 -0
- package/lib/index.js +634 -48
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
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 {
|
|
@@ -5524,7 +5978,7 @@ var HostBridge = class {
|
|
|
5524
5978
|
return capabilityBits(this.apiProxy, this.pushOutlet);
|
|
5525
5979
|
}
|
|
5526
5980
|
diagnostic(message) {
|
|
5527
|
-
console.log("
|
|
5981
|
+
console.log(formatLogLine("info", message));
|
|
5528
5982
|
}
|
|
5529
5983
|
currentCursor() {
|
|
5530
5984
|
return this.cursor;
|
|
@@ -5568,12 +6022,17 @@ var HostBridge = class {
|
|
|
5568
6022
|
emitNotify(args) {
|
|
5569
6023
|
if ((args.category === "turn.completed" || args.category === "session.error") && this.subagentSessionIds.has(args.sessionId)) return;
|
|
5570
6024
|
const body = args.body.length > 120 ? args.body.slice(0, 119) + "…" : args.body;
|
|
6025
|
+
const approvalExtras = args.category === "approval.required" && args.toolName ? {
|
|
6026
|
+
toolName: args.toolName.slice(0, 80),
|
|
6027
|
+
...args.detail ? { detail: args.detail.slice(0, 600) } : {}
|
|
6028
|
+
} : {};
|
|
5571
6029
|
this.record("s2c.notify", {
|
|
5572
6030
|
notificationId: args.notificationId,
|
|
5573
6031
|
category: args.category,
|
|
5574
6032
|
sessionId: args.sessionId,
|
|
5575
6033
|
title: args.title,
|
|
5576
6034
|
body,
|
|
6035
|
+
...approvalExtras,
|
|
5577
6036
|
ts: Date.now()
|
|
5578
6037
|
}, (sink) => this.isViewedBy(sink, args.sessionId));
|
|
5579
6038
|
this.fanOutPush({
|
|
@@ -5581,7 +6040,8 @@ var HostBridge = class {
|
|
|
5581
6040
|
category: args.category,
|
|
5582
6041
|
sessionId: args.sessionId,
|
|
5583
6042
|
title: args.title,
|
|
5584
|
-
body
|
|
6043
|
+
body,
|
|
6044
|
+
...approvalExtras
|
|
5585
6045
|
});
|
|
5586
6046
|
}
|
|
5587
6047
|
/** F-9: when a turn completes, notify every device not viewing the session. */
|
|
@@ -5811,7 +6271,9 @@ var HostBridge = class {
|
|
|
5811
6271
|
category: "approval.required",
|
|
5812
6272
|
title: "需要批准",
|
|
5813
6273
|
body: toolName + ": " + summary,
|
|
5814
|
-
notificationId: "apr-" + p.approvalId
|
|
6274
|
+
notificationId: "apr-" + p.approvalId,
|
|
6275
|
+
toolName,
|
|
6276
|
+
detail: summary
|
|
5815
6277
|
});
|
|
5816
6278
|
this.bumpPendingFlags(sessionId);
|
|
5817
6279
|
break;
|
|
@@ -8266,6 +8728,40 @@ function parsePushTestResult(value) {
|
|
|
8266
8728
|
results
|
|
8267
8729
|
};
|
|
8268
8730
|
}
|
|
8731
|
+
function parseDiagnosticFailure(value) {
|
|
8732
|
+
const s = rec(value, "diagnostics.recentFailures[]");
|
|
8733
|
+
const kind = str(s, "kind", "diagnostics.kind");
|
|
8734
|
+
if (!/^[a-z][a-z0-9-]{0,47}$/.test(kind)) reject("diagnostics.kind");
|
|
8735
|
+
const reason = str(s, "reason", "diagnostics.reason");
|
|
8736
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(reason)) reject("diagnostics.reason");
|
|
8737
|
+
const attemptId = s.attemptId;
|
|
8738
|
+
if (attemptId !== void 0 && (typeof attemptId !== "string" || !/^[A-Za-z0-9_-]{1,16}$/.test(attemptId))) reject("diagnostics.attemptId");
|
|
8739
|
+
const source = s.source;
|
|
8740
|
+
if (source !== void 0 && (typeof source !== "string" || !/^[0-9a-f]{8,64}$/.test(source))) reject("diagnostics.source");
|
|
8741
|
+
const closeCode = s.closeCode;
|
|
8742
|
+
if (closeCode !== void 0 && (typeof closeCode !== "number" || !Number.isInteger(closeCode))) reject("diagnostics.closeCode");
|
|
8743
|
+
const durationMs = s.durationMs;
|
|
8744
|
+
if (durationMs !== void 0 && (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs < 0)) reject("diagnostics.durationMs");
|
|
8745
|
+
return {
|
|
8746
|
+
ts: int(s, "ts", "diagnostics.ts"),
|
|
8747
|
+
kind,
|
|
8748
|
+
reason,
|
|
8749
|
+
...typeof attemptId === "string" ? { attemptId } : {},
|
|
8750
|
+
...typeof source === "string" ? { source } : {},
|
|
8751
|
+
...typeof closeCode === "number" ? { closeCode } : {},
|
|
8752
|
+
...typeof durationMs === "number" ? { durationMs: Math.round(durationMs) } : {}
|
|
8753
|
+
};
|
|
8754
|
+
}
|
|
8755
|
+
function parseDiagnostics(value) {
|
|
8756
|
+
const s = rec(value, "diagnostics");
|
|
8757
|
+
const failures = s.recentFailures;
|
|
8758
|
+
if (!Array.isArray(failures)) reject("diagnostics.recentFailures");
|
|
8759
|
+
if (failures.length > 20) reject("diagnostics.recentFailures");
|
|
8760
|
+
return {
|
|
8761
|
+
startedAt: int(s, "startedAt", "diagnostics.startedAt"),
|
|
8762
|
+
recentFailures: failures.map(parseDiagnosticFailure)
|
|
8763
|
+
};
|
|
8764
|
+
}
|
|
8269
8765
|
function parseReport(value) {
|
|
8270
8766
|
const s = rec(value, "report");
|
|
8271
8767
|
const devices = s.devices;
|
|
@@ -8287,7 +8783,8 @@ function parseReport(value) {
|
|
|
8287
8783
|
lanAddresses,
|
|
8288
8784
|
local: parseLocal(s.local),
|
|
8289
8785
|
remote: parseRemote(s.remote),
|
|
8290
|
-
devices: devices.map(parseDevice)
|
|
8786
|
+
devices: devices.map(parseDevice),
|
|
8787
|
+
...s.diagnostics !== void 0 ? { diagnostics: parseDiagnostics(s.diagnostics) } : {}
|
|
8291
8788
|
};
|
|
8292
8789
|
}
|
|
8293
8790
|
const reportSchema = { parse: parseReport };
|
|
@@ -8756,13 +9253,17 @@ async function loadOrCreateLanTlsIdentity(dir, now = Date.now()) {
|
|
|
8756
9253
|
});
|
|
8757
9254
|
const keyPath = join(dir, LAN_TLS_KEY_FILE);
|
|
8758
9255
|
const certPath = join(dir, LAN_TLS_CERT_FILE);
|
|
8759
|
-
let key = await readPem(keyPath)
|
|
9256
|
+
let key = await readPem(keyPath, (error) => {
|
|
9257
|
+
console.log(formatLogLine("warn", "LAN TLS private key unreadable, regenerating identity: " + describeFileError(error)));
|
|
9258
|
+
});
|
|
8760
9259
|
let regenerated = false;
|
|
8761
9260
|
if (key === void 0 || !validPrivateKey(key)) {
|
|
8762
9261
|
regenerated = true;
|
|
8763
9262
|
key = void 0;
|
|
8764
9263
|
}
|
|
8765
|
-
let cert = regenerated ? void 0 : await readPem(certPath)
|
|
9264
|
+
let cert = regenerated ? void 0 : await readPem(certPath, (error) => {
|
|
9265
|
+
console.log(formatLogLine("warn", "LAN TLS certificate unreadable, re-signing with the existing key: " + describeFileError(error)));
|
|
9266
|
+
});
|
|
8766
9267
|
let resigned = false;
|
|
8767
9268
|
const existing = key !== void 0 && cert !== void 0 ? inspectCertificate(cert, key, now) : void 0;
|
|
8768
9269
|
if (existing === void 0) {
|
|
@@ -8784,15 +9285,22 @@ async function loadOrCreateLanTlsIdentity(dir, now = Date.now()) {
|
|
|
8784
9285
|
resigned
|
|
8785
9286
|
};
|
|
8786
9287
|
}
|
|
8787
|
-
async function readPem(path) {
|
|
9288
|
+
async function readPem(path, onError) {
|
|
8788
9289
|
try {
|
|
8789
9290
|
const text = await readFile(path, "utf8");
|
|
8790
9291
|
return text.trim().length > 0 ? text : void 0;
|
|
8791
9292
|
} catch (error) {
|
|
8792
9293
|
if (error.code === "ENOENT") return void 0;
|
|
9294
|
+
onError?.(error);
|
|
8793
9295
|
return;
|
|
8794
9296
|
}
|
|
8795
9297
|
}
|
|
9298
|
+
/** 读失败的安全描述:文件名 + errno,不带文件内容。 */
|
|
9299
|
+
function describeFileError(error) {
|
|
9300
|
+
const code = error?.code;
|
|
9301
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
9302
|
+
return (typeof code === "string" ? "code=" + code + " " : "") + message.slice(0, 140);
|
|
9303
|
+
}
|
|
8796
9304
|
function validPrivateKey(pem) {
|
|
8797
9305
|
try {
|
|
8798
9306
|
return createPrivateKey(pem).asymmetricKeyType === "ec";
|
|
@@ -8935,9 +9443,7 @@ function readOwnPackageVersion() {
|
|
|
8935
9443
|
}
|
|
8936
9444
|
function apply(ctx, options) {
|
|
8937
9445
|
const cfg = normalizeOptions(options);
|
|
8938
|
-
const log = (
|
|
8939
|
-
console.log("[deeppilot] " + message);
|
|
8940
|
-
};
|
|
9446
|
+
const log = createLogger();
|
|
8941
9447
|
const auditSalt = randomBytes(32);
|
|
8942
9448
|
const auditLabel = (value) => createHash("sha256").update(auditSalt).update(value).digest("hex").slice(0, 12);
|
|
8943
9449
|
let scheduleRemoteReconcile;
|
|
@@ -8950,6 +9456,30 @@ function apply(ctx, options) {
|
|
|
8950
9456
|
}));
|
|
8951
9457
|
if (currentConfig().enabled !== true) log("disabled via settings; bridge stays inactive (rumors of /phone below are skipped)");
|
|
8952
9458
|
const dataDir = bridgeDataDir();
|
|
9459
|
+
/** 插件启动时刻:`/phone/health` 与设置页报告都读它。 */
|
|
9460
|
+
const startedAt = Date.now();
|
|
9461
|
+
/**
|
|
9462
|
+
* 连接诊断事件:内存环形 256 条 + 按天落盘(插件数据目录 diagnostics/,
|
|
9463
|
+
* 保留 7 天、目录上限 5MB)。与 `diagnostics.debug` 完全解耦——失败事件
|
|
9464
|
+
* 无条件记录,否则用户不开调试开关就永远看不到「为什么连不上」。
|
|
9465
|
+
*/
|
|
9466
|
+
const diagnostics = new DiagnosticsStore({
|
|
9467
|
+
dir: join(dataDir, "diagnostics"),
|
|
9468
|
+
write: (line) => {
|
|
9469
|
+
console.log(line);
|
|
9470
|
+
}
|
|
9471
|
+
});
|
|
9472
|
+
/**
|
|
9473
|
+
* 传输层错误(TLS 握手失败 / HTTP 报文解析失败)的统一入口。来源先经
|
|
9474
|
+
* auditLabel 哈希再记录;原始 IP 绝不进入事件或日志。
|
|
9475
|
+
*/
|
|
9476
|
+
const recordTransportError = (event) => {
|
|
9477
|
+
const code = event.error?.code;
|
|
9478
|
+
diagnostics.record("connection-error", event.kind, {
|
|
9479
|
+
...event.remoteAddress !== void 0 ? { source: auditLabel(event.remoteAddress) } : {},
|
|
9480
|
+
detail: typeof code === "string" ? code : event.error instanceof Error ? event.error.message : String(event.error)
|
|
9481
|
+
});
|
|
9482
|
+
};
|
|
8953
9483
|
const pushGateway = new PushGateway({
|
|
8954
9484
|
config: currentConfig,
|
|
8955
9485
|
dataDir,
|
|
@@ -8970,7 +9500,7 @@ function apply(ctx, options) {
|
|
|
8970
9500
|
const migratedFrom = await migrateLegacyBridgeDataDir();
|
|
8971
9501
|
if (migratedFrom !== null) log(`migrated legacy plugin state from ${migratedFrom} to ${dataDir}`);
|
|
8972
9502
|
} catch (error) {
|
|
8973
|
-
log("legacy plugin-state migration skipped: " + String(error));
|
|
9503
|
+
log("legacy plugin-state migration skipped: " + String(error), "warn");
|
|
8974
9504
|
}
|
|
8975
9505
|
await ensurePrivateBridgeDataDir();
|
|
8976
9506
|
auth.audience = await loadOrCreateHostAudience(join(dataDir, "host-id"));
|
|
@@ -8982,7 +9512,7 @@ function apply(ctx, options) {
|
|
|
8982
9512
|
}
|
|
8983
9513
|
await pushGateway.restore();
|
|
8984
9514
|
} catch (error) {
|
|
8985
|
-
log("auth material unavailable, bridge degraded: " + String(error));
|
|
9515
|
+
log("auth material unavailable, bridge degraded: " + String(error), "warn");
|
|
8986
9516
|
return {
|
|
8987
9517
|
audience: null,
|
|
8988
9518
|
devices: null
|
|
@@ -9109,7 +9639,19 @@ function apply(ctx, options) {
|
|
|
9109
9639
|
lanAddresses,
|
|
9110
9640
|
local: localStatus(lanAddresses),
|
|
9111
9641
|
remote: remoteStatus(),
|
|
9112
|
-
devices
|
|
9642
|
+
devices,
|
|
9643
|
+
diagnostics: {
|
|
9644
|
+
startedAt,
|
|
9645
|
+
recentFailures: diagnostics.recentFailures(20).map((event) => ({
|
|
9646
|
+
ts: event.ts,
|
|
9647
|
+
kind: event.kind,
|
|
9648
|
+
reason: event.reason,
|
|
9649
|
+
...event.attemptId !== void 0 ? { attemptId: event.attemptId } : {},
|
|
9650
|
+
...event.source !== void 0 ? { source: event.source } : {},
|
|
9651
|
+
...event.closeCode !== void 0 ? { closeCode: event.closeCode } : {},
|
|
9652
|
+
...event.durationMs !== void 0 ? { durationMs: event.durationMs } : {}
|
|
9653
|
+
}))
|
|
9654
|
+
}
|
|
9113
9655
|
};
|
|
9114
9656
|
}, beginPairing, async (deviceId) => {
|
|
9115
9657
|
return await revokeDevice(deviceId);
|
|
@@ -9149,7 +9691,16 @@ function apply(ctx, options) {
|
|
|
9149
9691
|
};
|
|
9150
9692
|
const handlePair = async (req, res) => {
|
|
9151
9693
|
res.setHeader("Content-Type", "application/json");
|
|
9694
|
+
const source = requestClientIdentity(req);
|
|
9695
|
+
const attemptId = attemptIdFromRequest(req);
|
|
9696
|
+
const reportPairRejected = (reason) => {
|
|
9697
|
+
diagnostics.record("pair-rejected", reason, {
|
|
9698
|
+
source: auditLabel(source),
|
|
9699
|
+
...attemptId !== void 0 ? { attemptId } : {}
|
|
9700
|
+
});
|
|
9701
|
+
};
|
|
9152
9702
|
if (!enabledNow()) {
|
|
9703
|
+
reportPairRejected("bridge-disabled");
|
|
9153
9704
|
res.statusCode = 503;
|
|
9154
9705
|
res.end(JSON.stringify({
|
|
9155
9706
|
ok: false,
|
|
@@ -9158,6 +9709,7 @@ function apply(ctx, options) {
|
|
|
9158
9709
|
return;
|
|
9159
9710
|
}
|
|
9160
9711
|
if (req.method !== "POST") {
|
|
9712
|
+
reportPairRejected("method-not-allowed");
|
|
9161
9713
|
res.statusCode = 405;
|
|
9162
9714
|
res.setHeader("Allow", "POST");
|
|
9163
9715
|
res.end(JSON.stringify({
|
|
@@ -9166,9 +9718,9 @@ function apply(ctx, options) {
|
|
|
9166
9718
|
}));
|
|
9167
9719
|
return;
|
|
9168
9720
|
}
|
|
9169
|
-
const source = requestClientIdentity(req);
|
|
9170
9721
|
const admission = authRateLimiter.admit(source);
|
|
9171
9722
|
if (!admission.ok) {
|
|
9723
|
+
reportPairRejected("rate-limited");
|
|
9172
9724
|
res.statusCode = 429;
|
|
9173
9725
|
res.setHeader("Retry-After", String(Math.max(1, Math.ceil(admission.retryAfterMs / 1e3))));
|
|
9174
9726
|
res.end(JSON.stringify({
|
|
@@ -9188,6 +9740,7 @@ function apply(ctx, options) {
|
|
|
9188
9740
|
const appVersion = sanitizeDeviceField(raw.appVersion, 32) || "unknown";
|
|
9189
9741
|
const deviceId = deviceIdForPublicKey(publicKey);
|
|
9190
9742
|
if (devices.list().length >= 64 && devices.authorized(deviceId) === void 0) {
|
|
9743
|
+
reportPairRejected("registry-full");
|
|
9191
9744
|
res.statusCode = 409;
|
|
9192
9745
|
res.end(JSON.stringify({
|
|
9193
9746
|
ok: false,
|
|
@@ -9197,6 +9750,7 @@ function apply(ctx, options) {
|
|
|
9197
9750
|
}
|
|
9198
9751
|
if (!pairingCodes.consume(code)) {
|
|
9199
9752
|
const failure = authRateLimiter.recordFailure(source);
|
|
9753
|
+
reportPairRejected("invalid-code");
|
|
9200
9754
|
res.statusCode = failure.blocked ? 429 : 401;
|
|
9201
9755
|
if (failure.retryAfterMs > 0) res.setHeader("Retry-After", String(Math.max(1, Math.ceil(failure.retryAfterMs / 1e3))));
|
|
9202
9756
|
res.end(JSON.stringify({
|
|
@@ -9224,7 +9778,9 @@ function apply(ctx, options) {
|
|
|
9224
9778
|
...tlsFingerprint ? { tlsFingerprint } : {}
|
|
9225
9779
|
}));
|
|
9226
9780
|
} catch (error) {
|
|
9227
|
-
|
|
9781
|
+
const badRequest = error instanceof SyntaxError || error instanceof TypeError;
|
|
9782
|
+
reportPairRejected(badRequest ? "bad-request" : "internal-error");
|
|
9783
|
+
res.statusCode = badRequest ? 400 : 503;
|
|
9228
9784
|
res.end(JSON.stringify({
|
|
9229
9785
|
ok: false,
|
|
9230
9786
|
error: error instanceof Error ? error.message : "pairing failed"
|
|
@@ -9234,14 +9790,24 @@ function apply(ctx, options) {
|
|
|
9234
9790
|
}
|
|
9235
9791
|
};
|
|
9236
9792
|
const handleUpgrade = (req, socket, head) => {
|
|
9793
|
+
const source = requestClientIdentity(req);
|
|
9794
|
+
const sourceLabel = auditLabel(source);
|
|
9795
|
+
const attemptId = attemptIdFromRequest(req);
|
|
9796
|
+
const reject = (status, reason) => {
|
|
9797
|
+
diagnostics.record("upgrade-rejected", reason, {
|
|
9798
|
+
source: sourceLabel,
|
|
9799
|
+
...attemptId !== void 0 ? { attemptId } : {}
|
|
9800
|
+
});
|
|
9801
|
+
rejectUpgrade(socket, status, reason);
|
|
9802
|
+
};
|
|
9237
9803
|
(async () => {
|
|
9238
9804
|
try {
|
|
9239
9805
|
if (!enabledNow()) {
|
|
9240
|
-
|
|
9806
|
+
reject(503, "bridge disabled");
|
|
9241
9807
|
return;
|
|
9242
9808
|
}
|
|
9243
9809
|
if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
|
|
9244
|
-
|
|
9810
|
+
reject(429, "too many connections");
|
|
9245
9811
|
return;
|
|
9246
9812
|
}
|
|
9247
9813
|
pendingUpgrades += 1;
|
|
@@ -9249,30 +9815,36 @@ function apply(ctx, options) {
|
|
|
9249
9815
|
try {
|
|
9250
9816
|
const { devices, audience } = await ready;
|
|
9251
9817
|
if (!audience || !devices) {
|
|
9252
|
-
|
|
9818
|
+
reject(503, "bridge degraded");
|
|
9253
9819
|
return;
|
|
9254
9820
|
}
|
|
9255
9821
|
const bridge = state.bridge;
|
|
9256
9822
|
if (!bridge) {
|
|
9257
|
-
|
|
9823
|
+
reject(503, "bridge not ready");
|
|
9258
9824
|
return;
|
|
9259
9825
|
}
|
|
9260
9826
|
gate = new ConnectionGate({
|
|
9261
|
-
source
|
|
9827
|
+
source,
|
|
9262
9828
|
devices,
|
|
9263
9829
|
audience,
|
|
9264
9830
|
limiter: authRateLimiter,
|
|
9265
9831
|
log,
|
|
9266
|
-
auditLabel
|
|
9832
|
+
auditLabel,
|
|
9833
|
+
diagnostics,
|
|
9834
|
+
...attemptId !== void 0 ? { attemptId } : {}
|
|
9267
9835
|
});
|
|
9268
9836
|
if (!gate.admitted) {
|
|
9269
|
-
|
|
9837
|
+
reject(429, "authentication rate limited");
|
|
9270
9838
|
return;
|
|
9271
9839
|
}
|
|
9272
9840
|
const live = gate;
|
|
9273
9841
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
9274
9842
|
if (auth.audience !== audience || state.bridge !== bridge) {
|
|
9275
9843
|
live.markDead();
|
|
9844
|
+
diagnostics.record("upgrade-rejected", "bridge-changed", {
|
|
9845
|
+
source: sourceLabel,
|
|
9846
|
+
...attemptId !== void 0 ? { attemptId } : {}
|
|
9847
|
+
});
|
|
9276
9848
|
ws.close(1012, "bridge changed");
|
|
9277
9849
|
return;
|
|
9278
9850
|
}
|
|
@@ -9287,6 +9859,8 @@ function apply(ctx, options) {
|
|
|
9287
9859
|
source: live.source,
|
|
9288
9860
|
rateLimiter: authRateLimiter,
|
|
9289
9861
|
auditLabel,
|
|
9862
|
+
diagnostics,
|
|
9863
|
+
...attemptId !== void 0 ? { attemptId } : {},
|
|
9290
9864
|
onClosed: (closed) => connections.delete(closed),
|
|
9291
9865
|
onDeviceAuthenticated: (deviceId) => {
|
|
9292
9866
|
log(`device authenticated id=${auditLabel(deviceId)} source=${auditLabel(live.source)}`);
|
|
@@ -9306,7 +9880,12 @@ function apply(ctx, options) {
|
|
|
9306
9880
|
pendingUpgrades -= 1;
|
|
9307
9881
|
}
|
|
9308
9882
|
} catch (error) {
|
|
9309
|
-
log("upgrade failed: " + String(error));
|
|
9883
|
+
log("upgrade failed: " + String(error), "warn");
|
|
9884
|
+
diagnostics.record("upgrade-rejected", "internal-error", {
|
|
9885
|
+
source: sourceLabel,
|
|
9886
|
+
...attemptId !== void 0 ? { attemptId } : {},
|
|
9887
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
9888
|
+
});
|
|
9310
9889
|
rejectUpgrade(socket, 500, "internal error");
|
|
9311
9890
|
}
|
|
9312
9891
|
})();
|
|
@@ -9323,14 +9902,19 @@ function apply(ctx, options) {
|
|
|
9323
9902
|
}));
|
|
9324
9903
|
return;
|
|
9325
9904
|
}
|
|
9905
|
+
const local = localTransport.status();
|
|
9326
9906
|
res.statusCode = 200;
|
|
9327
|
-
res.end(JSON.stringify({
|
|
9328
|
-
|
|
9907
|
+
res.end(JSON.stringify(buildHealthPayload({
|
|
9908
|
+
startedAt,
|
|
9329
9909
|
enabled: enabledNow(),
|
|
9330
9910
|
protocolVersion: 2,
|
|
9331
9911
|
serverVersion: SERVER_VERSION,
|
|
9332
|
-
|
|
9333
|
-
|
|
9912
|
+
dataPlaneActive: Boolean(state.bridge),
|
|
9913
|
+
localPhase: local.phase,
|
|
9914
|
+
localPort: local.port,
|
|
9915
|
+
tlsIdentityRegenerated,
|
|
9916
|
+
lastFailure: diagnostics.lastFailureSummary()
|
|
9917
|
+
})));
|
|
9334
9918
|
} catch {
|
|
9335
9919
|
res.statusCode = 500;
|
|
9336
9920
|
res.end(JSON.stringify({ ok: false }));
|
|
@@ -9345,7 +9929,8 @@ function apply(ctx, options) {
|
|
|
9345
9929
|
handlers: phoneHandlers,
|
|
9346
9930
|
config: currentConfig,
|
|
9347
9931
|
tls: loadLanTls,
|
|
9348
|
-
log
|
|
9932
|
+
log,
|
|
9933
|
+
onTransportError: recordTransportError
|
|
9349
9934
|
});
|
|
9350
9935
|
const remoteTransport = createRemoteTransport({
|
|
9351
9936
|
handlers: phoneHandlers,
|
|
@@ -9363,7 +9948,7 @@ function apply(ctx, options) {
|
|
|
9363
9948
|
};
|
|
9364
9949
|
};
|
|
9365
9950
|
const remoteStatus = () => remoteTransport.status();
|
|
9366
|
-
const originServer = createPhoneServer(phoneHandlers);
|
|
9951
|
+
const originServer = createPhoneServer(phoneHandlers, void 0, { onTransportError: recordTransportError });
|
|
9367
9952
|
let originURL;
|
|
9368
9953
|
scheduleLocalReconcile = () => localTransport.scheduleReconcile();
|
|
9369
9954
|
scheduleRemoteReconcile = () => remoteTransport.scheduleReconcile();
|
|
@@ -9375,7 +9960,7 @@ function apply(ctx, options) {
|
|
|
9375
9960
|
originURL = `http://127.0.0.1:${address.port}`;
|
|
9376
9961
|
scheduleRemoteReconcile?.();
|
|
9377
9962
|
}
|
|
9378
|
-
}, (error) => log("remote origin failed: " + String(error)));
|
|
9963
|
+
}, (error) => log("remote origin failed: " + String(error), "warn"));
|
|
9379
9964
|
return async () => {
|
|
9380
9965
|
scheduleLocalReconcile = void 0;
|
|
9381
9966
|
scheduleRemoteReconcile = void 0;
|
|
@@ -9419,7 +10004,7 @@ function apply(ctx, options) {
|
|
|
9419
10004
|
try {
|
|
9420
10005
|
proxy = new DshApiProxy(apiCtx, { shouldSurfaceInteraction: hasPairedPhoneSurface });
|
|
9421
10006
|
} catch (error) {
|
|
9422
|
-
log("DSH session bridge unavailable: " + String(error));
|
|
10007
|
+
log("DSH session bridge unavailable: " + String(error), "warn");
|
|
9423
10008
|
return;
|
|
9424
10009
|
}
|
|
9425
10010
|
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 +10027,7 @@ function apply(ctx, options) {
|
|
|
9442
10027
|
await pushGateway.dispose();
|
|
9443
10028
|
const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
|
|
9444
10029
|
await Promise.allSettled([wssClosed]);
|
|
10030
|
+
await diagnostics.dispose();
|
|
9445
10031
|
}, "deeppilot: process resources");
|
|
9446
10032
|
}
|
|
9447
10033
|
//#endregion
|