dsh-deeppilot 0.9.7 → 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/COMPATIBILITY.md +74 -9
- package/README.md +33 -12
- package/README.zh-CN.md +21 -7
- package/bin/SHA256SUMS +6 -6
- package/bin/darwin-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/darwin-arm64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-arm64/dsh-deeppilot-tunnel +0 -0
- package/bin/windows-amd64/dsh-deeppilot-tunnel.exe +0 -0
- package/bin/windows-arm64/dsh-deeppilot-tunnel.exe +0 -0
- package/docs/DSH_RELEASE_MEMORY.md +157 -8
- package/docs/NPM_PUBLISHING.md +29 -0
- package/lib/client.js +152 -4
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +90 -3
- package/lib/index.js +1077 -48
- package/lib/index.js.map +1 -1
- package/package.json +12 -12
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
|
|
3
|
-
import { access, chmod, mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { access, appendFile, chmod, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { WebSocketServer } from "ws";
|
|
6
6
|
import { homedir, networkInterfaces } from "node:os";
|
|
@@ -98,6 +98,24 @@ const DOMAIN_TABLES = {
|
|
|
98
98
|
"frequency_too_high": "E_PROTOCOL",
|
|
99
99
|
"schedule_ended": "E_PROTOCOL",
|
|
100
100
|
"subagent_session": "E_PROTOCOL"
|
|
101
|
+
},
|
|
102
|
+
command: {
|
|
103
|
+
"command/not-found": "E_NOT_FOUND",
|
|
104
|
+
"command/definition-changed": "E_NOT_FOUND",
|
|
105
|
+
"command/attachments-unsupported": "E_PROTOCOL",
|
|
106
|
+
"command/export-unavailable": "E_INTERNAL",
|
|
107
|
+
"command/export-cancelled": "E_INTERNAL",
|
|
108
|
+
"command/export-unsupported": "E_UNSUPPORTED",
|
|
109
|
+
"command/invalid-response": "E_INTERNAL",
|
|
110
|
+
"command/permissions-unsupported": "E_UNSUPPORTED",
|
|
111
|
+
"gateway/bad-request": "E_PROTOCOL",
|
|
112
|
+
"session/not-found": "E_NOT_FOUND",
|
|
113
|
+
"session/agent-busy": "E_BUSY",
|
|
114
|
+
"session/writer-held": "E_BUSY",
|
|
115
|
+
"session/conflict": "E_BUSY",
|
|
116
|
+
"session-not-found": "E_NOT_FOUND",
|
|
117
|
+
"agent-busy": "E_BUSY",
|
|
118
|
+
"session-conflict": "E_BUSY"
|
|
101
119
|
}
|
|
102
120
|
};
|
|
103
121
|
/**
|
|
@@ -127,6 +145,24 @@ function wireErrorOf(domain, error) {
|
|
|
127
145
|
};
|
|
128
146
|
}
|
|
129
147
|
/**
|
|
148
|
+
* 把任意抛出物收敛成 `HostFailure`,供 `catch` 分支复用同一张域表。
|
|
149
|
+
*
|
|
150
|
+
* 指令链路里有两种失败来源:`DshApiProxy.call` 把插件的失败码放在
|
|
151
|
+
* `{result:{ok:false,error}}` 里正常返回,而导出的归档流是直接抛出的
|
|
152
|
+
* (`AbortError`、主机侧 `Error` 都算)。把「取形状」和「查表」分开,是为了让
|
|
153
|
+
* 两条路都只经一次翻译;没有 code 的抛出物用调用方给的 fallback,避免把
|
|
154
|
+
* `undefined` 当码送进 lookupCode 而落进无法解释的兜底。
|
|
155
|
+
*/
|
|
156
|
+
function hostFailureOf(error, fallbackCode) {
|
|
157
|
+
const value = error;
|
|
158
|
+
const code = typeof value?.code === "string" && value.code.length > 0 ? value.code : fallbackCode;
|
|
159
|
+
const message = typeof value?.message === "string" && value.message.length > 0 ? value.message : void 0;
|
|
160
|
+
return message === void 0 ? { code } : {
|
|
161
|
+
code,
|
|
162
|
+
message
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
130
166
|
* 组装给手机看的那句话。
|
|
131
167
|
*
|
|
132
168
|
* DSH 把 prompt 准入的**任意**内部失败包成 `session/agent-busy`,真实原因只留在
|
|
@@ -213,6 +249,9 @@ function isEnvelope(value) {
|
|
|
213
249
|
*/
|
|
214
250
|
const PUSH_SCOPE_BY_TYPE = {
|
|
215
251
|
"s2c.session.event": "sessions.read",
|
|
252
|
+
"s2c.skills.changed": "sessions.read",
|
|
253
|
+
"s2c.commands.changed": "sessions.read",
|
|
254
|
+
"s2c.models.changed": "sessions.read",
|
|
216
255
|
"s2c.sessions.delta": "sessions.read",
|
|
217
256
|
"s2c.session.tail": "sessions.read",
|
|
218
257
|
"s2c.history.page": "sessions.read",
|
|
@@ -355,6 +394,333 @@ var AuthRateLimiter = class {
|
|
|
355
394
|
}
|
|
356
395
|
};
|
|
357
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
|
|
358
724
|
//#region src/device-auth.ts
|
|
359
725
|
function getPairingCodeTtlMs() {
|
|
360
726
|
return Number(process.env.DEEPPILOT_PAIRING_TTL_MS) || 3e5;
|
|
@@ -380,6 +746,7 @@ async function loadOrCreateHostAudience(path) {
|
|
|
380
746
|
const audience = "deeppilot:" + randomBytes(16).toString("base64url");
|
|
381
747
|
await mkdir(dirname(path), { recursive: true });
|
|
382
748
|
await writeFile(path, audience + "\n", { mode: 384 });
|
|
749
|
+
console.log(formatLogLine("info", "host identity created: " + path + " (previously paired devices must pair again)"));
|
|
383
750
|
return audience;
|
|
384
751
|
}
|
|
385
752
|
function b64urlText(value) {
|
|
@@ -644,6 +1011,11 @@ var DeviceStore = class DeviceStore {
|
|
|
644
1011
|
filePath;
|
|
645
1012
|
devices = /* @__PURE__ */ new Map();
|
|
646
1013
|
flushTail = Promise.resolve();
|
|
1014
|
+
/**
|
|
1015
|
+
* 注册表写失败的日志节流:磁盘只读 / 空间不足时每次 flush 都会失败,
|
|
1016
|
+
* 不节流会把「写不进去」变成新的刷屏源。同一条错误 60s 只报一次。
|
|
1017
|
+
*/
|
|
1018
|
+
lastWriteFailureLogAt = 0;
|
|
647
1019
|
constructor(filePath) {
|
|
648
1020
|
this.filePath = filePath;
|
|
649
1021
|
}
|
|
@@ -653,7 +1025,7 @@ var DeviceStore = class DeviceStore {
|
|
|
653
1025
|
const raw = JSON.parse(await readFile(expandHome(filePath), "utf8"));
|
|
654
1026
|
for (const rec of raw.devices ?? []) if (typeof rec.deviceId === "string") store.devices.set(rec.deviceId, rec);
|
|
655
1027
|
} catch (error) {
|
|
656
|
-
if (error?.code === "ENOENT") {} else console.log("
|
|
1028
|
+
if (error?.code === "ENOENT") {} else console.log(formatLogLine("warn", "device registry unreadable, starting empty: " + String(error)));
|
|
657
1029
|
}
|
|
658
1030
|
return store;
|
|
659
1031
|
}
|
|
@@ -686,6 +1058,18 @@ var DeviceStore = class DeviceStore {
|
|
|
686
1058
|
if (!record?.publicKey || record.revokedAt !== void 0) return void 0;
|
|
687
1059
|
return record;
|
|
688
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
|
+
}
|
|
689
1073
|
markAuthenticated(deviceId, deviceName, appVersion, now) {
|
|
690
1074
|
const record = this.authorized(deviceId);
|
|
691
1075
|
if (!record) return;
|
|
@@ -850,7 +1234,18 @@ var DeviceStore = class DeviceStore {
|
|
|
850
1234
|
const temp = `${full}.${randomBytes(6).toString("hex")}.tmp`;
|
|
851
1235
|
await writeFile(temp, body + "\n", { mode: 384 });
|
|
852
1236
|
await rename(temp, full);
|
|
853
|
-
} 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)));
|
|
854
1249
|
}
|
|
855
1250
|
};
|
|
856
1251
|
//#endregion
|
|
@@ -2067,6 +2462,26 @@ const sessionRows = [
|
|
|
2067
2462
|
ctx.send("s2c.ack", image);
|
|
2068
2463
|
}
|
|
2069
2464
|
},
|
|
2465
|
+
{
|
|
2466
|
+
type: "c2s.session.skills",
|
|
2467
|
+
stage: "authenticated",
|
|
2468
|
+
scopes: READ,
|
|
2469
|
+
capability: "skills",
|
|
2470
|
+
doc: "PROTOCOL.md c2s.session.skills",
|
|
2471
|
+
validate: (payload) => {
|
|
2472
|
+
const checked = payloadObject(payload);
|
|
2473
|
+
if (!checked.ok) return checked;
|
|
2474
|
+
return requireSessionId(checked.value) ?? checked;
|
|
2475
|
+
},
|
|
2476
|
+
handle: async (ctx, payload) => {
|
|
2477
|
+
const result = await ctx.bridge.sessionSkills(payload.sessionId);
|
|
2478
|
+
if (!result.ok) return ctx.fail(result.code, result.message);
|
|
2479
|
+
ctx.send("s2c.session.skills", {
|
|
2480
|
+
sessionId: payload.sessionId,
|
|
2481
|
+
...result.value
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
},
|
|
2070
2485
|
{
|
|
2071
2486
|
type: "c2s.session.models",
|
|
2072
2487
|
stage: "authenticated",
|
|
@@ -2219,6 +2634,121 @@ const sessionRows = [
|
|
|
2219
2634
|
}
|
|
2220
2635
|
}
|
|
2221
2636
|
];
|
|
2637
|
+
//#endregion
|
|
2638
|
+
//#region src/wire-command.ts
|
|
2639
|
+
function sessionPayload(value) {
|
|
2640
|
+
const result = payloadObject(value);
|
|
2641
|
+
return result.ok && !isText(result.value.sessionId) ? reject$1("E_PROTOCOL", "会话标识无效") : result;
|
|
2642
|
+
}
|
|
2643
|
+
const categories = [
|
|
2644
|
+
"task-result",
|
|
2645
|
+
"instruction-following",
|
|
2646
|
+
"product-interaction",
|
|
2647
|
+
"service-stability",
|
|
2648
|
+
"resource-cost",
|
|
2649
|
+
"security-privacy-permission",
|
|
2650
|
+
"other"
|
|
2651
|
+
];
|
|
2652
|
+
const commandRows = [...[
|
|
2653
|
+
"commands",
|
|
2654
|
+
"permissions",
|
|
2655
|
+
"feedback",
|
|
2656
|
+
"command"
|
|
2657
|
+
].map((name) => ({
|
|
2658
|
+
type: `c2s.session.${name}`,
|
|
2659
|
+
stage: "authenticated",
|
|
2660
|
+
capability: "commands",
|
|
2661
|
+
scopes: name === "commands" || name === "permissions" ? ["sessions.read"] : ["prompt.send"],
|
|
2662
|
+
doc: `PROTOCOL.md c2s.session.${name}`,
|
|
2663
|
+
validate: (value) => {
|
|
2664
|
+
const result = sessionPayload(value);
|
|
2665
|
+
if (!result.ok) return result;
|
|
2666
|
+
const p = result.value;
|
|
2667
|
+
if (name === "command") {
|
|
2668
|
+
if (typeof p.line !== "string" || p.line.length > 65536 || !/^\/[a-z][a-z0-9_-]*(?:\s|$)/.test(p.line)) return reject$1("E_PROTOCOL", "指令格式无效");
|
|
2669
|
+
if (p.expectedDefinitionId !== void 0 && (typeof p.expectedDefinitionId !== "string" || !p.expectedDefinitionId || p.expectedDefinitionId.length > 512)) return reject$1("E_PROTOCOL", "指令定义标识无效");
|
|
2670
|
+
const attachments = p.attachments ?? [];
|
|
2671
|
+
if (!Array.isArray(attachments) || attachments.length > 4) return reject$1("E_PROTOCOL", "附件数量无效");
|
|
2672
|
+
for (const item of attachments) if (!item || !["image", "document"].includes(item.type) || typeof item.name !== "string" || item.name.length > 512 || typeof item.mediaType !== "string" || typeof item.data !== "string" || item.data.length > 16777216 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(item.data) || item.type === "image" && ![
|
|
2673
|
+
"image/png",
|
|
2674
|
+
"image/jpeg",
|
|
2675
|
+
"image/webp",
|
|
2676
|
+
"image/gif"
|
|
2677
|
+
].includes(item.mediaType)) return reject$1("E_PROTOCOL", "附件格式无效");
|
|
2678
|
+
return {
|
|
2679
|
+
ok: true,
|
|
2680
|
+
value: {
|
|
2681
|
+
...p,
|
|
2682
|
+
attachments
|
|
2683
|
+
}
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
if (name === "feedback" && (typeof p.text !== "string" || p.text.length > 65536 || p.category !== void 0 && !categories.includes(String(p.category)))) return reject$1("E_PROTOCOL", "反馈内容无效");
|
|
2687
|
+
return result;
|
|
2688
|
+
},
|
|
2689
|
+
handle: async (ctx, payload) => {
|
|
2690
|
+
const operation = name === "commands" ? "list" : name === "command" ? "execute" : name;
|
|
2691
|
+
const result = await ctx.bridge.commandOperation(operation, payload);
|
|
2692
|
+
if (!result.ok) return ctx.fail(result.code, result.message);
|
|
2693
|
+
const type = name === "commands" ? "s2c.session.commands" : name === "permissions" ? "s2c.session.permissions" : name === "command" ? "s2c.session.commandResult" : "s2c.ack";
|
|
2694
|
+
ctx.send(type, {
|
|
2695
|
+
sessionId: payload.sessionId,
|
|
2696
|
+
...result.value
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
})), {
|
|
2700
|
+
type: "c2s.session.export",
|
|
2701
|
+
stage: "authenticated",
|
|
2702
|
+
scopes: ["sessions.read"],
|
|
2703
|
+
capability: "commands",
|
|
2704
|
+
doc: "PROTOCOL.md c2s.session.export",
|
|
2705
|
+
validate: sessionPayload,
|
|
2706
|
+
handle: async (ctx, payload) => {
|
|
2707
|
+
const signal = AbortSignal.any([ctx.signal ?? new AbortController().signal, AbortSignal.timeout(6e5)]);
|
|
2708
|
+
let reader;
|
|
2709
|
+
const cancel = () => {
|
|
2710
|
+
reader?.cancel().catch(() => {});
|
|
2711
|
+
};
|
|
2712
|
+
try {
|
|
2713
|
+
reader = (await ctx.bridge.exportSession(payload.sessionId, signal)).body.getReader();
|
|
2714
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
2715
|
+
let index = 0, bytes = 0;
|
|
2716
|
+
while (true) {
|
|
2717
|
+
signal.throwIfAborted();
|
|
2718
|
+
const next = await reader.read();
|
|
2719
|
+
if (next.done) break;
|
|
2720
|
+
for (let offset = 0; offset < next.value.length; offset += 49152) {
|
|
2721
|
+
const chunk = next.value.subarray(offset, offset + 49152);
|
|
2722
|
+
bytes += chunk.length;
|
|
2723
|
+
await ctx.waitForWritable?.();
|
|
2724
|
+
ctx.send("s2c.session.export.chunk", {
|
|
2725
|
+
index: index++,
|
|
2726
|
+
data: Buffer.from(chunk).toString("base64")
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
signal.throwIfAborted();
|
|
2731
|
+
ctx.send("s2c.ack", {
|
|
2732
|
+
sessionId: payload.sessionId,
|
|
2733
|
+
fileName: "dsh-session-log.zip",
|
|
2734
|
+
chunks: index,
|
|
2735
|
+
bytes
|
|
2736
|
+
});
|
|
2737
|
+
} catch (error) {
|
|
2738
|
+
const failure = hostFailureOf(error, "command/export-cancelled");
|
|
2739
|
+
const message = failure.code === "command/export-cancelled" ? "会话日志导出失败,请重试" : failure.message;
|
|
2740
|
+
const mapped = wireErrorOf("command", {
|
|
2741
|
+
...failure,
|
|
2742
|
+
message
|
|
2743
|
+
});
|
|
2744
|
+
ctx.fail(mapped.code, mapped.message);
|
|
2745
|
+
} finally {
|
|
2746
|
+
signal.removeEventListener("abort", cancel);
|
|
2747
|
+
await reader?.cancel().catch(() => {});
|
|
2748
|
+
reader?.releaseLock();
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
}];
|
|
2222
2752
|
/** 第一个缺失的 scope;消息与迁移前一致(`scope <name> required`)。 */
|
|
2223
2753
|
function scopeRejection(row, scopes) {
|
|
2224
2754
|
for (const scope of row.scopes) if (!scopes.has(scope)) return {
|
|
@@ -2297,6 +2827,7 @@ function accept(value) {
|
|
|
2297
2827
|
/** 全部入站行。装配是静态的:新增特性 module 必须在这里显式出现。 */
|
|
2298
2828
|
const WIRE_FRAME_ROWS = [
|
|
2299
2829
|
...sessionRows,
|
|
2830
|
+
...commandRows,
|
|
2300
2831
|
...scheduleRows,
|
|
2301
2832
|
...interactionRows,
|
|
2302
2833
|
...pushRows,
|
|
@@ -2329,6 +2860,27 @@ function registryRowFor(type) {
|
|
|
2329
2860
|
* (成功/证明无效/超时/关闭)都经 settleAuthentication 的 exactly-once
|
|
2330
2861
|
* 守卫释放一次。
|
|
2331
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
|
+
}
|
|
2332
2884
|
/** upgrade 正常是毫秒级;5 秒已极宽,只为「回调根本不执行」兜底。 */
|
|
2333
2885
|
const DEFAULT_ATTACH_TIMEOUT_MS = 5e3;
|
|
2334
2886
|
var ConnectionGate = class {
|
|
@@ -2389,6 +2941,7 @@ var ConnectionGate = class {
|
|
|
2389
2941
|
this.helloTimer = void 0;
|
|
2390
2942
|
if (this.authenticated) return;
|
|
2391
2943
|
this.settle(false, "timeout");
|
|
2944
|
+
this.reportAuthFailure("timeout", 4402);
|
|
2392
2945
|
this.host?.close(4402, "auth timeout");
|
|
2393
2946
|
}, AUTH_TIMEOUT_MS);
|
|
2394
2947
|
this.helloTimer.unref();
|
|
@@ -2478,18 +3031,21 @@ var ConnectionGate = class {
|
|
|
2478
3031
|
const p = env.payload ?? {};
|
|
2479
3032
|
if (!p.deviceId) {
|
|
2480
3033
|
host.fail(env.id, "E_PROTOCOL", "deviceId required");
|
|
3034
|
+
this.reportAuthFailure("device-id-missing", 4403);
|
|
2481
3035
|
host.close(4403, "deviceId required");
|
|
2482
3036
|
return;
|
|
2483
3037
|
}
|
|
2484
3038
|
const deviceId = sanitizeDeviceField(p.deviceId, 128);
|
|
2485
3039
|
if (!deviceId) {
|
|
2486
3040
|
host.fail(env.id, "E_PROTOCOL", "deviceId required");
|
|
3041
|
+
this.reportAuthFailure("device-id-missing", 4403);
|
|
2487
3042
|
host.close(4403, "deviceId required");
|
|
2488
3043
|
return;
|
|
2489
3044
|
}
|
|
2490
3045
|
const deviceName = sanitizeDeviceField(p.deviceName, 64) || "unknown";
|
|
2491
3046
|
const appVersion = sanitizeDeviceField(p.appVersion, 32) || "unknown";
|
|
2492
|
-
const
|
|
3047
|
+
const deviceState = this.options.devices.statusOf(deviceId);
|
|
3048
|
+
const record = deviceState === "active" ? this.options.devices.authorized(deviceId) : void 0;
|
|
2493
3049
|
const resumeCursor = typeof p.resumeCursor === "number" && Number.isInteger(p.resumeCursor) && p.resumeCursor >= 0 ? p.resumeCursor : void 0;
|
|
2494
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;
|
|
2495
3051
|
if (!(record?.publicKey !== void 0 && typeof p.signature === "string" && challengeMatches && verifyAuthProof(record.publicKey, {
|
|
@@ -2499,7 +3055,14 @@ var ConnectionGate = class {
|
|
|
2499
3055
|
resumeCursor,
|
|
2500
3056
|
...this.challenge
|
|
2501
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
|
+
});
|
|
2502
3064
|
host.fail(env.id, "E_AUTH", "device proof missing or invalid");
|
|
3065
|
+
this.reportAuthFailure(reason, 4401);
|
|
2503
3066
|
this.settle(false, "invalid-proof");
|
|
2504
3067
|
host.close(4401, "invalid device proof");
|
|
2505
3068
|
return;
|
|
@@ -2521,6 +3084,19 @@ var ConnectionGate = class {
|
|
|
2521
3084
|
appVersion
|
|
2522
3085
|
});
|
|
2523
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
|
+
}
|
|
2524
3100
|
/** 限流记账与名额释放只此一处;exactly-once 由 settled 守卫保证。 */
|
|
2525
3101
|
settle(ok, reason) {
|
|
2526
3102
|
if (this.settled) return;
|
|
@@ -2545,6 +3121,8 @@ var ConnectionGate = class {
|
|
|
2545
3121
|
const host = this.host;
|
|
2546
3122
|
return {
|
|
2547
3123
|
frame: env,
|
|
3124
|
+
signal: host.signal,
|
|
3125
|
+
waitForWritable: host.waitForWritable?.bind(host),
|
|
2548
3126
|
deviceId: this.deviceId,
|
|
2549
3127
|
widgetClient: this.widgetClient,
|
|
2550
3128
|
bridge: host.bridge,
|
|
@@ -2604,6 +3182,11 @@ var BridgeConnection = class {
|
|
|
2604
3182
|
liveActivityRegistrationGeneration = 0;
|
|
2605
3183
|
pendingLiveActivityId;
|
|
2606
3184
|
lastActivity = Date.now();
|
|
3185
|
+
streamAbort = new AbortController();
|
|
3186
|
+
/** 建连时刻:关闭事件用它算存活时长。 */
|
|
3187
|
+
openedAt = Date.now();
|
|
3188
|
+
/** 来源的审计哈希;没有 auditLabel 时留空,绝不记录原始 IP。 */
|
|
3189
|
+
sourceLabel;
|
|
2607
3190
|
/** True when no inbound frame arrived within maxIdleMs. */
|
|
2608
3191
|
isStale(now, maxIdleMs) {
|
|
2609
3192
|
return now - this.lastActivity > maxIdleMs;
|
|
@@ -2618,12 +3201,24 @@ var BridgeConnection = class {
|
|
|
2618
3201
|
audience: deps.audience,
|
|
2619
3202
|
limiter: deps.rateLimiter ?? new AuthRateLimiter(),
|
|
2620
3203
|
log: (message) => deps.log(message),
|
|
2621
|
-
...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 } : {}
|
|
2622
3207
|
})) {
|
|
2623
3208
|
this.ws = ws;
|
|
2624
3209
|
this.deps = deps;
|
|
2625
3210
|
const connection = this;
|
|
3211
|
+
const sourceLabel = deps.auditLabel?.(deps.source ?? "local");
|
|
3212
|
+
this.sourceLabel = sourceLabel;
|
|
2626
3213
|
const host = {
|
|
3214
|
+
signal: this.streamAbort.signal,
|
|
3215
|
+
waitForWritable: async () => {
|
|
3216
|
+
while (this.ws.bufferedAmount > 262144) {
|
|
3217
|
+
this.streamAbort.signal.throwIfAborted();
|
|
3218
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
3219
|
+
}
|
|
3220
|
+
this.streamAbort.signal.throwIfAborted();
|
|
3221
|
+
},
|
|
2627
3222
|
get bridge() {
|
|
2628
3223
|
return connection.deps.bridge;
|
|
2629
3224
|
},
|
|
@@ -2676,12 +3271,23 @@ var BridgeConnection = class {
|
|
|
2676
3271
|
this.terminate();
|
|
2677
3272
|
});
|
|
2678
3273
|
});
|
|
2679
|
-
ws.on("close", () => {
|
|
2680
|
-
this.onClose();
|
|
3274
|
+
ws.on("close", (code) => {
|
|
3275
|
+
this.onClose(code);
|
|
2681
3276
|
deps.onClosed?.(this);
|
|
2682
3277
|
});
|
|
2683
|
-
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
|
+
});
|
|
2684
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
|
+
});
|
|
2685
3291
|
}
|
|
2686
3292
|
/** Hard-drop the socket (server-side stale sweep). */
|
|
2687
3293
|
terminate() {
|
|
@@ -2766,9 +3372,18 @@ var BridgeConnection = class {
|
|
|
2766
3372
|
else this.resync();
|
|
2767
3373
|
}
|
|
2768
3374
|
}
|
|
2769
|
-
onClose() {
|
|
3375
|
+
onClose(code) {
|
|
2770
3376
|
if (this.closed) return;
|
|
2771
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
|
+
});
|
|
3386
|
+
this.streamAbort.abort();
|
|
2772
3387
|
this.gate.onClose();
|
|
2773
3388
|
for (const id of this.openSessions) this.deps.bridge.markSinkClosed(this, id);
|
|
2774
3389
|
this.openSessions.clear();
|
|
@@ -3422,7 +4037,7 @@ function apnsPayload(notification) {
|
|
|
3422
4037
|
return alertPayload(notification);
|
|
3423
4038
|
}
|
|
3424
4039
|
function alertPayload(notification) {
|
|
3425
|
-
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";
|
|
3426
4041
|
return {
|
|
3427
4042
|
aps: {
|
|
3428
4043
|
alert: {
|
|
@@ -3431,13 +4046,18 @@ function alertPayload(notification) {
|
|
|
3431
4046
|
},
|
|
3432
4047
|
sound: "default",
|
|
3433
4048
|
category: notification.category,
|
|
4049
|
+
"mutable-content": 1,
|
|
3434
4050
|
"thread-id": notification.hostAudience ? createHash("sha256").update(`${notification.hostAudience}:${notification.sessionId}`).digest("hex") : notification.sessionId.slice(0, 64),
|
|
3435
4051
|
...timeSensitive ? { "interruption-level": "time-sensitive" } : {}
|
|
3436
4052
|
},
|
|
3437
4053
|
...notification.hostAudience ? { hostAudience: notification.hostAudience } : {},
|
|
3438
4054
|
sessionId: notification.sessionId,
|
|
3439
4055
|
notificationId: notification.notificationId,
|
|
3440
|
-
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
|
+
} : {}
|
|
3441
4061
|
};
|
|
3442
4062
|
}
|
|
3443
4063
|
function pushHeaders(bundleId, notification) {
|
|
@@ -3758,7 +4378,7 @@ function liveActivityState(session) {
|
|
|
3758
4378
|
task: Array.from(session?.activity?.trim() || task?.content || "").slice(0, 160).join(""),
|
|
3759
4379
|
done,
|
|
3760
4380
|
total,
|
|
3761
|
-
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"
|
|
3762
4382
|
};
|
|
3763
4383
|
}
|
|
3764
4384
|
/** Persist terminal state before coalescing so a rapid next round cannot revive an activity. */
|
|
@@ -3779,7 +4399,7 @@ var LiveActivityPushManager = class {
|
|
|
3779
4399
|
const r = device.liveActivity;
|
|
3780
4400
|
if (!r) continue;
|
|
3781
4401
|
const state = liveActivityState(this.latest.get(r.sessionId));
|
|
3782
|
-
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);
|
|
3783
4403
|
}
|
|
3784
4404
|
this.scheduler.changed();
|
|
3785
4405
|
}
|
|
@@ -3843,6 +4463,8 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
|
|
|
3843
4463
|
/** Apply before either transport receives the payload, including the Relay. */
|
|
3844
4464
|
function pushContent(notification, mode) {
|
|
3845
4465
|
if (mode !== "generic") return notification;
|
|
4466
|
+
const { toolName: _toolName, detail: _detail, ...rest } = notification;
|
|
4467
|
+
notification = rest;
|
|
3846
4468
|
const bodies = {
|
|
3847
4469
|
"turn.completed": "A task has completed.",
|
|
3848
4470
|
"approval.required": "An approval needs your attention.",
|
|
@@ -4484,7 +5106,7 @@ var PushGateway = class {
|
|
|
4484
5106
|
* it the listener is plain HTTP, which is reserved for the loopback Funnel
|
|
4485
5107
|
* origin where tailscaled terminates TLS.
|
|
4486
5108
|
*/
|
|
4487
|
-
function createPhoneServer(handlers, tls) {
|
|
5109
|
+
function createPhoneServer(handlers, tls, options) {
|
|
4488
5110
|
const onRequest = (req, res) => {
|
|
4489
5111
|
const path = requestPath(req);
|
|
4490
5112
|
if (path === "/phone/health") handlers.health(req, res);
|
|
@@ -4506,6 +5128,25 @@ function createPhoneServer(handlers, tls) {
|
|
|
4506
5128
|
}
|
|
4507
5129
|
handlers.upgrade(req, socket, head);
|
|
4508
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
|
+
}
|
|
4509
5150
|
return server;
|
|
4510
5151
|
}
|
|
4511
5152
|
function requestPath(req) {
|
|
@@ -4606,7 +5247,7 @@ var TransportReconciler = class {
|
|
|
4606
5247
|
this.instance = void 0;
|
|
4607
5248
|
if (partial !== void 0) await this.spec.stop(partial).catch(() => {});
|
|
4608
5249
|
this.currentStatus = this.spec.errorStatus(error, target);
|
|
4609
|
-
this.log(this.spec.failureMessage?.(error) ?? "transport failed: " + String(error));
|
|
5250
|
+
this.log(this.spec.failureMessage?.(error, target) ?? "transport failed: " + String(error));
|
|
4610
5251
|
}
|
|
4611
5252
|
}
|
|
4612
5253
|
/** 进程收尾:标记销毁、拆掉实例、等在途的那一次完成。 */
|
|
@@ -4647,7 +5288,7 @@ function createLocalTransport(deps) {
|
|
|
4647
5288
|
const server = createPhoneServer(deps.handlers, {
|
|
4648
5289
|
key: tls.key,
|
|
4649
5290
|
cert: tls.cert
|
|
4650
|
-
});
|
|
5291
|
+
}, deps.onTransportError !== void 0 ? { onTransportError: deps.onTransportError } : void 0);
|
|
4651
5292
|
instance.server = server;
|
|
4652
5293
|
instance.tlsFingerprint = tls.fingerprint;
|
|
4653
5294
|
await listen(server, port, "0.0.0.0");
|
|
@@ -4685,7 +5326,7 @@ function createLocalTransport(deps) {
|
|
|
4685
5326
|
updatedAt: Date.now()
|
|
4686
5327
|
}),
|
|
4687
5328
|
disabledMessage: "local transport disabled",
|
|
4688
|
-
failureMessage: (error) => "local transport failed: " + localListenError(error,
|
|
5329
|
+
failureMessage: (error, target) => "local transport failed: " + localListenError(error, normalizeLocalPort(target.port)),
|
|
4689
5330
|
statusOf: (instance) => {
|
|
4690
5331
|
if (instance === void 0 || instance.server === void 0) return void 0;
|
|
4691
5332
|
return {
|
|
@@ -5225,6 +5866,8 @@ function unwrapStreamItem(item) {
|
|
|
5225
5866
|
/** 每位一行:Host 上必须存在的依赖。缺失即该位为 false,相关帧回 E_UNSUPPORTED。 */
|
|
5226
5867
|
const HOST_CAPABILITY_PROBES = {
|
|
5227
5868
|
models: (proxy) => typeof proxy.sessions.models === "function",
|
|
5869
|
+
skills: (proxy) => typeof proxy.sessions.skills === "function",
|
|
5870
|
+
commands: (proxy) => typeof proxy.commands?.list === "function" && typeof proxy.commands?.execute === "function",
|
|
5228
5871
|
sessionManagement: (proxy) => typeof proxy.sessions.rename === "function" && typeof proxy.workspace?.archiveSession === "function",
|
|
5229
5872
|
sessionFork: (proxy) => typeof proxy.sessions.fork === "function",
|
|
5230
5873
|
sessionRestore: (proxy) => typeof proxy.workspace?.unarchiveSession === "function",
|
|
@@ -5245,6 +5888,8 @@ function capabilityBits(proxy, pushOutlet) {
|
|
|
5245
5888
|
promptDelivery: true,
|
|
5246
5889
|
notifyAllCategories: true,
|
|
5247
5890
|
models: HOST_CAPABILITY_PROBES.models(proxy),
|
|
5891
|
+
skills: HOST_CAPABILITY_PROBES.skills(proxy),
|
|
5892
|
+
commands: HOST_CAPABILITY_PROBES.commands(proxy),
|
|
5248
5893
|
sessionManagement: HOST_CAPABILITY_PROBES.sessionManagement(proxy),
|
|
5249
5894
|
sessionFork: HOST_CAPABILITY_PROBES.sessionFork(proxy),
|
|
5250
5895
|
sessionRestore: HOST_CAPABILITY_PROBES.sessionRestore(proxy),
|
|
@@ -5333,7 +5978,7 @@ var HostBridge = class {
|
|
|
5333
5978
|
return capabilityBits(this.apiProxy, this.pushOutlet);
|
|
5334
5979
|
}
|
|
5335
5980
|
diagnostic(message) {
|
|
5336
|
-
console.log("
|
|
5981
|
+
console.log(formatLogLine("info", message));
|
|
5337
5982
|
}
|
|
5338
5983
|
currentCursor() {
|
|
5339
5984
|
return this.cursor;
|
|
@@ -5377,12 +6022,17 @@ var HostBridge = class {
|
|
|
5377
6022
|
emitNotify(args) {
|
|
5378
6023
|
if ((args.category === "turn.completed" || args.category === "session.error") && this.subagentSessionIds.has(args.sessionId)) return;
|
|
5379
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
|
+
} : {};
|
|
5380
6029
|
this.record("s2c.notify", {
|
|
5381
6030
|
notificationId: args.notificationId,
|
|
5382
6031
|
category: args.category,
|
|
5383
6032
|
sessionId: args.sessionId,
|
|
5384
6033
|
title: args.title,
|
|
5385
6034
|
body,
|
|
6035
|
+
...approvalExtras,
|
|
5386
6036
|
ts: Date.now()
|
|
5387
6037
|
}, (sink) => this.isViewedBy(sink, args.sessionId));
|
|
5388
6038
|
this.fanOutPush({
|
|
@@ -5390,7 +6040,8 @@ var HostBridge = class {
|
|
|
5390
6040
|
category: args.category,
|
|
5391
6041
|
sessionId: args.sessionId,
|
|
5392
6042
|
title: args.title,
|
|
5393
|
-
body
|
|
6043
|
+
body,
|
|
6044
|
+
...approvalExtras
|
|
5394
6045
|
});
|
|
5395
6046
|
}
|
|
5396
6047
|
/** F-9: when a turn completes, notify every device not viewing the session. */
|
|
@@ -5550,12 +6201,19 @@ var HostBridge = class {
|
|
|
5550
6201
|
}
|
|
5551
6202
|
onMuxFrame(frame) {
|
|
5552
6203
|
switch (frame.type) {
|
|
6204
|
+
case "skills/changed":
|
|
6205
|
+
this.record("s2c.skills.changed", {});
|
|
6206
|
+
break;
|
|
6207
|
+
case "commands/changed":
|
|
6208
|
+
this.record("s2c.commands.changed", {});
|
|
6209
|
+
break;
|
|
5553
6210
|
case "session/event": {
|
|
5554
6211
|
const event = frame.event;
|
|
5555
6212
|
const sessionId = String(frame.sessionId ?? "");
|
|
5556
6213
|
if (!event || !sessionId) break;
|
|
5557
6214
|
if (frame.isSubagent === true) this.subagentSessionIds.add(sessionId);
|
|
5558
6215
|
if (this.subagentSessionIds.has(sessionId)) break;
|
|
6216
|
+
if (event.type === "agent-preset/selected") this.record("s2c.skills.changed", { sessionId });
|
|
5559
6217
|
this.noteActivity(sessionId, event);
|
|
5560
6218
|
this.captureAssistantText(sessionId, event);
|
|
5561
6219
|
const projection = projectEvent(sessionId, event);
|
|
@@ -5572,6 +6230,19 @@ var HostBridge = class {
|
|
|
5572
6230
|
case "session/projection": {
|
|
5573
6231
|
const p = frame;
|
|
5574
6232
|
if (!p.sessionId) break;
|
|
6233
|
+
if (p.key === "workingDirectory" || p.key === "agentPreset") this.record("s2c.skills.changed", { sessionId: p.sessionId });
|
|
6234
|
+
if (p.key === "modelSelection") {
|
|
6235
|
+
const next = p.value?.next;
|
|
6236
|
+
const current = next && typeof next.provider === "string" && typeof next.model === "string" ? {
|
|
6237
|
+
provider: next.provider,
|
|
6238
|
+
model: next.model,
|
|
6239
|
+
...typeof next.reasoningEffort === "string" ? { reasoningEffort: next.reasoningEffort } : {}
|
|
6240
|
+
} : void 0;
|
|
6241
|
+
this.record("s2c.models.changed", {
|
|
6242
|
+
sessionId: p.sessionId,
|
|
6243
|
+
...current ? { current } : {}
|
|
6244
|
+
});
|
|
6245
|
+
}
|
|
5575
6246
|
this.applyProjection(p.sessionId, String(p.key ?? ""), p.value);
|
|
5576
6247
|
break;
|
|
5577
6248
|
}
|
|
@@ -5600,7 +6271,9 @@ var HostBridge = class {
|
|
|
5600
6271
|
category: "approval.required",
|
|
5601
6272
|
title: "需要批准",
|
|
5602
6273
|
body: toolName + ": " + summary,
|
|
5603
|
-
notificationId: "apr-" + p.approvalId
|
|
6274
|
+
notificationId: "apr-" + p.approvalId,
|
|
6275
|
+
toolName,
|
|
6276
|
+
detail: summary
|
|
5604
6277
|
});
|
|
5605
6278
|
this.bumpPendingFlags(sessionId);
|
|
5606
6279
|
break;
|
|
@@ -5948,6 +6621,75 @@ var HostBridge = class {
|
|
|
5948
6621
|
return null;
|
|
5949
6622
|
}
|
|
5950
6623
|
}
|
|
6624
|
+
async commandOperation(operation, payload) {
|
|
6625
|
+
const api = this.apiProxy.commands;
|
|
6626
|
+
if (!api) return {
|
|
6627
|
+
ok: false,
|
|
6628
|
+
code: "E_UNSUPPORTED",
|
|
6629
|
+
message: "当前主机不支持手机指令"
|
|
6630
|
+
};
|
|
6631
|
+
try {
|
|
6632
|
+
const response = await api[operation].call(api, {
|
|
6633
|
+
rpcId: randomUUID(),
|
|
6634
|
+
payload
|
|
6635
|
+
});
|
|
6636
|
+
if (response.result?.ok) return {
|
|
6637
|
+
ok: true,
|
|
6638
|
+
value: response.result.value ?? {}
|
|
6639
|
+
};
|
|
6640
|
+
return wireErrorOf("command", response.result?.error ?? { code: "command/invalid-response" });
|
|
6641
|
+
} catch (error) {
|
|
6642
|
+
return wireErrorOf("command", hostFailureOf(error, "command/invalid-response"));
|
|
6643
|
+
}
|
|
6644
|
+
}
|
|
6645
|
+
async exportSession(sessionId, signal) {
|
|
6646
|
+
const api = this.apiProxy.commands;
|
|
6647
|
+
if (!api) throw Object.assign(/* @__PURE__ */ new Error("当前主机不支持日志导出"), { code: "command/export-unsupported" });
|
|
6648
|
+
return api.export({
|
|
6649
|
+
rpcId: randomUUID(),
|
|
6650
|
+
payload: { sessionId }
|
|
6651
|
+
}, signal);
|
|
6652
|
+
}
|
|
6653
|
+
/** 由会话目录决定技能可见性;不读取正文,也不把本机路径发给移动端。 */
|
|
6654
|
+
async sessionSkills(sessionId) {
|
|
6655
|
+
const read = this.apiProxy.sessions.skills;
|
|
6656
|
+
if (!read) return {
|
|
6657
|
+
ok: false,
|
|
6658
|
+
code: "E_UNSUPPORTED",
|
|
6659
|
+
message: "当前主机不支持技能目录"
|
|
6660
|
+
};
|
|
6661
|
+
try {
|
|
6662
|
+
const response = await read.call(this.apiProxy.sessions, {
|
|
6663
|
+
rpcId: randomUUID(),
|
|
6664
|
+
payload: { sessionId }
|
|
6665
|
+
});
|
|
6666
|
+
if (!response.result) return {
|
|
6667
|
+
ok: false,
|
|
6668
|
+
code: "E_INTERNAL",
|
|
6669
|
+
message: "技能目录响应无效"
|
|
6670
|
+
};
|
|
6671
|
+
if (!response.result.ok) return wireErrorOf("session", response.result.error);
|
|
6672
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6673
|
+
return {
|
|
6674
|
+
ok: true,
|
|
6675
|
+
value: { skills: response.result.value.skills.map((skill) => {
|
|
6676
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(skill.name) || seen.has(skill.name) || typeof skill.description !== "string" || typeof skill.modelInvocable !== "boolean") throw new Error("技能目录条目无效");
|
|
6677
|
+
seen.add(skill.name);
|
|
6678
|
+
return {
|
|
6679
|
+
name: skill.name,
|
|
6680
|
+
description: skill.description,
|
|
6681
|
+
modelInvocable: skill.modelInvocable
|
|
6682
|
+
};
|
|
6683
|
+
}) }
|
|
6684
|
+
};
|
|
6685
|
+
} catch {
|
|
6686
|
+
return {
|
|
6687
|
+
ok: false,
|
|
6688
|
+
code: "E_INTERNAL",
|
|
6689
|
+
message: "无法读取当前会话的技能目录"
|
|
6690
|
+
};
|
|
6691
|
+
}
|
|
6692
|
+
}
|
|
5951
6693
|
async sessionModels(sessionId) {
|
|
5952
6694
|
const models = this.apiProxy.sessions.models;
|
|
5953
6695
|
if (typeof models !== "function") return {
|
|
@@ -7083,6 +7825,145 @@ async function startDshRemoteInteractions(hostCtx, handlers) {
|
|
|
7083
7825
|
}
|
|
7084
7826
|
}
|
|
7085
7827
|
//#endregion
|
|
7828
|
+
//#region src/command-api.ts
|
|
7829
|
+
/**
|
|
7830
|
+
* 给本域自造的失败带上 Host 风格的码。
|
|
7831
|
+
*
|
|
7832
|
+
* 这些失败不是上游返回的,而是插件在这里判定出来的,所以必须自己声明语义:
|
|
7833
|
+
* 没有码的抛出物在 wire-errors 的域表里查不到,一律落进 `E_INTERNAL`,手机端
|
|
7834
|
+
* 便无法区分「指令不存在」「定义已变化」「这个指令不收附件」——它们对用户的
|
|
7835
|
+
* 下一步动作要求完全不同。码的取值见 `src/wire-errors.ts` 的 `command` 域表。
|
|
7836
|
+
*/
|
|
7837
|
+
function fail(code, message) {
|
|
7838
|
+
return Object.assign(new Error(message), { code });
|
|
7839
|
+
}
|
|
7840
|
+
/** 只经官方 Gateway 调用命令,保留其 Agent 解析、作用域覆盖和生命周期记录。 */
|
|
7841
|
+
var DshCommandApi = class {
|
|
7842
|
+
ctx;
|
|
7843
|
+
peer;
|
|
7844
|
+
constructor(ctx) {
|
|
7845
|
+
this.ctx = ctx;
|
|
7846
|
+
}
|
|
7847
|
+
get available() {
|
|
7848
|
+
return this.ctx.get("commands") !== void 0 && typeof this.ctx.get("typertGateway")?.invoke === "function";
|
|
7849
|
+
}
|
|
7850
|
+
connection() {
|
|
7851
|
+
return this.ctx.get("connection");
|
|
7852
|
+
}
|
|
7853
|
+
owner() {
|
|
7854
|
+
const connection = this.connection();
|
|
7855
|
+
return this.peer ??= connection?.createPeer?.({
|
|
7856
|
+
principal: { kind: "owner" },
|
|
7857
|
+
interactions: "none"
|
|
7858
|
+
}) ?? connection?.operator;
|
|
7859
|
+
}
|
|
7860
|
+
invoke(namespace, method, args) {
|
|
7861
|
+
return this.ctx.get("typertGateway").invoke({
|
|
7862
|
+
namespace,
|
|
7863
|
+
method,
|
|
7864
|
+
args,
|
|
7865
|
+
peer: this.owner(),
|
|
7866
|
+
signal: AbortSignal.timeout(6e5)
|
|
7867
|
+
});
|
|
7868
|
+
}
|
|
7869
|
+
async list(sessionId) {
|
|
7870
|
+
const rows = await this.invoke("commands", "list", { agentId: sessionId });
|
|
7871
|
+
const names = /* @__PURE__ */ new Set();
|
|
7872
|
+
return {
|
|
7873
|
+
commands: rows.map((row) => {
|
|
7874
|
+
if (!/^[a-z][a-z0-9_-]{0,127}$/.test(row.name) || names.has(row.name) || typeof row.description !== "string") throw fail("command/invalid-response", "命令目录无效");
|
|
7875
|
+
names.add(row.name);
|
|
7876
|
+
return {
|
|
7877
|
+
name: row.name,
|
|
7878
|
+
description: row.description,
|
|
7879
|
+
...typeof row.definitionId === "string" ? { definitionId: row.definitionId } : {},
|
|
7880
|
+
...row.input && typeof row.input.hint === "string" ? { input: {
|
|
7881
|
+
hint: row.input.hint,
|
|
7882
|
+
attachments: row.input.attachments === true
|
|
7883
|
+
} } : {}
|
|
7884
|
+
};
|
|
7885
|
+
}),
|
|
7886
|
+
permissions: this.ctx.get("permissionPresets") !== void 0,
|
|
7887
|
+
export: typeof this.connection()?.createSharedFetchHandler === "function"
|
|
7888
|
+
};
|
|
7889
|
+
}
|
|
7890
|
+
async execute(sessionId, line, attachments, expectedDefinitionId) {
|
|
7891
|
+
const catalog = await this.list(sessionId);
|
|
7892
|
+
const name = /^\/([a-z][a-z0-9_-]*)(?=\s|$)/.exec(line)?.[1];
|
|
7893
|
+
const command = catalog.commands.find((row) => row.name === name);
|
|
7894
|
+
if (!command) throw fail("command/not-found", "当前会话没有这个指令");
|
|
7895
|
+
if (expectedDefinitionId !== void 0 && command.definitionId !== expectedDefinitionId) throw fail("command/definition-changed", "指令定义已变化,请重新选择");
|
|
7896
|
+
if (attachments.length && command.input?.attachments !== true) throw fail("command/attachments-unsupported", "这个指令不接受附件");
|
|
7897
|
+
const submittedAttachments = [];
|
|
7898
|
+
for (const item of attachments) if (item.type === "image") submittedAttachments.push({
|
|
7899
|
+
type: "image",
|
|
7900
|
+
mediaType: item.mediaType,
|
|
7901
|
+
data: item.data
|
|
7902
|
+
});
|
|
7903
|
+
else {
|
|
7904
|
+
const uploaded = await this.invoke("fileUploads", "upload", {
|
|
7905
|
+
agentId: sessionId,
|
|
7906
|
+
request: {
|
|
7907
|
+
name: item.name + ".txt",
|
|
7908
|
+
data: item.data
|
|
7909
|
+
}
|
|
7910
|
+
});
|
|
7911
|
+
submittedAttachments.push({
|
|
7912
|
+
type: "file",
|
|
7913
|
+
receiptId: uploaded.receiptId
|
|
7914
|
+
});
|
|
7915
|
+
}
|
|
7916
|
+
const result = await this.invoke("commands", "execute", {
|
|
7917
|
+
agentId: sessionId,
|
|
7918
|
+
line,
|
|
7919
|
+
submittedAttachments
|
|
7920
|
+
});
|
|
7921
|
+
if (!result || typeof result.commandId !== "string" || !["success", "error"].includes(result.result?.kind)) throw fail("command/invalid-response", "指令执行响应无效");
|
|
7922
|
+
return {
|
|
7923
|
+
commandId: result.commandId,
|
|
7924
|
+
result: {
|
|
7925
|
+
kind: result.result.kind,
|
|
7926
|
+
...typeof result.result.text === "string" ? { text: result.result.text } : {},
|
|
7927
|
+
...Number.isSafeInteger(result.result.sourceEventSeq) && result.result.sourceEventSeq >= 0 ? { sourceEventSeq: result.result.sourceEventSeq } : {}
|
|
7928
|
+
}
|
|
7929
|
+
};
|
|
7930
|
+
}
|
|
7931
|
+
async permissions(sessionId) {
|
|
7932
|
+
const service = this.ctx.get("permissionPresets");
|
|
7933
|
+
if (!service) throw fail("command/permissions-unsupported", "当前主机不支持权限预设");
|
|
7934
|
+
const catalog = await this.invoke("permissionPresets", "catalog", {});
|
|
7935
|
+
const currentValue = (await this.ctx.get("sessionController").projections({ sessionId }, new AbortController().signal))?.values?.permissions?.currentValue;
|
|
7936
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7937
|
+
const options = catalog.options.map((option) => {
|
|
7938
|
+
if (typeof option.value !== "string" || !option.value.trim() || option.value.length > 512 || /[\r\n]/.test(option.value) || seen.has(option.value) || typeof option.name !== "string") throw fail("command/invalid-response", "权限目录无效");
|
|
7939
|
+
seen.add(option.value);
|
|
7940
|
+
return {
|
|
7941
|
+
value: option.value,
|
|
7942
|
+
name: option.name,
|
|
7943
|
+
...typeof option.description === "string" ? { description: option.description } : {},
|
|
7944
|
+
confirmation: option.value === "auto" || service.resolve(option.value).sandbox === "danger-full-access"
|
|
7945
|
+
};
|
|
7946
|
+
});
|
|
7947
|
+
return {
|
|
7948
|
+
...typeof currentValue === "string" ? { currentValue } : {},
|
|
7949
|
+
options
|
|
7950
|
+
};
|
|
7951
|
+
}
|
|
7952
|
+
async feedback(sessionId, text, category) {
|
|
7953
|
+
const result = await this.invoke("sessionFeedback", "record", { request: {
|
|
7954
|
+
sessionId,
|
|
7955
|
+
...text.trim() ? { text: text.trim() } : {},
|
|
7956
|
+
...category ? { category } : {}
|
|
7957
|
+
} });
|
|
7958
|
+
if (result?.ok !== true) throw fail(typeof result?.error?.code === "string" ? result.error.code : "command/invalid-response", typeof result?.error?.message === "string" ? result.error.message : "反馈未能保存");
|
|
7959
|
+
}
|
|
7960
|
+
async export(sessionId, signal) {
|
|
7961
|
+
const response = await this.connection().createSharedFetchHandler("/api").fetch(new Request("http://localhost/api/session.export?includeDescendants=true&sessionId=" + encodeURIComponent(sessionId), { signal }), this.owner());
|
|
7962
|
+
if (!response.ok || !response.body || !response.headers.get("content-type")?.includes("application/zip")) throw fail("command/export-unavailable", "主机未能生成会话日志归档");
|
|
7963
|
+
return response;
|
|
7964
|
+
}
|
|
7965
|
+
};
|
|
7966
|
+
//#endregion
|
|
7086
7967
|
//#region src/dsh-api-proxy.ts
|
|
7087
7968
|
/**
|
|
7088
7969
|
* Adapter for the DSH rc.2 controller API.
|
|
@@ -7095,6 +7976,17 @@ async function startDshRemoteInteractions(hostCtx, handlers) {
|
|
|
7095
7976
|
/** Direct-controller adapter with the shape HostBridge consumes. */
|
|
7096
7977
|
var DshApiProxy = class {
|
|
7097
7978
|
ctx;
|
|
7979
|
+
commandApi;
|
|
7980
|
+
get commands() {
|
|
7981
|
+
if (!this.commandApi.available) return void 0;
|
|
7982
|
+
return {
|
|
7983
|
+
list: (request) => this.call(() => this.commandApi.list(request.payload.sessionId)),
|
|
7984
|
+
execute: (request) => this.call(() => this.commandApi.execute(request.payload.sessionId, request.payload.line, request.payload.attachments, request.payload.expectedDefinitionId)),
|
|
7985
|
+
permissions: (request) => this.call(() => this.commandApi.permissions(request.payload.sessionId)),
|
|
7986
|
+
feedback: (request) => this.call(() => this.commandApi.feedback(request.payload.sessionId, request.payload.text, request.payload.category)),
|
|
7987
|
+
export: (request, signal) => this.commandApi.export(request.payload.sessionId, signal)
|
|
7988
|
+
};
|
|
7989
|
+
}
|
|
7098
7990
|
session;
|
|
7099
7991
|
workspaceController;
|
|
7100
7992
|
scheduleController;
|
|
@@ -7104,6 +7996,7 @@ var DshApiProxy = class {
|
|
|
7104
7996
|
shouldSurfaceInteraction;
|
|
7105
7997
|
constructor(ctx, options = {}) {
|
|
7106
7998
|
this.ctx = ctx;
|
|
7999
|
+
this.commandApi = new DshCommandApi(ctx);
|
|
7107
8000
|
const session = ctx.get("sessionController");
|
|
7108
8001
|
if (session === void 0) throw new Error("DSH sessionController is unavailable");
|
|
7109
8002
|
this.session = session;
|
|
@@ -7112,6 +8005,12 @@ var DshApiProxy = class {
|
|
|
7112
8005
|
this.directoryPicker = ctx.get("directoryPickerController");
|
|
7113
8006
|
this.shouldSurfaceInteraction = options.shouldSurfaceInteraction ?? (() => true);
|
|
7114
8007
|
this.resolveSchedule();
|
|
8008
|
+
Object.defineProperty(this.sessions, "skills", { get: () => {
|
|
8009
|
+
const catalog = ctx.get("sessionSkillCatalog");
|
|
8010
|
+
if (typeof catalog?.list !== "function") return void 0;
|
|
8011
|
+
const read = async (request) => this.call(() => catalog.list({ sessionId: request.payload.sessionId }, new AbortController().signal));
|
|
8012
|
+
return read;
|
|
8013
|
+
} });
|
|
7115
8014
|
}
|
|
7116
8015
|
/**
|
|
7117
8016
|
* Resolve the optional Schedule service lazily, on every read.
|
|
@@ -7307,6 +8206,10 @@ var DshApiProxy = class {
|
|
|
7307
8206
|
value
|
|
7308
8207
|
});
|
|
7309
8208
|
});
|
|
8209
|
+
const offSkills = this.ctx.on("skills/change", (() => {
|
|
8210
|
+
queue.push({ type: "skills/changed" });
|
|
8211
|
+
}), { global: true });
|
|
8212
|
+
const offCommands = ["commands/change", "permission-presets/catalog-changed"].map((event) => this.ctx.on(event, (() => queue.push({ type: "commands/changed" })), { global: true }));
|
|
7310
8213
|
let disposeInteractions;
|
|
7311
8214
|
try {
|
|
7312
8215
|
disposeInteractions = await startDshRemoteInteractions(this.ctx, {
|
|
@@ -7321,6 +8224,8 @@ var DshApiProxy = class {
|
|
|
7321
8224
|
} finally {
|
|
7322
8225
|
offEvent();
|
|
7323
8226
|
offProjection?.();
|
|
8227
|
+
offSkills();
|
|
8228
|
+
offCommands.forEach((dispose) => dispose());
|
|
7324
8229
|
await disposeInteractions?.();
|
|
7325
8230
|
queue.close();
|
|
7326
8231
|
}
|
|
@@ -7823,6 +8728,40 @@ function parsePushTestResult(value) {
|
|
|
7823
8728
|
results
|
|
7824
8729
|
};
|
|
7825
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
|
+
}
|
|
7826
8765
|
function parseReport(value) {
|
|
7827
8766
|
const s = rec(value, "report");
|
|
7828
8767
|
const devices = s.devices;
|
|
@@ -7844,7 +8783,8 @@ function parseReport(value) {
|
|
|
7844
8783
|
lanAddresses,
|
|
7845
8784
|
local: parseLocal(s.local),
|
|
7846
8785
|
remote: parseRemote(s.remote),
|
|
7847
|
-
devices: devices.map(parseDevice)
|
|
8786
|
+
devices: devices.map(parseDevice),
|
|
8787
|
+
...s.diagnostics !== void 0 ? { diagnostics: parseDiagnostics(s.diagnostics) } : {}
|
|
7848
8788
|
};
|
|
7849
8789
|
}
|
|
7850
8790
|
const reportSchema = { parse: parseReport };
|
|
@@ -8313,13 +9253,17 @@ async function loadOrCreateLanTlsIdentity(dir, now = Date.now()) {
|
|
|
8313
9253
|
});
|
|
8314
9254
|
const keyPath = join(dir, LAN_TLS_KEY_FILE);
|
|
8315
9255
|
const certPath = join(dir, LAN_TLS_CERT_FILE);
|
|
8316
|
-
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
|
+
});
|
|
8317
9259
|
let regenerated = false;
|
|
8318
9260
|
if (key === void 0 || !validPrivateKey(key)) {
|
|
8319
9261
|
regenerated = true;
|
|
8320
9262
|
key = void 0;
|
|
8321
9263
|
}
|
|
8322
|
-
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
|
+
});
|
|
8323
9267
|
let resigned = false;
|
|
8324
9268
|
const existing = key !== void 0 && cert !== void 0 ? inspectCertificate(cert, key, now) : void 0;
|
|
8325
9269
|
if (existing === void 0) {
|
|
@@ -8341,15 +9285,22 @@ async function loadOrCreateLanTlsIdentity(dir, now = Date.now()) {
|
|
|
8341
9285
|
resigned
|
|
8342
9286
|
};
|
|
8343
9287
|
}
|
|
8344
|
-
async function readPem(path) {
|
|
9288
|
+
async function readPem(path, onError) {
|
|
8345
9289
|
try {
|
|
8346
9290
|
const text = await readFile(path, "utf8");
|
|
8347
9291
|
return text.trim().length > 0 ? text : void 0;
|
|
8348
9292
|
} catch (error) {
|
|
8349
9293
|
if (error.code === "ENOENT") return void 0;
|
|
9294
|
+
onError?.(error);
|
|
8350
9295
|
return;
|
|
8351
9296
|
}
|
|
8352
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
|
+
}
|
|
8353
9304
|
function validPrivateKey(pem) {
|
|
8354
9305
|
try {
|
|
8355
9306
|
return createPrivateKey(pem).asymmetricKeyType === "ec";
|
|
@@ -8492,9 +9443,7 @@ function readOwnPackageVersion() {
|
|
|
8492
9443
|
}
|
|
8493
9444
|
function apply(ctx, options) {
|
|
8494
9445
|
const cfg = normalizeOptions(options);
|
|
8495
|
-
const log = (
|
|
8496
|
-
console.log("[deeppilot] " + message);
|
|
8497
|
-
};
|
|
9446
|
+
const log = createLogger();
|
|
8498
9447
|
const auditSalt = randomBytes(32);
|
|
8499
9448
|
const auditLabel = (value) => createHash("sha256").update(auditSalt).update(value).digest("hex").slice(0, 12);
|
|
8500
9449
|
let scheduleRemoteReconcile;
|
|
@@ -8507,6 +9456,30 @@ function apply(ctx, options) {
|
|
|
8507
9456
|
}));
|
|
8508
9457
|
if (currentConfig().enabled !== true) log("disabled via settings; bridge stays inactive (rumors of /phone below are skipped)");
|
|
8509
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
|
+
};
|
|
8510
9483
|
const pushGateway = new PushGateway({
|
|
8511
9484
|
config: currentConfig,
|
|
8512
9485
|
dataDir,
|
|
@@ -8527,7 +9500,7 @@ function apply(ctx, options) {
|
|
|
8527
9500
|
const migratedFrom = await migrateLegacyBridgeDataDir();
|
|
8528
9501
|
if (migratedFrom !== null) log(`migrated legacy plugin state from ${migratedFrom} to ${dataDir}`);
|
|
8529
9502
|
} catch (error) {
|
|
8530
|
-
log("legacy plugin-state migration skipped: " + String(error));
|
|
9503
|
+
log("legacy plugin-state migration skipped: " + String(error), "warn");
|
|
8531
9504
|
}
|
|
8532
9505
|
await ensurePrivateBridgeDataDir();
|
|
8533
9506
|
auth.audience = await loadOrCreateHostAudience(join(dataDir, "host-id"));
|
|
@@ -8539,7 +9512,7 @@ function apply(ctx, options) {
|
|
|
8539
9512
|
}
|
|
8540
9513
|
await pushGateway.restore();
|
|
8541
9514
|
} catch (error) {
|
|
8542
|
-
log("auth material unavailable, bridge degraded: " + String(error));
|
|
9515
|
+
log("auth material unavailable, bridge degraded: " + String(error), "warn");
|
|
8543
9516
|
return {
|
|
8544
9517
|
audience: null,
|
|
8545
9518
|
devices: null
|
|
@@ -8666,7 +9639,19 @@ function apply(ctx, options) {
|
|
|
8666
9639
|
lanAddresses,
|
|
8667
9640
|
local: localStatus(lanAddresses),
|
|
8668
9641
|
remote: remoteStatus(),
|
|
8669
|
-
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
|
+
}
|
|
8670
9655
|
};
|
|
8671
9656
|
}, beginPairing, async (deviceId) => {
|
|
8672
9657
|
return await revokeDevice(deviceId);
|
|
@@ -8706,7 +9691,16 @@ function apply(ctx, options) {
|
|
|
8706
9691
|
};
|
|
8707
9692
|
const handlePair = async (req, res) => {
|
|
8708
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
|
+
};
|
|
8709
9702
|
if (!enabledNow()) {
|
|
9703
|
+
reportPairRejected("bridge-disabled");
|
|
8710
9704
|
res.statusCode = 503;
|
|
8711
9705
|
res.end(JSON.stringify({
|
|
8712
9706
|
ok: false,
|
|
@@ -8715,6 +9709,7 @@ function apply(ctx, options) {
|
|
|
8715
9709
|
return;
|
|
8716
9710
|
}
|
|
8717
9711
|
if (req.method !== "POST") {
|
|
9712
|
+
reportPairRejected("method-not-allowed");
|
|
8718
9713
|
res.statusCode = 405;
|
|
8719
9714
|
res.setHeader("Allow", "POST");
|
|
8720
9715
|
res.end(JSON.stringify({
|
|
@@ -8723,9 +9718,9 @@ function apply(ctx, options) {
|
|
|
8723
9718
|
}));
|
|
8724
9719
|
return;
|
|
8725
9720
|
}
|
|
8726
|
-
const source = requestClientIdentity(req);
|
|
8727
9721
|
const admission = authRateLimiter.admit(source);
|
|
8728
9722
|
if (!admission.ok) {
|
|
9723
|
+
reportPairRejected("rate-limited");
|
|
8729
9724
|
res.statusCode = 429;
|
|
8730
9725
|
res.setHeader("Retry-After", String(Math.max(1, Math.ceil(admission.retryAfterMs / 1e3))));
|
|
8731
9726
|
res.end(JSON.stringify({
|
|
@@ -8745,6 +9740,7 @@ function apply(ctx, options) {
|
|
|
8745
9740
|
const appVersion = sanitizeDeviceField(raw.appVersion, 32) || "unknown";
|
|
8746
9741
|
const deviceId = deviceIdForPublicKey(publicKey);
|
|
8747
9742
|
if (devices.list().length >= 64 && devices.authorized(deviceId) === void 0) {
|
|
9743
|
+
reportPairRejected("registry-full");
|
|
8748
9744
|
res.statusCode = 409;
|
|
8749
9745
|
res.end(JSON.stringify({
|
|
8750
9746
|
ok: false,
|
|
@@ -8754,6 +9750,7 @@ function apply(ctx, options) {
|
|
|
8754
9750
|
}
|
|
8755
9751
|
if (!pairingCodes.consume(code)) {
|
|
8756
9752
|
const failure = authRateLimiter.recordFailure(source);
|
|
9753
|
+
reportPairRejected("invalid-code");
|
|
8757
9754
|
res.statusCode = failure.blocked ? 429 : 401;
|
|
8758
9755
|
if (failure.retryAfterMs > 0) res.setHeader("Retry-After", String(Math.max(1, Math.ceil(failure.retryAfterMs / 1e3))));
|
|
8759
9756
|
res.end(JSON.stringify({
|
|
@@ -8781,7 +9778,9 @@ function apply(ctx, options) {
|
|
|
8781
9778
|
...tlsFingerprint ? { tlsFingerprint } : {}
|
|
8782
9779
|
}));
|
|
8783
9780
|
} catch (error) {
|
|
8784
|
-
|
|
9781
|
+
const badRequest = error instanceof SyntaxError || error instanceof TypeError;
|
|
9782
|
+
reportPairRejected(badRequest ? "bad-request" : "internal-error");
|
|
9783
|
+
res.statusCode = badRequest ? 400 : 503;
|
|
8785
9784
|
res.end(JSON.stringify({
|
|
8786
9785
|
ok: false,
|
|
8787
9786
|
error: error instanceof Error ? error.message : "pairing failed"
|
|
@@ -8791,14 +9790,24 @@ function apply(ctx, options) {
|
|
|
8791
9790
|
}
|
|
8792
9791
|
};
|
|
8793
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
|
+
};
|
|
8794
9803
|
(async () => {
|
|
8795
9804
|
try {
|
|
8796
9805
|
if (!enabledNow()) {
|
|
8797
|
-
|
|
9806
|
+
reject(503, "bridge disabled");
|
|
8798
9807
|
return;
|
|
8799
9808
|
}
|
|
8800
9809
|
if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
|
|
8801
|
-
|
|
9810
|
+
reject(429, "too many connections");
|
|
8802
9811
|
return;
|
|
8803
9812
|
}
|
|
8804
9813
|
pendingUpgrades += 1;
|
|
@@ -8806,30 +9815,36 @@ function apply(ctx, options) {
|
|
|
8806
9815
|
try {
|
|
8807
9816
|
const { devices, audience } = await ready;
|
|
8808
9817
|
if (!audience || !devices) {
|
|
8809
|
-
|
|
9818
|
+
reject(503, "bridge degraded");
|
|
8810
9819
|
return;
|
|
8811
9820
|
}
|
|
8812
9821
|
const bridge = state.bridge;
|
|
8813
9822
|
if (!bridge) {
|
|
8814
|
-
|
|
9823
|
+
reject(503, "bridge not ready");
|
|
8815
9824
|
return;
|
|
8816
9825
|
}
|
|
8817
9826
|
gate = new ConnectionGate({
|
|
8818
|
-
source
|
|
9827
|
+
source,
|
|
8819
9828
|
devices,
|
|
8820
9829
|
audience,
|
|
8821
9830
|
limiter: authRateLimiter,
|
|
8822
9831
|
log,
|
|
8823
|
-
auditLabel
|
|
9832
|
+
auditLabel,
|
|
9833
|
+
diagnostics,
|
|
9834
|
+
...attemptId !== void 0 ? { attemptId } : {}
|
|
8824
9835
|
});
|
|
8825
9836
|
if (!gate.admitted) {
|
|
8826
|
-
|
|
9837
|
+
reject(429, "authentication rate limited");
|
|
8827
9838
|
return;
|
|
8828
9839
|
}
|
|
8829
9840
|
const live = gate;
|
|
8830
9841
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
8831
9842
|
if (auth.audience !== audience || state.bridge !== bridge) {
|
|
8832
9843
|
live.markDead();
|
|
9844
|
+
diagnostics.record("upgrade-rejected", "bridge-changed", {
|
|
9845
|
+
source: sourceLabel,
|
|
9846
|
+
...attemptId !== void 0 ? { attemptId } : {}
|
|
9847
|
+
});
|
|
8833
9848
|
ws.close(1012, "bridge changed");
|
|
8834
9849
|
return;
|
|
8835
9850
|
}
|
|
@@ -8844,6 +9859,8 @@ function apply(ctx, options) {
|
|
|
8844
9859
|
source: live.source,
|
|
8845
9860
|
rateLimiter: authRateLimiter,
|
|
8846
9861
|
auditLabel,
|
|
9862
|
+
diagnostics,
|
|
9863
|
+
...attemptId !== void 0 ? { attemptId } : {},
|
|
8847
9864
|
onClosed: (closed) => connections.delete(closed),
|
|
8848
9865
|
onDeviceAuthenticated: (deviceId) => {
|
|
8849
9866
|
log(`device authenticated id=${auditLabel(deviceId)} source=${auditLabel(live.source)}`);
|
|
@@ -8863,7 +9880,12 @@ function apply(ctx, options) {
|
|
|
8863
9880
|
pendingUpgrades -= 1;
|
|
8864
9881
|
}
|
|
8865
9882
|
} catch (error) {
|
|
8866
|
-
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
|
+
});
|
|
8867
9889
|
rejectUpgrade(socket, 500, "internal error");
|
|
8868
9890
|
}
|
|
8869
9891
|
})();
|
|
@@ -8880,14 +9902,19 @@ function apply(ctx, options) {
|
|
|
8880
9902
|
}));
|
|
8881
9903
|
return;
|
|
8882
9904
|
}
|
|
9905
|
+
const local = localTransport.status();
|
|
8883
9906
|
res.statusCode = 200;
|
|
8884
|
-
res.end(JSON.stringify({
|
|
8885
|
-
|
|
9907
|
+
res.end(JSON.stringify(buildHealthPayload({
|
|
9908
|
+
startedAt,
|
|
8886
9909
|
enabled: enabledNow(),
|
|
8887
9910
|
protocolVersion: 2,
|
|
8888
9911
|
serverVersion: SERVER_VERSION,
|
|
8889
|
-
|
|
8890
|
-
|
|
9912
|
+
dataPlaneActive: Boolean(state.bridge),
|
|
9913
|
+
localPhase: local.phase,
|
|
9914
|
+
localPort: local.port,
|
|
9915
|
+
tlsIdentityRegenerated,
|
|
9916
|
+
lastFailure: diagnostics.lastFailureSummary()
|
|
9917
|
+
})));
|
|
8891
9918
|
} catch {
|
|
8892
9919
|
res.statusCode = 500;
|
|
8893
9920
|
res.end(JSON.stringify({ ok: false }));
|
|
@@ -8902,7 +9929,8 @@ function apply(ctx, options) {
|
|
|
8902
9929
|
handlers: phoneHandlers,
|
|
8903
9930
|
config: currentConfig,
|
|
8904
9931
|
tls: loadLanTls,
|
|
8905
|
-
log
|
|
9932
|
+
log,
|
|
9933
|
+
onTransportError: recordTransportError
|
|
8906
9934
|
});
|
|
8907
9935
|
const remoteTransport = createRemoteTransport({
|
|
8908
9936
|
handlers: phoneHandlers,
|
|
@@ -8920,7 +9948,7 @@ function apply(ctx, options) {
|
|
|
8920
9948
|
};
|
|
8921
9949
|
};
|
|
8922
9950
|
const remoteStatus = () => remoteTransport.status();
|
|
8923
|
-
const originServer = createPhoneServer(phoneHandlers);
|
|
9951
|
+
const originServer = createPhoneServer(phoneHandlers, void 0, { onTransportError: recordTransportError });
|
|
8924
9952
|
let originURL;
|
|
8925
9953
|
scheduleLocalReconcile = () => localTransport.scheduleReconcile();
|
|
8926
9954
|
scheduleRemoteReconcile = () => remoteTransport.scheduleReconcile();
|
|
@@ -8932,7 +9960,7 @@ function apply(ctx, options) {
|
|
|
8932
9960
|
originURL = `http://127.0.0.1:${address.port}`;
|
|
8933
9961
|
scheduleRemoteReconcile?.();
|
|
8934
9962
|
}
|
|
8935
|
-
}, (error) => log("remote origin failed: " + String(error)));
|
|
9963
|
+
}, (error) => log("remote origin failed: " + String(error), "warn"));
|
|
8936
9964
|
return async () => {
|
|
8937
9965
|
scheduleLocalReconcile = void 0;
|
|
8938
9966
|
scheduleRemoteReconcile = void 0;
|
|
@@ -8976,7 +10004,7 @@ function apply(ctx, options) {
|
|
|
8976
10004
|
try {
|
|
8977
10005
|
proxy = new DshApiProxy(apiCtx, { shouldSurfaceInteraction: hasPairedPhoneSurface });
|
|
8978
10006
|
} catch (error) {
|
|
8979
|
-
log("DSH session bridge unavailable: " + String(error));
|
|
10007
|
+
log("DSH session bridge unavailable: " + String(error), "warn");
|
|
8980
10008
|
return;
|
|
8981
10009
|
}
|
|
8982
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);
|
|
@@ -8999,6 +10027,7 @@ function apply(ctx, options) {
|
|
|
8999
10027
|
await pushGateway.dispose();
|
|
9000
10028
|
const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
|
|
9001
10029
|
await Promise.allSettled([wssClosed]);
|
|
10030
|
+
await diagnostics.dispose();
|
|
9002
10031
|
}, "deeppilot: process resources");
|
|
9003
10032
|
}
|
|
9004
10033
|
//#endregion
|