dsh-deeppilot 0.6.0 → 0.6.1
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/PRIVACY.md +15 -1
- package/lib/client.js +31 -6
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +99 -2
- package/lib/index.js +595 -23
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -3,18 +3,253 @@ import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID,
|
|
|
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";
|
|
6
|
+
import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { homedir, networkInterfaces } from "node:os";
|
|
7
8
|
import * as Cordis from "@deepseek-ai/cordis";
|
|
8
9
|
import { Context } from "@deepseek-ai/cordis";
|
|
9
10
|
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
10
11
|
import { connect } from "node:http2";
|
|
11
12
|
import { spawn } from "node:child_process";
|
|
12
|
-
import { constants } from "node:fs";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { request } from "node:https";
|
|
15
15
|
import z from "@deepseek-ai/schemastery";
|
|
16
16
|
import { isIP } from "node:net";
|
|
17
17
|
import { createServer } from "node:http";
|
|
18
|
+
function validSendId(id) {
|
|
19
|
+
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
|
+
/** Durable at-most-once dispatch. Unknown outcomes are never automatically retried. */
|
|
22
|
+
var PromptDeliveryJournal = class {
|
|
23
|
+
path;
|
|
24
|
+
capacity;
|
|
25
|
+
entries = Object.create(null);
|
|
26
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
27
|
+
healthy = true;
|
|
28
|
+
constructor(path, capacity = 1e4) {
|
|
29
|
+
this.path = path;
|
|
30
|
+
this.capacity = capacity;
|
|
31
|
+
if (!path || !existsSync(path)) return;
|
|
32
|
+
try {
|
|
33
|
+
if (statSync(path).size > 8388608) throw new Error("oversized journal");
|
|
34
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > capacity) throw new Error("invalid journal");
|
|
36
|
+
for (const [key, value] of parsed.entries) {
|
|
37
|
+
if (typeof key !== "string" || !/^[a-f0-9]{64}$/.test(key) || !value || typeof value.sessionId !== "string" || !Number.isFinite(value.createdAt) || typeof value.fingerprint !== "string" || !validSendId(value.receipt?.clientSendId) || ![
|
|
38
|
+
"accepted",
|
|
39
|
+
"rejected",
|
|
40
|
+
"unknown"
|
|
41
|
+
].includes(value.receipt.status)) throw new Error("invalid entry");
|
|
42
|
+
if (!/^[a-f0-9]{64}$/.test(value.fingerprint) || value.createdAt !== Number(value.receipt.clientSendId.slice(0, 13)) || value.receipt.status === "accepted" && !Number.isSafeInteger(value.receipt.userSeq) || value.receipt.status === "rejected" && ![
|
|
43
|
+
"E_BUSY",
|
|
44
|
+
"E_NOT_FOUND",
|
|
45
|
+
"E_PROTOCOL",
|
|
46
|
+
"E_UNSUPPORTED"
|
|
47
|
+
].includes(value.receipt.code)) throw new Error("invalid receipt");
|
|
48
|
+
this.entries[key] = value;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
this.healthy = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
key(deviceId, id) {
|
|
55
|
+
return createHash("sha256").update(JSON.stringify([deviceId, id])).digest("hex");
|
|
56
|
+
}
|
|
57
|
+
expired(id, now = Date.now()) {
|
|
58
|
+
const age = now - Number(id.slice(0, 13));
|
|
59
|
+
return age > 6048e5 || age < -3e5;
|
|
60
|
+
}
|
|
61
|
+
save() {
|
|
62
|
+
if (!this.path) return;
|
|
63
|
+
mkdirSync(dirname(this.path), {
|
|
64
|
+
recursive: true,
|
|
65
|
+
mode: 448
|
|
66
|
+
});
|
|
67
|
+
const temp = this.path + "." + randomUUID() + ".tmp";
|
|
68
|
+
const fd = openSync(temp, "wx", 384);
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(fd, JSON.stringify({
|
|
71
|
+
version: 1,
|
|
72
|
+
entries: Object.entries(this.entries)
|
|
73
|
+
}));
|
|
74
|
+
fsyncSync(fd);
|
|
75
|
+
} finally {
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
renameSync(temp, this.path);
|
|
79
|
+
const dir = openSync(dirname(this.path), "r");
|
|
80
|
+
try {
|
|
81
|
+
fsyncSync(dir);
|
|
82
|
+
} finally {
|
|
83
|
+
closeSync(dir);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
lookup(deviceId, sessionId, id) {
|
|
87
|
+
if (!this.healthy) return {
|
|
88
|
+
clientSendId: id,
|
|
89
|
+
status: "unknown"
|
|
90
|
+
};
|
|
91
|
+
const entry = this.entries[this.key(deviceId, id)];
|
|
92
|
+
if (entry && entry.sessionId === sessionId) return entry.receipt;
|
|
93
|
+
return {
|
|
94
|
+
clientSendId: id,
|
|
95
|
+
status: this.expired(id) ? "expired" : "notFound"
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async dispatch(deviceId, sessionId, id, content, operation) {
|
|
99
|
+
const key = this.key(deviceId, id);
|
|
100
|
+
const fingerprint = createHash("sha256").update(JSON.stringify([sessionId, content])).digest("hex");
|
|
101
|
+
const existing = this.entries[key];
|
|
102
|
+
if (existing) {
|
|
103
|
+
if (existing.fingerprint !== fingerprint) return {
|
|
104
|
+
clientSendId: id,
|
|
105
|
+
status: "rejected",
|
|
106
|
+
code: "E_PROTOCOL"
|
|
107
|
+
};
|
|
108
|
+
return this.inFlight.get(key) ?? existing.receipt;
|
|
109
|
+
}
|
|
110
|
+
if (!this.healthy) return {
|
|
111
|
+
clientSendId: id,
|
|
112
|
+
status: "unknown"
|
|
113
|
+
};
|
|
114
|
+
if (this.expired(id)) return {
|
|
115
|
+
clientSendId: id,
|
|
116
|
+
status: "expired"
|
|
117
|
+
};
|
|
118
|
+
for (const [key, value] of Object.entries(this.entries)) if (Date.now() - value.createdAt > 6051e5 && !this.inFlight.has(key)) delete this.entries[key];
|
|
119
|
+
if (Object.keys(this.entries).length >= this.capacity) return {
|
|
120
|
+
clientSendId: id,
|
|
121
|
+
status: "rejected",
|
|
122
|
+
code: "E_BUSY"
|
|
123
|
+
};
|
|
124
|
+
const entry = {
|
|
125
|
+
fingerprint,
|
|
126
|
+
sessionId,
|
|
127
|
+
createdAt: Number(id.slice(0, 13)),
|
|
128
|
+
receipt: {
|
|
129
|
+
clientSendId: id,
|
|
130
|
+
status: "unknown"
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
this.entries[key] = entry;
|
|
134
|
+
try {
|
|
135
|
+
this.save();
|
|
136
|
+
} catch {
|
|
137
|
+
this.healthy = false;
|
|
138
|
+
return entry.receipt;
|
|
139
|
+
}
|
|
140
|
+
const run = (async () => {
|
|
141
|
+
try {
|
|
142
|
+
const result = await operation();
|
|
143
|
+
if (result.ok) entry.receipt = {
|
|
144
|
+
clientSendId: id,
|
|
145
|
+
status: "accepted",
|
|
146
|
+
userSeq: result.value
|
|
147
|
+
};
|
|
148
|
+
else if ([
|
|
149
|
+
"busy",
|
|
150
|
+
"not-found",
|
|
151
|
+
"invalid",
|
|
152
|
+
"unsupported"
|
|
153
|
+
].includes(result.kind)) {
|
|
154
|
+
const code = {
|
|
155
|
+
busy: "E_BUSY",
|
|
156
|
+
"not-found": "E_NOT_FOUND",
|
|
157
|
+
invalid: "E_PROTOCOL",
|
|
158
|
+
unsupported: "E_UNSUPPORTED"
|
|
159
|
+
}[result.kind];
|
|
160
|
+
entry.receipt = {
|
|
161
|
+
clientSendId: id,
|
|
162
|
+
status: "rejected",
|
|
163
|
+
code
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
} catch {}
|
|
167
|
+
try {
|
|
168
|
+
this.save();
|
|
169
|
+
} catch {
|
|
170
|
+
this.healthy = false;
|
|
171
|
+
}
|
|
172
|
+
return entry.receipt;
|
|
173
|
+
})();
|
|
174
|
+
this.inFlight.set(key, run);
|
|
175
|
+
try {
|
|
176
|
+
return await run;
|
|
177
|
+
} finally {
|
|
178
|
+
this.inFlight.delete(key);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const journals = /* @__PURE__ */ new Map();
|
|
183
|
+
function openDeliveryJournal(path) {
|
|
184
|
+
if (!path) return new PromptDeliveryJournal();
|
|
185
|
+
let journal = journals.get(path);
|
|
186
|
+
if (!journal) {
|
|
187
|
+
journal = new PromptDeliveryJournal(path);
|
|
188
|
+
journals.set(path, journal);
|
|
189
|
+
}
|
|
190
|
+
return journal;
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/request-validation.ts
|
|
194
|
+
/** Shape and resource limits for authenticated client requests. Unknown fields
|
|
195
|
+
* remain accepted for additive protocol-v2 compatibility. Business validation
|
|
196
|
+
* (supported models, pending choices, permissions) stays with its owner. */
|
|
197
|
+
function validateRequest(type, value) {
|
|
198
|
+
const empty = /* @__PURE__ */ new Set([
|
|
199
|
+
"c2s.ping",
|
|
200
|
+
"c2s.sessions.list",
|
|
201
|
+
"c2s.pending.list",
|
|
202
|
+
"c2s.workspaces.list",
|
|
203
|
+
"c2s.directory.pick"
|
|
204
|
+
]);
|
|
205
|
+
if (value === void 0 && (type === "c2s.session.create" || type === "c2s.directory.list")) value = {};
|
|
206
|
+
if (value == null && empty.has(type)) return;
|
|
207
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "payload must be an object";
|
|
208
|
+
const p = value;
|
|
209
|
+
const text = (v, max = 4096, nonempty = true) => typeof v === "string" && v.length <= max && (!nonempty || v.trim().length > 0);
|
|
210
|
+
const optional = (key, check) => p[key] === void 0 || check(p[key]);
|
|
211
|
+
const integer = (v, min, max) => typeof v === "number" && Number.isSafeInteger(v) && v >= min && v <= max;
|
|
212
|
+
if (type.startsWith("c2s.session.") && type !== "c2s.session.create" && !text(p.sessionId)) return "invalid sessionId";
|
|
213
|
+
if (type === "c2s.session.create" && (!optional("workspaceId", (v) => text(v)) || !optional("cwd", (v) => text(v, 32768)) || p.workspaceId !== void 0 && p.cwd !== void 0)) return "invalid workspace selection";
|
|
214
|
+
if (type === "c2s.directory.list" && !optional("path", (v) => text(v, 32768, false))) return "invalid path";
|
|
215
|
+
if (type === "c2s.workspace.create" && !text(p.path, 32768)) return "invalid path";
|
|
216
|
+
if (type === "c2s.session.open" && !optional("tailCount", (v) => integer(v, 1, 1e4))) return "invalid tailCount";
|
|
217
|
+
if (type === "c2s.session.history" && (!integer(p.beforeSeq, 0, Number.MAX_SAFE_INTEGER) || !optional("limit", (v) => integer(v, 1, 500)))) return "invalid history range";
|
|
218
|
+
if (type === "c2s.session.rename" && !text(p.title, 4096)) return "invalid title";
|
|
219
|
+
if (type === "c2s.session.attachment" && !text(p.attachmentId)) return "invalid attachmentId";
|
|
220
|
+
if (type === "c2s.session.selectModel" && (!text(p.provider, 256) || !text(p.model, 1024) || !optional("reasoningEffort", (v) => text(v, 128, false)))) return "invalid model selection";
|
|
221
|
+
if (type === "c2s.session.delivery" && !validSendId(p.clientSendId)) return "invalid clientSendId";
|
|
222
|
+
if (type === "c2s.session.sendPrompt") {
|
|
223
|
+
if (p.clientSendId !== void 0 && !validSendId(p.clientSendId)) return "invalid clientSendId";
|
|
224
|
+
if (!optional("text", (v) => text(v, 262144, false)) || !optional("images", Array.isArray) || !optional("documents", Array.isArray)) return "invalid prompt fields";
|
|
225
|
+
for (const key of ["images", "documents"]) {
|
|
226
|
+
const items = p[key];
|
|
227
|
+
if (items && items.some((v) => v === null || typeof v !== "object" || Array.isArray(v))) return "invalid attachment";
|
|
228
|
+
for (const item of items ?? []) {
|
|
229
|
+
if (item.name !== void 0 && !text(item.name, 4096, false)) return "invalid attachment name";
|
|
230
|
+
if (item.truncated !== void 0 && typeof item.truncated !== "boolean") return "invalid truncated flag";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (type === "c2s.approval.respond" || type === "c2s.question.respond") {
|
|
235
|
+
if (!text(p.requestId)) return "invalid requestId";
|
|
236
|
+
}
|
|
237
|
+
if (type === "c2s.approval.respond" && (!["allow", "deny"].includes(p.decision) || !optional("reason", (v) => text(v, 65536, false)))) return "invalid approval response";
|
|
238
|
+
if (type === "c2s.question.respond") {
|
|
239
|
+
if (!Array.isArray(p.answers) || p.answers.length > 100) return "invalid answers";
|
|
240
|
+
const ids = /* @__PURE__ */ new Set();
|
|
241
|
+
for (const answer of p.answers) {
|
|
242
|
+
if (!answer || typeof answer !== "object" || Array.isArray(answer) || !text(answer.id) || ids.has(answer.id) || !Array.isArray(answer.selected) || answer.selected.length > 100 || !answer.selected.every((v) => text(v, 4096)) || answer.custom !== void 0 && !text(answer.custom, 65536)) return "invalid answer";
|
|
243
|
+
ids.add(answer.id);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (type === "c2s.push.register" || type === "c2s.widget.push.register") {
|
|
247
|
+
if (p.environment !== void 0 && p.environment !== "production" && p.environment !== "development") return "invalid APNs environment";
|
|
248
|
+
if (!optional("enrollKey", (v) => text(v, 128))) return "invalid enrollKey";
|
|
249
|
+
if (p.categories !== void 0 && (!p.categories || typeof p.categories !== "object" || Array.isArray(p.categories) || Object.values(p.categories).some((v) => typeof v !== "boolean"))) return "invalid categories";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
18
253
|
//#region src/device-auth.ts
|
|
19
254
|
function getPairingCodeTtlMs() {
|
|
20
255
|
return Number(process.env.DEEPPILOT_PAIRING_TTL_MS) || 3e5;
|
|
@@ -226,7 +461,8 @@ var DeviceStore = class DeviceStore {
|
|
|
226
461
|
scopes: normalizeDeviceScopes(record.scopes),
|
|
227
462
|
firstSeenTs: existing?.firstSeenTs ?? now,
|
|
228
463
|
lastSeenTs: now,
|
|
229
|
-
...existing?.apns ? { apns: existing.apns } : {}
|
|
464
|
+
...existing?.apns ? { apns: existing.apns } : {},
|
|
465
|
+
...existing?.widgetApns ? { widgetApns: existing.widgetApns } : {}
|
|
230
466
|
};
|
|
231
467
|
this.devices.set(deviceId, next);
|
|
232
468
|
this.flush();
|
|
@@ -251,6 +487,7 @@ var DeviceStore = class DeviceStore {
|
|
|
251
487
|
if (!record || record.revokedAt !== void 0) return false;
|
|
252
488
|
record.revokedAt = now;
|
|
253
489
|
delete record.apns;
|
|
490
|
+
delete record.widgetApns;
|
|
254
491
|
this.flush();
|
|
255
492
|
return true;
|
|
256
493
|
}
|
|
@@ -296,6 +533,25 @@ var DeviceStore = class DeviceStore {
|
|
|
296
533
|
delete record.apns;
|
|
297
534
|
this.flush();
|
|
298
535
|
}
|
|
536
|
+
setWidgetPushToken(deviceId, token, environment, now) {
|
|
537
|
+
const record = this.authorized(deviceId);
|
|
538
|
+
if (!record || !isValidApnsToken(token)) return;
|
|
539
|
+
const previous = record.widgetApns;
|
|
540
|
+
if (previous?.token === token && previous.environment === environment && now - previous.updatedAt < 36e5) return;
|
|
541
|
+
record.widgetApns = {
|
|
542
|
+
token,
|
|
543
|
+
environment,
|
|
544
|
+
updatedAt: now
|
|
545
|
+
};
|
|
546
|
+
this.flush();
|
|
547
|
+
}
|
|
548
|
+
/** Compare-and-clear protects a rotated token from a delayed APNs rejection. */
|
|
549
|
+
clearWidgetPushToken(deviceId, token) {
|
|
550
|
+
const record = this.devices.get(deviceId);
|
|
551
|
+
if (record?.widgetApns?.token !== token) return;
|
|
552
|
+
delete record.widgetApns;
|
|
553
|
+
this.flush();
|
|
554
|
+
}
|
|
299
555
|
/** Serialized so concurrent touches can never interleave half-written JSON. */
|
|
300
556
|
flush() {
|
|
301
557
|
const next = this.flushTail.then(() => this.writeFile());
|
|
@@ -332,14 +588,54 @@ const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
|
332
588
|
function sanitizeDeviceField(value, maxChars) {
|
|
333
589
|
return (typeof value === "string" ? value : String(value ?? "")).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxChars);
|
|
334
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* Runtime shape guard for a frame after JSON.parse. The old `as Envelope`
|
|
593
|
+
* cast alone let JSON `null` reach `env.v` and let a missing or mistyped
|
|
594
|
+
* `type` reach `requiredScope()`'s string operations — one anonymous frame
|
|
595
|
+
* could crash the host process. Reject anything that is not a plain object
|
|
596
|
+
* with a numeric version and a non-empty string type, so field access below
|
|
597
|
+
* is always safe. The payload is intentionally opaque here; each handler
|
|
598
|
+
* validates its own payload shape.
|
|
599
|
+
*/
|
|
600
|
+
function isEnvelope(value) {
|
|
601
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
602
|
+
const frame = value;
|
|
603
|
+
if (typeof frame.v !== "number") return false;
|
|
604
|
+
if (typeof frame.type !== "string" || frame.type.length === 0) return false;
|
|
605
|
+
if (frame.id !== void 0 && typeof frame.id !== "string") return false;
|
|
606
|
+
if (frame.ts !== void 0 && typeof frame.ts !== "number") return false;
|
|
607
|
+
if (frame.seq !== void 0 && typeof frame.seq !== "number") return false;
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
335
610
|
function requiredScope(type) {
|
|
611
|
+
if (typeof type !== "string") return void 0;
|
|
336
612
|
if (type === "c2s.ping" || type === "c2s.resume") return void 0;
|
|
337
|
-
if (type === "c2s.session.sendPrompt") return "prompt.send";
|
|
613
|
+
if (type === "c2s.session.sendPrompt" || type === "c2s.session.delivery") return "prompt.send";
|
|
614
|
+
if (type === "c2s.pending.list") return "interactions.respond";
|
|
338
615
|
if (type === "c2s.approval.respond" || type === "c2s.question.respond") return "interactions.respond";
|
|
339
|
-
if (type === "c2s.push.register") return "notifications.register";
|
|
616
|
+
if (type === "c2s.push.register" || type === "c2s.widget.push.register") return "notifications.register";
|
|
340
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";
|
|
341
618
|
if (type.startsWith("c2s.")) return "sessions.read";
|
|
342
619
|
}
|
|
620
|
+
/** Broadcast and replay authorization. Unknown frame types fail closed. */
|
|
621
|
+
const PUSH_SCOPE_BY_TYPE = {
|
|
622
|
+
"s2c.session.event": "sessions.read",
|
|
623
|
+
"s2c.sessions.delta": "sessions.read",
|
|
624
|
+
"s2c.session.tail": "sessions.read",
|
|
625
|
+
"s2c.history.page": "sessions.read",
|
|
626
|
+
"s2c.pending.approval": "interactions.respond",
|
|
627
|
+
"s2c.pending.question": "interactions.respond",
|
|
628
|
+
"s2c.pending.cleared": "interactions.respond"
|
|
629
|
+
};
|
|
630
|
+
function pushScopeFor(type, payload) {
|
|
631
|
+
const fixed = PUSH_SCOPE_BY_TYPE[type];
|
|
632
|
+
if (fixed !== void 0) return fixed;
|
|
633
|
+
if (type === "s2c.notify") {
|
|
634
|
+
const category = payload?.category;
|
|
635
|
+
if (category === "approval.required" || category === "question.asked") return "interactions.respond";
|
|
636
|
+
if (category === "turn.completed" || category === "session.error") return "sessions.read";
|
|
637
|
+
}
|
|
638
|
+
}
|
|
343
639
|
function sanitizeImageName(value) {
|
|
344
640
|
return value.replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, 120);
|
|
345
641
|
}
|
|
@@ -396,13 +692,18 @@ var BridgeConnection = class {
|
|
|
396
692
|
/** Sanitized device identity from hello; needed for push registration. */
|
|
397
693
|
deviceId;
|
|
398
694
|
scopes = /* @__PURE__ */ new Set();
|
|
695
|
+
widgetClient = false;
|
|
399
696
|
authChallenge;
|
|
400
697
|
constructor(ws, deps) {
|
|
401
698
|
this.ws = ws;
|
|
402
699
|
this.deps = deps;
|
|
403
700
|
this.authChallenge = createAuthChallenge(deps.audience);
|
|
404
701
|
ws.on("message", (data) => {
|
|
405
|
-
this.onMessage(String(data))
|
|
702
|
+
this.onMessage(String(data)).catch((error) => {
|
|
703
|
+
if (this.deps.debug === true) this.deps.log("frame handler failed: " + String(error));
|
|
704
|
+
if (this.closed) return;
|
|
705
|
+
this.terminate();
|
|
706
|
+
});
|
|
406
707
|
});
|
|
407
708
|
ws.on("close", () => {
|
|
408
709
|
this.onClose();
|
|
@@ -439,7 +740,19 @@ var BridgeConnection = class {
|
|
|
439
740
|
get connectedDeviceId() {
|
|
440
741
|
return this.authenticated ? this.deviceId : void 0;
|
|
441
742
|
}
|
|
743
|
+
get suppressesAlertPush() {
|
|
744
|
+
return !this.widgetClient;
|
|
745
|
+
}
|
|
746
|
+
/** S→C permission gate consulted by the bridge for every broadcast/replay
|
|
747
|
+
* frame: a device only receives what its scopes grant (R1/P2). */
|
|
748
|
+
canReceive(scope) {
|
|
749
|
+
return this.scopes.has(scope);
|
|
750
|
+
}
|
|
442
751
|
push(type, payload, seq) {
|
|
752
|
+
if (type === "s2c.notify") payload = {
|
|
753
|
+
...payload,
|
|
754
|
+
hostAudience: this.deps.audience
|
|
755
|
+
};
|
|
443
756
|
if (type === "s2c.session.event") {
|
|
444
757
|
const sessionId = payload?.sessionId;
|
|
445
758
|
if (typeof sessionId === "string") {
|
|
@@ -528,7 +841,12 @@ var BridgeConnection = class {
|
|
|
528
841
|
}
|
|
529
842
|
let env;
|
|
530
843
|
try {
|
|
531
|
-
|
|
844
|
+
const parsed = JSON.parse(raw);
|
|
845
|
+
if (!isEnvelope(parsed)) {
|
|
846
|
+
this.fail(void 0, "E_PROTOCOL", "malformed frame");
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
env = parsed;
|
|
532
850
|
} catch {
|
|
533
851
|
this.fail(void 0, "E_PROTOCOL", "frame is not valid JSON");
|
|
534
852
|
return;
|
|
@@ -550,11 +868,19 @@ var BridgeConnection = class {
|
|
|
550
868
|
this.fail(env.id, "E_PROTOCOL", "authenticate first");
|
|
551
869
|
return;
|
|
552
870
|
}
|
|
871
|
+
if (this.widgetClient && ![
|
|
872
|
+
"c2s.ping",
|
|
873
|
+
"c2s.sessions.list",
|
|
874
|
+
"c2s.pending.list",
|
|
875
|
+
"c2s.widget.push.register"
|
|
876
|
+
].includes(env.type)) return this.fail(env.id, "E_FORBIDDEN", "widget connection is read-only");
|
|
553
877
|
const required = requiredScope(env.type);
|
|
554
878
|
if (required !== void 0 && !this.scopes.has(required)) {
|
|
555
879
|
this.fail(env.id, "E_FORBIDDEN", `scope ${required} required`);
|
|
556
880
|
return;
|
|
557
881
|
}
|
|
882
|
+
const invalid = validateRequest(env.type, env.payload);
|
|
883
|
+
if (invalid) return this.fail(env.id, "E_PROTOCOL", invalid);
|
|
558
884
|
switch (env.type) {
|
|
559
885
|
case "c2s.ping":
|
|
560
886
|
this.send("s2c.pong", { serverTime: Date.now() }, env.id);
|
|
@@ -727,6 +1053,11 @@ var BridgeConnection = class {
|
|
|
727
1053
|
}, env.id);
|
|
728
1054
|
return;
|
|
729
1055
|
}
|
|
1056
|
+
case "c2s.session.delivery": {
|
|
1057
|
+
const p = env.payload;
|
|
1058
|
+
this.send("s2c.ack", this.deps.bridge.promptDeliveries.lookup(this.deviceId, p.sessionId, p.clientSendId), env.id);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
730
1061
|
case "c2s.session.sendPrompt": {
|
|
731
1062
|
const p = env.payload;
|
|
732
1063
|
const text = typeof p?.text === "string" ? p.text : "";
|
|
@@ -758,6 +1089,15 @@ var BridgeConnection = class {
|
|
|
758
1089
|
...document.truncated === true ? { truncated: true } : {}
|
|
759
1090
|
});
|
|
760
1091
|
}
|
|
1092
|
+
if (p.clientSendId) {
|
|
1093
|
+
const receipt = await this.deps.bridge.promptDeliveries.dispatch(this.deviceId, p.sessionId, p.clientSendId, {
|
|
1094
|
+
text,
|
|
1095
|
+
images,
|
|
1096
|
+
documents
|
|
1097
|
+
}, () => this.deps.bridge.sendPrompt(p.sessionId, text, images, documents));
|
|
1098
|
+
this.send("s2c.ack", receipt, env.id);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
761
1101
|
const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images, documents);
|
|
762
1102
|
if (!userSeq.ok) return this.fail(env.id, managementErrorCode(userSeq.kind), userSeq.message);
|
|
763
1103
|
this.send("s2c.ack", { userSeq: userSeq.value }, env.id);
|
|
@@ -779,7 +1119,9 @@ var BridgeConnection = class {
|
|
|
779
1119
|
this.send("s2c.ack", {}, env.id);
|
|
780
1120
|
return;
|
|
781
1121
|
}
|
|
1122
|
+
case "c2s.widget.push.register":
|
|
782
1123
|
case "c2s.push.register": {
|
|
1124
|
+
const widget = env.type === "c2s.widget.push.register";
|
|
783
1125
|
const p = env.payload;
|
|
784
1126
|
const token = typeof p?.deviceToken === "string" ? p.deviceToken.trim() : "";
|
|
785
1127
|
if (!isValidApnsToken(token)) return this.fail(env.id, "E_PROTOCOL", "hex deviceToken (32-512 chars) required");
|
|
@@ -790,7 +1132,12 @@ var BridgeConnection = class {
|
|
|
790
1132
|
const enrollKey = p.enrollKey.trim().replace(/[^\x20-\x7e]/g, "").slice(0, 128);
|
|
791
1133
|
if (enrollKey.length >= 8 && enrollKey.length <= 128) await this.deps.onPushEnrollKey?.(enrollKey);
|
|
792
1134
|
}
|
|
793
|
-
this.deps.devices.
|
|
1135
|
+
const record = this.deps.devices.authorized(this.deviceId);
|
|
1136
|
+
if (this.closed || !record?.scopes?.includes("notifications.register")) return this.fail(env.id, "E_FORBIDDEN", "device authorization changed");
|
|
1137
|
+
if (widget) {
|
|
1138
|
+
if (!record.scopes.includes("sessions.read") || !record.scopes.includes("interactions.respond")) return this.fail(env.id, "E_FORBIDDEN", "widget overview permissions required");
|
|
1139
|
+
this.deps.devices.setWidgetPushToken(this.deviceId, token, environment, Date.now());
|
|
1140
|
+
} else this.deps.devices.setPushToken(this.deviceId, token, environment, categories, Date.now());
|
|
794
1141
|
if (!this.deps.bridge.capabilities.push) {
|
|
795
1142
|
if (this.deps.debug === true) this.deps.log("push register held: bridge not ready");
|
|
796
1143
|
return this.fail(env.id, "E_UNSUPPORTED", "push is not configured on this bridge");
|
|
@@ -836,11 +1183,12 @@ var BridgeConnection = class {
|
|
|
836
1183
|
this.settleAuthentication(true, "success");
|
|
837
1184
|
this.authenticated = true;
|
|
838
1185
|
this.deviceId = deviceId;
|
|
1186
|
+
this.widgetClient = p.clientRole === "widget";
|
|
839
1187
|
this.scopes = new Set(record.scopes ?? []);
|
|
840
1188
|
if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
|
|
841
|
-
this.deps.devices.markAuthenticated(deviceId, deviceName, appVersion, Date.now());
|
|
1189
|
+
if (!this.widgetClient) this.deps.devices.markAuthenticated(deviceId, deviceName, appVersion, Date.now());
|
|
842
1190
|
this.deps.onDeviceAuthenticated?.(deviceId);
|
|
843
|
-
const cursor = resumeCursor;
|
|
1191
|
+
const cursor = this.widgetClient ? void 0 : resumeCursor;
|
|
844
1192
|
const canResume = cursor !== void 0 && this.deps.bridge.canResumeFrom(cursor);
|
|
845
1193
|
this.send("s2c.welcome", {
|
|
846
1194
|
protocolVersion: 2,
|
|
@@ -851,7 +1199,7 @@ var BridgeConnection = class {
|
|
|
851
1199
|
cursor: this.deps.bridge.currentCursor(),
|
|
852
1200
|
resumed: canResume
|
|
853
1201
|
}, env.id);
|
|
854
|
-
this.deps.bridge.addSink(this);
|
|
1202
|
+
if (!this.widgetClient) this.deps.bridge.addSink(this);
|
|
855
1203
|
if (cursor !== void 0) {
|
|
856
1204
|
if (canResume) this.deps.bridge.resumeFrom(cursor, this);
|
|
857
1205
|
else this.resync();
|
|
@@ -1401,11 +1749,14 @@ var HostBridge = class {
|
|
|
1401
1749
|
abort = new AbortController();
|
|
1402
1750
|
started = false;
|
|
1403
1751
|
disposed = false;
|
|
1404
|
-
|
|
1752
|
+
promptDeliveries;
|
|
1753
|
+
constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT, deliveryJournalPath) {
|
|
1405
1754
|
this.apiProxy = apiProxy;
|
|
1406
1755
|
this.historyBufferMax = historyBufferMax;
|
|
1756
|
+
this.promptDeliveries = openDeliveryJournal(deliveryJournalPath);
|
|
1407
1757
|
}
|
|
1408
1758
|
pushOutlet;
|
|
1759
|
+
widgetFingerprint = "";
|
|
1409
1760
|
/**
|
|
1410
1761
|
* Wire the offline-push fan-out. Present ⇒ welcome advertises the `push`
|
|
1411
1762
|
* capability and notify-worthy events are mirrored to APNs.
|
|
@@ -1420,11 +1771,13 @@ var HostBridge = class {
|
|
|
1420
1771
|
approvals: true,
|
|
1421
1772
|
questions: true,
|
|
1422
1773
|
pendingSnapshot: true,
|
|
1774
|
+
promptDelivery: true,
|
|
1423
1775
|
notifyAllCategories: true,
|
|
1424
1776
|
models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
|
|
1425
1777
|
sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
|
|
1426
1778
|
projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
|
|
1427
|
-
push: this.pushOutlet?.isAvailable() === true
|
|
1779
|
+
push: this.pushOutlet?.isAvailable() === true,
|
|
1780
|
+
widgetPush: true
|
|
1428
1781
|
};
|
|
1429
1782
|
}
|
|
1430
1783
|
diagnostic(message) {
|
|
@@ -1521,17 +1874,45 @@ var HostBridge = class {
|
|
|
1521
1874
|
* Replay buffered pushes after the given cursor; false when the gap is
|
|
1522
1875
|
* unrecoverable. Frames go to `target` only — replaying into every sink
|
|
1523
1876
|
* duplicated the whole window onto devices that never asked for it.
|
|
1877
|
+
* Each frame is filtered by the S→C permission policy per sink, so a
|
|
1878
|
+
* reader without interactions.respond never gets the missed approval
|
|
1879
|
+
* frames back (R1/P2).
|
|
1524
1880
|
*/
|
|
1525
1881
|
resumeFrom(cursor, target) {
|
|
1526
1882
|
const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
|
|
1527
1883
|
if (cursor + 1 < oldest) return false;
|
|
1528
1884
|
const receivers = target !== void 0 ? [target] : [...this.sinks];
|
|
1529
|
-
for (const entry of this.ring) if (entry.seq > cursor) for (const sink of receivers)
|
|
1885
|
+
for (const entry of this.ring) if (entry.seq > cursor) for (const sink of receivers) {
|
|
1886
|
+
const need = pushScopeFor(entry.type, entry.payload);
|
|
1887
|
+
if (need === void 0 || !sink.canReceive(need)) continue;
|
|
1888
|
+
sink.replay([entry]);
|
|
1889
|
+
}
|
|
1530
1890
|
for (const sink of receivers) sink.replayDone();
|
|
1531
1891
|
return true;
|
|
1532
1892
|
}
|
|
1533
1893
|
record(type, payload, except) {
|
|
1534
1894
|
if (this.disposed) return;
|
|
1895
|
+
if (type === "s2c.sessions.delta" || type.startsWith("s2c.pending.")) {
|
|
1896
|
+
const fingerprint = JSON.stringify([
|
|
1897
|
+
this.listSessions().map(({ id, title, status, lastActivityTs, todos, pendingApproval, pendingQuestion }) => ({
|
|
1898
|
+
id,
|
|
1899
|
+
title,
|
|
1900
|
+
status,
|
|
1901
|
+
lastActivityTs,
|
|
1902
|
+
todos,
|
|
1903
|
+
pendingApproval,
|
|
1904
|
+
pendingQuestion
|
|
1905
|
+
})),
|
|
1906
|
+
this.approvals.size,
|
|
1907
|
+
this.questions.size
|
|
1908
|
+
]);
|
|
1909
|
+
if (fingerprint !== this.widgetFingerprint) {
|
|
1910
|
+
this.widgetFingerprint = fingerprint;
|
|
1911
|
+
try {
|
|
1912
|
+
this.pushOutlet?.widgetChanged?.();
|
|
1913
|
+
} catch {}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1535
1916
|
this.cursor += 1;
|
|
1536
1917
|
const entry = {
|
|
1537
1918
|
seq: this.cursor,
|
|
@@ -1540,7 +1921,9 @@ var HostBridge = class {
|
|
|
1540
1921
|
};
|
|
1541
1922
|
this.ring.push(entry);
|
|
1542
1923
|
if (this.ring.length > this.historyBufferMax) this.ring.splice(0, this.ring.length - this.historyBufferMax);
|
|
1924
|
+
const need = pushScopeFor(type, payload);
|
|
1543
1925
|
for (const sink of this.sinks) {
|
|
1926
|
+
if (need === void 0 || !sink.canReceive(need)) continue;
|
|
1544
1927
|
if (except && except(sink)) continue;
|
|
1545
1928
|
sink.push(type, payload, entry.seq);
|
|
1546
1929
|
}
|
|
@@ -1782,6 +2165,14 @@ var HostBridge = class {
|
|
|
1782
2165
|
} else if (key === "sessionListMetadata") {
|
|
1783
2166
|
const meta = value;
|
|
1784
2167
|
if (meta?.lastPromptAt) row.lastActivityTs = Math.max(row.lastActivityTs, meta.lastPromptAt);
|
|
2168
|
+
} else if (key === "sessionStats" || key === "tokenUsage") {
|
|
2169
|
+
const patch = sanitizeUsageStatsPatch(key, value);
|
|
2170
|
+
if (!patch) return;
|
|
2171
|
+
row.stats = {
|
|
2172
|
+
...emptyUsageStats,
|
|
2173
|
+
...row.stats ?? {},
|
|
2174
|
+
...patch
|
|
2175
|
+
};
|
|
1785
2176
|
} else return;
|
|
1786
2177
|
this.pushSummary(row);
|
|
1787
2178
|
}
|
|
@@ -2415,6 +2806,65 @@ function sanitizeTodoItems(items) {
|
|
|
2415
2806
|
status: i.status
|
|
2416
2807
|
}));
|
|
2417
2808
|
}
|
|
2809
|
+
/** Zero-value stats snapshot used as the merge base for partial patches. */
|
|
2810
|
+
const emptyUsageStats = {
|
|
2811
|
+
turns: 0,
|
|
2812
|
+
steps: 0,
|
|
2813
|
+
llmMs: 0,
|
|
2814
|
+
toolMs: 0,
|
|
2815
|
+
ttftMs: 0,
|
|
2816
|
+
ttftSteps: 0,
|
|
2817
|
+
decodeMs: 0,
|
|
2818
|
+
decodeTokens: 0,
|
|
2819
|
+
inputTokens: 0,
|
|
2820
|
+
outputTokens: 0,
|
|
2821
|
+
cacheReadTokens: 0,
|
|
2822
|
+
cacheWriteTokens: 0
|
|
2823
|
+
};
|
|
2824
|
+
/** Coerce one host counter to a non-negative integer; junk becomes 0. */
|
|
2825
|
+
function usageCounter(value) {
|
|
2826
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
2827
|
+
if (!Number.isFinite(n) || n < 0) return 0;
|
|
2828
|
+
return Math.floor(n);
|
|
2829
|
+
}
|
|
2830
|
+
const SESSION_STATS_COUNTERS = [
|
|
2831
|
+
"turns",
|
|
2832
|
+
"steps",
|
|
2833
|
+
"llmMs",
|
|
2834
|
+
"toolMs",
|
|
2835
|
+
"ttftMs",
|
|
2836
|
+
"ttftSteps",
|
|
2837
|
+
"decodeMs",
|
|
2838
|
+
"decodeTokens"
|
|
2839
|
+
];
|
|
2840
|
+
const TOKEN_USAGE_COUNTERS = [
|
|
2841
|
+
"outputTokens",
|
|
2842
|
+
"cacheReadTokens",
|
|
2843
|
+
"cacheWriteTokens"
|
|
2844
|
+
];
|
|
2845
|
+
/** Sanitize one `sessionStats` / `tokenUsage` projection value into the wire
|
|
2846
|
+
* stats fields; undefined when the payload carries nothing readable. Host
|
|
2847
|
+
* field names differ between the two projections (tokenUsage reports
|
|
2848
|
+
* `uncachedInputTokens`, the wire mirrors it as `inputTokens`). */
|
|
2849
|
+
function sanitizeUsageStatsPatch(key, value) {
|
|
2850
|
+
if (!value || typeof value !== "object") return void 0;
|
|
2851
|
+
const raw = value;
|
|
2852
|
+
const patch = {};
|
|
2853
|
+
if (key === "sessionStats") {
|
|
2854
|
+
for (const field of SESSION_STATS_COUNTERS) if (field in raw) patch[field] = usageCounter(raw[field]);
|
|
2855
|
+
} else {
|
|
2856
|
+
if ("uncachedInputTokens" in raw) patch.inputTokens = usageCounter(raw.uncachedInputTokens);
|
|
2857
|
+
for (const field of TOKEN_USAGE_COUNTERS) if (field in raw) patch[field] = usageCounter(raw[field]);
|
|
2858
|
+
}
|
|
2859
|
+
return Object.keys(patch).length > 0 ? patch : void 0;
|
|
2860
|
+
}
|
|
2861
|
+
/** Combine one session's `sessionStats` + `tokenUsage` projection values into
|
|
2862
|
+
* a complete wire stats object; undefined when neither carries anything. */
|
|
2863
|
+
function usageStatsOf(sessionStats, tokenUsage) {
|
|
2864
|
+
const patches = [sanitizeUsageStatsPatch("sessionStats", sessionStats), sanitizeUsageStatsPatch("tokenUsage", tokenUsage)].filter((patch) => patch !== void 0);
|
|
2865
|
+
if (patches.length === 0) return void 0;
|
|
2866
|
+
return Object.assign({}, emptyUsageStats, ...patches);
|
|
2867
|
+
}
|
|
2418
2868
|
function toSummary(row, approvals, questions, workspace) {
|
|
2419
2869
|
const values = row.projections?.values ?? {};
|
|
2420
2870
|
const todos = Array.isArray(values.todos) ? values.todos : null;
|
|
@@ -2425,6 +2875,7 @@ function toSummary(row, approvals, questions, workspace) {
|
|
|
2425
2875
|
const cwd = typeof row.cwd === "string" ? row.cwd : "";
|
|
2426
2876
|
const label = workspace?.title ?? (cwd ? cwd.split("/").filter(Boolean).pop() : void 0);
|
|
2427
2877
|
const todoItems = sanitizeTodoItems(todos);
|
|
2878
|
+
const stats = usageStatsOf(values.sessionStats, values.tokenUsage);
|
|
2428
2879
|
return {
|
|
2429
2880
|
id: row.sessionId,
|
|
2430
2881
|
title: typeof values.title === "string" ? values.title : "",
|
|
@@ -2437,6 +2888,7 @@ function toSummary(row, approvals, questions, workspace) {
|
|
|
2437
2888
|
todoItems: todoItems.length > 0 ? todoItems : null,
|
|
2438
2889
|
pendingApproval,
|
|
2439
2890
|
pendingQuestion,
|
|
2891
|
+
...stats ? { stats } : {},
|
|
2440
2892
|
workspaceLabel: label ?? null,
|
|
2441
2893
|
workspaceId: workspace?.workspaceId ?? null,
|
|
2442
2894
|
workspacePath: workspace?.path ?? (cwd || null)
|
|
@@ -3642,6 +4094,10 @@ function p8ToDer(pem) {
|
|
|
3642
4094
|
}
|
|
3643
4095
|
/** Pure payload builder so tests can assert the wire format without sockets. */
|
|
3644
4096
|
function apnsPayload(notification) {
|
|
4097
|
+
if ("kind" in notification && notification.kind === "widget") return { aps: { "content-changed": true } };
|
|
4098
|
+
return alertPayload(notification);
|
|
4099
|
+
}
|
|
4100
|
+
function alertPayload(notification) {
|
|
3645
4101
|
const timeSensitive = notification.category === "approval.required" || notification.category === "question.asked";
|
|
3646
4102
|
return {
|
|
3647
4103
|
aps: {
|
|
@@ -3651,17 +4107,32 @@ function apnsPayload(notification) {
|
|
|
3651
4107
|
},
|
|
3652
4108
|
sound: "default",
|
|
3653
4109
|
category: notification.category,
|
|
3654
|
-
"thread-id": notification.sessionId.slice(0, 64),
|
|
4110
|
+
"thread-id": notification.hostAudience ? createHash("sha256").update(`${notification.hostAudience}:${notification.sessionId}`).digest("hex") : notification.sessionId.slice(0, 64),
|
|
3655
4111
|
...timeSensitive ? { "interruption-level": "time-sensitive" } : {}
|
|
3656
4112
|
},
|
|
4113
|
+
...notification.hostAudience ? { hostAudience: notification.hostAudience } : {},
|
|
3657
4114
|
sessionId: notification.sessionId,
|
|
3658
4115
|
notificationId: notification.notificationId,
|
|
3659
4116
|
kind: notification.category
|
|
3660
4117
|
};
|
|
3661
4118
|
}
|
|
4119
|
+
function pushHeaders(bundleId, notification) {
|
|
4120
|
+
if ("kind" in notification && notification.kind === "widget") return {
|
|
4121
|
+
"apns-topic": bundleId + ".push-type.widgets",
|
|
4122
|
+
"apns-push-type": "widgets",
|
|
4123
|
+
"apns-priority": "5",
|
|
4124
|
+
"apns-collapse-id": "widget-overview"
|
|
4125
|
+
};
|
|
4126
|
+
return {
|
|
4127
|
+
"apns-topic": bundleId,
|
|
4128
|
+
"apns-push-type": "alert",
|
|
4129
|
+
"apns-priority": "10",
|
|
4130
|
+
"apns-collapse-id": collapseIdFor(notification)
|
|
4131
|
+
};
|
|
4132
|
+
}
|
|
3662
4133
|
/** collapse-id accepts ≤64 bytes of ASCII; keep it stable per session+event. */
|
|
3663
4134
|
function collapseIdFor(notification) {
|
|
3664
|
-
const raw = `${notification.category}:${notification.sessionId}`;
|
|
4135
|
+
const raw = `${notification.hostAudience ? notification.hostAudience + ":" : ""}${notification.category}:${notification.sessionId}`;
|
|
3665
4136
|
const readable = raw.replace(/[^a-zA-Z0-9.:-]/g, "");
|
|
3666
4137
|
const digest = createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 12);
|
|
3667
4138
|
return `${readable.slice(0, 51)}:${digest}`;
|
|
@@ -3738,11 +4209,8 @@ var ApnsClient = class {
|
|
|
3738
4209
|
[":method"]: "POST",
|
|
3739
4210
|
[":path"]: "/3/device/" + deviceToken,
|
|
3740
4211
|
authorization: "bearer " + token,
|
|
3741
|
-
|
|
3742
|
-
"apns-push-type": "alert",
|
|
3743
|
-
"apns-priority": "10",
|
|
4212
|
+
...pushHeaders(this.opts.bundleId, notification),
|
|
3744
4213
|
"apns-expiration": String(Math.floor(Date.now() / 1e3) + 3600),
|
|
3745
|
-
"apns-collapse-id": collapseIdFor(notification),
|
|
3746
4214
|
"content-type": "application/json",
|
|
3747
4215
|
"content-length": String(Buffer.byteLength(body))
|
|
3748
4216
|
});
|
|
@@ -4608,6 +5076,7 @@ const Config = z.object({
|
|
|
4608
5076
|
"apns",
|
|
4609
5077
|
"relay"
|
|
4610
5078
|
]).default("none"),
|
|
5079
|
+
contentMode: z.union(["preview", "generic"]).default("preview"),
|
|
4611
5080
|
teamId: z.string().default(""),
|
|
4612
5081
|
keyId: z.string().default(""),
|
|
4613
5082
|
keyPath: z.string().default(join(bridgeDataDir(), "apns", "AuthKey.p8")),
|
|
@@ -4616,6 +5085,7 @@ const Config = z.object({
|
|
|
4616
5085
|
relayToken: z.string().default("")
|
|
4617
5086
|
}).default({
|
|
4618
5087
|
provider: "none",
|
|
5088
|
+
contentMode: "preview",
|
|
4619
5089
|
teamId: "",
|
|
4620
5090
|
keyId: "",
|
|
4621
5091
|
keyPath: join(bridgeDataDir(), "apns", "AuthKey.p8"),
|
|
@@ -4793,6 +5263,11 @@ var AuthRateLimiter = class {
|
|
|
4793
5263
|
function shouldPrunePushToken(outcome, reason) {
|
|
4794
5264
|
return outcome === "invalid-token" && (reason === "Unregistered" || reason === "ExpiredToken");
|
|
4795
5265
|
}
|
|
5266
|
+
/** APNs requires both registration and the same content permission as WS. */
|
|
5267
|
+
function mayReceivePush(device, notification) {
|
|
5268
|
+
const scope = pushScopeFor("s2c.notify", notification);
|
|
5269
|
+
return scope !== void 0 && device.scopes?.includes("notifications.register") === true && device.scopes.includes(scope);
|
|
5270
|
+
}
|
|
4796
5271
|
/**
|
|
4797
5272
|
* Zero-touch relay self-heal: HTTP 401 means the relay no longer honors the
|
|
4798
5273
|
* cached credential. Only auto-enrolled cells with a still-current token may
|
|
@@ -4802,6 +5277,59 @@ function shouldPrunePushToken(outcome, reason) {
|
|
|
4802
5277
|
function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
|
|
4803
5278
|
return transport === "relay" && outcome === "failed" && reason === "HTTP 401" && opts.hasEnrollKey && opts.usedCellToken && opts.tokenStillCurrent;
|
|
4804
5279
|
}
|
|
5280
|
+
/** Apply before either transport receives the payload, including the Relay. */
|
|
5281
|
+
function pushContent(notification, mode) {
|
|
5282
|
+
if (mode !== "generic") return notification;
|
|
5283
|
+
const bodies = {
|
|
5284
|
+
"turn.completed": "A task has completed.",
|
|
5285
|
+
"approval.required": "An approval needs your attention.",
|
|
5286
|
+
"question.asked": "A question needs your answer.",
|
|
5287
|
+
"session.error": "A task needs your attention."
|
|
5288
|
+
};
|
|
5289
|
+
return {
|
|
5290
|
+
...notification,
|
|
5291
|
+
title: "DeepPilot",
|
|
5292
|
+
body: bodies[notification.category] ?? "Open DeepPilot for an update."
|
|
5293
|
+
};
|
|
5294
|
+
}
|
|
5295
|
+
//#endregion
|
|
5296
|
+
//#region src/widget-push.ts
|
|
5297
|
+
/** Coalesce bursts and bound continuous updates, without starving the trailing
|
|
5298
|
+
* state. One scheduler per host, not per token or per streaming text frame. */
|
|
5299
|
+
var WidgetPushScheduler = class {
|
|
5300
|
+
send;
|
|
5301
|
+
intervalMs;
|
|
5302
|
+
timer;
|
|
5303
|
+
lastSent = 0;
|
|
5304
|
+
disposed = false;
|
|
5305
|
+
sending = false;
|
|
5306
|
+
dirty = false;
|
|
5307
|
+
constructor(send, intervalMs = 3e4) {
|
|
5308
|
+
this.send = send;
|
|
5309
|
+
this.intervalMs = intervalMs;
|
|
5310
|
+
}
|
|
5311
|
+
changed() {
|
|
5312
|
+
if (this.disposed) return;
|
|
5313
|
+
this.dirty = true;
|
|
5314
|
+
if (this.timer || this.sending) return;
|
|
5315
|
+
this.timer = setTimeout(() => {
|
|
5316
|
+
this.timer = void 0;
|
|
5317
|
+
this.dirty = false;
|
|
5318
|
+
this.sending = true;
|
|
5319
|
+
this.lastSent = Date.now();
|
|
5320
|
+
this.send().catch(() => {}).finally(() => {
|
|
5321
|
+
this.sending = false;
|
|
5322
|
+
if (this.dirty) this.changed();
|
|
5323
|
+
});
|
|
5324
|
+
}, Math.max(Math.min(1e3, this.intervalMs), this.intervalMs - (Date.now() - this.lastSent)));
|
|
5325
|
+
this.timer.unref();
|
|
5326
|
+
}
|
|
5327
|
+
dispose() {
|
|
5328
|
+
this.disposed = true;
|
|
5329
|
+
if (this.timer) clearTimeout(this.timer);
|
|
5330
|
+
this.timer = void 0;
|
|
5331
|
+
}
|
|
5332
|
+
};
|
|
4805
5333
|
//#endregion
|
|
4806
5334
|
//#region src/phone-server.ts
|
|
4807
5335
|
/**
|
|
@@ -5250,20 +5778,59 @@ function apply(ctx, options) {
|
|
|
5250
5778
|
* token. Rules:
|
|
5251
5779
|
* - devices with a live WebSocket are skipped (they already got the WS
|
|
5252
5780
|
* frame and will raise the local notification themselves);
|
|
5781
|
+
* - only devices granted `notifications.register` are candidates — a
|
|
5782
|
+
* device whose scope was revoked must not receive offline pushes
|
|
5783
|
+
* (R1/P2 S→C permission policy);
|
|
5253
5784
|
* - each device is delivered on ITS registered environment (the build
|
|
5254
5785
|
* kind it self-reported), so sandbox and production devices coexist;
|
|
5255
5786
|
* - the device's per-category switches suppress muted categories;
|
|
5256
5787
|
* - only APNs' terminal Unregistered/ExpiredToken verdicts prune storage;
|
|
5257
5788
|
* BadDeviceToken may be an environment mismatch and stays diagnosable.
|
|
5258
5789
|
*/
|
|
5790
|
+
const widgetPush = new WidgetPushScheduler(async () => {
|
|
5791
|
+
if (!enabledNow()) return;
|
|
5792
|
+
const resolved = resolvePushConfig(currentConfig());
|
|
5793
|
+
if (!resolved.ok) return;
|
|
5794
|
+
const devices = auth.devices;
|
|
5795
|
+
const send = await senderFor(resolved.value);
|
|
5796
|
+
if (!devices || !send) return;
|
|
5797
|
+
const sent = /* @__PURE__ */ new Set();
|
|
5798
|
+
for (const device of devices.list()) {
|
|
5799
|
+
const registration = device.widgetApns;
|
|
5800
|
+
if (!registration || device.revokedAt !== void 0 || ![
|
|
5801
|
+
"notifications.register",
|
|
5802
|
+
"sessions.read",
|
|
5803
|
+
"interactions.respond"
|
|
5804
|
+
].every((scope) => device.scopes?.includes(scope)) || Date.now() - registration.updatedAt > 6048e5) continue;
|
|
5805
|
+
const key = registration.environment + ":" + registration.token;
|
|
5806
|
+
if (sent.has(key)) continue;
|
|
5807
|
+
sent.add(key);
|
|
5808
|
+
const { outcome, reason } = await send({
|
|
5809
|
+
deviceToken: registration.token,
|
|
5810
|
+
environment: registration.environment,
|
|
5811
|
+
notification: { kind: "widget" }
|
|
5812
|
+
});
|
|
5813
|
+
if (shouldPrunePushToken(outcome, reason)) devices.clearWidgetPushToken(device.deviceId, registration.token);
|
|
5814
|
+
if (resolved.value.kind === "relay" && reason === "HTTP 401" && enrollmentCell.token === resolved.value.token && enrollmentCell.enrollKey) {
|
|
5815
|
+
enrollmentCell.token = void 0;
|
|
5816
|
+
persistEnrollment();
|
|
5817
|
+
await ensureRelayEnrolled(resolved.value.url);
|
|
5818
|
+
}
|
|
5819
|
+
}
|
|
5820
|
+
});
|
|
5259
5821
|
const makePushOutlet = () => ({
|
|
5822
|
+
widgetChanged: () => widgetPush.changed(),
|
|
5260
5823
|
isAvailable: () => {
|
|
5261
5824
|
const resolved = resolvePushConfig(currentConfig());
|
|
5262
5825
|
if (!resolved.ok) return false;
|
|
5263
5826
|
if (resolved.value.kind === "relay" && !resolved.value.token) return false;
|
|
5264
5827
|
return true;
|
|
5265
5828
|
},
|
|
5266
|
-
fanOut: (
|
|
5829
|
+
fanOut: (sourceNotification) => {
|
|
5830
|
+
const notification = pushContent({
|
|
5831
|
+
...sourceNotification,
|
|
5832
|
+
hostAudience: auth.audience ?? void 0
|
|
5833
|
+
}, currentConfig().push?.contentMode);
|
|
5267
5834
|
(async () => {
|
|
5268
5835
|
let resolved = resolvePushConfig(currentConfig());
|
|
5269
5836
|
if (!resolved.ok && resolved.reason === "relay token not enrolled yet") {
|
|
@@ -5280,12 +5847,16 @@ function apply(ctx, options) {
|
|
|
5280
5847
|
const connectedIds = /* @__PURE__ */ new Set();
|
|
5281
5848
|
for (const connection of connections) {
|
|
5282
5849
|
const id = connection.connectedDeviceId;
|
|
5283
|
-
if (id) connectedIds.add(id);
|
|
5850
|
+
if (id && connection.suppressesAlertPush) connectedIds.add(id);
|
|
5284
5851
|
}
|
|
5285
5852
|
const candidates = devices.list().filter((device) => {
|
|
5286
5853
|
const registration = device.apns;
|
|
5287
5854
|
if (!registration) return false;
|
|
5288
5855
|
if (connectedIds.has(device.deviceId)) return false;
|
|
5856
|
+
if (!mayReceivePush(device, notification)) {
|
|
5857
|
+
if (currentConfig().debug === true) log(`push skip "${device.deviceName}": notification permission not granted`);
|
|
5858
|
+
return false;
|
|
5859
|
+
}
|
|
5289
5860
|
if (registration.categories?.[notification.category] === false) {
|
|
5290
5861
|
if (currentConfig().debug === true) log(`push skip "${device.deviceName}": category ${notification.category} muted`);
|
|
5291
5862
|
return false;
|
|
@@ -5840,7 +6411,7 @@ function apply(ctx, options) {
|
|
|
5840
6411
|
log("dsh 0.1.2 session bridge unavailable: " + String(error));
|
|
5841
6412
|
return;
|
|
5842
6413
|
}
|
|
5843
|
-
const bridge = new HostBridge(proxy, cfg.historyBufferMax);
|
|
6414
|
+
const bridge = new HostBridge(proxy, cfg.historyBufferMax, join(dataDir, "prompt-deliveries-v1.json"));
|
|
5844
6415
|
bridge.setPushOutlet(makePushOutlet());
|
|
5845
6416
|
state.bridge = bridge;
|
|
5846
6417
|
bridge.start();
|
|
@@ -5883,6 +6454,7 @@ function apply(ctx, options) {
|
|
|
5883
6454
|
const sender = cachedSender;
|
|
5884
6455
|
cachedSender = void 0;
|
|
5885
6456
|
updateChecker.dispose();
|
|
6457
|
+
widgetPush.dispose();
|
|
5886
6458
|
const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
|
|
5887
6459
|
await Promise.allSettled([
|
|
5888
6460
|
enrollmentWriteTail,
|
|
@@ -5892,6 +6464,6 @@ function apply(ctx, options) {
|
|
|
5892
6464
|
}, "deeppilot: process resources");
|
|
5893
6465
|
}
|
|
5894
6466
|
//#endregion
|
|
5895
|
-
export { Config, HostBridge, apply, inject, name, shouldPrunePushToken, shouldReEnrollRelayToken };
|
|
6467
|
+
export { Config, HostBridge, apply, inject, mayReceivePush, name, shouldPrunePushToken, shouldReEnrollRelayToken };
|
|
5896
6468
|
|
|
5897
6469
|
//# sourceMappingURL=index.js.map
|