dsh-deeppilot 0.6.1 → 0.7.0
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 +21 -5
- package/PRIVACY.md +9 -1
- package/README.md +43 -31
- package/README.zh-CN.md +33 -24
- package/SECURITY.md +1 -1
- package/lib/client.js +42 -27
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +37 -4
- package/lib/index.js +589 -68
- package/lib/index.js.map +1 -1
- package/package.json +19 -16
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
|
|
2
|
+
import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
|
|
3
3
|
import { access, chmod, mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { WebSocketServer } from "ws";
|
|
@@ -11,10 +11,11 @@ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
|
11
11
|
import { connect } from "node:http2";
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
|
-
import { request } from "node:https";
|
|
14
|
+
import { createServer, request } from "node:https";
|
|
15
15
|
import z from "@deepseek-ai/schemastery";
|
|
16
16
|
import { isIP } from "node:net";
|
|
17
|
-
import { createServer } from "node:http";
|
|
17
|
+
import { createServer as createServer$1 } from "node:http";
|
|
18
|
+
import { generate } from "selfsigned";
|
|
18
19
|
function validSendId(id) {
|
|
19
20
|
return typeof id === "string" && /^\d{13}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
20
21
|
}
|
|
@@ -243,6 +244,10 @@ function validateRequest(type, value) {
|
|
|
243
244
|
ids.add(answer.id);
|
|
244
245
|
}
|
|
245
246
|
}
|
|
247
|
+
if (type === "c2s.liveActivity.register" || type === "c2s.liveActivity.unregister") {
|
|
248
|
+
if (!text(p.activityId, 128)) return "invalid activityId";
|
|
249
|
+
if (type === "c2s.liveActivity.register" && (!text(p.sessionId) || typeof p.deviceToken !== "string" || !/^[0-9a-fA-F]{32,512}$/.test(p.deviceToken) || !["development", "production"].includes(p.environment) || !optional("enrollKey", (v) => text(v, 128)))) return "invalid live activity registration";
|
|
250
|
+
}
|
|
246
251
|
if (type === "c2s.push.register" || type === "c2s.widget.push.register") {
|
|
247
252
|
if (p.environment !== void 0 && p.environment !== "production" && p.environment !== "development") return "invalid APNs environment";
|
|
248
253
|
if (!optional("enrollKey", (v) => text(v, 128))) return "invalid enrollKey";
|
|
@@ -462,7 +467,8 @@ var DeviceStore = class DeviceStore {
|
|
|
462
467
|
firstSeenTs: existing?.firstSeenTs ?? now,
|
|
463
468
|
lastSeenTs: now,
|
|
464
469
|
...existing?.apns ? { apns: existing.apns } : {},
|
|
465
|
-
...existing?.widgetApns ? { widgetApns: existing.widgetApns } : {}
|
|
470
|
+
...existing?.widgetApns ? { widgetApns: existing.widgetApns } : {},
|
|
471
|
+
...existing?.liveActivity ? { liveActivity: existing.liveActivity } : {}
|
|
466
472
|
};
|
|
467
473
|
this.devices.set(deviceId, next);
|
|
468
474
|
this.flush();
|
|
@@ -488,6 +494,7 @@ var DeviceStore = class DeviceStore {
|
|
|
488
494
|
record.revokedAt = now;
|
|
489
495
|
delete record.apns;
|
|
490
496
|
delete record.widgetApns;
|
|
497
|
+
delete record.liveActivity;
|
|
491
498
|
this.flush();
|
|
492
499
|
return true;
|
|
493
500
|
}
|
|
@@ -533,6 +540,29 @@ var DeviceStore = class DeviceStore {
|
|
|
533
540
|
delete record.apns;
|
|
534
541
|
this.flush();
|
|
535
542
|
}
|
|
543
|
+
setLiveActivity(deviceId, registration) {
|
|
544
|
+
const record = this.authorized(deviceId);
|
|
545
|
+
if (!record || !isValidApnsToken(registration.token)) return;
|
|
546
|
+
const old = record.liveActivity;
|
|
547
|
+
record.liveActivity = old?.activityId === registration.activityId ? {
|
|
548
|
+
...registration,
|
|
549
|
+
expiresAt: old.expiresAt,
|
|
550
|
+
endedState: old.endedState
|
|
551
|
+
} : registration;
|
|
552
|
+
this.flush();
|
|
553
|
+
}
|
|
554
|
+
endLiveActivity(deviceId, token, state) {
|
|
555
|
+
const registration = this.devices.get(deviceId)?.liveActivity;
|
|
556
|
+
if (!registration || registration.token !== token || registration.endedState) return;
|
|
557
|
+
registration.endedState = state;
|
|
558
|
+
this.flush();
|
|
559
|
+
}
|
|
560
|
+
clearLiveActivity(deviceId, activityId, token) {
|
|
561
|
+
const record = this.devices.get(deviceId);
|
|
562
|
+
if (record?.liveActivity?.activityId !== activityId || token !== void 0 && record.liveActivity.token !== token) return;
|
|
563
|
+
delete record.liveActivity;
|
|
564
|
+
this.flush();
|
|
565
|
+
}
|
|
536
566
|
setWidgetPushToken(deviceId, token, environment, now) {
|
|
537
567
|
const record = this.authorized(deviceId);
|
|
538
568
|
if (!record || !isValidApnsToken(token)) return;
|
|
@@ -613,8 +643,9 @@ function requiredScope(type) {
|
|
|
613
643
|
if (type === "c2s.session.sendPrompt" || type === "c2s.session.delivery") return "prompt.send";
|
|
614
644
|
if (type === "c2s.pending.list") return "interactions.respond";
|
|
615
645
|
if (type === "c2s.approval.respond" || type === "c2s.question.respond") return "interactions.respond";
|
|
646
|
+
if (type === "c2s.liveActivity.register" || type === "c2s.liveActivity.unregister") return "notifications.register";
|
|
616
647
|
if (type === "c2s.push.register" || type === "c2s.widget.push.register") return "notifications.register";
|
|
617
|
-
if (type === "c2s.workspace.create" || type === "c2s.session.create" || type === "c2s.session.rename" || type === "c2s.session.archive" || type === "c2s.session.cancel" || type === "c2s.session.selectModel") return "sessions.manage";
|
|
648
|
+
if (type === "c2s.workspace.create" || type === "c2s.session.create" || type === "c2s.session.rename" || type === "c2s.session.archive" || type === "c2s.session.unarchive" || type === "c2s.session.cancel" || type === "c2s.session.selectModel") return "sessions.manage";
|
|
618
649
|
if (type.startsWith("c2s.")) return "sessions.read";
|
|
619
650
|
}
|
|
620
651
|
/** Broadcast and replay authorization. Unknown frame types fail closed. */
|
|
@@ -693,6 +724,8 @@ var BridgeConnection = class {
|
|
|
693
724
|
deviceId;
|
|
694
725
|
scopes = /* @__PURE__ */ new Set();
|
|
695
726
|
widgetClient = false;
|
|
727
|
+
liveActivityRegistrationGeneration = 0;
|
|
728
|
+
pendingLiveActivityId;
|
|
696
729
|
authChallenge;
|
|
697
730
|
constructor(ws, deps) {
|
|
698
731
|
this.ws = ws;
|
|
@@ -992,6 +1025,18 @@ var BridgeConnection = class {
|
|
|
992
1025
|
this.send("s2c.session.archived", { sessionId: p.sessionId }, env.id);
|
|
993
1026
|
return;
|
|
994
1027
|
}
|
|
1028
|
+
case "c2s.sessions.archived":
|
|
1029
|
+
this.send("s2c.sessions.archived.snapshot", { sessions: this.deps.bridge.listArchivedSessions() }, env.id);
|
|
1030
|
+
return;
|
|
1031
|
+
case "c2s.session.unarchive": {
|
|
1032
|
+
const p = env.payload;
|
|
1033
|
+
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
1034
|
+
if (!this.deps.bridge.capabilities.sessionRestore) return this.fail(env.id, "E_UNSUPPORTED", "session restore unavailable on this host version");
|
|
1035
|
+
const result = await this.deps.bridge.unarchiveSession(p.sessionId);
|
|
1036
|
+
if (!result.ok) return this.fail(env.id, managementErrorCode(result.kind), result.message);
|
|
1037
|
+
this.send("s2c.session.unarchived", { sessionId: p.sessionId }, env.id);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
995
1040
|
case "c2s.session.cancel": {
|
|
996
1041
|
const p = env.payload;
|
|
997
1042
|
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
@@ -1119,6 +1164,49 @@ var BridgeConnection = class {
|
|
|
1119
1164
|
this.send("s2c.ack", {}, env.id);
|
|
1120
1165
|
return;
|
|
1121
1166
|
}
|
|
1167
|
+
case "c2s.liveActivity.unregister": {
|
|
1168
|
+
const p = env.payload;
|
|
1169
|
+
if (this.pendingLiveActivityId === p.activityId) {
|
|
1170
|
+
this.liveActivityRegistrationGeneration += 1;
|
|
1171
|
+
this.pendingLiveActivityId = void 0;
|
|
1172
|
+
}
|
|
1173
|
+
this.deps.devices.clearLiveActivity(this.deviceId, p.activityId);
|
|
1174
|
+
this.send("s2c.ack", {}, env.id);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
case "c2s.liveActivity.register": {
|
|
1178
|
+
const p = env.payload;
|
|
1179
|
+
const initial = this.deviceId && this.deps.devices.authorized(this.deviceId);
|
|
1180
|
+
if (!initial || ![
|
|
1181
|
+
"notifications.register",
|
|
1182
|
+
"sessions.read",
|
|
1183
|
+
"interactions.respond"
|
|
1184
|
+
].every((scope) => initial.scopes?.includes(scope))) return this.fail(env.id, "E_FORBIDDEN", "live activity permissions required");
|
|
1185
|
+
const generation = ++this.liveActivityRegistrationGeneration;
|
|
1186
|
+
this.pendingLiveActivityId = p.activityId;
|
|
1187
|
+
if (p.enrollKey) await this.deps.onPushEnrollKey?.(p.enrollKey);
|
|
1188
|
+
if (generation !== this.liveActivityRegistrationGeneration) return this.fail(env.id, "E_BUSY", "live activity registration superseded");
|
|
1189
|
+
const record = this.deviceId && this.deps.devices.authorized(this.deviceId);
|
|
1190
|
+
if (this.closed || !record || ![
|
|
1191
|
+
"notifications.register",
|
|
1192
|
+
"sessions.read",
|
|
1193
|
+
"interactions.respond"
|
|
1194
|
+
].every((scope) => record.scopes?.includes(scope))) return this.fail(env.id, "E_FORBIDDEN", "live activity permissions required");
|
|
1195
|
+
if (!this.deps.bridge.listSessions().some((row) => row.id === p.sessionId)) return this.fail(env.id, "E_NOT_FOUND", "session not found");
|
|
1196
|
+
const previous = record.liveActivity;
|
|
1197
|
+
if (previous?.activityId === p.activityId && previous.sessionId !== p.sessionId) return this.fail(env.id, "E_PROTOCOL", "activity is bound to another session");
|
|
1198
|
+
this.deps.devices.setLiveActivity(record.deviceId, {
|
|
1199
|
+
activityId: p.activityId,
|
|
1200
|
+
sessionId: p.sessionId,
|
|
1201
|
+
token: p.deviceToken.toLowerCase(),
|
|
1202
|
+
environment: p.environment,
|
|
1203
|
+
updatedAt: Date.now(),
|
|
1204
|
+
expiresAt: Date.now() + 288e5
|
|
1205
|
+
});
|
|
1206
|
+
this.deps.bridge.refreshLiveActivities();
|
|
1207
|
+
this.send("s2c.ack", { enabled: this.deps.bridge.capabilities.push }, env.id);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1122
1210
|
case "c2s.widget.push.register":
|
|
1123
1211
|
case "c2s.push.register": {
|
|
1124
1212
|
const widget = env.type === "c2s.widget.push.register";
|
|
@@ -1298,6 +1386,7 @@ function projectEvent(sessionId, event) {
|
|
|
1298
1386
|
kind: "turn.end",
|
|
1299
1387
|
data: { ok: event.data?.reason?.kind === "completed" }
|
|
1300
1388
|
};
|
|
1389
|
+
case "system/message": return null;
|
|
1301
1390
|
case "user/message": return {
|
|
1302
1391
|
kind: "message.final",
|
|
1303
1392
|
data: { ...limitMessageProjection({
|
|
@@ -1358,14 +1447,28 @@ function projectEvent(sessionId, event) {
|
|
|
1358
1447
|
}
|
|
1359
1448
|
case "tool/result": {
|
|
1360
1449
|
const data = event.data;
|
|
1450
|
+
const ok = data?.error === void 0;
|
|
1451
|
+
const result = limitMessageProjection({
|
|
1452
|
+
seq: event.seq,
|
|
1453
|
+
role: "tool",
|
|
1454
|
+
ts: tsOf(event),
|
|
1455
|
+
tool: {
|
|
1456
|
+
name: "result",
|
|
1457
|
+
state: ok ? "ok" : "error",
|
|
1458
|
+
summary: ok ? summarizeResult(data?.message?.content) : "失败"
|
|
1459
|
+
},
|
|
1460
|
+
...ok ? attachmentProjection(data?.message) : {}
|
|
1461
|
+
});
|
|
1361
1462
|
return {
|
|
1362
1463
|
kind: "tool.end",
|
|
1363
1464
|
data: {
|
|
1364
|
-
seq:
|
|
1365
|
-
role:
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1465
|
+
seq: result.seq,
|
|
1466
|
+
role: result.role,
|
|
1467
|
+
ts: result.ts,
|
|
1468
|
+
ok,
|
|
1469
|
+
summary: result.tool?.summary,
|
|
1470
|
+
...result.attachments ? { attachments: result.attachments } : {},
|
|
1471
|
+
...data?.callId ? { callId: String(data.callId) } : {}
|
|
1369
1472
|
}
|
|
1370
1473
|
};
|
|
1371
1474
|
}
|
|
@@ -1560,6 +1663,7 @@ function projectHistory(events) {
|
|
|
1560
1663
|
ts: tsOf(event)
|
|
1561
1664
|
};
|
|
1562
1665
|
switch (event.type) {
|
|
1666
|
+
case "system/message": break;
|
|
1563
1667
|
case "user/message":
|
|
1564
1668
|
messages.push({
|
|
1565
1669
|
...base,
|
|
@@ -1602,19 +1706,23 @@ function projectHistory(events) {
|
|
|
1602
1706
|
const target = callId ? toolByCall.get(callId) : void 0;
|
|
1603
1707
|
const failed = data?.error !== void 0;
|
|
1604
1708
|
const summary = failed ? "失败" : summarizeResult(data?.message?.content);
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1709
|
+
const attachments = failed ? {} : attachmentProjection(data?.message);
|
|
1710
|
+
if (target?.tool) {
|
|
1711
|
+
target.tool = {
|
|
1712
|
+
...target.tool,
|
|
1713
|
+
state: failed ? "error" : "ok",
|
|
1714
|
+
summary
|
|
1715
|
+
};
|
|
1716
|
+
if (attachments.attachments) Object.assign(target, attachments);
|
|
1717
|
+
} else messages.push({
|
|
1611
1718
|
...base,
|
|
1612
1719
|
role: "tool",
|
|
1613
1720
|
tool: {
|
|
1614
1721
|
name: "result",
|
|
1615
1722
|
state: failed ? "error" : "ok",
|
|
1616
1723
|
summary
|
|
1617
|
-
}
|
|
1724
|
+
},
|
|
1725
|
+
...attachments
|
|
1618
1726
|
});
|
|
1619
1727
|
break;
|
|
1620
1728
|
}
|
|
@@ -1738,9 +1846,13 @@ var HostBridge = class {
|
|
|
1738
1846
|
historyBufferMax;
|
|
1739
1847
|
id = ++BRIDGE_SEQ;
|
|
1740
1848
|
summaries = /* @__PURE__ */ new Map();
|
|
1849
|
+
activeTools = /* @__PURE__ */ new Map();
|
|
1741
1850
|
approvals = /* @__PURE__ */ new Map();
|
|
1742
1851
|
questions = /* @__PURE__ */ new Map();
|
|
1743
1852
|
archivedSessionIds = /* @__PURE__ */ new Set();
|
|
1853
|
+
/** Mirrors archived rows from the last sessions.list so the phone can browse
|
|
1854
|
+
* and restore them. Excluded from `summaries` and from the live broadcast. */
|
|
1855
|
+
archivedSummaries = /* @__PURE__ */ new Map();
|
|
1744
1856
|
subagentSessionIds = /* @__PURE__ */ new Set();
|
|
1745
1857
|
sinks = /* @__PURE__ */ new Set();
|
|
1746
1858
|
ring = [];
|
|
@@ -1775,9 +1887,11 @@ var HostBridge = class {
|
|
|
1775
1887
|
notifyAllCategories: true,
|
|
1776
1888
|
models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
|
|
1777
1889
|
sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
|
|
1890
|
+
sessionRestore: typeof this.apiProxy.workspace?.unarchiveSession === "function",
|
|
1778
1891
|
projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
|
|
1779
1892
|
push: this.pushOutlet?.isAvailable() === true,
|
|
1780
|
-
widgetPush: true
|
|
1893
|
+
widgetPush: true,
|
|
1894
|
+
liveActivityPush: true
|
|
1781
1895
|
};
|
|
1782
1896
|
}
|
|
1783
1897
|
diagnostic(message) {
|
|
@@ -1823,7 +1937,7 @@ var HostBridge = class {
|
|
|
1823
1937
|
* PROTOCOL §6 + §7), then fan the same payload out to offline devices
|
|
1824
1938
|
* holding an APNs token. */
|
|
1825
1939
|
emitNotify(args) {
|
|
1826
|
-
if (this.subagentSessionIds.has(args.sessionId)) return;
|
|
1940
|
+
if ((args.category === "turn.completed" || args.category === "session.error") && this.subagentSessionIds.has(args.sessionId)) return;
|
|
1827
1941
|
const body = args.body.length > 120 ? args.body.slice(0, 119) + "…" : args.body;
|
|
1828
1942
|
this.record("s2c.notify", {
|
|
1829
1943
|
notificationId: args.notificationId,
|
|
@@ -1890,16 +2004,23 @@ var HostBridge = class {
|
|
|
1890
2004
|
for (const sink of receivers) sink.replayDone();
|
|
1891
2005
|
return true;
|
|
1892
2006
|
}
|
|
2007
|
+
refreshLiveActivities() {
|
|
2008
|
+
try {
|
|
2009
|
+
this.pushOutlet?.liveActivityChanged?.(this.listSessions());
|
|
2010
|
+
} catch {}
|
|
2011
|
+
}
|
|
1893
2012
|
record(type, payload, except) {
|
|
1894
2013
|
if (this.disposed) return;
|
|
2014
|
+
if (type === "s2c.sessions.delta") this.refreshLiveActivities();
|
|
1895
2015
|
if (type === "s2c.sessions.delta" || type.startsWith("s2c.pending.")) {
|
|
1896
2016
|
const fingerprint = JSON.stringify([
|
|
1897
|
-
this.listSessions().map(({ id, title, status, lastActivityTs, todos, pendingApproval, pendingQuestion }) => ({
|
|
2017
|
+
this.listSessions().map(({ id, title, status, lastActivityTs, todos, todoItems, pendingApproval, pendingQuestion }) => ({
|
|
1898
2018
|
id,
|
|
1899
2019
|
title,
|
|
1900
2020
|
status,
|
|
1901
2021
|
lastActivityTs,
|
|
1902
2022
|
todos,
|
|
2023
|
+
todoItems,
|
|
1903
2024
|
pendingApproval,
|
|
1904
2025
|
pendingQuestion
|
|
1905
2026
|
})),
|
|
@@ -1992,9 +2113,12 @@ var HostBridge = class {
|
|
|
1992
2113
|
const event = frame.event;
|
|
1993
2114
|
const sessionId = String(frame.sessionId ?? "");
|
|
1994
2115
|
if (!event || !sessionId) break;
|
|
2116
|
+
if (frame.isSubagent === true) this.subagentSessionIds.add(sessionId);
|
|
2117
|
+
if (this.subagentSessionIds.has(sessionId)) break;
|
|
1995
2118
|
this.noteActivity(sessionId, event);
|
|
1996
2119
|
this.captureAssistantText(sessionId, event);
|
|
1997
2120
|
const projection = projectEvent(sessionId, event);
|
|
2121
|
+
this.captureActivity(sessionId, event, projection?.data.tool);
|
|
1998
2122
|
if (projection) this.record("s2c.session.event", {
|
|
1999
2123
|
sessionId,
|
|
2000
2124
|
kind: projection.kind,
|
|
@@ -2029,6 +2153,7 @@ var HostBridge = class {
|
|
|
2029
2153
|
summary,
|
|
2030
2154
|
riskLevel: riskOf(toolName)
|
|
2031
2155
|
});
|
|
2156
|
+
if (p.callId) this.loadApprovalArguments(p.approvalId, p.callId);
|
|
2032
2157
|
this.emitNotify({
|
|
2033
2158
|
sessionId,
|
|
2034
2159
|
category: "approval.required",
|
|
@@ -2111,17 +2236,27 @@ var HostBridge = class {
|
|
|
2111
2236
|
const workspaceBySession = /* @__PURE__ */ new Map();
|
|
2112
2237
|
for (const workspace of workspaces) for (const sessionId of workspace.sessionIds ?? []) workspaceBySession.set(String(sessionId), workspace);
|
|
2113
2238
|
const next = /* @__PURE__ */ new Map();
|
|
2114
|
-
const
|
|
2239
|
+
const nextArchived = /* @__PURE__ */ new Map();
|
|
2115
2240
|
for (const row of response.result.value.items ?? []) {
|
|
2116
|
-
if (this.
|
|
2117
|
-
if (
|
|
2118
|
-
|
|
2241
|
+
if (isSubagentRow(row)) this.subagentSessionIds.add(row.sessionId);
|
|
2242
|
+
if (this.subagentSessionIds.has(row.sessionId)) continue;
|
|
2243
|
+
const summary = toSummary(row, this.approvals, this.questions, workspaceBySession.get(row.sessionId));
|
|
2244
|
+
if (this.archivedSessionIds.has(row.sessionId)) {
|
|
2245
|
+
nextArchived.set(row.sessionId, {
|
|
2246
|
+
...summary,
|
|
2247
|
+
archived: true
|
|
2248
|
+
});
|
|
2119
2249
|
continue;
|
|
2120
2250
|
}
|
|
2121
|
-
next.set(row.sessionId,
|
|
2251
|
+
next.set(row.sessionId, summary);
|
|
2252
|
+
}
|
|
2253
|
+
for (const [id, calls] of this.activeTools) {
|
|
2254
|
+
const row = next.get(id);
|
|
2255
|
+
if (!row || row.status !== "running" && !row.pendingApproval && !row.pendingQuestion) this.activeTools.delete(id);
|
|
2256
|
+
else row.activity = [...calls.values()].at(-1) ?? null;
|
|
2122
2257
|
}
|
|
2123
|
-
this.subagentSessionIds = subagentIds;
|
|
2124
2258
|
this.summaries = next;
|
|
2259
|
+
this.archivedSummaries = nextArchived;
|
|
2125
2260
|
const removedIds = [...previousIds].filter((id) => !next.has(id));
|
|
2126
2261
|
for (const id of this.lastAssistantText.keys()) if (!next.has(id)) this.lastAssistantText.delete(id);
|
|
2127
2262
|
this.record("s2c.sessions.delta", {
|
|
@@ -2139,6 +2274,24 @@ var HostBridge = class {
|
|
|
2139
2274
|
row.title = firstUser.text.replace(/\s+/g, " ").trim().slice(0, 60);
|
|
2140
2275
|
this.pushSummary(row);
|
|
2141
2276
|
}
|
|
2277
|
+
captureActivity(sessionId, event, tool) {
|
|
2278
|
+
const row = this.summaries.get(sessionId);
|
|
2279
|
+
if (!row) return;
|
|
2280
|
+
const data = event.data;
|
|
2281
|
+
if (event.type === "tool/call" && data?.callId) {
|
|
2282
|
+
const calls = this.activeTools.get(sessionId) ?? /* @__PURE__ */ new Map();
|
|
2283
|
+
const detail = [tool?.name, tool?.summary].filter(Boolean).join(": ");
|
|
2284
|
+
calls.set(data.callId, Array.from(detail).slice(0, 160).join(""));
|
|
2285
|
+
if (calls.size > 64) calls.delete(calls.keys().next().value);
|
|
2286
|
+
this.activeTools.set(sessionId, calls);
|
|
2287
|
+
} else if (event.type === "tool/result" && data?.callId) this.activeTools.get(sessionId)?.delete(data.callId);
|
|
2288
|
+
else if (event.type === "turn/start" || event.type === "turn/end") this.activeTools.delete(sessionId);
|
|
2289
|
+
else return;
|
|
2290
|
+
const activity = [...this.activeTools.get(sessionId)?.values() ?? []].at(-1) ?? null;
|
|
2291
|
+
if (row.activity === activity) return;
|
|
2292
|
+
row.activity = activity;
|
|
2293
|
+
this.pushSummary(row);
|
|
2294
|
+
}
|
|
2142
2295
|
noteActivity(sessionId, event) {
|
|
2143
2296
|
const row = this.summaries.get(sessionId);
|
|
2144
2297
|
if (!row) return;
|
|
@@ -2197,6 +2350,14 @@ var HostBridge = class {
|
|
|
2197
2350
|
return [...this.summaries.values()].sort((a, b) => b.lastActivityTs - a.lastActivityTs);
|
|
2198
2351
|
}
|
|
2199
2352
|
/**
|
|
2353
|
+
* Archived rows from the last sessions.list, newest first. Served only on
|
|
2354
|
+
* demand so the live session list and its broadcast keep their shape for
|
|
2355
|
+
* clients that predate `c2s.sessions.archived`.
|
|
2356
|
+
*/
|
|
2357
|
+
listArchivedSessions() {
|
|
2358
|
+
return [...this.archivedSummaries.values()].sort((a, b) => b.lastActivityTs - a.lastActivityTs);
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2200
2361
|
* Complete transient interaction state. Unlike the replay ring, this remains
|
|
2201
2362
|
* authoritative after a long disconnect and is rehydrated by apiProxy's mux
|
|
2202
2363
|
* stream when the bridge itself restarts.
|
|
@@ -2208,6 +2369,7 @@ var HostBridge = class {
|
|
|
2208
2369
|
sessionId: pending.sessionId,
|
|
2209
2370
|
toolName: pending.toolName,
|
|
2210
2371
|
summary: pending.reason,
|
|
2372
|
+
...pending.toolArguments !== void 0 ? { toolArguments: pending.toolArguments } : {},
|
|
2211
2373
|
riskLevel: riskOf(pending.toolName)
|
|
2212
2374
|
})),
|
|
2213
2375
|
questions: [...this.questions.entries()].map(([requestId, pending]) => ({
|
|
@@ -2217,6 +2379,35 @@ var HostBridge = class {
|
|
|
2217
2379
|
}))
|
|
2218
2380
|
};
|
|
2219
2381
|
}
|
|
2382
|
+
/** Resolve only the exact invocation in this session; never guess from the latest tool. */
|
|
2383
|
+
async loadApprovalArguments(requestId, callId) {
|
|
2384
|
+
const pending = this.approvals.get(requestId);
|
|
2385
|
+
if (!pending) return;
|
|
2386
|
+
try {
|
|
2387
|
+
const response = await this.apiProxy.sessions.history({
|
|
2388
|
+
rpcId: randomUUID(),
|
|
2389
|
+
payload: {
|
|
2390
|
+
sessionId: pending.sessionId,
|
|
2391
|
+
maxMessages: 200
|
|
2392
|
+
}
|
|
2393
|
+
});
|
|
2394
|
+
if (this.disposed || this.approvals.get(requestId) !== pending || !response.result?.ok) return;
|
|
2395
|
+
const args = ((response.result.value.events ?? []).map((row) => row.event).find((event) => {
|
|
2396
|
+
const data = event.data;
|
|
2397
|
+
return event.type === "tool/call" && data?.callId === callId && data?.name === pending.toolName;
|
|
2398
|
+
})?.data)?.arguments;
|
|
2399
|
+
if (typeof args !== "string" || !args.trim()) return;
|
|
2400
|
+
pending.toolArguments = args;
|
|
2401
|
+
this.record("s2c.pending.approval", {
|
|
2402
|
+
requestId,
|
|
2403
|
+
sessionId: pending.sessionId,
|
|
2404
|
+
toolName: pending.toolName,
|
|
2405
|
+
summary: pending.reason,
|
|
2406
|
+
riskLevel: riskOf(pending.toolName),
|
|
2407
|
+
toolArguments: args
|
|
2408
|
+
});
|
|
2409
|
+
} catch {}
|
|
2410
|
+
}
|
|
2220
2411
|
/** Tail history for an opened session; pushes s2c.session.tail to the sink. */
|
|
2221
2412
|
async openSession(sink, sessionId, tailCount) {
|
|
2222
2413
|
try {
|
|
@@ -2430,6 +2621,38 @@ var HostBridge = class {
|
|
|
2430
2621
|
};
|
|
2431
2622
|
}
|
|
2432
2623
|
}
|
|
2624
|
+
async unarchiveSession(sessionId) {
|
|
2625
|
+
const unarchive = this.apiProxy.workspace?.unarchiveSession;
|
|
2626
|
+
if (typeof unarchive !== "function") return {
|
|
2627
|
+
ok: false,
|
|
2628
|
+
kind: "unsupported",
|
|
2629
|
+
message: "session restore unavailable on this host version"
|
|
2630
|
+
};
|
|
2631
|
+
try {
|
|
2632
|
+
const response = await unarchive.call(this.apiProxy.workspace, {
|
|
2633
|
+
rpcId: randomUUID(),
|
|
2634
|
+
payload: { sessionId }
|
|
2635
|
+
});
|
|
2636
|
+
if (!response.result) return {
|
|
2637
|
+
ok: false,
|
|
2638
|
+
kind: "internal",
|
|
2639
|
+
message: "session restore returned no result"
|
|
2640
|
+
};
|
|
2641
|
+
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
2642
|
+
this.archivedSessionIds = new Set((response.result.value.archivedSessionIds ?? []).map(String));
|
|
2643
|
+
await this.refreshSummaries();
|
|
2644
|
+
return {
|
|
2645
|
+
ok: true,
|
|
2646
|
+
value: true
|
|
2647
|
+
};
|
|
2648
|
+
} catch (error) {
|
|
2649
|
+
return {
|
|
2650
|
+
ok: false,
|
|
2651
|
+
kind: "internal",
|
|
2652
|
+
message: String(error)
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2433
2656
|
async cancelSession(sessionId) {
|
|
2434
2657
|
const cancel = this.apiProxy.sessions.cancel;
|
|
2435
2658
|
if (typeof cancel !== "function") return {
|
|
@@ -3163,6 +3386,7 @@ var Dsh012ApiProxy = class {
|
|
|
3163
3386
|
if (session === void 0) throw new Error("dsh 0.1.2 sessionController is unavailable");
|
|
3164
3387
|
this.session = session;
|
|
3165
3388
|
this.workspaceController = ctx.get("workspaceController");
|
|
3389
|
+
if (typeof this.workspaceController?.unarchiveSession !== "function") delete this.workspace?.unarchiveSession;
|
|
3166
3390
|
this.directoryPicker = ctx.get("directoryPickerController");
|
|
3167
3391
|
this.shouldSurfaceInteraction = options.shouldSurfaceInteraction ?? (() => true);
|
|
3168
3392
|
}
|
|
@@ -3180,7 +3404,7 @@ var Dsh012ApiProxy = class {
|
|
|
3180
3404
|
}
|
|
3181
3405
|
sessions = {
|
|
3182
3406
|
list: async () => this.call(async () => {
|
|
3183
|
-
return { items: (await this.session.list({}, new AbortController().signal)).items.map(toPhoneSessionRow)
|
|
3407
|
+
return { items: (await this.session.list({}, new AbortController().signal)).items.map(toPhoneSessionRow) };
|
|
3184
3408
|
}),
|
|
3185
3409
|
history: async (request) => this.call(async () => {
|
|
3186
3410
|
const sessionId = request.payload.sessionId;
|
|
@@ -3242,6 +3466,12 @@ var Dsh012ApiProxy = class {
|
|
|
3242
3466
|
archiveSession: async (request) => this.call(async () => {
|
|
3243
3467
|
if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
|
|
3244
3468
|
return { archivedSessionIds: [...(await this.workspaceController.archiveSession(request.payload)).archivedSessionIds] };
|
|
3469
|
+
}),
|
|
3470
|
+
unarchiveSession: async (request) => this.call(async () => {
|
|
3471
|
+
if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
|
|
3472
|
+
const unarchive = this.workspaceController.unarchiveSession;
|
|
3473
|
+
if (typeof unarchive !== "function") throw unavailable("session restore unavailable on this host version");
|
|
3474
|
+
return { archivedSessionIds: [...(await unarchive.call(this.workspaceController, request.payload)).archivedSessionIds] };
|
|
3245
3475
|
})
|
|
3246
3476
|
};
|
|
3247
3477
|
host = {
|
|
@@ -3278,6 +3508,7 @@ var Dsh012ApiProxy = class {
|
|
|
3278
3508
|
queue.push({
|
|
3279
3509
|
type: "session/event",
|
|
3280
3510
|
sessionId: String(session.id),
|
|
3511
|
+
isSubagent: session.header?.origin === "subagent",
|
|
3281
3512
|
event
|
|
3282
3513
|
});
|
|
3283
3514
|
}), { global: true });
|
|
@@ -3326,7 +3557,8 @@ var Dsh012ApiProxy = class {
|
|
|
3326
3557
|
sessionId,
|
|
3327
3558
|
approvalId: rpcId,
|
|
3328
3559
|
toolName: String(request.toolName ?? "tool"),
|
|
3329
|
-
reason: String(request.reason ?? "")
|
|
3560
|
+
reason: String(request.reason ?? ""),
|
|
3561
|
+
...request.callId ? { callId: request.callId } : {}
|
|
3330
3562
|
});
|
|
3331
3563
|
return response.promise.finally(() => {
|
|
3332
3564
|
request.signal?.removeEventListener("abort", abort);
|
|
@@ -3665,10 +3897,16 @@ function parseLocal(value) {
|
|
|
3665
3897
|
if (!Array.isArray(endpoints) || endpoints.some((entry) => typeof entry !== "string")) reject("local.endpoints");
|
|
3666
3898
|
const message = s.message;
|
|
3667
3899
|
if (message !== void 0 && typeof message !== "string") reject("local.message");
|
|
3900
|
+
const tlsFingerprint = s.tlsFingerprint;
|
|
3901
|
+
if (tlsFingerprint !== void 0 && typeof tlsFingerprint !== "string") reject("local.tlsFingerprint");
|
|
3902
|
+
const tlsIdentityRegenerated = s.tlsIdentityRegenerated;
|
|
3903
|
+
if (tlsIdentityRegenerated !== void 0 && typeof tlsIdentityRegenerated !== "boolean") reject("local.tlsIdentityRegenerated");
|
|
3668
3904
|
return {
|
|
3669
3905
|
phase,
|
|
3670
3906
|
port: int(s, "port", "local.port"),
|
|
3671
3907
|
endpoints,
|
|
3908
|
+
...typeof tlsFingerprint === "string" ? { tlsFingerprint } : {},
|
|
3909
|
+
...tlsIdentityRegenerated === true ? { tlsIdentityRegenerated: true } : {},
|
|
3672
3910
|
...typeof message === "string" ? { message } : {},
|
|
3673
3911
|
updatedAt: int(s, "updatedAt", "local.updatedAt")
|
|
3674
3912
|
};
|
|
@@ -4094,6 +4332,12 @@ function p8ToDer(pem) {
|
|
|
4094
4332
|
}
|
|
4095
4333
|
/** Pure payload builder so tests can assert the wire format without sockets. */
|
|
4096
4334
|
function apnsPayload(notification) {
|
|
4335
|
+
if ("kind" in notification && notification.kind === "liveactivity") return { aps: {
|
|
4336
|
+
timestamp: notification.timestamp,
|
|
4337
|
+
event: notification.event,
|
|
4338
|
+
"content-state": notification.contentState,
|
|
4339
|
+
...notification.event === "end" ? { "dismissal-date": notification.timestamp + 300 } : { "stale-date": notification.timestamp + 180 }
|
|
4340
|
+
} };
|
|
4097
4341
|
if ("kind" in notification && notification.kind === "widget") return { aps: { "content-changed": true } };
|
|
4098
4342
|
return alertPayload(notification);
|
|
4099
4343
|
}
|
|
@@ -4117,6 +4361,12 @@ function alertPayload(notification) {
|
|
|
4117
4361
|
};
|
|
4118
4362
|
}
|
|
4119
4363
|
function pushHeaders(bundleId, notification) {
|
|
4364
|
+
if ("kind" in notification && notification.kind === "liveactivity") return {
|
|
4365
|
+
"apns-topic": bundleId + ".push-type.liveactivity",
|
|
4366
|
+
"apns-push-type": "liveactivity",
|
|
4367
|
+
"apns-priority": notification.event === "end" ? "10" : "5",
|
|
4368
|
+
"apns-expiration": String(notification.timestamp + 180)
|
|
4369
|
+
};
|
|
4120
4370
|
if ("kind" in notification && notification.kind === "widget") return {
|
|
4121
4371
|
"apns-topic": bundleId + ".push-type.widgets",
|
|
4122
4372
|
"apns-push-type": "widgets",
|
|
@@ -5023,9 +5273,10 @@ const MAX_LOCAL_PORT = 65535;
|
|
|
5023
5273
|
function normalizeLocalPort(value) {
|
|
5024
5274
|
return typeof value === "number" && Number.isInteger(value) && value >= 1024 && value <= 65535 ? value : DEFAULT_LOCAL_PORT;
|
|
5025
5275
|
}
|
|
5276
|
+
/** The LAN listener is TLS-only, so every advertised endpoint is `https://`. */
|
|
5026
5277
|
function localEndpointURLs(addresses, port) {
|
|
5027
5278
|
const normalizedPort = normalizeLocalPort(port);
|
|
5028
|
-
return [...new Set(addresses)].map((address) => `
|
|
5279
|
+
return [...new Set(addresses)].map((address) => `https://${address}:${normalizedPort}`);
|
|
5029
5280
|
}
|
|
5030
5281
|
function localListenError(error, port) {
|
|
5031
5282
|
const code = error?.code;
|
|
@@ -5044,7 +5295,7 @@ const Config = z.object({
|
|
|
5044
5295
|
debug: z.boolean().default(false),
|
|
5045
5296
|
local: z.object({
|
|
5046
5297
|
enabled: z.boolean().default(true),
|
|
5047
|
-
port: z.natural().min(MIN_LOCAL_PORT).max(MAX_LOCAL_PORT).default(DEFAULT_LOCAL_PORT).description("DeepPilot
|
|
5298
|
+
port: z.natural().min(MIN_LOCAL_PORT).max(MAX_LOCAL_PORT).default(DEFAULT_LOCAL_PORT).description("DeepPilot 局域网独立端口(仅 TLS;默认 3098,修改后本地连接会短暂重连)")
|
|
5048
5299
|
}).default({
|
|
5049
5300
|
enabled: true,
|
|
5050
5301
|
port: DEFAULT_LOCAL_PORT
|
|
@@ -5331,14 +5582,91 @@ var WidgetPushScheduler = class {
|
|
|
5331
5582
|
}
|
|
5332
5583
|
};
|
|
5333
5584
|
//#endregion
|
|
5585
|
+
//#region src/live-activity.ts
|
|
5586
|
+
function liveActivityState(session) {
|
|
5587
|
+
const total = Math.max(0, session?.todos?.total ?? session?.todoItems?.length ?? 0);
|
|
5588
|
+
const done = Math.min(total, Math.max(0, session?.todos?.done ?? 0));
|
|
5589
|
+
const task = session?.todoItems?.find((item) => item.status === "in_progress") ?? session?.todoItems?.find((item) => item.status === "pending");
|
|
5590
|
+
return {
|
|
5591
|
+
title: Array.from(session?.title ?? "").slice(0, 100).join(""),
|
|
5592
|
+
task: Array.from(session?.activity?.trim() || task?.content || "").slice(0, 160).join(""),
|
|
5593
|
+
done,
|
|
5594
|
+
total,
|
|
5595
|
+
phase: !session || total === 0 ? "unavailable" : session.pendingApproval ? "approval" : session.pendingQuestion ? "question" : session.status === "running" ? "running" : session.status === "idle" ? "ended" : "unavailable"
|
|
5596
|
+
};
|
|
5597
|
+
}
|
|
5598
|
+
/** Persist terminal state before coalescing so a rapid next round cannot revive an activity. */
|
|
5599
|
+
var LiveActivityPushManager = class {
|
|
5600
|
+
devices;
|
|
5601
|
+
send;
|
|
5602
|
+
latest = /* @__PURE__ */ new Map();
|
|
5603
|
+
sent = /* @__PURE__ */ new Map();
|
|
5604
|
+
scheduler;
|
|
5605
|
+
constructor(devices, send, intervalMs = 15e3) {
|
|
5606
|
+
this.devices = devices;
|
|
5607
|
+
this.send = send;
|
|
5608
|
+
this.scheduler = new WidgetPushScheduler(() => this.flush(), intervalMs);
|
|
5609
|
+
}
|
|
5610
|
+
changed(sessions) {
|
|
5611
|
+
this.latest = new Map(sessions.map((session) => [session.id, session]));
|
|
5612
|
+
for (const device of this.devices()?.list() ?? []) {
|
|
5613
|
+
const r = device.liveActivity;
|
|
5614
|
+
if (!r) continue;
|
|
5615
|
+
const state = liveActivityState(this.latest.get(r.sessionId));
|
|
5616
|
+
if (state.phase === "ended" || state.phase === "unavailable") this.devices()?.endLiveActivity(device.deviceId, r.token, state);
|
|
5617
|
+
}
|
|
5618
|
+
this.scheduler.changed();
|
|
5619
|
+
}
|
|
5620
|
+
async flush() {
|
|
5621
|
+
const devices = this.devices();
|
|
5622
|
+
if (!devices) return;
|
|
5623
|
+
const valid = /* @__PURE__ */ new Set();
|
|
5624
|
+
for (const device of devices.list()) {
|
|
5625
|
+
const r = device.liveActivity;
|
|
5626
|
+
if (!r) continue;
|
|
5627
|
+
const key = device.deviceId + ":" + r.token;
|
|
5628
|
+
valid.add(key);
|
|
5629
|
+
if (device.revokedAt !== void 0 || ![
|
|
5630
|
+
"notifications.register",
|
|
5631
|
+
"sessions.read",
|
|
5632
|
+
"interactions.respond"
|
|
5633
|
+
].every((scope) => device.scopes?.some((value) => value === scope)) || r.expiresAt <= Date.now()) {
|
|
5634
|
+
devices.clearLiveActivity(device.deviceId, r.activityId, r.token);
|
|
5635
|
+
continue;
|
|
5636
|
+
}
|
|
5637
|
+
const state = r.endedState ?? liveActivityState(this.latest.get(r.sessionId));
|
|
5638
|
+
const fingerprint = JSON.stringify(state);
|
|
5639
|
+
if (this.sent.get(key) === fingerprint) continue;
|
|
5640
|
+
if (devices.authorized(device.deviceId)?.liveActivity?.token !== r.token) continue;
|
|
5641
|
+
const result = await this.send(r.token, r.environment, {
|
|
5642
|
+
kind: "liveactivity",
|
|
5643
|
+
event: r.endedState ? "end" : "update",
|
|
5644
|
+
timestamp: Math.floor(Date.now() / 1e3),
|
|
5645
|
+
contentState: state
|
|
5646
|
+
}).catch(() => ({ outcome: "failed" }));
|
|
5647
|
+
if (result.outcome === "sent") this.sent.set(key, fingerprint);
|
|
5648
|
+
else if (result.outcome === "invalid-token") devices.clearLiveActivity(device.deviceId, r.activityId, r.token);
|
|
5649
|
+
else this.scheduler.changed();
|
|
5650
|
+
}
|
|
5651
|
+
for (const key of this.sent.keys()) if (!valid.has(key)) this.sent.delete(key);
|
|
5652
|
+
}
|
|
5653
|
+
dispose() {
|
|
5654
|
+
this.scheduler.dispose();
|
|
5655
|
+
}
|
|
5656
|
+
};
|
|
5657
|
+
//#endregion
|
|
5334
5658
|
//#region src/phone-server.ts
|
|
5335
5659
|
/**
|
|
5336
5660
|
* A deliberately narrow transport listener. Both the LAN endpoint and the
|
|
5337
5661
|
* loopback-only Funnel origin use this factory, so neither can accidentally
|
|
5338
5662
|
* inherit DSH's wider web/API route surface.
|
|
5663
|
+
*
|
|
5664
|
+
* With `tls` the listener speaks HTTPS/WSS only (the LAN posture); without
|
|
5665
|
+
* it the listener is plain HTTP, which is reserved for the loopback Funnel
|
|
5666
|
+
* origin where tailscaled terminates TLS.
|
|
5339
5667
|
*/
|
|
5340
|
-
function createPhoneServer(handlers) {
|
|
5341
|
-
const
|
|
5668
|
+
function createPhoneServer(handlers, tls) {
|
|
5669
|
+
const onRequest = (req, res) => {
|
|
5342
5670
|
const path = requestPath(req);
|
|
5343
5671
|
if (path === "/phone/health") handlers.health(req, res);
|
|
5344
5672
|
else if (path === "/phone/pair") handlers.pair(req, res);
|
|
@@ -5346,7 +5674,12 @@ function createPhoneServer(handlers) {
|
|
|
5346
5674
|
res.statusCode = 404;
|
|
5347
5675
|
res.end("not found");
|
|
5348
5676
|
}
|
|
5349
|
-
}
|
|
5677
|
+
};
|
|
5678
|
+
const server = tls === void 0 ? createServer$1(onRequest) : createServer({
|
|
5679
|
+
key: tls.key,
|
|
5680
|
+
cert: tls.cert,
|
|
5681
|
+
minVersion: "TLSv1.2"
|
|
5682
|
+
}, onRequest);
|
|
5350
5683
|
server.on("upgrade", (req, socket, head) => {
|
|
5351
5684
|
if (requestPath(req) !== "/phone") {
|
|
5352
5685
|
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
@@ -5383,12 +5716,175 @@ function closeServer(server) {
|
|
|
5383
5716
|
return new Promise((resolve) => server.close(() => resolve()));
|
|
5384
5717
|
}
|
|
5385
5718
|
//#endregion
|
|
5719
|
+
//#region src/lan-tls.ts
|
|
5720
|
+
const LAN_TLS_COMMON_NAME = "dsh-deeppilot";
|
|
5721
|
+
const LAN_TLS_KEY_FILE = "key.pem";
|
|
5722
|
+
const LAN_TLS_CERT_FILE = "cert.pem";
|
|
5723
|
+
const CERT_VALIDITY_MS = 31536e7;
|
|
5724
|
+
/** Re-sign this far ahead of expiry so a long-running host never serves a stale leaf. */
|
|
5725
|
+
const CERT_RENEW_LEAD_MS = 2592e6;
|
|
5726
|
+
/** Compute the pinned public-key fingerprint of a PEM certificate. */
|
|
5727
|
+
function spkiFingerprint(certPem) {
|
|
5728
|
+
const der = createPublicKey(certPem).export({
|
|
5729
|
+
type: "spki",
|
|
5730
|
+
format: "der"
|
|
5731
|
+
});
|
|
5732
|
+
return "sha256:" + createHash("sha256").update(der).digest("base64url");
|
|
5733
|
+
}
|
|
5734
|
+
/**
|
|
5735
|
+
* Load the LAN listener's TLS identity from `dir`, creating or repairing it
|
|
5736
|
+
* as needed. The private key is the durable identity: it is created once and
|
|
5737
|
+
* reused for every subsequent certificate, so pinned devices survive
|
|
5738
|
+
* certificate renewals. Only a missing or unreadable key forces a new one.
|
|
5739
|
+
*/
|
|
5740
|
+
async function loadOrCreateLanTlsIdentity(dir, now = Date.now()) {
|
|
5741
|
+
await mkdir(dir, {
|
|
5742
|
+
recursive: true,
|
|
5743
|
+
mode: 448
|
|
5744
|
+
});
|
|
5745
|
+
const keyPath = join(dir, LAN_TLS_KEY_FILE);
|
|
5746
|
+
const certPath = join(dir, LAN_TLS_CERT_FILE);
|
|
5747
|
+
let key = await readPem(keyPath);
|
|
5748
|
+
let regenerated = false;
|
|
5749
|
+
if (key === void 0 || !validPrivateKey(key)) {
|
|
5750
|
+
regenerated = true;
|
|
5751
|
+
key = void 0;
|
|
5752
|
+
}
|
|
5753
|
+
let cert = regenerated ? void 0 : await readPem(certPath);
|
|
5754
|
+
let resigned = false;
|
|
5755
|
+
const existing = key !== void 0 && cert !== void 0 ? inspectCertificate(cert, key, now) : void 0;
|
|
5756
|
+
if (existing === void 0) {
|
|
5757
|
+
const issued = await issueCertificate(key, now);
|
|
5758
|
+
if (key === void 0) {
|
|
5759
|
+
key = pemFile(issued.key);
|
|
5760
|
+
await writePrivate(keyPath, key);
|
|
5761
|
+
} else resigned = true;
|
|
5762
|
+
cert = pemFile(issued.cert);
|
|
5763
|
+
await writePrivate(certPath, cert);
|
|
5764
|
+
}
|
|
5765
|
+
if (key === void 0 || cert === void 0) throw new Error("LAN TLS identity unavailable");
|
|
5766
|
+
return {
|
|
5767
|
+
key,
|
|
5768
|
+
cert,
|
|
5769
|
+
fingerprint: spkiFingerprint(cert),
|
|
5770
|
+
notAfter: existing ?? new X509Certificate(cert).validToDate.getTime(),
|
|
5771
|
+
regenerated,
|
|
5772
|
+
resigned
|
|
5773
|
+
};
|
|
5774
|
+
}
|
|
5775
|
+
async function readPem(path) {
|
|
5776
|
+
try {
|
|
5777
|
+
const text = await readFile(path, "utf8");
|
|
5778
|
+
return text.trim().length > 0 ? text : void 0;
|
|
5779
|
+
} catch (error) {
|
|
5780
|
+
if (error.code === "ENOENT") return void 0;
|
|
5781
|
+
return;
|
|
5782
|
+
}
|
|
5783
|
+
}
|
|
5784
|
+
function validPrivateKey(pem) {
|
|
5785
|
+
try {
|
|
5786
|
+
return createPrivateKey(pem).asymmetricKeyType === "ec";
|
|
5787
|
+
} catch {
|
|
5788
|
+
return false;
|
|
5789
|
+
}
|
|
5790
|
+
}
|
|
5791
|
+
/**
|
|
5792
|
+
* Return the certificate's expiry when it is usable with `keyPem`, or
|
|
5793
|
+
* `undefined` when it must be re-issued (malformed, wrong key, or expiring).
|
|
5794
|
+
*/
|
|
5795
|
+
function inspectCertificate(certPem, keyPem, now) {
|
|
5796
|
+
try {
|
|
5797
|
+
const cert = new X509Certificate(certPem);
|
|
5798
|
+
const certSpki = cert.publicKey.export({
|
|
5799
|
+
type: "spki",
|
|
5800
|
+
format: "der"
|
|
5801
|
+
});
|
|
5802
|
+
const keySpki = createPublicKey(createPrivateKey(keyPem)).export({
|
|
5803
|
+
type: "spki",
|
|
5804
|
+
format: "der"
|
|
5805
|
+
});
|
|
5806
|
+
if (!certSpki.equals(keySpki)) return void 0;
|
|
5807
|
+
const notAfter = cert.validToDate.getTime();
|
|
5808
|
+
if (!(cert.validFromDate.getTime() <= now && notAfter - CERT_RENEW_LEAD_MS > now)) return void 0;
|
|
5809
|
+
return notAfter;
|
|
5810
|
+
} catch {
|
|
5811
|
+
return;
|
|
5812
|
+
}
|
|
5813
|
+
}
|
|
5814
|
+
async function issueCertificate(existingKey, now) {
|
|
5815
|
+
const notBeforeDate = /* @__PURE__ */ new Date(now - 3e5);
|
|
5816
|
+
const notAfterDate = new Date(now + CERT_VALIDITY_MS);
|
|
5817
|
+
const keyPair = existingKey === void 0 ? void 0 : {
|
|
5818
|
+
privateKey: createPrivateKey(existingKey).export({
|
|
5819
|
+
type: "pkcs8",
|
|
5820
|
+
format: "pem"
|
|
5821
|
+
}),
|
|
5822
|
+
publicKey: createPublicKey(existingKey).export({
|
|
5823
|
+
type: "spki",
|
|
5824
|
+
format: "pem"
|
|
5825
|
+
})
|
|
5826
|
+
};
|
|
5827
|
+
const result = await generate([{
|
|
5828
|
+
name: "commonName",
|
|
5829
|
+
value: LAN_TLS_COMMON_NAME
|
|
5830
|
+
}], {
|
|
5831
|
+
keyType: "ec",
|
|
5832
|
+
curve: "P-256",
|
|
5833
|
+
algorithm: "sha256",
|
|
5834
|
+
notBeforeDate,
|
|
5835
|
+
notAfterDate,
|
|
5836
|
+
...keyPair ? { keyPair } : {},
|
|
5837
|
+
extensions: [
|
|
5838
|
+
{
|
|
5839
|
+
name: "basicConstraints",
|
|
5840
|
+
cA: false,
|
|
5841
|
+
critical: true
|
|
5842
|
+
},
|
|
5843
|
+
{
|
|
5844
|
+
name: "keyUsage",
|
|
5845
|
+
digitalSignature: true,
|
|
5846
|
+
keyAgreement: true,
|
|
5847
|
+
critical: true
|
|
5848
|
+
},
|
|
5849
|
+
{
|
|
5850
|
+
name: "extKeyUsage",
|
|
5851
|
+
serverAuth: true
|
|
5852
|
+
},
|
|
5853
|
+
{
|
|
5854
|
+
name: "subjectAltName",
|
|
5855
|
+
altNames: [{
|
|
5856
|
+
type: 2,
|
|
5857
|
+
value: LAN_TLS_COMMON_NAME
|
|
5858
|
+
}]
|
|
5859
|
+
}
|
|
5860
|
+
]
|
|
5861
|
+
});
|
|
5862
|
+
return {
|
|
5863
|
+
key: existingKey ?? result.private,
|
|
5864
|
+
cert: result.cert
|
|
5865
|
+
};
|
|
5866
|
+
}
|
|
5867
|
+
/** Exact on-disk form, so a freshly issued value equals its later reload. */
|
|
5868
|
+
function pemFile(pem) {
|
|
5869
|
+
return pem.replace(/\r\n/g, "\n").trimEnd() + "\n";
|
|
5870
|
+
}
|
|
5871
|
+
async function writePrivate(path, contents) {
|
|
5872
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
5873
|
+
try {
|
|
5874
|
+
await writeFile(tmp, contents, { mode: 384 });
|
|
5875
|
+
await rename(tmp, path);
|
|
5876
|
+
} catch (error) {
|
|
5877
|
+
await unlink(tmp).catch(() => {});
|
|
5878
|
+
throw error;
|
|
5879
|
+
}
|
|
5880
|
+
}
|
|
5881
|
+
//#endregion
|
|
5386
5882
|
//#region src/index.ts
|
|
5387
5883
|
/**
|
|
5388
5884
|
* dsh-deeppilot — data bridge between the DSH host and DeepPilot
|
|
5389
|
-
* clients. Owns independent, narrowly routed LAN
|
|
5390
|
-
*
|
|
5391
|
-
*
|
|
5885
|
+
* clients. Owns independent, narrowly routed LAN (TLS-only, pinned by paired
|
|
5886
|
+
* devices) and loopback Funnel-origin listeners. The web UI and the rest of
|
|
5887
|
+
* DSH's API are never exposed by these listeners.
|
|
5392
5888
|
*
|
|
5393
5889
|
* Data plane: an in-process HostBridge consumes a local compatibility façade
|
|
5394
5890
|
* over DSH 0.1.2 Session/Workspace controllers, mirrors session summaries,
|
|
@@ -5399,7 +5895,7 @@ function closeServer(server) {
|
|
|
5399
5895
|
* Swift models mirror that v2 contract.
|
|
5400
5896
|
*/
|
|
5401
5897
|
const name = "deeppilot";
|
|
5402
|
-
/** No
|
|
5898
|
+
/** No web-service requirement: the plugin owns its own transport listeners. */
|
|
5403
5899
|
const inject = [];
|
|
5404
5900
|
const SERVER_VERSION = readOwnPackageVersion();
|
|
5405
5901
|
const MAX_CLIENT_CONNECTIONS = 16;
|
|
@@ -5818,8 +6314,27 @@ function apply(ctx, options) {
|
|
|
5818
6314
|
}
|
|
5819
6315
|
}
|
|
5820
6316
|
});
|
|
6317
|
+
const liveActivityPush = new LiveActivityPushManager(() => auth.devices ?? void 0, async (deviceToken, environment, notification) => {
|
|
6318
|
+
if (!enabledNow()) return { outcome: "failed" };
|
|
6319
|
+
const resolved = resolvePushConfig(currentConfig());
|
|
6320
|
+
if (!resolved.ok) return { outcome: "failed" };
|
|
6321
|
+
const send = await senderFor(resolved.value);
|
|
6322
|
+
if (!send) return { outcome: "failed" };
|
|
6323
|
+
const result = await send({
|
|
6324
|
+
deviceToken,
|
|
6325
|
+
environment,
|
|
6326
|
+
notification
|
|
6327
|
+
});
|
|
6328
|
+
if (resolved.value.kind === "relay" && result.reason === "HTTP 401" && enrollmentCell.token === resolved.value.token && enrollmentCell.enrollKey) {
|
|
6329
|
+
enrollmentCell.token = void 0;
|
|
6330
|
+
persistEnrollment();
|
|
6331
|
+
await ensureRelayEnrolled(resolved.value.url);
|
|
6332
|
+
}
|
|
6333
|
+
return result;
|
|
6334
|
+
});
|
|
5821
6335
|
const makePushOutlet = () => ({
|
|
5822
6336
|
widgetChanged: () => widgetPush.changed(),
|
|
6337
|
+
liveActivityChanged: (sessions) => liveActivityPush.changed(sessions),
|
|
5823
6338
|
isAvailable: () => {
|
|
5824
6339
|
const resolved = resolvePushConfig(currentConfig());
|
|
5825
6340
|
if (!resolved.ok) return false;
|
|
@@ -5911,9 +6426,27 @@ function apply(ctx, options) {
|
|
|
5911
6426
|
endpoints: [],
|
|
5912
6427
|
updatedAt: Date.now()
|
|
5913
6428
|
};
|
|
6429
|
+
let lanTlsIdentity;
|
|
6430
|
+
let tlsIdentityRegenerated = false;
|
|
6431
|
+
const loadLanTls = () => {
|
|
6432
|
+
lanTlsIdentity ??= (async () => {
|
|
6433
|
+
await ready;
|
|
6434
|
+
const identity = await loadOrCreateLanTlsIdentity(join(dataDir, "lan-tls"));
|
|
6435
|
+
if (identity.regenerated) {
|
|
6436
|
+
tlsIdentityRegenerated = true;
|
|
6437
|
+
log(`LAN TLS identity created: fingerprint=${identity.fingerprint}; previously paired LAN devices must pair again`);
|
|
6438
|
+
} else if (identity.resigned) log(`LAN TLS certificate renewed (fingerprint unchanged: ${identity.fingerprint})`);
|
|
6439
|
+
return identity;
|
|
6440
|
+
})();
|
|
6441
|
+
lanTlsIdentity.catch(() => {
|
|
6442
|
+
lanTlsIdentity = void 0;
|
|
6443
|
+
});
|
|
6444
|
+
return lanTlsIdentity;
|
|
6445
|
+
};
|
|
5914
6446
|
const localStatus = (addresses) => ({
|
|
5915
6447
|
...localState,
|
|
5916
|
-
endpoints: localState.phase === "online" ? localEndpointURLs(addresses, localState.port) : []
|
|
6448
|
+
endpoints: localState.phase === "online" ? localEndpointURLs(addresses, localState.port) : [],
|
|
6449
|
+
...tlsIdentityRegenerated ? { tlsIdentityRegenerated: true } : {}
|
|
5917
6450
|
});
|
|
5918
6451
|
const remoteStatus = () => remoteSupervisor?.status() ?? {
|
|
5919
6452
|
provider: "tailscale-funnel",
|
|
@@ -6113,13 +6646,15 @@ function apply(ctx, options) {
|
|
|
6113
6646
|
}, Date.now());
|
|
6114
6647
|
authRateLimiter.recordSuccess(source);
|
|
6115
6648
|
log(`device paired id=${auditLabel(record.deviceId)} source=${auditLabel(source)}`);
|
|
6649
|
+
const tlsFingerprint = req.socket.encrypted === true ? (await lanTlsIdentity)?.fingerprint : void 0;
|
|
6116
6650
|
res.statusCode = 201;
|
|
6117
6651
|
res.end(JSON.stringify({
|
|
6118
6652
|
ok: true,
|
|
6119
6653
|
v: 2,
|
|
6120
6654
|
deviceId: record.deviceId,
|
|
6121
6655
|
audience,
|
|
6122
|
-
scopes: record.scopes ?? []
|
|
6656
|
+
scopes: record.scopes ?? [],
|
|
6657
|
+
...tlsFingerprint ? { tlsFingerprint } : {}
|
|
6123
6658
|
}));
|
|
6124
6659
|
} catch (error) {
|
|
6125
6660
|
res.statusCode = error instanceof SyntaxError || error instanceof TypeError ? 400 : 503;
|
|
@@ -6272,9 +6807,15 @@ function apply(ctx, options) {
|
|
|
6272
6807
|
endpoints: [],
|
|
6273
6808
|
updatedAt: Date.now()
|
|
6274
6809
|
};
|
|
6275
|
-
|
|
6276
|
-
localServer = server;
|
|
6810
|
+
let server;
|
|
6277
6811
|
try {
|
|
6812
|
+
const tls = await loadLanTls();
|
|
6813
|
+
if (localDisposed || appliedLocalKey !== nextKey) return;
|
|
6814
|
+
server = createPhoneServer(phoneHandlers, {
|
|
6815
|
+
key: tls.key,
|
|
6816
|
+
cert: tls.cert
|
|
6817
|
+
});
|
|
6818
|
+
localServer = server;
|
|
6278
6819
|
await listen(server, next.port, "0.0.0.0");
|
|
6279
6820
|
if (localDisposed || localServer !== server) {
|
|
6280
6821
|
await closeServer(server);
|
|
@@ -6284,12 +6825,15 @@ function apply(ctx, options) {
|
|
|
6284
6825
|
phase: "online",
|
|
6285
6826
|
port: next.port,
|
|
6286
6827
|
endpoints: [],
|
|
6828
|
+
tlsFingerprint: tls.fingerprint,
|
|
6287
6829
|
updatedAt: Date.now()
|
|
6288
6830
|
};
|
|
6289
|
-
log(`local transport listening on 0.0.0.0:${next.port}`);
|
|
6831
|
+
log(`local transport listening on https://0.0.0.0:${next.port} (tls fingerprint ${tls.fingerprint})`);
|
|
6290
6832
|
} catch (error) {
|
|
6291
|
-
if (
|
|
6292
|
-
|
|
6833
|
+
if (server !== void 0) {
|
|
6834
|
+
if (localServer === server) localServer = void 0;
|
|
6835
|
+
await closeServer(server);
|
|
6836
|
+
}
|
|
6293
6837
|
const message = localListenError(error, next.port);
|
|
6294
6838
|
localState = {
|
|
6295
6839
|
phase: "error",
|
|
@@ -6422,30 +6966,6 @@ function apply(ctx, options) {
|
|
|
6422
6966
|
bridge.dispose();
|
|
6423
6967
|
}, "deeppilot: host streams");
|
|
6424
6968
|
});
|
|
6425
|
-
ctx.inject(["webServer"], (sub) => {
|
|
6426
|
-
const webCtx = sub;
|
|
6427
|
-
const web = webCtx.webServer;
|
|
6428
|
-
if (!web) {
|
|
6429
|
-
log("webServer service absent; 3080 compatibility routes unavailable (independent transports remain active)");
|
|
6430
|
-
return;
|
|
6431
|
-
}
|
|
6432
|
-
webCtx.effect(() => web.registerUpgrade({
|
|
6433
|
-
path: "/phone",
|
|
6434
|
-
handler: handleUpgrade
|
|
6435
|
-
}), "deeppilot: /phone WebSocket");
|
|
6436
|
-
webCtx.effect(() => web.register({
|
|
6437
|
-
kind: "exact",
|
|
6438
|
-
path: "/phone/health",
|
|
6439
|
-
handler: handleHealth
|
|
6440
|
-
}), "deeppilot: /phone/health");
|
|
6441
|
-
webCtx.effect(() => web.register({
|
|
6442
|
-
kind: "exact",
|
|
6443
|
-
path: "/phone/pair",
|
|
6444
|
-
handler: handlePair
|
|
6445
|
-
}), "deeppilot: /phone/pair");
|
|
6446
|
-
if (enabledNow()) log("/phone compatibility routes registered on DSH web server");
|
|
6447
|
-
else log("bridge disabled; /phone refuses connections until re-enabled");
|
|
6448
|
-
});
|
|
6449
6969
|
ctx.effect(() => async () => {
|
|
6450
6970
|
closeAllConnections();
|
|
6451
6971
|
const bridge = state.bridge;
|
|
@@ -6455,6 +6975,7 @@ function apply(ctx, options) {
|
|
|
6455
6975
|
cachedSender = void 0;
|
|
6456
6976
|
updateChecker.dispose();
|
|
6457
6977
|
widgetPush.dispose();
|
|
6978
|
+
liveActivityPush.dispose();
|
|
6458
6979
|
const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
|
|
6459
6980
|
await Promise.allSettled([
|
|
6460
6981
|
enrollmentWriteTail,
|