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/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 {
@@ -5524,7 +5978,7 @@ var HostBridge = class {
5524
5978
  return capabilityBits(this.apiProxy, this.pushOutlet);
5525
5979
  }
5526
5980
  diagnostic(message) {
5527
- console.log("[deeppilot] " + message);
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 = (message) => {
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
- res.statusCode = error instanceof SyntaxError || error instanceof TypeError ? 400 : 503;
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
- rejectUpgrade(socket, 503, "bridge disabled");
9806
+ reject(503, "bridge disabled");
9241
9807
  return;
9242
9808
  }
9243
9809
  if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
9244
- rejectUpgrade(socket, 429, "too many connections");
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
- rejectUpgrade(socket, 503, "bridge degraded");
9818
+ reject(503, "bridge degraded");
9253
9819
  return;
9254
9820
  }
9255
9821
  const bridge = state.bridge;
9256
9822
  if (!bridge) {
9257
- rejectUpgrade(socket, 503, "bridge not ready");
9823
+ reject(503, "bridge not ready");
9258
9824
  return;
9259
9825
  }
9260
9826
  gate = new ConnectionGate({
9261
- source: requestClientIdentity(req),
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
- rejectUpgrade(socket, 429, "authentication rate limited");
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
- ok: true,
9907
+ res.end(JSON.stringify(buildHealthPayload({
9908
+ startedAt,
9329
9909
  enabled: enabledNow(),
9330
9910
  protocolVersion: 2,
9331
9911
  serverVersion: SERVER_VERSION,
9332
- dataPlane: Boolean(state.bridge)
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