dsh-deeppilot 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/README.zh-CN.md +14 -4
- package/bin/SHA256SUMS +6 -1
- 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/lib/client.js +14 -3
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +47 -3
- package/lib/index.js +541 -47
- package/lib/index.js.map +1 -1
- package/package.json +3 -1
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { createServer } from "node:http";
|
|
2
3
|
import { createPrivateKey, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
|
|
3
4
|
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
@@ -11,6 +12,7 @@ import { connect } from "node:http2";
|
|
|
11
12
|
import { spawn } from "node:child_process";
|
|
12
13
|
import { constants } from "node:fs";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { request } from "node:https";
|
|
14
16
|
//#region src/token.ts
|
|
15
17
|
/** Expand a leading ~ using the process home directory. */
|
|
16
18
|
function expandHome(p) {
|
|
@@ -362,6 +364,9 @@ var BridgeConnection = class {
|
|
|
362
364
|
sessions: this.deps.bridge.listSessions()
|
|
363
365
|
}, env.id);
|
|
364
366
|
return;
|
|
367
|
+
case "c2s.pending.list":
|
|
368
|
+
this.send("s2c.pending.snapshot", this.deps.bridge.pendingSnapshot(), env.id);
|
|
369
|
+
return;
|
|
365
370
|
case "c2s.workspaces.list": {
|
|
366
371
|
if (!this.deps.bridge.capabilities.projectSelection) return this.fail(env.id, "E_UNSUPPORTED", "project selection unavailable on this host version");
|
|
367
372
|
const result = await this.deps.bridge.listWorkspaces();
|
|
@@ -395,10 +400,15 @@ var BridgeConnection = class {
|
|
|
395
400
|
}
|
|
396
401
|
case "c2s.session.open": {
|
|
397
402
|
const p = env.payload;
|
|
398
|
-
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
399
|
-
|
|
400
|
-
this.
|
|
401
|
-
|
|
403
|
+
if (!p?.sessionId || typeof p.sessionId !== "string") return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
404
|
+
const sessionId = p.sessionId;
|
|
405
|
+
this.openSessions.add(sessionId);
|
|
406
|
+
this.deps.bridge.markSinkOpen(this, sessionId);
|
|
407
|
+
if (!await this.deps.bridge.openSession(this, sessionId, p.tailCount ?? 100)) {
|
|
408
|
+
this.openSessions.delete(sessionId);
|
|
409
|
+
this.deps.bridge.markSinkClosed(this, sessionId);
|
|
410
|
+
return this.fail(env.id, "E_NOT_FOUND", "session history unavailable");
|
|
411
|
+
}
|
|
402
412
|
return;
|
|
403
413
|
}
|
|
404
414
|
case "c2s.session.close": {
|
|
@@ -526,21 +536,23 @@ var BridgeConnection = class {
|
|
|
526
536
|
});
|
|
527
537
|
}
|
|
528
538
|
const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images);
|
|
529
|
-
if (userSeq
|
|
530
|
-
this.send("s2c.ack", { userSeq }, env.id);
|
|
539
|
+
if (!userSeq.ok) return this.fail(env.id, managementErrorCode(userSeq.kind), userSeq.message);
|
|
540
|
+
this.send("s2c.ack", { userSeq: userSeq.value }, env.id);
|
|
531
541
|
return;
|
|
532
542
|
}
|
|
533
543
|
case "c2s.approval.respond": {
|
|
534
544
|
const p = env.payload;
|
|
535
545
|
if (!p?.requestId || p.decision !== "allow" && p.decision !== "deny") return this.fail(env.id, "E_PROTOCOL", "requestId and decision required");
|
|
536
|
-
|
|
546
|
+
const outcome = await this.deps.bridge.respondApproval(p.requestId, p.decision, typeof p.reason === "string" ? p.reason : void 0);
|
|
547
|
+
if (!outcome.ok) return this.fail(env.id, pendingResponseErrorCode(outcome.reason), pendingResponseMessage("approval", outcome.reason));
|
|
537
548
|
this.send("s2c.ack", {}, env.id);
|
|
538
549
|
return;
|
|
539
550
|
}
|
|
540
551
|
case "c2s.question.respond": {
|
|
541
552
|
const p = env.payload;
|
|
542
553
|
if (!p?.requestId || !Array.isArray(p.answers)) return this.fail(env.id, "E_PROTOCOL", "requestId and answers required");
|
|
543
|
-
|
|
554
|
+
const outcome = await this.deps.bridge.respondQuestion(p.requestId, p.answers);
|
|
555
|
+
if (!outcome.ok) return this.fail(env.id, pendingResponseErrorCode(outcome.reason), pendingResponseMessage("question", outcome.reason));
|
|
544
556
|
this.send("s2c.ack", {}, env.id);
|
|
545
557
|
return;
|
|
546
558
|
}
|
|
@@ -612,6 +624,23 @@ var BridgeConnection = class {
|
|
|
612
624
|
}
|
|
613
625
|
}
|
|
614
626
|
};
|
|
627
|
+
/** Error code for a failed approval/question response outcome. */
|
|
628
|
+
function pendingResponseErrorCode(reason) {
|
|
629
|
+
switch (reason) {
|
|
630
|
+
case "not-pending": return "E_NOT_FOUND";
|
|
631
|
+
case "bad-response": return "E_PROTOCOL";
|
|
632
|
+
case "transport": return "E_INTERNAL";
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/** Human-readable failure detail; `question not pending` must only ever mean
|
|
636
|
+
* "nothing pending", never "the host rejected the answer". */
|
|
637
|
+
function pendingResponseMessage(kind, reason) {
|
|
638
|
+
switch (reason) {
|
|
639
|
+
case "not-pending": return kind + " not pending";
|
|
640
|
+
case "bad-response": return kind + " answer rejected by host: answer does not match the asked questions";
|
|
641
|
+
case "transport": return "host connection failed while answering " + kind;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
615
644
|
function managementErrorCode(kind) {
|
|
616
645
|
switch (kind) {
|
|
617
646
|
case "unsupported": return "E_UNSUPPORTED";
|
|
@@ -677,6 +706,7 @@ var HostBridge = class {
|
|
|
677
706
|
replay: true,
|
|
678
707
|
approvals: true,
|
|
679
708
|
questions: true,
|
|
709
|
+
pendingSnapshot: true,
|
|
680
710
|
models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
|
|
681
711
|
sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
|
|
682
712
|
projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
|
|
@@ -698,7 +728,7 @@ var HostBridge = class {
|
|
|
698
728
|
/** Whether the ring still holds everything after the cursor. */
|
|
699
729
|
canResumeFrom(cursor) {
|
|
700
730
|
const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
|
|
701
|
-
return cursor + 1 >= oldest;
|
|
731
|
+
return cursor <= this.cursor && cursor + 1 >= oldest;
|
|
702
732
|
}
|
|
703
733
|
sinkSessions = /* @__PURE__ */ new Map();
|
|
704
734
|
lastAssistantText = /* @__PURE__ */ new Map();
|
|
@@ -1040,6 +1070,27 @@ var HostBridge = class {
|
|
|
1040
1070
|
listSessions() {
|
|
1041
1071
|
return [...this.summaries.values()].sort((a, b) => b.lastActivityTs - a.lastActivityTs);
|
|
1042
1072
|
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Complete transient interaction state. Unlike the replay ring, this remains
|
|
1075
|
+
* authoritative after a long disconnect and is rehydrated by apiProxy's mux
|
|
1076
|
+
* stream when the bridge itself restarts.
|
|
1077
|
+
*/
|
|
1078
|
+
pendingSnapshot() {
|
|
1079
|
+
return {
|
|
1080
|
+
approvals: [...this.approvals.entries()].map(([requestId, pending]) => ({
|
|
1081
|
+
requestId,
|
|
1082
|
+
sessionId: pending.sessionId,
|
|
1083
|
+
toolName: pending.toolName,
|
|
1084
|
+
summary: pending.reason,
|
|
1085
|
+
riskLevel: riskOf(pending.toolName)
|
|
1086
|
+
})),
|
|
1087
|
+
questions: [...this.questions.entries()].map(([requestId, pending]) => ({
|
|
1088
|
+
requestId,
|
|
1089
|
+
sessionId: pending.sessionId,
|
|
1090
|
+
questions: Array.isArray(pending.questions) ? pending.questions : []
|
|
1091
|
+
}))
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1043
1094
|
/** Tail history for an opened session; pushes s2c.session.tail to the sink. */
|
|
1044
1095
|
async openSession(sink, sessionId, tailCount) {
|
|
1045
1096
|
try {
|
|
@@ -1445,22 +1496,38 @@ var HostBridge = class {
|
|
|
1445
1496
|
clientTimeZone: localTimeZone()
|
|
1446
1497
|
}
|
|
1447
1498
|
});
|
|
1448
|
-
if (!response.result
|
|
1499
|
+
if (!response.result) return {
|
|
1500
|
+
ok: false,
|
|
1501
|
+
kind: "internal",
|
|
1502
|
+
message: "prompt returned no result"
|
|
1503
|
+
};
|
|
1504
|
+
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
1449
1505
|
const row = this.summaries.get(sessionId);
|
|
1450
1506
|
if (row) {
|
|
1451
1507
|
row.lastActivityTs = Date.now();
|
|
1452
1508
|
this.pushSummary(row);
|
|
1453
1509
|
}
|
|
1454
|
-
return
|
|
1455
|
-
|
|
1456
|
-
|
|
1510
|
+
return {
|
|
1511
|
+
ok: true,
|
|
1512
|
+
value: Date.now()
|
|
1513
|
+
};
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
return {
|
|
1516
|
+
ok: false,
|
|
1517
|
+
kind: "internal",
|
|
1518
|
+
message: String(error)
|
|
1519
|
+
};
|
|
1457
1520
|
}
|
|
1458
1521
|
}
|
|
1459
|
-
async respondApproval(requestId, decision) {
|
|
1522
|
+
async respondApproval(requestId, decision, reason) {
|
|
1460
1523
|
const pending = this.approvals.get(requestId);
|
|
1461
|
-
if (!pending) return
|
|
1524
|
+
if (!pending) return {
|
|
1525
|
+
ok: false,
|
|
1526
|
+
reason: "not-pending"
|
|
1527
|
+
};
|
|
1462
1528
|
this.approvals.delete(requestId);
|
|
1463
1529
|
const outcome = decision === "allow" ? "allowed-once" : "rejected";
|
|
1530
|
+
const denialReason = typeof reason === "string" ? reason.trim().slice(0, 500) : "";
|
|
1464
1531
|
try {
|
|
1465
1532
|
const receipt = await this.apiProxy.respond({
|
|
1466
1533
|
type: "client-response",
|
|
@@ -1470,21 +1537,35 @@ var HostBridge = class {
|
|
|
1470
1537
|
value: {
|
|
1471
1538
|
sessionId: pending.sessionId,
|
|
1472
1539
|
approvalId: requestId,
|
|
1473
|
-
outcome
|
|
1540
|
+
outcome,
|
|
1541
|
+
...denialReason.length > 0 ? { reason: denialReason } : {}
|
|
1474
1542
|
}
|
|
1475
1543
|
}
|
|
1476
1544
|
});
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1545
|
+
if (!Boolean(receipt?.accepted)) {
|
|
1546
|
+
const failure = receiptFailureReason(receipt);
|
|
1547
|
+
if (failure !== "not-pending" && !this.approvals.has(requestId)) this.approvals.set(requestId, pending);
|
|
1548
|
+
return {
|
|
1549
|
+
ok: false,
|
|
1550
|
+
reason: failure
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
this.bumpPendingFlags(pending.sessionId);
|
|
1554
|
+
return { ok: true };
|
|
1480
1555
|
} catch {
|
|
1481
1556
|
if (!this.approvals.has(requestId)) this.approvals.set(requestId, pending);
|
|
1482
|
-
return
|
|
1557
|
+
return {
|
|
1558
|
+
ok: false,
|
|
1559
|
+
reason: "transport"
|
|
1560
|
+
};
|
|
1483
1561
|
}
|
|
1484
1562
|
}
|
|
1485
1563
|
async respondQuestion(requestId, answers) {
|
|
1486
1564
|
const pending = this.questions.get(requestId);
|
|
1487
|
-
if (!pending) return
|
|
1565
|
+
if (!pending) return {
|
|
1566
|
+
ok: false,
|
|
1567
|
+
reason: "not-pending"
|
|
1568
|
+
};
|
|
1488
1569
|
this.questions.delete(requestId);
|
|
1489
1570
|
try {
|
|
1490
1571
|
const receipt = await this.apiProxy.respond({
|
|
@@ -1494,19 +1575,65 @@ var HostBridge = class {
|
|
|
1494
1575
|
ok: true,
|
|
1495
1576
|
value: {
|
|
1496
1577
|
sessionId: pending.sessionId,
|
|
1497
|
-
answer: { answers }
|
|
1578
|
+
answer: { answers: normalizeAnswerItems(answers, pending.questions) }
|
|
1498
1579
|
}
|
|
1499
1580
|
}
|
|
1500
1581
|
});
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1582
|
+
if (!Boolean(receipt?.accepted)) {
|
|
1583
|
+
const failure = receiptFailureReason(receipt);
|
|
1584
|
+
if (failure !== "not-pending" && !this.questions.has(requestId)) this.questions.set(requestId, pending);
|
|
1585
|
+
return {
|
|
1586
|
+
ok: false,
|
|
1587
|
+
reason: failure
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
this.bumpPendingFlags(pending.sessionId);
|
|
1591
|
+
return { ok: true };
|
|
1504
1592
|
} catch {
|
|
1505
1593
|
if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
|
|
1506
|
-
return
|
|
1594
|
+
return {
|
|
1595
|
+
ok: false,
|
|
1596
|
+
reason: "transport"
|
|
1597
|
+
};
|
|
1507
1598
|
}
|
|
1508
1599
|
}
|
|
1509
1600
|
};
|
|
1601
|
+
/**
|
|
1602
|
+
* The host validates question answers strictly (core dsh-user-questions via
|
|
1603
|
+
* apiProxy): a present-but-empty `custom` fails `matchesQuestions`, and a
|
|
1604
|
+
* single-select question rejects `custom` combined with a selection. Clients
|
|
1605
|
+
* may send lenient shapes (the phone historically always attached
|
|
1606
|
+
* `"custom": ""`, which made EVERY option-only answer fail), so normalize to
|
|
1607
|
+
* exactly what the host accepts before forwarding.
|
|
1608
|
+
*/
|
|
1609
|
+
function normalizeAnswerItems(raw, questions) {
|
|
1610
|
+
if (!Array.isArray(raw)) return [];
|
|
1611
|
+
const askedById = /* @__PURE__ */ new Map();
|
|
1612
|
+
if (Array.isArray(questions)) {
|
|
1613
|
+
for (const q of questions) if (typeof q === "object" && q !== null && typeof q.id === "string") askedById.set(q.id, q);
|
|
1614
|
+
}
|
|
1615
|
+
const items = [];
|
|
1616
|
+
for (const entry of raw) {
|
|
1617
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1618
|
+
const r = entry;
|
|
1619
|
+
if (typeof r.id !== "string") continue;
|
|
1620
|
+
const selected = [...new Set(Array.isArray(r.selected) ? r.selected.filter((s) => typeof s === "string") : [])];
|
|
1621
|
+
const customText = typeof r.custom === "string" ? r.custom : "";
|
|
1622
|
+
let custom;
|
|
1623
|
+
if (customText.trim().length > 0) custom = customText;
|
|
1624
|
+
if (custom !== void 0 && selected.length > 0 && askedById.get(r.id)?.multiSelect !== true) custom = void 0;
|
|
1625
|
+
items.push({
|
|
1626
|
+
id: r.id,
|
|
1627
|
+
selected,
|
|
1628
|
+
...custom !== void 0 ? { custom } : {}
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
return items;
|
|
1632
|
+
}
|
|
1633
|
+
/** Map an apiProxy respond receipt onto the failure vocabulary. */
|
|
1634
|
+
function receiptFailureReason(receipt) {
|
|
1635
|
+
return receipt?.reason === "not-pending" ? "not-pending" : "bad-response";
|
|
1636
|
+
}
|
|
1510
1637
|
function clampTail(n) {
|
|
1511
1638
|
if (!Number.isFinite(n)) return 100;
|
|
1512
1639
|
return Math.max(10, Math.min(500, Math.floor(n)));
|
|
@@ -1598,9 +1725,10 @@ function projectEvent(sessionId, event) {
|
|
|
1598
1725
|
kind: "message.final",
|
|
1599
1726
|
data: {
|
|
1600
1727
|
seq: event.seq,
|
|
1601
|
-
role:
|
|
1728
|
+
role: userRoleOf(event.data),
|
|
1602
1729
|
text: messageText(event.data),
|
|
1603
1730
|
...attachmentProjection(event.data),
|
|
1731
|
+
...contextProjectionOf(event.data),
|
|
1604
1732
|
ts: tsOf(event)
|
|
1605
1733
|
}
|
|
1606
1734
|
};
|
|
@@ -1644,27 +1772,99 @@ function projectEvent(sessionId, event) {
|
|
|
1644
1772
|
tool: {
|
|
1645
1773
|
name: String(data?.name ?? "tool"),
|
|
1646
1774
|
state: "running",
|
|
1647
|
-
summary: summarizeArgs(data?.arguments)
|
|
1775
|
+
summary: summarizeArgs(data?.arguments),
|
|
1776
|
+
...data?.callId ? { callId: String(data.callId) } : {}
|
|
1648
1777
|
},
|
|
1649
1778
|
ts: tsOf(event)
|
|
1650
1779
|
}
|
|
1651
1780
|
};
|
|
1652
1781
|
}
|
|
1653
|
-
case "tool/result":
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1782
|
+
case "tool/result": {
|
|
1783
|
+
const data = event.data;
|
|
1784
|
+
return {
|
|
1785
|
+
kind: "tool.end",
|
|
1786
|
+
data: {
|
|
1787
|
+
seq: event.seq,
|
|
1788
|
+
role: "tool",
|
|
1789
|
+
ok: !event.data || data?.error === void 0,
|
|
1790
|
+
...data?.callId ? { callId: String(data.callId) } : {},
|
|
1791
|
+
ts: tsOf(event)
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1662
1795
|
default: return null;
|
|
1663
1796
|
}
|
|
1664
1797
|
}
|
|
1665
1798
|
function tsOf(event) {
|
|
1666
1799
|
return typeof event.time === "number" ? event.time : Date.now();
|
|
1667
1800
|
}
|
|
1801
|
+
/** Read the durable message source off one user/message payload. Handles both
|
|
1802
|
+
* bare-message payloads and older `{message: {...}}` wrappers; undefined when
|
|
1803
|
+
* the shape carries no readable source (legacy hosts). */
|
|
1804
|
+
function userMessageSource(data) {
|
|
1805
|
+
if (!data || typeof data !== "object") return void 0;
|
|
1806
|
+
const obj = data;
|
|
1807
|
+
if (obj.source && typeof obj.source === "object") return obj.source;
|
|
1808
|
+
if (obj.message && typeof obj.message === "object" && obj.message.source && typeof obj.message.source === "object") return obj.message.source;
|
|
1809
|
+
}
|
|
1810
|
+
/** Wire role for one user/message payload. A payload without any readable
|
|
1811
|
+
* source degrades to 'user' so history written by older hosts stays visible;
|
|
1812
|
+
* a present source follows the host's own trajectory rule — anything whose
|
|
1813
|
+
* `kind` is not 'user' is injected context and projects as 'system'. */
|
|
1814
|
+
function userRoleOf(data) {
|
|
1815
|
+
const source = userMessageSource(data);
|
|
1816
|
+
if (!source) return "user";
|
|
1817
|
+
return source.kind === "user" ? "user" : "system";
|
|
1818
|
+
}
|
|
1819
|
+
/** Producer name of one injected-context source, mirroring how the DSH client
|
|
1820
|
+
* runtime derives its trajectory label: plugin name, skill name, instruction
|
|
1821
|
+
* paths, session-reference labels, or the raw kind as fallback. */
|
|
1822
|
+
function contextLabelOf(source) {
|
|
1823
|
+
const kind = typeof source.kind === "string" ? source.kind : "";
|
|
1824
|
+
const joined = (member) => {
|
|
1825
|
+
const list = source[member];
|
|
1826
|
+
if (!Array.isArray(list)) return void 0;
|
|
1827
|
+
const names = list.flatMap((entry) => {
|
|
1828
|
+
if (!entry || typeof entry !== "object") return [];
|
|
1829
|
+
const record = entry;
|
|
1830
|
+
return [typeof record.label === "string" ? record.label : typeof record.path === "string" ? record.path : ""];
|
|
1831
|
+
}).filter((name) => name.length > 0);
|
|
1832
|
+
return names.length > 0 ? names.join(", ") : void 0;
|
|
1833
|
+
};
|
|
1834
|
+
switch (kind) {
|
|
1835
|
+
case "session-reference": return joined("references") ?? (kind || void 0);
|
|
1836
|
+
case "agent-instructions": return joined("changes") ?? (kind || void 0);
|
|
1837
|
+
case "plugin": return typeof source.plugin === "string" && source.plugin.length > 0 ? source.plugin : kind || void 0;
|
|
1838
|
+
case "skill-invocation": return typeof source.name === "string" && source.name.length > 0 ? source.name : kind || void 0;
|
|
1839
|
+
default: return kind || void 0;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
/** Semantic ContextForm declared by the producer ('snapshot', 'notice', …);
|
|
1843
|
+
* anything unrecognized stays undefined so clients render it opaque. */
|
|
1844
|
+
function contextFormOf(source) {
|
|
1845
|
+
if (typeof source.form !== "string" || source.form.length === 0) return void 0;
|
|
1846
|
+
return [
|
|
1847
|
+
"instructions",
|
|
1848
|
+
"catalog",
|
|
1849
|
+
"snapshot",
|
|
1850
|
+
"notice",
|
|
1851
|
+
"relay",
|
|
1852
|
+
"recall"
|
|
1853
|
+
].includes(source.form) ? source.form : void 0;
|
|
1854
|
+
}
|
|
1855
|
+
/** Optional `context` metadata for one system row; {} on user rows. */
|
|
1856
|
+
function contextProjectionOf(data) {
|
|
1857
|
+
if (userRoleOf(data) !== "system") return {};
|
|
1858
|
+
const source = userMessageSource(data);
|
|
1859
|
+
if (!source) return {};
|
|
1860
|
+
const label = contextLabelOf(source);
|
|
1861
|
+
const form = contextFormOf(source);
|
|
1862
|
+
if (!label && !form) return {};
|
|
1863
|
+
return { context: {
|
|
1864
|
+
...label ? { label } : {},
|
|
1865
|
+
...form ? { form } : {}
|
|
1866
|
+
} };
|
|
1867
|
+
}
|
|
1668
1868
|
/** Extract plain text from user/assistant message payloads across shapes. */
|
|
1669
1869
|
function messageText(data) {
|
|
1670
1870
|
if (typeof data === "string") return data;
|
|
@@ -1874,9 +2074,10 @@ function projectHistory(events) {
|
|
|
1874
2074
|
case "user/message":
|
|
1875
2075
|
messages.push({
|
|
1876
2076
|
...base,
|
|
1877
|
-
role:
|
|
2077
|
+
role: userRoleOf(event.data),
|
|
1878
2078
|
text: messageText(event.data),
|
|
1879
|
-
...attachmentProjection(event.data)
|
|
2079
|
+
...attachmentProjection(event.data),
|
|
2080
|
+
...contextProjectionOf(event.data)
|
|
1880
2081
|
});
|
|
1881
2082
|
break;
|
|
1882
2083
|
case "assistant/message": {
|
|
@@ -2123,9 +2324,13 @@ function parseReport(value) {
|
|
|
2123
2324
|
const lanAddresses = s.lanAddresses;
|
|
2124
2325
|
if (!Array.isArray(devices)) reject("devices");
|
|
2125
2326
|
if (!Array.isArray(lanAddresses) || lanAddresses.some((value) => typeof value !== "string")) reject("lanAddresses");
|
|
2327
|
+
const releaseUrl = s.releaseUrl;
|
|
2126
2328
|
return {
|
|
2127
2329
|
protocolVersion: num(s, "protocolVersion", "protocolVersion"),
|
|
2128
2330
|
serverVersion: str(s, "serverVersion", "serverVersion"),
|
|
2331
|
+
pluginVersion: str(s, "pluginVersion", "pluginVersion"),
|
|
2332
|
+
...s.updateAvailable === true ? { updateAvailable: true } : {},
|
|
2333
|
+
...typeof releaseUrl === "string" && releaseUrl.length > 0 ? { releaseUrl } : {},
|
|
2129
2334
|
enabled: bool(s, "enabled", "enabled"),
|
|
2130
2335
|
tokenPath: str(s, "tokenPath", "tokenPath"),
|
|
2131
2336
|
tokenReady: bool(s, "tokenReady", "tokenReady"),
|
|
@@ -2677,9 +2882,30 @@ function parseHelperEvent(line) {
|
|
|
2677
2882
|
return null;
|
|
2678
2883
|
}
|
|
2679
2884
|
}
|
|
2680
|
-
|
|
2885
|
+
/** Build the list of candidate locations for the embedded tunnel helper, in
|
|
2886
|
+
* priority order. The first existing executable wins at start() time. The
|
|
2887
|
+
* order matters: explicit config (handled by the caller) > npm install
|
|
2888
|
+
* layout > DSH-bundled layout > user data dir. */
|
|
2889
|
+
function bundledHelperCandidates() {
|
|
2681
2890
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
2682
|
-
|
|
2891
|
+
const pkgRoot = resolve(here, "..");
|
|
2892
|
+
const fileName = process.platform === "win32" ? "dsh-deeppilot-tunnel.exe" : "dsh-deeppilot-tunnel";
|
|
2893
|
+
const platformDir = `${process.platform}-${process.arch}`;
|
|
2894
|
+
const candidates = [];
|
|
2895
|
+
candidates.push(resolve(pkgRoot, "bin", platformDir, fileName));
|
|
2896
|
+
candidates.push(resolve(pkgRoot, "..", "..", "..", "node_modules", "dsh-deeppilot", "bin", platformDir, fileName));
|
|
2897
|
+
candidates.push(resolve(pkgRoot, "..", "..", "dsh-deeppilot", "bin", platformDir, fileName));
|
|
2898
|
+
candidates.push(resolve(pkgRoot, "..", "..", "..", "..", "node_modules", "dsh-deeppilot", "bin", platformDir, fileName));
|
|
2899
|
+
try {
|
|
2900
|
+
const resolved = createRequire(import.meta.url).resolve(`dsh-deeppilot/bin/${platformDir}/${fileName}`);
|
|
2901
|
+
if (!candidates.includes(resolved)) candidates.push(resolved);
|
|
2902
|
+
} catch {}
|
|
2903
|
+
const home = process.env.DSH_HOME?.trim() || process.env.HOME || process.env.USERPROFILE;
|
|
2904
|
+
if (home && home.length > 0) {
|
|
2905
|
+
const dataDir = resolve(home, ".dsh");
|
|
2906
|
+
candidates.push(join(dataDir, "deeppilot", "bin", platformDir, fileName));
|
|
2907
|
+
}
|
|
2908
|
+
return candidates;
|
|
2683
2909
|
}
|
|
2684
2910
|
/** Owns exactly one embedded tunnel helper and restarts it after failures. */
|
|
2685
2911
|
var RemoteSupervisor = class {
|
|
@@ -2702,21 +2928,42 @@ var RemoteSupervisor = class {
|
|
|
2702
2928
|
}
|
|
2703
2929
|
async start(originURL) {
|
|
2704
2930
|
if (!this.options.enabled || this.child !== void 0 || this.stopping) return;
|
|
2705
|
-
const helper = expandHome(this.options.helperPath ?? bundledHelperPath());
|
|
2706
2931
|
const statePath = expandHome(this.options.statePath);
|
|
2932
|
+
const configured = this.options.helperPath?.trim() ?? "";
|
|
2933
|
+
const candidates = configured ? [expandHome(configured)] : bundledHelperCandidates();
|
|
2934
|
+
let helper;
|
|
2935
|
+
let lastError;
|
|
2936
|
+
for (const candidate of candidates) try {
|
|
2937
|
+
await access(candidate, constants.X_OK);
|
|
2938
|
+
helper = candidate;
|
|
2939
|
+
break;
|
|
2940
|
+
} catch (error) {
|
|
2941
|
+
lastError = error;
|
|
2942
|
+
}
|
|
2943
|
+
if (helper === void 0) {
|
|
2944
|
+
if (this.stopping) return;
|
|
2945
|
+
const platform = `${process.platform}-${process.arch}`;
|
|
2946
|
+
const message = configured ? `embedded tunnel helper unavailable: ${configured}: ${String(lastError ?? "not found")}` : `embedded tunnel helper not found for ${platform} (tried: ${candidates.join(", ")}); set remote.helperPath to override`;
|
|
2947
|
+
this.setStatus({
|
|
2948
|
+
phase: "unavailable",
|
|
2949
|
+
message
|
|
2950
|
+
});
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2707
2953
|
try {
|
|
2708
|
-
await access(helper, constants.X_OK);
|
|
2709
2954
|
await mkdir(statePath, {
|
|
2710
2955
|
recursive: true,
|
|
2711
2956
|
mode: 448
|
|
2712
2957
|
});
|
|
2713
2958
|
} catch (error) {
|
|
2959
|
+
if (this.stopping) return;
|
|
2714
2960
|
this.setStatus({
|
|
2715
2961
|
phase: "unavailable",
|
|
2716
|
-
message: `
|
|
2962
|
+
message: `cannot create remote state dir: ${String(error)}`
|
|
2717
2963
|
});
|
|
2718
2964
|
return;
|
|
2719
2965
|
}
|
|
2966
|
+
if (this.stopping) return;
|
|
2720
2967
|
this.setStatus({
|
|
2721
2968
|
phase: "starting",
|
|
2722
2969
|
message: void 0
|
|
@@ -2875,6 +3122,203 @@ function localLANIPv4Addresses() {
|
|
|
2875
3122
|
return [...new Set(candidates.map(({ address }) => address))];
|
|
2876
3123
|
}
|
|
2877
3124
|
//#endregion
|
|
3125
|
+
//#region src/update-check.ts
|
|
3126
|
+
/**
|
|
3127
|
+
* Lightweight self-update check for dsh-deeppilot.
|
|
3128
|
+
*
|
|
3129
|
+
* On Host boot we ask the GitHub Releases API (REST) which is the latest
|
|
3130
|
+
* stable tag, compare it to the installed plugin version, and surface a
|
|
3131
|
+
* "newer release exists" flag plus the GitHub release URL through the
|
|
3132
|
+
* report Remote. The settings page renders one small line at the bottom;
|
|
3133
|
+
* a successful check is enough — no manual button, no persistent cache
|
|
3134
|
+
* (the host process is the lifetime of the answer).
|
|
3135
|
+
*
|
|
3136
|
+
* Deliberately no third-party dependency: we use {@link https.request}
|
|
3137
|
+
* directly to keep parity with the rest of the project (remote-supervisor
|
|
3138
|
+
* uses node:http, host-bridge uses ws, etc).
|
|
3139
|
+
*
|
|
3140
|
+
* Failure policy: every network/parse error collapses to a single log
|
|
3141
|
+
* line and the in-memory snapshot stays "unknown". The bridge must never
|
|
3142
|
+
* crash because GitHub rate-limited us, returned a 5xx, or the user is
|
|
3143
|
+
* offline.
|
|
3144
|
+
*/
|
|
3145
|
+
/** GitHub repo (no .git suffix). Public, unauthenticated, low rate limit. */
|
|
3146
|
+
const RELEASES_PATH = "/repos/Mars-Sea/dsh-deeppilot/releases";
|
|
3147
|
+
/** Hard ceiling on the network round-trip. The host must never hang. */
|
|
3148
|
+
const FETCH_TIMEOUT_MS = 8e3;
|
|
3149
|
+
/** Per-page limit. We only need the first stable release, but pre-releases
|
|
3150
|
+
* tend to be listed first; fetching 20 gives the comparator enough room. */
|
|
3151
|
+
const PER_PAGE = 20;
|
|
3152
|
+
function isPlainObject(value) {
|
|
3153
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3154
|
+
}
|
|
3155
|
+
function parseStableEntry(value) {
|
|
3156
|
+
if (!isPlainObject(value)) return null;
|
|
3157
|
+
const tag = value.tag_name;
|
|
3158
|
+
if (typeof tag !== "string") return null;
|
|
3159
|
+
if (value.prerelease === true || value.draft === true) return null;
|
|
3160
|
+
if (parseStableTag(tag) === null) return null;
|
|
3161
|
+
const url = value.html_url;
|
|
3162
|
+
return {
|
|
3163
|
+
tag,
|
|
3164
|
+
url: typeof url === "string" ? url : null
|
|
3165
|
+
};
|
|
3166
|
+
}
|
|
3167
|
+
/** Parse one stable release from the `tag_name` shape `vX.Y.Z` (the v is
|
|
3168
|
+
* optional; `1.2.3` is also accepted). Pre-release tags like `0.3.0-rc.1`
|
|
3169
|
+
* return null — the policy is "stable channel only". */
|
|
3170
|
+
function parseStableTag(tag) {
|
|
3171
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
|
|
3172
|
+
if (match === null) return null;
|
|
3173
|
+
return {
|
|
3174
|
+
major: Number(match[1]),
|
|
3175
|
+
minor: Number(match[2]),
|
|
3176
|
+
patch: Number(match[3])
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
/** Semver compare for X.Y.Z. Returns -1 / 0 / 1. */
|
|
3180
|
+
function compareSemver(a, b) {
|
|
3181
|
+
const pa = parseStableTag(a);
|
|
3182
|
+
const pb = parseStableTag(b);
|
|
3183
|
+
if (pa === null && pb === null) return 0;
|
|
3184
|
+
if (pa === null) return -1;
|
|
3185
|
+
if (pb === null) return 1;
|
|
3186
|
+
if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
|
|
3187
|
+
if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
|
|
3188
|
+
if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
|
|
3189
|
+
return 0;
|
|
3190
|
+
}
|
|
3191
|
+
/** Hit the GitHub Releases API. Resolves with the first stable release
|
|
3192
|
+
* GitHub returned, or null if the list contains no stable entries.
|
|
3193
|
+
* Network / parse errors reject — the caller is responsible for
|
|
3194
|
+
* collapsing them to a log line. */
|
|
3195
|
+
function fetchLatestStableRelease() {
|
|
3196
|
+
return new Promise((resolve, reject) => {
|
|
3197
|
+
const req = request({
|
|
3198
|
+
method: "GET",
|
|
3199
|
+
host: "api.github.com",
|
|
3200
|
+
path: `${RELEASES_PATH}?per_page=${PER_PAGE}`,
|
|
3201
|
+
headers: {
|
|
3202
|
+
"user-agent": "dsh-deeppilot-update-check",
|
|
3203
|
+
"accept": "application/vnd.github+json"
|
|
3204
|
+
}
|
|
3205
|
+
}, (res) => {
|
|
3206
|
+
const status = res.statusCode ?? 0;
|
|
3207
|
+
if (status < 200 || status >= 300) {
|
|
3208
|
+
res.resume();
|
|
3209
|
+
reject(/* @__PURE__ */ new Error(`github releases http ${status}`));
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
const chunks = [];
|
|
3213
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
3214
|
+
res.on("end", () => {
|
|
3215
|
+
try {
|
|
3216
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
3217
|
+
const parsed = JSON.parse(body);
|
|
3218
|
+
if (!Array.isArray(parsed)) {
|
|
3219
|
+
reject(/* @__PURE__ */ new Error("github releases: response is not an array"));
|
|
3220
|
+
return;
|
|
3221
|
+
}
|
|
3222
|
+
for (const entry of parsed) {
|
|
3223
|
+
const stable = parseStableEntry(entry);
|
|
3224
|
+
if (stable !== null) {
|
|
3225
|
+
resolve(stable);
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
resolve(null);
|
|
3230
|
+
} catch (error) {
|
|
3231
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
3232
|
+
}
|
|
3233
|
+
});
|
|
3234
|
+
res.on("error", (error) => reject(error));
|
|
3235
|
+
});
|
|
3236
|
+
req.setTimeout(FETCH_TIMEOUT_MS, () => {
|
|
3237
|
+
req.destroy(/* @__PURE__ */ new Error("github releases: timeout after 8000ms"));
|
|
3238
|
+
});
|
|
3239
|
+
req.on("error", (error) => reject(error));
|
|
3240
|
+
req.end();
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
3243
|
+
/**
|
|
3244
|
+
* Process-wide check state. Constructed once in `apply()`, queried
|
|
3245
|
+
* synchronously by the report snapshot. The check itself fires once in
|
|
3246
|
+
* the background shortly after boot; the answer lives for the lifetime
|
|
3247
|
+
* of the host process — re-running the page in the Web UI does not
|
|
3248
|
+
* trigger another network call.
|
|
3249
|
+
*/
|
|
3250
|
+
var UpdateChecker = class {
|
|
3251
|
+
log;
|
|
3252
|
+
currentVersion;
|
|
3253
|
+
fetchImpl;
|
|
3254
|
+
initialDelayMs;
|
|
3255
|
+
snapshot;
|
|
3256
|
+
inflight = null;
|
|
3257
|
+
constructor(options) {
|
|
3258
|
+
this.log = options.log;
|
|
3259
|
+
this.currentVersion = options.currentVersion;
|
|
3260
|
+
this.fetchImpl = options.fetchImpl ?? fetchLatestStableRelease;
|
|
3261
|
+
this.initialDelayMs = options.initialDelayMs ?? 2e3;
|
|
3262
|
+
this.snapshot = {
|
|
3263
|
+
currentVersion: this.currentVersion,
|
|
3264
|
+
available: false,
|
|
3265
|
+
releaseUrl: null,
|
|
3266
|
+
latestVersion: null
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
/** Return the current in-memory snapshot — safe to call from any host
|
|
3270
|
+
* thread. Never throws, never awaits. */
|
|
3271
|
+
get() {
|
|
3272
|
+
return this.snapshot;
|
|
3273
|
+
}
|
|
3274
|
+
/**
|
|
3275
|
+
* Schedule one background refresh after the configured initial delay.
|
|
3276
|
+
* Used by the plugin entry to do the first check without blocking boot.
|
|
3277
|
+
*/
|
|
3278
|
+
scheduleInitial() {
|
|
3279
|
+
if (this.initialDelayMs <= 0) {
|
|
3280
|
+
this.runOnce();
|
|
3281
|
+
return;
|
|
3282
|
+
}
|
|
3283
|
+
const timer = setTimeout(() => {
|
|
3284
|
+
this.runOnce();
|
|
3285
|
+
}, this.initialDelayMs);
|
|
3286
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
3287
|
+
}
|
|
3288
|
+
async runOnce() {
|
|
3289
|
+
if (this.inflight !== null) {
|
|
3290
|
+
await this.inflight;
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
const task = (async () => {
|
|
3294
|
+
try {
|
|
3295
|
+
const stable = await this.fetchImpl();
|
|
3296
|
+
if (stable === null) return;
|
|
3297
|
+
if (compareSemver(stable.tag, this.currentVersion) > 0) this.snapshot = {
|
|
3298
|
+
currentVersion: this.currentVersion,
|
|
3299
|
+
available: true,
|
|
3300
|
+
releaseUrl: stable.url,
|
|
3301
|
+
latestVersion: stable.tag
|
|
3302
|
+
};
|
|
3303
|
+
else this.snapshot = {
|
|
3304
|
+
currentVersion: this.currentVersion,
|
|
3305
|
+
available: false,
|
|
3306
|
+
releaseUrl: null,
|
|
3307
|
+
latestVersion: null
|
|
3308
|
+
};
|
|
3309
|
+
} catch (error) {
|
|
3310
|
+
this.log("update check failed: " + (error instanceof Error ? error.message : String(error)));
|
|
3311
|
+
}
|
|
3312
|
+
})();
|
|
3313
|
+
this.inflight = task.finally(() => {
|
|
3314
|
+
this.inflight = null;
|
|
3315
|
+
});
|
|
3316
|
+
return this.inflight;
|
|
3317
|
+
}
|
|
3318
|
+
/** No-op kept for API symmetry with the host lifecycle wiring. */
|
|
3319
|
+
dispose() {}
|
|
3320
|
+
};
|
|
3321
|
+
//#endregion
|
|
2878
3322
|
//#region src/index.ts
|
|
2879
3323
|
/**
|
|
2880
3324
|
* dsh-deeppilot — data bridge between the DSH host and DeepPilot
|
|
@@ -2933,7 +3377,7 @@ const Config = z.object({
|
|
|
2933
3377
|
relayToken: ""
|
|
2934
3378
|
})
|
|
2935
3379
|
});
|
|
2936
|
-
const SERVER_VERSION =
|
|
3380
|
+
const SERVER_VERSION = readOwnPackageVersion();
|
|
2937
3381
|
const MAX_CLIENT_CONNECTIONS = 16;
|
|
2938
3382
|
/**
|
|
2939
3383
|
* Single-frame bound. Covers the protocol maximum (4 × 8 MB base64 images
|
|
@@ -2941,6 +3385,22 @@ const MAX_CLIENT_CONNECTIONS = 16;
|
|
|
2941
3385
|
* pre-hello buffering far below ws's 100 MiB default.
|
|
2942
3386
|
*/
|
|
2943
3387
|
const MAX_FRAME_BYTES = 67108864;
|
|
3388
|
+
/**
|
|
3389
|
+
* Resolve the host plugin's own version from the installed package.json.
|
|
3390
|
+
* Sourced at boot so the wire / UI always agrees with what npm published.
|
|
3391
|
+
* `createRequire(import.meta.url)` is the tsdown-bundled ESM equivalent of
|
|
3392
|
+
* CommonJS's `require`; the package.json sits next to lib/index.js after
|
|
3393
|
+
* the build, so `../package.json` resolves to the published manifest.
|
|
3394
|
+
*/
|
|
3395
|
+
function readOwnPackageVersion() {
|
|
3396
|
+
try {
|
|
3397
|
+
const pkg = createRequire(import.meta.url)("../package.json");
|
|
3398
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) return pkg.version;
|
|
3399
|
+
} catch {}
|
|
3400
|
+
const envVersion = process.env.npm_package_version;
|
|
3401
|
+
if (typeof envVersion === "string" && envVersion.length > 0) return envVersion;
|
|
3402
|
+
return "0.0.0+unknown";
|
|
3403
|
+
}
|
|
2944
3404
|
function rejectUpgrade(socket, status, reason) {
|
|
2945
3405
|
const body = JSON.stringify({ error: reason });
|
|
2946
3406
|
socket.end("HTTP/1.1 " + status + " Forbidden\r\nContent-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
|
|
@@ -3207,6 +3667,10 @@ function apply(ctx, options) {
|
|
|
3207
3667
|
let enrollAttemptFor;
|
|
3208
3668
|
let enrollLastAttemptAt = 0;
|
|
3209
3669
|
const ensureRelayEnrolled = async (url) => {
|
|
3670
|
+
if (!/^https:\/\//i.test(url.trim())) {
|
|
3671
|
+
log("push relay enrollment refused: relayUrl must be an https URL");
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3210
3674
|
if (enrollmentCell.token) return enrollmentCell.token;
|
|
3211
3675
|
const fingerprint = url + ":" + String(enrollmentCell.enrollKey ?? "");
|
|
3212
3676
|
if (fingerprint !== enrollAttemptFor) {
|
|
@@ -3239,7 +3703,14 @@ function apply(ctx, options) {
|
|
|
3239
3703
|
}
|
|
3240
3704
|
};
|
|
3241
3705
|
let cachedSender;
|
|
3706
|
+
/**
|
|
3707
|
+
* Last failed APNs-sender build. The config fingerprint cannot see the
|
|
3708
|
+
* filesystem, so remembering a failure forever meant "copy the .p8 into
|
|
3709
|
+
* place later" never recovered without an edit or restart; throttle the
|
|
3710
|
+
* retry by time instead — same pattern as relay enrollment below.
|
|
3711
|
+
*/
|
|
3242
3712
|
let senderFailedFor;
|
|
3713
|
+
const SENDER_FAILURE_RETRY_MS = 6e4;
|
|
3243
3714
|
/**
|
|
3244
3715
|
* Lazily build the push sender for the current config. A broken config
|
|
3245
3716
|
* (unreadable .p8) disables push for that fingerprint with exactly one log
|
|
@@ -3248,7 +3719,7 @@ function apply(ctx, options) {
|
|
|
3248
3719
|
const senderFor = async (resolved) => {
|
|
3249
3720
|
const fingerprint = JSON.stringify(resolved);
|
|
3250
3721
|
if (cachedSender?.fingerprint === fingerprint) return cachedSender.send;
|
|
3251
|
-
if (senderFailedFor === fingerprint)
|
|
3722
|
+
if (senderFailedFor?.fingerprint === fingerprint && Date.now() - senderFailedFor.at < SENDER_FAILURE_RETRY_MS) return;
|
|
3252
3723
|
if (cachedSender) {
|
|
3253
3724
|
await cachedSender.dispose?.().catch(() => {});
|
|
3254
3725
|
cachedSender = void 0;
|
|
@@ -3269,7 +3740,10 @@ function apply(ctx, options) {
|
|
|
3269
3740
|
try {
|
|
3270
3741
|
await readFile(expandHome(resolved.keyPath), "utf8");
|
|
3271
3742
|
} catch (error) {
|
|
3272
|
-
senderFailedFor =
|
|
3743
|
+
senderFailedFor = {
|
|
3744
|
+
fingerprint,
|
|
3745
|
+
at: Date.now()
|
|
3746
|
+
};
|
|
3273
3747
|
log("apns push unavailable (key unreadable at " + resolved.keyPath + "): " + String(error));
|
|
3274
3748
|
return;
|
|
3275
3749
|
}
|
|
@@ -3373,6 +3847,12 @@ function apply(ctx, options) {
|
|
|
3373
3847
|
phase: currentConfig().remote?.enabled === true ? "stopped" : "disabled",
|
|
3374
3848
|
updatedAt: Date.now()
|
|
3375
3849
|
};
|
|
3850
|
+
const updateChecker = new UpdateChecker({
|
|
3851
|
+
log,
|
|
3852
|
+
currentVersion: SERVER_VERSION
|
|
3853
|
+
});
|
|
3854
|
+
updateChecker.scheduleInitial();
|
|
3855
|
+
const updateInfo = () => updateChecker.get();
|
|
3376
3856
|
applyReportRemote(ctx, async () => {
|
|
3377
3857
|
let tokenReady = false;
|
|
3378
3858
|
let devices = [];
|
|
@@ -3391,9 +3871,13 @@ function apply(ctx, options) {
|
|
|
3391
3871
|
} } : {}
|
|
3392
3872
|
}));
|
|
3393
3873
|
} catch {}
|
|
3874
|
+
const update = updateInfo();
|
|
3394
3875
|
return {
|
|
3395
3876
|
protocolVersion: 1,
|
|
3396
3877
|
serverVersion: SERVER_VERSION,
|
|
3878
|
+
pluginVersion: update.currentVersion,
|
|
3879
|
+
...update.available ? { updateAvailable: true } : {},
|
|
3880
|
+
...update.releaseUrl !== null ? { releaseUrl: update.releaseUrl } : {},
|
|
3397
3881
|
enabled: currentConfig().enabled === true,
|
|
3398
3882
|
tokenPath: expandHome(currentConfig().authTokenPath ?? join(bridgeDataDir(), "auth-token")),
|
|
3399
3883
|
tokenReady,
|
|
@@ -3422,6 +3906,16 @@ function apply(ctx, options) {
|
|
|
3422
3906
|
}]
|
|
3423
3907
|
};
|
|
3424
3908
|
const url = (push.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
|
|
3909
|
+
if (!/^https:\/\//i.test(url)) return {
|
|
3910
|
+
url,
|
|
3911
|
+
overall: "failed",
|
|
3912
|
+
tokenIssued: false,
|
|
3913
|
+
steps: [{
|
|
3914
|
+
id: "health",
|
|
3915
|
+
ok: false,
|
|
3916
|
+
message: "relayUrl 必须是 https 地址:注册请求携带共享密钥,明文 HTTP 会把它暴露给链路上的任何节点"
|
|
3917
|
+
}]
|
|
3918
|
+
};
|
|
3425
3919
|
if (!enrollmentCell.clientId && enrollmentCell.enrollKey) {
|
|
3426
3920
|
enrollmentCell.clientId = "u_" + randomBytes(16).toString("base64url");
|
|
3427
3921
|
persistEnrollment();
|