dsh-deeppilot 0.2.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 +36 -0
- package/LICENSE +21 -0
- package/PRIVACY.md +64 -0
- package/README.md +97 -0
- package/README.zh-CN.md +91 -0
- package/SECURITY.md +44 -0
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/bin/SHA256SUMS +1 -0
- package/bin/darwin-arm64/dsh-deeppilot-tunnel +0 -0
- package/cordis.patch.yml +20 -0
- package/lib/client.js +3398 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +586 -0
- package/lib/index.js +3654 -0
- package/lib/index.js.map +1 -0
- package/package.json +102 -0
- package/third_party/licenses/filippo.io/edwards25519/LICENSE +27 -0
- package/third_party/licenses/github.com/Mars-Sea/dsh-deeppilot/helper/LICENSE +21 -0
- package/third_party/licenses/github.com/coder/websocket/LICENSE.txt +13 -0
- package/third_party/licenses/github.com/creachadair/msync/trigger/LICENSE +26 -0
- package/third_party/licenses/github.com/fxamacker/cbor/v2/LICENSE +21 -0
- package/third_party/licenses/github.com/gaissmai/bart/LICENSE +21 -0
- package/third_party/licenses/github.com/go-json-experiment/json/LICENSE +27 -0
- package/third_party/licenses/github.com/golang/groupcache/lru/LICENSE +191 -0
- package/third_party/licenses/github.com/google/btree/LICENSE +202 -0
- package/third_party/licenses/github.com/hdevalence/ed25519consensus/LICENSE +28 -0
- package/third_party/licenses/github.com/huin/goupnp/LICENSE +23 -0
- package/third_party/licenses/github.com/klauspost/compress/LICENSE +304 -0
- package/third_party/licenses/github.com/klauspost/compress/internal/snapref/LICENSE +27 -0
- package/third_party/licenses/github.com/klauspost/compress/zstd/internal/xxhash/LICENSE.txt +22 -0
- package/third_party/licenses/github.com/mitchellh/go-ps/LICENSE.md +21 -0
- package/third_party/licenses/github.com/pires/go-proxyproto/LICENSE +201 -0
- package/third_party/licenses/github.com/tailscale/certstore/LICENSE.md +21 -0
- package/third_party/licenses/github.com/tailscale/hujson/LICENSE +27 -0
- package/third_party/licenses/github.com/tailscale/peercred/LICENSE +29 -0
- package/third_party/licenses/github.com/tailscale/web-client-prebuilt/LICENSE +28 -0
- package/third_party/licenses/github.com/tailscale/wireguard-go/LICENSE +17 -0
- package/third_party/licenses/github.com/x448/float16/LICENSE +22 -0
- package/third_party/licenses/go4.org/mem/LICENSE +202 -0
- package/third_party/licenses/go4.org/netipx/LICENSE +27 -0
- package/third_party/licenses/golang.org/x/crypto/LICENSE +27 -0
- package/third_party/licenses/golang.org/x/exp/LICENSE +27 -0
- package/third_party/licenses/golang.org/x/net/LICENSE +27 -0
- package/third_party/licenses/golang.org/x/oauth2/LICENSE +27 -0
- package/third_party/licenses/golang.org/x/sync/errgroup/LICENSE +27 -0
- package/third_party/licenses/golang.org/x/sys/LICENSE +27 -0
- package/third_party/licenses/golang.org/x/term/LICENSE +27 -0
- package/third_party/licenses/golang.org/x/text/LICENSE +27 -0
- package/third_party/licenses/golang.org/x/time/rate/LICENSE +27 -0
- package/third_party/licenses/gvisor.dev/gvisor/pkg/LICENSE +254 -0
- package/third_party/licenses/npm/dijkstrajs/LICENSE +17 -0
- package/third_party/licenses/npm/qrcode/LICENSE +21 -0
- package/third_party/licenses/tailscale.com/LICENSE +28 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3654 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createPrivateKey, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import z from "@deepseek-ai/schemastery";
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
import { homedir, networkInterfaces } from "node:os";
|
|
8
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
9
|
+
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
10
|
+
import { connect } from "node:http2";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { constants } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
//#region src/token.ts
|
|
15
|
+
/** Expand a leading ~ using the process home directory. */
|
|
16
|
+
function expandHome(p) {
|
|
17
|
+
if (p === "~") return homedir();
|
|
18
|
+
if (p.startsWith("~/")) return resolve(homedir(), p.slice(2));
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
function dshDataRoot() {
|
|
22
|
+
const dshHome = process.env.DSH_HOME;
|
|
23
|
+
if (dshHome && dshHome.trim().length > 0) return resolve(dshHome.trim());
|
|
24
|
+
return resolve(homedir(), ".dsh");
|
|
25
|
+
}
|
|
26
|
+
/** DeepPilot data directory: under $DSH_HOME when set, else ~/.dsh. */
|
|
27
|
+
function bridgeDataDir() {
|
|
28
|
+
return resolve(dshDataRoot(), "deeppilot");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Move the pre-DeepPilot data directory as one atomic directory rename.
|
|
32
|
+
* Existing canonical data always wins; secrets are never merged or replaced.
|
|
33
|
+
*/
|
|
34
|
+
async function migrateLegacyBridgeDataDir() {
|
|
35
|
+
const target = bridgeDataDir();
|
|
36
|
+
try {
|
|
37
|
+
await access(target);
|
|
38
|
+
return null;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code !== "ENOENT") throw error;
|
|
41
|
+
}
|
|
42
|
+
const legacy = resolve(dshDataRoot(), "pocket-bridge");
|
|
43
|
+
try {
|
|
44
|
+
await rename(legacy, target);
|
|
45
|
+
return legacy;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error.code === "ENOENT") return null;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Load the pairing token from disk or generate and persist a fresh one.
|
|
53
|
+
* The file is written 0600; the token never appears in logs.
|
|
54
|
+
*/
|
|
55
|
+
async function loadOrCreateToken(tokenPath) {
|
|
56
|
+
const full = expandHome(tokenPath);
|
|
57
|
+
try {
|
|
58
|
+
const existing = (await readFile(full, "utf8")).trim();
|
|
59
|
+
if (existing.length >= 32) return existing;
|
|
60
|
+
} catch {}
|
|
61
|
+
const token = randomBytes(32).toString("base64url");
|
|
62
|
+
await mkdir(dirname(full), { recursive: true });
|
|
63
|
+
await writeFile(full, token + "\n", { mode: 384 });
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Generate a fresh pairing token and replace the stored one, invalidating
|
|
68
|
+
* every copy of the old secret. The write goes to a same-directory temp file
|
|
69
|
+
* renamed over the target so a crash can never leave a truncated token file.
|
|
70
|
+
*/
|
|
71
|
+
async function writeNewToken(tokenPath) {
|
|
72
|
+
const full = expandHome(tokenPath);
|
|
73
|
+
const token = randomBytes(32).toString("base64url");
|
|
74
|
+
await mkdir(dirname(full), { recursive: true });
|
|
75
|
+
const temp = `${full}.${randomBytes(6).toString("hex")}.tmp`;
|
|
76
|
+
await writeFile(temp, token + "\n", { mode: 384 });
|
|
77
|
+
await rename(temp, full);
|
|
78
|
+
return token;
|
|
79
|
+
}
|
|
80
|
+
/** Constant-time token comparison; both sides are high-entropy secrets. */
|
|
81
|
+
function tokenMatches(presented, expected) {
|
|
82
|
+
if (!presented) return false;
|
|
83
|
+
const a = Buffer.from(presented);
|
|
84
|
+
const b = Buffer.from(expected);
|
|
85
|
+
if (a.length !== b.length) {
|
|
86
|
+
timingSafeEqual(b, b);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return timingSafeEqual(a, b);
|
|
90
|
+
}
|
|
91
|
+
/** Hex shape of an APNs device token as delivered by iOS (usually 64 chars). */
|
|
92
|
+
const APNS_TOKEN_PATTERN = /^[0-9a-f]{32,512}$/;
|
|
93
|
+
function isValidApnsToken(token) {
|
|
94
|
+
return typeof token === "string" && APNS_TOKEN_PATTERN.test(token);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Paired-device registry persisted as one JSON document. Whole-document
|
|
98
|
+
* writes (serialized, never interleaved); a corrupt file falls back to an
|
|
99
|
+
* empty registry rather than failing the plugin.
|
|
100
|
+
*/
|
|
101
|
+
var DeviceStore = class DeviceStore {
|
|
102
|
+
filePath;
|
|
103
|
+
devices = /* @__PURE__ */ new Map();
|
|
104
|
+
flushTail = Promise.resolve();
|
|
105
|
+
constructor(filePath) {
|
|
106
|
+
this.filePath = filePath;
|
|
107
|
+
}
|
|
108
|
+
static async load(filePath) {
|
|
109
|
+
const store = new DeviceStore(filePath);
|
|
110
|
+
try {
|
|
111
|
+
const raw = JSON.parse(await readFile(expandHome(filePath), "utf8"));
|
|
112
|
+
for (const rec of raw.devices ?? []) if (typeof rec.deviceId === "string") store.devices.set(rec.deviceId, rec);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error?.code === "ENOENT") {} else console.log("[deeppilot] device registry unreadable, starting empty: " + String(error));
|
|
115
|
+
}
|
|
116
|
+
return store;
|
|
117
|
+
}
|
|
118
|
+
touch(record, now) {
|
|
119
|
+
const existing = this.devices.get(record.deviceId);
|
|
120
|
+
if (existing) {
|
|
121
|
+
existing.lastSeenTs = now;
|
|
122
|
+
existing.deviceName = record.deviceName || existing.deviceName;
|
|
123
|
+
existing.appVersion = record.appVersion || existing.appVersion;
|
|
124
|
+
} else {
|
|
125
|
+
if (this.devices.size >= 64) {
|
|
126
|
+
let oldestId;
|
|
127
|
+
let oldestTs = Number.POSITIVE_INFINITY;
|
|
128
|
+
for (const [id, value] of this.devices) if (value.lastSeenTs < oldestTs) {
|
|
129
|
+
oldestTs = value.lastSeenTs;
|
|
130
|
+
oldestId = id;
|
|
131
|
+
}
|
|
132
|
+
if (oldestId !== void 0) this.devices.delete(oldestId);
|
|
133
|
+
}
|
|
134
|
+
this.devices.set(record.deviceId, {
|
|
135
|
+
...record,
|
|
136
|
+
firstSeenTs: now,
|
|
137
|
+
lastSeenTs: now
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
this.flush();
|
|
141
|
+
}
|
|
142
|
+
list() {
|
|
143
|
+
return [...this.devices.values()];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Store (or refresh) the APNs registration of a paired device. Idempotent:
|
|
147
|
+
* an unchanged registration does not rewrite the registry file, so the
|
|
148
|
+
* app's re-register-on-every-handshake policy stays write-quiet.
|
|
149
|
+
*/
|
|
150
|
+
setPushToken(deviceId, token, environment, categories, now) {
|
|
151
|
+
const normalized = token.toLowerCase();
|
|
152
|
+
if (!isValidApnsToken(normalized)) return;
|
|
153
|
+
let record = this.devices.get(deviceId);
|
|
154
|
+
if (!record) {
|
|
155
|
+
record = {
|
|
156
|
+
deviceId,
|
|
157
|
+
deviceName: "unknown",
|
|
158
|
+
appVersion: "unknown",
|
|
159
|
+
firstSeenTs: now,
|
|
160
|
+
lastSeenTs: now
|
|
161
|
+
};
|
|
162
|
+
this.devices.set(deviceId, record);
|
|
163
|
+
}
|
|
164
|
+
const next = {
|
|
165
|
+
token: normalized,
|
|
166
|
+
environment,
|
|
167
|
+
updatedAt: now
|
|
168
|
+
};
|
|
169
|
+
if (categories && typeof categories === "object") {
|
|
170
|
+
const clean = {};
|
|
171
|
+
for (const [key, value] of Object.entries(categories)) if (/^[a-z.]{1,64}$/.test(key) && typeof value === "boolean") clean[key] = value;
|
|
172
|
+
if (Object.keys(clean).length > 0) next.categories = clean;
|
|
173
|
+
}
|
|
174
|
+
const current = record.apns;
|
|
175
|
+
if (current && current.token === next.token && current.environment === next.environment && JSON.stringify(current.categories ?? {}) === JSON.stringify(next.categories ?? {})) return;
|
|
176
|
+
record.apns = next;
|
|
177
|
+
this.flush();
|
|
178
|
+
}
|
|
179
|
+
/** Drop a device's APNs registration (APNs reported the token unregistered). */
|
|
180
|
+
clearPushToken(deviceId) {
|
|
181
|
+
const record = this.devices.get(deviceId);
|
|
182
|
+
if (!record?.apns) return;
|
|
183
|
+
delete record.apns;
|
|
184
|
+
this.flush();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Drop every paired-device record. Used by token rotation: devices paired
|
|
188
|
+
* under the old token can no longer authenticate, so keeping their rows
|
|
189
|
+
* would paint a misleading "still paired" picture.
|
|
190
|
+
*/
|
|
191
|
+
clear() {
|
|
192
|
+
this.devices.clear();
|
|
193
|
+
this.flush();
|
|
194
|
+
}
|
|
195
|
+
/** Serialized so concurrent touches can never interleave half-written JSON. */
|
|
196
|
+
flush() {
|
|
197
|
+
const next = this.flushTail.then(() => this.writeFile());
|
|
198
|
+
this.flushTail = next.catch(() => {});
|
|
199
|
+
return next;
|
|
200
|
+
}
|
|
201
|
+
/** Resolves once every queued registry write has landed (test support). */
|
|
202
|
+
async drain() {
|
|
203
|
+
await this.flushTail;
|
|
204
|
+
}
|
|
205
|
+
async writeFile() {
|
|
206
|
+
const full = expandHome(this.filePath);
|
|
207
|
+
const body = JSON.stringify({
|
|
208
|
+
version: 1,
|
|
209
|
+
devices: this.list()
|
|
210
|
+
}, null, 2);
|
|
211
|
+
try {
|
|
212
|
+
await mkdir(dirname(full), { recursive: true });
|
|
213
|
+
const temp = `${full}.${randomBytes(6).toString("hex")}.tmp`;
|
|
214
|
+
await writeFile(temp, body + "\n", { mode: 384 });
|
|
215
|
+
await rename(temp, full);
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/connection.ts
|
|
221
|
+
const AUTH_TIMEOUT_MS = 5e3;
|
|
222
|
+
const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
223
|
+
"image/png",
|
|
224
|
+
"image/jpeg",
|
|
225
|
+
"image/webp",
|
|
226
|
+
"image/gif"
|
|
227
|
+
]);
|
|
228
|
+
const MAX_PROMPT_IMAGES = 4;
|
|
229
|
+
const MAX_BASE64_CHARS_PER_IMAGE = 8388608;
|
|
230
|
+
/** Bounds a single prompt's text; the frame itself is capped by ws maxPayload. */
|
|
231
|
+
const MAX_PROMPT_TEXT_CHARS = 262144;
|
|
232
|
+
const MAX_DEVICE_ID_CHARS = 128;
|
|
233
|
+
const MAX_DEVICE_NAME_CHARS = 64;
|
|
234
|
+
const MAX_APP_VERSION_CHARS = 32;
|
|
235
|
+
function sanitizeDeviceField(value, maxChars) {
|
|
236
|
+
return (typeof value === "string" ? value : String(value ?? "")).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxChars);
|
|
237
|
+
}
|
|
238
|
+
function helloTokenAccepted(transportAuthenticated, presentedToken, expectedToken) {
|
|
239
|
+
return transportAuthenticated === true || tokenMatches(presentedToken, expectedToken);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* One connected phone. Implements BridgeSink so the HostBridge can push
|
|
243
|
+
* projected frames and replays. Bearer/query credentials may authenticate the
|
|
244
|
+
* HTTP upgrade; otherwise the first hello frame is verified here.
|
|
245
|
+
*/
|
|
246
|
+
var BridgeConnection = class {
|
|
247
|
+
ws;
|
|
248
|
+
deps;
|
|
249
|
+
authenticated = false;
|
|
250
|
+
helloTimer;
|
|
251
|
+
openSessions = /* @__PURE__ */ new Set();
|
|
252
|
+
/** Sanitized device identity from hello; needed for push registration. */
|
|
253
|
+
deviceId;
|
|
254
|
+
constructor(ws, deps) {
|
|
255
|
+
this.ws = ws;
|
|
256
|
+
this.deps = deps;
|
|
257
|
+
ws.on("message", (data) => {
|
|
258
|
+
this.onMessage(String(data));
|
|
259
|
+
});
|
|
260
|
+
ws.on("close", () => {
|
|
261
|
+
this.onClose();
|
|
262
|
+
deps.onClosed?.(this);
|
|
263
|
+
});
|
|
264
|
+
ws.on("error", () => {});
|
|
265
|
+
this.helloTimer = setTimeout(() => {
|
|
266
|
+
if (!this.authenticated) this.close(4402, "auth timeout");
|
|
267
|
+
}, AUTH_TIMEOUT_MS);
|
|
268
|
+
}
|
|
269
|
+
/** Hard-drop the socket (server-side stale sweep). */
|
|
270
|
+
terminate() {
|
|
271
|
+
this.ws.terminate();
|
|
272
|
+
}
|
|
273
|
+
/** Device identity once hello succeeded; undefined before that. */
|
|
274
|
+
get connectedDeviceId() {
|
|
275
|
+
return this.authenticated ? this.deviceId : void 0;
|
|
276
|
+
}
|
|
277
|
+
push(type, payload, seq) {
|
|
278
|
+
if (this.deps.debug === true) this.deps.log("push " + type + " seq=" + String(seq));
|
|
279
|
+
this.send(type, payload, void 0, seq);
|
|
280
|
+
}
|
|
281
|
+
replay(entries) {
|
|
282
|
+
for (const entry of entries) this.push(entry.type, entry.payload, entry.seq);
|
|
283
|
+
}
|
|
284
|
+
replayDone() {
|
|
285
|
+
this.push("s2c.resume.done", {});
|
|
286
|
+
}
|
|
287
|
+
resync() {
|
|
288
|
+
this.push("s2c.resync", { reason: "gap" });
|
|
289
|
+
}
|
|
290
|
+
lastCursor() {
|
|
291
|
+
return this.deps.bridge.currentCursor();
|
|
292
|
+
}
|
|
293
|
+
onClose() {
|
|
294
|
+
if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
|
|
295
|
+
for (const id of this.openSessions) this.deps.bridge.markSinkClosed(this, id);
|
|
296
|
+
this.openSessions.clear();
|
|
297
|
+
this.deps.bridge.dropSinkSessions(this);
|
|
298
|
+
if (this.authenticated) this.deps.bridge.removeSink(this);
|
|
299
|
+
}
|
|
300
|
+
close(code, reason) {
|
|
301
|
+
try {
|
|
302
|
+
this.ws.close(code, reason);
|
|
303
|
+
} catch {
|
|
304
|
+
this.ws.terminate();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
send(type, payload, id, seq) {
|
|
308
|
+
const envelope = {
|
|
309
|
+
v: 1,
|
|
310
|
+
type,
|
|
311
|
+
ts: Date.now(),
|
|
312
|
+
...id !== void 0 ? { id } : {},
|
|
313
|
+
...seq !== void 0 ? { seq } : {},
|
|
314
|
+
payload
|
|
315
|
+
};
|
|
316
|
+
if (this.ws.readyState === this.ws.OPEN) this.ws.send(JSON.stringify(envelope));
|
|
317
|
+
}
|
|
318
|
+
fail(id, code, message) {
|
|
319
|
+
this.send("s2c.error", {
|
|
320
|
+
code,
|
|
321
|
+
message
|
|
322
|
+
}, id);
|
|
323
|
+
}
|
|
324
|
+
lastActivity = Date.now();
|
|
325
|
+
/** True when no inbound frame arrived within maxIdleMs. */
|
|
326
|
+
isStale(now, maxIdleMs) {
|
|
327
|
+
return now - this.lastActivity > maxIdleMs;
|
|
328
|
+
}
|
|
329
|
+
async onMessage(raw) {
|
|
330
|
+
this.lastActivity = Date.now();
|
|
331
|
+
let env;
|
|
332
|
+
try {
|
|
333
|
+
env = JSON.parse(raw);
|
|
334
|
+
} catch {
|
|
335
|
+
this.fail(void 0, "E_PROTOCOL", "frame is not valid JSON");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (env.v !== 1) {
|
|
339
|
+
this.fail(env.id, "E_UNSUPPORTED", "unsupported protocol version");
|
|
340
|
+
this.close(4500, "protocol version mismatch");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (!this.authenticated) {
|
|
344
|
+
if (env.type === "c2s.ping") {
|
|
345
|
+
this.send("s2c.pong", { serverTime: Date.now() }, env.id);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (env.type === "c2s.hello.auth") {
|
|
349
|
+
await this.hello(env);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
this.fail(env.id, "E_PROTOCOL", "authenticate first");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
switch (env.type) {
|
|
356
|
+
case "c2s.ping":
|
|
357
|
+
this.send("s2c.pong", { serverTime: Date.now() }, env.id);
|
|
358
|
+
return;
|
|
359
|
+
case "c2s.sessions.list":
|
|
360
|
+
this.send("s2c.sessions.snapshot", {
|
|
361
|
+
full: true,
|
|
362
|
+
sessions: this.deps.bridge.listSessions()
|
|
363
|
+
}, env.id);
|
|
364
|
+
return;
|
|
365
|
+
case "c2s.workspaces.list": {
|
|
366
|
+
if (!this.deps.bridge.capabilities.projectSelection) return this.fail(env.id, "E_UNSUPPORTED", "project selection unavailable on this host version");
|
|
367
|
+
const result = await this.deps.bridge.listWorkspaces();
|
|
368
|
+
if (!result.ok) return this.fail(env.id, managementErrorCode(result.kind), result.message);
|
|
369
|
+
this.send("s2c.workspaces.snapshot", { workspaces: result.value }, env.id);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
case "c2s.directory.list": {
|
|
373
|
+
const p = env.payload;
|
|
374
|
+
if (p?.path !== void 0 && typeof p.path !== "string") return this.fail(env.id, "E_PROTOCOL", "path must be a string");
|
|
375
|
+
const result = await this.deps.bridge.listDirectory(p?.path);
|
|
376
|
+
if (!result.ok) return this.fail(env.id, managementErrorCode(result.kind), result.message);
|
|
377
|
+
this.send("s2c.directory.listing", result.value, env.id);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
case "c2s.directory.pick": {
|
|
381
|
+
const result = await this.deps.bridge.pickDirectory();
|
|
382
|
+
if (!result.ok) return this.fail(env.id, managementErrorCode(result.kind), result.message);
|
|
383
|
+
this.send("s2c.directory.picked", { path: result.value }, env.id);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
case "c2s.workspace.create": {
|
|
387
|
+
const p = env.payload;
|
|
388
|
+
const path = typeof p?.path === "string" ? p.path.trim() : "";
|
|
389
|
+
if (!path) return this.fail(env.id, "E_PROTOCOL", "non-empty path required");
|
|
390
|
+
if (!this.deps.bridge.capabilities.projectSelection) return this.fail(env.id, "E_UNSUPPORTED", "project selection unavailable on this host version");
|
|
391
|
+
const result = await this.deps.bridge.createWorkspace(path);
|
|
392
|
+
if (!result.ok) return this.fail(env.id, managementErrorCode(result.kind), result.message);
|
|
393
|
+
this.send("s2c.workspace.created", result.value, env.id);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
case "c2s.session.open": {
|
|
397
|
+
const p = env.payload;
|
|
398
|
+
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
399
|
+
this.openSessions.add(p.sessionId);
|
|
400
|
+
this.deps.bridge.markSinkOpen(this, p.sessionId);
|
|
401
|
+
if (!await this.deps.bridge.openSession(this, p.sessionId, p.tailCount ?? 100)) this.fail(env.id, "E_NOT_FOUND", "session history unavailable");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
case "c2s.session.close": {
|
|
405
|
+
const p = env.payload;
|
|
406
|
+
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
407
|
+
this.openSessions.delete(p.sessionId);
|
|
408
|
+
this.deps.bridge.markSinkClosed(this, p.sessionId);
|
|
409
|
+
this.send("s2c.ack", {}, env.id);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
case "c2s.session.create": {
|
|
413
|
+
const p = env.payload;
|
|
414
|
+
const workspaceId = typeof p?.workspaceId === "string" ? p.workspaceId.trim() : "";
|
|
415
|
+
const cwd = typeof p?.cwd === "string" ? p.cwd.trim() : "";
|
|
416
|
+
if (workspaceId && cwd) return this.fail(env.id, "E_PROTOCOL", "workspaceId and cwd are mutually exclusive");
|
|
417
|
+
const newId = await this.deps.bridge.createSession({
|
|
418
|
+
...workspaceId ? { workspaceId } : {},
|
|
419
|
+
...cwd ? { cwd } : {}
|
|
420
|
+
});
|
|
421
|
+
if (!newId) return this.fail(env.id, "E_INTERNAL", "session create failed");
|
|
422
|
+
this.send("s2c.ack", { sessionId: newId }, env.id);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
case "c2s.session.rename": {
|
|
426
|
+
const p = env.payload;
|
|
427
|
+
const title = typeof p?.title === "string" ? p.title.trim() : "";
|
|
428
|
+
if (!p?.sessionId || title.length === 0) return this.fail(env.id, "E_PROTOCOL", "sessionId and non-empty title required");
|
|
429
|
+
if (!this.deps.bridge.capabilities.sessionManagement) return this.fail(env.id, "E_UNSUPPORTED", "session management unavailable on this host version");
|
|
430
|
+
const result = await this.deps.bridge.renameSession(p.sessionId, title);
|
|
431
|
+
if (!result.ok) {
|
|
432
|
+
const code = result.kind === "not-found" ? "E_NOT_FOUND" : result.kind === "busy" ? "E_BUSY" : result.kind === "unsupported" ? "E_UNSUPPORTED" : result.kind === "invalid" ? "E_PROTOCOL" : "E_INTERNAL";
|
|
433
|
+
return this.fail(env.id, code, result.message);
|
|
434
|
+
}
|
|
435
|
+
this.send("s2c.session.renamed", {
|
|
436
|
+
sessionId: p.sessionId,
|
|
437
|
+
title: result.value
|
|
438
|
+
}, env.id);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
case "c2s.session.archive": {
|
|
442
|
+
const p = env.payload;
|
|
443
|
+
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
444
|
+
if (!this.deps.bridge.capabilities.sessionManagement) return this.fail(env.id, "E_UNSUPPORTED", "session management unavailable on this host version");
|
|
445
|
+
const result = await this.deps.bridge.archiveSession(p.sessionId);
|
|
446
|
+
if (!result.ok) {
|
|
447
|
+
const code = result.kind === "not-found" ? "E_NOT_FOUND" : result.kind === "busy" ? "E_BUSY" : result.kind === "unsupported" ? "E_UNSUPPORTED" : result.kind === "invalid" ? "E_PROTOCOL" : "E_INTERNAL";
|
|
448
|
+
return this.fail(env.id, code, result.message);
|
|
449
|
+
}
|
|
450
|
+
this.send("s2c.session.archived", { sessionId: p.sessionId }, env.id);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
case "c2s.session.cancel": {
|
|
454
|
+
const p = env.payload;
|
|
455
|
+
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
456
|
+
const result = await this.deps.bridge.cancelSession(p.sessionId);
|
|
457
|
+
if (!result.ok) {
|
|
458
|
+
const code = result.kind === "not-found" ? "E_NOT_FOUND" : result.kind === "busy" ? "E_BUSY" : result.kind === "unsupported" ? "E_UNSUPPORTED" : result.kind === "invalid" ? "E_PROTOCOL" : "E_INTERNAL";
|
|
459
|
+
return this.fail(env.id, code, result.message);
|
|
460
|
+
}
|
|
461
|
+
this.send("s2c.ack", { sessionId: p.sessionId }, env.id);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
case "c2s.session.history": {
|
|
465
|
+
const p = env.payload;
|
|
466
|
+
if (!p?.sessionId || typeof p.beforeSeq !== "number") return this.fail(env.id, "E_PROTOCOL", "sessionId and beforeSeq required");
|
|
467
|
+
if (!await this.deps.bridge.historyPage(this, p.sessionId, p.beforeSeq, Math.min(p.limit ?? 100, 500))) this.fail(env.id, "E_NOT_FOUND", "history unavailable");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
case "c2s.session.attachment": {
|
|
471
|
+
const p = env.payload;
|
|
472
|
+
if (!p?.sessionId || typeof p.attachmentId !== "string" || p.attachmentId.length === 0) return this.fail(env.id, "E_PROTOCOL", "sessionId and attachmentId required");
|
|
473
|
+
const image = await this.deps.bridge.attachmentData(p.sessionId, p.attachmentId);
|
|
474
|
+
if (!image) return this.fail(env.id, "E_NOT_FOUND", "attachment unavailable");
|
|
475
|
+
this.send("s2c.ack", image, env.id);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
case "c2s.session.models": {
|
|
479
|
+
const p = env.payload;
|
|
480
|
+
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
481
|
+
if (!this.deps.bridge.capabilities.models) return this.fail(env.id, "E_UNSUPPORTED", "model selection unavailable on this host version");
|
|
482
|
+
const result = await this.deps.bridge.sessionModels(p.sessionId);
|
|
483
|
+
if (!result.ok) {
|
|
484
|
+
const code = result.kind === "not-found" ? "E_NOT_FOUND" : result.kind === "busy" ? "E_BUSY" : result.kind === "unsupported" ? "E_UNSUPPORTED" : result.kind === "unavailable" ? "E_NOT_FOUND" : "E_INTERNAL";
|
|
485
|
+
return this.fail(env.id, code, result.message);
|
|
486
|
+
}
|
|
487
|
+
this.send("s2c.session.models", {
|
|
488
|
+
sessionId: p.sessionId,
|
|
489
|
+
...result.value
|
|
490
|
+
}, env.id);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
case "c2s.session.selectModel": {
|
|
494
|
+
const p = env.payload;
|
|
495
|
+
if (!p?.sessionId || !p.provider?.trim() || !p.model?.trim()) return this.fail(env.id, "E_PROTOCOL", "sessionId, provider and model required");
|
|
496
|
+
if (!this.deps.bridge.capabilities.models) return this.fail(env.id, "E_UNSUPPORTED", "model selection unavailable on this host version");
|
|
497
|
+
const result = await this.deps.bridge.selectSessionModel(p.sessionId, {
|
|
498
|
+
provider: p.provider.trim(),
|
|
499
|
+
model: p.model.trim(),
|
|
500
|
+
...p.reasoningEffort?.trim() ? { reasoningEffort: p.reasoningEffort.trim() } : {}
|
|
501
|
+
});
|
|
502
|
+
if (!result.ok) {
|
|
503
|
+
const code = result.kind === "not-found" ? "E_NOT_FOUND" : result.kind === "busy" ? "E_BUSY" : result.kind === "unsupported" ? "E_UNSUPPORTED" : result.kind === "unavailable" ? "E_NOT_FOUND" : "E_INTERNAL";
|
|
504
|
+
return this.fail(env.id, code, result.message);
|
|
505
|
+
}
|
|
506
|
+
this.send("s2c.session.modelSelected", {
|
|
507
|
+
sessionId: p.sessionId,
|
|
508
|
+
selected: result.value
|
|
509
|
+
}, env.id);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
case "c2s.session.sendPrompt": {
|
|
513
|
+
const p = env.payload;
|
|
514
|
+
const text = typeof p?.text === "string" ? p.text : "";
|
|
515
|
+
const rawImages = Array.isArray(p?.images) ? p.images : [];
|
|
516
|
+
if (!p?.sessionId || text.trim().length === 0 && rawImages.length === 0) return this.fail(env.id, "E_PROTOCOL", "sessionId and text or images required");
|
|
517
|
+
if (text.length > MAX_PROMPT_TEXT_CHARS) return this.fail(env.id, "E_PROTOCOL", "prompt text too long");
|
|
518
|
+
if (rawImages.length > MAX_PROMPT_IMAGES) return this.fail(env.id, "E_PROTOCOL", "too many images");
|
|
519
|
+
const images = [];
|
|
520
|
+
for (const image of rawImages) {
|
|
521
|
+
if (!IMAGE_MEDIA_TYPES.has(String(image?.mediaType)) || typeof image?.data !== "string" || image.data.length === 0 || image.data.length > MAX_BASE64_CHARS_PER_IMAGE) return this.fail(env.id, "E_PROTOCOL", "invalid image attachment");
|
|
522
|
+
images.push({
|
|
523
|
+
mediaType: image.mediaType,
|
|
524
|
+
data: image.data,
|
|
525
|
+
...typeof image.name === "string" && image.name.trim().length > 0 ? { name: image.name.trim().slice(0, 120) } : {}
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images);
|
|
529
|
+
if (userSeq === null) return this.fail(env.id, "E_BUSY", "session busy or unavailable");
|
|
530
|
+
this.send("s2c.ack", { userSeq }, env.id);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
case "c2s.approval.respond": {
|
|
534
|
+
const p = env.payload;
|
|
535
|
+
if (!p?.requestId || p.decision !== "allow" && p.decision !== "deny") return this.fail(env.id, "E_PROTOCOL", "requestId and decision required");
|
|
536
|
+
if (!await this.deps.bridge.respondApproval(p.requestId, p.decision)) return this.fail(env.id, "E_NOT_FOUND", "approval not pending");
|
|
537
|
+
this.send("s2c.ack", {}, env.id);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
case "c2s.question.respond": {
|
|
541
|
+
const p = env.payload;
|
|
542
|
+
if (!p?.requestId || !Array.isArray(p.answers)) return this.fail(env.id, "E_PROTOCOL", "requestId and answers required");
|
|
543
|
+
if (!await this.deps.bridge.respondQuestion(p.requestId, p.answers)) return this.fail(env.id, "E_NOT_FOUND", "question not pending");
|
|
544
|
+
this.send("s2c.ack", {}, env.id);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
case "c2s.push.register": {
|
|
548
|
+
const p = env.payload;
|
|
549
|
+
const token = typeof p?.deviceToken === "string" ? p.deviceToken.trim() : "";
|
|
550
|
+
if (!isValidApnsToken(token)) return this.fail(env.id, "E_PROTOCOL", "hex deviceToken (32-512 chars) required");
|
|
551
|
+
const environment = p?.environment === "production" ? "production" : "development";
|
|
552
|
+
const categories = typeof p?.categories === "object" && p.categories !== null ? p.categories : void 0;
|
|
553
|
+
if (!this.deviceId || !this.authenticated) return this.fail(env.id, "E_PROTOCOL", "authenticate first");
|
|
554
|
+
if (typeof p?.enrollKey === "string") {
|
|
555
|
+
const enrollKey = p.enrollKey.trim().replace(/[^\x20-\x7e]/g, "").slice(0, 128);
|
|
556
|
+
if (enrollKey.length >= 8 && enrollKey.length <= 128) await this.deps.onPushEnrollKey?.(enrollKey);
|
|
557
|
+
}
|
|
558
|
+
this.deps.devices.setPushToken(this.deviceId, token, environment, categories, Date.now());
|
|
559
|
+
if (!this.deps.bridge.capabilities.push) {
|
|
560
|
+
if (this.deps.debug === true) this.deps.log("push register held: bridge not ready");
|
|
561
|
+
return this.fail(env.id, "E_UNSUPPORTED", "push is not configured on this bridge");
|
|
562
|
+
}
|
|
563
|
+
if (this.deps.debug === true) this.deps.log("push token registered env=" + environment);
|
|
564
|
+
this.send("s2c.ack", { enabled: true }, env.id);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
default: this.fail(env.id, "E_PROTOCOL", "unknown type: " + env.type);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async hello(env) {
|
|
571
|
+
const p = env.payload ?? {};
|
|
572
|
+
if (!helloTokenAccepted(this.deps.transportAuthenticated, p.token, this.deps.expectedToken)) {
|
|
573
|
+
this.fail(env.id, "E_AUTH", "token missing or invalid");
|
|
574
|
+
this.close(4401, "invalid token");
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (!p.deviceId) {
|
|
578
|
+
this.fail(env.id, "E_PROTOCOL", "deviceId required");
|
|
579
|
+
this.close(4403, "deviceId required");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const deviceId = sanitizeDeviceField(p.deviceId, MAX_DEVICE_ID_CHARS);
|
|
583
|
+
if (!deviceId) {
|
|
584
|
+
this.fail(env.id, "E_PROTOCOL", "deviceId required");
|
|
585
|
+
this.close(4403, "deviceId required");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const deviceName = sanitizeDeviceField(p.deviceName, MAX_DEVICE_NAME_CHARS) || "unknown";
|
|
589
|
+
const appVersion = sanitizeDeviceField(p.appVersion, MAX_APP_VERSION_CHARS) || "unknown";
|
|
590
|
+
this.authenticated = true;
|
|
591
|
+
this.deviceId = deviceId;
|
|
592
|
+
if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
|
|
593
|
+
this.deps.devices.touch({
|
|
594
|
+
deviceId,
|
|
595
|
+
deviceName,
|
|
596
|
+
appVersion
|
|
597
|
+
}, Date.now());
|
|
598
|
+
this.deps.log("device paired: " + deviceName + " (" + deviceId + ")");
|
|
599
|
+
const cursor = typeof p.resumeCursor === "number" && p.resumeCursor >= 0 ? p.resumeCursor : void 0;
|
|
600
|
+
const canResume = cursor !== void 0 && this.deps.bridge.canResumeFrom(cursor);
|
|
601
|
+
this.send("s2c.welcome", {
|
|
602
|
+
protocolVersion: 1,
|
|
603
|
+
serverVersion: this.deps.serverVersion,
|
|
604
|
+
capabilities: this.deps.bridge.capabilities,
|
|
605
|
+
cursor: this.deps.bridge.currentCursor(),
|
|
606
|
+
resumed: canResume
|
|
607
|
+
}, env.id);
|
|
608
|
+
this.deps.bridge.addSink(this);
|
|
609
|
+
if (cursor !== void 0) {
|
|
610
|
+
if (canResume) this.deps.bridge.resumeFrom(cursor, this);
|
|
611
|
+
else this.resync();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
function managementErrorCode(kind) {
|
|
616
|
+
switch (kind) {
|
|
617
|
+
case "unsupported": return "E_UNSUPPORTED";
|
|
618
|
+
case "not-found": return "E_NOT_FOUND";
|
|
619
|
+
case "busy": return "E_BUSY";
|
|
620
|
+
case "invalid": return "E_PROTOCOL";
|
|
621
|
+
case "internal": return "E_INTERNAL";
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
//#endregion
|
|
625
|
+
//#region src/host-bridge.ts
|
|
626
|
+
/**
|
|
627
|
+
* Subagent sessions are host-internal workers of a parent conversation.
|
|
628
|
+
* They must never surface on the phone: not in the project/session list,
|
|
629
|
+
* and not as turn-completion pushes for a session the device cannot open.
|
|
630
|
+
*/
|
|
631
|
+
function isSubagentRow(row) {
|
|
632
|
+
return row.origin === "subagent" || typeof row.parentSessionId === "string" && row.parentSessionId.length > 0;
|
|
633
|
+
}
|
|
634
|
+
function unwrapStreamItem(item) {
|
|
635
|
+
const nested = item.payload;
|
|
636
|
+
if (nested && typeof nested === "object" && typeof nested.type === "string") return nested.rpcId || !item.rpcId ? nested : {
|
|
637
|
+
...nested,
|
|
638
|
+
rpcId: item.rpcId
|
|
639
|
+
};
|
|
640
|
+
return item;
|
|
641
|
+
}
|
|
642
|
+
const MAX_RING_DEFAULT = 2e3;
|
|
643
|
+
/**
|
|
644
|
+
* Process-wide bridge state: session mirror, pending approvals/questions,
|
|
645
|
+
* and the per-device replay ring. Consumes the in-process mux/host streams
|
|
646
|
+
* and fans projected pushes out to every registered sink.
|
|
647
|
+
*/
|
|
648
|
+
let BRIDGE_SEQ = 0;
|
|
649
|
+
var HostBridge = class {
|
|
650
|
+
apiProxy;
|
|
651
|
+
historyBufferMax;
|
|
652
|
+
id = ++BRIDGE_SEQ;
|
|
653
|
+
summaries = /* @__PURE__ */ new Map();
|
|
654
|
+
approvals = /* @__PURE__ */ new Map();
|
|
655
|
+
questions = /* @__PURE__ */ new Map();
|
|
656
|
+
archivedSessionIds = /* @__PURE__ */ new Set();
|
|
657
|
+
subagentSessionIds = /* @__PURE__ */ new Set();
|
|
658
|
+
sinks = /* @__PURE__ */ new Set();
|
|
659
|
+
ring = [];
|
|
660
|
+
cursor = 0;
|
|
661
|
+
abort = new AbortController();
|
|
662
|
+
constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT) {
|
|
663
|
+
this.apiProxy = apiProxy;
|
|
664
|
+
this.historyBufferMax = historyBufferMax;
|
|
665
|
+
}
|
|
666
|
+
pushOutlet;
|
|
667
|
+
/**
|
|
668
|
+
* Wire the offline-push fan-out. Present ⇒ welcome advertises the `push`
|
|
669
|
+
* capability and notify-worthy events are mirrored to APNs.
|
|
670
|
+
*/
|
|
671
|
+
setPushOutlet(outlet) {
|
|
672
|
+
this.pushOutlet = outlet;
|
|
673
|
+
}
|
|
674
|
+
get capabilities() {
|
|
675
|
+
return {
|
|
676
|
+
historyPaging: true,
|
|
677
|
+
replay: true,
|
|
678
|
+
approvals: true,
|
|
679
|
+
questions: true,
|
|
680
|
+
models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
|
|
681
|
+
sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
|
|
682
|
+
projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
|
|
683
|
+
push: this.pushOutlet?.isAvailable() === true
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
diagnostic(message) {
|
|
687
|
+
console.log("[deeppilot] " + message);
|
|
688
|
+
}
|
|
689
|
+
currentCursor() {
|
|
690
|
+
return this.cursor;
|
|
691
|
+
}
|
|
692
|
+
addSink(sink) {
|
|
693
|
+
this.sinks.add(sink);
|
|
694
|
+
}
|
|
695
|
+
removeSink(sink) {
|
|
696
|
+
this.sinks.delete(sink);
|
|
697
|
+
}
|
|
698
|
+
/** Whether the ring still holds everything after the cursor. */
|
|
699
|
+
canResumeFrom(cursor) {
|
|
700
|
+
const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
|
|
701
|
+
return cursor + 1 >= oldest;
|
|
702
|
+
}
|
|
703
|
+
sinkSessions = /* @__PURE__ */ new Map();
|
|
704
|
+
lastAssistantText = /* @__PURE__ */ new Map();
|
|
705
|
+
/** Mark a sink as actively viewing a session (suppresses its turn notifications). */
|
|
706
|
+
markSinkOpen(sink, sessionId) {
|
|
707
|
+
let set = this.sinkSessions.get(sink);
|
|
708
|
+
if (!set) {
|
|
709
|
+
set = /* @__PURE__ */ new Set();
|
|
710
|
+
this.sinkSessions.set(sink, set);
|
|
711
|
+
}
|
|
712
|
+
set.add(sessionId);
|
|
713
|
+
}
|
|
714
|
+
markSinkClosed(sink, sessionId) {
|
|
715
|
+
this.sinkSessions.get(sink)?.delete(sessionId);
|
|
716
|
+
}
|
|
717
|
+
dropSinkSessions(sink) {
|
|
718
|
+
this.sinkSessions.delete(sink);
|
|
719
|
+
}
|
|
720
|
+
isViewedBy(sink, sessionId) {
|
|
721
|
+
return this.sinkSessions.get(sink)?.has(sessionId) ?? false;
|
|
722
|
+
}
|
|
723
|
+
/** F-9: when a turn completes, notify every device not viewing the session. */
|
|
724
|
+
emitTurnCompletedNotify(sessionId, ok) {
|
|
725
|
+
if (this.subagentSessionIds.has(sessionId)) return;
|
|
726
|
+
const row = this.summaries.get(sessionId);
|
|
727
|
+
const title = ok ? "任务完成" : "任务异常结束";
|
|
728
|
+
const body = this.lastAssistantText.get(sessionId) ?? row?.title ?? "";
|
|
729
|
+
const truncatedBody = body.length > 120 ? body.slice(0, 119) + "…" : body;
|
|
730
|
+
const category = ok ? "turn.completed" : "session.error";
|
|
731
|
+
const notificationId = "n-" + (this.cursor + 1);
|
|
732
|
+
this.record("s2c.notify", {
|
|
733
|
+
notificationId,
|
|
734
|
+
category,
|
|
735
|
+
sessionId,
|
|
736
|
+
title,
|
|
737
|
+
body: truncatedBody,
|
|
738
|
+
ts: Date.now()
|
|
739
|
+
}, (sink) => this.isViewedBy(sink, sessionId));
|
|
740
|
+
this.fanOutPush({
|
|
741
|
+
notificationId,
|
|
742
|
+
category,
|
|
743
|
+
sessionId,
|
|
744
|
+
title,
|
|
745
|
+
body: truncatedBody
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Mirror one notification-worthy event to offline devices. Fire-and-forget:
|
|
750
|
+
* push failures must never block or break the WS data plane.
|
|
751
|
+
*/
|
|
752
|
+
fanOutPush(notification) {
|
|
753
|
+
try {
|
|
754
|
+
this.pushOutlet?.fanOut(notification);
|
|
755
|
+
} catch {}
|
|
756
|
+
}
|
|
757
|
+
/** Remember the latest assistant text so notifications can quote it. */
|
|
758
|
+
captureAssistantText(sessionId, event) {
|
|
759
|
+
if (event.type !== "assistant/message") return;
|
|
760
|
+
const text = messageText(event.data).trim();
|
|
761
|
+
if (text.length > 0) this.lastAssistantText.set(sessionId, text.slice(-160));
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Replay buffered pushes after the given cursor; false when the gap is
|
|
765
|
+
* unrecoverable. Frames go to `target` only — replaying into every sink
|
|
766
|
+
* duplicated the whole window onto devices that never asked for it.
|
|
767
|
+
*/
|
|
768
|
+
resumeFrom(cursor, target) {
|
|
769
|
+
const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
|
|
770
|
+
if (cursor + 1 < oldest) return false;
|
|
771
|
+
const receivers = target !== void 0 ? [target] : [...this.sinks];
|
|
772
|
+
for (const entry of this.ring) if (entry.seq > cursor) for (const sink of receivers) sink.replay([entry]);
|
|
773
|
+
for (const sink of receivers) sink.replayDone();
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
record(type, payload, except) {
|
|
777
|
+
this.cursor += 1;
|
|
778
|
+
const entry = {
|
|
779
|
+
seq: this.cursor,
|
|
780
|
+
type,
|
|
781
|
+
payload
|
|
782
|
+
};
|
|
783
|
+
this.ring.push(entry);
|
|
784
|
+
if (this.ring.length > this.historyBufferMax) this.ring.splice(0, this.ring.length - this.historyBufferMax);
|
|
785
|
+
for (const sink of this.sinks) {
|
|
786
|
+
if (except && except(sink)) continue;
|
|
787
|
+
sink.push(type, payload, entry.seq);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/** Start consuming host + mux streams. Idempotent; aborts on dispose(). */
|
|
791
|
+
start() {
|
|
792
|
+
this.runHostStream();
|
|
793
|
+
this.runMuxStream();
|
|
794
|
+
this.refreshSummaries();
|
|
795
|
+
}
|
|
796
|
+
dispose() {
|
|
797
|
+
this.abort.abort();
|
|
798
|
+
this.sinks.clear();
|
|
799
|
+
}
|
|
800
|
+
async runHostStream() {
|
|
801
|
+
try {
|
|
802
|
+
for await (const item of this.apiProxy.events.host({ rpcId: randomUUID() }, this.abort.signal)) {
|
|
803
|
+
const frame = unwrapStreamItem(item);
|
|
804
|
+
this.onHostFrame(frame);
|
|
805
|
+
}
|
|
806
|
+
} catch {}
|
|
807
|
+
}
|
|
808
|
+
async runMuxStream() {
|
|
809
|
+
try {
|
|
810
|
+
for await (const item of this.apiProxy.events.mux({ rpcId: randomUUID() }, this.abort.signal)) {
|
|
811
|
+
const frame = unwrapStreamItem(item);
|
|
812
|
+
this.onMuxFrame(frame);
|
|
813
|
+
}
|
|
814
|
+
} catch {}
|
|
815
|
+
}
|
|
816
|
+
onHostFrame(frame) {
|
|
817
|
+
switch (frame.type) {
|
|
818
|
+
case "host/session-added":
|
|
819
|
+
case "host/session-removed":
|
|
820
|
+
case "host/workspace-changed":
|
|
821
|
+
case "host/workspace-removed":
|
|
822
|
+
case "host/workspace-order-changed":
|
|
823
|
+
this.refreshSummaries();
|
|
824
|
+
break;
|
|
825
|
+
case "host/archived-sessions-changed": {
|
|
826
|
+
const archived = frame.archivedSessionIds;
|
|
827
|
+
if (Array.isArray(archived)) this.archivedSessionIds = new Set(archived.map(String));
|
|
828
|
+
this.refreshSummaries();
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
case "host/session-status": {
|
|
832
|
+
const p = frame;
|
|
833
|
+
const row = this.summaries.get(String(p.sessionId));
|
|
834
|
+
if (row && typeof p?.running === "boolean") {
|
|
835
|
+
row.status = p.running ? "running" : "idle";
|
|
836
|
+
this.pushSummary(row);
|
|
837
|
+
}
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
onMuxFrame(frame) {
|
|
843
|
+
switch (frame.type) {
|
|
844
|
+
case "session/event": {
|
|
845
|
+
const event = frame.event;
|
|
846
|
+
const sessionId = String(frame.sessionId ?? "");
|
|
847
|
+
if (!event || !sessionId) break;
|
|
848
|
+
this.noteActivity(sessionId, event);
|
|
849
|
+
this.captureAssistantText(sessionId, event);
|
|
850
|
+
const projection = projectEvent(sessionId, event);
|
|
851
|
+
if (projection) this.record("s2c.session.event", {
|
|
852
|
+
sessionId,
|
|
853
|
+
kind: projection.kind,
|
|
854
|
+
seq: event.seq,
|
|
855
|
+
data: projection.data
|
|
856
|
+
});
|
|
857
|
+
if (projection?.kind === "turn.end") this.emitTurnCompletedNotify(sessionId, projection.data.ok === true);
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
case "session/projection": {
|
|
861
|
+
const p = frame;
|
|
862
|
+
if (!p.sessionId) break;
|
|
863
|
+
this.applyProjection(p.sessionId, String(p.key ?? ""), p.value);
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
case "approval/requested": {
|
|
867
|
+
const p = frame;
|
|
868
|
+
if (!p.approvalId || !frame.rpcId) break;
|
|
869
|
+
const toolName = String(p.toolName ?? "tool");
|
|
870
|
+
const summary = String(p.reason ?? "");
|
|
871
|
+
this.approvals.set(p.approvalId, {
|
|
872
|
+
rpcId: frame.rpcId,
|
|
873
|
+
sessionId: String(p.sessionId ?? ""),
|
|
874
|
+
toolName,
|
|
875
|
+
reason: summary
|
|
876
|
+
});
|
|
877
|
+
this.record("s2c.pending.approval", {
|
|
878
|
+
requestId: p.approvalId,
|
|
879
|
+
sessionId: String(p.sessionId ?? ""),
|
|
880
|
+
toolName,
|
|
881
|
+
summary,
|
|
882
|
+
riskLevel: riskOf(toolName)
|
|
883
|
+
});
|
|
884
|
+
this.fanOutPush({
|
|
885
|
+
notificationId: "apr-" + p.approvalId,
|
|
886
|
+
category: "approval.required",
|
|
887
|
+
sessionId: String(p.sessionId ?? ""),
|
|
888
|
+
title: "需要批准",
|
|
889
|
+
body: toolName + ": " + summary
|
|
890
|
+
});
|
|
891
|
+
this.bumpPendingFlags(String(p.sessionId ?? ""));
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
case "approval/resolved": {
|
|
895
|
+
const p = frame;
|
|
896
|
+
if (!p.approvalId) break;
|
|
897
|
+
const pending = this.approvals.get(p.approvalId);
|
|
898
|
+
this.approvals.delete(p.approvalId);
|
|
899
|
+
this.record("s2c.pending.cleared", { requestId: p.approvalId });
|
|
900
|
+
if (pending) this.bumpPendingFlags(pending.sessionId);
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
case "question/requested": {
|
|
904
|
+
const p = frame;
|
|
905
|
+
if (!frame.rpcId) break;
|
|
906
|
+
const requestId = "q-" + frame.rpcId;
|
|
907
|
+
const sessionId = String(p?.sessionId ?? "");
|
|
908
|
+
this.questions.set(requestId, {
|
|
909
|
+
rpcId: frame.rpcId,
|
|
910
|
+
sessionId,
|
|
911
|
+
questions: p?.questions
|
|
912
|
+
});
|
|
913
|
+
this.record("s2c.pending.question", {
|
|
914
|
+
requestId,
|
|
915
|
+
sessionId,
|
|
916
|
+
questions: p?.questions ?? []
|
|
917
|
+
});
|
|
918
|
+
this.fanOutPush({
|
|
919
|
+
notificationId: requestId,
|
|
920
|
+
category: "question.asked",
|
|
921
|
+
sessionId,
|
|
922
|
+
title: "有问题需要回答",
|
|
923
|
+
body: firstQuestionText(p?.questions)
|
|
924
|
+
});
|
|
925
|
+
this.bumpPendingFlags(sessionId);
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
case "question/resolved": {
|
|
929
|
+
const p = frame;
|
|
930
|
+
if (!p.questionRpcId) break;
|
|
931
|
+
const requestId = "q-" + p.questionRpcId;
|
|
932
|
+
const pending = this.questions.get(requestId);
|
|
933
|
+
this.questions.delete(requestId);
|
|
934
|
+
this.record("s2c.pending.cleared", { requestId });
|
|
935
|
+
if (pending) this.bumpPendingFlags(pending.sessionId);
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
async refreshSummaries() {
|
|
941
|
+
try {
|
|
942
|
+
const response = await this.apiProxy.sessions.list({
|
|
943
|
+
rpcId: randomUUID(),
|
|
944
|
+
payload: {}
|
|
945
|
+
});
|
|
946
|
+
if (!response.result || !response.result.ok) {
|
|
947
|
+
this.diagnostic("sessions.list rejected: " + JSON.stringify(response.result ?? null).slice(0, 200));
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
let workspaces = [];
|
|
951
|
+
const workspaceList = this.apiProxy.workspace?.list;
|
|
952
|
+
if (typeof workspaceList === "function") {
|
|
953
|
+
const workspaceResponse = await workspaceList.call(this.apiProxy.workspace, {
|
|
954
|
+
rpcId: randomUUID(),
|
|
955
|
+
payload: {}
|
|
956
|
+
});
|
|
957
|
+
if (workspaceResponse.result?.ok) {
|
|
958
|
+
workspaces = workspaceResponse.result.value.items ?? [];
|
|
959
|
+
this.archivedSessionIds = new Set((workspaceResponse.result.value.archivedSessionIds ?? []).map(String));
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const previousIds = new Set(this.summaries.keys());
|
|
963
|
+
const workspaceBySession = /* @__PURE__ */ new Map();
|
|
964
|
+
for (const workspace of workspaces) for (const sessionId of workspace.sessionIds ?? []) workspaceBySession.set(String(sessionId), workspace);
|
|
965
|
+
const next = /* @__PURE__ */ new Map();
|
|
966
|
+
const subagentIds = /* @__PURE__ */ new Set();
|
|
967
|
+
for (const row of response.result.value.items ?? []) {
|
|
968
|
+
if (this.archivedSessionIds.has(row.sessionId)) continue;
|
|
969
|
+
if (isSubagentRow(row)) {
|
|
970
|
+
subagentIds.add(row.sessionId);
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
next.set(row.sessionId, toSummary(row, this.approvals, this.questions, workspaceBySession.get(row.sessionId)));
|
|
974
|
+
}
|
|
975
|
+
this.subagentSessionIds = subagentIds;
|
|
976
|
+
this.summaries = next;
|
|
977
|
+
const removedIds = [...previousIds].filter((id) => !next.has(id));
|
|
978
|
+
for (const id of removedIds) this.lastAssistantText.delete(id);
|
|
979
|
+
this.record("s2c.sessions.delta", {
|
|
980
|
+
upserted: [...next.values()],
|
|
981
|
+
removedIds
|
|
982
|
+
});
|
|
983
|
+
} catch {}
|
|
984
|
+
}
|
|
985
|
+
/** Cold sessions may lack a title projection; fall back to first user text. */
|
|
986
|
+
deriveTitleFallback(sessionId, messages) {
|
|
987
|
+
const row = this.summaries.get(sessionId);
|
|
988
|
+
if (!row || row.title.length > 0) return;
|
|
989
|
+
const firstUser = messages.find((m) => m.role === "user" && (m.text ?? "").trim().length > 0);
|
|
990
|
+
if (!firstUser) return;
|
|
991
|
+
row.title = firstUser.text.replace(/\s+/g, " ").trim().slice(0, 60);
|
|
992
|
+
this.pushSummary(row);
|
|
993
|
+
}
|
|
994
|
+
noteActivity(sessionId, event) {
|
|
995
|
+
const row = this.summaries.get(sessionId);
|
|
996
|
+
if (!row) return;
|
|
997
|
+
switch (event?.type) {
|
|
998
|
+
case "user/message":
|
|
999
|
+
case "turn/start":
|
|
1000
|
+
case "turn/end": break;
|
|
1001
|
+
default: return;
|
|
1002
|
+
}
|
|
1003
|
+
row.lastActivityTs = Date.now();
|
|
1004
|
+
this.pushSummary(row);
|
|
1005
|
+
}
|
|
1006
|
+
applyProjection(sessionId, key, value) {
|
|
1007
|
+
const row = this.summaries.get(sessionId);
|
|
1008
|
+
if (!row) return;
|
|
1009
|
+
if (key === "title") row.title = typeof value === "string" ? value : "";
|
|
1010
|
+
else if (key === "todos") {
|
|
1011
|
+
const sanitized = sanitizeTodoItems(Array.isArray(value) ? value : null);
|
|
1012
|
+
row.todoItems = sanitized.length > 0 ? sanitized : null;
|
|
1013
|
+
row.todos = sanitized.length > 0 ? {
|
|
1014
|
+
done: sanitized.filter((i) => i.status === "completed").length,
|
|
1015
|
+
total: sanitized.length
|
|
1016
|
+
} : null;
|
|
1017
|
+
} else if (key === "sessionListMetadata") {
|
|
1018
|
+
const meta = value;
|
|
1019
|
+
if (meta?.lastPromptAt) row.lastActivityTs = Math.max(row.lastActivityTs, meta.lastPromptAt);
|
|
1020
|
+
} else return;
|
|
1021
|
+
this.pushSummary(row);
|
|
1022
|
+
}
|
|
1023
|
+
bumpPendingFlags(sessionId) {
|
|
1024
|
+
const row = this.summaries.get(sessionId);
|
|
1025
|
+
if (!row) return;
|
|
1026
|
+
let approval = false;
|
|
1027
|
+
for (const pending of this.approvals.values()) if (pending.sessionId === sessionId) approval = true;
|
|
1028
|
+
let question = false;
|
|
1029
|
+
for (const pending of this.questions.values()) if (pending.sessionId === sessionId) question = true;
|
|
1030
|
+
row.pendingApproval = approval;
|
|
1031
|
+
row.pendingQuestion = question;
|
|
1032
|
+
this.pushSummary(row);
|
|
1033
|
+
}
|
|
1034
|
+
pushSummary(row) {
|
|
1035
|
+
this.record("s2c.sessions.delta", {
|
|
1036
|
+
upserted: [row],
|
|
1037
|
+
removedIds: []
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
listSessions() {
|
|
1041
|
+
return [...this.summaries.values()].sort((a, b) => b.lastActivityTs - a.lastActivityTs);
|
|
1042
|
+
}
|
|
1043
|
+
/** Tail history for an opened session; pushes s2c.session.tail to the sink. */
|
|
1044
|
+
async openSession(sink, sessionId, tailCount) {
|
|
1045
|
+
try {
|
|
1046
|
+
const response = await this.apiProxy.sessions.history({
|
|
1047
|
+
rpcId: randomUUID(),
|
|
1048
|
+
payload: {
|
|
1049
|
+
sessionId,
|
|
1050
|
+
maxMessages: clampTail(tailCount)
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
if (!response.result || !response.result.ok) return false;
|
|
1054
|
+
const result = response.result.value;
|
|
1055
|
+
const messages = projectHistory(result.events ?? []);
|
|
1056
|
+
const oldestSeq = messages.length > 0 ? messages[0].seq : 0;
|
|
1057
|
+
sink.push("s2c.session.tail", {
|
|
1058
|
+
sessionId,
|
|
1059
|
+
messages,
|
|
1060
|
+
oldestSeq,
|
|
1061
|
+
hasMore: Boolean(result.hasMore)
|
|
1062
|
+
});
|
|
1063
|
+
this.deriveTitleFallback(sessionId, messages);
|
|
1064
|
+
return true;
|
|
1065
|
+
} catch {
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
async historyPage(sink, sessionId, beforeSeq, limit) {
|
|
1070
|
+
try {
|
|
1071
|
+
const response = await this.apiProxy.sessions.history({
|
|
1072
|
+
rpcId: randomUUID(),
|
|
1073
|
+
payload: {
|
|
1074
|
+
sessionId,
|
|
1075
|
+
beforeSeq,
|
|
1076
|
+
maxMessages: clampTail(limit)
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
if (!response.result || !response.result.ok) return false;
|
|
1080
|
+
const result = response.result.value;
|
|
1081
|
+
const messages = projectHistory(result.events ?? []);
|
|
1082
|
+
sink.push("s2c.history.page", {
|
|
1083
|
+
sessionId,
|
|
1084
|
+
messages,
|
|
1085
|
+
hasMore: Boolean(result.hasMore)
|
|
1086
|
+
});
|
|
1087
|
+
return true;
|
|
1088
|
+
} catch {
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/** Result of one attachment read-back for the phone. */
|
|
1093
|
+
async attachmentData(sessionId, attachmentId) {
|
|
1094
|
+
const read = this.apiProxy.sessions.attachment;
|
|
1095
|
+
if (typeof read !== "function") return null;
|
|
1096
|
+
try {
|
|
1097
|
+
const response = await read.call(this.apiProxy.sessions, {
|
|
1098
|
+
rpcId: randomUUID(),
|
|
1099
|
+
payload: {
|
|
1100
|
+
sessionId,
|
|
1101
|
+
attachmentId
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
if (!response.result || !response.result.ok) return null;
|
|
1105
|
+
const data = response.result.value.data;
|
|
1106
|
+
if (typeof data !== "string" || data.length === 0) return null;
|
|
1107
|
+
return {
|
|
1108
|
+
...typeof response.result.value.attachment?.mediaType === "string" ? { mediaType: response.result.value.attachment.mediaType } : {},
|
|
1109
|
+
data
|
|
1110
|
+
};
|
|
1111
|
+
} catch {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
async sessionModels(sessionId) {
|
|
1116
|
+
const models = this.apiProxy.sessions.models;
|
|
1117
|
+
if (typeof models !== "function") return {
|
|
1118
|
+
ok: false,
|
|
1119
|
+
kind: "unsupported",
|
|
1120
|
+
message: "model catalog unavailable on this host version"
|
|
1121
|
+
};
|
|
1122
|
+
try {
|
|
1123
|
+
const response = await models.call(this.apiProxy.sessions, {
|
|
1124
|
+
rpcId: randomUUID(),
|
|
1125
|
+
payload: { sessionId }
|
|
1126
|
+
});
|
|
1127
|
+
if (!response.result) return {
|
|
1128
|
+
ok: false,
|
|
1129
|
+
kind: "internal",
|
|
1130
|
+
message: "model catalog returned no result"
|
|
1131
|
+
};
|
|
1132
|
+
if (!response.result.ok) return hostModelError(response.result.error);
|
|
1133
|
+
return {
|
|
1134
|
+
ok: true,
|
|
1135
|
+
value: projectSessionModels(response.result.value)
|
|
1136
|
+
};
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
return {
|
|
1139
|
+
ok: false,
|
|
1140
|
+
kind: "internal",
|
|
1141
|
+
message: String(error)
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
async selectSessionModel(sessionId, selection) {
|
|
1146
|
+
const selectModel = this.apiProxy.sessions.selectModel;
|
|
1147
|
+
if (typeof selectModel !== "function") return {
|
|
1148
|
+
ok: false,
|
|
1149
|
+
kind: "unsupported",
|
|
1150
|
+
message: "model selection unavailable on this host version"
|
|
1151
|
+
};
|
|
1152
|
+
try {
|
|
1153
|
+
const response = await selectModel.call(this.apiProxy.sessions, {
|
|
1154
|
+
rpcId: randomUUID(),
|
|
1155
|
+
payload: {
|
|
1156
|
+
sessionId,
|
|
1157
|
+
provider: selection.provider,
|
|
1158
|
+
model: selection.model,
|
|
1159
|
+
...selection.reasoningEffort ? { reasoningEffort: selection.reasoningEffort } : {}
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
if (!response.result) return {
|
|
1163
|
+
ok: false,
|
|
1164
|
+
kind: "internal",
|
|
1165
|
+
message: "model selection returned no result"
|
|
1166
|
+
};
|
|
1167
|
+
if (!response.result.ok) return hostModelError(response.result.error);
|
|
1168
|
+
return {
|
|
1169
|
+
ok: true,
|
|
1170
|
+
value: { ...response.result.value.selected }
|
|
1171
|
+
};
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
return {
|
|
1174
|
+
ok: false,
|
|
1175
|
+
kind: "internal",
|
|
1176
|
+
message: String(error)
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
async renameSession(sessionId, title) {
|
|
1181
|
+
const rename = this.apiProxy.sessions.rename;
|
|
1182
|
+
if (typeof rename !== "function") return {
|
|
1183
|
+
ok: false,
|
|
1184
|
+
kind: "unsupported",
|
|
1185
|
+
message: "session rename unavailable on this host version"
|
|
1186
|
+
};
|
|
1187
|
+
try {
|
|
1188
|
+
const response = await rename.call(this.apiProxy.sessions, {
|
|
1189
|
+
rpcId: randomUUID(),
|
|
1190
|
+
payload: {
|
|
1191
|
+
sessionId,
|
|
1192
|
+
title
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
if (!response.result) return {
|
|
1196
|
+
ok: false,
|
|
1197
|
+
kind: "internal",
|
|
1198
|
+
message: "session rename returned no result"
|
|
1199
|
+
};
|
|
1200
|
+
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
1201
|
+
const acceptedTitle = String(response.result.value.title);
|
|
1202
|
+
const row = this.summaries.get(sessionId);
|
|
1203
|
+
if (row) {
|
|
1204
|
+
row.title = acceptedTitle;
|
|
1205
|
+
this.pushSummary(row);
|
|
1206
|
+
}
|
|
1207
|
+
return {
|
|
1208
|
+
ok: true,
|
|
1209
|
+
value: acceptedTitle
|
|
1210
|
+
};
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
return {
|
|
1213
|
+
ok: false,
|
|
1214
|
+
kind: "internal",
|
|
1215
|
+
message: String(error)
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
async archiveSession(sessionId) {
|
|
1220
|
+
const archive = this.apiProxy.workspace?.archiveSession;
|
|
1221
|
+
if (typeof archive !== "function") return {
|
|
1222
|
+
ok: false,
|
|
1223
|
+
kind: "unsupported",
|
|
1224
|
+
message: "session archive unavailable on this host version"
|
|
1225
|
+
};
|
|
1226
|
+
try {
|
|
1227
|
+
const response = await archive.call(this.apiProxy.workspace, {
|
|
1228
|
+
rpcId: randomUUID(),
|
|
1229
|
+
payload: { sessionId }
|
|
1230
|
+
});
|
|
1231
|
+
if (!response.result) return {
|
|
1232
|
+
ok: false,
|
|
1233
|
+
kind: "internal",
|
|
1234
|
+
message: "session archive returned no result"
|
|
1235
|
+
};
|
|
1236
|
+
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
1237
|
+
this.archivedSessionIds = new Set((response.result.value.archivedSessionIds ?? []).map(String));
|
|
1238
|
+
this.summaries.delete(sessionId);
|
|
1239
|
+
this.lastAssistantText.delete(sessionId);
|
|
1240
|
+
this.record("s2c.sessions.delta", {
|
|
1241
|
+
upserted: [],
|
|
1242
|
+
removedIds: [sessionId]
|
|
1243
|
+
});
|
|
1244
|
+
return {
|
|
1245
|
+
ok: true,
|
|
1246
|
+
value: true
|
|
1247
|
+
};
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
return {
|
|
1250
|
+
ok: false,
|
|
1251
|
+
kind: "internal",
|
|
1252
|
+
message: String(error)
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
async cancelSession(sessionId) {
|
|
1257
|
+
const cancel = this.apiProxy.sessions.cancel;
|
|
1258
|
+
if (typeof cancel !== "function") return {
|
|
1259
|
+
ok: false,
|
|
1260
|
+
kind: "unsupported",
|
|
1261
|
+
message: "session cancel unavailable on this host version"
|
|
1262
|
+
};
|
|
1263
|
+
try {
|
|
1264
|
+
const response = await cancel.call(this.apiProxy.sessions, {
|
|
1265
|
+
rpcId: randomUUID(),
|
|
1266
|
+
payload: { sessionId }
|
|
1267
|
+
});
|
|
1268
|
+
if (!response.result) return {
|
|
1269
|
+
ok: false,
|
|
1270
|
+
kind: "internal",
|
|
1271
|
+
message: "session cancel returned no result"
|
|
1272
|
+
};
|
|
1273
|
+
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
1274
|
+
return {
|
|
1275
|
+
ok: true,
|
|
1276
|
+
value: true
|
|
1277
|
+
};
|
|
1278
|
+
} catch (error) {
|
|
1279
|
+
return {
|
|
1280
|
+
ok: false,
|
|
1281
|
+
kind: "internal",
|
|
1282
|
+
message: String(error)
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
async listWorkspaces() {
|
|
1287
|
+
const list = this.apiProxy.workspace?.list;
|
|
1288
|
+
if (typeof list !== "function") return {
|
|
1289
|
+
ok: false,
|
|
1290
|
+
kind: "unsupported",
|
|
1291
|
+
message: "workspace list unavailable on this host version"
|
|
1292
|
+
};
|
|
1293
|
+
try {
|
|
1294
|
+
const response = await list.call(this.apiProxy.workspace, {
|
|
1295
|
+
rpcId: randomUUID(),
|
|
1296
|
+
payload: {}
|
|
1297
|
+
});
|
|
1298
|
+
if (!response.result) return {
|
|
1299
|
+
ok: false,
|
|
1300
|
+
kind: "internal",
|
|
1301
|
+
message: "workspace list returned no result"
|
|
1302
|
+
};
|
|
1303
|
+
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
1304
|
+
return {
|
|
1305
|
+
ok: true,
|
|
1306
|
+
value: (response.result.value.items ?? []).map(projectWorkspace)
|
|
1307
|
+
};
|
|
1308
|
+
} catch (error) {
|
|
1309
|
+
return {
|
|
1310
|
+
ok: false,
|
|
1311
|
+
kind: "internal",
|
|
1312
|
+
message: String(error)
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
async createWorkspace(path) {
|
|
1317
|
+
const create = this.apiProxy.workspace?.create;
|
|
1318
|
+
if (typeof create !== "function") return {
|
|
1319
|
+
ok: false,
|
|
1320
|
+
kind: "unsupported",
|
|
1321
|
+
message: "workspace create unavailable on this host version"
|
|
1322
|
+
};
|
|
1323
|
+
try {
|
|
1324
|
+
const response = await create.call(this.apiProxy.workspace, {
|
|
1325
|
+
rpcId: randomUUID(),
|
|
1326
|
+
payload: { path }
|
|
1327
|
+
});
|
|
1328
|
+
if (!response.result) return {
|
|
1329
|
+
ok: false,
|
|
1330
|
+
kind: "internal",
|
|
1331
|
+
message: "workspace create returned no result"
|
|
1332
|
+
};
|
|
1333
|
+
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
1334
|
+
await this.refreshSummaries();
|
|
1335
|
+
return {
|
|
1336
|
+
ok: true,
|
|
1337
|
+
value: {
|
|
1338
|
+
workspace: projectWorkspace(response.result.value.workspace),
|
|
1339
|
+
created: response.result.value.created === true
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
return {
|
|
1344
|
+
ok: false,
|
|
1345
|
+
kind: "internal",
|
|
1346
|
+
message: String(error)
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
async listDirectory(path) {
|
|
1351
|
+
const list = this.apiProxy.host?.listDirectory;
|
|
1352
|
+
if (typeof list !== "function") return {
|
|
1353
|
+
ok: false,
|
|
1354
|
+
kind: "unsupported",
|
|
1355
|
+
message: "directory browsing unavailable on this host version"
|
|
1356
|
+
};
|
|
1357
|
+
try {
|
|
1358
|
+
const response = await list.call(this.apiProxy.host, {
|
|
1359
|
+
rpcId: randomUUID(),
|
|
1360
|
+
payload: path && path.trim().length > 0 ? { path } : {}
|
|
1361
|
+
}, this.abort.signal);
|
|
1362
|
+
if (!response.result) return {
|
|
1363
|
+
ok: false,
|
|
1364
|
+
kind: "internal",
|
|
1365
|
+
message: "directory list returned no result"
|
|
1366
|
+
};
|
|
1367
|
+
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
1368
|
+
return {
|
|
1369
|
+
ok: true,
|
|
1370
|
+
value: response.result.value
|
|
1371
|
+
};
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
return {
|
|
1374
|
+
ok: false,
|
|
1375
|
+
kind: "internal",
|
|
1376
|
+
message: String(error)
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
async pickDirectory() {
|
|
1381
|
+
const pick = this.apiProxy.host?.pickDirectory;
|
|
1382
|
+
if (typeof pick !== "function") return {
|
|
1383
|
+
ok: false,
|
|
1384
|
+
kind: "unsupported",
|
|
1385
|
+
message: "native directory picker unavailable on this host version"
|
|
1386
|
+
};
|
|
1387
|
+
try {
|
|
1388
|
+
const response = await pick.call(this.apiProxy.host, {
|
|
1389
|
+
rpcId: randomUUID(),
|
|
1390
|
+
payload: {}
|
|
1391
|
+
}, this.abort.signal);
|
|
1392
|
+
if (!response.result) return {
|
|
1393
|
+
ok: false,
|
|
1394
|
+
kind: "internal",
|
|
1395
|
+
message: "directory picker returned no result"
|
|
1396
|
+
};
|
|
1397
|
+
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
1398
|
+
return {
|
|
1399
|
+
ok: true,
|
|
1400
|
+
value: response.result.value.path
|
|
1401
|
+
};
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
return {
|
|
1404
|
+
ok: false,
|
|
1405
|
+
kind: "internal",
|
|
1406
|
+
message: String(error)
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
/** Create a fresh blank session in an existing workspace or legacy cwd. */
|
|
1411
|
+
async createSession(destination = {}) {
|
|
1412
|
+
try {
|
|
1413
|
+
const response = await this.apiProxy.sessions.create({
|
|
1414
|
+
rpcId: randomUUID(),
|
|
1415
|
+
payload: {
|
|
1416
|
+
...destination.workspaceId?.trim() ? { workspaceId: destination.workspaceId.trim() } : {},
|
|
1417
|
+
...destination.cwd?.trim() ? { cwd: destination.cwd.trim() } : {}
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
if (!response.result || !response.result.ok) return null;
|
|
1421
|
+
const sessionId = response.result.value.sessionId;
|
|
1422
|
+
await this.refreshSummaries();
|
|
1423
|
+
return sessionId;
|
|
1424
|
+
} catch {
|
|
1425
|
+
return null;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
async sendPrompt(sessionId, text, images = []) {
|
|
1429
|
+
try {
|
|
1430
|
+
const content = [];
|
|
1431
|
+
if (text.trim().length > 0) content.push({
|
|
1432
|
+
type: "text",
|
|
1433
|
+
text
|
|
1434
|
+
});
|
|
1435
|
+
for (const image of images) content.push({
|
|
1436
|
+
type: "image",
|
|
1437
|
+
...image
|
|
1438
|
+
});
|
|
1439
|
+
const response = await this.apiProxy.sessions.prompt({
|
|
1440
|
+
rpcId: randomUUID(),
|
|
1441
|
+
payload: {
|
|
1442
|
+
sessionId,
|
|
1443
|
+
mode: "queue",
|
|
1444
|
+
content,
|
|
1445
|
+
clientTimeZone: localTimeZone()
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
if (!response.result || !response.result.ok) return null;
|
|
1449
|
+
const row = this.summaries.get(sessionId);
|
|
1450
|
+
if (row) {
|
|
1451
|
+
row.lastActivityTs = Date.now();
|
|
1452
|
+
this.pushSummary(row);
|
|
1453
|
+
}
|
|
1454
|
+
return Date.now();
|
|
1455
|
+
} catch {
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
async respondApproval(requestId, decision) {
|
|
1460
|
+
const pending = this.approvals.get(requestId);
|
|
1461
|
+
if (!pending) return false;
|
|
1462
|
+
this.approvals.delete(requestId);
|
|
1463
|
+
const outcome = decision === "allow" ? "allowed-once" : "rejected";
|
|
1464
|
+
try {
|
|
1465
|
+
const receipt = await this.apiProxy.respond({
|
|
1466
|
+
type: "client-response",
|
|
1467
|
+
rpcId: pending.rpcId,
|
|
1468
|
+
result: {
|
|
1469
|
+
ok: true,
|
|
1470
|
+
value: {
|
|
1471
|
+
sessionId: pending.sessionId,
|
|
1472
|
+
approvalId: requestId,
|
|
1473
|
+
outcome
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
const accepted = Boolean(receipt?.accepted);
|
|
1478
|
+
if (!accepted && !this.approvals.has(requestId)) this.approvals.set(requestId, pending);
|
|
1479
|
+
return accepted;
|
|
1480
|
+
} catch {
|
|
1481
|
+
if (!this.approvals.has(requestId)) this.approvals.set(requestId, pending);
|
|
1482
|
+
return false;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
async respondQuestion(requestId, answers) {
|
|
1486
|
+
const pending = this.questions.get(requestId);
|
|
1487
|
+
if (!pending) return false;
|
|
1488
|
+
this.questions.delete(requestId);
|
|
1489
|
+
try {
|
|
1490
|
+
const receipt = await this.apiProxy.respond({
|
|
1491
|
+
type: "client-response",
|
|
1492
|
+
rpcId: pending.rpcId,
|
|
1493
|
+
result: {
|
|
1494
|
+
ok: true,
|
|
1495
|
+
value: {
|
|
1496
|
+
sessionId: pending.sessionId,
|
|
1497
|
+
answer: { answers }
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
const accepted = Boolean(receipt?.accepted);
|
|
1502
|
+
if (!accepted && !this.questions.has(requestId)) this.questions.set(requestId, pending);
|
|
1503
|
+
return accepted;
|
|
1504
|
+
} catch {
|
|
1505
|
+
if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
|
|
1506
|
+
return false;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
function clampTail(n) {
|
|
1511
|
+
if (!Number.isFinite(n)) return 100;
|
|
1512
|
+
return Math.max(10, Math.min(500, Math.floor(n)));
|
|
1513
|
+
}
|
|
1514
|
+
function localTimeZone() {
|
|
1515
|
+
try {
|
|
1516
|
+
return new Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
|
|
1517
|
+
} catch {
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
function riskOf(toolName) {
|
|
1522
|
+
if (/bash|pwsh|terminal/.test(toolName)) return "write";
|
|
1523
|
+
if (/edit|write|str_replace|create/.test(toolName)) return "write";
|
|
1524
|
+
if (/delete|remove|kill/.test(toolName)) return "destructive";
|
|
1525
|
+
return "read";
|
|
1526
|
+
}
|
|
1527
|
+
/** First question's text for the push banner; the questions payload shape is
|
|
1528
|
+
* host-version dependent, so extract defensively. */
|
|
1529
|
+
function firstQuestionText(questions) {
|
|
1530
|
+
if (!Array.isArray(questions) || questions.length === 0) return "Agent 等待你的输入";
|
|
1531
|
+
const first = questions[0];
|
|
1532
|
+
return String(first?.question ?? "").trim() || "Agent 等待你的输入";
|
|
1533
|
+
}
|
|
1534
|
+
const TODO_STATUSES = /* @__PURE__ */ new Set([
|
|
1535
|
+
"pending",
|
|
1536
|
+
"in_progress",
|
|
1537
|
+
"completed"
|
|
1538
|
+
]);
|
|
1539
|
+
/** Validate a host todo projection once; progress counts and the full
|
|
1540
|
+
* checklist both derive from this sanitized list so they never disagree. */
|
|
1541
|
+
function sanitizeTodoItems(items) {
|
|
1542
|
+
if (!items) return [];
|
|
1543
|
+
return items.map((i) => ({
|
|
1544
|
+
content: String(i.content ?? "").trim(),
|
|
1545
|
+
status: String(i.status ?? "")
|
|
1546
|
+
})).filter((i) => i.content.length > 0 && TODO_STATUSES.has(i.status)).slice(0, 100).map((i) => ({
|
|
1547
|
+
content: i.content,
|
|
1548
|
+
status: i.status
|
|
1549
|
+
}));
|
|
1550
|
+
}
|
|
1551
|
+
function toSummary(row, approvals, questions, workspace) {
|
|
1552
|
+
const values = row.projections?.values ?? {};
|
|
1553
|
+
const todos = Array.isArray(values.todos) ? values.todos : null;
|
|
1554
|
+
let pendingApproval = false;
|
|
1555
|
+
for (const pending of approvals.values()) if (pending.sessionId === row.sessionId) pendingApproval = true;
|
|
1556
|
+
let pendingQuestion = false;
|
|
1557
|
+
for (const pending of questions.values()) if (pending.sessionId === row.sessionId) pendingQuestion = true;
|
|
1558
|
+
const cwd = typeof row.cwd === "string" ? row.cwd : "";
|
|
1559
|
+
const label = workspace?.title ?? (cwd ? cwd.split("/").filter(Boolean).pop() : void 0);
|
|
1560
|
+
const todoItems = sanitizeTodoItems(todos);
|
|
1561
|
+
return {
|
|
1562
|
+
id: row.sessionId,
|
|
1563
|
+
title: typeof values.title === "string" ? values.title : "",
|
|
1564
|
+
status: row.running ? "running" : row.blank ? "unknown" : "idle",
|
|
1565
|
+
lastActivityTs: Number(row.updatedAt ?? Date.now()),
|
|
1566
|
+
todos: todoItems.length > 0 ? {
|
|
1567
|
+
done: todoItems.filter((i) => i.status === "completed").length,
|
|
1568
|
+
total: todoItems.length
|
|
1569
|
+
} : null,
|
|
1570
|
+
todoItems: todoItems.length > 0 ? todoItems : null,
|
|
1571
|
+
pendingApproval,
|
|
1572
|
+
pendingQuestion,
|
|
1573
|
+
workspaceLabel: label ?? null,
|
|
1574
|
+
workspaceId: workspace?.workspaceId ?? null,
|
|
1575
|
+
workspacePath: workspace?.path ?? (cwd || null)
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
function projectWorkspace(workspace) {
|
|
1579
|
+
return {
|
|
1580
|
+
id: String(workspace.workspaceId),
|
|
1581
|
+
title: String(workspace.title),
|
|
1582
|
+
path: String(workspace.path),
|
|
1583
|
+
sessionIds: (workspace.sessionIds ?? []).map(String)
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
/** Project one raw session event into a protocol push, when it maps to one. */
|
|
1587
|
+
function projectEvent(sessionId, event) {
|
|
1588
|
+
switch (event.type) {
|
|
1589
|
+
case "turn/start": return {
|
|
1590
|
+
kind: "turn.start",
|
|
1591
|
+
data: {}
|
|
1592
|
+
};
|
|
1593
|
+
case "turn/end": return {
|
|
1594
|
+
kind: "turn.end",
|
|
1595
|
+
data: { ok: event.data?.reason?.kind === "completed" }
|
|
1596
|
+
};
|
|
1597
|
+
case "user/message": return {
|
|
1598
|
+
kind: "message.final",
|
|
1599
|
+
data: {
|
|
1600
|
+
seq: event.seq,
|
|
1601
|
+
role: "user",
|
|
1602
|
+
text: messageText(event.data),
|
|
1603
|
+
...attachmentProjection(event.data),
|
|
1604
|
+
ts: tsOf(event)
|
|
1605
|
+
}
|
|
1606
|
+
};
|
|
1607
|
+
case "assistant/chunk":
|
|
1608
|
+
if (chunkTypeOf(event.data) === "reasoning-delta") return {
|
|
1609
|
+
kind: "thinking.delta",
|
|
1610
|
+
data: {
|
|
1611
|
+
text: chunkText(event.data),
|
|
1612
|
+
ts: tsOf(event)
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
return {
|
|
1616
|
+
kind: "message.delta",
|
|
1617
|
+
data: {
|
|
1618
|
+
text: chunkText(event.data),
|
|
1619
|
+
ts: tsOf(event)
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1622
|
+
case "assistant/message": {
|
|
1623
|
+
const text = messageText(event.data);
|
|
1624
|
+
const thinking = messageThinking(event.data);
|
|
1625
|
+
if (!text.trim() && !thinking.trim()) return null;
|
|
1626
|
+
return {
|
|
1627
|
+
kind: "message.final",
|
|
1628
|
+
data: {
|
|
1629
|
+
seq: event.seq,
|
|
1630
|
+
role: "assistant",
|
|
1631
|
+
text,
|
|
1632
|
+
...thinking ? { thinking } : {},
|
|
1633
|
+
ts: tsOf(event)
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
case "tool/call": {
|
|
1638
|
+
const data = event.data;
|
|
1639
|
+
return {
|
|
1640
|
+
kind: "tool.start",
|
|
1641
|
+
data: {
|
|
1642
|
+
seq: event.seq,
|
|
1643
|
+
role: "tool",
|
|
1644
|
+
tool: {
|
|
1645
|
+
name: String(data?.name ?? "tool"),
|
|
1646
|
+
state: "running",
|
|
1647
|
+
summary: summarizeArgs(data?.arguments)
|
|
1648
|
+
},
|
|
1649
|
+
ts: tsOf(event)
|
|
1650
|
+
}
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
case "tool/result": return {
|
|
1654
|
+
kind: "tool.end",
|
|
1655
|
+
data: {
|
|
1656
|
+
seq: event.seq,
|
|
1657
|
+
role: "tool",
|
|
1658
|
+
ok: !event.data || event.data.error === void 0,
|
|
1659
|
+
ts: tsOf(event)
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
default: return null;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
function tsOf(event) {
|
|
1666
|
+
return typeof event.time === "number" ? event.time : Date.now();
|
|
1667
|
+
}
|
|
1668
|
+
/** Extract plain text from user/assistant message payloads across shapes. */
|
|
1669
|
+
function messageText(data) {
|
|
1670
|
+
if (typeof data === "string") return data;
|
|
1671
|
+
if (!data || typeof data !== "object") return "";
|
|
1672
|
+
const obj = data;
|
|
1673
|
+
if (typeof obj.text === "string") return obj.text;
|
|
1674
|
+
if (obj.message && typeof obj.message === "object") return messageText(obj.message);
|
|
1675
|
+
return contentText(obj.content);
|
|
1676
|
+
}
|
|
1677
|
+
function contentText(content) {
|
|
1678
|
+
if (typeof content === "string") return content;
|
|
1679
|
+
if (Array.isArray(content)) return content.map((part) => {
|
|
1680
|
+
if (typeof part === "string") return part;
|
|
1681
|
+
if (part && typeof part === "object") {
|
|
1682
|
+
const piece = part;
|
|
1683
|
+
if (piece.type === "text" && typeof piece.text === "string") return piece.text;
|
|
1684
|
+
}
|
|
1685
|
+
return "";
|
|
1686
|
+
}).join("");
|
|
1687
|
+
return "";
|
|
1688
|
+
}
|
|
1689
|
+
function messageAttachments(data) {
|
|
1690
|
+
if (!data || typeof data !== "object") return [];
|
|
1691
|
+
const obj = data;
|
|
1692
|
+
if (obj.message && typeof obj.message === "object") return messageAttachments(obj.message);
|
|
1693
|
+
if (!Array.isArray(obj.content)) return [];
|
|
1694
|
+
return obj.content.flatMap((part) => {
|
|
1695
|
+
if (!part || typeof part !== "object") return [];
|
|
1696
|
+
const block = part;
|
|
1697
|
+
if (block.type !== "image" || !block.attachment) return [];
|
|
1698
|
+
const attachmentId = typeof block.attachment.attachmentId === "string" && block.attachment.attachmentId.length > 0 ? block.attachment.attachmentId : void 0;
|
|
1699
|
+
const width = typeof block.attachment.width === "number" && Number.isFinite(block.attachment.width) ? block.attachment.width : void 0;
|
|
1700
|
+
const height = typeof block.attachment.height === "number" && Number.isFinite(block.attachment.height) ? block.attachment.height : void 0;
|
|
1701
|
+
return [{
|
|
1702
|
+
kind: "image",
|
|
1703
|
+
...typeof block.attachment.name === "string" ? { name: block.attachment.name } : {},
|
|
1704
|
+
...typeof block.attachment.mediaType === "string" ? { mediaType: block.attachment.mediaType } : {},
|
|
1705
|
+
...attachmentId ? { attachmentId } : {},
|
|
1706
|
+
...width !== void 0 ? { width } : {},
|
|
1707
|
+
...height !== void 0 ? { height } : {}
|
|
1708
|
+
}];
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
function attachmentProjection(data) {
|
|
1712
|
+
const attachments = messageAttachments(data);
|
|
1713
|
+
return attachments.length > 0 ? { attachments } : {};
|
|
1714
|
+
}
|
|
1715
|
+
/** Extract reasoning ("thinking") text from assistant message payloads. */
|
|
1716
|
+
function messageThinking(data) {
|
|
1717
|
+
if (!data || typeof data !== "object") return "";
|
|
1718
|
+
const obj = data;
|
|
1719
|
+
if (obj.message && typeof obj.message === "object") return messageThinking(obj.message);
|
|
1720
|
+
return reasoningContent(obj.content);
|
|
1721
|
+
}
|
|
1722
|
+
function reasoningContent(content) {
|
|
1723
|
+
if (!Array.isArray(content)) return "";
|
|
1724
|
+
return content.map((part) => {
|
|
1725
|
+
if (part && typeof part === "object") {
|
|
1726
|
+
const piece = part;
|
|
1727
|
+
if (piece.type === "reasoning" && typeof piece.text === "string") return piece.text;
|
|
1728
|
+
}
|
|
1729
|
+
return "";
|
|
1730
|
+
}).join("");
|
|
1731
|
+
}
|
|
1732
|
+
/** Stream chunk type of an assistant/chunk payload ('' when unwrapped). */
|
|
1733
|
+
function chunkTypeOf(data) {
|
|
1734
|
+
if (!data || typeof data !== "object") return "";
|
|
1735
|
+
const obj = data;
|
|
1736
|
+
if (obj.chunk && typeof obj.chunk === "object") return String(obj.chunk.type ?? "");
|
|
1737
|
+
return "text-delta";
|
|
1738
|
+
}
|
|
1739
|
+
function chunkText(data) {
|
|
1740
|
+
if (!data || typeof data !== "object") return "";
|
|
1741
|
+
const obj = data;
|
|
1742
|
+
if (obj.chunk && typeof obj.chunk === "object") {
|
|
1743
|
+
const inner = obj.chunk;
|
|
1744
|
+
if ((inner.type === "text-delta" || inner.type === "reasoning-delta") && typeof inner.text === "string") return inner.text;
|
|
1745
|
+
return "";
|
|
1746
|
+
}
|
|
1747
|
+
const direct = data;
|
|
1748
|
+
return typeof direct.text === "string" ? direct.text : "";
|
|
1749
|
+
}
|
|
1750
|
+
function summarizeArgs(raw) {
|
|
1751
|
+
if (typeof raw !== "string" || raw.length === 0) return "";
|
|
1752
|
+
try {
|
|
1753
|
+
const parsed = JSON.parse(raw);
|
|
1754
|
+
const parts = [];
|
|
1755
|
+
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") parts.push(key + "=" + truncate(value.replace(/\s+/g, " "), 60));
|
|
1756
|
+
return truncate(parts.join(" "), 90);
|
|
1757
|
+
} catch {
|
|
1758
|
+
return truncate(raw, 90);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
function truncate(text, max) {
|
|
1762
|
+
return text.length <= max ? text : text.slice(0, max - 1) + "…";
|
|
1763
|
+
}
|
|
1764
|
+
function hostModelError(error) {
|
|
1765
|
+
const message = error.message ?? error.code;
|
|
1766
|
+
switch (error.code) {
|
|
1767
|
+
case "session-not-found": return {
|
|
1768
|
+
ok: false,
|
|
1769
|
+
kind: "not-found",
|
|
1770
|
+
message
|
|
1771
|
+
};
|
|
1772
|
+
case "agent-busy":
|
|
1773
|
+
case "session-conflict": return {
|
|
1774
|
+
ok: false,
|
|
1775
|
+
kind: "busy",
|
|
1776
|
+
message
|
|
1777
|
+
};
|
|
1778
|
+
case "model-unavailable": return {
|
|
1779
|
+
ok: false,
|
|
1780
|
+
kind: "unavailable",
|
|
1781
|
+
message
|
|
1782
|
+
};
|
|
1783
|
+
default: return {
|
|
1784
|
+
ok: false,
|
|
1785
|
+
kind: "internal",
|
|
1786
|
+
message
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
function hostSessionManagementError(error) {
|
|
1791
|
+
const message = error.message ?? error.code;
|
|
1792
|
+
switch (error.code) {
|
|
1793
|
+
case "session-not-found": return {
|
|
1794
|
+
ok: false,
|
|
1795
|
+
kind: "not-found",
|
|
1796
|
+
message
|
|
1797
|
+
};
|
|
1798
|
+
case "agent-busy":
|
|
1799
|
+
case "session-conflict": return {
|
|
1800
|
+
ok: false,
|
|
1801
|
+
kind: "busy",
|
|
1802
|
+
message
|
|
1803
|
+
};
|
|
1804
|
+
case "title-invalid":
|
|
1805
|
+
case "workspace-invalid-path":
|
|
1806
|
+
case "workspace-name-conflict":
|
|
1807
|
+
case "directory-unreadable":
|
|
1808
|
+
case "directory-exists":
|
|
1809
|
+
case "directory-create-failed": return {
|
|
1810
|
+
ok: false,
|
|
1811
|
+
kind: "invalid",
|
|
1812
|
+
message
|
|
1813
|
+
};
|
|
1814
|
+
case "directory-picker-unavailable": return {
|
|
1815
|
+
ok: false,
|
|
1816
|
+
kind: "unsupported",
|
|
1817
|
+
message
|
|
1818
|
+
};
|
|
1819
|
+
case "workspace-not-found": return {
|
|
1820
|
+
ok: false,
|
|
1821
|
+
kind: "not-found",
|
|
1822
|
+
message
|
|
1823
|
+
};
|
|
1824
|
+
default: return {
|
|
1825
|
+
ok: false,
|
|
1826
|
+
kind: "internal",
|
|
1827
|
+
message
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
function projectSessionModels(value) {
|
|
1832
|
+
return {
|
|
1833
|
+
current: {
|
|
1834
|
+
provider: String(value.current.provider),
|
|
1835
|
+
model: String(value.current.model),
|
|
1836
|
+
...value.current.reasoningEffort ? { reasoningEffort: String(value.current.reasoningEffort) } : {}
|
|
1837
|
+
},
|
|
1838
|
+
routable: value.routable === true,
|
|
1839
|
+
groups: (value.groups ?? []).map((group) => ({
|
|
1840
|
+
id: String(group.id),
|
|
1841
|
+
name: String(group.name),
|
|
1842
|
+
models: (group.models ?? []).map((model) => ({
|
|
1843
|
+
id: String(model.id),
|
|
1844
|
+
name: String(model.name),
|
|
1845
|
+
...model.description ? { description: String(model.description) } : {},
|
|
1846
|
+
...model.reasoning ? { reasoning: {
|
|
1847
|
+
efforts: (model.reasoning.efforts ?? []).map((effort) => ({
|
|
1848
|
+
id: String(effort.id),
|
|
1849
|
+
name: String(effort.name),
|
|
1850
|
+
...effort.description ? { description: String(effort.description) } : {}
|
|
1851
|
+
})),
|
|
1852
|
+
...model.reasoning.defaultEffort ? { defaultEffort: String(model.reasoning.defaultEffort) } : {}
|
|
1853
|
+
} } : {}
|
|
1854
|
+
}))
|
|
1855
|
+
})),
|
|
1856
|
+
failures: (value.failures ?? []).map((failure) => ({
|
|
1857
|
+
id: String(failure.id),
|
|
1858
|
+
name: String(failure.name),
|
|
1859
|
+
message: String(failure.message)
|
|
1860
|
+
}))
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
/** Project a history page (raw events) into MessageProjection rows. */
|
|
1864
|
+
function projectHistory(events) {
|
|
1865
|
+
const messages = [];
|
|
1866
|
+
const toolByCall = /* @__PURE__ */ new Map();
|
|
1867
|
+
for (const entry of events) {
|
|
1868
|
+
const event = entry.event;
|
|
1869
|
+
const base = {
|
|
1870
|
+
seq: event.seq,
|
|
1871
|
+
ts: tsOf(event)
|
|
1872
|
+
};
|
|
1873
|
+
switch (event.type) {
|
|
1874
|
+
case "user/message":
|
|
1875
|
+
messages.push({
|
|
1876
|
+
...base,
|
|
1877
|
+
role: "user",
|
|
1878
|
+
text: messageText(event.data),
|
|
1879
|
+
...attachmentProjection(event.data)
|
|
1880
|
+
});
|
|
1881
|
+
break;
|
|
1882
|
+
case "assistant/message": {
|
|
1883
|
+
const text = messageText(event.data);
|
|
1884
|
+
const thinking = messageThinking(event.data);
|
|
1885
|
+
if (!text.trim() && !thinking.trim()) break;
|
|
1886
|
+
messages.push({
|
|
1887
|
+
...base,
|
|
1888
|
+
role: "assistant",
|
|
1889
|
+
text,
|
|
1890
|
+
...thinking ? { thinking } : {}
|
|
1891
|
+
});
|
|
1892
|
+
break;
|
|
1893
|
+
}
|
|
1894
|
+
case "tool/call": {
|
|
1895
|
+
const data = event.data;
|
|
1896
|
+
const row = {
|
|
1897
|
+
...base,
|
|
1898
|
+
role: "tool",
|
|
1899
|
+
tool: {
|
|
1900
|
+
name: String(data?.name ?? "tool"),
|
|
1901
|
+
state: "running",
|
|
1902
|
+
summary: summarizeArgs(data?.arguments)
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
messages.push(row);
|
|
1906
|
+
if (data?.callId) toolByCall.set(String(data.callId), row);
|
|
1907
|
+
break;
|
|
1908
|
+
}
|
|
1909
|
+
case "tool/result": {
|
|
1910
|
+
const data = event.data;
|
|
1911
|
+
const callId = data?.callId ? String(data.callId) : void 0;
|
|
1912
|
+
const target = callId ? toolByCall.get(callId) : void 0;
|
|
1913
|
+
const failed = data?.error !== void 0;
|
|
1914
|
+
const summary = failed ? "失败" : summarizeResult(data?.message?.content);
|
|
1915
|
+
if (target?.tool) target.tool = {
|
|
1916
|
+
...target.tool,
|
|
1917
|
+
state: failed ? "error" : "ok",
|
|
1918
|
+
summary
|
|
1919
|
+
};
|
|
1920
|
+
else messages.push({
|
|
1921
|
+
...base,
|
|
1922
|
+
role: "tool",
|
|
1923
|
+
tool: {
|
|
1924
|
+
name: "result",
|
|
1925
|
+
state: failed ? "error" : "ok",
|
|
1926
|
+
summary
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
break;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
return messages.sort((a, b) => a.seq - b.seq);
|
|
1934
|
+
}
|
|
1935
|
+
function summarizeResult(content) {
|
|
1936
|
+
return truncate(contentText(content).replace(/\s+/g, " ").trim(), 90);
|
|
1937
|
+
}
|
|
1938
|
+
//#endregion
|
|
1939
|
+
//#region src/report-service.ts
|
|
1940
|
+
/**
|
|
1941
|
+
* The Typert receiver the Gateway resolves for the DeepPilot Bridge report.
|
|
1942
|
+
* Snapshot data stays non-secret; the token crosses the boundary only through
|
|
1943
|
+
* the explicit, user-triggered revealToken/rotateToken invocations.
|
|
1944
|
+
*/
|
|
1945
|
+
var DeepPilotReportService = class extends TypertRemoteService {
|
|
1946
|
+
snapshot;
|
|
1947
|
+
pairingToken;
|
|
1948
|
+
rotatePairingToken;
|
|
1949
|
+
relayTester;
|
|
1950
|
+
pushTester;
|
|
1951
|
+
constructor(ctx, snapshot, pairingToken, rotatePairingToken, relayTester, pushTester) {
|
|
1952
|
+
super(ctx, "deeppilotReport", { namespace: "deeppilot" });
|
|
1953
|
+
this.snapshot = snapshot;
|
|
1954
|
+
this.pairingToken = pairingToken;
|
|
1955
|
+
this.rotatePairingToken = rotatePairingToken;
|
|
1956
|
+
this.relayTester = relayTester;
|
|
1957
|
+
this.pushTester = pushTester;
|
|
1958
|
+
}
|
|
1959
|
+
async report() {
|
|
1960
|
+
return this.snapshot();
|
|
1961
|
+
}
|
|
1962
|
+
async revealToken() {
|
|
1963
|
+
return this.pairingToken();
|
|
1964
|
+
}
|
|
1965
|
+
async rotateToken() {
|
|
1966
|
+
return this.rotatePairingToken();
|
|
1967
|
+
}
|
|
1968
|
+
async testRelay() {
|
|
1969
|
+
return this.relayTester();
|
|
1970
|
+
}
|
|
1971
|
+
async testPush() {
|
|
1972
|
+
return this.pushTester();
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
//#endregion
|
|
1976
|
+
//#region src/report-wire.ts
|
|
1977
|
+
/** The npm package identity both contribution registrations claim. */
|
|
1978
|
+
const REPORT_REMOTE_PACKAGE = "dsh-deeppilot";
|
|
1979
|
+
/** Canonical `<namespace>/<method>` endpoint of the report Remote. */
|
|
1980
|
+
const REPORT_ENDPOINT = "deeppilot/report";
|
|
1981
|
+
/** Explicit, user-triggered endpoint for revealing the pairing secret. */
|
|
1982
|
+
const REVEAL_TOKEN_ENDPOINT = "deeppilot/revealToken";
|
|
1983
|
+
/**
|
|
1984
|
+
* Explicit, user-triggered endpoint that replaces the pairing secret. The old
|
|
1985
|
+
* token stops working immediately; the fresh one is returned so the page can
|
|
1986
|
+
* show/QR it right away.
|
|
1987
|
+
*/
|
|
1988
|
+
const ROTATE_TOKEN_ENDPOINT = "deeppilot/rotateToken";
|
|
1989
|
+
function reject(field) {
|
|
1990
|
+
throw new TypeError(`deeppilot/report result: invalid ${field}`);
|
|
1991
|
+
}
|
|
1992
|
+
function str(source, key, field) {
|
|
1993
|
+
const value = source[key];
|
|
1994
|
+
if (typeof value !== "string") reject(field);
|
|
1995
|
+
return value;
|
|
1996
|
+
}
|
|
1997
|
+
function num(source, key, field) {
|
|
1998
|
+
const value = source[key];
|
|
1999
|
+
if (typeof value !== "number" || !Number.isFinite(value)) reject(field);
|
|
2000
|
+
return value;
|
|
2001
|
+
}
|
|
2002
|
+
function bool(source, key, field) {
|
|
2003
|
+
const value = source[key];
|
|
2004
|
+
if (typeof value !== "boolean") reject(field);
|
|
2005
|
+
return value;
|
|
2006
|
+
}
|
|
2007
|
+
function rec(value, field) {
|
|
2008
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) reject(field);
|
|
2009
|
+
return value;
|
|
2010
|
+
}
|
|
2011
|
+
function parseDevice(value) {
|
|
2012
|
+
const s = rec(value, "device");
|
|
2013
|
+
let apns;
|
|
2014
|
+
if (s.apns !== void 0) {
|
|
2015
|
+
const a = rec(s.apns, "device.apns");
|
|
2016
|
+
const environment = str(a, "environment", "device.apns.environment");
|
|
2017
|
+
if (environment !== "development" && environment !== "production") reject("device.apns.environment");
|
|
2018
|
+
apns = {
|
|
2019
|
+
environment,
|
|
2020
|
+
updatedAt: num(a, "updatedAt", "device.apns.updatedAt")
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
return {
|
|
2024
|
+
deviceId: str(s, "deviceId", "device.deviceId"),
|
|
2025
|
+
deviceName: str(s, "deviceName", "device.deviceName"),
|
|
2026
|
+
appVersion: str(s, "appVersion", "device.appVersion"),
|
|
2027
|
+
firstSeenTs: num(s, "firstSeenTs", "device.firstSeenTs"),
|
|
2028
|
+
lastSeenTs: num(s, "lastSeenTs", "device.lastSeenTs"),
|
|
2029
|
+
...apns ? { apns } : {}
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
function parseRemote(value) {
|
|
2033
|
+
const s = rec(value, "remote");
|
|
2034
|
+
const provider = str(s, "provider", "remote.provider");
|
|
2035
|
+
const phase = str(s, "phase", "remote.phase");
|
|
2036
|
+
if (provider !== "tailscale-funnel") reject("remote.provider");
|
|
2037
|
+
if (![
|
|
2038
|
+
"disabled",
|
|
2039
|
+
"starting",
|
|
2040
|
+
"login_required",
|
|
2041
|
+
"online",
|
|
2042
|
+
"error",
|
|
2043
|
+
"unavailable",
|
|
2044
|
+
"stopped"
|
|
2045
|
+
].includes(phase)) reject("remote.phase");
|
|
2046
|
+
const publicURL = s.publicURL;
|
|
2047
|
+
const authURL = s.authURL;
|
|
2048
|
+
const message = s.message;
|
|
2049
|
+
if (publicURL !== void 0 && typeof publicURL !== "string") reject("remote.publicURL");
|
|
2050
|
+
if (authURL !== void 0 && typeof authURL !== "string") reject("remote.authURL");
|
|
2051
|
+
if (message !== void 0 && typeof message !== "string") reject("remote.message");
|
|
2052
|
+
return {
|
|
2053
|
+
provider,
|
|
2054
|
+
phase,
|
|
2055
|
+
...typeof publicURL === "string" ? { publicURL } : {},
|
|
2056
|
+
...typeof authURL === "string" ? { authURL } : {},
|
|
2057
|
+
...typeof message === "string" ? { message } : {},
|
|
2058
|
+
updatedAt: num(s, "updatedAt", "remote.updatedAt")
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
function parseRelayTestStep(value) {
|
|
2062
|
+
const st = rec(value, "step");
|
|
2063
|
+
const id = str(st, "id", "step.id");
|
|
2064
|
+
if (id !== "health" && id !== "enroll") reject("step.id");
|
|
2065
|
+
const latencyMs = st.latencyMs;
|
|
2066
|
+
if (latencyMs !== void 0 && typeof latencyMs !== "number") reject("step.latencyMs");
|
|
2067
|
+
return {
|
|
2068
|
+
id,
|
|
2069
|
+
ok: bool(st, "ok", "step.ok"),
|
|
2070
|
+
message: str(st, "message", "step.message"),
|
|
2071
|
+
...typeof latencyMs === "number" ? { latencyMs } : {}
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
function parseRelayTestResult(value) {
|
|
2075
|
+
const s = rec(value, "result");
|
|
2076
|
+
const overall = str(s, "overall", "overall");
|
|
2077
|
+
if (overall !== "ok" && overall !== "failed") reject("overall");
|
|
2078
|
+
const stepsRaw = s.steps;
|
|
2079
|
+
if (!Array.isArray(stepsRaw)) reject("steps");
|
|
2080
|
+
return {
|
|
2081
|
+
url: str(s, "url", "url"),
|
|
2082
|
+
overall,
|
|
2083
|
+
tokenIssued: bool(s, "tokenIssued", "tokenIssued"),
|
|
2084
|
+
steps: stepsRaw.map(parseRelayTestStep)
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
function parsePushTestResult(value) {
|
|
2088
|
+
const s = rec(value, "result");
|
|
2089
|
+
const transport = str(s, "transport", "transport");
|
|
2090
|
+
if (transport !== "apns" && transport !== "relay" && transport !== "none") reject("transport");
|
|
2091
|
+
const overall = str(s, "overall", "overall");
|
|
2092
|
+
if (![
|
|
2093
|
+
"sent",
|
|
2094
|
+
"failed",
|
|
2095
|
+
"no-targets",
|
|
2096
|
+
"not-configured"
|
|
2097
|
+
].includes(overall)) reject("overall");
|
|
2098
|
+
const resultsRaw = s.results;
|
|
2099
|
+
if (!Array.isArray(resultsRaw)) reject("results");
|
|
2100
|
+
const results = resultsRaw.map((value) => {
|
|
2101
|
+
const r = rec(value, "device result");
|
|
2102
|
+
const reason = r.reason;
|
|
2103
|
+
const tokenFingerprint = r.tokenFingerprint;
|
|
2104
|
+
return {
|
|
2105
|
+
name: str(r, "name", "result.name"),
|
|
2106
|
+
environment: str(r, "environment", "result.environment"),
|
|
2107
|
+
outcome: str(r, "outcome", "result.outcome"),
|
|
2108
|
+
...typeof reason === "string" && reason.length > 0 ? { reason } : {},
|
|
2109
|
+
...typeof tokenFingerprint === "string" && /^[0-9a-f]{1,32}$/.test(tokenFingerprint) ? { tokenFingerprint } : {}
|
|
2110
|
+
};
|
|
2111
|
+
});
|
|
2112
|
+
const message = s.message;
|
|
2113
|
+
return {
|
|
2114
|
+
transport,
|
|
2115
|
+
overall,
|
|
2116
|
+
...typeof message === "string" && message.length > 0 ? { message } : {},
|
|
2117
|
+
results
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
function parseReport(value) {
|
|
2121
|
+
const s = rec(value, "report");
|
|
2122
|
+
const devices = s.devices;
|
|
2123
|
+
const lanAddresses = s.lanAddresses;
|
|
2124
|
+
if (!Array.isArray(devices)) reject("devices");
|
|
2125
|
+
if (!Array.isArray(lanAddresses) || lanAddresses.some((value) => typeof value !== "string")) reject("lanAddresses");
|
|
2126
|
+
return {
|
|
2127
|
+
protocolVersion: num(s, "protocolVersion", "protocolVersion"),
|
|
2128
|
+
serverVersion: str(s, "serverVersion", "serverVersion"),
|
|
2129
|
+
enabled: bool(s, "enabled", "enabled"),
|
|
2130
|
+
tokenPath: str(s, "tokenPath", "tokenPath"),
|
|
2131
|
+
tokenReady: bool(s, "tokenReady", "tokenReady"),
|
|
2132
|
+
activeConnections: num(s, "activeConnections", "activeConnections"),
|
|
2133
|
+
historyBufferMax: num(s, "historyBufferMax", "historyBufferMax"),
|
|
2134
|
+
debug: bool(s, "debug", "debug"),
|
|
2135
|
+
lanAddresses,
|
|
2136
|
+
remote: parseRemote(s.remote),
|
|
2137
|
+
devices: devices.map(parseDevice)
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
const reportSchema = { parse: parseReport };
|
|
2141
|
+
const relayTestSchema = { parse: parseRelayTestResult };
|
|
2142
|
+
const pushTestSchema = { parse: parsePushTestResult };
|
|
2143
|
+
const pairingTokenSchema = { parse(value) {
|
|
2144
|
+
if (typeof value !== "string" || value.length < 32) throw new TypeError("deeppilot/revealToken result: invalid token");
|
|
2145
|
+
return value;
|
|
2146
|
+
} };
|
|
2147
|
+
const REPORT_HOST_CONTRIBUTION = {
|
|
2148
|
+
package: REPORT_REMOTE_PACKAGE,
|
|
2149
|
+
face: "host",
|
|
2150
|
+
schemas: [],
|
|
2151
|
+
invocations: [
|
|
2152
|
+
{
|
|
2153
|
+
id: `${REPORT_REMOTE_PACKAGE}#${REPORT_ENDPOINT}`,
|
|
2154
|
+
service: "deeppilotReport",
|
|
2155
|
+
namespace: "deeppilot",
|
|
2156
|
+
method: "report",
|
|
2157
|
+
invocation: { kind: "direct" },
|
|
2158
|
+
parameters: [],
|
|
2159
|
+
result: {
|
|
2160
|
+
mode: "strict",
|
|
2161
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeepPilotReport`,
|
|
2162
|
+
schema: reportSchema
|
|
2163
|
+
}
|
|
2164
|
+
},
|
|
2165
|
+
{
|
|
2166
|
+
id: `${REPORT_REMOTE_PACKAGE}#${REVEAL_TOKEN_ENDPOINT}`,
|
|
2167
|
+
service: "deeppilotReport",
|
|
2168
|
+
namespace: "deeppilot",
|
|
2169
|
+
method: "revealToken",
|
|
2170
|
+
invocation: { kind: "direct" },
|
|
2171
|
+
parameters: [],
|
|
2172
|
+
result: {
|
|
2173
|
+
mode: "strict",
|
|
2174
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingToken`,
|
|
2175
|
+
schema: pairingTokenSchema
|
|
2176
|
+
}
|
|
2177
|
+
},
|
|
2178
|
+
{
|
|
2179
|
+
id: `${REPORT_REMOTE_PACKAGE}#${ROTATE_TOKEN_ENDPOINT}`,
|
|
2180
|
+
service: "deeppilotReport",
|
|
2181
|
+
namespace: "deeppilot",
|
|
2182
|
+
method: "rotateToken",
|
|
2183
|
+
invocation: { kind: "direct" },
|
|
2184
|
+
parameters: [],
|
|
2185
|
+
result: {
|
|
2186
|
+
mode: "strict",
|
|
2187
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingToken`,
|
|
2188
|
+
schema: pairingTokenSchema
|
|
2189
|
+
}
|
|
2190
|
+
},
|
|
2191
|
+
{
|
|
2192
|
+
id: `${REPORT_REMOTE_PACKAGE}#deeppilot/testRelay`,
|
|
2193
|
+
service: "deeppilotReport",
|
|
2194
|
+
namespace: "deeppilot",
|
|
2195
|
+
method: "testRelay",
|
|
2196
|
+
invocation: { kind: "direct" },
|
|
2197
|
+
parameters: [],
|
|
2198
|
+
result: {
|
|
2199
|
+
mode: "strict",
|
|
2200
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#RelayTestResult`,
|
|
2201
|
+
schema: relayTestSchema
|
|
2202
|
+
}
|
|
2203
|
+
},
|
|
2204
|
+
{
|
|
2205
|
+
id: `${REPORT_REMOTE_PACKAGE}#deeppilot/testPush`,
|
|
2206
|
+
service: "deeppilotReport",
|
|
2207
|
+
namespace: "deeppilot",
|
|
2208
|
+
method: "testPush",
|
|
2209
|
+
invocation: { kind: "direct" },
|
|
2210
|
+
parameters: [],
|
|
2211
|
+
result: {
|
|
2212
|
+
mode: "strict",
|
|
2213
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#PushTestResult`,
|
|
2214
|
+
schema: pushTestSchema
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
]
|
|
2218
|
+
};
|
|
2219
|
+
//#endregion
|
|
2220
|
+
//#region src/report-remote.ts
|
|
2221
|
+
/**
|
|
2222
|
+
* Provide the report service and register its Remote descriptor. Rides an
|
|
2223
|
+
* optional `typert` inject: profiles without the web stack never activate it.
|
|
2224
|
+
*/
|
|
2225
|
+
function applyReportRemote(ctx, snapshot, pairingToken, rotatePairingToken, relayTester, pushTester) {
|
|
2226
|
+
ctx.inject(["typert"], (remoteCtx) => {
|
|
2227
|
+
new DeepPilotReportService(remoteCtx, snapshot, pairingToken, rotatePairingToken, relayTester, pushTester);
|
|
2228
|
+
const unregister = remoteCtx.typert.register(REPORT_HOST_CONTRIBUTION);
|
|
2229
|
+
remoteCtx.effect(() => () => void unregister(), "dsh-deeppilot: report remote");
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
//#endregion
|
|
2233
|
+
//#region src/relay-test.ts
|
|
2234
|
+
const DEFAULT_TIMEOUT_MS = 6e3;
|
|
2235
|
+
async function requestJson(fetchImpl, url, init, timeoutMs) {
|
|
2236
|
+
const response = await fetchImpl(url, {
|
|
2237
|
+
...init,
|
|
2238
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2239
|
+
});
|
|
2240
|
+
let body = null;
|
|
2241
|
+
try {
|
|
2242
|
+
body = await response.json();
|
|
2243
|
+
} catch {}
|
|
2244
|
+
return {
|
|
2245
|
+
status: response.status,
|
|
2246
|
+
body
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
async function runRelayProbe(options) {
|
|
2250
|
+
const base = options.url.trim().replace(/\/+$/, "");
|
|
2251
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
2252
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
2253
|
+
const steps = [];
|
|
2254
|
+
let tokenIssued = false;
|
|
2255
|
+
try {
|
|
2256
|
+
const startedAt = Date.now();
|
|
2257
|
+
const { status, body } = await requestJson(fetchImpl, `${base}/healthz`, { method: "GET" }, timeoutMs);
|
|
2258
|
+
const latencyMs = Date.now() - startedAt;
|
|
2259
|
+
if (status === 200 && body?.ok === true) steps.push({
|
|
2260
|
+
id: "health",
|
|
2261
|
+
ok: true,
|
|
2262
|
+
message: "中继服务可达",
|
|
2263
|
+
latencyMs
|
|
2264
|
+
});
|
|
2265
|
+
else steps.push({
|
|
2266
|
+
id: "health",
|
|
2267
|
+
ok: false,
|
|
2268
|
+
message: `中继响应异常(HTTP ${status})`,
|
|
2269
|
+
latencyMs
|
|
2270
|
+
});
|
|
2271
|
+
} catch (error) {
|
|
2272
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2273
|
+
steps.push({
|
|
2274
|
+
id: "health",
|
|
2275
|
+
ok: false,
|
|
2276
|
+
message: "无法连接中继:" + reason
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
if (options.manualToken ?? false) steps.push({
|
|
2280
|
+
id: "enroll",
|
|
2281
|
+
ok: true,
|
|
2282
|
+
message: "已手动配置 relayToken,跳过注册验证"
|
|
2283
|
+
});
|
|
2284
|
+
else if (!options.enrollKey) steps.push({
|
|
2285
|
+
id: "enroll",
|
|
2286
|
+
ok: false,
|
|
2287
|
+
message: "尚无注册密钥:等待分发版 App 首次注册后才能验证注册"
|
|
2288
|
+
});
|
|
2289
|
+
else {
|
|
2290
|
+
const clientId = options.clientId ?? "u_" + Math.random().toString(36).slice(2);
|
|
2291
|
+
try {
|
|
2292
|
+
const startedAt = Date.now();
|
|
2293
|
+
const { status, body } = await requestJson(fetchImpl, `${base}/v1/enroll`, {
|
|
2294
|
+
method: "POST",
|
|
2295
|
+
headers: { "content-type": "application/json" },
|
|
2296
|
+
body: JSON.stringify({
|
|
2297
|
+
clientId,
|
|
2298
|
+
enrollKey: options.enrollKey
|
|
2299
|
+
})
|
|
2300
|
+
}, timeoutMs);
|
|
2301
|
+
const latencyMs = Date.now() - startedAt;
|
|
2302
|
+
const token = body?.token;
|
|
2303
|
+
if (status === 200 && typeof token === "string" && token.startsWith("rl_")) {
|
|
2304
|
+
tokenIssued = true;
|
|
2305
|
+
options.onEnrolled?.(token);
|
|
2306
|
+
steps.push({
|
|
2307
|
+
id: "enroll",
|
|
2308
|
+
ok: true,
|
|
2309
|
+
message: "注册成功,已取得推送凭证",
|
|
2310
|
+
latencyMs
|
|
2311
|
+
});
|
|
2312
|
+
} else if (status === 403) steps.push({
|
|
2313
|
+
id: "enroll",
|
|
2314
|
+
ok: false,
|
|
2315
|
+
message: "注册被拒:注册密钥不匹配(检查 App 内 DSPushEnrollKey 与服务器 RELAY_ENROLL_KEY)",
|
|
2316
|
+
latencyMs
|
|
2317
|
+
});
|
|
2318
|
+
else if (status === 429) steps.push({
|
|
2319
|
+
id: "enroll",
|
|
2320
|
+
ok: false,
|
|
2321
|
+
message: "尝试过于频繁,稍后再试",
|
|
2322
|
+
latencyMs
|
|
2323
|
+
});
|
|
2324
|
+
else steps.push({
|
|
2325
|
+
id: "enroll",
|
|
2326
|
+
ok: false,
|
|
2327
|
+
message: `注册失败(HTTP ${status})`,
|
|
2328
|
+
latencyMs
|
|
2329
|
+
});
|
|
2330
|
+
} catch (error) {
|
|
2331
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2332
|
+
steps.push({
|
|
2333
|
+
id: "enroll",
|
|
2334
|
+
ok: false,
|
|
2335
|
+
message: "注册请求失败:" + reason
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
const executed = steps.filter((step) => step.ok !== void 0);
|
|
2340
|
+
return {
|
|
2341
|
+
url: base,
|
|
2342
|
+
overall: steps.length > 0 && steps.some((step) => step.id === "health" && step.ok) && executed.every((step) => step.ok) ? "ok" : "failed",
|
|
2343
|
+
tokenIssued,
|
|
2344
|
+
steps
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
//#endregion
|
|
2348
|
+
//#region src/apns.ts
|
|
2349
|
+
/**
|
|
2350
|
+
* Minimal APNs provider client (HTTP/2) with zero npm dependencies.
|
|
2351
|
+
*
|
|
2352
|
+
* Implements exactly what the bridge needs:
|
|
2353
|
+
* - ES256 provider token (JWT) signed with an Apple .p8 key, refreshed under
|
|
2354
|
+
* the 1-hour freshness window Apple enforces;
|
|
2355
|
+
* - one long-lived HTTP/2 session per environment, recreated transparently
|
|
2356
|
+
* after GOAWAY/errors;
|
|
2357
|
+
* - alert pushes carrying the notify projection (category/thread/collapse),
|
|
2358
|
+
* with `interruption-level: time-sensitive` for approval/question events;
|
|
2359
|
+
* - outcome classification so callers can prune dead device tokens.
|
|
2360
|
+
*
|
|
2361
|
+
* Privacy: logs carry outcomes and masked token prefixes only — never message
|
|
2362
|
+
* bodies or full tokens.
|
|
2363
|
+
*/
|
|
2364
|
+
const PROVIDER_TOKEN_TTL_MS = 3e6;
|
|
2365
|
+
const REQUEST_TIMEOUT_MS = 1e4;
|
|
2366
|
+
/** base64url without padding. */
|
|
2367
|
+
function b64url(input) {
|
|
2368
|
+
return Buffer.from(input).toString("base64url");
|
|
2369
|
+
}
|
|
2370
|
+
/** Sign one ES256 JWT for the given signing input with a P-256 private key. */
|
|
2371
|
+
function es256Jwt(signingInput, key) {
|
|
2372
|
+
const signature = sign("sha256", Buffer.from(signingInput, "utf8"), {
|
|
2373
|
+
key,
|
|
2374
|
+
dsaEncoding: "ieee-p1363"
|
|
2375
|
+
});
|
|
2376
|
+
return signingInput + "." + b64url(signature);
|
|
2377
|
+
}
|
|
2378
|
+
/** Strip PEM armor from a .p8 file and decode to PKCS#8 DER. */
|
|
2379
|
+
function p8ToDer(pem) {
|
|
2380
|
+
const body = pem.replace(/-----BEGIN PRIVATE KEY-----/, "").replace(/-----END PRIVATE KEY-----/, "").replace(/\s+/g, "");
|
|
2381
|
+
return Buffer.from(body, "base64");
|
|
2382
|
+
}
|
|
2383
|
+
/** Pure payload builder so tests can assert the wire format without sockets. */
|
|
2384
|
+
function apnsPayload(notification) {
|
|
2385
|
+
const timeSensitive = notification.category === "approval.required" || notification.category === "question.asked";
|
|
2386
|
+
return {
|
|
2387
|
+
aps: {
|
|
2388
|
+
alert: {
|
|
2389
|
+
title: notification.title.slice(0, 120),
|
|
2390
|
+
body: notification.body.slice(0, 200)
|
|
2391
|
+
},
|
|
2392
|
+
sound: "default",
|
|
2393
|
+
category: notification.category,
|
|
2394
|
+
"thread-id": notification.sessionId.slice(0, 64),
|
|
2395
|
+
...timeSensitive ? { "interruption-level": "time-sensitive" } : {}
|
|
2396
|
+
},
|
|
2397
|
+
sessionId: notification.sessionId,
|
|
2398
|
+
notificationId: notification.notificationId,
|
|
2399
|
+
kind: notification.category
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
/** collapse-id accepts ≤64 bytes of ASCII; keep it stable per session+event. */
|
|
2403
|
+
function collapseIdFor(notification) {
|
|
2404
|
+
return `${notification.category}:${notification.sessionId}`.replace(/[^a-zA-Z0-9.:-]/g, "").slice(0, 64);
|
|
2405
|
+
}
|
|
2406
|
+
function authorityFor(environment) {
|
|
2407
|
+
return environment === "production" ? "api.push.apple.com" : "api.sandbox.push.apple.com";
|
|
2408
|
+
}
|
|
2409
|
+
var ApnsClient = class {
|
|
2410
|
+
log;
|
|
2411
|
+
debug;
|
|
2412
|
+
opts;
|
|
2413
|
+
providerToken = "";
|
|
2414
|
+
providerTokenIssuedAt = 0;
|
|
2415
|
+
key;
|
|
2416
|
+
constructor(opts) {
|
|
2417
|
+
this.opts = opts;
|
|
2418
|
+
this.log = opts.log;
|
|
2419
|
+
this.debug = opts.debug === true;
|
|
2420
|
+
}
|
|
2421
|
+
async dispose() {
|
|
2422
|
+
const sessions = [...this.sessions.values()];
|
|
2423
|
+
this.sessions.clear();
|
|
2424
|
+
await Promise.all(sessions.filter((session) => !session.destroyed).map((session) => new Promise((resolve) => session.close(() => resolve()))));
|
|
2425
|
+
}
|
|
2426
|
+
async ensureProviderToken() {
|
|
2427
|
+
if (this.providerToken && Date.now() - this.providerTokenIssuedAt < PROVIDER_TOKEN_TTL_MS) return this.providerToken;
|
|
2428
|
+
if (!this.key) {
|
|
2429
|
+
const pem = await readFile(this.opts.keyPath, "utf8");
|
|
2430
|
+
this.key = createPrivateKey({
|
|
2431
|
+
key: p8ToDer(pem),
|
|
2432
|
+
format: "der",
|
|
2433
|
+
type: "pkcs8"
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
const issuedAt = Math.floor(Date.now() / 1e3);
|
|
2437
|
+
const header = b64url(JSON.stringify({
|
|
2438
|
+
alg: "ES256",
|
|
2439
|
+
kid: this.opts.keyId
|
|
2440
|
+
}));
|
|
2441
|
+
const claims = b64url(JSON.stringify({
|
|
2442
|
+
iss: this.opts.teamId,
|
|
2443
|
+
iat: issuedAt
|
|
2444
|
+
}));
|
|
2445
|
+
this.providerToken = es256Jwt(`${header}.${claims}`, this.key);
|
|
2446
|
+
this.providerTokenIssuedAt = Date.now();
|
|
2447
|
+
return this.providerToken;
|
|
2448
|
+
}
|
|
2449
|
+
/** One long-lived HTTP/2 session per Apple host (sandbox + production). */
|
|
2450
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2451
|
+
ensureSession(authority) {
|
|
2452
|
+
const existing = this.sessions.get(authority);
|
|
2453
|
+
if (existing && !existing.destroyed && !existing.closed) return existing;
|
|
2454
|
+
const session = connect(`https://${authority}`);
|
|
2455
|
+
session.on("error", (error) => {
|
|
2456
|
+
if (this.debug) this.log("apns session error (" + authority + "): " + String(error));
|
|
2457
|
+
this.sessions.delete(authority);
|
|
2458
|
+
});
|
|
2459
|
+
this.sessions.set(authority, session);
|
|
2460
|
+
return session;
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Deliver one alert. Never throws — every failure path resolves to an
|
|
2464
|
+
* outcome so fan-out loops cannot crash the host on a flaky network.
|
|
2465
|
+
*/
|
|
2466
|
+
async send(request) {
|
|
2467
|
+
const { deviceToken, environment, ...notification } = request;
|
|
2468
|
+
let stream;
|
|
2469
|
+
try {
|
|
2470
|
+
const token = await this.ensureProviderToken();
|
|
2471
|
+
const body = JSON.stringify(apnsPayload(notification));
|
|
2472
|
+
const session = this.ensureSession(authorityFor(environment));
|
|
2473
|
+
return await new Promise((resolve) => {
|
|
2474
|
+
const req = session.request({
|
|
2475
|
+
[":method"]: "POST",
|
|
2476
|
+
[":path"]: "/3/device/" + deviceToken,
|
|
2477
|
+
authorization: "bearer " + token,
|
|
2478
|
+
"apns-topic": this.opts.bundleId,
|
|
2479
|
+
"apns-push-type": "alert",
|
|
2480
|
+
"apns-priority": "10",
|
|
2481
|
+
"apns-expiration": String(Math.floor(Date.now() / 1e3) + 3600),
|
|
2482
|
+
"apns-collapse-id": collapseIdFor(notification),
|
|
2483
|
+
"content-type": "application/json",
|
|
2484
|
+
"content-length": String(Buffer.byteLength(body))
|
|
2485
|
+
});
|
|
2486
|
+
stream = req;
|
|
2487
|
+
let status = 0;
|
|
2488
|
+
let responseBody = "";
|
|
2489
|
+
const settle = (outcome, reason) => {
|
|
2490
|
+
if (this.debug) this.log(`apns ${outcome}${reason ? " (" + reason + ")" : ""} (${this.maskToken(deviceToken)})`);
|
|
2491
|
+
resolve(reason !== void 0 && reason !== "" ? {
|
|
2492
|
+
outcome,
|
|
2493
|
+
reason
|
|
2494
|
+
} : { outcome });
|
|
2495
|
+
};
|
|
2496
|
+
const timer = setTimeout(() => {
|
|
2497
|
+
req.close();
|
|
2498
|
+
settle("failed");
|
|
2499
|
+
}, REQUEST_TIMEOUT_MS);
|
|
2500
|
+
timer.unref?.();
|
|
2501
|
+
req.on("response", (headers) => {
|
|
2502
|
+
status = Number(headers[":status"] ?? 0);
|
|
2503
|
+
});
|
|
2504
|
+
req.on("data", (chunk) => {
|
|
2505
|
+
responseBody += chunk.toString("utf8");
|
|
2506
|
+
});
|
|
2507
|
+
req.on("error", () => {
|
|
2508
|
+
clearTimeout(timer);
|
|
2509
|
+
settle("failed");
|
|
2510
|
+
});
|
|
2511
|
+
req.on("end", () => {
|
|
2512
|
+
clearTimeout(timer);
|
|
2513
|
+
if (status === 200) return settle("sent");
|
|
2514
|
+
let reason = "";
|
|
2515
|
+
try {
|
|
2516
|
+
reason = String(JSON.parse(responseBody).reason ?? "");
|
|
2517
|
+
} catch {}
|
|
2518
|
+
if (status !== 200 && !reason) reason = "HTTP " + String(status);
|
|
2519
|
+
if (reason === "Unregistered" || reason === "BadDeviceToken") return settle("invalid-token", reason);
|
|
2520
|
+
if (this.debug) this.log(`apns rejected status=${status} reason=${reason}`);
|
|
2521
|
+
settle("failed", reason);
|
|
2522
|
+
});
|
|
2523
|
+
req.end(body);
|
|
2524
|
+
});
|
|
2525
|
+
} catch (error) {
|
|
2526
|
+
this.key = void 0;
|
|
2527
|
+
this.providerToken = "";
|
|
2528
|
+
this.sessions.clear();
|
|
2529
|
+
if (this.debug) this.log("apns send failed: " + String(error));
|
|
2530
|
+
return {
|
|
2531
|
+
outcome: "failed",
|
|
2532
|
+
reason: String(error).slice(0, 120)
|
|
2533
|
+
};
|
|
2534
|
+
} finally {
|
|
2535
|
+
try {
|
|
2536
|
+
stream?.close();
|
|
2537
|
+
} catch {}
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
maskToken(token) {
|
|
2541
|
+
return token.length <= 10 ? "…" : token.slice(0, 6) + "…" + token.slice(-4);
|
|
2542
|
+
}
|
|
2543
|
+
};
|
|
2544
|
+
//#endregion
|
|
2545
|
+
//#region src/relay-client.ts
|
|
2546
|
+
var RelayClient = class {
|
|
2547
|
+
base;
|
|
2548
|
+
token;
|
|
2549
|
+
timeoutMs;
|
|
2550
|
+
debug;
|
|
2551
|
+
log;
|
|
2552
|
+
constructor(opts) {
|
|
2553
|
+
this.base = opts.url.trim().replace(/\/+$/, "");
|
|
2554
|
+
this.token = (opts.token ?? "").trim();
|
|
2555
|
+
this.timeoutMs = opts.timeoutMs ?? 1e4;
|
|
2556
|
+
this.debug = opts.debug === true;
|
|
2557
|
+
this.log = opts.log;
|
|
2558
|
+
}
|
|
2559
|
+
/**
|
|
2560
|
+
* Zero-touch enrollment: exchange the distributor's shared key (baked into
|
|
2561
|
+
* the distributed app) for a stable per-bridge bearer token. Idempotent —
|
|
2562
|
+
* relays derive the same token for the same clientId. Returns null on any
|
|
2563
|
+
* failure; callers treat that as "not enrolled yet", not as an error.
|
|
2564
|
+
*/
|
|
2565
|
+
async enroll(clientId, enrollKey) {
|
|
2566
|
+
try {
|
|
2567
|
+
const response = await fetch(this.base + "/v1/enroll", {
|
|
2568
|
+
method: "POST",
|
|
2569
|
+
headers: { "content-type": "application/json" },
|
|
2570
|
+
body: JSON.stringify({
|
|
2571
|
+
clientId,
|
|
2572
|
+
enrollKey
|
|
2573
|
+
}),
|
|
2574
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
2575
|
+
});
|
|
2576
|
+
if (!response.ok) {
|
|
2577
|
+
if (this.debug) this.log(`enroll http ${response.status}`);
|
|
2578
|
+
return null;
|
|
2579
|
+
}
|
|
2580
|
+
const body = await response.json();
|
|
2581
|
+
if (typeof body.token === "string" && body.token.startsWith("rl_")) return body.token;
|
|
2582
|
+
if (this.debug) this.log("enroll returned no usable token");
|
|
2583
|
+
return null;
|
|
2584
|
+
} catch (error) {
|
|
2585
|
+
if (this.debug) this.log("enroll failed: " + String(error));
|
|
2586
|
+
return null;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
async send(request) {
|
|
2590
|
+
try {
|
|
2591
|
+
const response = await fetch(this.base + "/v1/push", {
|
|
2592
|
+
method: "POST",
|
|
2593
|
+
headers: {
|
|
2594
|
+
authorization: "Bearer " + this.token,
|
|
2595
|
+
"content-type": "application/json"
|
|
2596
|
+
},
|
|
2597
|
+
body: JSON.stringify({
|
|
2598
|
+
deviceToken: request.deviceToken,
|
|
2599
|
+
environment: request.environment,
|
|
2600
|
+
notification: request.notification
|
|
2601
|
+
}),
|
|
2602
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
2603
|
+
});
|
|
2604
|
+
if (response.status === 401 || response.status === 429) {
|
|
2605
|
+
if (this.debug) this.log(`relay rejected status=${response.status}`);
|
|
2606
|
+
return {
|
|
2607
|
+
outcome: "failed",
|
|
2608
|
+
reason: "HTTP " + String(response.status)
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
if (!response.ok) {
|
|
2612
|
+
if (this.debug) this.log(`relay http ${response.status}`);
|
|
2613
|
+
return {
|
|
2614
|
+
outcome: "failed",
|
|
2615
|
+
reason: "HTTP " + String(response.status)
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
const body = await response.json();
|
|
2619
|
+
if (body.outcome === "sent") return { outcome: "sent" };
|
|
2620
|
+
if (body.outcome === "invalid-token") return {
|
|
2621
|
+
outcome: "invalid-token",
|
|
2622
|
+
reason: body.reason
|
|
2623
|
+
};
|
|
2624
|
+
if (this.debug) this.log("relay outcome=" + String(body.outcome) + " reason=" + String(body.reason ?? ""));
|
|
2625
|
+
return {
|
|
2626
|
+
outcome: "failed",
|
|
2627
|
+
reason: body.reason
|
|
2628
|
+
};
|
|
2629
|
+
} catch (error) {
|
|
2630
|
+
if (this.debug) this.log("relay send failed: " + String(error));
|
|
2631
|
+
return {
|
|
2632
|
+
outcome: "failed",
|
|
2633
|
+
reason: error instanceof Error ? error.message.slice(0, 120) : String(error).slice(0, 120)
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
//#endregion
|
|
2639
|
+
//#region src/remote-supervisor.ts
|
|
2640
|
+
const RESTART_DELAYS_MS = [
|
|
2641
|
+
1e3,
|
|
2642
|
+
2e3,
|
|
2643
|
+
4e3,
|
|
2644
|
+
8e3,
|
|
2645
|
+
16e3,
|
|
2646
|
+
3e4
|
|
2647
|
+
];
|
|
2648
|
+
const DEFAULT_REMOTE_HOSTNAME = "dsh-deeppilot";
|
|
2649
|
+
/** Preserve custom node names while migrating every pre-DeepPilot default. */
|
|
2650
|
+
function normalizeRemoteHostname(value) {
|
|
2651
|
+
const hostname = value?.trim() ?? "";
|
|
2652
|
+
if (hostname === "" || [
|
|
2653
|
+
"dsh-phone",
|
|
2654
|
+
"dsh-pocket",
|
|
2655
|
+
"harnesspocket"
|
|
2656
|
+
].includes(hostname.toLowerCase())) return DEFAULT_REMOTE_HOSTNAME;
|
|
2657
|
+
return hostname;
|
|
2658
|
+
}
|
|
2659
|
+
/** Parse one helper IPC line without ever evaluating or interpolating it. */
|
|
2660
|
+
function parseHelperEvent(line) {
|
|
2661
|
+
try {
|
|
2662
|
+
const value = JSON.parse(line);
|
|
2663
|
+
if (typeof value.phase !== "string" || ![
|
|
2664
|
+
"starting",
|
|
2665
|
+
"login_required",
|
|
2666
|
+
"online",
|
|
2667
|
+
"error",
|
|
2668
|
+
"stopped"
|
|
2669
|
+
].includes(value.phase)) return null;
|
|
2670
|
+
return {
|
|
2671
|
+
phase: value.phase,
|
|
2672
|
+
...typeof value.publicURL === "string" ? { publicURL: value.publicURL } : {},
|
|
2673
|
+
...typeof value.authURL === "string" ? { authURL: value.authURL } : {},
|
|
2674
|
+
...typeof value.message === "string" ? { message: value.message.slice(0, 500) } : {}
|
|
2675
|
+
};
|
|
2676
|
+
} catch {
|
|
2677
|
+
return null;
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
function bundledHelperPath() {
|
|
2681
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2682
|
+
return resolve(here, "..", "bin", `${process.platform}-${process.arch}`, "dsh-deeppilot-tunnel");
|
|
2683
|
+
}
|
|
2684
|
+
/** Owns exactly one embedded tunnel helper and restarts it after failures. */
|
|
2685
|
+
var RemoteSupervisor = class {
|
|
2686
|
+
options;
|
|
2687
|
+
child;
|
|
2688
|
+
restartTimer;
|
|
2689
|
+
restartAttempt = 0;
|
|
2690
|
+
stopping = false;
|
|
2691
|
+
statusValue;
|
|
2692
|
+
constructor(options) {
|
|
2693
|
+
this.options = options;
|
|
2694
|
+
this.statusValue = {
|
|
2695
|
+
provider: "tailscale-funnel",
|
|
2696
|
+
phase: options.enabled ? "stopped" : "disabled",
|
|
2697
|
+
updatedAt: Date.now()
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
status() {
|
|
2701
|
+
return { ...this.statusValue };
|
|
2702
|
+
}
|
|
2703
|
+
async start(originURL) {
|
|
2704
|
+
if (!this.options.enabled || this.child !== void 0 || this.stopping) return;
|
|
2705
|
+
const helper = expandHome(this.options.helperPath ?? bundledHelperPath());
|
|
2706
|
+
const statePath = expandHome(this.options.statePath);
|
|
2707
|
+
try {
|
|
2708
|
+
await access(helper, constants.X_OK);
|
|
2709
|
+
await mkdir(statePath, {
|
|
2710
|
+
recursive: true,
|
|
2711
|
+
mode: 448
|
|
2712
|
+
});
|
|
2713
|
+
} catch (error) {
|
|
2714
|
+
this.setStatus({
|
|
2715
|
+
phase: "unavailable",
|
|
2716
|
+
message: `embedded tunnel helper unavailable: ${String(error)}`
|
|
2717
|
+
});
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
this.setStatus({
|
|
2721
|
+
phase: "starting",
|
|
2722
|
+
message: void 0
|
|
2723
|
+
});
|
|
2724
|
+
const child = spawn(helper, [
|
|
2725
|
+
"--origin",
|
|
2726
|
+
originURL,
|
|
2727
|
+
"--hostname",
|
|
2728
|
+
normalizeRemoteHostname(this.options.hostname),
|
|
2729
|
+
"--state-dir",
|
|
2730
|
+
statePath,
|
|
2731
|
+
"--port",
|
|
2732
|
+
String(this.options.funnelPort ?? 443)
|
|
2733
|
+
], {
|
|
2734
|
+
stdio: [
|
|
2735
|
+
"ignore",
|
|
2736
|
+
"pipe",
|
|
2737
|
+
"pipe"
|
|
2738
|
+
],
|
|
2739
|
+
env: {
|
|
2740
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
2741
|
+
TMPDIR: process.env.TMPDIR ?? "/tmp"
|
|
2742
|
+
}
|
|
2743
|
+
});
|
|
2744
|
+
this.child = child;
|
|
2745
|
+
if (child.stdout === null || child.stderr === null) {
|
|
2746
|
+
this.setStatus({
|
|
2747
|
+
phase: "error",
|
|
2748
|
+
message: "helper stdio unavailable"
|
|
2749
|
+
});
|
|
2750
|
+
child.kill("SIGTERM");
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
let stdoutBuffer = "";
|
|
2754
|
+
child.stdout.setEncoding("utf8");
|
|
2755
|
+
child.stdout.on("data", (chunk) => {
|
|
2756
|
+
stdoutBuffer += chunk;
|
|
2757
|
+
const lines = stdoutBuffer.split("\n");
|
|
2758
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
2759
|
+
for (const line of lines) this.acceptLine(line);
|
|
2760
|
+
});
|
|
2761
|
+
let stderrBuffer = "";
|
|
2762
|
+
child.stderr.setEncoding("utf8");
|
|
2763
|
+
child.stderr.on("data", (chunk) => {
|
|
2764
|
+
stderrBuffer = (stderrBuffer + chunk).slice(-2e3);
|
|
2765
|
+
});
|
|
2766
|
+
child.once("error", (error) => {
|
|
2767
|
+
this.setStatus({
|
|
2768
|
+
phase: "error",
|
|
2769
|
+
message: `helper launch failed: ${String(error)}`
|
|
2770
|
+
});
|
|
2771
|
+
});
|
|
2772
|
+
child.once("exit", (code, signal) => {
|
|
2773
|
+
if (this.child === child) this.child = void 0;
|
|
2774
|
+
if (this.stopping) {
|
|
2775
|
+
this.setStatus({
|
|
2776
|
+
phase: "stopped",
|
|
2777
|
+
message: void 0
|
|
2778
|
+
});
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
const detail = stderrBuffer.trim().split("\n").at(-1);
|
|
2782
|
+
this.setStatus({
|
|
2783
|
+
phase: "error",
|
|
2784
|
+
message: detail || `helper exited (${signal ?? String(code)})`
|
|
2785
|
+
});
|
|
2786
|
+
this.scheduleRestart(originURL);
|
|
2787
|
+
});
|
|
2788
|
+
}
|
|
2789
|
+
async dispose() {
|
|
2790
|
+
this.stopping = true;
|
|
2791
|
+
if (this.restartTimer !== void 0) clearTimeout(this.restartTimer);
|
|
2792
|
+
this.restartTimer = void 0;
|
|
2793
|
+
const child = this.child;
|
|
2794
|
+
this.child = void 0;
|
|
2795
|
+
if (child === void 0) {
|
|
2796
|
+
this.setStatus({
|
|
2797
|
+
phase: "stopped",
|
|
2798
|
+
message: void 0
|
|
2799
|
+
});
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
await new Promise((resolveDone) => {
|
|
2803
|
+
const force = setTimeout(() => {
|
|
2804
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
2805
|
+
}, 3e3);
|
|
2806
|
+
child.once("exit", () => {
|
|
2807
|
+
clearTimeout(force);
|
|
2808
|
+
resolveDone();
|
|
2809
|
+
});
|
|
2810
|
+
child.kill("SIGTERM");
|
|
2811
|
+
});
|
|
2812
|
+
this.setStatus({
|
|
2813
|
+
phase: "stopped",
|
|
2814
|
+
message: void 0
|
|
2815
|
+
});
|
|
2816
|
+
}
|
|
2817
|
+
acceptLine(line) {
|
|
2818
|
+
const event = parseHelperEvent(line);
|
|
2819
|
+
if (event === null || event.phase === void 0) return;
|
|
2820
|
+
if (event.phase === "online") this.restartAttempt = 0;
|
|
2821
|
+
this.setStatus({
|
|
2822
|
+
...event,
|
|
2823
|
+
phase: event.phase
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
scheduleRestart(originURL) {
|
|
2827
|
+
if (this.stopping || this.restartTimer !== void 0) return;
|
|
2828
|
+
const delay = RESTART_DELAYS_MS[Math.min(this.restartAttempt, RESTART_DELAYS_MS.length - 1)];
|
|
2829
|
+
this.restartAttempt += 1;
|
|
2830
|
+
this.restartTimer = setTimeout(() => {
|
|
2831
|
+
this.restartTimer = void 0;
|
|
2832
|
+
this.start(originURL);
|
|
2833
|
+
}, delay);
|
|
2834
|
+
}
|
|
2835
|
+
setStatus(next) {
|
|
2836
|
+
const cleared = next.phase === "online" ? {
|
|
2837
|
+
authURL: void 0,
|
|
2838
|
+
message: void 0
|
|
2839
|
+
} : next.phase === "login_required" ? {
|
|
2840
|
+
publicURL: void 0,
|
|
2841
|
+
message: void 0
|
|
2842
|
+
} : next.phase === "starting" || next.phase === "stopped" || next.phase === "disabled" ? {
|
|
2843
|
+
publicURL: void 0,
|
|
2844
|
+
authURL: void 0,
|
|
2845
|
+
message: void 0
|
|
2846
|
+
} : {};
|
|
2847
|
+
this.statusValue = {
|
|
2848
|
+
...this.statusValue,
|
|
2849
|
+
...cleared,
|
|
2850
|
+
...next,
|
|
2851
|
+
updatedAt: Date.now()
|
|
2852
|
+
};
|
|
2853
|
+
if (next.phase === "online") this.options.log("remote Funnel online");
|
|
2854
|
+
else if (next.phase === "login_required") this.options.log("remote Funnel requires browser authorization");
|
|
2855
|
+
else if (next.phase === "error" || next.phase === "unavailable") this.options.log(`remote Funnel ${next.phase}: ${next.message ?? "unknown error"}`);
|
|
2856
|
+
}
|
|
2857
|
+
};
|
|
2858
|
+
//#endregion
|
|
2859
|
+
//#region src/local-address.ts
|
|
2860
|
+
function isPrivateIPv4(address) {
|
|
2861
|
+
const octets = address.split(".").map(Number);
|
|
2862
|
+
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false;
|
|
2863
|
+
const [a, b] = octets;
|
|
2864
|
+
return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
2865
|
+
}
|
|
2866
|
+
/** Private IPv4 candidates, preferring physical en* interfaces over tunnels. */
|
|
2867
|
+
function localLANIPv4Addresses() {
|
|
2868
|
+
const candidates = [];
|
|
2869
|
+
for (const [name, entries] of Object.entries(networkInterfaces())) for (const entry of entries ?? []) if (entry.family === "IPv4" && !entry.internal && isPrivateIPv4(entry.address)) candidates.push({
|
|
2870
|
+
name,
|
|
2871
|
+
address: entry.address
|
|
2872
|
+
});
|
|
2873
|
+
const priority = (name) => name === "en0" ? 0 : name.startsWith("en") ? 1 : name.startsWith("bridge") ? 2 : 3;
|
|
2874
|
+
candidates.sort((left, right) => priority(left.name) - priority(right.name) || left.name.localeCompare(right.name));
|
|
2875
|
+
return [...new Set(candidates.map(({ address }) => address))];
|
|
2876
|
+
}
|
|
2877
|
+
//#endregion
|
|
2878
|
+
//#region src/index.ts
|
|
2879
|
+
/**
|
|
2880
|
+
* dsh-deeppilot — data bridge between the DSH host and DeepPilot
|
|
2881
|
+
* clients. Registers exactly one WebSocket upgrade route (/phone) plus an
|
|
2882
|
+
* optional health probe (/phone/health) on the existing web server. The web
|
|
2883
|
+
* UI is never touched.
|
|
2884
|
+
*
|
|
2885
|
+
* Data plane: an in-process HostBridge consumes apiProxy.events.mux()/host()
|
|
2886
|
+
* streams, mirrors session summaries, tracks pending approvals/questions,
|
|
2887
|
+
* and fans projected protocol-v1 pushes out to every connected device.
|
|
2888
|
+
*
|
|
2889
|
+
* Protocol: src/protocol.ts, v1. The private app repository carries the
|
|
2890
|
+
* matching normative document and Swift models.
|
|
2891
|
+
*/
|
|
2892
|
+
const name = "deeppilot";
|
|
2893
|
+
/** No eager service requirement: profiles without a web stack simply skip. */
|
|
2894
|
+
const inject = [];
|
|
2895
|
+
/** Operator-run relay used by distributed builds; overridable via config. */
|
|
2896
|
+
const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
|
|
2897
|
+
const Config = z.object({
|
|
2898
|
+
enabled: z.boolean().default(true),
|
|
2899
|
+
authTokenPath: z.string().default(join(bridgeDataDir(), "auth-token")),
|
|
2900
|
+
devicesPath: z.string().default(join(bridgeDataDir(), "devices.json")),
|
|
2901
|
+
historyBufferMax: z.natural().min(100).default(2e3),
|
|
2902
|
+
debug: z.boolean().default(false),
|
|
2903
|
+
remote: z.object({
|
|
2904
|
+
enabled: z.boolean().default(false),
|
|
2905
|
+
provider: z.string().default("tailscale-funnel"),
|
|
2906
|
+
hostname: z.string().default(DEFAULT_REMOTE_HOSTNAME),
|
|
2907
|
+
statePath: z.string().default(join(bridgeDataDir(), "tailscale")),
|
|
2908
|
+
helperPath: z.string().default(""),
|
|
2909
|
+
funnelPort: z.natural().default(443)
|
|
2910
|
+
}).default({
|
|
2911
|
+
enabled: false,
|
|
2912
|
+
provider: "tailscale-funnel",
|
|
2913
|
+
hostname: DEFAULT_REMOTE_HOSTNAME,
|
|
2914
|
+
statePath: join(bridgeDataDir(), "tailscale"),
|
|
2915
|
+
helperPath: "",
|
|
2916
|
+
funnelPort: 443
|
|
2917
|
+
}),
|
|
2918
|
+
push: z.object({
|
|
2919
|
+
provider: z.string().default("none"),
|
|
2920
|
+
teamId: z.string().default(""),
|
|
2921
|
+
keyId: z.string().default(""),
|
|
2922
|
+
keyPath: z.string().default(join(bridgeDataDir(), "apns", "AuthKey.p8")),
|
|
2923
|
+
bundleId: z.string().default("dev.hailab.deeppilot"),
|
|
2924
|
+
relayUrl: z.string().default(DEFAULT_RELAY_URL),
|
|
2925
|
+
relayToken: z.string().default("")
|
|
2926
|
+
}).default({
|
|
2927
|
+
provider: "none",
|
|
2928
|
+
teamId: "",
|
|
2929
|
+
keyId: "",
|
|
2930
|
+
keyPath: join(bridgeDataDir(), "apns", "AuthKey.p8"),
|
|
2931
|
+
bundleId: "dev.hailab.deeppilot",
|
|
2932
|
+
relayUrl: DEFAULT_RELAY_URL,
|
|
2933
|
+
relayToken: ""
|
|
2934
|
+
})
|
|
2935
|
+
});
|
|
2936
|
+
const SERVER_VERSION = "0.2.0";
|
|
2937
|
+
const MAX_CLIENT_CONNECTIONS = 16;
|
|
2938
|
+
/**
|
|
2939
|
+
* Single-frame bound. Covers the protocol maximum (4 × 8 MB base64 images
|
|
2940
|
+
* plus prompt text) with headroom while keeping an unauthenticated client's
|
|
2941
|
+
* pre-hello buffering far below ws's 100 MiB default.
|
|
2942
|
+
*/
|
|
2943
|
+
const MAX_FRAME_BYTES = 67108864;
|
|
2944
|
+
function rejectUpgrade(socket, status, reason) {
|
|
2945
|
+
const body = JSON.stringify({ error: reason });
|
|
2946
|
+
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);
|
|
2947
|
+
}
|
|
2948
|
+
/** Authorization is preferred; the query form remains for older app builds. */
|
|
2949
|
+
function requestToken(req) {
|
|
2950
|
+
const authorization = req.headers.authorization;
|
|
2951
|
+
if (typeof authorization === "string") {
|
|
2952
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
2953
|
+
if (match?.[1]) return match[1];
|
|
2954
|
+
}
|
|
2955
|
+
try {
|
|
2956
|
+
return new URL(req.url ?? "/", "http://phone.local").searchParams.get("token");
|
|
2957
|
+
} catch {
|
|
2958
|
+
return null;
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
/**
|
|
2962
|
+
* Cordis hands the second argument in different shapes depending on host
|
|
2963
|
+
* composition: a reactive options getter, the resolved config value, or
|
|
2964
|
+
* nothing when the patch row omits `config`. Normalize all of them.
|
|
2965
|
+
*/
|
|
2966
|
+
function normalizeOptions(options) {
|
|
2967
|
+
if (typeof options === "function") return options();
|
|
2968
|
+
if (options && typeof options === "object") return options;
|
|
2969
|
+
return Config(void 0) ?? {};
|
|
2970
|
+
}
|
|
2971
|
+
function apply(ctx, options) {
|
|
2972
|
+
const cfg = normalizeOptions(options);
|
|
2973
|
+
const log = (message) => {
|
|
2974
|
+
console.log("[deeppilot] " + message);
|
|
2975
|
+
};
|
|
2976
|
+
/**
|
|
2977
|
+
* Settings-section source: while a settings service is attached this holds
|
|
2978
|
+
* the user-edited section value; otherwise the composition defaults. Read
|
|
2979
|
+
* through currentConfig() everywhere (normalizeOptions prefers it).
|
|
2980
|
+
*/
|
|
2981
|
+
let liveSource;
|
|
2982
|
+
let scheduleRemoteReconcile;
|
|
2983
|
+
const currentConfig = () => {
|
|
2984
|
+
if (liveSource !== void 0) return normalizeOptions(liveSource());
|
|
2985
|
+
return normalizeOptions(options);
|
|
2986
|
+
};
|
|
2987
|
+
const enabledNow = () => currentConfig().enabled === true;
|
|
2988
|
+
installSettingsSection(ctx, settingsNamespace("deeppilot"), Config, normalizeOptions(void 0), {
|
|
2989
|
+
setSource: (source) => {
|
|
2990
|
+
liveSource = source;
|
|
2991
|
+
queueMicrotask(() => scheduleRemoteReconcile?.());
|
|
2992
|
+
},
|
|
2993
|
+
onChange: () => queueMicrotask(() => scheduleRemoteReconcile?.())
|
|
2994
|
+
});
|
|
2995
|
+
if (currentConfig().enabled !== true) log("disabled via settings; bridge stays inactive (rumors of /phone below are skipped)");
|
|
2996
|
+
const dataDir = bridgeDataDir();
|
|
2997
|
+
const pushRelayPath = join(dataDir, "push-relay.json");
|
|
2998
|
+
const enrollmentCell = {};
|
|
2999
|
+
function persistEnrollment() {
|
|
3000
|
+
(async () => {
|
|
3001
|
+
try {
|
|
3002
|
+
await mkdir(dataDir, { recursive: true });
|
|
3003
|
+
await writeFile(pushRelayPath, JSON.stringify({
|
|
3004
|
+
version: 1,
|
|
3005
|
+
...enrollmentCell
|
|
3006
|
+
}, null, 2) + "\n", { mode: 384 });
|
|
3007
|
+
} catch {}
|
|
3008
|
+
})();
|
|
3009
|
+
}
|
|
3010
|
+
/** Fired from BridgeConnection when an app presents its built-in key. */
|
|
3011
|
+
const handlePushEnrollKey = async (enrollKey) => {
|
|
3012
|
+
if (enrollmentCell.enrollKey !== enrollKey) enrollmentCell.enrollKey = enrollKey;
|
|
3013
|
+
const configuredProvider = currentConfig().push?.provider;
|
|
3014
|
+
if (!configuredProvider || configuredProvider === "none") {
|
|
3015
|
+
if (!enrollmentCell.autoRelay) {
|
|
3016
|
+
enrollmentCell.autoRelay = true;
|
|
3017
|
+
log("push relay mode auto-enabled by enrolled app");
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
persistEnrollment();
|
|
3021
|
+
const url = (currentConfig().push?.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
|
|
3022
|
+
await ensureRelayEnrolled(url);
|
|
3023
|
+
};
|
|
3024
|
+
const auth = {
|
|
3025
|
+
token: null,
|
|
3026
|
+
tokenPath: cfg.authTokenPath ?? join(dataDir, "auth-token"),
|
|
3027
|
+
devices: null
|
|
3028
|
+
};
|
|
3029
|
+
const ready = (async () => {
|
|
3030
|
+
try {
|
|
3031
|
+
try {
|
|
3032
|
+
const migratedFrom = await migrateLegacyBridgeDataDir();
|
|
3033
|
+
if (migratedFrom !== null) log(`migrated legacy plugin state from ${migratedFrom} to ${dataDir}`);
|
|
3034
|
+
} catch (error) {
|
|
3035
|
+
log("legacy plugin-state migration skipped: " + String(error));
|
|
3036
|
+
}
|
|
3037
|
+
auth.tokenPath = cfg.authTokenPath ?? join(dataDir, "auth-token");
|
|
3038
|
+
auth.token = await loadOrCreateToken(auth.tokenPath);
|
|
3039
|
+
auth.devices = await DeviceStore.load(cfg.devicesPath ?? join(dataDir, "devices.json"));
|
|
3040
|
+
{
|
|
3041
|
+
const rows = auth.devices.list();
|
|
3042
|
+
const registered = rows.filter((row) => row.apns !== void 0).length;
|
|
3043
|
+
log(`device registry loaded from ${expandHome(cfg.devicesPath ?? join(dataDir, "devices.json"))}: ${rows.length} device(s), ${registered} push registration(s)`);
|
|
3044
|
+
}
|
|
3045
|
+
try {
|
|
3046
|
+
const raw = JSON.parse(await readFile(pushRelayPath, "utf8"));
|
|
3047
|
+
if (typeof raw.clientId === "string") enrollmentCell.clientId = raw.clientId;
|
|
3048
|
+
if (typeof raw.enrollKey === "string") enrollmentCell.enrollKey = raw.enrollKey;
|
|
3049
|
+
if (typeof raw.token === "string") enrollmentCell.token = raw.token;
|
|
3050
|
+
if (raw.autoRelay === true) enrollmentCell.autoRelay = true;
|
|
3051
|
+
} catch {}
|
|
3052
|
+
} catch (error) {
|
|
3053
|
+
log("auth material unavailable, bridge degraded: " + String(error));
|
|
3054
|
+
return {
|
|
3055
|
+
token: null,
|
|
3056
|
+
devices: null
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
return {
|
|
3060
|
+
token: auth.token,
|
|
3061
|
+
devices: auth.devices
|
|
3062
|
+
};
|
|
3063
|
+
})();
|
|
3064
|
+
/**
|
|
3065
|
+
* Replace the pairing secret: persist a fresh token, clear the paired-device
|
|
3066
|
+
* registry, and drop every live phone socket. Handshake-time auth means an
|
|
3067
|
+
* already-open socket would otherwise outlive its token; terminating forces
|
|
3068
|
+
* each device to re-pair with the new secret. The old token is invalid the
|
|
3069
|
+
* moment the file is rewritten.
|
|
3070
|
+
*
|
|
3071
|
+
* Serialized through rotateTail so two overlapping invocations can never
|
|
3072
|
+
* return a token that a later write already invalidated.
|
|
3073
|
+
*/
|
|
3074
|
+
const doRotate = async () => {
|
|
3075
|
+
const { devices } = await ready;
|
|
3076
|
+
if (auth.token === null || devices === null) throw new Error("pairing token unavailable");
|
|
3077
|
+
auth.token = await writeNewToken(auth.tokenPath);
|
|
3078
|
+
devices.clear();
|
|
3079
|
+
let dropped = 0;
|
|
3080
|
+
for (const connection of connections) {
|
|
3081
|
+
connection.terminate();
|
|
3082
|
+
dropped += 1;
|
|
3083
|
+
}
|
|
3084
|
+
connections.clear();
|
|
3085
|
+
log(`pairing token rotated; ${dropped} live phone connection(s) dropped`);
|
|
3086
|
+
return auth.token;
|
|
3087
|
+
};
|
|
3088
|
+
let rotateTail = Promise.resolve();
|
|
3089
|
+
const rotatePairingToken = () => {
|
|
3090
|
+
const next = rotateTail.then(doRotate);
|
|
3091
|
+
rotateTail = next.catch(() => {});
|
|
3092
|
+
return next;
|
|
3093
|
+
};
|
|
3094
|
+
/**
|
|
3095
|
+
* Settings-page push self-test: force one synthetic notification down the
|
|
3096
|
+
* active pathway to EVERY registered device, deliberately ignoring the
|
|
3097
|
+
* connected-skip and category-mute filters — an explicit user action must
|
|
3098
|
+
* always be able to prove delivery end to end.
|
|
3099
|
+
*/
|
|
3100
|
+
const runPushSelfTest = async () => {
|
|
3101
|
+
const resolved = resolvePushConfig(currentConfig());
|
|
3102
|
+
if (!resolved.ok) return {
|
|
3103
|
+
transport: "none",
|
|
3104
|
+
overall: "not-configured",
|
|
3105
|
+
message: "推送未启用(" + resolved.reason + ")。可先用「测试访问与注册」完成中继注册,或在配置中设置 push.provider",
|
|
3106
|
+
results: []
|
|
3107
|
+
};
|
|
3108
|
+
const tokenized = (auth.devices?.list() ?? []).filter((device) => device.apns !== void 0);
|
|
3109
|
+
if (!auth.devices || tokenized.length === 0) return {
|
|
3110
|
+
transport: resolved.value.kind,
|
|
3111
|
+
overall: "no-targets",
|
|
3112
|
+
message: "还没有设备注册离线推送——在手机上打开 DeepPilot 并允许系统通知,等状态变为「已就绪」后再试",
|
|
3113
|
+
results: []
|
|
3114
|
+
};
|
|
3115
|
+
const send = await senderFor(resolved.value);
|
|
3116
|
+
if (!send) return {
|
|
3117
|
+
transport: resolved.value.kind,
|
|
3118
|
+
overall: "failed",
|
|
3119
|
+
message: "发送通道不可用(检查 .p8 密钥文件或中继配置)",
|
|
3120
|
+
results: []
|
|
3121
|
+
};
|
|
3122
|
+
const notification = {
|
|
3123
|
+
notificationId: "test-" + Date.now(),
|
|
3124
|
+
category: "turn.completed",
|
|
3125
|
+
sessionId: "push-test",
|
|
3126
|
+
title: "DeepPilot 测试推送",
|
|
3127
|
+
body: "收到这条通知说明离线推送链路正常"
|
|
3128
|
+
};
|
|
3129
|
+
const results = await Promise.all(tokenized.map(async (device) => {
|
|
3130
|
+
const registration = device.apns;
|
|
3131
|
+
const { outcome, reason } = await send({
|
|
3132
|
+
deviceToken: registration.token,
|
|
3133
|
+
environment: registration.environment,
|
|
3134
|
+
notification
|
|
3135
|
+
});
|
|
3136
|
+
return {
|
|
3137
|
+
name: device.deviceName,
|
|
3138
|
+
environment: registration.environment,
|
|
3139
|
+
outcome,
|
|
3140
|
+
tokenFingerprint: registration.token.slice(0, 10),
|
|
3141
|
+
...reason !== void 0 ? { reason } : {}
|
|
3142
|
+
};
|
|
3143
|
+
}));
|
|
3144
|
+
const overall = results.some((r) => r.outcome === "sent") ? "sent" : "failed";
|
|
3145
|
+
log("push self-test: " + overall + " (" + results.map((r) => `"${r.name}"=${r.outcome}${r.reason ? "/" + r.reason : ""}`).join(", ") + ")");
|
|
3146
|
+
return {
|
|
3147
|
+
transport: resolved.value.kind,
|
|
3148
|
+
overall,
|
|
3149
|
+
results
|
|
3150
|
+
};
|
|
3151
|
+
};
|
|
3152
|
+
const connections = /* @__PURE__ */ new Set();
|
|
3153
|
+
const resolvePushConfig = (config) => {
|
|
3154
|
+
const push = config.push ?? {};
|
|
3155
|
+
const configured = push.provider ?? "none";
|
|
3156
|
+
const effectiveProvider = configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured;
|
|
3157
|
+
if (effectiveProvider === "relay") {
|
|
3158
|
+
const url = (push.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
|
|
3159
|
+
const token = (push.relayToken ?? "").trim() || enrollmentCell.token || "";
|
|
3160
|
+
if (!/^https:\/\//i.test(url)) return {
|
|
3161
|
+
ok: false,
|
|
3162
|
+
reason: "relayUrl must be an https URL"
|
|
3163
|
+
};
|
|
3164
|
+
if (!token) return {
|
|
3165
|
+
ok: false,
|
|
3166
|
+
reason: "relay token not enrolled yet"
|
|
3167
|
+
};
|
|
3168
|
+
return {
|
|
3169
|
+
ok: true,
|
|
3170
|
+
value: {
|
|
3171
|
+
kind: "relay",
|
|
3172
|
+
url,
|
|
3173
|
+
token
|
|
3174
|
+
}
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
if (effectiveProvider === "apns") {
|
|
3178
|
+
const teamId = (push.teamId ?? "").trim();
|
|
3179
|
+
const keyId = (push.keyId ?? "").trim();
|
|
3180
|
+
const keyPath = expandHome((push.keyPath ?? "").trim() || join(dataDir, "apns", "AuthKey.p8"));
|
|
3181
|
+
const bundleId = (push.bundleId ?? "").trim();
|
|
3182
|
+
if (!teamId || !keyId || !bundleId) return {
|
|
3183
|
+
ok: false,
|
|
3184
|
+
reason: "teamId/keyId/bundleId missing"
|
|
3185
|
+
};
|
|
3186
|
+
return {
|
|
3187
|
+
ok: true,
|
|
3188
|
+
value: {
|
|
3189
|
+
kind: "apns",
|
|
3190
|
+
teamId,
|
|
3191
|
+
keyId,
|
|
3192
|
+
keyPath,
|
|
3193
|
+
bundleId
|
|
3194
|
+
}
|
|
3195
|
+
};
|
|
3196
|
+
}
|
|
3197
|
+
return {
|
|
3198
|
+
ok: false,
|
|
3199
|
+
reason: "provider disabled"
|
|
3200
|
+
};
|
|
3201
|
+
};
|
|
3202
|
+
/**
|
|
3203
|
+
* Zero-touch enrollment against the operator's relay. Idempotent and
|
|
3204
|
+
* cached in the persistent cell; a failure disables push for this config
|
|
3205
|
+
* fingerprint with one log line until something changes.
|
|
3206
|
+
*/
|
|
3207
|
+
let enrollAttemptFor;
|
|
3208
|
+
let enrollLastAttemptAt = 0;
|
|
3209
|
+
const ensureRelayEnrolled = async (url) => {
|
|
3210
|
+
if (enrollmentCell.token) return enrollmentCell.token;
|
|
3211
|
+
const fingerprint = url + ":" + String(enrollmentCell.enrollKey ?? "");
|
|
3212
|
+
if (fingerprint !== enrollAttemptFor) {
|
|
3213
|
+
enrollAttemptFor = fingerprint;
|
|
3214
|
+
enrollLastAttemptAt = 0;
|
|
3215
|
+
}
|
|
3216
|
+
if (Date.now() - enrollLastAttemptAt < 6e4) return void 0;
|
|
3217
|
+
enrollLastAttemptAt = Date.now();
|
|
3218
|
+
try {
|
|
3219
|
+
if (!enrollmentCell.clientId) {
|
|
3220
|
+
enrollmentCell.clientId = "u_" + randomBytes(16).toString("base64url");
|
|
3221
|
+
persistEnrollment();
|
|
3222
|
+
}
|
|
3223
|
+
const token = await new RelayClient({
|
|
3224
|
+
url,
|
|
3225
|
+
debug: currentConfig().debug === true,
|
|
3226
|
+
log
|
|
3227
|
+
}).enroll(enrollmentCell.clientId, enrollmentCell.enrollKey ?? "");
|
|
3228
|
+
if (!token) {
|
|
3229
|
+
log("push relay enrollment failed (" + url + "); will retry on next trigger");
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
enrollmentCell.token = token;
|
|
3233
|
+
persistEnrollment();
|
|
3234
|
+
log("push relay enrollment succeeded");
|
|
3235
|
+
return token;
|
|
3236
|
+
} catch (error) {
|
|
3237
|
+
log("push relay enrollment error: " + String(error));
|
|
3238
|
+
return;
|
|
3239
|
+
}
|
|
3240
|
+
};
|
|
3241
|
+
let cachedSender;
|
|
3242
|
+
let senderFailedFor;
|
|
3243
|
+
/**
|
|
3244
|
+
* Lazily build the push sender for the current config. A broken config
|
|
3245
|
+
* (unreadable .p8) disables push for that fingerprint with exactly one log
|
|
3246
|
+
* line instead of failing on every event.
|
|
3247
|
+
*/
|
|
3248
|
+
const senderFor = async (resolved) => {
|
|
3249
|
+
const fingerprint = JSON.stringify(resolved);
|
|
3250
|
+
if (cachedSender?.fingerprint === fingerprint) return cachedSender.send;
|
|
3251
|
+
if (senderFailedFor === fingerprint) return void 0;
|
|
3252
|
+
if (cachedSender) {
|
|
3253
|
+
await cachedSender.dispose?.().catch(() => {});
|
|
3254
|
+
cachedSender = void 0;
|
|
3255
|
+
}
|
|
3256
|
+
if (resolved.kind === "relay") {
|
|
3257
|
+
const client = new RelayClient({
|
|
3258
|
+
url: resolved.url,
|
|
3259
|
+
token: resolved.token,
|
|
3260
|
+
debug: currentConfig().debug === true,
|
|
3261
|
+
log
|
|
3262
|
+
});
|
|
3263
|
+
cachedSender = {
|
|
3264
|
+
fingerprint,
|
|
3265
|
+
send: (request) => client.send(request)
|
|
3266
|
+
};
|
|
3267
|
+
log("push relay enabled");
|
|
3268
|
+
} else {
|
|
3269
|
+
try {
|
|
3270
|
+
await readFile(expandHome(resolved.keyPath), "utf8");
|
|
3271
|
+
} catch (error) {
|
|
3272
|
+
senderFailedFor = fingerprint;
|
|
3273
|
+
log("apns push unavailable (key unreadable at " + resolved.keyPath + "): " + String(error));
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
const client = new ApnsClient({
|
|
3277
|
+
teamId: resolved.teamId,
|
|
3278
|
+
keyId: resolved.keyId,
|
|
3279
|
+
keyPath: resolved.keyPath,
|
|
3280
|
+
bundleId: resolved.bundleId,
|
|
3281
|
+
debug: currentConfig().debug === true,
|
|
3282
|
+
log
|
|
3283
|
+
});
|
|
3284
|
+
cachedSender = {
|
|
3285
|
+
fingerprint,
|
|
3286
|
+
send: (request) => client.send({
|
|
3287
|
+
...request.notification,
|
|
3288
|
+
deviceToken: request.deviceToken,
|
|
3289
|
+
environment: request.environment
|
|
3290
|
+
}),
|
|
3291
|
+
dispose: () => client.dispose()
|
|
3292
|
+
};
|
|
3293
|
+
log("apns push enabled");
|
|
3294
|
+
}
|
|
3295
|
+
senderFailedFor = void 0;
|
|
3296
|
+
return cachedSender.send;
|
|
3297
|
+
};
|
|
3298
|
+
/**
|
|
3299
|
+
* Fan one notification-worthy event out to paired devices holding an APNs
|
|
3300
|
+
* token. Rules:
|
|
3301
|
+
* - devices with a live WebSocket are skipped (they already got the WS
|
|
3302
|
+
* frame and will raise the local notification themselves);
|
|
3303
|
+
* - each device is delivered on ITS registered environment (the build
|
|
3304
|
+
* kind it self-reported), so sandbox and production devices coexist;
|
|
3305
|
+
* - the device's per-category switches suppress muted categories;
|
|
3306
|
+
* - Unregistered/BadDeviceToken outcomes prune the stored token.
|
|
3307
|
+
*/
|
|
3308
|
+
const makePushOutlet = () => ({
|
|
3309
|
+
isAvailable: () => {
|
|
3310
|
+
const resolved = resolvePushConfig(currentConfig());
|
|
3311
|
+
if (!resolved.ok) return false;
|
|
3312
|
+
if (resolved.value.kind === "relay" && !resolved.value.token) return false;
|
|
3313
|
+
return true;
|
|
3314
|
+
},
|
|
3315
|
+
fanOut: (notification) => {
|
|
3316
|
+
(async () => {
|
|
3317
|
+
let resolved = resolvePushConfig(currentConfig());
|
|
3318
|
+
if (!resolved.ok && resolved.reason === "relay token not enrolled yet") {
|
|
3319
|
+
const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
|
|
3320
|
+
await ensureRelayEnrolled(relayUrl);
|
|
3321
|
+
resolved = resolvePushConfig(currentConfig());
|
|
3322
|
+
}
|
|
3323
|
+
if (!resolved.ok) return;
|
|
3324
|
+
const devices = auth.devices;
|
|
3325
|
+
if (!devices) return;
|
|
3326
|
+
const send = await senderFor(resolved.value);
|
|
3327
|
+
if (!send) return;
|
|
3328
|
+
const transport = resolved.value.kind;
|
|
3329
|
+
const connectedIds = /* @__PURE__ */ new Set();
|
|
3330
|
+
for (const connection of connections) {
|
|
3331
|
+
const id = connection.connectedDeviceId;
|
|
3332
|
+
if (id) connectedIds.add(id);
|
|
3333
|
+
}
|
|
3334
|
+
const candidates = devices.list().filter((device) => {
|
|
3335
|
+
const registration = device.apns;
|
|
3336
|
+
if (!registration) return false;
|
|
3337
|
+
if (connectedIds.has(device.deviceId)) return false;
|
|
3338
|
+
if (registration.categories?.[notification.category] === false) {
|
|
3339
|
+
if (currentConfig().debug === true) log(`push skip "${device.deviceName}": category ${notification.category} muted`);
|
|
3340
|
+
return false;
|
|
3341
|
+
}
|
|
3342
|
+
return true;
|
|
3343
|
+
});
|
|
3344
|
+
if (candidates.length === 0) {
|
|
3345
|
+
const tokenized = devices.list().filter((device) => device.apns !== void 0).length;
|
|
3346
|
+
log(`push(${transport}) ${notification.category}: no offline targets (connected=${connectedIds.size}, tokenized=${tokenized})`);
|
|
3347
|
+
return;
|
|
3348
|
+
}
|
|
3349
|
+
for (const device of candidates) {
|
|
3350
|
+
const registration = device.apns;
|
|
3351
|
+
send({
|
|
3352
|
+
deviceToken: registration.token,
|
|
3353
|
+
environment: registration.environment,
|
|
3354
|
+
notification
|
|
3355
|
+
}).then(({ outcome, reason }) => {
|
|
3356
|
+
log(`push(${transport}) ${notification.category} → "${device.deviceName}" [${registration.environment}] = ${outcome}${reason ? " (" + reason + ")" : ""}`);
|
|
3357
|
+
if (outcome === "invalid-token") {
|
|
3358
|
+
devices.clearPushToken(device.deviceId);
|
|
3359
|
+
log(`push: pruned stale token of "${device.deviceName}" (${reason ?? "unknown"}) — app re-registers on next launch`);
|
|
3360
|
+
}
|
|
3361
|
+
}).catch(() => {});
|
|
3362
|
+
}
|
|
3363
|
+
})();
|
|
3364
|
+
}
|
|
3365
|
+
});
|
|
3366
|
+
const wss = new WebSocketServer({
|
|
3367
|
+
noServer: true,
|
|
3368
|
+
maxPayload: MAX_FRAME_BYTES
|
|
3369
|
+
});
|
|
3370
|
+
let remoteSupervisor;
|
|
3371
|
+
const remoteStatus = () => remoteSupervisor?.status() ?? {
|
|
3372
|
+
provider: "tailscale-funnel",
|
|
3373
|
+
phase: currentConfig().remote?.enabled === true ? "stopped" : "disabled",
|
|
3374
|
+
updatedAt: Date.now()
|
|
3375
|
+
};
|
|
3376
|
+
applyReportRemote(ctx, async () => {
|
|
3377
|
+
let tokenReady = false;
|
|
3378
|
+
let devices = [];
|
|
3379
|
+
try {
|
|
3380
|
+
await ready;
|
|
3381
|
+
tokenReady = auth.token !== null;
|
|
3382
|
+
devices = (auth.devices?.list() ?? []).map(({ deviceId, deviceName, appVersion, firstSeenTs, lastSeenTs, apns }) => ({
|
|
3383
|
+
deviceId,
|
|
3384
|
+
deviceName,
|
|
3385
|
+
appVersion,
|
|
3386
|
+
firstSeenTs,
|
|
3387
|
+
lastSeenTs,
|
|
3388
|
+
...apns ? { apns: {
|
|
3389
|
+
environment: apns.environment,
|
|
3390
|
+
updatedAt: apns.updatedAt
|
|
3391
|
+
} } : {}
|
|
3392
|
+
}));
|
|
3393
|
+
} catch {}
|
|
3394
|
+
return {
|
|
3395
|
+
protocolVersion: 1,
|
|
3396
|
+
serverVersion: SERVER_VERSION,
|
|
3397
|
+
enabled: currentConfig().enabled === true,
|
|
3398
|
+
tokenPath: expandHome(currentConfig().authTokenPath ?? join(bridgeDataDir(), "auth-token")),
|
|
3399
|
+
tokenReady,
|
|
3400
|
+
activeConnections: connections.size,
|
|
3401
|
+
historyBufferMax: currentConfig().historyBufferMax ?? 2e3,
|
|
3402
|
+
debug: currentConfig().debug === true,
|
|
3403
|
+
lanAddresses: localLANIPv4Addresses(),
|
|
3404
|
+
remote: remoteStatus(),
|
|
3405
|
+
devices
|
|
3406
|
+
};
|
|
3407
|
+
}, async () => {
|
|
3408
|
+
await ready;
|
|
3409
|
+
if (auth.token === null) throw new Error("pairing token unavailable");
|
|
3410
|
+
return auth.token;
|
|
3411
|
+
}, rotatePairingToken, async () => {
|
|
3412
|
+
const push = currentConfig().push ?? {};
|
|
3413
|
+
const configured = push.provider ?? "none";
|
|
3414
|
+
if ((configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured) !== "relay") return {
|
|
3415
|
+
url: "",
|
|
3416
|
+
overall: "failed",
|
|
3417
|
+
tokenIssued: false,
|
|
3418
|
+
steps: [{
|
|
3419
|
+
id: "health",
|
|
3420
|
+
ok: false,
|
|
3421
|
+
message: `当前推送模式不是中继(provider=${configured})。启用方式二选一:① 零配置——在 ios/project.yml 填写 DSPushEnrollKey(与服务器 RELAY_ENROLL_KEY 一致)并重新安装 App,打开 App 即自动启用;② 手动——将 push.provider 设为 relay 并填入 relayToken`
|
|
3422
|
+
}]
|
|
3423
|
+
};
|
|
3424
|
+
const url = (push.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
|
|
3425
|
+
if (!enrollmentCell.clientId && enrollmentCell.enrollKey) {
|
|
3426
|
+
enrollmentCell.clientId = "u_" + randomBytes(16).toString("base64url");
|
|
3427
|
+
persistEnrollment();
|
|
3428
|
+
}
|
|
3429
|
+
return await runRelayProbe({
|
|
3430
|
+
url,
|
|
3431
|
+
clientId: enrollmentCell.clientId,
|
|
3432
|
+
enrollKey: enrollmentCell.enrollKey,
|
|
3433
|
+
manualToken: Boolean((push.relayToken ?? "").trim()),
|
|
3434
|
+
onEnrolled: (token) => {
|
|
3435
|
+
enrollmentCell.token = token;
|
|
3436
|
+
persistEnrollment();
|
|
3437
|
+
log("push relay enrollment succeeded (via settings self-test)");
|
|
3438
|
+
}
|
|
3439
|
+
});
|
|
3440
|
+
}, async () => {
|
|
3441
|
+
return await runPushSelfTest();
|
|
3442
|
+
});
|
|
3443
|
+
const state = {};
|
|
3444
|
+
const handleUpgrade = (req, socket, head) => {
|
|
3445
|
+
(async () => {
|
|
3446
|
+
try {
|
|
3447
|
+
if (!enabledNow()) {
|
|
3448
|
+
rejectUpgrade(socket, 503, "bridge disabled");
|
|
3449
|
+
return;
|
|
3450
|
+
}
|
|
3451
|
+
if (connections.size >= MAX_CLIENT_CONNECTIONS) {
|
|
3452
|
+
rejectUpgrade(socket, 429, "too many connections");
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
const { devices } = await ready;
|
|
3456
|
+
const token = auth.token;
|
|
3457
|
+
if (!token || !devices) {
|
|
3458
|
+
rejectUpgrade(socket, 503, "bridge degraded");
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
const presentedToken = requestToken(req);
|
|
3462
|
+
if (presentedToken !== null && !tokenMatches(presentedToken, token)) {
|
|
3463
|
+
rejectUpgrade(socket, 401, "invalid token");
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
const bridge = state.bridge;
|
|
3467
|
+
if (!bridge) {
|
|
3468
|
+
rejectUpgrade(socket, 503, "bridge not ready");
|
|
3469
|
+
return;
|
|
3470
|
+
}
|
|
3471
|
+
if (auth.token !== token) {
|
|
3472
|
+
rejectUpgrade(socket, 401, "invalid token");
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3475
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
3476
|
+
const connection = new BridgeConnection(ws, {
|
|
3477
|
+
bridge,
|
|
3478
|
+
devices,
|
|
3479
|
+
serverVersion: SERVER_VERSION,
|
|
3480
|
+
expectedToken: token,
|
|
3481
|
+
transportAuthenticated: presentedToken !== null,
|
|
3482
|
+
log,
|
|
3483
|
+
debug: currentConfig().debug === true,
|
|
3484
|
+
onClosed: (closed) => connections.delete(closed),
|
|
3485
|
+
onPushEnrollKey: handlePushEnrollKey
|
|
3486
|
+
});
|
|
3487
|
+
connections.add(connection);
|
|
3488
|
+
});
|
|
3489
|
+
} catch (error) {
|
|
3490
|
+
log("upgrade failed: " + String(error));
|
|
3491
|
+
rejectUpgrade(socket, 500, "internal error");
|
|
3492
|
+
}
|
|
3493
|
+
})();
|
|
3494
|
+
};
|
|
3495
|
+
const handleHealth = async (req, res) => {
|
|
3496
|
+
try {
|
|
3497
|
+
await ready;
|
|
3498
|
+
const token = auth.token;
|
|
3499
|
+
res.setHeader("Content-Type", "application/json");
|
|
3500
|
+
if (!token) {
|
|
3501
|
+
res.statusCode = 503;
|
|
3502
|
+
res.end(JSON.stringify({
|
|
3503
|
+
ok: false,
|
|
3504
|
+
degraded: true
|
|
3505
|
+
}));
|
|
3506
|
+
return;
|
|
3507
|
+
}
|
|
3508
|
+
if (!tokenMatches(requestToken(req), token)) {
|
|
3509
|
+
res.statusCode = 401;
|
|
3510
|
+
res.end(JSON.stringify({ ok: false }));
|
|
3511
|
+
return;
|
|
3512
|
+
}
|
|
3513
|
+
res.statusCode = 200;
|
|
3514
|
+
res.end(JSON.stringify({
|
|
3515
|
+
ok: true,
|
|
3516
|
+
enabled: enabledNow(),
|
|
3517
|
+
protocolVersion: 1,
|
|
3518
|
+
serverVersion: SERVER_VERSION,
|
|
3519
|
+
dataPlane: Boolean(state.bridge)
|
|
3520
|
+
}));
|
|
3521
|
+
} catch {
|
|
3522
|
+
res.statusCode = 500;
|
|
3523
|
+
res.end(JSON.stringify({ ok: false }));
|
|
3524
|
+
}
|
|
3525
|
+
};
|
|
3526
|
+
ctx.inject(["apiProxy"], (sub) => {
|
|
3527
|
+
if (currentConfig().enabled !== true) {
|
|
3528
|
+
log("bridge disabled; data plane stays inactive");
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
3531
|
+
const apiCtx = sub;
|
|
3532
|
+
const proxy = apiCtx.apiProxy;
|
|
3533
|
+
if (!proxy) {
|
|
3534
|
+
log("apiProxy service absent; data plane stays inactive");
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
const bridge = new HostBridge(proxy, cfg.historyBufferMax);
|
|
3538
|
+
bridge.setPushOutlet(makePushOutlet());
|
|
3539
|
+
state.bridge = bridge;
|
|
3540
|
+
bridge.start();
|
|
3541
|
+
log("data plane active (mux + host streams)");
|
|
3542
|
+
apiCtx.effect(() => () => bridge.dispose(), "deeppilot: host streams");
|
|
3543
|
+
});
|
|
3544
|
+
ctx.inject(["webServer"], (sub) => {
|
|
3545
|
+
const webCtx = sub;
|
|
3546
|
+
const web = webCtx.webServer;
|
|
3547
|
+
if (!web) {
|
|
3548
|
+
log("webServer service absent in this profile; bridge stays inactive");
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
webCtx.effect(() => web.registerUpgrade({
|
|
3552
|
+
path: "/phone",
|
|
3553
|
+
handler: handleUpgrade
|
|
3554
|
+
}), "deeppilot: /phone WebSocket");
|
|
3555
|
+
webCtx.effect(() => web.register({
|
|
3556
|
+
kind: "exact",
|
|
3557
|
+
path: "/phone/health",
|
|
3558
|
+
handler: handleHealth
|
|
3559
|
+
}), "deeppilot: /phone/health");
|
|
3560
|
+
const sweep = setInterval(() => {
|
|
3561
|
+
const now = Date.now();
|
|
3562
|
+
for (const connection of connections) if (connection.isStale(now, 6e4)) {
|
|
3563
|
+
log("dropping stale connection");
|
|
3564
|
+
connection.terminate();
|
|
3565
|
+
connections.delete(connection);
|
|
3566
|
+
}
|
|
3567
|
+
}, 3e4);
|
|
3568
|
+
webCtx.effect(() => () => clearInterval(sweep), "deeppilot: stale sweep");
|
|
3569
|
+
const originServer = createServer((req, res) => {
|
|
3570
|
+
let path = "/";
|
|
3571
|
+
try {
|
|
3572
|
+
path = new URL(req.url ?? "/", "http://phone.local").pathname;
|
|
3573
|
+
} catch {}
|
|
3574
|
+
if (path === "/phone/health") handleHealth(req, res);
|
|
3575
|
+
else {
|
|
3576
|
+
res.statusCode = 404;
|
|
3577
|
+
res.end("not found");
|
|
3578
|
+
}
|
|
3579
|
+
});
|
|
3580
|
+
originServer.on("upgrade", (req, socket, head) => {
|
|
3581
|
+
let path = "/";
|
|
3582
|
+
try {
|
|
3583
|
+
path = new URL(req.url ?? "/", "http://phone.local").pathname;
|
|
3584
|
+
} catch {}
|
|
3585
|
+
if (path !== "/phone") {
|
|
3586
|
+
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
3587
|
+
return;
|
|
3588
|
+
}
|
|
3589
|
+
handleUpgrade(req, socket, head);
|
|
3590
|
+
});
|
|
3591
|
+
let originURL;
|
|
3592
|
+
let appliedRemoteKey;
|
|
3593
|
+
let remoteDisposed = false;
|
|
3594
|
+
let reconcileTail = Promise.resolve();
|
|
3595
|
+
const reconcileRemote = async () => {
|
|
3596
|
+
if (remoteDisposed || originURL === void 0) return;
|
|
3597
|
+
const config = currentConfig();
|
|
3598
|
+
const remoteConfig = config.remote ?? {};
|
|
3599
|
+
const remotePort = remoteConfig.funnelPort === 8443 || remoteConfig.funnelPort === 1e4 ? remoteConfig.funnelPort : 443;
|
|
3600
|
+
const helperPath = remoteConfig.helperPath?.trim() || void 0;
|
|
3601
|
+
const next = {
|
|
3602
|
+
enabled: config.enabled === true && remoteConfig.enabled === true && remoteConfig.provider === "tailscale-funnel",
|
|
3603
|
+
hostname: normalizeRemoteHostname(remoteConfig.hostname),
|
|
3604
|
+
statePath: remoteConfig.statePath?.trim() || join(dataDir, "tailscale"),
|
|
3605
|
+
helperPath,
|
|
3606
|
+
funnelPort: remotePort
|
|
3607
|
+
};
|
|
3608
|
+
const nextKey = JSON.stringify(next);
|
|
3609
|
+
if (nextKey === appliedRemoteKey) return;
|
|
3610
|
+
const previous = remoteSupervisor;
|
|
3611
|
+
remoteSupervisor = void 0;
|
|
3612
|
+
if (previous !== void 0) await previous.dispose();
|
|
3613
|
+
if (remoteDisposed) return;
|
|
3614
|
+
const supervisor = new RemoteSupervisor({
|
|
3615
|
+
enabled: next.enabled,
|
|
3616
|
+
hostname: next.hostname,
|
|
3617
|
+
statePath: next.statePath,
|
|
3618
|
+
...next.helperPath ? { helperPath: next.helperPath } : {},
|
|
3619
|
+
funnelPort: next.funnelPort,
|
|
3620
|
+
log
|
|
3621
|
+
});
|
|
3622
|
+
remoteSupervisor = supervisor;
|
|
3623
|
+
appliedRemoteKey = nextKey;
|
|
3624
|
+
await supervisor.start(originURL);
|
|
3625
|
+
};
|
|
3626
|
+
scheduleRemoteReconcile = () => {
|
|
3627
|
+
reconcileTail = reconcileTail.then(reconcileRemote).catch((error) => log("remote reconcile failed: " + String(error)));
|
|
3628
|
+
};
|
|
3629
|
+
originServer.listen(0, "127.0.0.1", () => {
|
|
3630
|
+
const address = originServer.address();
|
|
3631
|
+
if (address && typeof address === "object") {
|
|
3632
|
+
originURL = `http://127.0.0.1:${address.port}`;
|
|
3633
|
+
scheduleRemoteReconcile?.();
|
|
3634
|
+
}
|
|
3635
|
+
});
|
|
3636
|
+
originServer.on("error", (error) => log("remote origin failed: " + String(error)));
|
|
3637
|
+
webCtx.effect(() => () => {
|
|
3638
|
+
remoteDisposed = true;
|
|
3639
|
+
scheduleRemoteReconcile = void 0;
|
|
3640
|
+
originServer.close();
|
|
3641
|
+
reconcileTail = reconcileTail.then(async () => {
|
|
3642
|
+
const supervisor = remoteSupervisor;
|
|
3643
|
+
remoteSupervisor = void 0;
|
|
3644
|
+
if (supervisor !== void 0) await supervisor.dispose();
|
|
3645
|
+
});
|
|
3646
|
+
}, "deeppilot: embedded Funnel");
|
|
3647
|
+
if (enabledNow()) log("/phone WebSocket registered");
|
|
3648
|
+
else log("bridge disabled; /phone refuses connections until re-enabled and restarted");
|
|
3649
|
+
});
|
|
3650
|
+
}
|
|
3651
|
+
//#endregion
|
|
3652
|
+
export { Config, HostBridge, apply, inject, name, requestToken };
|
|
3653
|
+
|
|
3654
|
+
//# sourceMappingURL=index.js.map
|