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/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("[deeppilot] device registry unreadable, starting empty: " + String(error));
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 record = this.options.devices.authorized(deviceId);
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, 0),
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("[deeppilot] " + message);
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
- if ((args.category === "turn.completed" || args.category === "session.error") && this.subagentSessionIds.has(args.sessionId)) return;
5570
- const body = args.body.length > 120 ? args.body.slice(0, 119) + "…" : args.body;
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
- /** Remember the latest assistant text so notifications can quote it. */
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.slice(-160));
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 = (message) => {
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
- res.statusCode = error instanceof SyntaxError || error instanceof TypeError ? 400 : 503;
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
- rejectUpgrade(socket, 503, "bridge disabled");
9856
+ reject(503, "bridge disabled");
9241
9857
  return;
9242
9858
  }
9243
9859
  if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
9244
- rejectUpgrade(socket, 429, "too many connections");
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
- rejectUpgrade(socket, 503, "bridge degraded");
9868
+ reject(503, "bridge degraded");
9253
9869
  return;
9254
9870
  }
9255
9871
  const bridge = state.bridge;
9256
9872
  if (!bridge) {
9257
- rejectUpgrade(socket, 503, "bridge not ready");
9873
+ reject(503, "bridge not ready");
9258
9874
  return;
9259
9875
  }
9260
9876
  gate = new ConnectionGate({
9261
- source: requestClientIdentity(req),
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
- rejectUpgrade(socket, 429, "authentication rate limited");
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
- ok: true,
9957
+ res.end(JSON.stringify(buildHealthPayload({
9958
+ startedAt,
9329
9959
  enabled: enabledNow(),
9330
9960
  protocolVersion: 2,
9331
9961
  serverVersion: SERVER_VERSION,
9332
- dataPlane: Boolean(state.bridge)
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