dsh-deeppilot 0.3.0 → 0.5.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 +20 -17
- package/PRIVACY.md +15 -43
- package/README.md +18 -8
- package/README.zh-CN.md +13 -7
- package/SECURITY.md +27 -29
- package/bin/SHA256SUMS +6 -6
- package/bin/darwin-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/darwin-arm64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-arm64/dsh-deeppilot-tunnel +0 -0
- package/bin/windows-amd64/dsh-deeppilot-tunnel.exe +0 -0
- package/bin/windows-arm64/dsh-deeppilot-tunnel.exe +0 -0
- package/docs/SECURITY_ROADMAP.md +24 -0
- package/docs/SECURITY_ROADMAP.zh-CN.md +24 -0
- package/lib/client.js +2043 -1419
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +131 -56
- package/lib/index.js +2165 -1009
- package/lib/index.js.map +1 -1
- package/package.json +38 -9
package/lib/index.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
-
import { createPrivateKey, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
|
|
4
|
+
import { access, chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
|
-
import z from "@deepseek-ai/schemastery";
|
|
7
6
|
import { WebSocketServer } from "ws";
|
|
8
7
|
import { homedir, networkInterfaces } from "node:os";
|
|
9
8
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
@@ -13,6 +12,129 @@ import { spawn } from "node:child_process";
|
|
|
13
12
|
import { constants } from "node:fs";
|
|
14
13
|
import { fileURLToPath } from "node:url";
|
|
15
14
|
import { request } from "node:https";
|
|
15
|
+
import z from "@deepseek-ai/schemastery";
|
|
16
|
+
import { isIP } from "node:net";
|
|
17
|
+
//#region src/device-auth.ts
|
|
18
|
+
const PAIRING_CODE_TTL_MS = 3e5;
|
|
19
|
+
const AUTH_CHALLENGE_TTL_MS = 3e4;
|
|
20
|
+
const DEVICE_SCOPES = [
|
|
21
|
+
"sessions.read",
|
|
22
|
+
"prompt.send",
|
|
23
|
+
"sessions.manage",
|
|
24
|
+
"interactions.respond",
|
|
25
|
+
"notifications.register"
|
|
26
|
+
];
|
|
27
|
+
const DEFAULT_DEVICE_SCOPES = DEVICE_SCOPES;
|
|
28
|
+
async function loadOrCreateHostAudience(path) {
|
|
29
|
+
try {
|
|
30
|
+
const existing = (await readFile(path, "utf8")).trim();
|
|
31
|
+
if (/^deeppilot:[A-Za-z0-9_-]{22}$/.test(existing)) return existing;
|
|
32
|
+
throw new Error(`host audience is malformed at ${path}`);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error.code !== "ENOENT") throw error;
|
|
35
|
+
}
|
|
36
|
+
const audience = "deeppilot:" + randomBytes(16).toString("base64url");
|
|
37
|
+
await mkdir(dirname(path), { recursive: true });
|
|
38
|
+
await writeFile(path, audience + "\n", { mode: 384 });
|
|
39
|
+
return audience;
|
|
40
|
+
}
|
|
41
|
+
function b64urlText(value) {
|
|
42
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Cross-language signature input. Text fields are base64url encoded before
|
|
46
|
+
* joining so names cannot create ambiguous separators. Decimal timestamps
|
|
47
|
+
* and cursor values are finite integers, or `-` when the cursor is absent.
|
|
48
|
+
*/
|
|
49
|
+
function canonicalAuthChallenge(fields) {
|
|
50
|
+
const cursor = fields.resumeCursor === void 0 ? "-" : String(fields.resumeCursor);
|
|
51
|
+
return Buffer.from([
|
|
52
|
+
"deeppilot-auth-v2",
|
|
53
|
+
`device-id:${b64urlText(fields.deviceId)}`,
|
|
54
|
+
`nonce:${fields.nonce}`,
|
|
55
|
+
`audience:${b64urlText(fields.audience)}`,
|
|
56
|
+
`issued-at:${fields.issuedAt}`,
|
|
57
|
+
`expires-at:${fields.expiresAt}`,
|
|
58
|
+
`device-name:${b64urlText(fields.deviceName)}`,
|
|
59
|
+
`app-version:${b64urlText(fields.appVersion)}`,
|
|
60
|
+
`resume-cursor:${cursor}`
|
|
61
|
+
].join("\n"), "utf8");
|
|
62
|
+
}
|
|
63
|
+
/** Accept only an uncompressed ANSI X9.63 P-256 public key (65 bytes). */
|
|
64
|
+
function parseP256PublicKey(encoded) {
|
|
65
|
+
const raw = Buffer.from(encoded, "base64url");
|
|
66
|
+
if (raw.length !== 65 || raw[0] !== 4) throw new TypeError("publicKey must be an uncompressed P-256 X9.63 key");
|
|
67
|
+
const spkiPrefix = Buffer.from("3059301306072a8648ce3d020106082a8648ce3d030107034200", "hex");
|
|
68
|
+
const key = createPublicKey({
|
|
69
|
+
key: Buffer.concat([spkiPrefix, raw]),
|
|
70
|
+
format: "der",
|
|
71
|
+
type: "spki"
|
|
72
|
+
});
|
|
73
|
+
if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new TypeError("publicKey must use P-256");
|
|
74
|
+
return {
|
|
75
|
+
key,
|
|
76
|
+
raw
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function deviceIdForPublicKey(publicKey) {
|
|
80
|
+
const { raw } = parseP256PublicKey(publicKey);
|
|
81
|
+
return createHash("sha256").update(raw).digest("base64url");
|
|
82
|
+
}
|
|
83
|
+
function fingerprintForPublicKey(publicKey) {
|
|
84
|
+
const { raw } = parseP256PublicKey(publicKey);
|
|
85
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
86
|
+
}
|
|
87
|
+
function verifyAuthProof(publicKey, fields, signature) {
|
|
88
|
+
try {
|
|
89
|
+
const { key } = parseP256PublicKey(publicKey);
|
|
90
|
+
const der = Buffer.from(signature, "base64url");
|
|
91
|
+
if (der.length < 64 || der.length > 80) return false;
|
|
92
|
+
return verify("sha256", canonicalAuthChallenge(fields), key, der);
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function normalizeDeviceScopes(value) {
|
|
98
|
+
if (!Array.isArray(value)) return [...DEFAULT_DEVICE_SCOPES];
|
|
99
|
+
const allowed = new Set(DEVICE_SCOPES);
|
|
100
|
+
return [...new Set(value.filter((scope) => typeof scope === "string" && allowed.has(scope)))];
|
|
101
|
+
}
|
|
102
|
+
/** One active, single-use pairing grant per plugin runtime. */
|
|
103
|
+
var PairingCodeManager = class {
|
|
104
|
+
active = null;
|
|
105
|
+
issue(now = Date.now()) {
|
|
106
|
+
const grant = {
|
|
107
|
+
code: randomBytes(24).toString("base64url"),
|
|
108
|
+
expiresAt: now + PAIRING_CODE_TTL_MS
|
|
109
|
+
};
|
|
110
|
+
this.active = grant;
|
|
111
|
+
return { ...grant };
|
|
112
|
+
}
|
|
113
|
+
consume(presented, now = Date.now()) {
|
|
114
|
+
const active = this.active;
|
|
115
|
+
if (active === null || now > active.expiresAt) {
|
|
116
|
+
this.active = null;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const expected = Buffer.from(active.code);
|
|
120
|
+
const actual = Buffer.from(presented);
|
|
121
|
+
const matches = expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
122
|
+
if (matches) this.active = null;
|
|
123
|
+
return matches;
|
|
124
|
+
}
|
|
125
|
+
invalidate() {
|
|
126
|
+
this.active = null;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
function createAuthChallenge(audience, now = Date.now()) {
|
|
130
|
+
return {
|
|
131
|
+
nonce: randomBytes(24).toString("base64url"),
|
|
132
|
+
audience,
|
|
133
|
+
issuedAt: now,
|
|
134
|
+
expiresAt: now + AUTH_CHALLENGE_TTL_MS
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
16
138
|
//#region src/token.ts
|
|
17
139
|
/** Expand a leading ~ using the process home directory. */
|
|
18
140
|
function expandHome(p) {
|
|
@@ -29,6 +151,16 @@ function dshDataRoot() {
|
|
|
29
151
|
function bridgeDataDir() {
|
|
30
152
|
return resolve(dshDataRoot(), "deeppilot");
|
|
31
153
|
}
|
|
154
|
+
/** Create or repair the canonical secret-bearing directory as owner-only. */
|
|
155
|
+
async function ensurePrivateBridgeDataDir() {
|
|
156
|
+
const target = bridgeDataDir();
|
|
157
|
+
await mkdir(target, {
|
|
158
|
+
recursive: true,
|
|
159
|
+
mode: 448
|
|
160
|
+
});
|
|
161
|
+
await chmod(target, 448);
|
|
162
|
+
return target;
|
|
163
|
+
}
|
|
32
164
|
/**
|
|
33
165
|
* Move the pre-DeepPilot data directory as one atomic directory rename.
|
|
34
166
|
* Existing canonical data always wins; secrets are never merged or replaced.
|
|
@@ -50,46 +182,6 @@ async function migrateLegacyBridgeDataDir() {
|
|
|
50
182
|
throw error;
|
|
51
183
|
}
|
|
52
184
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Load the pairing token from disk or generate and persist a fresh one.
|
|
55
|
-
* The file is written 0600; the token never appears in logs.
|
|
56
|
-
*/
|
|
57
|
-
async function loadOrCreateToken(tokenPath) {
|
|
58
|
-
const full = expandHome(tokenPath);
|
|
59
|
-
try {
|
|
60
|
-
const existing = (await readFile(full, "utf8")).trim();
|
|
61
|
-
if (existing.length >= 32) return existing;
|
|
62
|
-
} catch {}
|
|
63
|
-
const token = randomBytes(32).toString("base64url");
|
|
64
|
-
await mkdir(dirname(full), { recursive: true });
|
|
65
|
-
await writeFile(full, token + "\n", { mode: 384 });
|
|
66
|
-
return token;
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Generate a fresh pairing token and replace the stored one, invalidating
|
|
70
|
-
* every copy of the old secret. The write goes to a same-directory temp file
|
|
71
|
-
* renamed over the target so a crash can never leave a truncated token file.
|
|
72
|
-
*/
|
|
73
|
-
async function writeNewToken(tokenPath) {
|
|
74
|
-
const full = expandHome(tokenPath);
|
|
75
|
-
const token = randomBytes(32).toString("base64url");
|
|
76
|
-
await mkdir(dirname(full), { recursive: true });
|
|
77
|
-
const temp = `${full}.${randomBytes(6).toString("hex")}.tmp`;
|
|
78
|
-
await writeFile(temp, token + "\n", { mode: 384 });
|
|
79
|
-
await rename(temp, full);
|
|
80
|
-
return token;
|
|
81
|
-
}
|
|
82
|
-
/** Constant-time token comparison; both sides are high-entropy secrets. */
|
|
83
|
-
function tokenMatches(presented, expected) {
|
|
84
|
-
if (!presented) return false;
|
|
85
|
-
const a = Buffer.from(presented);
|
|
86
|
-
const b = Buffer.from(expected);
|
|
87
|
-
if (a.length !== b.length) {
|
|
88
|
-
timingSafeEqual(b, b);
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
return timingSafeEqual(a, b);
|
|
92
|
-
}
|
|
93
185
|
/** Hex shape of an APNs device token as delivered by iOS (usually 64 chars). */
|
|
94
186
|
const APNS_TOKEN_PATTERN = /^[0-9a-f]{32,512}$/;
|
|
95
187
|
function isValidApnsToken(token) {
|
|
@@ -117,32 +209,57 @@ var DeviceStore = class DeviceStore {
|
|
|
117
209
|
}
|
|
118
210
|
return store;
|
|
119
211
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
212
|
+
/** Register one public key after a valid, single-use pairing grant. */
|
|
213
|
+
register(record, now) {
|
|
214
|
+
const deviceId = deviceIdForPublicKey(record.publicKey);
|
|
215
|
+
const existing = this.devices.get(deviceId);
|
|
216
|
+
if (!existing && this.devices.size >= 64) throw new Error("device registry is full");
|
|
217
|
+
const next = {
|
|
218
|
+
deviceId,
|
|
219
|
+
deviceName: record.deviceName,
|
|
220
|
+
appVersion: record.appVersion,
|
|
221
|
+
publicKey: record.publicKey,
|
|
222
|
+
fingerprint: fingerprintForPublicKey(record.publicKey),
|
|
223
|
+
scopes: normalizeDeviceScopes(record.scopes),
|
|
224
|
+
firstSeenTs: existing?.firstSeenTs ?? now,
|
|
225
|
+
lastSeenTs: now,
|
|
226
|
+
...existing?.apns ? { apns: existing.apns } : {}
|
|
227
|
+
};
|
|
228
|
+
this.devices.set(deviceId, next);
|
|
229
|
+
this.flush();
|
|
230
|
+
return structuredClone(next);
|
|
231
|
+
}
|
|
232
|
+
/** Return an active cryptographic identity. */
|
|
233
|
+
authorized(deviceId) {
|
|
234
|
+
const record = this.devices.get(deviceId);
|
|
235
|
+
if (!record?.publicKey || record.revokedAt !== void 0) return void 0;
|
|
236
|
+
return record;
|
|
237
|
+
}
|
|
238
|
+
markAuthenticated(deviceId, deviceName, appVersion, now) {
|
|
239
|
+
const record = this.authorized(deviceId);
|
|
240
|
+
if (!record) return;
|
|
241
|
+
record.deviceName = deviceName || record.deviceName;
|
|
242
|
+
record.appVersion = appVersion || record.appVersion;
|
|
243
|
+
record.lastSeenTs = now;
|
|
244
|
+
this.flush();
|
|
245
|
+
}
|
|
246
|
+
revoke(deviceId, now) {
|
|
247
|
+
const record = this.devices.get(deviceId);
|
|
248
|
+
if (!record || record.revokedAt !== void 0) return false;
|
|
249
|
+
record.revokedAt = now;
|
|
250
|
+
delete record.apns;
|
|
251
|
+
this.flush();
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
setScopes(deviceId, scopes) {
|
|
255
|
+
const record = this.authorized(deviceId);
|
|
256
|
+
if (!record) return null;
|
|
257
|
+
record.scopes = normalizeDeviceScopes(scopes);
|
|
142
258
|
this.flush();
|
|
259
|
+
return [...record.scopes];
|
|
143
260
|
}
|
|
144
261
|
list() {
|
|
145
|
-
return [...this.devices.values()];
|
|
262
|
+
return [...this.devices.values()].map((record) => structuredClone(record));
|
|
146
263
|
}
|
|
147
264
|
/**
|
|
148
265
|
* Store (or refresh) the APNs registration of a paired device. Idempotent:
|
|
@@ -152,17 +269,8 @@ var DeviceStore = class DeviceStore {
|
|
|
152
269
|
setPushToken(deviceId, token, environment, categories, now) {
|
|
153
270
|
const normalized = token.toLowerCase();
|
|
154
271
|
if (!isValidApnsToken(normalized)) return;
|
|
155
|
-
|
|
156
|
-
if (!record)
|
|
157
|
-
record = {
|
|
158
|
-
deviceId,
|
|
159
|
-
deviceName: "unknown",
|
|
160
|
-
appVersion: "unknown",
|
|
161
|
-
firstSeenTs: now,
|
|
162
|
-
lastSeenTs: now
|
|
163
|
-
};
|
|
164
|
-
this.devices.set(deviceId, record);
|
|
165
|
-
}
|
|
272
|
+
const record = this.authorized(deviceId);
|
|
273
|
+
if (!record) return;
|
|
166
274
|
const next = {
|
|
167
275
|
token: normalized,
|
|
168
276
|
environment,
|
|
@@ -185,15 +293,6 @@ var DeviceStore = class DeviceStore {
|
|
|
185
293
|
delete record.apns;
|
|
186
294
|
this.flush();
|
|
187
295
|
}
|
|
188
|
-
/**
|
|
189
|
-
* Drop every paired-device record. Used by token rotation: devices paired
|
|
190
|
-
* under the old token can no longer authenticate, so keeping their rows
|
|
191
|
-
* would paint a misleading "still paired" picture.
|
|
192
|
-
*/
|
|
193
|
-
clear() {
|
|
194
|
-
this.devices.clear();
|
|
195
|
-
this.flush();
|
|
196
|
-
}
|
|
197
296
|
/** Serialized so concurrent touches can never interleave half-written JSON. */
|
|
198
297
|
flush() {
|
|
199
298
|
const next = this.flushTail.then(() => this.writeFile());
|
|
@@ -207,7 +306,7 @@ var DeviceStore = class DeviceStore {
|
|
|
207
306
|
async writeFile() {
|
|
208
307
|
const full = expandHome(this.filePath);
|
|
209
308
|
const body = JSON.stringify({
|
|
210
|
-
version:
|
|
309
|
+
version: 2,
|
|
211
310
|
devices: this.list()
|
|
212
311
|
}, null, 2);
|
|
213
312
|
try {
|
|
@@ -219,43 +318,83 @@ var DeviceStore = class DeviceStore {
|
|
|
219
318
|
}
|
|
220
319
|
};
|
|
221
320
|
//#endregion
|
|
222
|
-
//#region src/connection.ts
|
|
223
|
-
const AUTH_TIMEOUT_MS =
|
|
321
|
+
//#region src/connection-policy.ts
|
|
322
|
+
const AUTH_TIMEOUT_MS = 35e3;
|
|
224
323
|
const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
225
324
|
"image/png",
|
|
226
325
|
"image/jpeg",
|
|
227
326
|
"image/webp",
|
|
228
327
|
"image/gif"
|
|
229
328
|
]);
|
|
230
|
-
const MAX_PROMPT_IMAGES = 4;
|
|
231
|
-
const MAX_BASE64_CHARS_PER_IMAGE = 8388608;
|
|
232
|
-
/** Bounds a single prompt's text; the frame itself is capped by ws maxPayload. */
|
|
233
|
-
const MAX_PROMPT_TEXT_CHARS = 262144;
|
|
234
|
-
const MAX_DEVICE_ID_CHARS = 128;
|
|
235
|
-
const MAX_DEVICE_NAME_CHARS = 64;
|
|
236
|
-
const MAX_APP_VERSION_CHARS = 32;
|
|
237
329
|
function sanitizeDeviceField(value, maxChars) {
|
|
238
330
|
return (typeof value === "string" ? value : String(value ?? "")).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxChars);
|
|
239
331
|
}
|
|
240
|
-
function
|
|
241
|
-
|
|
332
|
+
function requiredScope(type) {
|
|
333
|
+
if (type === "c2s.ping" || type === "c2s.resume") return void 0;
|
|
334
|
+
if (type === "c2s.session.sendPrompt") return "prompt.send";
|
|
335
|
+
if (type === "c2s.approval.respond" || type === "c2s.question.respond") return "interactions.respond";
|
|
336
|
+
if (type === "c2s.push.register") return "notifications.register";
|
|
337
|
+
if (type === "c2s.workspace.create" || type === "c2s.session.create" || type === "c2s.session.rename" || type === "c2s.session.archive" || type === "c2s.session.cancel" || type === "c2s.session.selectModel") return "sessions.manage";
|
|
338
|
+
if (type.startsWith("c2s.")) return "sessions.read";
|
|
339
|
+
}
|
|
340
|
+
function sanitizeImageName(value) {
|
|
341
|
+
return value.replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, 120);
|
|
342
|
+
}
|
|
343
|
+
/** Error code for a failed approval/question response outcome. */
|
|
344
|
+
function pendingResponseErrorCode(reason) {
|
|
345
|
+
switch (reason) {
|
|
346
|
+
case "not-pending": return "E_NOT_FOUND";
|
|
347
|
+
case "bad-response": return "E_PROTOCOL";
|
|
348
|
+
case "transport": return "E_INTERNAL";
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** Human-readable failure detail; `question not pending` must only ever mean
|
|
352
|
+
* "nothing pending", never "the host rejected the answer". */
|
|
353
|
+
function pendingResponseMessage(kind, reason) {
|
|
354
|
+
switch (reason) {
|
|
355
|
+
case "not-pending": return kind + " not pending";
|
|
356
|
+
case "bad-response": return kind + " answer rejected by host: answer does not match the asked questions";
|
|
357
|
+
case "transport": return "host connection failed while answering " + kind;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function managementErrorCode(kind) {
|
|
361
|
+
switch (kind) {
|
|
362
|
+
case "unsupported": return "E_UNSUPPORTED";
|
|
363
|
+
case "not-found": return "E_NOT_FOUND";
|
|
364
|
+
case "busy": return "E_BUSY";
|
|
365
|
+
case "invalid": return "E_PROTOCOL";
|
|
366
|
+
case "internal": return "E_INTERNAL";
|
|
367
|
+
}
|
|
242
368
|
}
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/connection.ts
|
|
243
371
|
/**
|
|
244
372
|
* One connected phone. Implements BridgeSink so the HostBridge can push
|
|
245
|
-
* projected frames and replays.
|
|
246
|
-
*
|
|
373
|
+
* projected frames and replays. Every socket starts anonymous, receives one
|
|
374
|
+
* server challenge, and must prove possession of a registered P-256 key.
|
|
247
375
|
*/
|
|
248
376
|
var BridgeConnection = class {
|
|
249
377
|
ws;
|
|
250
378
|
deps;
|
|
251
379
|
authenticated = false;
|
|
380
|
+
authenticationSettled = false;
|
|
381
|
+
closed = false;
|
|
252
382
|
helloTimer;
|
|
253
383
|
openSessions = /* @__PURE__ */ new Set();
|
|
384
|
+
/**
|
|
385
|
+
* Realtime events that arrive while a session's history snapshot is in
|
|
386
|
+
* flight. The wire contract requires tail first; sending these immediately
|
|
387
|
+
* lets the later tail roll the client back over messages it just rendered.
|
|
388
|
+
*/
|
|
389
|
+
openingSessionEvents = /* @__PURE__ */ new Map();
|
|
254
390
|
/** Sanitized device identity from hello; needed for push registration. */
|
|
255
391
|
deviceId;
|
|
392
|
+
scopes = /* @__PURE__ */ new Set();
|
|
393
|
+
authChallenge;
|
|
256
394
|
constructor(ws, deps) {
|
|
257
395
|
this.ws = ws;
|
|
258
396
|
this.deps = deps;
|
|
397
|
+
this.authChallenge = createAuthChallenge(deps.audience);
|
|
259
398
|
ws.on("message", (data) => {
|
|
260
399
|
this.onMessage(String(data));
|
|
261
400
|
});
|
|
@@ -265,18 +404,50 @@ var BridgeConnection = class {
|
|
|
265
404
|
});
|
|
266
405
|
ws.on("error", () => {});
|
|
267
406
|
this.helloTimer = setTimeout(() => {
|
|
268
|
-
if (!this.authenticated)
|
|
407
|
+
if (!this.authenticated) {
|
|
408
|
+
this.settleAuthentication(false, "timeout");
|
|
409
|
+
this.close(4402, "auth timeout");
|
|
410
|
+
}
|
|
269
411
|
}, AUTH_TIMEOUT_MS);
|
|
412
|
+
this.helloTimer.unref();
|
|
413
|
+
this.send("s2c.auth.challenge", this.authChallenge);
|
|
270
414
|
}
|
|
271
415
|
/** Hard-drop the socket (server-side stale sweep). */
|
|
272
416
|
terminate() {
|
|
273
417
|
this.ws.terminate();
|
|
274
418
|
}
|
|
419
|
+
/** Protocol-compliant idle timeout: let the peer observe a normal 1001 close. */
|
|
420
|
+
closeIdle() {
|
|
421
|
+
this.close(1001, "idle timeout");
|
|
422
|
+
}
|
|
423
|
+
/** Announce an orderly plugin/data-plane shutdown before closing the socket. */
|
|
424
|
+
closeForServerStop() {
|
|
425
|
+
this.fail(void 0, "E_INTERNAL", "server stopping");
|
|
426
|
+
this.close(1001, "server stopping");
|
|
427
|
+
}
|
|
428
|
+
/** Used by dependency-lifecycle cleanup to avoid closing a replacement bridge. */
|
|
429
|
+
isAttachedTo(bridge) {
|
|
430
|
+
return this.deps.bridge === bridge;
|
|
431
|
+
}
|
|
275
432
|
/** Device identity once hello succeeded; undefined before that. */
|
|
276
433
|
get connectedDeviceId() {
|
|
277
434
|
return this.authenticated ? this.deviceId : void 0;
|
|
278
435
|
}
|
|
279
436
|
push(type, payload, seq) {
|
|
437
|
+
if (type === "s2c.session.event") {
|
|
438
|
+
const sessionId = payload?.sessionId;
|
|
439
|
+
if (typeof sessionId === "string") {
|
|
440
|
+
const buffered = this.openingSessionEvents.get(sessionId);
|
|
441
|
+
if (buffered) {
|
|
442
|
+
buffered.push({
|
|
443
|
+
type,
|
|
444
|
+
payload,
|
|
445
|
+
...seq !== void 0 ? { seq } : {}
|
|
446
|
+
});
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
280
451
|
if (this.deps.debug === true) this.deps.log("push " + type + " seq=" + String(seq));
|
|
281
452
|
this.send(type, payload, void 0, seq);
|
|
282
453
|
}
|
|
@@ -293,13 +464,23 @@ var BridgeConnection = class {
|
|
|
293
464
|
return this.deps.bridge.currentCursor();
|
|
294
465
|
}
|
|
295
466
|
onClose() {
|
|
467
|
+
if (this.closed) return;
|
|
468
|
+
this.closed = true;
|
|
469
|
+
if (!this.authenticationSettled) this.settleAuthentication(false, "closed");
|
|
296
470
|
if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
|
|
297
471
|
for (const id of this.openSessions) this.deps.bridge.markSinkClosed(this, id);
|
|
298
472
|
this.openSessions.clear();
|
|
473
|
+
this.openingSessionEvents.clear();
|
|
299
474
|
this.deps.bridge.dropSinkSessions(this);
|
|
300
475
|
if (this.authenticated) this.deps.bridge.removeSink(this);
|
|
301
476
|
}
|
|
477
|
+
settleAuthentication(ok, reason) {
|
|
478
|
+
if (this.authenticationSettled) return;
|
|
479
|
+
this.authenticationSettled = true;
|
|
480
|
+
this.deps.onAuthenticationSettled?.(ok, reason);
|
|
481
|
+
}
|
|
302
482
|
close(code, reason) {
|
|
483
|
+
if (this.closed) return;
|
|
303
484
|
try {
|
|
304
485
|
this.ws.close(code, reason);
|
|
305
486
|
} catch {
|
|
@@ -308,14 +489,19 @@ var BridgeConnection = class {
|
|
|
308
489
|
}
|
|
309
490
|
send(type, payload, id, seq) {
|
|
310
491
|
const envelope = {
|
|
311
|
-
v:
|
|
492
|
+
v: 2,
|
|
312
493
|
type,
|
|
313
494
|
ts: Date.now(),
|
|
314
495
|
...id !== void 0 ? { id } : {},
|
|
315
496
|
...seq !== void 0 ? { seq } : {},
|
|
316
497
|
payload
|
|
317
498
|
};
|
|
318
|
-
if (this.ws.readyState
|
|
499
|
+
if (this.ws.readyState !== this.ws.OPEN) return;
|
|
500
|
+
if (this.ws.bufferedAmount > 4194304) {
|
|
501
|
+
this.close(1013, "client too slow");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
this.ws.send(JSON.stringify(envelope));
|
|
319
505
|
}
|
|
320
506
|
fail(id, code, message) {
|
|
321
507
|
this.send("s2c.error", {
|
|
@@ -330,6 +516,10 @@ var BridgeConnection = class {
|
|
|
330
516
|
}
|
|
331
517
|
async onMessage(raw) {
|
|
332
518
|
this.lastActivity = Date.now();
|
|
519
|
+
if (!this.authenticated && raw.length > 65536) {
|
|
520
|
+
this.close(1009, "pre-auth frame too large");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
333
523
|
let env;
|
|
334
524
|
try {
|
|
335
525
|
env = JSON.parse(raw);
|
|
@@ -337,7 +527,7 @@ var BridgeConnection = class {
|
|
|
337
527
|
this.fail(void 0, "E_PROTOCOL", "frame is not valid JSON");
|
|
338
528
|
return;
|
|
339
529
|
}
|
|
340
|
-
if (env.v !==
|
|
530
|
+
if (env.v !== 2) {
|
|
341
531
|
this.fail(env.id, "E_UNSUPPORTED", "unsupported protocol version");
|
|
342
532
|
this.close(4500, "protocol version mismatch");
|
|
343
533
|
return;
|
|
@@ -347,13 +537,18 @@ var BridgeConnection = class {
|
|
|
347
537
|
this.send("s2c.pong", { serverTime: Date.now() }, env.id);
|
|
348
538
|
return;
|
|
349
539
|
}
|
|
350
|
-
if (env.type === "c2s.
|
|
351
|
-
await this.
|
|
540
|
+
if (env.type === "c2s.auth.prove") {
|
|
541
|
+
await this.prove(env);
|
|
352
542
|
return;
|
|
353
543
|
}
|
|
354
544
|
this.fail(env.id, "E_PROTOCOL", "authenticate first");
|
|
355
545
|
return;
|
|
356
546
|
}
|
|
547
|
+
const required = requiredScope(env.type);
|
|
548
|
+
if (required !== void 0 && !this.scopes.has(required)) {
|
|
549
|
+
this.fail(env.id, "E_FORBIDDEN", `scope ${required} required`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
357
552
|
switch (env.type) {
|
|
358
553
|
case "c2s.ping":
|
|
359
554
|
this.send("s2c.pong", { serverTime: Date.now() }, env.id);
|
|
@@ -402,18 +597,23 @@ var BridgeConnection = class {
|
|
|
402
597
|
const p = env.payload;
|
|
403
598
|
if (!p?.sessionId || typeof p.sessionId !== "string") return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
404
599
|
const sessionId = p.sessionId;
|
|
405
|
-
|
|
406
|
-
this.
|
|
600
|
+
const bufferedEvents = [];
|
|
601
|
+
this.openingSessionEvents.set(sessionId, bufferedEvents);
|
|
407
602
|
if (!await this.deps.bridge.openSession(this, sessionId, p.tailCount ?? 100)) {
|
|
408
|
-
this.
|
|
409
|
-
this.deps.bridge.markSinkClosed(this, sessionId);
|
|
603
|
+
if (this.openingSessionEvents.get(sessionId) === bufferedEvents) this.openingSessionEvents.delete(sessionId);
|
|
410
604
|
return this.fail(env.id, "E_NOT_FOUND", "session history unavailable");
|
|
411
605
|
}
|
|
606
|
+
if (this.openingSessionEvents.get(sessionId) !== bufferedEvents) return;
|
|
607
|
+
this.openSessions.add(sessionId);
|
|
608
|
+
this.deps.bridge.markSinkOpen(this, sessionId);
|
|
609
|
+
this.openingSessionEvents.delete(sessionId);
|
|
610
|
+
for (const frame of bufferedEvents) this.push(frame.type, frame.payload, frame.seq);
|
|
412
611
|
return;
|
|
413
612
|
}
|
|
414
613
|
case "c2s.session.close": {
|
|
415
614
|
const p = env.payload;
|
|
416
615
|
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
616
|
+
this.openingSessionEvents.delete(p.sessionId);
|
|
417
617
|
this.openSessions.delete(p.sessionId);
|
|
418
618
|
this.deps.bridge.markSinkClosed(this, p.sessionId);
|
|
419
619
|
this.send("s2c.ack", {}, env.id);
|
|
@@ -474,7 +674,9 @@ var BridgeConnection = class {
|
|
|
474
674
|
case "c2s.session.history": {
|
|
475
675
|
const p = env.payload;
|
|
476
676
|
if (!p?.sessionId || typeof p.beforeSeq !== "number") return this.fail(env.id, "E_PROTOCOL", "sessionId and beforeSeq required");
|
|
477
|
-
|
|
677
|
+
const page = await this.deps.bridge.historyPage(p.sessionId, p.beforeSeq, Math.min(p.limit ?? 100, 500));
|
|
678
|
+
if (!page) return this.fail(env.id, "E_NOT_FOUND", "history unavailable");
|
|
679
|
+
this.send("s2c.history.page", page, env.id);
|
|
478
680
|
return;
|
|
479
681
|
}
|
|
480
682
|
case "c2s.session.attachment": {
|
|
@@ -524,15 +726,15 @@ var BridgeConnection = class {
|
|
|
524
726
|
const text = typeof p?.text === "string" ? p.text : "";
|
|
525
727
|
const rawImages = Array.isArray(p?.images) ? p.images : [];
|
|
526
728
|
if (!p?.sessionId || text.trim().length === 0 && rawImages.length === 0) return this.fail(env.id, "E_PROTOCOL", "sessionId and text or images required");
|
|
527
|
-
if (text.length >
|
|
528
|
-
if (rawImages.length >
|
|
729
|
+
if (text.length > 262144) return this.fail(env.id, "E_PROTOCOL", "prompt text too long");
|
|
730
|
+
if (rawImages.length > 4) return this.fail(env.id, "E_PROTOCOL", "too many images");
|
|
529
731
|
const images = [];
|
|
530
732
|
for (const image of rawImages) {
|
|
531
|
-
if (!IMAGE_MEDIA_TYPES.has(String(image?.mediaType)) || typeof image?.data !== "string" || image.data.length === 0 || image.data.length >
|
|
733
|
+
if (!IMAGE_MEDIA_TYPES.has(String(image?.mediaType)) || typeof image?.data !== "string" || image.data.length === 0 || image.data.length > 8388608) return this.fail(env.id, "E_PROTOCOL", "invalid image attachment");
|
|
532
734
|
images.push({
|
|
533
735
|
mediaType: image.mediaType,
|
|
534
736
|
data: image.data,
|
|
535
|
-
...typeof image.name === "string" && image.name
|
|
737
|
+
...typeof image.name === "string" && sanitizeImageName(image.name).length > 0 ? { name: sanitizeImageName(image.name) } : {}
|
|
536
738
|
});
|
|
537
739
|
}
|
|
538
740
|
const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images);
|
|
@@ -579,40 +781,51 @@ var BridgeConnection = class {
|
|
|
579
781
|
default: this.fail(env.id, "E_PROTOCOL", "unknown type: " + env.type);
|
|
580
782
|
}
|
|
581
783
|
}
|
|
582
|
-
async
|
|
784
|
+
async prove(env) {
|
|
583
785
|
const p = env.payload ?? {};
|
|
584
|
-
if (!helloTokenAccepted(this.deps.transportAuthenticated, p.token, this.deps.expectedToken)) {
|
|
585
|
-
this.fail(env.id, "E_AUTH", "token missing or invalid");
|
|
586
|
-
this.close(4401, "invalid token");
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
786
|
if (!p.deviceId) {
|
|
590
787
|
this.fail(env.id, "E_PROTOCOL", "deviceId required");
|
|
591
788
|
this.close(4403, "deviceId required");
|
|
592
789
|
return;
|
|
593
790
|
}
|
|
594
|
-
const deviceId = sanitizeDeviceField(p.deviceId,
|
|
791
|
+
const deviceId = sanitizeDeviceField(p.deviceId, 128);
|
|
595
792
|
if (!deviceId) {
|
|
596
793
|
this.fail(env.id, "E_PROTOCOL", "deviceId required");
|
|
597
794
|
this.close(4403, "deviceId required");
|
|
598
795
|
return;
|
|
599
796
|
}
|
|
600
|
-
const deviceName = sanitizeDeviceField(p.deviceName,
|
|
601
|
-
const appVersion = sanitizeDeviceField(p.appVersion,
|
|
797
|
+
const deviceName = sanitizeDeviceField(p.deviceName, 64) || "unknown";
|
|
798
|
+
const appVersion = sanitizeDeviceField(p.appVersion, 32) || "unknown";
|
|
799
|
+
const challenge = this.authChallenge;
|
|
800
|
+
const record = this.deps.devices.authorized(deviceId);
|
|
801
|
+
const resumeCursor = typeof p.resumeCursor === "number" && Number.isInteger(p.resumeCursor) && p.resumeCursor >= 0 ? p.resumeCursor : void 0;
|
|
802
|
+
const challengeMatches = p.nonce === challenge.nonce && p.audience === challenge.audience && p.issuedAt === challenge.issuedAt && p.expiresAt === challenge.expiresAt && Date.now() <= challenge.expiresAt;
|
|
803
|
+
if (!(record?.publicKey !== void 0 && typeof p.signature === "string" && challengeMatches && verifyAuthProof(record.publicKey, {
|
|
804
|
+
deviceId,
|
|
805
|
+
deviceName,
|
|
806
|
+
appVersion,
|
|
807
|
+
resumeCursor,
|
|
808
|
+
...challenge
|
|
809
|
+
}, p.signature)) || record === void 0) {
|
|
810
|
+
this.fail(env.id, "E_AUTH", "device proof missing or invalid");
|
|
811
|
+
this.settleAuthentication(false, "invalid-proof");
|
|
812
|
+
this.close(4401, "invalid device proof");
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
this.settleAuthentication(true, "success");
|
|
602
816
|
this.authenticated = true;
|
|
603
817
|
this.deviceId = deviceId;
|
|
818
|
+
this.scopes = new Set(record.scopes ?? []);
|
|
604
819
|
if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
|
|
605
|
-
this.deps.devices.
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
appVersion
|
|
609
|
-
}, Date.now());
|
|
610
|
-
this.deps.log("device paired: " + deviceName + " (" + deviceId + ")");
|
|
611
|
-
const cursor = typeof p.resumeCursor === "number" && p.resumeCursor >= 0 ? p.resumeCursor : void 0;
|
|
820
|
+
this.deps.devices.markAuthenticated(deviceId, deviceName, appVersion, Date.now());
|
|
821
|
+
this.deps.onDeviceAuthenticated?.(deviceId);
|
|
822
|
+
const cursor = resumeCursor;
|
|
612
823
|
const canResume = cursor !== void 0 && this.deps.bridge.canResumeFrom(cursor);
|
|
613
824
|
this.send("s2c.welcome", {
|
|
614
|
-
protocolVersion:
|
|
825
|
+
protocolVersion: 2,
|
|
615
826
|
serverVersion: this.deps.serverVersion,
|
|
827
|
+
deviceId,
|
|
828
|
+
scopes: [...this.scopes],
|
|
616
829
|
capabilities: this.deps.bridge.capabilities,
|
|
617
830
|
cursor: this.deps.bridge.currentCursor(),
|
|
618
831
|
resumed: canResume
|
|
@@ -624,34 +837,8 @@ var BridgeConnection = class {
|
|
|
624
837
|
}
|
|
625
838
|
}
|
|
626
839
|
};
|
|
627
|
-
/** Error code for a failed approval/question response outcome. */
|
|
628
|
-
function pendingResponseErrorCode(reason) {
|
|
629
|
-
switch (reason) {
|
|
630
|
-
case "not-pending": return "E_NOT_FOUND";
|
|
631
|
-
case "bad-response": return "E_PROTOCOL";
|
|
632
|
-
case "transport": return "E_INTERNAL";
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
/** Human-readable failure detail; `question not pending` must only ever mean
|
|
636
|
-
* "nothing pending", never "the host rejected the answer". */
|
|
637
|
-
function pendingResponseMessage(kind, reason) {
|
|
638
|
-
switch (reason) {
|
|
639
|
-
case "not-pending": return kind + " not pending";
|
|
640
|
-
case "bad-response": return kind + " answer rejected by host: answer does not match the asked questions";
|
|
641
|
-
case "transport": return "host connection failed while answering " + kind;
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
function managementErrorCode(kind) {
|
|
645
|
-
switch (kind) {
|
|
646
|
-
case "unsupported": return "E_UNSUPPORTED";
|
|
647
|
-
case "not-found": return "E_NOT_FOUND";
|
|
648
|
-
case "busy": return "E_BUSY";
|
|
649
|
-
case "invalid": return "E_PROTOCOL";
|
|
650
|
-
case "internal": return "E_INTERNAL";
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
840
|
//#endregion
|
|
654
|
-
//#region src/host-
|
|
841
|
+
//#region src/host-api.ts
|
|
655
842
|
/**
|
|
656
843
|
* Subagent sessions are host-internal workers of a parent conversation.
|
|
657
844
|
* They must never surface on the phone: not in the project/session list,
|
|
@@ -662,117 +849,586 @@ function isSubagentRow(row) {
|
|
|
662
849
|
}
|
|
663
850
|
function unwrapStreamItem(item) {
|
|
664
851
|
const nested = item.payload;
|
|
665
|
-
if (nested && typeof nested === "object" && typeof nested.type === "string") return
|
|
852
|
+
if (nested && typeof nested === "object" && typeof nested.type === "string") return item.rpcId ? {
|
|
666
853
|
...nested,
|
|
667
854
|
rpcId: item.rpcId
|
|
668
|
-
};
|
|
855
|
+
} : nested;
|
|
669
856
|
return item;
|
|
670
857
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
*
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT) {
|
|
692
|
-
this.apiProxy = apiProxy;
|
|
693
|
-
this.historyBufferMax = historyBufferMax;
|
|
694
|
-
}
|
|
695
|
-
pushOutlet;
|
|
696
|
-
/**
|
|
697
|
-
* Wire the offline-push fan-out. Present ⇒ welcome advertises the `push`
|
|
698
|
-
* capability and notify-worthy events are mirrored to APNs.
|
|
699
|
-
*/
|
|
700
|
-
setPushOutlet(outlet) {
|
|
701
|
-
this.pushOutlet = outlet;
|
|
858
|
+
//#endregion
|
|
859
|
+
//#region src/host-event-projection.ts
|
|
860
|
+
const MAX_MESSAGE_PROJECTION_BYTES = 262144;
|
|
861
|
+
/** One durable host event sequence becomes exactly one phone message row.
|
|
862
|
+
* Keep the last projection when a host history response repeats an event. */
|
|
863
|
+
function canonicalSessionMessages(messages) {
|
|
864
|
+
const bySequence = /* @__PURE__ */ new Map();
|
|
865
|
+
for (const message of messages) bySequence.set(message.seq, message);
|
|
866
|
+
return [...bySequence.values()].sort((a, b) => a.seq - b.seq);
|
|
867
|
+
}
|
|
868
|
+
function limitSessionPageMessages(messages) {
|
|
869
|
+
const canonical = canonicalSessionMessages(messages);
|
|
870
|
+
let bytes = 2;
|
|
871
|
+
const kept = [];
|
|
872
|
+
for (let index = canonical.length - 1; index >= 0; index -= 1) {
|
|
873
|
+
const message = canonical[index];
|
|
874
|
+
const candidateBytes = jsonBytes(message) + (kept.length > 0 ? 1 : 0);
|
|
875
|
+
if (bytes + candidateBytes > 921600) break;
|
|
876
|
+
kept.unshift(message);
|
|
877
|
+
bytes += candidateBytes;
|
|
702
878
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
push: this.pushOutlet?.isAvailable() === true
|
|
879
|
+
return {
|
|
880
|
+
messages: kept,
|
|
881
|
+
dropped: canonical.length - kept.length
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
function projectEvent(sessionId, event) {
|
|
885
|
+
switch (event.type) {
|
|
886
|
+
case "turn/start": return {
|
|
887
|
+
kind: "turn.start",
|
|
888
|
+
data: {}
|
|
714
889
|
};
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
890
|
+
case "turn/end": return {
|
|
891
|
+
kind: "turn.end",
|
|
892
|
+
data: { ok: event.data?.reason?.kind === "completed" }
|
|
893
|
+
};
|
|
894
|
+
case "user/message": return {
|
|
895
|
+
kind: "message.final",
|
|
896
|
+
data: { ...limitMessageProjection({
|
|
897
|
+
seq: event.seq,
|
|
898
|
+
role: userRoleOf(event.data),
|
|
899
|
+
text: messageText(event.data),
|
|
900
|
+
...attachmentProjection(event.data),
|
|
901
|
+
...contextProjectionOf(event.data),
|
|
902
|
+
ts: tsOf(event)
|
|
903
|
+
}) }
|
|
904
|
+
};
|
|
905
|
+
case "assistant/chunk":
|
|
906
|
+
if (chunkTypeOf(event.data) === "reasoning-delta") return {
|
|
907
|
+
kind: "thinking.delta",
|
|
908
|
+
data: limitRealtimeText({
|
|
909
|
+
text: chunkText(event.data),
|
|
910
|
+
ts: tsOf(event)
|
|
911
|
+
})
|
|
912
|
+
};
|
|
913
|
+
return {
|
|
914
|
+
kind: "message.delta",
|
|
915
|
+
data: limitRealtimeText({
|
|
916
|
+
text: chunkText(event.data),
|
|
917
|
+
ts: tsOf(event)
|
|
918
|
+
})
|
|
919
|
+
};
|
|
920
|
+
case "assistant/message": {
|
|
921
|
+
const text = messageText(event.data);
|
|
922
|
+
const thinking = messageThinking(event.data);
|
|
923
|
+
if (!text.trim() && !thinking.trim()) return null;
|
|
924
|
+
return {
|
|
925
|
+
kind: "message.final",
|
|
926
|
+
data: { ...limitMessageProjection({
|
|
927
|
+
seq: event.seq,
|
|
928
|
+
role: "assistant",
|
|
929
|
+
text,
|
|
930
|
+
...thinking ? { thinking } : {},
|
|
931
|
+
ts: tsOf(event)
|
|
932
|
+
}) }
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
case "tool/call": {
|
|
936
|
+
const data = event.data;
|
|
937
|
+
return {
|
|
938
|
+
kind: "tool.start",
|
|
939
|
+
data: {
|
|
940
|
+
seq: event.seq,
|
|
941
|
+
role: "tool",
|
|
942
|
+
tool: {
|
|
943
|
+
name: String(data?.name ?? "tool"),
|
|
944
|
+
state: "running",
|
|
945
|
+
summary: summarizeArgs(data?.arguments),
|
|
946
|
+
...data?.callId ? { callId: String(data.callId) } : {}
|
|
947
|
+
},
|
|
948
|
+
ts: tsOf(event)
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
case "tool/result": {
|
|
953
|
+
const data = event.data;
|
|
954
|
+
return {
|
|
955
|
+
kind: "tool.end",
|
|
956
|
+
data: {
|
|
957
|
+
seq: event.seq,
|
|
958
|
+
role: "tool",
|
|
959
|
+
ok: !event.data || data?.error === void 0,
|
|
960
|
+
...data?.callId ? { callId: String(data.callId) } : {},
|
|
961
|
+
ts: tsOf(event)
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
default: return null;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function tsOf(event) {
|
|
969
|
+
return typeof event.time === "number" ? event.time : Date.now();
|
|
970
|
+
}
|
|
971
|
+
/** Read the durable message source off one user/message payload. Handles both
|
|
972
|
+
* bare-message payloads and older `{message: {...}}` wrappers; undefined when
|
|
973
|
+
* the shape carries no readable source (legacy hosts). */
|
|
974
|
+
function userMessageSource(data) {
|
|
975
|
+
if (!data || typeof data !== "object") return void 0;
|
|
976
|
+
const obj = data;
|
|
977
|
+
if (obj.source && typeof obj.source === "object") return obj.source;
|
|
978
|
+
if (obj.message && typeof obj.message === "object" && obj.message.source && typeof obj.message.source === "object") return obj.message.source;
|
|
979
|
+
}
|
|
980
|
+
/** Wire role for one user/message payload. A payload without any readable
|
|
981
|
+
* source degrades to 'user' so history written by older hosts stays visible;
|
|
982
|
+
* a present source follows the host's own trajectory rule — anything whose
|
|
983
|
+
* `kind` is not 'user' is injected context and projects as 'system'. */
|
|
984
|
+
function userRoleOf(data) {
|
|
985
|
+
const source = userMessageSource(data);
|
|
986
|
+
if (!source) return "user";
|
|
987
|
+
return source.kind === "user" ? "user" : "system";
|
|
988
|
+
}
|
|
989
|
+
/** Producer name of one injected-context source, mirroring how the DSH client
|
|
990
|
+
* runtime derives its trajectory label: plugin name, skill name, instruction
|
|
991
|
+
* paths, session-reference labels, or the raw kind as fallback. */
|
|
992
|
+
function contextLabelOf(source) {
|
|
993
|
+
const kind = typeof source.kind === "string" ? source.kind : "";
|
|
994
|
+
const joined = (member) => {
|
|
995
|
+
const list = source[member];
|
|
996
|
+
if (!Array.isArray(list)) return void 0;
|
|
997
|
+
const names = list.flatMap((entry) => {
|
|
998
|
+
if (!entry || typeof entry !== "object") return [];
|
|
999
|
+
const record = entry;
|
|
1000
|
+
return [typeof record.label === "string" ? record.label : typeof record.path === "string" ? record.path : ""];
|
|
1001
|
+
}).filter((name) => name.length > 0);
|
|
1002
|
+
return names.length > 0 ? names.join(", ") : void 0;
|
|
1003
|
+
};
|
|
1004
|
+
switch (kind) {
|
|
1005
|
+
case "session-reference": return joined("references") ?? (kind || void 0);
|
|
1006
|
+
case "agent-instructions": return joined("changes") ?? (kind || void 0);
|
|
1007
|
+
case "plugin": return typeof source.plugin === "string" && source.plugin.length > 0 ? source.plugin : kind || void 0;
|
|
1008
|
+
case "skill-invocation": return typeof source.name === "string" && source.name.length > 0 ? source.name : kind || void 0;
|
|
1009
|
+
default: return kind || void 0;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
/** Semantic ContextForm declared by the producer ('snapshot', 'notice', …);
|
|
1013
|
+
* anything unrecognized stays undefined so clients render it opaque. */
|
|
1014
|
+
function contextFormOf(source) {
|
|
1015
|
+
if (typeof source.form !== "string" || source.form.length === 0) return void 0;
|
|
1016
|
+
return [
|
|
1017
|
+
"instructions",
|
|
1018
|
+
"catalog",
|
|
1019
|
+
"snapshot",
|
|
1020
|
+
"notice",
|
|
1021
|
+
"relay",
|
|
1022
|
+
"recall"
|
|
1023
|
+
].includes(source.form) ? source.form : void 0;
|
|
1024
|
+
}
|
|
1025
|
+
/** Optional `context` metadata for one system row; {} on user rows. */
|
|
1026
|
+
function contextProjectionOf(data) {
|
|
1027
|
+
if (userRoleOf(data) !== "system") return {};
|
|
1028
|
+
const source = userMessageSource(data);
|
|
1029
|
+
if (!source) return {};
|
|
1030
|
+
const label = contextLabelOf(source);
|
|
1031
|
+
const form = contextFormOf(source);
|
|
1032
|
+
if (!label && !form) return {};
|
|
1033
|
+
return { context: {
|
|
1034
|
+
...label ? { label } : {},
|
|
1035
|
+
...form ? { form } : {}
|
|
1036
|
+
} };
|
|
1037
|
+
}
|
|
1038
|
+
/** Extract plain text from user/assistant message payloads across shapes. */
|
|
1039
|
+
function messageText(data) {
|
|
1040
|
+
if (typeof data === "string") return data;
|
|
1041
|
+
if (!data || typeof data !== "object") return "";
|
|
1042
|
+
const obj = data;
|
|
1043
|
+
if (typeof obj.text === "string") return obj.text;
|
|
1044
|
+
if (obj.message && typeof obj.message === "object") return messageText(obj.message);
|
|
1045
|
+
return contentText(obj.content);
|
|
1046
|
+
}
|
|
1047
|
+
function contentText(content) {
|
|
1048
|
+
if (typeof content === "string") return content;
|
|
1049
|
+
if (Array.isArray(content)) return content.map((part) => {
|
|
1050
|
+
if (typeof part === "string") return part;
|
|
1051
|
+
if (part && typeof part === "object") {
|
|
1052
|
+
const piece = part;
|
|
1053
|
+
if (piece.type === "text" && typeof piece.text === "string") return piece.text;
|
|
1054
|
+
}
|
|
1055
|
+
return "";
|
|
1056
|
+
}).join("");
|
|
1057
|
+
return "";
|
|
1058
|
+
}
|
|
1059
|
+
function messageAttachments(data) {
|
|
1060
|
+
if (!data || typeof data !== "object") return [];
|
|
1061
|
+
const obj = data;
|
|
1062
|
+
if (obj.message && typeof obj.message === "object") return messageAttachments(obj.message);
|
|
1063
|
+
if (!Array.isArray(obj.content)) return [];
|
|
1064
|
+
return obj.content.flatMap((part) => {
|
|
1065
|
+
if (!part || typeof part !== "object") return [];
|
|
1066
|
+
const block = part;
|
|
1067
|
+
if (block.type !== "image" || !block.attachment) return [];
|
|
1068
|
+
const attachmentId = typeof block.attachment.attachmentId === "string" && block.attachment.attachmentId.length > 0 ? block.attachment.attachmentId : void 0;
|
|
1069
|
+
const width = typeof block.attachment.width === "number" && Number.isFinite(block.attachment.width) ? block.attachment.width : void 0;
|
|
1070
|
+
const height = typeof block.attachment.height === "number" && Number.isFinite(block.attachment.height) ? block.attachment.height : void 0;
|
|
1071
|
+
return [{
|
|
1072
|
+
kind: "image",
|
|
1073
|
+
...typeof block.attachment.name === "string" ? { name: block.attachment.name } : {},
|
|
1074
|
+
...typeof block.attachment.mediaType === "string" ? { mediaType: block.attachment.mediaType } : {},
|
|
1075
|
+
...attachmentId ? { attachmentId } : {},
|
|
1076
|
+
...width !== void 0 ? { width } : {},
|
|
1077
|
+
...height !== void 0 ? { height } : {}
|
|
1078
|
+
}];
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
function attachmentProjection(data) {
|
|
1082
|
+
const attachments = messageAttachments(data);
|
|
1083
|
+
return attachments.length > 0 ? { attachments } : {};
|
|
1084
|
+
}
|
|
1085
|
+
/** Extract reasoning ("thinking") text from assistant message payloads. */
|
|
1086
|
+
function messageThinking(data) {
|
|
1087
|
+
if (!data || typeof data !== "object") return "";
|
|
1088
|
+
const obj = data;
|
|
1089
|
+
if (obj.message && typeof obj.message === "object") return messageThinking(obj.message);
|
|
1090
|
+
return reasoningContent(obj.content);
|
|
1091
|
+
}
|
|
1092
|
+
function reasoningContent(content) {
|
|
1093
|
+
if (!Array.isArray(content)) return "";
|
|
1094
|
+
return content.map((part) => {
|
|
1095
|
+
if (part && typeof part === "object") {
|
|
1096
|
+
const piece = part;
|
|
1097
|
+
if (piece.type === "reasoning" && typeof piece.text === "string") return piece.text;
|
|
1098
|
+
}
|
|
1099
|
+
return "";
|
|
1100
|
+
}).join("");
|
|
1101
|
+
}
|
|
1102
|
+
/** Stream chunk type of an assistant/chunk payload ('' when unwrapped). */
|
|
1103
|
+
function chunkTypeOf(data) {
|
|
1104
|
+
if (!data || typeof data !== "object") return "";
|
|
1105
|
+
const obj = data;
|
|
1106
|
+
if (obj.chunk && typeof obj.chunk === "object") return String(obj.chunk.type ?? "");
|
|
1107
|
+
return "text-delta";
|
|
1108
|
+
}
|
|
1109
|
+
function chunkText(data) {
|
|
1110
|
+
if (!data || typeof data !== "object") return "";
|
|
1111
|
+
const obj = data;
|
|
1112
|
+
if (obj.chunk && typeof obj.chunk === "object") {
|
|
1113
|
+
const inner = obj.chunk;
|
|
1114
|
+
if ((inner.type === "text-delta" || inner.type === "reasoning-delta") && typeof inner.text === "string") return inner.text;
|
|
1115
|
+
return "";
|
|
1116
|
+
}
|
|
1117
|
+
const direct = data;
|
|
1118
|
+
return typeof direct.text === "string" ? direct.text : "";
|
|
1119
|
+
}
|
|
1120
|
+
function summarizeArgs(raw) {
|
|
1121
|
+
if (typeof raw !== "string" || raw.length === 0) return "";
|
|
1122
|
+
try {
|
|
1123
|
+
const parsed = JSON.parse(raw);
|
|
1124
|
+
const parts = [];
|
|
1125
|
+
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") parts.push(key + "=" + truncate(value.replace(/\s+/g, " "), 60));
|
|
1126
|
+
return truncate(parts.join(" "), 90);
|
|
1127
|
+
} catch {
|
|
1128
|
+
return truncate(raw, 90);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
function truncate(text, max) {
|
|
1132
|
+
return text.length <= max ? text : text.slice(0, max - 1) + "…";
|
|
1133
|
+
}
|
|
1134
|
+
function projectHistory(events) {
|
|
1135
|
+
const messages = [];
|
|
1136
|
+
const toolByCall = /* @__PURE__ */ new Map();
|
|
1137
|
+
for (const entry of events) {
|
|
1138
|
+
const event = entry.event;
|
|
1139
|
+
const base = {
|
|
1140
|
+
seq: event.seq,
|
|
1141
|
+
ts: tsOf(event)
|
|
1142
|
+
};
|
|
1143
|
+
switch (event.type) {
|
|
1144
|
+
case "user/message":
|
|
1145
|
+
messages.push({
|
|
1146
|
+
...base,
|
|
1147
|
+
role: userRoleOf(event.data),
|
|
1148
|
+
text: messageText(event.data),
|
|
1149
|
+
...attachmentProjection(event.data),
|
|
1150
|
+
...contextProjectionOf(event.data)
|
|
1151
|
+
});
|
|
1152
|
+
break;
|
|
1153
|
+
case "assistant/message": {
|
|
1154
|
+
const text = messageText(event.data);
|
|
1155
|
+
const thinking = messageThinking(event.data);
|
|
1156
|
+
if (!text.trim() && !thinking.trim()) break;
|
|
1157
|
+
messages.push({
|
|
1158
|
+
...base,
|
|
1159
|
+
role: "assistant",
|
|
1160
|
+
text,
|
|
1161
|
+
...thinking ? { thinking } : {}
|
|
1162
|
+
});
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
1165
|
+
case "tool/call": {
|
|
1166
|
+
const data = event.data;
|
|
1167
|
+
const row = {
|
|
1168
|
+
...base,
|
|
1169
|
+
role: "tool",
|
|
1170
|
+
tool: {
|
|
1171
|
+
name: String(data?.name ?? "tool"),
|
|
1172
|
+
state: "running",
|
|
1173
|
+
summary: summarizeArgs(data?.arguments)
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
messages.push(row);
|
|
1177
|
+
if (data?.callId) toolByCall.set(String(data.callId), row);
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
1180
|
+
case "tool/result": {
|
|
1181
|
+
const data = event.data;
|
|
1182
|
+
const callId = data?.callId ? String(data.callId) : void 0;
|
|
1183
|
+
const target = callId ? toolByCall.get(callId) : void 0;
|
|
1184
|
+
const failed = data?.error !== void 0;
|
|
1185
|
+
const summary = failed ? "失败" : summarizeResult(data?.message?.content);
|
|
1186
|
+
if (target?.tool) target.tool = {
|
|
1187
|
+
...target.tool,
|
|
1188
|
+
state: failed ? "error" : "ok",
|
|
1189
|
+
summary
|
|
1190
|
+
};
|
|
1191
|
+
else messages.push({
|
|
1192
|
+
...base,
|
|
1193
|
+
role: "tool",
|
|
1194
|
+
tool: {
|
|
1195
|
+
name: "result",
|
|
1196
|
+
state: failed ? "error" : "ok",
|
|
1197
|
+
summary
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return canonicalSessionMessages(messages.map(limitMessageProjection));
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Enforce PROTOCOL.md's per-message 256 KB ceiling by UTF-8 JSON byte size.
|
|
1208
|
+
* Keep structural identity and attachment references intact; progressively
|
|
1209
|
+
* shorten human-readable fields until the serialized projection fits.
|
|
1210
|
+
*/
|
|
1211
|
+
function limitMessageProjection(message) {
|
|
1212
|
+
if (jsonBytes(message) <= 262144) return message;
|
|
1213
|
+
const next = {
|
|
1214
|
+
...message,
|
|
1215
|
+
...message.tool ? { tool: {
|
|
1216
|
+
...message.tool,
|
|
1217
|
+
name: truncateUtf8(message.tool.name, 4096),
|
|
1218
|
+
summary: truncateUtf8(message.tool.summary, 65536)
|
|
1219
|
+
} } : {},
|
|
1220
|
+
...message.attachments ? { attachments: message.attachments.slice(0, 16).map((attachment) => ({
|
|
1221
|
+
...attachment,
|
|
1222
|
+
...attachment.name ? { name: truncateUtf8(attachment.name, 4096) } : {},
|
|
1223
|
+
...attachment.mediaType ? { mediaType: truncateUtf8(attachment.mediaType, 256) } : {},
|
|
1224
|
+
...attachment.attachmentId ? { attachmentId: truncateUtf8(attachment.attachmentId, 4096) } : {}
|
|
1225
|
+
})) } : {},
|
|
1226
|
+
...message.context ? { context: {
|
|
1227
|
+
...message.context.label ? { label: truncateUtf8(message.context.label, 8192) } : {},
|
|
1228
|
+
...message.context.form ? { form: truncateUtf8(message.context.form, 256) } : {}
|
|
1229
|
+
} } : {},
|
|
1230
|
+
truncated: true
|
|
1231
|
+
};
|
|
1232
|
+
const textFields = [];
|
|
1233
|
+
if (typeof next.text === "string") textFields.push({
|
|
1234
|
+
get: () => next.text ?? "",
|
|
1235
|
+
set: (value) => {
|
|
1236
|
+
next.text = value;
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
if (typeof next.thinking === "string") textFields.push({
|
|
1240
|
+
get: () => next.thinking ?? "",
|
|
1241
|
+
set: (value) => {
|
|
1242
|
+
next.thinking = value;
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
if (next.tool) textFields.push({
|
|
1246
|
+
get: () => next.tool?.summary ?? "",
|
|
1247
|
+
set: (value) => {
|
|
1248
|
+
if (next.tool) next.tool.summary = value;
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
if (next.context?.label) textFields.push({
|
|
1252
|
+
get: () => next.context?.label ?? "",
|
|
1253
|
+
set: (value) => {
|
|
1254
|
+
if (next.context) next.context.label = value;
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
while (jsonBytes(next) > MAX_MESSAGE_PROJECTION_BYTES) {
|
|
1258
|
+
const largest = textFields.map((field) => ({
|
|
1259
|
+
field,
|
|
1260
|
+
bytes: Buffer.byteLength(field.get(), "utf8")
|
|
1261
|
+
})).sort((a, b) => b.bytes - a.bytes)[0];
|
|
1262
|
+
if (largest && largest.bytes > 0) {
|
|
1263
|
+
largest.field.set(truncateUtf8(largest.field.get(), Math.floor(largest.bytes / 2)));
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
if (next.attachments && next.attachments.length > 0) {
|
|
1267
|
+
next.attachments = next.attachments.slice(0, -1);
|
|
1268
|
+
continue;
|
|
1269
|
+
}
|
|
1270
|
+
break;
|
|
1271
|
+
}
|
|
1272
|
+
return next;
|
|
1273
|
+
}
|
|
1274
|
+
function limitRealtimeText(data) {
|
|
1275
|
+
if (jsonBytes(data) <= 262144) return data;
|
|
1276
|
+
let text = data.text;
|
|
1277
|
+
const next = {
|
|
1278
|
+
...data,
|
|
1279
|
+
truncated: true
|
|
1280
|
+
};
|
|
1281
|
+
while (jsonBytes(next) > 262144 && text.length > 0) {
|
|
1282
|
+
text = truncateUtf8(text, Math.floor(Buffer.byteLength(text, "utf8") / 2));
|
|
1283
|
+
next.text = text;
|
|
1284
|
+
}
|
|
1285
|
+
return next;
|
|
1286
|
+
}
|
|
1287
|
+
function jsonBytes(value) {
|
|
1288
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
1289
|
+
}
|
|
1290
|
+
function truncateUtf8(value, maxBytes) {
|
|
1291
|
+
if (maxBytes <= 0) return "";
|
|
1292
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
|
1293
|
+
let low = 0;
|
|
1294
|
+
let high = value.length;
|
|
1295
|
+
while (low < high) {
|
|
1296
|
+
const mid = Math.ceil((low + high) / 2);
|
|
1297
|
+
const candidate = value.slice(0, mid);
|
|
1298
|
+
if (Buffer.byteLength(candidate, "utf8") <= maxBytes) low = mid;
|
|
1299
|
+
else high = mid - 1;
|
|
1300
|
+
}
|
|
1301
|
+
let end = low;
|
|
1302
|
+
if (end > 0 && /[\uD800-\uDBFF]/.test(value[end - 1])) end -= 1;
|
|
1303
|
+
return value.slice(0, end);
|
|
1304
|
+
}
|
|
1305
|
+
function summarizeResult(content) {
|
|
1306
|
+
return truncate(contentText(content).replace(/\s+/g, " ").trim(), 90);
|
|
1307
|
+
}
|
|
1308
|
+
//#endregion
|
|
1309
|
+
//#region src/host-bridge.ts
|
|
1310
|
+
const MAX_RING_DEFAULT = 2e3;
|
|
1311
|
+
/**
|
|
1312
|
+
* Process-wide bridge state: session mirror, pending approvals/questions,
|
|
1313
|
+
* and the per-device replay ring. Consumes the in-process mux/host streams
|
|
1314
|
+
* and fans projected pushes out to every registered sink.
|
|
1315
|
+
*/
|
|
1316
|
+
let BRIDGE_SEQ = 0;
|
|
1317
|
+
var HostBridge = class {
|
|
1318
|
+
apiProxy;
|
|
1319
|
+
historyBufferMax;
|
|
1320
|
+
id = ++BRIDGE_SEQ;
|
|
1321
|
+
summaries = /* @__PURE__ */ new Map();
|
|
1322
|
+
approvals = /* @__PURE__ */ new Map();
|
|
1323
|
+
questions = /* @__PURE__ */ new Map();
|
|
1324
|
+
archivedSessionIds = /* @__PURE__ */ new Set();
|
|
1325
|
+
subagentSessionIds = /* @__PURE__ */ new Set();
|
|
1326
|
+
sinks = /* @__PURE__ */ new Set();
|
|
1327
|
+
ring = [];
|
|
1328
|
+
cursor = 0;
|
|
1329
|
+
userReceiptSeq = 0;
|
|
1330
|
+
abort = new AbortController();
|
|
1331
|
+
started = false;
|
|
1332
|
+
disposed = false;
|
|
1333
|
+
constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT) {
|
|
1334
|
+
this.apiProxy = apiProxy;
|
|
1335
|
+
this.historyBufferMax = historyBufferMax;
|
|
1336
|
+
}
|
|
1337
|
+
pushOutlet;
|
|
1338
|
+
/**
|
|
1339
|
+
* Wire the offline-push fan-out. Present ⇒ welcome advertises the `push`
|
|
1340
|
+
* capability and notify-worthy events are mirrored to APNs.
|
|
1341
|
+
*/
|
|
1342
|
+
setPushOutlet(outlet) {
|
|
1343
|
+
this.pushOutlet = outlet;
|
|
1344
|
+
}
|
|
1345
|
+
get capabilities() {
|
|
1346
|
+
return {
|
|
1347
|
+
historyPaging: true,
|
|
1348
|
+
replay: true,
|
|
1349
|
+
approvals: true,
|
|
1350
|
+
questions: true,
|
|
1351
|
+
pendingSnapshot: true,
|
|
1352
|
+
notifyAllCategories: true,
|
|
1353
|
+
models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
|
|
1354
|
+
sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
|
|
1355
|
+
projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
|
|
1356
|
+
push: this.pushOutlet?.isAvailable() === true
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
diagnostic(message) {
|
|
1360
|
+
console.log("[deeppilot] " + message);
|
|
1361
|
+
}
|
|
1362
|
+
currentCursor() {
|
|
1363
|
+
return this.cursor;
|
|
1364
|
+
}
|
|
1365
|
+
addSink(sink) {
|
|
1366
|
+
this.sinks.add(sink);
|
|
1367
|
+
}
|
|
1368
|
+
removeSink(sink) {
|
|
1369
|
+
this.sinks.delete(sink);
|
|
1370
|
+
}
|
|
1371
|
+
/** Whether the ring still holds everything after the cursor. */
|
|
1372
|
+
canResumeFrom(cursor) {
|
|
1373
|
+
const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
|
|
1374
|
+
return cursor <= this.cursor && cursor + 1 >= oldest;
|
|
1375
|
+
}
|
|
1376
|
+
sinkSessions = /* @__PURE__ */ new Map();
|
|
1377
|
+
lastAssistantText = /* @__PURE__ */ new Map();
|
|
1378
|
+
/** Mark a sink as actively viewing a session (suppresses its turn notifications). */
|
|
1379
|
+
markSinkOpen(sink, sessionId) {
|
|
1380
|
+
let set = this.sinkSessions.get(sink);
|
|
1381
|
+
if (!set) {
|
|
1382
|
+
set = /* @__PURE__ */ new Set();
|
|
1383
|
+
this.sinkSessions.set(sink, set);
|
|
1384
|
+
}
|
|
1385
|
+
set.add(sessionId);
|
|
1386
|
+
}
|
|
1387
|
+
markSinkClosed(sink, sessionId) {
|
|
1388
|
+
this.sinkSessions.get(sink)?.delete(sessionId);
|
|
1389
|
+
}
|
|
1390
|
+
dropSinkSessions(sink) {
|
|
1391
|
+
this.sinkSessions.delete(sink);
|
|
1392
|
+
}
|
|
1393
|
+
isViewedBy(sink, sessionId) {
|
|
1394
|
+
return this.sinkSessions.get(sink)?.has(sessionId) ?? false;
|
|
1395
|
+
}
|
|
1396
|
+
/** F-9: when a notification-worthy event fires, mirror it to every
|
|
1397
|
+
* online device that is not currently viewing the session (the s2c.notify
|
|
1398
|
+
* frame counts toward the seq cursor and joins the replay ring per
|
|
1399
|
+
* PROTOCOL §6 + §7), then fan the same payload out to offline devices
|
|
1400
|
+
* holding an APNs token. */
|
|
1401
|
+
emitNotify(args) {
|
|
1402
|
+
if (this.subagentSessionIds.has(args.sessionId)) return;
|
|
1403
|
+
const body = args.body.length > 120 ? args.body.slice(0, 119) + "…" : args.body;
|
|
1404
|
+
this.record("s2c.notify", {
|
|
1405
|
+
notificationId: args.notificationId,
|
|
1406
|
+
category: args.category,
|
|
1407
|
+
sessionId: args.sessionId,
|
|
1408
|
+
title: args.title,
|
|
1409
|
+
body,
|
|
1410
|
+
ts: Date.now()
|
|
1411
|
+
}, (sink) => this.isViewedBy(sink, args.sessionId));
|
|
1412
|
+
this.fanOutPush({
|
|
1413
|
+
notificationId: args.notificationId,
|
|
1414
|
+
category: args.category,
|
|
1415
|
+
sessionId: args.sessionId,
|
|
1416
|
+
title: args.title,
|
|
1417
|
+
body
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
753
1420
|
/** F-9: when a turn completes, notify every device not viewing the session. */
|
|
754
1421
|
emitTurnCompletedNotify(sessionId, ok) {
|
|
755
1422
|
if (this.subagentSessionIds.has(sessionId)) return;
|
|
756
1423
|
const row = this.summaries.get(sessionId);
|
|
757
1424
|
const title = ok ? "任务完成" : "任务异常结束";
|
|
758
1425
|
const body = this.lastAssistantText.get(sessionId) ?? row?.title ?? "";
|
|
759
|
-
|
|
760
|
-
const category = ok ? "turn.completed" : "session.error";
|
|
761
|
-
const notificationId = "n-" + (this.cursor + 1);
|
|
762
|
-
this.record("s2c.notify", {
|
|
763
|
-
notificationId,
|
|
764
|
-
category,
|
|
1426
|
+
this.emitNotify({
|
|
765
1427
|
sessionId,
|
|
1428
|
+
category: ok ? "turn.completed" : "session.error",
|
|
766
1429
|
title,
|
|
767
|
-
body
|
|
768
|
-
|
|
769
|
-
}, (sink) => this.isViewedBy(sink, sessionId));
|
|
770
|
-
this.fanOutPush({
|
|
771
|
-
notificationId,
|
|
772
|
-
category,
|
|
773
|
-
sessionId,
|
|
774
|
-
title,
|
|
775
|
-
body: truncatedBody
|
|
1430
|
+
body,
|
|
1431
|
+
notificationId: "n-" + (this.cursor + 1)
|
|
776
1432
|
});
|
|
777
1433
|
}
|
|
778
1434
|
/**
|
|
@@ -804,6 +1460,7 @@ var HostBridge = class {
|
|
|
804
1460
|
return true;
|
|
805
1461
|
}
|
|
806
1462
|
record(type, payload, except) {
|
|
1463
|
+
if (this.disposed) return;
|
|
807
1464
|
this.cursor += 1;
|
|
808
1465
|
const entry = {
|
|
809
1466
|
seq: this.cursor,
|
|
@@ -819,13 +1476,19 @@ var HostBridge = class {
|
|
|
819
1476
|
}
|
|
820
1477
|
/** Start consuming host + mux streams. Idempotent; aborts on dispose(). */
|
|
821
1478
|
start() {
|
|
1479
|
+
if (this.started || this.disposed) return;
|
|
1480
|
+
this.started = true;
|
|
822
1481
|
this.runHostStream();
|
|
823
1482
|
this.runMuxStream();
|
|
824
1483
|
this.refreshSummaries();
|
|
825
1484
|
}
|
|
826
1485
|
dispose() {
|
|
1486
|
+
if (this.disposed) return;
|
|
1487
|
+
this.disposed = true;
|
|
827
1488
|
this.abort.abort();
|
|
828
1489
|
this.sinks.clear();
|
|
1490
|
+
this.sinkSessions.clear();
|
|
1491
|
+
this.pushOutlet = void 0;
|
|
829
1492
|
}
|
|
830
1493
|
async runHostStream() {
|
|
831
1494
|
try {
|
|
@@ -898,27 +1561,28 @@ var HostBridge = class {
|
|
|
898
1561
|
if (!p.approvalId || !frame.rpcId) break;
|
|
899
1562
|
const toolName = String(p.toolName ?? "tool");
|
|
900
1563
|
const summary = String(p.reason ?? "");
|
|
1564
|
+
const sessionId = String(p.sessionId ?? "");
|
|
901
1565
|
this.approvals.set(p.approvalId, {
|
|
902
1566
|
rpcId: frame.rpcId,
|
|
903
|
-
sessionId
|
|
1567
|
+
sessionId,
|
|
904
1568
|
toolName,
|
|
905
1569
|
reason: summary
|
|
906
1570
|
});
|
|
907
1571
|
this.record("s2c.pending.approval", {
|
|
908
1572
|
requestId: p.approvalId,
|
|
909
|
-
sessionId
|
|
1573
|
+
sessionId,
|
|
910
1574
|
toolName,
|
|
911
1575
|
summary,
|
|
912
1576
|
riskLevel: riskOf(toolName)
|
|
913
1577
|
});
|
|
914
|
-
this.
|
|
915
|
-
|
|
1578
|
+
this.emitNotify({
|
|
1579
|
+
sessionId,
|
|
916
1580
|
category: "approval.required",
|
|
917
|
-
sessionId: String(p.sessionId ?? ""),
|
|
918
1581
|
title: "需要批准",
|
|
919
|
-
body: toolName + ": " + summary
|
|
1582
|
+
body: toolName + ": " + summary,
|
|
1583
|
+
notificationId: "apr-" + p.approvalId
|
|
920
1584
|
});
|
|
921
|
-
this.bumpPendingFlags(
|
|
1585
|
+
this.bumpPendingFlags(sessionId);
|
|
922
1586
|
break;
|
|
923
1587
|
}
|
|
924
1588
|
case "approval/resolved": {
|
|
@@ -945,12 +1609,12 @@ var HostBridge = class {
|
|
|
945
1609
|
sessionId,
|
|
946
1610
|
questions: p?.questions ?? []
|
|
947
1611
|
});
|
|
948
|
-
this.
|
|
949
|
-
notificationId: requestId,
|
|
950
|
-
category: "question.asked",
|
|
1612
|
+
this.emitNotify({
|
|
951
1613
|
sessionId,
|
|
1614
|
+
category: "question.asked",
|
|
952
1615
|
title: "有问题需要回答",
|
|
953
|
-
body: firstQuestionText(p?.questions)
|
|
1616
|
+
body: firstQuestionText(p?.questions),
|
|
1617
|
+
notificationId: requestId
|
|
954
1618
|
});
|
|
955
1619
|
this.bumpPendingFlags(sessionId);
|
|
956
1620
|
break;
|
|
@@ -1005,7 +1669,7 @@ var HostBridge = class {
|
|
|
1005
1669
|
this.subagentSessionIds = subagentIds;
|
|
1006
1670
|
this.summaries = next;
|
|
1007
1671
|
const removedIds = [...previousIds].filter((id) => !next.has(id));
|
|
1008
|
-
for (const id of
|
|
1672
|
+
for (const id of this.lastAssistantText.keys()) if (!next.has(id)) this.lastAssistantText.delete(id);
|
|
1009
1673
|
this.record("s2c.sessions.delta", {
|
|
1010
1674
|
upserted: [...next.values()],
|
|
1011
1675
|
removedIds
|
|
@@ -1103,13 +1767,14 @@ var HostBridge = class {
|
|
|
1103
1767
|
});
|
|
1104
1768
|
if (!response.result || !response.result.ok) return false;
|
|
1105
1769
|
const result = response.result.value;
|
|
1106
|
-
const
|
|
1770
|
+
const page = limitSessionPageMessages(projectHistory(result.events ?? []));
|
|
1771
|
+
const messages = page.messages;
|
|
1107
1772
|
const oldestSeq = messages.length > 0 ? messages[0].seq : 0;
|
|
1108
1773
|
sink.push("s2c.session.tail", {
|
|
1109
1774
|
sessionId,
|
|
1110
1775
|
messages,
|
|
1111
1776
|
oldestSeq,
|
|
1112
|
-
hasMore: Boolean(result.hasMore)
|
|
1777
|
+
hasMore: Boolean(result.hasMore) || page.dropped > 0
|
|
1113
1778
|
});
|
|
1114
1779
|
this.deriveTitleFallback(sessionId, messages);
|
|
1115
1780
|
return true;
|
|
@@ -1117,7 +1782,7 @@ var HostBridge = class {
|
|
|
1117
1782
|
return false;
|
|
1118
1783
|
}
|
|
1119
1784
|
}
|
|
1120
|
-
async historyPage(
|
|
1785
|
+
async historyPage(sessionId, beforeSeq, limit) {
|
|
1121
1786
|
try {
|
|
1122
1787
|
const response = await this.apiProxy.sessions.history({
|
|
1123
1788
|
rpcId: randomUUID(),
|
|
@@ -1127,17 +1792,16 @@ var HostBridge = class {
|
|
|
1127
1792
|
maxMessages: clampTail(limit)
|
|
1128
1793
|
}
|
|
1129
1794
|
});
|
|
1130
|
-
if (!response.result || !response.result.ok) return
|
|
1795
|
+
if (!response.result || !response.result.ok) return null;
|
|
1131
1796
|
const result = response.result.value;
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1797
|
+
const page = limitSessionPageMessages(projectHistory(result.events ?? []).filter((message) => message.seq < beforeSeq));
|
|
1798
|
+
return {
|
|
1134
1799
|
sessionId,
|
|
1135
|
-
messages,
|
|
1136
|
-
hasMore: Boolean(result.hasMore)
|
|
1137
|
-
}
|
|
1138
|
-
return true;
|
|
1800
|
+
messages: page.messages,
|
|
1801
|
+
hasMore: page.messages.length > 0 && (Boolean(result.hasMore) || page.dropped > 0)
|
|
1802
|
+
};
|
|
1139
1803
|
} catch {
|
|
1140
|
-
return
|
|
1804
|
+
return null;
|
|
1141
1805
|
}
|
|
1142
1806
|
}
|
|
1143
1807
|
/** Result of one attachment read-back for the phone. */
|
|
@@ -1501,466 +2165,217 @@ var HostBridge = class {
|
|
|
1501
2165
|
kind: "internal",
|
|
1502
2166
|
message: "prompt returned no result"
|
|
1503
2167
|
};
|
|
1504
|
-
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
1505
|
-
const row = this.summaries.get(sessionId);
|
|
1506
|
-
if (row) {
|
|
1507
|
-
row.lastActivityTs = Date.now();
|
|
1508
|
-
this.pushSummary(row);
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
ok: true,
|
|
1512
|
-
value: Date.now()
|
|
1513
|
-
};
|
|
1514
|
-
} catch (error) {
|
|
1515
|
-
return {
|
|
1516
|
-
ok: false,
|
|
1517
|
-
kind: "internal",
|
|
1518
|
-
message: String(error)
|
|
1519
|
-
};
|
|
1520
|
-
}
|
|
1521
|
-
}
|
|
1522
|
-
async respondApproval(requestId, decision, reason) {
|
|
1523
|
-
const pending = this.approvals.get(requestId);
|
|
1524
|
-
if (!pending) return {
|
|
1525
|
-
ok: false,
|
|
1526
|
-
reason: "not-pending"
|
|
1527
|
-
};
|
|
1528
|
-
this.approvals.delete(requestId);
|
|
1529
|
-
const outcome = decision === "allow" ? "allowed-once" : "rejected";
|
|
1530
|
-
const denialReason = typeof reason === "string" ? reason.trim().slice(0, 500) : "";
|
|
1531
|
-
try {
|
|
1532
|
-
const receipt = await this.apiProxy.respond({
|
|
1533
|
-
type: "client-response",
|
|
1534
|
-
rpcId: pending.rpcId,
|
|
1535
|
-
result: {
|
|
1536
|
-
ok: true,
|
|
1537
|
-
value: {
|
|
1538
|
-
sessionId: pending.sessionId,
|
|
1539
|
-
approvalId: requestId,
|
|
1540
|
-
outcome,
|
|
1541
|
-
...denialReason.length > 0 ? { reason: denialReason } : {}
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
});
|
|
1545
|
-
if (!Boolean(receipt?.accepted)) {
|
|
1546
|
-
const failure = receiptFailureReason(receipt);
|
|
1547
|
-
if (failure !== "not-pending" && !this.approvals.has(requestId)) this.approvals.set(requestId, pending);
|
|
1548
|
-
return {
|
|
1549
|
-
ok: false,
|
|
1550
|
-
reason: failure
|
|
1551
|
-
};
|
|
1552
|
-
}
|
|
1553
|
-
this.bumpPendingFlags(pending.sessionId);
|
|
1554
|
-
return { ok: true };
|
|
1555
|
-
} catch {
|
|
1556
|
-
if (!this.approvals.has(requestId)) this.approvals.set(requestId, pending);
|
|
1557
|
-
return {
|
|
1558
|
-
ok: false,
|
|
1559
|
-
reason: "transport"
|
|
1560
|
-
};
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
async respondQuestion(requestId, answers) {
|
|
1564
|
-
const pending = this.questions.get(requestId);
|
|
1565
|
-
if (!pending) return {
|
|
1566
|
-
ok: false,
|
|
1567
|
-
reason: "not-pending"
|
|
1568
|
-
};
|
|
1569
|
-
this.questions.delete(requestId);
|
|
1570
|
-
try {
|
|
1571
|
-
const receipt = await this.apiProxy.respond({
|
|
1572
|
-
type: "client-response",
|
|
1573
|
-
rpcId: pending.rpcId,
|
|
1574
|
-
result: {
|
|
1575
|
-
ok: true,
|
|
1576
|
-
value: {
|
|
1577
|
-
sessionId: pending.sessionId,
|
|
1578
|
-
answer: { answers: normalizeAnswerItems(answers, pending.questions) }
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
});
|
|
1582
|
-
if (!Boolean(receipt?.accepted)) {
|
|
1583
|
-
const failure = receiptFailureReason(receipt);
|
|
1584
|
-
if (failure !== "not-pending" && !this.questions.has(requestId)) this.questions.set(requestId, pending);
|
|
1585
|
-
return {
|
|
1586
|
-
ok: false,
|
|
1587
|
-
reason: failure
|
|
1588
|
-
};
|
|
1589
|
-
}
|
|
1590
|
-
this.bumpPendingFlags(pending.sessionId);
|
|
1591
|
-
return { ok: true };
|
|
1592
|
-
} catch {
|
|
1593
|
-
if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
|
|
1594
|
-
return {
|
|
1595
|
-
ok: false,
|
|
1596
|
-
reason: "transport"
|
|
1597
|
-
};
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
};
|
|
1601
|
-
/**
|
|
1602
|
-
* The host validates question answers strictly (core dsh-user-questions via
|
|
1603
|
-
* apiProxy): a present-but-empty `custom` fails `matchesQuestions`, and a
|
|
1604
|
-
* single-select question rejects `custom` combined with a selection. Clients
|
|
1605
|
-
* may send lenient shapes (the phone historically always attached
|
|
1606
|
-
* `"custom": ""`, which made EVERY option-only answer fail), so normalize to
|
|
1607
|
-
* exactly what the host accepts before forwarding.
|
|
1608
|
-
*/
|
|
1609
|
-
function normalizeAnswerItems(raw, questions) {
|
|
1610
|
-
if (!Array.isArray(raw)) return [];
|
|
1611
|
-
const askedById = /* @__PURE__ */ new Map();
|
|
1612
|
-
if (Array.isArray(questions)) {
|
|
1613
|
-
for (const q of questions) if (typeof q === "object" && q !== null && typeof q.id === "string") askedById.set(q.id, q);
|
|
1614
|
-
}
|
|
1615
|
-
const items = [];
|
|
1616
|
-
for (const entry of raw) {
|
|
1617
|
-
if (typeof entry !== "object" || entry === null) continue;
|
|
1618
|
-
const r = entry;
|
|
1619
|
-
if (typeof r.id !== "string") continue;
|
|
1620
|
-
const selected = [...new Set(Array.isArray(r.selected) ? r.selected.filter((s) => typeof s === "string") : [])];
|
|
1621
|
-
const customText = typeof r.custom === "string" ? r.custom : "";
|
|
1622
|
-
let custom;
|
|
1623
|
-
if (customText.trim().length > 0) custom = customText;
|
|
1624
|
-
if (custom !== void 0 && selected.length > 0 && askedById.get(r.id)?.multiSelect !== true) custom = void 0;
|
|
1625
|
-
items.push({
|
|
1626
|
-
id: r.id,
|
|
1627
|
-
selected,
|
|
1628
|
-
...custom !== void 0 ? { custom } : {}
|
|
1629
|
-
});
|
|
1630
|
-
}
|
|
1631
|
-
return items;
|
|
1632
|
-
}
|
|
1633
|
-
/** Map an apiProxy respond receipt onto the failure vocabulary. */
|
|
1634
|
-
function receiptFailureReason(receipt) {
|
|
1635
|
-
return receipt?.reason === "not-pending" ? "not-pending" : "bad-response";
|
|
1636
|
-
}
|
|
1637
|
-
function clampTail(n) {
|
|
1638
|
-
if (!Number.isFinite(n)) return 100;
|
|
1639
|
-
return Math.max(10, Math.min(500, Math.floor(n)));
|
|
1640
|
-
}
|
|
1641
|
-
function localTimeZone() {
|
|
1642
|
-
try {
|
|
1643
|
-
return new Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
|
|
1644
|
-
} catch {
|
|
1645
|
-
return;
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
function riskOf(toolName) {
|
|
1649
|
-
if (/bash|pwsh|terminal/.test(toolName)) return "write";
|
|
1650
|
-
if (/edit|write|str_replace|create/.test(toolName)) return "write";
|
|
1651
|
-
if (/delete|remove|kill/.test(toolName)) return "destructive";
|
|
1652
|
-
return "read";
|
|
1653
|
-
}
|
|
1654
|
-
/** First question's text for the push banner; the questions payload shape is
|
|
1655
|
-
* host-version dependent, so extract defensively. */
|
|
1656
|
-
function firstQuestionText(questions) {
|
|
1657
|
-
if (!Array.isArray(questions) || questions.length === 0) return "Agent 等待你的输入";
|
|
1658
|
-
const first = questions[0];
|
|
1659
|
-
return String(first?.question ?? "").trim() || "Agent 等待你的输入";
|
|
1660
|
-
}
|
|
1661
|
-
const TODO_STATUSES = /* @__PURE__ */ new Set([
|
|
1662
|
-
"pending",
|
|
1663
|
-
"in_progress",
|
|
1664
|
-
"completed"
|
|
1665
|
-
]);
|
|
1666
|
-
/** Validate a host todo projection once; progress counts and the full
|
|
1667
|
-
* checklist both derive from this sanitized list so they never disagree. */
|
|
1668
|
-
function sanitizeTodoItems(items) {
|
|
1669
|
-
if (!items) return [];
|
|
1670
|
-
return items.map((i) => ({
|
|
1671
|
-
content: String(i.content ?? "").trim(),
|
|
1672
|
-
status: String(i.status ?? "")
|
|
1673
|
-
})).filter((i) => i.content.length > 0 && TODO_STATUSES.has(i.status)).slice(0, 100).map((i) => ({
|
|
1674
|
-
content: i.content,
|
|
1675
|
-
status: i.status
|
|
1676
|
-
}));
|
|
1677
|
-
}
|
|
1678
|
-
function toSummary(row, approvals, questions, workspace) {
|
|
1679
|
-
const values = row.projections?.values ?? {};
|
|
1680
|
-
const todos = Array.isArray(values.todos) ? values.todos : null;
|
|
1681
|
-
let pendingApproval = false;
|
|
1682
|
-
for (const pending of approvals.values()) if (pending.sessionId === row.sessionId) pendingApproval = true;
|
|
1683
|
-
let pendingQuestion = false;
|
|
1684
|
-
for (const pending of questions.values()) if (pending.sessionId === row.sessionId) pendingQuestion = true;
|
|
1685
|
-
const cwd = typeof row.cwd === "string" ? row.cwd : "";
|
|
1686
|
-
const label = workspace?.title ?? (cwd ? cwd.split("/").filter(Boolean).pop() : void 0);
|
|
1687
|
-
const todoItems = sanitizeTodoItems(todos);
|
|
1688
|
-
return {
|
|
1689
|
-
id: row.sessionId,
|
|
1690
|
-
title: typeof values.title === "string" ? values.title : "",
|
|
1691
|
-
status: row.running ? "running" : row.blank ? "unknown" : "idle",
|
|
1692
|
-
lastActivityTs: Number(row.updatedAt ?? Date.now()),
|
|
1693
|
-
todos: todoItems.length > 0 ? {
|
|
1694
|
-
done: todoItems.filter((i) => i.status === "completed").length,
|
|
1695
|
-
total: todoItems.length
|
|
1696
|
-
} : null,
|
|
1697
|
-
todoItems: todoItems.length > 0 ? todoItems : null,
|
|
1698
|
-
pendingApproval,
|
|
1699
|
-
pendingQuestion,
|
|
1700
|
-
workspaceLabel: label ?? null,
|
|
1701
|
-
workspaceId: workspace?.workspaceId ?? null,
|
|
1702
|
-
workspacePath: workspace?.path ?? (cwd || null)
|
|
1703
|
-
};
|
|
1704
|
-
}
|
|
1705
|
-
function projectWorkspace(workspace) {
|
|
1706
|
-
return {
|
|
1707
|
-
id: String(workspace.workspaceId),
|
|
1708
|
-
title: String(workspace.title),
|
|
1709
|
-
path: String(workspace.path),
|
|
1710
|
-
sessionIds: (workspace.sessionIds ?? []).map(String)
|
|
1711
|
-
};
|
|
1712
|
-
}
|
|
1713
|
-
/** Project one raw session event into a protocol push, when it maps to one. */
|
|
1714
|
-
function projectEvent(sessionId, event) {
|
|
1715
|
-
switch (event.type) {
|
|
1716
|
-
case "turn/start": return {
|
|
1717
|
-
kind: "turn.start",
|
|
1718
|
-
data: {}
|
|
1719
|
-
};
|
|
1720
|
-
case "turn/end": return {
|
|
1721
|
-
kind: "turn.end",
|
|
1722
|
-
data: { ok: event.data?.reason?.kind === "completed" }
|
|
1723
|
-
};
|
|
1724
|
-
case "user/message": return {
|
|
1725
|
-
kind: "message.final",
|
|
1726
|
-
data: {
|
|
1727
|
-
seq: event.seq,
|
|
1728
|
-
role: userRoleOf(event.data),
|
|
1729
|
-
text: messageText(event.data),
|
|
1730
|
-
...attachmentProjection(event.data),
|
|
1731
|
-
...contextProjectionOf(event.data),
|
|
1732
|
-
ts: tsOf(event)
|
|
1733
|
-
}
|
|
1734
|
-
};
|
|
1735
|
-
case "assistant/chunk":
|
|
1736
|
-
if (chunkTypeOf(event.data) === "reasoning-delta") return {
|
|
1737
|
-
kind: "thinking.delta",
|
|
1738
|
-
data: {
|
|
1739
|
-
text: chunkText(event.data),
|
|
1740
|
-
ts: tsOf(event)
|
|
1741
|
-
}
|
|
1742
|
-
};
|
|
1743
|
-
return {
|
|
1744
|
-
kind: "message.delta",
|
|
1745
|
-
data: {
|
|
1746
|
-
text: chunkText(event.data),
|
|
1747
|
-
ts: tsOf(event)
|
|
1748
|
-
}
|
|
1749
|
-
};
|
|
1750
|
-
case "assistant/message": {
|
|
1751
|
-
const text = messageText(event.data);
|
|
1752
|
-
const thinking = messageThinking(event.data);
|
|
1753
|
-
if (!text.trim() && !thinking.trim()) return null;
|
|
2168
|
+
if (!response.result.ok) return hostSessionManagementError(response.result.error);
|
|
2169
|
+
const row = this.summaries.get(sessionId);
|
|
2170
|
+
if (row) {
|
|
2171
|
+
row.lastActivityTs = Date.now();
|
|
2172
|
+
this.pushSummary(row);
|
|
2173
|
+
}
|
|
2174
|
+
this.userReceiptSeq += 1;
|
|
1754
2175
|
return {
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
seq: event.seq,
|
|
1758
|
-
role: "assistant",
|
|
1759
|
-
text,
|
|
1760
|
-
...thinking ? { thinking } : {},
|
|
1761
|
-
ts: tsOf(event)
|
|
1762
|
-
}
|
|
2176
|
+
ok: true,
|
|
2177
|
+
value: this.userReceiptSeq
|
|
1763
2178
|
};
|
|
1764
|
-
}
|
|
1765
|
-
case "tool/call": {
|
|
1766
|
-
const data = event.data;
|
|
2179
|
+
} catch (error) {
|
|
1767
2180
|
return {
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
role: "tool",
|
|
1772
|
-
tool: {
|
|
1773
|
-
name: String(data?.name ?? "tool"),
|
|
1774
|
-
state: "running",
|
|
1775
|
-
summary: summarizeArgs(data?.arguments),
|
|
1776
|
-
...data?.callId ? { callId: String(data.callId) } : {}
|
|
1777
|
-
},
|
|
1778
|
-
ts: tsOf(event)
|
|
1779
|
-
}
|
|
2181
|
+
ok: false,
|
|
2182
|
+
kind: "internal",
|
|
2183
|
+
message: String(error)
|
|
1780
2184
|
};
|
|
1781
2185
|
}
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
2186
|
+
}
|
|
2187
|
+
async respondApproval(requestId, decision, reason) {
|
|
2188
|
+
const pending = this.approvals.get(requestId);
|
|
2189
|
+
if (!pending) return {
|
|
2190
|
+
ok: false,
|
|
2191
|
+
reason: "not-pending"
|
|
2192
|
+
};
|
|
2193
|
+
this.approvals.delete(requestId);
|
|
2194
|
+
const outcome = decision === "allow" ? "allowed-once" : "rejected";
|
|
2195
|
+
const denialReason = typeof reason === "string" ? reason.trim().slice(0, 500) : "";
|
|
2196
|
+
try {
|
|
2197
|
+
const receipt = await this.apiProxy.respond({
|
|
2198
|
+
type: "client-response",
|
|
2199
|
+
rpcId: pending.rpcId,
|
|
2200
|
+
result: {
|
|
2201
|
+
ok: true,
|
|
2202
|
+
value: {
|
|
2203
|
+
sessionId: pending.sessionId,
|
|
2204
|
+
approvalId: requestId,
|
|
2205
|
+
outcome,
|
|
2206
|
+
...denialReason.length > 0 ? { reason: denialReason } : {}
|
|
2207
|
+
}
|
|
1792
2208
|
}
|
|
2209
|
+
});
|
|
2210
|
+
if (!Boolean(receipt?.accepted)) {
|
|
2211
|
+
const failure = receiptFailureReason(receipt);
|
|
2212
|
+
if (failure !== "not-pending" && !this.approvals.has(requestId)) this.approvals.set(requestId, pending);
|
|
2213
|
+
return {
|
|
2214
|
+
ok: false,
|
|
2215
|
+
reason: failure
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
this.bumpPendingFlags(pending.sessionId);
|
|
2219
|
+
return { ok: true };
|
|
2220
|
+
} catch {
|
|
2221
|
+
if (!this.approvals.has(requestId)) this.approvals.set(requestId, pending);
|
|
2222
|
+
return {
|
|
2223
|
+
ok: false,
|
|
2224
|
+
reason: "transport"
|
|
1793
2225
|
};
|
|
1794
2226
|
}
|
|
1795
|
-
default: return null;
|
|
1796
|
-
}
|
|
1797
|
-
}
|
|
1798
|
-
function tsOf(event) {
|
|
1799
|
-
return typeof event.time === "number" ? event.time : Date.now();
|
|
1800
|
-
}
|
|
1801
|
-
/** Read the durable message source off one user/message payload. Handles both
|
|
1802
|
-
* bare-message payloads and older `{message: {...}}` wrappers; undefined when
|
|
1803
|
-
* the shape carries no readable source (legacy hosts). */
|
|
1804
|
-
function userMessageSource(data) {
|
|
1805
|
-
if (!data || typeof data !== "object") return void 0;
|
|
1806
|
-
const obj = data;
|
|
1807
|
-
if (obj.source && typeof obj.source === "object") return obj.source;
|
|
1808
|
-
if (obj.message && typeof obj.message === "object" && obj.message.source && typeof obj.message.source === "object") return obj.message.source;
|
|
1809
|
-
}
|
|
1810
|
-
/** Wire role for one user/message payload. A payload without any readable
|
|
1811
|
-
* source degrades to 'user' so history written by older hosts stays visible;
|
|
1812
|
-
* a present source follows the host's own trajectory rule — anything whose
|
|
1813
|
-
* `kind` is not 'user' is injected context and projects as 'system'. */
|
|
1814
|
-
function userRoleOf(data) {
|
|
1815
|
-
const source = userMessageSource(data);
|
|
1816
|
-
if (!source) return "user";
|
|
1817
|
-
return source.kind === "user" ? "user" : "system";
|
|
1818
|
-
}
|
|
1819
|
-
/** Producer name of one injected-context source, mirroring how the DSH client
|
|
1820
|
-
* runtime derives its trajectory label: plugin name, skill name, instruction
|
|
1821
|
-
* paths, session-reference labels, or the raw kind as fallback. */
|
|
1822
|
-
function contextLabelOf(source) {
|
|
1823
|
-
const kind = typeof source.kind === "string" ? source.kind : "";
|
|
1824
|
-
const joined = (member) => {
|
|
1825
|
-
const list = source[member];
|
|
1826
|
-
if (!Array.isArray(list)) return void 0;
|
|
1827
|
-
const names = list.flatMap((entry) => {
|
|
1828
|
-
if (!entry || typeof entry !== "object") return [];
|
|
1829
|
-
const record = entry;
|
|
1830
|
-
return [typeof record.label === "string" ? record.label : typeof record.path === "string" ? record.path : ""];
|
|
1831
|
-
}).filter((name) => name.length > 0);
|
|
1832
|
-
return names.length > 0 ? names.join(", ") : void 0;
|
|
1833
|
-
};
|
|
1834
|
-
switch (kind) {
|
|
1835
|
-
case "session-reference": return joined("references") ?? (kind || void 0);
|
|
1836
|
-
case "agent-instructions": return joined("changes") ?? (kind || void 0);
|
|
1837
|
-
case "plugin": return typeof source.plugin === "string" && source.plugin.length > 0 ? source.plugin : kind || void 0;
|
|
1838
|
-
case "skill-invocation": return typeof source.name === "string" && source.name.length > 0 ? source.name : kind || void 0;
|
|
1839
|
-
default: return kind || void 0;
|
|
1840
2227
|
}
|
|
2228
|
+
async respondQuestion(requestId, answers) {
|
|
2229
|
+
const pending = this.questions.get(requestId);
|
|
2230
|
+
if (!pending) return {
|
|
2231
|
+
ok: false,
|
|
2232
|
+
reason: "not-pending"
|
|
2233
|
+
};
|
|
2234
|
+
this.questions.delete(requestId);
|
|
2235
|
+
try {
|
|
2236
|
+
const receipt = await this.apiProxy.respond({
|
|
2237
|
+
type: "client-response",
|
|
2238
|
+
rpcId: pending.rpcId,
|
|
2239
|
+
result: {
|
|
2240
|
+
ok: true,
|
|
2241
|
+
value: {
|
|
2242
|
+
sessionId: pending.sessionId,
|
|
2243
|
+
answer: { answers: normalizeAnswerItems(answers, pending.questions) }
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
if (!Boolean(receipt?.accepted)) {
|
|
2248
|
+
const failure = receiptFailureReason(receipt);
|
|
2249
|
+
if (failure !== "not-pending" && !this.questions.has(requestId)) this.questions.set(requestId, pending);
|
|
2250
|
+
return {
|
|
2251
|
+
ok: false,
|
|
2252
|
+
reason: failure
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
this.bumpPendingFlags(pending.sessionId);
|
|
2256
|
+
return { ok: true };
|
|
2257
|
+
} catch {
|
|
2258
|
+
if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
|
|
2259
|
+
return {
|
|
2260
|
+
ok: false,
|
|
2261
|
+
reason: "transport"
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
};
|
|
2266
|
+
/**
|
|
2267
|
+
* The host validates question answers strictly (core dsh-user-questions via
|
|
2268
|
+
* apiProxy): a present-but-empty `custom` fails `matchesQuestions`, and a
|
|
2269
|
+
* single-select question rejects `custom` combined with a selection. Clients
|
|
2270
|
+
* may send lenient shapes (the phone historically always attached
|
|
2271
|
+
* `"custom": ""`, which made EVERY option-only answer fail), so normalize to
|
|
2272
|
+
* exactly what the host accepts before forwarding.
|
|
2273
|
+
*/
|
|
2274
|
+
function normalizeAnswerItems(raw, questions) {
|
|
2275
|
+
if (!Array.isArray(raw)) return [];
|
|
2276
|
+
const askedById = /* @__PURE__ */ new Map();
|
|
2277
|
+
if (Array.isArray(questions)) {
|
|
2278
|
+
for (const q of questions) if (typeof q === "object" && q !== null && typeof q.id === "string") askedById.set(q.id, q);
|
|
2279
|
+
}
|
|
2280
|
+
const items = [];
|
|
2281
|
+
for (const entry of raw) {
|
|
2282
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
2283
|
+
const r = entry;
|
|
2284
|
+
if (typeof r.id !== "string") continue;
|
|
2285
|
+
const selected = [...new Set(Array.isArray(r.selected) ? r.selected.filter((s) => typeof s === "string") : [])];
|
|
2286
|
+
const customText = typeof r.custom === "string" ? r.custom : "";
|
|
2287
|
+
let custom;
|
|
2288
|
+
if (customText.trim().length > 0) custom = customText;
|
|
2289
|
+
if (custom !== void 0 && selected.length > 0 && askedById.get(r.id)?.multiSelect !== true) custom = void 0;
|
|
2290
|
+
items.push({
|
|
2291
|
+
id: r.id,
|
|
2292
|
+
selected,
|
|
2293
|
+
...custom !== void 0 ? { custom } : {}
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
return items;
|
|
1841
2297
|
}
|
|
1842
|
-
/**
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
if (typeof source.form !== "string" || source.form.length === 0) return void 0;
|
|
1846
|
-
return [
|
|
1847
|
-
"instructions",
|
|
1848
|
-
"catalog",
|
|
1849
|
-
"snapshot",
|
|
1850
|
-
"notice",
|
|
1851
|
-
"relay",
|
|
1852
|
-
"recall"
|
|
1853
|
-
].includes(source.form) ? source.form : void 0;
|
|
1854
|
-
}
|
|
1855
|
-
/** Optional `context` metadata for one system row; {} on user rows. */
|
|
1856
|
-
function contextProjectionOf(data) {
|
|
1857
|
-
if (userRoleOf(data) !== "system") return {};
|
|
1858
|
-
const source = userMessageSource(data);
|
|
1859
|
-
if (!source) return {};
|
|
1860
|
-
const label = contextLabelOf(source);
|
|
1861
|
-
const form = contextFormOf(source);
|
|
1862
|
-
if (!label && !form) return {};
|
|
1863
|
-
return { context: {
|
|
1864
|
-
...label ? { label } : {},
|
|
1865
|
-
...form ? { form } : {}
|
|
1866
|
-
} };
|
|
1867
|
-
}
|
|
1868
|
-
/** Extract plain text from user/assistant message payloads across shapes. */
|
|
1869
|
-
function messageText(data) {
|
|
1870
|
-
if (typeof data === "string") return data;
|
|
1871
|
-
if (!data || typeof data !== "object") return "";
|
|
1872
|
-
const obj = data;
|
|
1873
|
-
if (typeof obj.text === "string") return obj.text;
|
|
1874
|
-
if (obj.message && typeof obj.message === "object") return messageText(obj.message);
|
|
1875
|
-
return contentText(obj.content);
|
|
1876
|
-
}
|
|
1877
|
-
function contentText(content) {
|
|
1878
|
-
if (typeof content === "string") return content;
|
|
1879
|
-
if (Array.isArray(content)) return content.map((part) => {
|
|
1880
|
-
if (typeof part === "string") return part;
|
|
1881
|
-
if (part && typeof part === "object") {
|
|
1882
|
-
const piece = part;
|
|
1883
|
-
if (piece.type === "text" && typeof piece.text === "string") return piece.text;
|
|
1884
|
-
}
|
|
1885
|
-
return "";
|
|
1886
|
-
}).join("");
|
|
1887
|
-
return "";
|
|
1888
|
-
}
|
|
1889
|
-
function messageAttachments(data) {
|
|
1890
|
-
if (!data || typeof data !== "object") return [];
|
|
1891
|
-
const obj = data;
|
|
1892
|
-
if (obj.message && typeof obj.message === "object") return messageAttachments(obj.message);
|
|
1893
|
-
if (!Array.isArray(obj.content)) return [];
|
|
1894
|
-
return obj.content.flatMap((part) => {
|
|
1895
|
-
if (!part || typeof part !== "object") return [];
|
|
1896
|
-
const block = part;
|
|
1897
|
-
if (block.type !== "image" || !block.attachment) return [];
|
|
1898
|
-
const attachmentId = typeof block.attachment.attachmentId === "string" && block.attachment.attachmentId.length > 0 ? block.attachment.attachmentId : void 0;
|
|
1899
|
-
const width = typeof block.attachment.width === "number" && Number.isFinite(block.attachment.width) ? block.attachment.width : void 0;
|
|
1900
|
-
const height = typeof block.attachment.height === "number" && Number.isFinite(block.attachment.height) ? block.attachment.height : void 0;
|
|
1901
|
-
return [{
|
|
1902
|
-
kind: "image",
|
|
1903
|
-
...typeof block.attachment.name === "string" ? { name: block.attachment.name } : {},
|
|
1904
|
-
...typeof block.attachment.mediaType === "string" ? { mediaType: block.attachment.mediaType } : {},
|
|
1905
|
-
...attachmentId ? { attachmentId } : {},
|
|
1906
|
-
...width !== void 0 ? { width } : {},
|
|
1907
|
-
...height !== void 0 ? { height } : {}
|
|
1908
|
-
}];
|
|
1909
|
-
});
|
|
1910
|
-
}
|
|
1911
|
-
function attachmentProjection(data) {
|
|
1912
|
-
const attachments = messageAttachments(data);
|
|
1913
|
-
return attachments.length > 0 ? { attachments } : {};
|
|
1914
|
-
}
|
|
1915
|
-
/** Extract reasoning ("thinking") text from assistant message payloads. */
|
|
1916
|
-
function messageThinking(data) {
|
|
1917
|
-
if (!data || typeof data !== "object") return "";
|
|
1918
|
-
const obj = data;
|
|
1919
|
-
if (obj.message && typeof obj.message === "object") return messageThinking(obj.message);
|
|
1920
|
-
return reasoningContent(obj.content);
|
|
1921
|
-
}
|
|
1922
|
-
function reasoningContent(content) {
|
|
1923
|
-
if (!Array.isArray(content)) return "";
|
|
1924
|
-
return content.map((part) => {
|
|
1925
|
-
if (part && typeof part === "object") {
|
|
1926
|
-
const piece = part;
|
|
1927
|
-
if (piece.type === "reasoning" && typeof piece.text === "string") return piece.text;
|
|
1928
|
-
}
|
|
1929
|
-
return "";
|
|
1930
|
-
}).join("");
|
|
1931
|
-
}
|
|
1932
|
-
/** Stream chunk type of an assistant/chunk payload ('' when unwrapped). */
|
|
1933
|
-
function chunkTypeOf(data) {
|
|
1934
|
-
if (!data || typeof data !== "object") return "";
|
|
1935
|
-
const obj = data;
|
|
1936
|
-
if (obj.chunk && typeof obj.chunk === "object") return String(obj.chunk.type ?? "");
|
|
1937
|
-
return "text-delta";
|
|
2298
|
+
/** Map an apiProxy respond receipt onto the failure vocabulary. */
|
|
2299
|
+
function receiptFailureReason(receipt) {
|
|
2300
|
+
return receipt?.reason === "not-pending" ? "not-pending" : "bad-response";
|
|
1938
2301
|
}
|
|
1939
|
-
function
|
|
1940
|
-
if (!
|
|
1941
|
-
|
|
1942
|
-
if (obj.chunk && typeof obj.chunk === "object") {
|
|
1943
|
-
const inner = obj.chunk;
|
|
1944
|
-
if ((inner.type === "text-delta" || inner.type === "reasoning-delta") && typeof inner.text === "string") return inner.text;
|
|
1945
|
-
return "";
|
|
1946
|
-
}
|
|
1947
|
-
const direct = data;
|
|
1948
|
-
return typeof direct.text === "string" ? direct.text : "";
|
|
2302
|
+
function clampTail(n) {
|
|
2303
|
+
if (!Number.isFinite(n)) return 100;
|
|
2304
|
+
return Math.max(10, Math.min(500, Math.floor(n)));
|
|
1949
2305
|
}
|
|
1950
|
-
function
|
|
1951
|
-
if (typeof raw !== "string" || raw.length === 0) return "";
|
|
2306
|
+
function localTimeZone() {
|
|
1952
2307
|
try {
|
|
1953
|
-
|
|
1954
|
-
const parts = [];
|
|
1955
|
-
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") parts.push(key + "=" + truncate(value.replace(/\s+/g, " "), 60));
|
|
1956
|
-
return truncate(parts.join(" "), 90);
|
|
2308
|
+
return new Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
|
|
1957
2309
|
} catch {
|
|
1958
|
-
return
|
|
2310
|
+
return;
|
|
1959
2311
|
}
|
|
1960
2312
|
}
|
|
1961
|
-
function
|
|
1962
|
-
|
|
2313
|
+
function riskOf(toolName) {
|
|
2314
|
+
if (/bash|pwsh|terminal/.test(toolName)) return "write";
|
|
2315
|
+
if (/edit|write|str_replace|create/.test(toolName)) return "write";
|
|
2316
|
+
if (/delete|remove|kill/.test(toolName)) return "destructive";
|
|
2317
|
+
return "read";
|
|
2318
|
+
}
|
|
2319
|
+
/** First question's text for the push banner; the questions payload shape is
|
|
2320
|
+
* host-version dependent, so extract defensively. */
|
|
2321
|
+
function firstQuestionText(questions) {
|
|
2322
|
+
if (!Array.isArray(questions) || questions.length === 0) return "Agent 等待你的输入";
|
|
2323
|
+
const first = questions[0];
|
|
2324
|
+
return String(first?.question ?? "").trim() || "Agent 等待你的输入";
|
|
2325
|
+
}
|
|
2326
|
+
const TODO_STATUSES = /* @__PURE__ */ new Set([
|
|
2327
|
+
"pending",
|
|
2328
|
+
"in_progress",
|
|
2329
|
+
"completed"
|
|
2330
|
+
]);
|
|
2331
|
+
/** Validate a host todo projection once; progress counts and the full
|
|
2332
|
+
* checklist both derive from this sanitized list so they never disagree. */
|
|
2333
|
+
function sanitizeTodoItems(items) {
|
|
2334
|
+
if (!items) return [];
|
|
2335
|
+
return items.map((i) => ({
|
|
2336
|
+
content: String(i.content ?? "").trim(),
|
|
2337
|
+
status: String(i.status ?? "")
|
|
2338
|
+
})).filter((i) => i.content.length > 0 && TODO_STATUSES.has(i.status)).slice(0, 100).map((i) => ({
|
|
2339
|
+
content: i.content,
|
|
2340
|
+
status: i.status
|
|
2341
|
+
}));
|
|
2342
|
+
}
|
|
2343
|
+
function toSummary(row, approvals, questions, workspace) {
|
|
2344
|
+
const values = row.projections?.values ?? {};
|
|
2345
|
+
const todos = Array.isArray(values.todos) ? values.todos : null;
|
|
2346
|
+
let pendingApproval = false;
|
|
2347
|
+
for (const pending of approvals.values()) if (pending.sessionId === row.sessionId) pendingApproval = true;
|
|
2348
|
+
let pendingQuestion = false;
|
|
2349
|
+
for (const pending of questions.values()) if (pending.sessionId === row.sessionId) pendingQuestion = true;
|
|
2350
|
+
const cwd = typeof row.cwd === "string" ? row.cwd : "";
|
|
2351
|
+
const label = workspace?.title ?? (cwd ? cwd.split("/").filter(Boolean).pop() : void 0);
|
|
2352
|
+
const todoItems = sanitizeTodoItems(todos);
|
|
2353
|
+
return {
|
|
2354
|
+
id: row.sessionId,
|
|
2355
|
+
title: typeof values.title === "string" ? values.title : "",
|
|
2356
|
+
status: row.running ? "running" : "idle",
|
|
2357
|
+
lastActivityTs: Number(row.updatedAt ?? Date.now()),
|
|
2358
|
+
todos: todoItems.length > 0 ? {
|
|
2359
|
+
done: todoItems.filter((i) => i.status === "completed").length,
|
|
2360
|
+
total: todoItems.length
|
|
2361
|
+
} : null,
|
|
2362
|
+
todoItems: todoItems.length > 0 ? todoItems : null,
|
|
2363
|
+
pendingApproval,
|
|
2364
|
+
pendingQuestion,
|
|
2365
|
+
workspaceLabel: label ?? null,
|
|
2366
|
+
workspaceId: workspace?.workspaceId ?? null,
|
|
2367
|
+
workspacePath: workspace?.path ?? (cwd || null)
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
function projectWorkspace(workspace) {
|
|
2371
|
+
return {
|
|
2372
|
+
id: String(workspace.workspaceId),
|
|
2373
|
+
title: String(workspace.title),
|
|
2374
|
+
path: String(workspace.path),
|
|
2375
|
+
sessionIds: (workspace.sessionIds ?? []).map(String)
|
|
2376
|
+
};
|
|
1963
2377
|
}
|
|
2378
|
+
/** Project one raw session event into a protocol push, when it maps to one. */
|
|
1964
2379
|
function hostModelError(error) {
|
|
1965
2380
|
const message = error.message ?? error.code;
|
|
1966
2381
|
switch (error.code) {
|
|
@@ -2061,80 +2476,364 @@ function projectSessionModels(value) {
|
|
|
2061
2476
|
};
|
|
2062
2477
|
}
|
|
2063
2478
|
/** Project a history page (raw events) into MessageProjection rows. */
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2479
|
+
//#endregion
|
|
2480
|
+
//#region src/dsh012-api-proxy.ts
|
|
2481
|
+
/**
|
|
2482
|
+
* Compatibility façade for the DSH 0.1.2 controller API.
|
|
2483
|
+
*
|
|
2484
|
+
* DeepPilot's phone protocol deliberately speaks one stable in-process
|
|
2485
|
+
* `apiProxy` vocabulary. Harness 0.1.2 removed that service in favor of
|
|
2486
|
+
* direct Session/Workspace controllers plus scoped Cordis interaction events.
|
|
2487
|
+
* This adapter rebuilds the small subset the bridge needs from those public
|
|
2488
|
+
* controllers, keeping the protocol implementation isolated from the Host API
|
|
2489
|
+
* migration. It is intentionally Host-only.
|
|
2490
|
+
*/
|
|
2491
|
+
/** Direct-controller facade with the exact legacy shape HostBridge consumes. */
|
|
2492
|
+
var Dsh012ApiProxy = class {
|
|
2493
|
+
ctx;
|
|
2494
|
+
session;
|
|
2495
|
+
workspaceController;
|
|
2496
|
+
directoryPicker;
|
|
2497
|
+
interactions = /* @__PURE__ */ new Map();
|
|
2498
|
+
constructor(ctx) {
|
|
2499
|
+
this.ctx = ctx;
|
|
2500
|
+
const session = ctx.get("sessionController");
|
|
2501
|
+
if (session === void 0) throw new Error("dsh 0.1.2 sessionController is unavailable");
|
|
2502
|
+
this.session = session;
|
|
2503
|
+
this.workspaceController = ctx.get("workspaceController");
|
|
2504
|
+
this.directoryPicker = ctx.get("directoryPickerController");
|
|
2505
|
+
}
|
|
2506
|
+
sessions = {
|
|
2507
|
+
list: async () => this.call(async () => {
|
|
2508
|
+
return { items: (await this.session.list({}, new AbortController().signal)).items.map(toPhoneSessionRow).filter((row) => !isSubagentRow(row)) };
|
|
2509
|
+
}),
|
|
2510
|
+
history: async (request) => this.call(async () => {
|
|
2511
|
+
const sessionId = request.payload.sessionId;
|
|
2512
|
+
let inspected;
|
|
2513
|
+
try {
|
|
2514
|
+
inspected = await this.session.inspect(sessionId);
|
|
2515
|
+
} catch (error) {
|
|
2516
|
+
console.warn(`[deeppilot] session history unavailable for ${JSON.stringify(sessionId)}: ${toError(error).code}: ${toError(error).message}`);
|
|
2517
|
+
throw error;
|
|
2518
|
+
}
|
|
2519
|
+
const before = request.payload?.beforeSeq;
|
|
2520
|
+
const limit = Math.max(1, request.payload?.maxMessages ?? 100);
|
|
2521
|
+
const source = inspected.events.filter((event) => typeof event === "object" && event !== null && typeof event.type === "string" && typeof event.seq === "number").filter((event) => before === void 0 || event.seq < before);
|
|
2522
|
+
let end = source.length;
|
|
2523
|
+
let events = [];
|
|
2524
|
+
while (end > 0 && projectHistory(events).length < limit) {
|
|
2525
|
+
const start = Math.max(0, end - limit);
|
|
2526
|
+
events = [...source.slice(start, end).map((event) => ({ event })), ...events];
|
|
2527
|
+
end = start;
|
|
2528
|
+
}
|
|
2529
|
+
let trimmed = false;
|
|
2530
|
+
while (events.length > 0 && projectHistory(events).length > limit) {
|
|
2531
|
+
events = events.slice(1);
|
|
2532
|
+
trimmed = true;
|
|
2533
|
+
}
|
|
2534
|
+
return {
|
|
2535
|
+
events,
|
|
2536
|
+
hasMore: end > 0 || trimmed
|
|
2537
|
+
};
|
|
2538
|
+
}),
|
|
2539
|
+
prompt: async (request) => this.call(() => this.session.prompt({
|
|
2540
|
+
...request.payload,
|
|
2541
|
+
requestId: request.rpcId ?? randomUUID()
|
|
2542
|
+
}, new AbortController().signal)),
|
|
2543
|
+
create: async (request) => this.call(() => this.session.create(request.payload ?? {})),
|
|
2544
|
+
models: async (request) => this.call(async () => projectModels(await this.session.modelCatalog(), String(request.payload?.sessionId ?? ""), await this.session.list({}, new AbortController().signal))),
|
|
2545
|
+
selectModel: async (request) => this.call(() => this.session.selectModel(request.payload ?? {})),
|
|
2546
|
+
rename: async (request) => this.call(() => this.session.rename(request.payload)),
|
|
2547
|
+
cancel: async (request) => this.call(() => this.session.cancel(request.payload)),
|
|
2548
|
+
attachment: async (request) => this.call(() => this.session.attachment(request.payload))
|
|
2549
|
+
};
|
|
2550
|
+
workspace = {
|
|
2551
|
+
list: async () => this.call(async () => {
|
|
2552
|
+
if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
|
|
2553
|
+
const baseline = await readWorkspaceBaseline(this.workspaceController);
|
|
2554
|
+
return {
|
|
2555
|
+
items: baseline.items.map(toWorkspaceView),
|
|
2556
|
+
archivedSessionIds: baseline.archivedSessionIds.map(String)
|
|
2557
|
+
};
|
|
2558
|
+
}),
|
|
2559
|
+
create: async (request) => this.call(async () => {
|
|
2560
|
+
if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
|
|
2561
|
+
const value = await this.workspaceController.create(request.payload);
|
|
2562
|
+
return {
|
|
2563
|
+
workspace: toWorkspaceView(value.workspace),
|
|
2564
|
+
created: value.created === true
|
|
2565
|
+
};
|
|
2566
|
+
}),
|
|
2567
|
+
archiveSession: async (request) => this.call(async () => {
|
|
2568
|
+
if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
|
|
2569
|
+
return { archivedSessionIds: [...(await this.workspaceController.archiveSession(request.payload)).archivedSessionIds] };
|
|
2570
|
+
})
|
|
2571
|
+
};
|
|
2572
|
+
host = {
|
|
2573
|
+
listDirectory: async (request, signal) => this.call(async () => {
|
|
2574
|
+
if (this.directoryPicker === void 0) throw unavailable("directory picker unavailable");
|
|
2575
|
+
return await this.directoryPicker.list(request.payload?.path, signal ?? new AbortController().signal);
|
|
2576
|
+
}),
|
|
2577
|
+
pickDirectory: async (_request, signal) => this.call(async () => {
|
|
2578
|
+
if (this.directoryPicker === void 0) throw unavailable("directory picker unavailable");
|
|
2579
|
+
return { path: await this.directoryPicker.pick(signal ?? new AbortController().signal) };
|
|
2580
|
+
})
|
|
2581
|
+
};
|
|
2582
|
+
events = {
|
|
2583
|
+
mux: (_request, signal) => this.mux(signal),
|
|
2584
|
+
host: (_request, signal) => this.hostEvents(signal)
|
|
2585
|
+
};
|
|
2586
|
+
async respond(message) {
|
|
2587
|
+
const pending = this.interactions.get(message.rpcId);
|
|
2588
|
+
if (pending === void 0) return {
|
|
2589
|
+
accepted: false,
|
|
2590
|
+
reason: "not-pending"
|
|
2072
2591
|
};
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2592
|
+
if (!message.result.ok) return {
|
|
2593
|
+
accepted: false,
|
|
2594
|
+
reason: "bad-response"
|
|
2595
|
+
};
|
|
2596
|
+
this.interactions.delete(message.rpcId);
|
|
2597
|
+
pending.resolve(pending.map(message.result.value));
|
|
2598
|
+
return { accepted: true };
|
|
2599
|
+
}
|
|
2600
|
+
async *mux(signal) {
|
|
2601
|
+
const queue = new AsyncFrameQueue(signal);
|
|
2602
|
+
const offEvent = this.ctx.on("session/event", ((session, event) => {
|
|
2603
|
+
queue.push({
|
|
2604
|
+
type: "session/event",
|
|
2605
|
+
sessionId: String(session.id),
|
|
2606
|
+
event
|
|
2607
|
+
});
|
|
2608
|
+
}), { global: true });
|
|
2609
|
+
const offProjection = this.ctx.get("sessionProjections")?.onChanged?.((session, key, value) => {
|
|
2610
|
+
queue.push({
|
|
2611
|
+
type: "session/projection",
|
|
2612
|
+
sessionId: String(session.id),
|
|
2613
|
+
key,
|
|
2614
|
+
value
|
|
2615
|
+
});
|
|
2616
|
+
});
|
|
2617
|
+
const offApproval = this.ctx.on("approval/request", ((request) => {
|
|
2618
|
+
const rpcId = randomUUID();
|
|
2619
|
+
const sessionId = String(request.agent?.session?.id ?? request.agent?.id ?? "");
|
|
2620
|
+
const response = deferred();
|
|
2621
|
+
const abort = () => response.resolve("cancelled");
|
|
2622
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
2623
|
+
this.interactions.set(rpcId, {
|
|
2624
|
+
resolve: response.resolve,
|
|
2625
|
+
map: (value) => {
|
|
2626
|
+
const outcome = value?.outcome;
|
|
2627
|
+
return outcome === "allowed-once" || outcome === "rejected" ? outcome : "unavailable";
|
|
2628
|
+
}
|
|
2629
|
+
});
|
|
2630
|
+
queue.push({
|
|
2631
|
+
type: "approval/requested",
|
|
2632
|
+
rpcId,
|
|
2633
|
+
sessionId,
|
|
2634
|
+
approvalId: rpcId,
|
|
2635
|
+
toolName: String(request.toolName ?? "tool"),
|
|
2636
|
+
reason: String(request.reason ?? "")
|
|
2637
|
+
});
|
|
2638
|
+
return response.promise.finally(() => {
|
|
2639
|
+
request.signal?.removeEventListener("abort", abort);
|
|
2640
|
+
this.interactions.delete(rpcId);
|
|
2641
|
+
queue.push({
|
|
2642
|
+
type: "approval/resolved",
|
|
2643
|
+
approvalId: rpcId
|
|
2081
2644
|
});
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2645
|
+
});
|
|
2646
|
+
}), { global: true });
|
|
2647
|
+
const offQuestion = this.ctx.on("user-questions/request", ((request) => {
|
|
2648
|
+
const rpcId = randomUUID();
|
|
2649
|
+
const sessionId = String(request.agent?.session?.id ?? request.agent?.id ?? "");
|
|
2650
|
+
const response = deferred();
|
|
2651
|
+
const abort = () => response.reject(/* @__PURE__ */ new Error("question cancelled"));
|
|
2652
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
2653
|
+
this.interactions.set(rpcId, {
|
|
2654
|
+
resolve: response.resolve,
|
|
2655
|
+
map: (value) => value?.answer ?? value
|
|
2656
|
+
});
|
|
2657
|
+
queue.push({
|
|
2658
|
+
type: "question/requested",
|
|
2659
|
+
rpcId,
|
|
2660
|
+
sessionId,
|
|
2661
|
+
questions: request.questions ?? []
|
|
2662
|
+
});
|
|
2663
|
+
return response.promise.finally(() => {
|
|
2664
|
+
request.signal?.removeEventListener("abort", abort);
|
|
2665
|
+
this.interactions.delete(rpcId);
|
|
2666
|
+
queue.push({
|
|
2667
|
+
type: "question/resolved",
|
|
2668
|
+
questionRpcId: rpcId
|
|
2092
2669
|
});
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
}
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
}
|
|
2670
|
+
});
|
|
2671
|
+
}), { global: true });
|
|
2672
|
+
try {
|
|
2673
|
+
yield* queue.iterate();
|
|
2674
|
+
} finally {
|
|
2675
|
+
offEvent();
|
|
2676
|
+
offProjection?.();
|
|
2677
|
+
offApproval();
|
|
2678
|
+
offQuestion();
|
|
2679
|
+
queue.close();
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
async *hostEvents(signal) {
|
|
2683
|
+
const queue = new AsyncFrameQueue(signal);
|
|
2684
|
+
const listen = (event, type, project) => this.ctx.on(event, ((...args) => queue.push({
|
|
2685
|
+
type,
|
|
2686
|
+
...project?.(...args) ?? {}
|
|
2687
|
+
})), { global: true });
|
|
2688
|
+
const off = [
|
|
2689
|
+
listen("api-session/added", "host/session-added"),
|
|
2690
|
+
listen("api-session/removed", "host/session-removed"),
|
|
2691
|
+
listen("api-session/status", "host/session-status", (sessionId, running) => ({
|
|
2692
|
+
sessionId: String(sessionId),
|
|
2693
|
+
running: running === true
|
|
2694
|
+
})),
|
|
2695
|
+
listen("api-session/activity", "host/session-added")
|
|
2696
|
+
];
|
|
2697
|
+
const workspaceAbort = new AbortController();
|
|
2698
|
+
const stop = () => workspaceAbort.abort();
|
|
2699
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
2700
|
+
this.workspaceController === void 0 || (async () => {
|
|
2701
|
+
try {
|
|
2702
|
+
for await (const frame of this.workspaceController.follow(workspaceAbort.signal)) if (frame.type === "archived") queue.push({
|
|
2703
|
+
type: "host/archived-sessions-changed",
|
|
2704
|
+
archivedSessionIds: frame.archivedSessionIds
|
|
2129
2705
|
});
|
|
2130
|
-
|
|
2706
|
+
else if (frame.type !== "baseline") queue.push({ type: "host/workspace-changed" });
|
|
2707
|
+
} catch {}
|
|
2708
|
+
})();
|
|
2709
|
+
try {
|
|
2710
|
+
yield* queue.iterate();
|
|
2711
|
+
} finally {
|
|
2712
|
+
for (const dispose of off) dispose();
|
|
2713
|
+
signal.removeEventListener("abort", stop);
|
|
2714
|
+
workspaceAbort.abort();
|
|
2715
|
+
queue.close();
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
async call(invoke) {
|
|
2719
|
+
try {
|
|
2720
|
+
return { result: {
|
|
2721
|
+
ok: true,
|
|
2722
|
+
value: await invoke()
|
|
2723
|
+
} };
|
|
2724
|
+
} catch (error) {
|
|
2725
|
+
return { result: {
|
|
2726
|
+
ok: false,
|
|
2727
|
+
error: toError(error)
|
|
2728
|
+
} };
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
};
|
|
2732
|
+
function deferred() {
|
|
2733
|
+
let resolve;
|
|
2734
|
+
let reject;
|
|
2735
|
+
return {
|
|
2736
|
+
promise: new Promise((ok, fail) => {
|
|
2737
|
+
resolve = ok;
|
|
2738
|
+
reject = fail;
|
|
2739
|
+
}),
|
|
2740
|
+
resolve,
|
|
2741
|
+
reject
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
var AsyncFrameQueue = class {
|
|
2745
|
+
frames = [];
|
|
2746
|
+
wake;
|
|
2747
|
+
closed = false;
|
|
2748
|
+
constructor(signal) {
|
|
2749
|
+
signal.addEventListener("abort", () => this.close(), { once: true });
|
|
2750
|
+
}
|
|
2751
|
+
push(frame) {
|
|
2752
|
+
if (!this.closed) {
|
|
2753
|
+
this.frames.push(frame);
|
|
2754
|
+
this.wake?.();
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
close() {
|
|
2758
|
+
if (!this.closed) {
|
|
2759
|
+
this.closed = true;
|
|
2760
|
+
this.wake?.();
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
async *iterate() {
|
|
2764
|
+
while (!this.closed) {
|
|
2765
|
+
const frame = this.frames.shift();
|
|
2766
|
+
if (frame !== void 0) {
|
|
2767
|
+
yield frame;
|
|
2768
|
+
continue;
|
|
2131
2769
|
}
|
|
2770
|
+
await new Promise((resolve) => {
|
|
2771
|
+
this.wake = resolve;
|
|
2772
|
+
});
|
|
2773
|
+
this.wake = void 0;
|
|
2132
2774
|
}
|
|
2133
2775
|
}
|
|
2134
|
-
|
|
2776
|
+
};
|
|
2777
|
+
function unavailable(message) {
|
|
2778
|
+
return Object.assign(new Error(message), { code: "directory-picker-unavailable" });
|
|
2135
2779
|
}
|
|
2136
|
-
function
|
|
2137
|
-
|
|
2780
|
+
function toError(error) {
|
|
2781
|
+
const value = error;
|
|
2782
|
+
return {
|
|
2783
|
+
code: typeof value?.code === "string" ? value.code : "internal",
|
|
2784
|
+
message: typeof value?.message === "string" ? value.message : String(error)
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
function toPhoneSessionRow(value) {
|
|
2788
|
+
const row = value;
|
|
2789
|
+
return {
|
|
2790
|
+
sessionId: String(row.sessionId ?? ""),
|
|
2791
|
+
updatedAt: Number(row.updatedAt ?? Date.now()),
|
|
2792
|
+
running: row.running === true,
|
|
2793
|
+
...row.blank === true ? { blank: true } : {},
|
|
2794
|
+
...typeof row.cwd === "string" ? { cwd: row.cwd } : {},
|
|
2795
|
+
...typeof row.origin === "string" ? { origin: row.origin } : {},
|
|
2796
|
+
...typeof row.parentSessionId === "string" ? { parentSessionId: row.parentSessionId } : {},
|
|
2797
|
+
...row.projections && typeof row.projections === "object" ? { projections: row.projections } : {}
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
function toWorkspaceView(value) {
|
|
2801
|
+
const row = value;
|
|
2802
|
+
return {
|
|
2803
|
+
workspaceId: String(row.workspaceId ?? ""),
|
|
2804
|
+
title: String(row.title ?? ""),
|
|
2805
|
+
path: String(row.path ?? ""),
|
|
2806
|
+
sessionIds: Array.isArray(row.sessionIds) ? row.sessionIds.map(String) : []
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
async function readWorkspaceBaseline(controller) {
|
|
2810
|
+
const abort = new AbortController();
|
|
2811
|
+
const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]();
|
|
2812
|
+
try {
|
|
2813
|
+
const baseline = (await iterator.next()).value;
|
|
2814
|
+
if (baseline?.type !== "baseline") throw new Error("workspace follow did not provide a baseline");
|
|
2815
|
+
return {
|
|
2816
|
+
items: (baseline.value?.items ?? []).map(toWorkspaceView),
|
|
2817
|
+
archivedSessionIds: (baseline.value?.archivedSessionIds ?? []).map(String)
|
|
2818
|
+
};
|
|
2819
|
+
} finally {
|
|
2820
|
+
abort.abort();
|
|
2821
|
+
await iterator.return?.();
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
async function projectModels(catalog, sessionId, list) {
|
|
2825
|
+
const value = catalog;
|
|
2826
|
+
const selected = (list.items.map(toPhoneSessionRow).find((item) => item.sessionId === sessionId)?.projections?.values?.modelSelection)?.next;
|
|
2827
|
+
return {
|
|
2828
|
+
current: {
|
|
2829
|
+
provider: String(selected?.provider ?? value.default?.provider ?? ""),
|
|
2830
|
+
model: String(selected?.model ?? value.default?.model ?? ""),
|
|
2831
|
+
...typeof selected?.reasoningEffort === "string" ? { reasoningEffort: selected.reasoningEffort } : {}
|
|
2832
|
+
},
|
|
2833
|
+
routable: true,
|
|
2834
|
+
groups: Array.isArray(value.groups) ? value.groups : [],
|
|
2835
|
+
failures: Array.isArray(value.failures) ? value.failures : []
|
|
2836
|
+
};
|
|
2138
2837
|
}
|
|
2139
2838
|
//#endregion
|
|
2140
2839
|
//#region src/report-service.ts
|
|
@@ -2145,26 +2844,31 @@ function summarizeResult(content) {
|
|
|
2145
2844
|
*/
|
|
2146
2845
|
var DeepPilotReportService = class extends TypertRemoteService {
|
|
2147
2846
|
snapshot;
|
|
2148
|
-
|
|
2149
|
-
|
|
2847
|
+
pairingStarter;
|
|
2848
|
+
deviceRevoker;
|
|
2849
|
+
deviceScopeUpdater;
|
|
2150
2850
|
relayTester;
|
|
2151
2851
|
pushTester;
|
|
2152
|
-
constructor(ctx, snapshot,
|
|
2852
|
+
constructor(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
|
|
2153
2853
|
super(ctx, "deeppilotReport", { namespace: "deeppilot" });
|
|
2154
2854
|
this.snapshot = snapshot;
|
|
2155
|
-
this.
|
|
2156
|
-
this.
|
|
2855
|
+
this.pairingStarter = pairingStarter;
|
|
2856
|
+
this.deviceRevoker = deviceRevoker;
|
|
2857
|
+
this.deviceScopeUpdater = deviceScopeUpdater;
|
|
2157
2858
|
this.relayTester = relayTester;
|
|
2158
2859
|
this.pushTester = pushTester;
|
|
2159
2860
|
}
|
|
2160
2861
|
async report() {
|
|
2161
2862
|
return this.snapshot();
|
|
2162
2863
|
}
|
|
2163
|
-
async
|
|
2164
|
-
return this.
|
|
2864
|
+
async beginPairing() {
|
|
2865
|
+
return this.pairingStarter();
|
|
2165
2866
|
}
|
|
2166
|
-
async
|
|
2167
|
-
return this.
|
|
2867
|
+
async revokeDevice(deviceId) {
|
|
2868
|
+
return this.deviceRevoker(deviceId);
|
|
2869
|
+
}
|
|
2870
|
+
async setDeviceScopes(deviceId, scopes) {
|
|
2871
|
+
return this.deviceScopeUpdater(deviceId, scopes);
|
|
2168
2872
|
}
|
|
2169
2873
|
async testRelay() {
|
|
2170
2874
|
return this.relayTester();
|
|
@@ -2179,14 +2883,9 @@ var DeepPilotReportService = class extends TypertRemoteService {
|
|
|
2179
2883
|
const REPORT_REMOTE_PACKAGE = "dsh-deeppilot";
|
|
2180
2884
|
/** Canonical `<namespace>/<method>` endpoint of the report Remote. */
|
|
2181
2885
|
const REPORT_ENDPOINT = "deeppilot/report";
|
|
2182
|
-
|
|
2183
|
-
const
|
|
2184
|
-
|
|
2185
|
-
* Explicit, user-triggered endpoint that replaces the pairing secret. The old
|
|
2186
|
-
* token stops working immediately; the fresh one is returned so the page can
|
|
2187
|
-
* show/QR it right away.
|
|
2188
|
-
*/
|
|
2189
|
-
const ROTATE_TOKEN_ENDPOINT = "deeppilot/rotateToken";
|
|
2886
|
+
const BEGIN_PAIRING_ENDPOINT = "deeppilot/beginPairing";
|
|
2887
|
+
const REVOKE_DEVICE_ENDPOINT = "deeppilot/revokeDevice";
|
|
2888
|
+
const SET_DEVICE_SCOPES_ENDPOINT = "deeppilot/setDeviceScopes";
|
|
2190
2889
|
function reject(field) {
|
|
2191
2890
|
throw new TypeError(`deeppilot/report result: invalid ${field}`);
|
|
2192
2891
|
}
|
|
@@ -2195,9 +2894,16 @@ function str(source, key, field) {
|
|
|
2195
2894
|
if (typeof value !== "string") reject(field);
|
|
2196
2895
|
return value;
|
|
2197
2896
|
}
|
|
2198
|
-
|
|
2897
|
+
/**
|
|
2898
|
+
* Non-negative integer: counters and timestamps (activeConnections,
|
|
2899
|
+
* historyBufferMax, updatedAt, lastSeenTs, protocolVersion, etc.). A bare
|
|
2900
|
+
* `typeof number` check accepts 1.5, -1, and 1e20 — all of which then
|
|
2901
|
+
* surface verbatim on the settings page and break any sort or arithmetic
|
|
2902
|
+
* the UI does.
|
|
2903
|
+
*/
|
|
2904
|
+
function int(source, key, field) {
|
|
2199
2905
|
const value = source[key];
|
|
2200
|
-
if (typeof value !== "number" || !Number.isFinite(value)) reject(field);
|
|
2906
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) reject(field);
|
|
2201
2907
|
return value;
|
|
2202
2908
|
}
|
|
2203
2909
|
function bool(source, key, field) {
|
|
@@ -2218,15 +2924,18 @@ function parseDevice(value) {
|
|
|
2218
2924
|
if (environment !== "development" && environment !== "production") reject("device.apns.environment");
|
|
2219
2925
|
apns = {
|
|
2220
2926
|
environment,
|
|
2221
|
-
updatedAt:
|
|
2927
|
+
updatedAt: int(a, "updatedAt", "device.apns.updatedAt")
|
|
2222
2928
|
};
|
|
2223
2929
|
}
|
|
2224
2930
|
return {
|
|
2225
2931
|
deviceId: str(s, "deviceId", "device.deviceId"),
|
|
2226
2932
|
deviceName: str(s, "deviceName", "device.deviceName"),
|
|
2227
2933
|
appVersion: str(s, "appVersion", "device.appVersion"),
|
|
2228
|
-
firstSeenTs:
|
|
2229
|
-
lastSeenTs:
|
|
2934
|
+
firstSeenTs: int(s, "firstSeenTs", "device.firstSeenTs"),
|
|
2935
|
+
lastSeenTs: int(s, "lastSeenTs", "device.lastSeenTs"),
|
|
2936
|
+
fingerprint: str(s, "fingerprint", "device.fingerprint"),
|
|
2937
|
+
scopes: normalizeDeviceScopes(s.scopes),
|
|
2938
|
+
...s.revokedAt !== void 0 ? { revokedAt: int(s, "revokedAt", "device.revokedAt") } : {},
|
|
2230
2939
|
...apns ? { apns } : {}
|
|
2231
2940
|
};
|
|
2232
2941
|
}
|
|
@@ -2256,7 +2965,7 @@ function parseRemote(value) {
|
|
|
2256
2965
|
...typeof publicURL === "string" ? { publicURL } : {},
|
|
2257
2966
|
...typeof authURL === "string" ? { authURL } : {},
|
|
2258
2967
|
...typeof message === "string" ? { message } : {},
|
|
2259
|
-
updatedAt:
|
|
2968
|
+
updatedAt: int(s, "updatedAt", "remote.updatedAt")
|
|
2260
2969
|
};
|
|
2261
2970
|
}
|
|
2262
2971
|
function parseRelayTestStep(value) {
|
|
@@ -2264,7 +2973,9 @@ function parseRelayTestStep(value) {
|
|
|
2264
2973
|
const id = str(st, "id", "step.id");
|
|
2265
2974
|
if (id !== "health" && id !== "enroll") reject("step.id");
|
|
2266
2975
|
const latencyMs = st.latencyMs;
|
|
2267
|
-
if (latencyMs !== void 0
|
|
2976
|
+
if (latencyMs !== void 0) {
|
|
2977
|
+
if (typeof latencyMs !== "number" || !Number.isFinite(latencyMs) || !Number.isInteger(latencyMs) || latencyMs < 0) reject("step.latencyMs");
|
|
2978
|
+
}
|
|
2268
2979
|
return {
|
|
2269
2980
|
id,
|
|
2270
2981
|
ok: bool(st, "ok", "step.ok"),
|
|
@@ -2307,7 +3018,7 @@ function parsePushTestResult(value) {
|
|
|
2307
3018
|
environment: str(r, "environment", "result.environment"),
|
|
2308
3019
|
outcome: str(r, "outcome", "result.outcome"),
|
|
2309
3020
|
...typeof reason === "string" && reason.length > 0 ? { reason } : {},
|
|
2310
|
-
...typeof tokenFingerprint === "string" && /^[0-9a-f]{
|
|
3021
|
+
...typeof tokenFingerprint === "string" && /^[0-9a-f]{10}$/.test(tokenFingerprint) ? { tokenFingerprint } : {}
|
|
2311
3022
|
};
|
|
2312
3023
|
});
|
|
2313
3024
|
const message = s.message;
|
|
@@ -2326,16 +3037,16 @@ function parseReport(value) {
|
|
|
2326
3037
|
if (!Array.isArray(lanAddresses) || lanAddresses.some((value) => typeof value !== "string")) reject("lanAddresses");
|
|
2327
3038
|
const releaseUrl = s.releaseUrl;
|
|
2328
3039
|
return {
|
|
2329
|
-
protocolVersion:
|
|
3040
|
+
protocolVersion: int(s, "protocolVersion", "protocolVersion"),
|
|
2330
3041
|
serverVersion: str(s, "serverVersion", "serverVersion"),
|
|
2331
3042
|
pluginVersion: str(s, "pluginVersion", "pluginVersion"),
|
|
2332
3043
|
...s.updateAvailable === true ? { updateAvailable: true } : {},
|
|
2333
3044
|
...typeof releaseUrl === "string" && releaseUrl.length > 0 ? { releaseUrl } : {},
|
|
2334
3045
|
enabled: bool(s, "enabled", "enabled"),
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
activeConnections:
|
|
2338
|
-
historyBufferMax:
|
|
3046
|
+
identityPath: str(s, "identityPath", "identityPath"),
|
|
3047
|
+
pairingReady: bool(s, "pairingReady", "pairingReady"),
|
|
3048
|
+
activeConnections: int(s, "activeConnections", "activeConnections"),
|
|
3049
|
+
historyBufferMax: int(s, "historyBufferMax", "historyBufferMax"),
|
|
2339
3050
|
debug: bool(s, "debug", "debug"),
|
|
2340
3051
|
lanAddresses,
|
|
2341
3052
|
remote: parseRemote(s.remote),
|
|
@@ -2345,14 +3056,33 @@ function parseReport(value) {
|
|
|
2345
3056
|
const reportSchema = { parse: parseReport };
|
|
2346
3057
|
const relayTestSchema = { parse: parseRelayTestResult };
|
|
2347
3058
|
const pushTestSchema = { parse: parsePushTestResult };
|
|
2348
|
-
const
|
|
2349
|
-
|
|
3059
|
+
const pairingGrantSchema = { parse(value) {
|
|
3060
|
+
const s = rec(value, "pairing grant");
|
|
3061
|
+
const code = str(s, "code", "pairingGrant.code");
|
|
3062
|
+
if (code.length < 32) reject("pairingGrant.code");
|
|
3063
|
+
return {
|
|
3064
|
+
code,
|
|
3065
|
+
expiresAt: int(s, "expiresAt", "pairingGrant.expiresAt"),
|
|
3066
|
+
audience: str(s, "audience", "pairingGrant.audience")
|
|
3067
|
+
};
|
|
3068
|
+
} };
|
|
3069
|
+
const deviceIdSchema = { parse(value) {
|
|
3070
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) reject("deviceId");
|
|
2350
3071
|
return value;
|
|
2351
3072
|
} };
|
|
3073
|
+
const scopesSchema = { parse(value) {
|
|
3074
|
+
if (!Array.isArray(value) || value.some((scope) => typeof scope !== "string" || !DEVICE_SCOPES.includes(scope))) reject("scopes");
|
|
3075
|
+
return normalizeDeviceScopes(value);
|
|
3076
|
+
} };
|
|
2352
3077
|
const REPORT_HOST_CONTRIBUTION = {
|
|
2353
3078
|
package: REPORT_REMOTE_PACKAGE,
|
|
2354
3079
|
face: "host",
|
|
2355
3080
|
schemas: [],
|
|
3081
|
+
model: {
|
|
3082
|
+
services: [],
|
|
3083
|
+
events: [],
|
|
3084
|
+
objects: []
|
|
3085
|
+
},
|
|
2356
3086
|
invocations: [
|
|
2357
3087
|
{
|
|
2358
3088
|
id: `${REPORT_REMOTE_PACKAGE}#${REPORT_ENDPOINT}`,
|
|
@@ -2368,29 +3098,72 @@ const REPORT_HOST_CONTRIBUTION = {
|
|
|
2368
3098
|
}
|
|
2369
3099
|
},
|
|
2370
3100
|
{
|
|
2371
|
-
id: `${REPORT_REMOTE_PACKAGE}#${
|
|
3101
|
+
id: `${REPORT_REMOTE_PACKAGE}#${BEGIN_PAIRING_ENDPOINT}`,
|
|
2372
3102
|
service: "deeppilotReport",
|
|
2373
3103
|
namespace: "deeppilot",
|
|
2374
|
-
method: "
|
|
3104
|
+
method: "beginPairing",
|
|
2375
3105
|
invocation: { kind: "direct" },
|
|
2376
3106
|
parameters: [],
|
|
2377
3107
|
result: {
|
|
2378
3108
|
mode: "strict",
|
|
2379
|
-
typeSymbol: `${REPORT_REMOTE_PACKAGE}#
|
|
2380
|
-
schema:
|
|
3109
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`,
|
|
3110
|
+
schema: pairingGrantSchema
|
|
2381
3111
|
}
|
|
2382
3112
|
},
|
|
2383
3113
|
{
|
|
2384
|
-
id: `${REPORT_REMOTE_PACKAGE}#${
|
|
3114
|
+
id: `${REPORT_REMOTE_PACKAGE}#${REVOKE_DEVICE_ENDPOINT}`,
|
|
2385
3115
|
service: "deeppilotReport",
|
|
2386
3116
|
namespace: "deeppilot",
|
|
2387
|
-
method: "
|
|
3117
|
+
method: "revokeDevice",
|
|
2388
3118
|
invocation: { kind: "direct" },
|
|
2389
|
-
parameters: [
|
|
3119
|
+
parameters: [{
|
|
3120
|
+
name: "deviceId",
|
|
3121
|
+
wire: "deviceId",
|
|
3122
|
+
source: "json",
|
|
3123
|
+
codec: {
|
|
3124
|
+
mode: "strict",
|
|
3125
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
|
|
3126
|
+
schema: deviceIdSchema
|
|
3127
|
+
}
|
|
3128
|
+
}],
|
|
3129
|
+
result: {
|
|
3130
|
+
mode: "strict",
|
|
3131
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#Boolean`,
|
|
3132
|
+
schema: { parse(value) {
|
|
3133
|
+
if (typeof value !== "boolean") reject("boolean");
|
|
3134
|
+
return value;
|
|
3135
|
+
} }
|
|
3136
|
+
}
|
|
3137
|
+
},
|
|
3138
|
+
{
|
|
3139
|
+
id: `${REPORT_REMOTE_PACKAGE}#${SET_DEVICE_SCOPES_ENDPOINT}`,
|
|
3140
|
+
service: "deeppilotReport",
|
|
3141
|
+
namespace: "deeppilot",
|
|
3142
|
+
method: "setDeviceScopes",
|
|
3143
|
+
invocation: { kind: "direct" },
|
|
3144
|
+
parameters: [{
|
|
3145
|
+
name: "deviceId",
|
|
3146
|
+
wire: "deviceId",
|
|
3147
|
+
source: "json",
|
|
3148
|
+
codec: {
|
|
3149
|
+
mode: "strict",
|
|
3150
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
|
|
3151
|
+
schema: deviceIdSchema
|
|
3152
|
+
}
|
|
3153
|
+
}, {
|
|
3154
|
+
name: "scopes",
|
|
3155
|
+
wire: "scopes",
|
|
3156
|
+
source: "json",
|
|
3157
|
+
codec: {
|
|
3158
|
+
mode: "strict",
|
|
3159
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
|
|
3160
|
+
schema: scopesSchema
|
|
3161
|
+
}
|
|
3162
|
+
}],
|
|
2390
3163
|
result: {
|
|
2391
3164
|
mode: "strict",
|
|
2392
|
-
typeSymbol: `${REPORT_REMOTE_PACKAGE}#
|
|
2393
|
-
schema:
|
|
3165
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
|
|
3166
|
+
schema: scopesSchema
|
|
2394
3167
|
}
|
|
2395
3168
|
},
|
|
2396
3169
|
{
|
|
@@ -2427,9 +3200,9 @@ const REPORT_HOST_CONTRIBUTION = {
|
|
|
2427
3200
|
* Provide the report service and register its Remote descriptor. Rides an
|
|
2428
3201
|
* optional `typert` inject: profiles without the web stack never activate it.
|
|
2429
3202
|
*/
|
|
2430
|
-
function applyReportRemote(ctx, snapshot,
|
|
3203
|
+
function applyReportRemote(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
|
|
2431
3204
|
ctx.inject(["typert"], (remoteCtx) => {
|
|
2432
|
-
new DeepPilotReportService(remoteCtx, snapshot,
|
|
3205
|
+
new DeepPilotReportService(remoteCtx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester);
|
|
2433
3206
|
const unregister = remoteCtx.typert.register(REPORT_HOST_CONTRIBUTION);
|
|
2434
3207
|
remoteCtx.effect(() => () => void unregister(), "dsh-deeppilot: report remote");
|
|
2435
3208
|
});
|
|
@@ -2541,10 +3314,9 @@ async function runRelayProbe(options) {
|
|
|
2541
3314
|
});
|
|
2542
3315
|
}
|
|
2543
3316
|
}
|
|
2544
|
-
const executed = steps.filter((step) => step.ok !== void 0);
|
|
2545
3317
|
return {
|
|
2546
3318
|
url: base,
|
|
2547
|
-
overall: steps.length > 0 && steps.some((step) => step.id === "health" && step.ok) &&
|
|
3319
|
+
overall: steps.length > 0 && steps.some((step) => step.id === "health" && step.ok) && steps.every((step) => step.ok) ? "ok" : "failed",
|
|
2548
3320
|
tokenIssued,
|
|
2549
3321
|
steps
|
|
2550
3322
|
};
|
|
@@ -2566,6 +3338,10 @@ async function runRelayProbe(options) {
|
|
|
2566
3338
|
* Privacy: logs carry outcomes and masked token prefixes only — never message
|
|
2567
3339
|
* bodies or full tokens.
|
|
2568
3340
|
*/
|
|
3341
|
+
/** Classify Apple's reason without losing recoverable configuration errors. */
|
|
3342
|
+
function classifyApnsReason(reason) {
|
|
3343
|
+
return reason === "Unregistered" || reason === "ExpiredToken" ? "invalid-token" : "failed";
|
|
3344
|
+
}
|
|
2569
3345
|
const PROVIDER_TOKEN_TTL_MS = 3e6;
|
|
2570
3346
|
const REQUEST_TIMEOUT_MS = 1e4;
|
|
2571
3347
|
/** base64url without padding. */
|
|
@@ -2606,7 +3382,10 @@ function apnsPayload(notification) {
|
|
|
2606
3382
|
}
|
|
2607
3383
|
/** collapse-id accepts ≤64 bytes of ASCII; keep it stable per session+event. */
|
|
2608
3384
|
function collapseIdFor(notification) {
|
|
2609
|
-
|
|
3385
|
+
const raw = `${notification.category}:${notification.sessionId}`;
|
|
3386
|
+
const readable = raw.replace(/[^a-zA-Z0-9.:-]/g, "");
|
|
3387
|
+
const digest = createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 12);
|
|
3388
|
+
return `${readable.slice(0, 51)}:${digest}`;
|
|
2610
3389
|
}
|
|
2611
3390
|
function authorityFor(environment) {
|
|
2612
3391
|
return environment === "production" ? "api.push.apple.com" : "api.sandbox.push.apple.com";
|
|
@@ -2721,7 +3500,8 @@ var ApnsClient = class {
|
|
|
2721
3500
|
reason = String(JSON.parse(responseBody).reason ?? "");
|
|
2722
3501
|
} catch {}
|
|
2723
3502
|
if (status !== 200 && !reason) reason = "HTTP " + String(status);
|
|
2724
|
-
|
|
3503
|
+
const outcome = classifyApnsReason(reason);
|
|
3504
|
+
if (outcome === "invalid-token") return settle(outcome, reason);
|
|
2725
3505
|
if (this.debug) this.log(`apns rejected status=${status} reason=${reason}`);
|
|
2726
3506
|
settle("failed", reason);
|
|
2727
3507
|
});
|
|
@@ -2840,6 +3620,9 @@ var RelayClient = class {
|
|
|
2840
3620
|
}
|
|
2841
3621
|
}
|
|
2842
3622
|
};
|
|
3623
|
+
function normalizeFunnelConnectionLimit(value) {
|
|
3624
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 16 ? value : 8;
|
|
3625
|
+
}
|
|
2843
3626
|
//#endregion
|
|
2844
3627
|
//#region src/remote-supervisor.ts
|
|
2845
3628
|
const RESTART_DELAYS_MS = [
|
|
@@ -2850,6 +3633,14 @@ const RESTART_DELAYS_MS = [
|
|
|
2850
3633
|
16e3,
|
|
2851
3634
|
3e4
|
|
2852
3635
|
];
|
|
3636
|
+
/**
|
|
3637
|
+
* Throttle for configuration-level failures (helper binary missing, state dir
|
|
3638
|
+
* unwritable). The environment will not self-heal between attempts, so the
|
|
3639
|
+
* previous "1s..30s exponential" backoff was a CPU/for-loop on a misconfigured
|
|
3640
|
+
* Host. 60s matches the APNs sender-failure throttle on the host plugin
|
|
3641
|
+
* (index.ts SENDER_FAILURE_RETRY_MS) and the relay enrollment throttle.
|
|
3642
|
+
*/
|
|
3643
|
+
const UNAVAILABLE_RETRY_MS = 6e4;
|
|
2853
3644
|
const DEFAULT_REMOTE_HOSTNAME = "dsh-deeppilot";
|
|
2854
3645
|
/** Preserve custom node names while migrating every pre-DeepPilot default. */
|
|
2855
3646
|
function normalizeRemoteHostname(value) {
|
|
@@ -2861,6 +3652,20 @@ function normalizeRemoteHostname(value) {
|
|
|
2861
3652
|
].includes(hostname.toLowerCase())) return DEFAULT_REMOTE_HOSTNAME;
|
|
2862
3653
|
return hostname;
|
|
2863
3654
|
}
|
|
3655
|
+
function tunnelHelperArguments(originURL, statePath, options) {
|
|
3656
|
+
return [
|
|
3657
|
+
"--origin",
|
|
3658
|
+
originURL,
|
|
3659
|
+
"--hostname",
|
|
3660
|
+
normalizeRemoteHostname(options.hostname),
|
|
3661
|
+
"--state-dir",
|
|
3662
|
+
statePath,
|
|
3663
|
+
"--port",
|
|
3664
|
+
String(options.funnelPort ?? 443),
|
|
3665
|
+
"--max-connections-per-source",
|
|
3666
|
+
String(normalizeFunnelConnectionLimit(options.maxConnectionsPerSource))
|
|
3667
|
+
];
|
|
3668
|
+
}
|
|
2864
3669
|
/** Parse one helper IPC line without ever evaluating or interpolating it. */
|
|
2865
3670
|
function parseHelperEvent(line) {
|
|
2866
3671
|
try {
|
|
@@ -2882,6 +3687,15 @@ function parseHelperEvent(line) {
|
|
|
2882
3687
|
return null;
|
|
2883
3688
|
}
|
|
2884
3689
|
}
|
|
3690
|
+
function isTailscaleAuthURL(value) {
|
|
3691
|
+
if (!value) return false;
|
|
3692
|
+
try {
|
|
3693
|
+
const url = new URL(value);
|
|
3694
|
+
return url.protocol === "https:" && (url.hostname === "login.tailscale.com" || url.hostname.endsWith(".login.tailscale.com"));
|
|
3695
|
+
} catch {
|
|
3696
|
+
return false;
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
2885
3699
|
/** Translate Node's platform/architecture names to the GOOS/GOARCH directory
|
|
2886
3700
|
* names used by the committed helper matrix. */
|
|
2887
3701
|
function bundledHelperPlatformDir(platform = process.platform, arch = process.arch) {
|
|
@@ -2953,6 +3767,7 @@ var RemoteSupervisor = class {
|
|
|
2953
3767
|
phase: "unavailable",
|
|
2954
3768
|
message
|
|
2955
3769
|
});
|
|
3770
|
+
this.scheduleRestart(originURL, "unavailable");
|
|
2956
3771
|
return;
|
|
2957
3772
|
}
|
|
2958
3773
|
try {
|
|
@@ -2966,6 +3781,7 @@ var RemoteSupervisor = class {
|
|
|
2966
3781
|
phase: "unavailable",
|
|
2967
3782
|
message: `cannot create remote state dir: ${String(error)}`
|
|
2968
3783
|
});
|
|
3784
|
+
this.scheduleRestart(originURL, "unavailable");
|
|
2969
3785
|
return;
|
|
2970
3786
|
}
|
|
2971
3787
|
if (this.stopping) return;
|
|
@@ -2973,16 +3789,7 @@ var RemoteSupervisor = class {
|
|
|
2973
3789
|
phase: "starting",
|
|
2974
3790
|
message: void 0
|
|
2975
3791
|
});
|
|
2976
|
-
const child = spawn(helper,
|
|
2977
|
-
"--origin",
|
|
2978
|
-
originURL,
|
|
2979
|
-
"--hostname",
|
|
2980
|
-
normalizeRemoteHostname(this.options.hostname),
|
|
2981
|
-
"--state-dir",
|
|
2982
|
-
statePath,
|
|
2983
|
-
"--port",
|
|
2984
|
-
String(this.options.funnelPort ?? 443)
|
|
2985
|
-
], {
|
|
3792
|
+
const child = spawn(helper, tunnelHelperArguments(originURL, statePath, this.options), {
|
|
2986
3793
|
stdio: [
|
|
2987
3794
|
"ignore",
|
|
2988
3795
|
"pipe",
|
|
@@ -3035,7 +3842,7 @@ var RemoteSupervisor = class {
|
|
|
3035
3842
|
phase: "error",
|
|
3036
3843
|
message: detail || `helper exited (${signal ?? String(code)})`
|
|
3037
3844
|
});
|
|
3038
|
-
this.scheduleRestart(originURL);
|
|
3845
|
+
this.scheduleRestart(originURL, "crash");
|
|
3039
3846
|
});
|
|
3040
3847
|
}
|
|
3041
3848
|
async dispose() {
|
|
@@ -3069,20 +3876,24 @@ var RemoteSupervisor = class {
|
|
|
3069
3876
|
acceptLine(line) {
|
|
3070
3877
|
const event = parseHelperEvent(line);
|
|
3071
3878
|
if (event === null || event.phase === void 0) return;
|
|
3879
|
+
if (event.phase === "login_required") {
|
|
3880
|
+
if (this.statusValue.phase === "online" || !isTailscaleAuthURL(event.authURL)) return;
|
|
3881
|
+
}
|
|
3072
3882
|
if (event.phase === "online") this.restartAttempt = 0;
|
|
3073
3883
|
this.setStatus({
|
|
3074
3884
|
...event,
|
|
3075
3885
|
phase: event.phase
|
|
3076
3886
|
});
|
|
3077
3887
|
}
|
|
3078
|
-
scheduleRestart(originURL) {
|
|
3888
|
+
scheduleRestart(originURL, kind = "crash") {
|
|
3079
3889
|
if (this.stopping || this.restartTimer !== void 0) return;
|
|
3080
|
-
const delay = RESTART_DELAYS_MS[Math.min(this.restartAttempt, RESTART_DELAYS_MS.length - 1)];
|
|
3081
|
-
this.restartAttempt += 1;
|
|
3890
|
+
const delay = kind === "unavailable" ? UNAVAILABLE_RETRY_MS : RESTART_DELAYS_MS[Math.min(this.restartAttempt, RESTART_DELAYS_MS.length - 1)];
|
|
3891
|
+
if (kind === "crash") this.restartAttempt += 1;
|
|
3082
3892
|
this.restartTimer = setTimeout(() => {
|
|
3083
3893
|
this.restartTimer = void 0;
|
|
3084
3894
|
this.start(originURL);
|
|
3085
3895
|
}, delay);
|
|
3896
|
+
this.restartTimer.unref?.();
|
|
3086
3897
|
}
|
|
3087
3898
|
setStatus(next) {
|
|
3088
3899
|
const cleared = next.phase === "online" ? {
|
|
@@ -3324,48 +4135,41 @@ var UpdateChecker = class {
|
|
|
3324
4135
|
dispose() {}
|
|
3325
4136
|
};
|
|
3326
4137
|
//#endregion
|
|
3327
|
-
//#region src/
|
|
3328
|
-
/**
|
|
3329
|
-
* dsh-deeppilot — data bridge between the DSH host and DeepPilot
|
|
3330
|
-
* clients. Registers exactly one WebSocket upgrade route (/phone) plus an
|
|
3331
|
-
* optional health probe (/phone/health) on the existing web server. The web
|
|
3332
|
-
* UI is never touched.
|
|
3333
|
-
*
|
|
3334
|
-
* Data plane: an in-process HostBridge consumes apiProxy.events.mux()/host()
|
|
3335
|
-
* streams, mirrors session summaries, tracks pending approvals/questions,
|
|
3336
|
-
* and fans projected protocol-v1 pushes out to every connected device.
|
|
3337
|
-
*
|
|
3338
|
-
* Protocol: src/protocol.ts, v1. The private app repository carries the
|
|
3339
|
-
* matching normative document and Swift models.
|
|
3340
|
-
*/
|
|
3341
|
-
const name = "deeppilot";
|
|
3342
|
-
/** No eager service requirement: profiles without a web stack simply skip. */
|
|
3343
|
-
const inject = [];
|
|
4138
|
+
//#region src/config.ts
|
|
3344
4139
|
/** Operator-run relay used by distributed builds; overridable via config. */
|
|
3345
4140
|
const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
|
|
3346
4141
|
const Config = z.object({
|
|
3347
4142
|
enabled: z.boolean().default(true),
|
|
3348
|
-
|
|
3349
|
-
devicesPath: z.string().default(join(bridgeDataDir(), "devices.json")),
|
|
4143
|
+
devicesPath: z.string().default(join(bridgeDataDir(), "devices-v2.json")),
|
|
3350
4144
|
historyBufferMax: z.natural().min(100).default(2e3),
|
|
3351
4145
|
debug: z.boolean().default(false),
|
|
3352
4146
|
remote: z.object({
|
|
3353
4147
|
enabled: z.boolean().default(false),
|
|
3354
|
-
provider: z.
|
|
4148
|
+
provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
|
|
3355
4149
|
hostname: z.string().default(DEFAULT_REMOTE_HOSTNAME),
|
|
3356
4150
|
statePath: z.string().default(join(bridgeDataDir(), "tailscale")),
|
|
3357
4151
|
helperPath: z.string().default(""),
|
|
3358
|
-
funnelPort: z.
|
|
4152
|
+
funnelPort: z.union([
|
|
4153
|
+
443,
|
|
4154
|
+
8443,
|
|
4155
|
+
1e4
|
|
4156
|
+
]).default(443),
|
|
4157
|
+
maxConnectionsPerSource: z.natural().min(1).max(16).default(8).description("Funnel 每个来源允许的并发连接数(1–16,修改后远程连接会短暂重连)")
|
|
3359
4158
|
}).default({
|
|
3360
4159
|
enabled: false,
|
|
3361
4160
|
provider: "tailscale-funnel",
|
|
3362
4161
|
hostname: DEFAULT_REMOTE_HOSTNAME,
|
|
3363
4162
|
statePath: join(bridgeDataDir(), "tailscale"),
|
|
3364
4163
|
helperPath: "",
|
|
3365
|
-
funnelPort: 443
|
|
4164
|
+
funnelPort: 443,
|
|
4165
|
+
maxConnectionsPerSource: 8
|
|
3366
4166
|
}),
|
|
3367
4167
|
push: z.object({
|
|
3368
|
-
provider: z.
|
|
4168
|
+
provider: z.union([
|
|
4169
|
+
"none",
|
|
4170
|
+
"apns",
|
|
4171
|
+
"relay"
|
|
4172
|
+
]).default("none"),
|
|
3369
4173
|
teamId: z.string().default(""),
|
|
3370
4174
|
keyId: z.string().default(""),
|
|
3371
4175
|
keyPath: z.string().default(join(bridgeDataDir(), "apns", "AuthKey.p8")),
|
|
@@ -3382,6 +4186,203 @@ const Config = z.object({
|
|
|
3382
4186
|
relayToken: ""
|
|
3383
4187
|
})
|
|
3384
4188
|
});
|
|
4189
|
+
/**
|
|
4190
|
+
* Cordis hands the second argument in different shapes depending on host
|
|
4191
|
+
* composition: a reactive options getter, the resolved config value, or
|
|
4192
|
+
* nothing when the patch row omits `config`. Normalize all of them.
|
|
4193
|
+
*/
|
|
4194
|
+
function normalizeOptions(options) {
|
|
4195
|
+
if (typeof options === "function") return options();
|
|
4196
|
+
if (options && typeof options === "object") return options;
|
|
4197
|
+
return Config(void 0) ?? {};
|
|
4198
|
+
}
|
|
4199
|
+
//#endregion
|
|
4200
|
+
//#region src/phone-http.ts
|
|
4201
|
+
const CLIENT_IP_HEADER = "x-deeppilot-client-ip";
|
|
4202
|
+
function rejectUpgrade(socket, status, reason, retryAfterSeconds) {
|
|
4203
|
+
const body = JSON.stringify({ error: reason });
|
|
4204
|
+
const statusText = {
|
|
4205
|
+
401: "Unauthorized",
|
|
4206
|
+
429: "Too Many Requests",
|
|
4207
|
+
500: "Internal Server Error",
|
|
4208
|
+
503: "Service Unavailable"
|
|
4209
|
+
};
|
|
4210
|
+
const retryAfter = status === 429 && retryAfterSeconds !== void 0 ? `Retry-After: ${Math.max(1, Math.ceil(retryAfterSeconds))}\r\n` : "";
|
|
4211
|
+
socket.end("HTTP/1.1 " + status + " " + (statusText[status] ?? "Error") + "\r\n" + retryAfter + "Content-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
|
|
4212
|
+
}
|
|
4213
|
+
function normalizedAddress(value) {
|
|
4214
|
+
if (!value) return null;
|
|
4215
|
+
const normalized = value.startsWith("::ffff:") ? value.slice(7) : value;
|
|
4216
|
+
return isIP(normalized) === 0 ? null : normalized;
|
|
4217
|
+
}
|
|
4218
|
+
function isLoopback(value) {
|
|
4219
|
+
const address = normalizedAddress(value);
|
|
4220
|
+
return address === "127.0.0.1" || address === "::1";
|
|
4221
|
+
}
|
|
4222
|
+
/**
|
|
4223
|
+
* Resolve a stable rate-limit key. The helper-supplied address is trusted only
|
|
4224
|
+
* on the private loopback hop; direct clients cannot spoof it.
|
|
4225
|
+
*/
|
|
4226
|
+
function requestClientIdentity(req) {
|
|
4227
|
+
if (isLoopback(req.socket.remoteAddress)) {
|
|
4228
|
+
const forwarded = req.headers[CLIENT_IP_HEADER];
|
|
4229
|
+
const address = normalizedAddress(Array.isArray(forwarded) ? forwarded[0] : forwarded);
|
|
4230
|
+
if (address !== null) return address;
|
|
4231
|
+
}
|
|
4232
|
+
return normalizedAddress(req.socket.remoteAddress) ?? "unknown";
|
|
4233
|
+
}
|
|
4234
|
+
//#endregion
|
|
4235
|
+
//#region src/auth-rate-limit.ts
|
|
4236
|
+
const DEFAULT_AUTH_RATE_POLICY = {
|
|
4237
|
+
windowMs: 6e4,
|
|
4238
|
+
attemptsPerSource: 12,
|
|
4239
|
+
globalAttempts: 120,
|
|
4240
|
+
maxUnauthenticatedPerSource: 2,
|
|
4241
|
+
failureWindowMs: 6e5,
|
|
4242
|
+
failuresBeforeBlock: 5,
|
|
4243
|
+
blockMs: 9e5,
|
|
4244
|
+
maxSources: 4096
|
|
4245
|
+
};
|
|
4246
|
+
const noop = () => {};
|
|
4247
|
+
/** Bounded in-memory protection for anonymous authentication attempts. */
|
|
4248
|
+
var AuthRateLimiter = class {
|
|
4249
|
+
policy;
|
|
4250
|
+
sources = /* @__PURE__ */ new Map();
|
|
4251
|
+
globalAttempts = [];
|
|
4252
|
+
constructor(policy = DEFAULT_AUTH_RATE_POLICY) {
|
|
4253
|
+
this.policy = policy;
|
|
4254
|
+
}
|
|
4255
|
+
admit(source, now = Date.now()) {
|
|
4256
|
+
const state = this.source(source, now);
|
|
4257
|
+
if (state === null) return {
|
|
4258
|
+
ok: false,
|
|
4259
|
+
retryAfterMs: this.policy.windowMs,
|
|
4260
|
+
release: noop
|
|
4261
|
+
};
|
|
4262
|
+
this.prune(state, now);
|
|
4263
|
+
state.lastSeen = now;
|
|
4264
|
+
if (state.blockedUntil > now) return {
|
|
4265
|
+
ok: false,
|
|
4266
|
+
retryAfterMs: state.blockedUntil - now,
|
|
4267
|
+
release: noop
|
|
4268
|
+
};
|
|
4269
|
+
if (state.active >= this.policy.maxUnauthenticatedPerSource) return {
|
|
4270
|
+
ok: false,
|
|
4271
|
+
retryAfterMs: this.policy.windowMs,
|
|
4272
|
+
release: noop
|
|
4273
|
+
};
|
|
4274
|
+
if (state.attempts.length >= this.policy.attemptsPerSource) return {
|
|
4275
|
+
ok: false,
|
|
4276
|
+
retryAfterMs: state.attempts[0] + this.policy.windowMs - now,
|
|
4277
|
+
release: noop
|
|
4278
|
+
};
|
|
4279
|
+
this.globalAttempts = this.globalAttempts.filter((ts) => ts > now - this.policy.windowMs);
|
|
4280
|
+
if (this.globalAttempts.length >= this.policy.globalAttempts) return {
|
|
4281
|
+
ok: false,
|
|
4282
|
+
retryAfterMs: this.globalAttempts[0] + this.policy.windowMs - now,
|
|
4283
|
+
release: noop
|
|
4284
|
+
};
|
|
4285
|
+
state.attempts.push(now);
|
|
4286
|
+
this.globalAttempts.push(now);
|
|
4287
|
+
state.active += 1;
|
|
4288
|
+
let released = false;
|
|
4289
|
+
return {
|
|
4290
|
+
ok: true,
|
|
4291
|
+
retryAfterMs: 0,
|
|
4292
|
+
release: () => {
|
|
4293
|
+
if (released) return;
|
|
4294
|
+
released = true;
|
|
4295
|
+
state.active = Math.max(0, state.active - 1);
|
|
4296
|
+
}
|
|
4297
|
+
};
|
|
4298
|
+
}
|
|
4299
|
+
recordFailure(source, now = Date.now()) {
|
|
4300
|
+
const state = this.source(source, now);
|
|
4301
|
+
if (state === null) return {
|
|
4302
|
+
blocked: true,
|
|
4303
|
+
newlyBlocked: false,
|
|
4304
|
+
retryAfterMs: this.policy.blockMs
|
|
4305
|
+
};
|
|
4306
|
+
this.prune(state, now);
|
|
4307
|
+
state.lastSeen = now;
|
|
4308
|
+
const wasBlocked = state.blockedUntil > now;
|
|
4309
|
+
state.failures.push(now);
|
|
4310
|
+
if (state.failures.length >= this.policy.failuresBeforeBlock) state.blockedUntil = Math.max(state.blockedUntil, now + this.policy.blockMs);
|
|
4311
|
+
return {
|
|
4312
|
+
blocked: state.blockedUntil > now,
|
|
4313
|
+
newlyBlocked: !wasBlocked && state.blockedUntil > now,
|
|
4314
|
+
retryAfterMs: Math.max(0, state.blockedUntil - now)
|
|
4315
|
+
};
|
|
4316
|
+
}
|
|
4317
|
+
recordSuccess(source, now = Date.now()) {
|
|
4318
|
+
const state = this.sources.get(source);
|
|
4319
|
+
if (state === void 0) return;
|
|
4320
|
+
state.failures = [];
|
|
4321
|
+
state.blockedUntil = 0;
|
|
4322
|
+
state.lastSeen = now;
|
|
4323
|
+
}
|
|
4324
|
+
source(source, now) {
|
|
4325
|
+
const existing = this.sources.get(source);
|
|
4326
|
+
if (existing !== void 0) return existing;
|
|
4327
|
+
if (this.sources.size >= this.policy.maxSources) this.pruneSources(now);
|
|
4328
|
+
if (this.sources.size >= this.policy.maxSources) return null;
|
|
4329
|
+
const state = {
|
|
4330
|
+
attempts: [],
|
|
4331
|
+
failures: [],
|
|
4332
|
+
blockedUntil: 0,
|
|
4333
|
+
active: 0,
|
|
4334
|
+
lastSeen: now
|
|
4335
|
+
};
|
|
4336
|
+
this.sources.set(source, state);
|
|
4337
|
+
return state;
|
|
4338
|
+
}
|
|
4339
|
+
prune(state, now) {
|
|
4340
|
+
state.attempts = state.attempts.filter((ts) => ts > now - this.policy.windowMs);
|
|
4341
|
+
state.failures = state.failures.filter((ts) => ts > now - this.policy.failureWindowMs);
|
|
4342
|
+
if (state.blockedUntil <= now) state.blockedUntil = 0;
|
|
4343
|
+
}
|
|
4344
|
+
pruneSources(now) {
|
|
4345
|
+
const staleBefore = now - Math.max(this.policy.failureWindowMs, this.policy.blockMs);
|
|
4346
|
+
for (const [source, state] of this.sources) {
|
|
4347
|
+
this.prune(state, now);
|
|
4348
|
+
if (state.active === 0 && state.blockedUntil === 0 && state.lastSeen < staleBefore) this.sources.delete(source);
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
};
|
|
4352
|
+
//#endregion
|
|
4353
|
+
//#region src/push-policy.ts
|
|
4354
|
+
/** Prune only when the provider supplies an authoritative token-lifecycle verdict. */
|
|
4355
|
+
function shouldPrunePushToken(outcome, reason) {
|
|
4356
|
+
return outcome === "invalid-token" && (reason === "Unregistered" || reason === "ExpiredToken");
|
|
4357
|
+
}
|
|
4358
|
+
/**
|
|
4359
|
+
* Zero-touch relay self-heal: HTTP 401 means the relay no longer honors the
|
|
4360
|
+
* cached credential. Only auto-enrolled cells with a still-current token may
|
|
4361
|
+
* re-derive it; an explicitly configured relay token remains user-owned
|
|
4362
|
+
* configuration and is never silently rewritten.
|
|
4363
|
+
*/
|
|
4364
|
+
function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
|
|
4365
|
+
return transport === "relay" && outcome === "failed" && reason === "HTTP 401" && opts.hasEnrollKey && opts.usedCellToken && opts.tokenStillCurrent;
|
|
4366
|
+
}
|
|
4367
|
+
//#endregion
|
|
4368
|
+
//#region src/index.ts
|
|
4369
|
+
/**
|
|
4370
|
+
* dsh-deeppilot — data bridge between the DSH host and DeepPilot
|
|
4371
|
+
* clients. Registers exactly one WebSocket upgrade route (/phone) plus an
|
|
4372
|
+
* optional health probe (/phone/health) on the existing web server. The web
|
|
4373
|
+
* UI is never touched.
|
|
4374
|
+
*
|
|
4375
|
+
* Data plane: an in-process HostBridge consumes a local compatibility façade
|
|
4376
|
+
* over DSH 0.1.2 Session/Workspace controllers, mirrors session summaries,
|
|
4377
|
+
* tracks pending approvals/questions, and fans projected protocol-v2 pushes
|
|
4378
|
+
* out to every connected device.
|
|
4379
|
+
*
|
|
4380
|
+
* Protocol: PROTOCOL.md is normative; src/protocol.ts and the private app's
|
|
4381
|
+
* Swift models mirror that v2 contract.
|
|
4382
|
+
*/
|
|
4383
|
+
const name = "deeppilot";
|
|
4384
|
+
/** No eager service requirement: profiles without a web stack simply skip. */
|
|
4385
|
+
const inject = [];
|
|
3385
4386
|
const SERVER_VERSION = readOwnPackageVersion();
|
|
3386
4387
|
const MAX_CLIENT_CONNECTIONS = 16;
|
|
3387
4388
|
/**
|
|
@@ -3406,38 +4407,13 @@ function readOwnPackageVersion() {
|
|
|
3406
4407
|
if (typeof envVersion === "string" && envVersion.length > 0) return envVersion;
|
|
3407
4408
|
return "0.0.0+unknown";
|
|
3408
4409
|
}
|
|
3409
|
-
function rejectUpgrade(socket, status, reason) {
|
|
3410
|
-
const body = JSON.stringify({ error: reason });
|
|
3411
|
-
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);
|
|
3412
|
-
}
|
|
3413
|
-
/** Authorization is preferred; the query form remains for older app builds. */
|
|
3414
|
-
function requestToken(req) {
|
|
3415
|
-
const authorization = req.headers.authorization;
|
|
3416
|
-
if (typeof authorization === "string") {
|
|
3417
|
-
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
3418
|
-
if (match?.[1]) return match[1];
|
|
3419
|
-
}
|
|
3420
|
-
try {
|
|
3421
|
-
return new URL(req.url ?? "/", "http://phone.local").searchParams.get("token");
|
|
3422
|
-
} catch {
|
|
3423
|
-
return null;
|
|
3424
|
-
}
|
|
3425
|
-
}
|
|
3426
|
-
/**
|
|
3427
|
-
* Cordis hands the second argument in different shapes depending on host
|
|
3428
|
-
* composition: a reactive options getter, the resolved config value, or
|
|
3429
|
-
* nothing when the patch row omits `config`. Normalize all of them.
|
|
3430
|
-
*/
|
|
3431
|
-
function normalizeOptions(options) {
|
|
3432
|
-
if (typeof options === "function") return options();
|
|
3433
|
-
if (options && typeof options === "object") return options;
|
|
3434
|
-
return Config(void 0) ?? {};
|
|
3435
|
-
}
|
|
3436
4410
|
function apply(ctx, options) {
|
|
3437
4411
|
const cfg = normalizeOptions(options);
|
|
3438
4412
|
const log = (message) => {
|
|
3439
4413
|
console.log("[deeppilot] " + message);
|
|
3440
4414
|
};
|
|
4415
|
+
const auditSalt = randomBytes(32);
|
|
4416
|
+
const auditLabel = (value) => createHash("sha256").update(auditSalt).update(value).digest("hex").slice(0, 12);
|
|
3441
4417
|
/**
|
|
3442
4418
|
* Settings-section source: while a settings service is attached this holds
|
|
3443
4419
|
* the user-edited section value; otherwise the composition defaults. Read
|
|
@@ -3461,16 +4437,22 @@ function apply(ctx, options) {
|
|
|
3461
4437
|
const dataDir = bridgeDataDir();
|
|
3462
4438
|
const pushRelayPath = join(dataDir, "push-relay.json");
|
|
3463
4439
|
const enrollmentCell = {};
|
|
4440
|
+
let enrollmentWriteTail = Promise.resolve();
|
|
3464
4441
|
function persistEnrollment() {
|
|
3465
|
-
|
|
4442
|
+
const snapshot = JSON.stringify({
|
|
4443
|
+
version: 1,
|
|
4444
|
+
...enrollmentCell
|
|
4445
|
+
}, null, 2) + "\n";
|
|
4446
|
+
enrollmentWriteTail = enrollmentWriteTail.then(async () => {
|
|
4447
|
+
const tempPath = pushRelayPath + "." + randomBytes(6).toString("hex") + ".tmp";
|
|
3466
4448
|
try {
|
|
3467
4449
|
await mkdir(dataDir, { recursive: true });
|
|
3468
|
-
await writeFile(
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
}
|
|
3473
|
-
})
|
|
4450
|
+
await writeFile(tempPath, snapshot, { mode: 384 });
|
|
4451
|
+
await rename(tempPath, pushRelayPath);
|
|
4452
|
+
} catch {
|
|
4453
|
+
await unlink(tempPath).catch(() => {});
|
|
4454
|
+
}
|
|
4455
|
+
});
|
|
3474
4456
|
}
|
|
3475
4457
|
/** Fired from BridgeConnection when an app presents its built-in key. */
|
|
3476
4458
|
const handlePushEnrollKey = async (enrollKey) => {
|
|
@@ -3483,12 +4465,12 @@ function apply(ctx, options) {
|
|
|
3483
4465
|
}
|
|
3484
4466
|
}
|
|
3485
4467
|
persistEnrollment();
|
|
3486
|
-
const url = (currentConfig().push?.relayUrl ?? "").trim() ||
|
|
4468
|
+
const url = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3487
4469
|
await ensureRelayEnrolled(url);
|
|
3488
4470
|
};
|
|
4471
|
+
const pairingCodes = new PairingCodeManager();
|
|
3489
4472
|
const auth = {
|
|
3490
|
-
|
|
3491
|
-
tokenPath: cfg.authTokenPath ?? join(dataDir, "auth-token"),
|
|
4473
|
+
audience: null,
|
|
3492
4474
|
devices: null
|
|
3493
4475
|
};
|
|
3494
4476
|
const ready = (async () => {
|
|
@@ -3499,13 +4481,13 @@ function apply(ctx, options) {
|
|
|
3499
4481
|
} catch (error) {
|
|
3500
4482
|
log("legacy plugin-state migration skipped: " + String(error));
|
|
3501
4483
|
}
|
|
3502
|
-
|
|
3503
|
-
auth.
|
|
3504
|
-
auth.devices = await DeviceStore.load(cfg.devicesPath ?? join(dataDir, "devices.json"));
|
|
4484
|
+
await ensurePrivateBridgeDataDir();
|
|
4485
|
+
auth.audience = await loadOrCreateHostAudience(join(dataDir, "host-id"));
|
|
4486
|
+
auth.devices = await DeviceStore.load(cfg.devicesPath ?? join(dataDir, "devices-v2.json"));
|
|
3505
4487
|
{
|
|
3506
4488
|
const rows = auth.devices.list();
|
|
3507
4489
|
const registered = rows.filter((row) => row.apns !== void 0).length;
|
|
3508
|
-
log(`device registry loaded from ${expandHome(cfg.devicesPath ?? join(dataDir, "devices.json"))}: ${rows.length} device(s), ${registered} push registration(s)`);
|
|
4490
|
+
log(`device registry loaded from ${expandHome(cfg.devicesPath ?? join(dataDir, "devices-v2.json"))}: ${rows.length} device(s), ${registered} push registration(s)`);
|
|
3509
4491
|
}
|
|
3510
4492
|
try {
|
|
3511
4493
|
const raw = JSON.parse(await readFile(pushRelayPath, "utf8"));
|
|
@@ -3517,44 +4499,22 @@ function apply(ctx, options) {
|
|
|
3517
4499
|
} catch (error) {
|
|
3518
4500
|
log("auth material unavailable, bridge degraded: " + String(error));
|
|
3519
4501
|
return {
|
|
3520
|
-
|
|
4502
|
+
audience: null,
|
|
3521
4503
|
devices: null
|
|
3522
4504
|
};
|
|
3523
4505
|
}
|
|
3524
4506
|
return {
|
|
3525
|
-
|
|
4507
|
+
audience: auth.audience,
|
|
3526
4508
|
devices: auth.devices
|
|
3527
4509
|
};
|
|
3528
4510
|
})();
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
* Serialized through rotateTail so two overlapping invocations can never
|
|
3537
|
-
* return a token that a later write already invalidated.
|
|
3538
|
-
*/
|
|
3539
|
-
const doRotate = async () => {
|
|
3540
|
-
const { devices } = await ready;
|
|
3541
|
-
if (auth.token === null || devices === null) throw new Error("pairing token unavailable");
|
|
3542
|
-
auth.token = await writeNewToken(auth.tokenPath);
|
|
3543
|
-
devices.clear();
|
|
3544
|
-
let dropped = 0;
|
|
3545
|
-
for (const connection of connections) {
|
|
3546
|
-
connection.terminate();
|
|
3547
|
-
dropped += 1;
|
|
3548
|
-
}
|
|
3549
|
-
connections.clear();
|
|
3550
|
-
log(`pairing token rotated; ${dropped} live phone connection(s) dropped`);
|
|
3551
|
-
return auth.token;
|
|
3552
|
-
};
|
|
3553
|
-
let rotateTail = Promise.resolve();
|
|
3554
|
-
const rotatePairingToken = () => {
|
|
3555
|
-
const next = rotateTail.then(doRotate);
|
|
3556
|
-
rotateTail = next.catch(() => {});
|
|
3557
|
-
return next;
|
|
4511
|
+
const beginPairing = async () => {
|
|
4512
|
+
await ready;
|
|
4513
|
+
if (auth.audience === null || auth.devices === null) throw new Error("device authentication unavailable");
|
|
4514
|
+
return {
|
|
4515
|
+
...pairingCodes.issue(),
|
|
4516
|
+
audience: auth.audience
|
|
4517
|
+
};
|
|
3558
4518
|
};
|
|
3559
4519
|
/**
|
|
3560
4520
|
* Settings-page push self-test: force one synthetic notification down the
|
|
@@ -3615,12 +4575,23 @@ function apply(ctx, options) {
|
|
|
3615
4575
|
};
|
|
3616
4576
|
};
|
|
3617
4577
|
const connections = /* @__PURE__ */ new Set();
|
|
4578
|
+
const closeConnectionsForBridge = (bridge) => {
|
|
4579
|
+
for (const connection of connections) {
|
|
4580
|
+
if (!connection.isAttachedTo(bridge)) continue;
|
|
4581
|
+
connection.closeForServerStop();
|
|
4582
|
+
connections.delete(connection);
|
|
4583
|
+
}
|
|
4584
|
+
};
|
|
4585
|
+
const closeAllConnections = () => {
|
|
4586
|
+
for (const connection of connections) connection.closeForServerStop();
|
|
4587
|
+
connections.clear();
|
|
4588
|
+
};
|
|
3618
4589
|
const resolvePushConfig = (config) => {
|
|
3619
4590
|
const push = config.push ?? {};
|
|
3620
4591
|
const configured = push.provider ?? "none";
|
|
3621
4592
|
const effectiveProvider = configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured;
|
|
3622
4593
|
if (effectiveProvider === "relay") {
|
|
3623
|
-
const url = (push.relayUrl ?? "").trim() ||
|
|
4594
|
+
const url = (push.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3624
4595
|
const token = (push.relayToken ?? "").trim() || enrollmentCell.token || "";
|
|
3625
4596
|
if (!/^https:\/\//i.test(url)) return {
|
|
3626
4597
|
ok: false,
|
|
@@ -3782,7 +4753,8 @@ function apply(ctx, options) {
|
|
|
3782
4753
|
* - each device is delivered on ITS registered environment (the build
|
|
3783
4754
|
* kind it self-reported), so sandbox and production devices coexist;
|
|
3784
4755
|
* - the device's per-category switches suppress muted categories;
|
|
3785
|
-
* - Unregistered/
|
|
4756
|
+
* - only APNs' terminal Unregistered/ExpiredToken verdicts prune storage;
|
|
4757
|
+
* BadDeviceToken may be an environment mismatch and stays diagnosable.
|
|
3786
4758
|
*/
|
|
3787
4759
|
const makePushOutlet = () => ({
|
|
3788
4760
|
isAvailable: () => {
|
|
@@ -3795,7 +4767,7 @@ function apply(ctx, options) {
|
|
|
3795
4767
|
(async () => {
|
|
3796
4768
|
let resolved = resolvePushConfig(currentConfig());
|
|
3797
4769
|
if (!resolved.ok && resolved.reason === "relay token not enrolled yet") {
|
|
3798
|
-
const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() ||
|
|
4770
|
+
const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3799
4771
|
await ensureRelayEnrolled(relayUrl);
|
|
3800
4772
|
resolved = resolvePushConfig(currentConfig());
|
|
3801
4773
|
}
|
|
@@ -3825,6 +4797,10 @@ function apply(ctx, options) {
|
|
|
3825
4797
|
log(`push(${transport}) ${notification.category}: no offline targets (connected=${connectedIds.size}, tokenized=${tokenized})`);
|
|
3826
4798
|
return;
|
|
3827
4799
|
}
|
|
4800
|
+
const relayUrl = resolved.value.kind === "relay" ? resolved.value.url : void 0;
|
|
4801
|
+
const relayTokenUsed = resolved.value.kind === "relay" ? resolved.value.token : void 0;
|
|
4802
|
+
const usedCellToken = relayTokenUsed !== void 0 && relayTokenUsed === enrollmentCell.token;
|
|
4803
|
+
const hasEnrollKey = Boolean(enrollmentCell.enrollKey);
|
|
3828
4804
|
for (const device of candidates) {
|
|
3829
4805
|
const registration = device.apns;
|
|
3830
4806
|
send({
|
|
@@ -3833,9 +4809,20 @@ function apply(ctx, options) {
|
|
|
3833
4809
|
notification
|
|
3834
4810
|
}).then(({ outcome, reason }) => {
|
|
3835
4811
|
log(`push(${transport}) ${notification.category} → "${device.deviceName}" [${registration.environment}] = ${outcome}${reason ? " (" + reason + ")" : ""}`);
|
|
3836
|
-
if (outcome
|
|
4812
|
+
if (shouldPrunePushToken(outcome, reason)) {
|
|
3837
4813
|
devices.clearPushToken(device.deviceId);
|
|
3838
4814
|
log(`push: pruned stale token of "${device.deviceName}" (${reason ?? "unknown"}) — app re-registers on next launch`);
|
|
4815
|
+
return;
|
|
4816
|
+
}
|
|
4817
|
+
if (relayUrl !== void 0 && shouldReEnrollRelayToken(transport, outcome, reason, {
|
|
4818
|
+
usedCellToken,
|
|
4819
|
+
hasEnrollKey,
|
|
4820
|
+
tokenStillCurrent: enrollmentCell.token === relayTokenUsed
|
|
4821
|
+
})) {
|
|
4822
|
+
enrollmentCell.token = void 0;
|
|
4823
|
+
persistEnrollment();
|
|
4824
|
+
log("push relay credential rejected (HTTP 401); re-enrolling");
|
|
4825
|
+
ensureRelayEnrolled(relayUrl);
|
|
3839
4826
|
}
|
|
3840
4827
|
}).catch(() => {});
|
|
3841
4828
|
}
|
|
@@ -3859,17 +4846,20 @@ function apply(ctx, options) {
|
|
|
3859
4846
|
updateChecker.scheduleInitial();
|
|
3860
4847
|
const updateInfo = () => updateChecker.get();
|
|
3861
4848
|
applyReportRemote(ctx, async () => {
|
|
3862
|
-
let
|
|
4849
|
+
let pairingReady = false;
|
|
3863
4850
|
let devices = [];
|
|
3864
4851
|
try {
|
|
3865
4852
|
await ready;
|
|
3866
|
-
|
|
3867
|
-
devices = (auth.devices?.list() ?? []).map(({ deviceId, deviceName, appVersion, firstSeenTs, lastSeenTs, apns }) => ({
|
|
4853
|
+
pairingReady = auth.audience !== null && auth.devices !== null;
|
|
4854
|
+
devices = (auth.devices?.list() ?? []).filter((device) => device.publicKey !== void 0 && device.fingerprint !== void 0).map(({ deviceId, deviceName, appVersion, firstSeenTs, lastSeenTs, fingerprint, scopes, revokedAt, apns }) => ({
|
|
3868
4855
|
deviceId,
|
|
3869
4856
|
deviceName,
|
|
3870
4857
|
appVersion,
|
|
3871
4858
|
firstSeenTs,
|
|
3872
4859
|
lastSeenTs,
|
|
4860
|
+
fingerprint,
|
|
4861
|
+
scopes: normalizeDeviceScopes(scopes),
|
|
4862
|
+
...revokedAt !== void 0 ? { revokedAt } : {},
|
|
3873
4863
|
...apns ? { apns: {
|
|
3874
4864
|
environment: apns.environment,
|
|
3875
4865
|
updatedAt: apns.updatedAt
|
|
@@ -3878,14 +4868,14 @@ function apply(ctx, options) {
|
|
|
3878
4868
|
} catch {}
|
|
3879
4869
|
const update = updateInfo();
|
|
3880
4870
|
return {
|
|
3881
|
-
protocolVersion:
|
|
4871
|
+
protocolVersion: 2,
|
|
3882
4872
|
serverVersion: SERVER_VERSION,
|
|
3883
4873
|
pluginVersion: update.currentVersion,
|
|
3884
4874
|
...update.available ? { updateAvailable: true } : {},
|
|
3885
4875
|
...update.releaseUrl !== null ? { releaseUrl: update.releaseUrl } : {},
|
|
3886
4876
|
enabled: currentConfig().enabled === true,
|
|
3887
|
-
|
|
3888
|
-
|
|
4877
|
+
identityPath: expandHome(currentConfig().devicesPath ?? join(bridgeDataDir(), "devices-v2.json")),
|
|
4878
|
+
pairingReady,
|
|
3889
4879
|
activeConnections: connections.size,
|
|
3890
4880
|
historyBufferMax: currentConfig().historyBufferMax ?? 2e3,
|
|
3891
4881
|
debug: currentConfig().debug === true,
|
|
@@ -3893,11 +4883,32 @@ function apply(ctx, options) {
|
|
|
3893
4883
|
remote: remoteStatus(),
|
|
3894
4884
|
devices
|
|
3895
4885
|
};
|
|
4886
|
+
}, beginPairing, async (deviceId) => {
|
|
4887
|
+
const { devices } = await ready;
|
|
4888
|
+
if (!devices) throw new Error("device registry unavailable");
|
|
4889
|
+
const revoked = devices.revoke(deviceId, Date.now());
|
|
4890
|
+
if (revoked) {
|
|
4891
|
+
for (const connection of [...connections]) {
|
|
4892
|
+
if (connection.connectedDeviceId !== deviceId) continue;
|
|
4893
|
+
connection.terminate();
|
|
4894
|
+
connections.delete(connection);
|
|
4895
|
+
}
|
|
4896
|
+
log(`device revoked id=${auditLabel(deviceId)}`);
|
|
4897
|
+
}
|
|
4898
|
+
return revoked;
|
|
4899
|
+
}, async (deviceId, scopes) => {
|
|
4900
|
+
const { devices } = await ready;
|
|
4901
|
+
if (!devices) throw new Error("device registry unavailable");
|
|
4902
|
+
const updated = devices.setScopes(deviceId, scopes);
|
|
4903
|
+
if (updated === null) throw new Error("active device not found");
|
|
4904
|
+
for (const connection of [...connections]) {
|
|
4905
|
+
if (connection.connectedDeviceId !== deviceId) continue;
|
|
4906
|
+
connection.terminate();
|
|
4907
|
+
connections.delete(connection);
|
|
4908
|
+
}
|
|
4909
|
+
log(`device scopes updated id=${auditLabel(deviceId)} scopes=${updated.join(",")}`);
|
|
4910
|
+
return updated;
|
|
3896
4911
|
}, async () => {
|
|
3897
|
-
await ready;
|
|
3898
|
-
if (auth.token === null) throw new Error("pairing token unavailable");
|
|
3899
|
-
return auth.token;
|
|
3900
|
-
}, rotatePairingToken, async () => {
|
|
3901
4912
|
const push = currentConfig().push ?? {};
|
|
3902
4913
|
const configured = push.provider ?? "none";
|
|
3903
4914
|
if ((configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured) !== "relay") return {
|
|
@@ -3910,7 +4921,7 @@ function apply(ctx, options) {
|
|
|
3910
4921
|
message: `当前推送模式不是中继(provider=${configured})。启用方式二选一:① 零配置——在 ios/project.yml 填写 DSPushEnrollKey(与服务器 RELAY_ENROLL_KEY 一致)并重新安装 App,打开 App 即自动启用;② 手动——将 push.provider 设为 relay 并填入 relayToken`
|
|
3911
4922
|
}]
|
|
3912
4923
|
};
|
|
3913
|
-
const url = (push.relayUrl ?? "").trim() ||
|
|
4924
|
+
const url = (push.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3914
4925
|
if (!/^https:\/\//i.test(url)) return {
|
|
3915
4926
|
url,
|
|
3916
4927
|
overall: "failed",
|
|
@@ -3940,6 +4951,103 @@ function apply(ctx, options) {
|
|
|
3940
4951
|
return await runPushSelfTest();
|
|
3941
4952
|
});
|
|
3942
4953
|
const state = {};
|
|
4954
|
+
let pendingUpgrades = 0;
|
|
4955
|
+
const authRateLimiter = new AuthRateLimiter();
|
|
4956
|
+
const readJSONBody = async (req, maxBytes = 16384) => {
|
|
4957
|
+
const chunks = [];
|
|
4958
|
+
let size = 0;
|
|
4959
|
+
for await (const chunk of req) {
|
|
4960
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
4961
|
+
size += buffer.length;
|
|
4962
|
+
if (size > maxBytes) throw new Error("request body too large");
|
|
4963
|
+
chunks.push(buffer);
|
|
4964
|
+
}
|
|
4965
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
4966
|
+
};
|
|
4967
|
+
const handlePair = async (req, res) => {
|
|
4968
|
+
res.setHeader("Content-Type", "application/json");
|
|
4969
|
+
if (!enabledNow()) {
|
|
4970
|
+
res.statusCode = 503;
|
|
4971
|
+
res.end(JSON.stringify({
|
|
4972
|
+
ok: false,
|
|
4973
|
+
error: "bridge disabled"
|
|
4974
|
+
}));
|
|
4975
|
+
return;
|
|
4976
|
+
}
|
|
4977
|
+
if (req.method !== "POST") {
|
|
4978
|
+
res.statusCode = 405;
|
|
4979
|
+
res.setHeader("Allow", "POST");
|
|
4980
|
+
res.end(JSON.stringify({
|
|
4981
|
+
ok: false,
|
|
4982
|
+
error: "POST required"
|
|
4983
|
+
}));
|
|
4984
|
+
return;
|
|
4985
|
+
}
|
|
4986
|
+
const source = requestClientIdentity(req);
|
|
4987
|
+
const admission = authRateLimiter.admit(source);
|
|
4988
|
+
if (!admission.ok) {
|
|
4989
|
+
res.statusCode = 429;
|
|
4990
|
+
res.setHeader("Retry-After", String(Math.max(1, Math.ceil(admission.retryAfterMs / 1e3))));
|
|
4991
|
+
res.end(JSON.stringify({
|
|
4992
|
+
ok: false,
|
|
4993
|
+
error: "pairing rate limited"
|
|
4994
|
+
}));
|
|
4995
|
+
return;
|
|
4996
|
+
}
|
|
4997
|
+
try {
|
|
4998
|
+
const { devices, audience } = await ready;
|
|
4999
|
+
if (!devices || !audience) throw new Error("device authentication unavailable");
|
|
5000
|
+
const raw = await readJSONBody(req);
|
|
5001
|
+
if (raw === null || typeof raw !== "object" || raw.v !== 2) throw new TypeError("protocol v2 required");
|
|
5002
|
+
const code = typeof raw.code === "string" ? raw.code : "";
|
|
5003
|
+
const publicKey = typeof raw.publicKey === "string" ? raw.publicKey : "";
|
|
5004
|
+
const deviceName = sanitizeDeviceField(raw.deviceName, 64) || "unknown";
|
|
5005
|
+
const appVersion = sanitizeDeviceField(raw.appVersion, 32) || "unknown";
|
|
5006
|
+
const deviceId = deviceIdForPublicKey(publicKey);
|
|
5007
|
+
if (devices.list().length >= 64 && devices.authorized(deviceId) === void 0) {
|
|
5008
|
+
res.statusCode = 409;
|
|
5009
|
+
res.end(JSON.stringify({
|
|
5010
|
+
ok: false,
|
|
5011
|
+
error: "device registry is full"
|
|
5012
|
+
}));
|
|
5013
|
+
return;
|
|
5014
|
+
}
|
|
5015
|
+
if (!pairingCodes.consume(code)) {
|
|
5016
|
+
const failure = authRateLimiter.recordFailure(source);
|
|
5017
|
+
res.statusCode = failure.blocked ? 429 : 401;
|
|
5018
|
+
if (failure.retryAfterMs > 0) res.setHeader("Retry-After", String(Math.max(1, Math.ceil(failure.retryAfterMs / 1e3))));
|
|
5019
|
+
res.end(JSON.stringify({
|
|
5020
|
+
ok: false,
|
|
5021
|
+
error: failure.blocked ? "pairing rate limited" : "pairing code invalid or expired"
|
|
5022
|
+
}));
|
|
5023
|
+
return;
|
|
5024
|
+
}
|
|
5025
|
+
const record = devices.register({
|
|
5026
|
+
publicKey,
|
|
5027
|
+
deviceName,
|
|
5028
|
+
appVersion,
|
|
5029
|
+
scopes: normalizeDeviceScopes(raw.scopes)
|
|
5030
|
+
}, Date.now());
|
|
5031
|
+
authRateLimiter.recordSuccess(source);
|
|
5032
|
+
log(`device paired id=${auditLabel(record.deviceId)} source=${auditLabel(source)}`);
|
|
5033
|
+
res.statusCode = 201;
|
|
5034
|
+
res.end(JSON.stringify({
|
|
5035
|
+
ok: true,
|
|
5036
|
+
v: 2,
|
|
5037
|
+
deviceId: record.deviceId,
|
|
5038
|
+
audience,
|
|
5039
|
+
scopes: record.scopes ?? []
|
|
5040
|
+
}));
|
|
5041
|
+
} catch (error) {
|
|
5042
|
+
res.statusCode = error instanceof SyntaxError || error instanceof TypeError ? 400 : 503;
|
|
5043
|
+
res.end(JSON.stringify({
|
|
5044
|
+
ok: false,
|
|
5045
|
+
error: error instanceof Error ? error.message : "pairing failed"
|
|
5046
|
+
}));
|
|
5047
|
+
} finally {
|
|
5048
|
+
admission.release();
|
|
5049
|
+
}
|
|
5050
|
+
};
|
|
3943
5051
|
const handleUpgrade = (req, socket, head) => {
|
|
3944
5052
|
(async () => {
|
|
3945
5053
|
try {
|
|
@@ -3947,44 +5055,69 @@ function apply(ctx, options) {
|
|
|
3947
5055
|
rejectUpgrade(socket, 503, "bridge disabled");
|
|
3948
5056
|
return;
|
|
3949
5057
|
}
|
|
3950
|
-
if (connections.size >= MAX_CLIENT_CONNECTIONS) {
|
|
5058
|
+
if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
|
|
3951
5059
|
rejectUpgrade(socket, 429, "too many connections");
|
|
3952
5060
|
return;
|
|
3953
5061
|
}
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
5062
|
+
pendingUpgrades += 1;
|
|
5063
|
+
try {
|
|
5064
|
+
const { devices, audience } = await ready;
|
|
5065
|
+
if (!audience || !devices) {
|
|
5066
|
+
rejectUpgrade(socket, 503, "bridge degraded");
|
|
5067
|
+
return;
|
|
5068
|
+
}
|
|
5069
|
+
const source = requestClientIdentity(req);
|
|
5070
|
+
const admission = authRateLimiter.admit(source);
|
|
5071
|
+
if (!admission.ok) {
|
|
5072
|
+
rejectUpgrade(socket, 429, "authentication rate limited", admission.retryAfterMs / 1e3);
|
|
5073
|
+
return;
|
|
5074
|
+
}
|
|
5075
|
+
const bridge = state.bridge;
|
|
5076
|
+
if (!bridge) {
|
|
5077
|
+
admission.release();
|
|
5078
|
+
rejectUpgrade(socket, 503, "bridge not ready");
|
|
5079
|
+
return;
|
|
5080
|
+
}
|
|
5081
|
+
try {
|
|
5082
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
5083
|
+
if (auth.audience !== audience || state.bridge !== bridge) {
|
|
5084
|
+
admission.release();
|
|
5085
|
+
ws.close(1012, "bridge changed");
|
|
5086
|
+
return;
|
|
5087
|
+
}
|
|
5088
|
+
try {
|
|
5089
|
+
const connection = new BridgeConnection(ws, {
|
|
5090
|
+
bridge,
|
|
5091
|
+
devices,
|
|
5092
|
+
serverVersion: SERVER_VERSION,
|
|
5093
|
+
audience,
|
|
5094
|
+
log,
|
|
5095
|
+
debug: currentConfig().debug === true,
|
|
5096
|
+
onClosed: (closed) => connections.delete(closed),
|
|
5097
|
+
onAuthenticationSettled: (ok) => {
|
|
5098
|
+
admission.release();
|
|
5099
|
+
if (ok) authRateLimiter.recordSuccess(source);
|
|
5100
|
+
else if (authRateLimiter.recordFailure(source).newlyBlocked) log(`authentication source blocked source=${auditLabel(source)}`);
|
|
5101
|
+
},
|
|
5102
|
+
onDeviceAuthenticated: (deviceId) => {
|
|
5103
|
+
log(`device authenticated id=${auditLabel(deviceId)} source=${auditLabel(source)}`);
|
|
5104
|
+
},
|
|
5105
|
+
onPushEnrollKey: handlePushEnrollKey
|
|
5106
|
+
});
|
|
5107
|
+
connections.add(connection);
|
|
5108
|
+
} catch (error) {
|
|
5109
|
+
admission.release();
|
|
5110
|
+
ws.close(1011, "connection setup failed");
|
|
5111
|
+
throw error;
|
|
5112
|
+
}
|
|
5113
|
+
});
|
|
5114
|
+
} catch (error) {
|
|
5115
|
+
admission.release();
|
|
5116
|
+
throw error;
|
|
5117
|
+
}
|
|
5118
|
+
} finally {
|
|
5119
|
+
pendingUpgrades -= 1;
|
|
3973
5120
|
}
|
|
3974
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
3975
|
-
const connection = new BridgeConnection(ws, {
|
|
3976
|
-
bridge,
|
|
3977
|
-
devices,
|
|
3978
|
-
serverVersion: SERVER_VERSION,
|
|
3979
|
-
expectedToken: token,
|
|
3980
|
-
transportAuthenticated: presentedToken !== null,
|
|
3981
|
-
log,
|
|
3982
|
-
debug: currentConfig().debug === true,
|
|
3983
|
-
onClosed: (closed) => connections.delete(closed),
|
|
3984
|
-
onPushEnrollKey: handlePushEnrollKey
|
|
3985
|
-
});
|
|
3986
|
-
connections.add(connection);
|
|
3987
|
-
});
|
|
3988
5121
|
} catch (error) {
|
|
3989
5122
|
log("upgrade failed: " + String(error));
|
|
3990
5123
|
rejectUpgrade(socket, 500, "internal error");
|
|
@@ -3994,9 +5127,8 @@ function apply(ctx, options) {
|
|
|
3994
5127
|
const handleHealth = async (req, res) => {
|
|
3995
5128
|
try {
|
|
3996
5129
|
await ready;
|
|
3997
|
-
const token = auth.token;
|
|
3998
5130
|
res.setHeader("Content-Type", "application/json");
|
|
3999
|
-
if (!
|
|
5131
|
+
if (!auth.audience || !auth.devices) {
|
|
4000
5132
|
res.statusCode = 503;
|
|
4001
5133
|
res.end(JSON.stringify({
|
|
4002
5134
|
ok: false,
|
|
@@ -4004,16 +5136,11 @@ function apply(ctx, options) {
|
|
|
4004
5136
|
}));
|
|
4005
5137
|
return;
|
|
4006
5138
|
}
|
|
4007
|
-
if (!tokenMatches(requestToken(req), token)) {
|
|
4008
|
-
res.statusCode = 401;
|
|
4009
|
-
res.end(JSON.stringify({ ok: false }));
|
|
4010
|
-
return;
|
|
4011
|
-
}
|
|
4012
5139
|
res.statusCode = 200;
|
|
4013
5140
|
res.end(JSON.stringify({
|
|
4014
5141
|
ok: true,
|
|
4015
5142
|
enabled: enabledNow(),
|
|
4016
|
-
protocolVersion:
|
|
5143
|
+
protocolVersion: 2,
|
|
4017
5144
|
serverVersion: SERVER_VERSION,
|
|
4018
5145
|
dataPlane: Boolean(state.bridge)
|
|
4019
5146
|
}));
|
|
@@ -4022,15 +5149,17 @@ function apply(ctx, options) {
|
|
|
4022
5149
|
res.end(JSON.stringify({ ok: false }));
|
|
4023
5150
|
}
|
|
4024
5151
|
};
|
|
4025
|
-
ctx.inject(["
|
|
5152
|
+
ctx.inject(["sessionController"], (sub) => {
|
|
4026
5153
|
if (currentConfig().enabled !== true) {
|
|
4027
5154
|
log("bridge disabled; data plane stays inactive");
|
|
4028
5155
|
return;
|
|
4029
5156
|
}
|
|
4030
5157
|
const apiCtx = sub;
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
5158
|
+
let proxy;
|
|
5159
|
+
try {
|
|
5160
|
+
proxy = new Dsh012ApiProxy(apiCtx);
|
|
5161
|
+
} catch (error) {
|
|
5162
|
+
log("dsh 0.1.2 session bridge unavailable: " + String(error));
|
|
4034
5163
|
return;
|
|
4035
5164
|
}
|
|
4036
5165
|
const bridge = new HostBridge(proxy, cfg.historyBufferMax);
|
|
@@ -4038,7 +5167,11 @@ function apply(ctx, options) {
|
|
|
4038
5167
|
state.bridge = bridge;
|
|
4039
5168
|
bridge.start();
|
|
4040
5169
|
log("data plane active (mux + host streams)");
|
|
4041
|
-
apiCtx.effect(() => () =>
|
|
5170
|
+
apiCtx.effect(() => () => {
|
|
5171
|
+
closeConnectionsForBridge(bridge);
|
|
5172
|
+
if (state.bridge === bridge) state.bridge = void 0;
|
|
5173
|
+
bridge.dispose();
|
|
5174
|
+
}, "deeppilot: host streams");
|
|
4042
5175
|
});
|
|
4043
5176
|
ctx.inject(["webServer"], (sub) => {
|
|
4044
5177
|
const webCtx = sub;
|
|
@@ -4056,11 +5189,16 @@ function apply(ctx, options) {
|
|
|
4056
5189
|
path: "/phone/health",
|
|
4057
5190
|
handler: handleHealth
|
|
4058
5191
|
}), "deeppilot: /phone/health");
|
|
5192
|
+
webCtx.effect(() => web.register({
|
|
5193
|
+
kind: "exact",
|
|
5194
|
+
path: "/phone/pair",
|
|
5195
|
+
handler: handlePair
|
|
5196
|
+
}), "deeppilot: /phone/pair");
|
|
4059
5197
|
const sweep = setInterval(() => {
|
|
4060
5198
|
const now = Date.now();
|
|
4061
5199
|
for (const connection of connections) if (connection.isStale(now, 6e4)) {
|
|
4062
5200
|
log("dropping stale connection");
|
|
4063
|
-
connection.
|
|
5201
|
+
connection.closeIdle();
|
|
4064
5202
|
connections.delete(connection);
|
|
4065
5203
|
}
|
|
4066
5204
|
}, 3e4);
|
|
@@ -4071,6 +5209,7 @@ function apply(ctx, options) {
|
|
|
4071
5209
|
path = new URL(req.url ?? "/", "http://phone.local").pathname;
|
|
4072
5210
|
} catch {}
|
|
4073
5211
|
if (path === "/phone/health") handleHealth(req, res);
|
|
5212
|
+
else if (path === "/phone/pair") handlePair(req, res);
|
|
4074
5213
|
else {
|
|
4075
5214
|
res.statusCode = 404;
|
|
4076
5215
|
res.end("not found");
|
|
@@ -4102,7 +5241,8 @@ function apply(ctx, options) {
|
|
|
4102
5241
|
hostname: normalizeRemoteHostname(remoteConfig.hostname),
|
|
4103
5242
|
statePath: remoteConfig.statePath?.trim() || join(dataDir, "tailscale"),
|
|
4104
5243
|
helperPath,
|
|
4105
|
-
funnelPort: remotePort
|
|
5244
|
+
funnelPort: remotePort,
|
|
5245
|
+
maxConnectionsPerSource: normalizeFunnelConnectionLimit(remoteConfig.maxConnectionsPerSource)
|
|
4106
5246
|
};
|
|
4107
5247
|
const nextKey = JSON.stringify(next);
|
|
4108
5248
|
if (nextKey === appliedRemoteKey) return;
|
|
@@ -4116,6 +5256,7 @@ function apply(ctx, options) {
|
|
|
4116
5256
|
statePath: next.statePath,
|
|
4117
5257
|
...next.helperPath ? { helperPath: next.helperPath } : {},
|
|
4118
5258
|
funnelPort: next.funnelPort,
|
|
5259
|
+
maxConnectionsPerSource: next.maxConnectionsPerSource,
|
|
4119
5260
|
log
|
|
4120
5261
|
});
|
|
4121
5262
|
remoteSupervisor = supervisor;
|
|
@@ -4146,8 +5287,23 @@ function apply(ctx, options) {
|
|
|
4146
5287
|
if (enabledNow()) log("/phone WebSocket registered");
|
|
4147
5288
|
else log("bridge disabled; /phone refuses connections until re-enabled and restarted");
|
|
4148
5289
|
});
|
|
5290
|
+
ctx.effect(() => async () => {
|
|
5291
|
+
closeAllConnections();
|
|
5292
|
+
const bridge = state.bridge;
|
|
5293
|
+
state.bridge = void 0;
|
|
5294
|
+
bridge?.dispose();
|
|
5295
|
+
const sender = cachedSender;
|
|
5296
|
+
cachedSender = void 0;
|
|
5297
|
+
updateChecker.dispose();
|
|
5298
|
+
const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
|
|
5299
|
+
await Promise.allSettled([
|
|
5300
|
+
enrollmentWriteTail,
|
|
5301
|
+
sender?.dispose?.() ?? Promise.resolve(),
|
|
5302
|
+
wssClosed
|
|
5303
|
+
]);
|
|
5304
|
+
}, "deeppilot: process resources");
|
|
4149
5305
|
}
|
|
4150
5306
|
//#endregion
|
|
4151
|
-
export { Config, HostBridge, apply, inject, name,
|
|
5307
|
+
export { Config, HostBridge, apply, inject, name, shouldPrunePushToken, shouldReEnrollRelayToken };
|
|
4152
5308
|
|
|
4153
5309
|
//# sourceMappingURL=index.js.map
|