dsh-deeppilot 0.4.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 +392 -177
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +63 -16
- package/lib/index.js +1147 -299
- package/lib/index.js.map +1 -1
- package/package.json +37 -7
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
-
import { createHash, createPrivateKey, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import { access, mkdir, readFile, rename, unlink, 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
6
|
import { WebSocketServer } from "ws";
|
|
7
7
|
import { homedir, networkInterfaces } from "node:os";
|
|
@@ -13,6 +13,128 @@ import { constants } from "node:fs";
|
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { request } from "node:https";
|
|
15
15
|
import z from "@deepseek-ai/schemastery";
|
|
16
|
+
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,63 +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
|
-
await preserveCorruptSidecar(full);
|
|
63
|
-
throw new Error(`pairing token is malformed at ${full} (length=${existing.length}); original preserved as ${full}.corrupt`);
|
|
64
|
-
} catch (error) {
|
|
65
|
-
if (error.code !== "ENOENT") throw error;
|
|
66
|
-
}
|
|
67
|
-
const token = randomBytes(32).toString("base64url");
|
|
68
|
-
await mkdir(dirname(full), { recursive: true });
|
|
69
|
-
await writeFile(full, token + "\n", { mode: 384 });
|
|
70
|
-
return token;
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Copy a malformed auth-token file to `<path>.corrupt` so a future operator
|
|
74
|
-
* can inspect what was on disk at the moment of corruption. Best-effort:
|
|
75
|
-
* a copy failure (permissions, full disk, ...) must not block the loud
|
|
76
|
-
* throw that actually surfaces the issue.
|
|
77
|
-
*/
|
|
78
|
-
async function preserveCorruptSidecar(full) {
|
|
79
|
-
try {
|
|
80
|
-
const original = await readFile(full);
|
|
81
|
-
const sidecar = `${full}.corrupt`;
|
|
82
|
-
await writeFile(sidecar, original, { mode: 384 });
|
|
83
|
-
} catch {}
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Generate a fresh pairing token and replace the stored one, invalidating
|
|
87
|
-
* every copy of the old secret. The write goes to a same-directory temp file
|
|
88
|
-
* renamed over the target so a crash can never leave a truncated token file.
|
|
89
|
-
*/
|
|
90
|
-
async function writeNewToken(tokenPath) {
|
|
91
|
-
const full = expandHome(tokenPath);
|
|
92
|
-
const token = randomBytes(32).toString("base64url");
|
|
93
|
-
await mkdir(dirname(full), { recursive: true });
|
|
94
|
-
const temp = `${full}.${randomBytes(6).toString("hex")}.tmp`;
|
|
95
|
-
await writeFile(temp, token + "\n", { mode: 384 });
|
|
96
|
-
await rename(temp, full);
|
|
97
|
-
return token;
|
|
98
|
-
}
|
|
99
|
-
/** Constant-time token comparison; both sides are high-entropy secrets. */
|
|
100
|
-
function tokenMatches(presented, expected) {
|
|
101
|
-
if (!presented) return false;
|
|
102
|
-
const b = Buffer.from(expected);
|
|
103
|
-
if (Buffer.byteLength(presented, "utf8") !== b.length) {
|
|
104
|
-
timingSafeEqual(b, b);
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
const a = Buffer.from(presented);
|
|
108
|
-
return timingSafeEqual(a, b);
|
|
109
|
-
}
|
|
110
185
|
/** Hex shape of an APNs device token as delivered by iOS (usually 64 chars). */
|
|
111
186
|
const APNS_TOKEN_PATTERN = /^[0-9a-f]{32,512}$/;
|
|
112
187
|
function isValidApnsToken(token) {
|
|
@@ -134,32 +209,57 @@ var DeviceStore = class DeviceStore {
|
|
|
134
209
|
}
|
|
135
210
|
return store;
|
|
136
211
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
...record,
|
|
155
|
-
firstSeenTs: now,
|
|
156
|
-
lastSeenTs: now
|
|
157
|
-
});
|
|
158
|
-
}
|
|
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);
|
|
159
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);
|
|
258
|
+
this.flush();
|
|
259
|
+
return [...record.scopes];
|
|
160
260
|
}
|
|
161
261
|
list() {
|
|
162
|
-
return [...this.devices.values()];
|
|
262
|
+
return [...this.devices.values()].map((record) => structuredClone(record));
|
|
163
263
|
}
|
|
164
264
|
/**
|
|
165
265
|
* Store (or refresh) the APNs registration of a paired device. Idempotent:
|
|
@@ -169,17 +269,8 @@ var DeviceStore = class DeviceStore {
|
|
|
169
269
|
setPushToken(deviceId, token, environment, categories, now) {
|
|
170
270
|
const normalized = token.toLowerCase();
|
|
171
271
|
if (!isValidApnsToken(normalized)) return;
|
|
172
|
-
|
|
173
|
-
if (!record)
|
|
174
|
-
record = {
|
|
175
|
-
deviceId,
|
|
176
|
-
deviceName: "unknown",
|
|
177
|
-
appVersion: "unknown",
|
|
178
|
-
firstSeenTs: now,
|
|
179
|
-
lastSeenTs: now
|
|
180
|
-
};
|
|
181
|
-
this.devices.set(deviceId, record);
|
|
182
|
-
}
|
|
272
|
+
const record = this.authorized(deviceId);
|
|
273
|
+
if (!record) return;
|
|
183
274
|
const next = {
|
|
184
275
|
token: normalized,
|
|
185
276
|
environment,
|
|
@@ -202,15 +293,6 @@ var DeviceStore = class DeviceStore {
|
|
|
202
293
|
delete record.apns;
|
|
203
294
|
this.flush();
|
|
204
295
|
}
|
|
205
|
-
/**
|
|
206
|
-
* Drop every paired-device record. Used by token rotation: devices paired
|
|
207
|
-
* under the old token can no longer authenticate, so keeping their rows
|
|
208
|
-
* would paint a misleading "still paired" picture.
|
|
209
|
-
*/
|
|
210
|
-
clear() {
|
|
211
|
-
this.devices.clear();
|
|
212
|
-
this.flush();
|
|
213
|
-
}
|
|
214
296
|
/** Serialized so concurrent touches can never interleave half-written JSON. */
|
|
215
297
|
flush() {
|
|
216
298
|
const next = this.flushTail.then(() => this.writeFile());
|
|
@@ -224,7 +306,7 @@ var DeviceStore = class DeviceStore {
|
|
|
224
306
|
async writeFile() {
|
|
225
307
|
const full = expandHome(this.filePath);
|
|
226
308
|
const body = JSON.stringify({
|
|
227
|
-
version:
|
|
309
|
+
version: 2,
|
|
228
310
|
devices: this.list()
|
|
229
311
|
}, null, 2);
|
|
230
312
|
try {
|
|
@@ -237,7 +319,7 @@ var DeviceStore = class DeviceStore {
|
|
|
237
319
|
};
|
|
238
320
|
//#endregion
|
|
239
321
|
//#region src/connection-policy.ts
|
|
240
|
-
const AUTH_TIMEOUT_MS =
|
|
322
|
+
const AUTH_TIMEOUT_MS = 35e3;
|
|
241
323
|
const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
242
324
|
"image/png",
|
|
243
325
|
"image/jpeg",
|
|
@@ -247,8 +329,13 @@ const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
|
247
329
|
function sanitizeDeviceField(value, maxChars) {
|
|
248
330
|
return (typeof value === "string" ? value : String(value ?? "")).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxChars);
|
|
249
331
|
}
|
|
250
|
-
function
|
|
251
|
-
|
|
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";
|
|
252
339
|
}
|
|
253
340
|
function sanitizeImageName(value) {
|
|
254
341
|
return value.replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, 120);
|
|
@@ -283,21 +370,31 @@ function managementErrorCode(kind) {
|
|
|
283
370
|
//#region src/connection.ts
|
|
284
371
|
/**
|
|
285
372
|
* One connected phone. Implements BridgeSink so the HostBridge can push
|
|
286
|
-
* projected frames and replays.
|
|
287
|
-
*
|
|
373
|
+
* projected frames and replays. Every socket starts anonymous, receives one
|
|
374
|
+
* server challenge, and must prove possession of a registered P-256 key.
|
|
288
375
|
*/
|
|
289
376
|
var BridgeConnection = class {
|
|
290
377
|
ws;
|
|
291
378
|
deps;
|
|
292
379
|
authenticated = false;
|
|
380
|
+
authenticationSettled = false;
|
|
293
381
|
closed = false;
|
|
294
382
|
helloTimer;
|
|
295
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();
|
|
296
390
|
/** Sanitized device identity from hello; needed for push registration. */
|
|
297
391
|
deviceId;
|
|
392
|
+
scopes = /* @__PURE__ */ new Set();
|
|
393
|
+
authChallenge;
|
|
298
394
|
constructor(ws, deps) {
|
|
299
395
|
this.ws = ws;
|
|
300
396
|
this.deps = deps;
|
|
397
|
+
this.authChallenge = createAuthChallenge(deps.audience);
|
|
301
398
|
ws.on("message", (data) => {
|
|
302
399
|
this.onMessage(String(data));
|
|
303
400
|
});
|
|
@@ -307,8 +404,13 @@ var BridgeConnection = class {
|
|
|
307
404
|
});
|
|
308
405
|
ws.on("error", () => {});
|
|
309
406
|
this.helloTimer = setTimeout(() => {
|
|
310
|
-
if (!this.authenticated)
|
|
407
|
+
if (!this.authenticated) {
|
|
408
|
+
this.settleAuthentication(false, "timeout");
|
|
409
|
+
this.close(4402, "auth timeout");
|
|
410
|
+
}
|
|
311
411
|
}, AUTH_TIMEOUT_MS);
|
|
412
|
+
this.helloTimer.unref();
|
|
413
|
+
this.send("s2c.auth.challenge", this.authChallenge);
|
|
312
414
|
}
|
|
313
415
|
/** Hard-drop the socket (server-side stale sweep). */
|
|
314
416
|
terminate() {
|
|
@@ -332,6 +434,20 @@ var BridgeConnection = class {
|
|
|
332
434
|
return this.authenticated ? this.deviceId : void 0;
|
|
333
435
|
}
|
|
334
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
|
+
}
|
|
335
451
|
if (this.deps.debug === true) this.deps.log("push " + type + " seq=" + String(seq));
|
|
336
452
|
this.send(type, payload, void 0, seq);
|
|
337
453
|
}
|
|
@@ -350,12 +466,19 @@ var BridgeConnection = class {
|
|
|
350
466
|
onClose() {
|
|
351
467
|
if (this.closed) return;
|
|
352
468
|
this.closed = true;
|
|
469
|
+
if (!this.authenticationSettled) this.settleAuthentication(false, "closed");
|
|
353
470
|
if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
|
|
354
471
|
for (const id of this.openSessions) this.deps.bridge.markSinkClosed(this, id);
|
|
355
472
|
this.openSessions.clear();
|
|
473
|
+
this.openingSessionEvents.clear();
|
|
356
474
|
this.deps.bridge.dropSinkSessions(this);
|
|
357
475
|
if (this.authenticated) this.deps.bridge.removeSink(this);
|
|
358
476
|
}
|
|
477
|
+
settleAuthentication(ok, reason) {
|
|
478
|
+
if (this.authenticationSettled) return;
|
|
479
|
+
this.authenticationSettled = true;
|
|
480
|
+
this.deps.onAuthenticationSettled?.(ok, reason);
|
|
481
|
+
}
|
|
359
482
|
close(code, reason) {
|
|
360
483
|
if (this.closed) return;
|
|
361
484
|
try {
|
|
@@ -366,7 +489,7 @@ var BridgeConnection = class {
|
|
|
366
489
|
}
|
|
367
490
|
send(type, payload, id, seq) {
|
|
368
491
|
const envelope = {
|
|
369
|
-
v:
|
|
492
|
+
v: 2,
|
|
370
493
|
type,
|
|
371
494
|
ts: Date.now(),
|
|
372
495
|
...id !== void 0 ? { id } : {},
|
|
@@ -404,7 +527,7 @@ var BridgeConnection = class {
|
|
|
404
527
|
this.fail(void 0, "E_PROTOCOL", "frame is not valid JSON");
|
|
405
528
|
return;
|
|
406
529
|
}
|
|
407
|
-
if (env.v !==
|
|
530
|
+
if (env.v !== 2) {
|
|
408
531
|
this.fail(env.id, "E_UNSUPPORTED", "unsupported protocol version");
|
|
409
532
|
this.close(4500, "protocol version mismatch");
|
|
410
533
|
return;
|
|
@@ -414,13 +537,18 @@ var BridgeConnection = class {
|
|
|
414
537
|
this.send("s2c.pong", { serverTime: Date.now() }, env.id);
|
|
415
538
|
return;
|
|
416
539
|
}
|
|
417
|
-
if (env.type === "c2s.
|
|
418
|
-
await this.
|
|
540
|
+
if (env.type === "c2s.auth.prove") {
|
|
541
|
+
await this.prove(env);
|
|
419
542
|
return;
|
|
420
543
|
}
|
|
421
544
|
this.fail(env.id, "E_PROTOCOL", "authenticate first");
|
|
422
545
|
return;
|
|
423
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
|
+
}
|
|
424
552
|
switch (env.type) {
|
|
425
553
|
case "c2s.ping":
|
|
426
554
|
this.send("s2c.pong", { serverTime: Date.now() }, env.id);
|
|
@@ -469,18 +597,23 @@ var BridgeConnection = class {
|
|
|
469
597
|
const p = env.payload;
|
|
470
598
|
if (!p?.sessionId || typeof p.sessionId !== "string") return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
471
599
|
const sessionId = p.sessionId;
|
|
472
|
-
|
|
473
|
-
this.
|
|
600
|
+
const bufferedEvents = [];
|
|
601
|
+
this.openingSessionEvents.set(sessionId, bufferedEvents);
|
|
474
602
|
if (!await this.deps.bridge.openSession(this, sessionId, p.tailCount ?? 100)) {
|
|
475
|
-
this.
|
|
476
|
-
this.deps.bridge.markSinkClosed(this, sessionId);
|
|
603
|
+
if (this.openingSessionEvents.get(sessionId) === bufferedEvents) this.openingSessionEvents.delete(sessionId);
|
|
477
604
|
return this.fail(env.id, "E_NOT_FOUND", "session history unavailable");
|
|
478
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);
|
|
479
611
|
return;
|
|
480
612
|
}
|
|
481
613
|
case "c2s.session.close": {
|
|
482
614
|
const p = env.payload;
|
|
483
615
|
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
616
|
+
this.openingSessionEvents.delete(p.sessionId);
|
|
484
617
|
this.openSessions.delete(p.sessionId);
|
|
485
618
|
this.deps.bridge.markSinkClosed(this, p.sessionId);
|
|
486
619
|
this.send("s2c.ack", {}, env.id);
|
|
@@ -541,7 +674,9 @@ var BridgeConnection = class {
|
|
|
541
674
|
case "c2s.session.history": {
|
|
542
675
|
const p = env.payload;
|
|
543
676
|
if (!p?.sessionId || typeof p.beforeSeq !== "number") return this.fail(env.id, "E_PROTOCOL", "sessionId and beforeSeq required");
|
|
544
|
-
|
|
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);
|
|
545
680
|
return;
|
|
546
681
|
}
|
|
547
682
|
case "c2s.session.attachment": {
|
|
@@ -646,13 +781,8 @@ var BridgeConnection = class {
|
|
|
646
781
|
default: this.fail(env.id, "E_PROTOCOL", "unknown type: " + env.type);
|
|
647
782
|
}
|
|
648
783
|
}
|
|
649
|
-
async
|
|
784
|
+
async prove(env) {
|
|
650
785
|
const p = env.payload ?? {};
|
|
651
|
-
if (!helloTokenAccepted(this.deps.transportAuthenticated, p.token, this.deps.expectedToken)) {
|
|
652
|
-
this.fail(env.id, "E_AUTH", "token missing or invalid");
|
|
653
|
-
this.close(4401, "invalid token");
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
786
|
if (!p.deviceId) {
|
|
657
787
|
this.fail(env.id, "E_PROTOCOL", "deviceId required");
|
|
658
788
|
this.close(4403, "deviceId required");
|
|
@@ -666,20 +796,36 @@ var BridgeConnection = class {
|
|
|
666
796
|
}
|
|
667
797
|
const deviceName = sanitizeDeviceField(p.deviceName, 64) || "unknown";
|
|
668
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");
|
|
669
816
|
this.authenticated = true;
|
|
670
817
|
this.deviceId = deviceId;
|
|
818
|
+
this.scopes = new Set(record.scopes ?? []);
|
|
671
819
|
if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
|
|
672
|
-
this.deps.devices.
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
appVersion
|
|
676
|
-
}, Date.now());
|
|
677
|
-
this.deps.log("device paired: " + deviceName + " (" + deviceId + ")");
|
|
678
|
-
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;
|
|
679
823
|
const canResume = cursor !== void 0 && this.deps.bridge.canResumeFrom(cursor);
|
|
680
824
|
this.send("s2c.welcome", {
|
|
681
|
-
protocolVersion:
|
|
825
|
+
protocolVersion: 2,
|
|
682
826
|
serverVersion: this.deps.serverVersion,
|
|
827
|
+
deviceId,
|
|
828
|
+
scopes: [...this.scopes],
|
|
683
829
|
capabilities: this.deps.bridge.capabilities,
|
|
684
830
|
cursor: this.deps.bridge.currentCursor(),
|
|
685
831
|
resumed: canResume
|
|
@@ -712,6 +858,29 @@ function unwrapStreamItem(item) {
|
|
|
712
858
|
//#endregion
|
|
713
859
|
//#region src/host-event-projection.ts
|
|
714
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;
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
messages: kept,
|
|
881
|
+
dropped: canonical.length - kept.length
|
|
882
|
+
};
|
|
883
|
+
}
|
|
715
884
|
function projectEvent(sessionId, event) {
|
|
716
885
|
switch (event.type) {
|
|
717
886
|
case "turn/start": return {
|
|
@@ -1032,7 +1201,7 @@ function projectHistory(events) {
|
|
|
1032
1201
|
}
|
|
1033
1202
|
}
|
|
1034
1203
|
}
|
|
1035
|
-
return messages.
|
|
1204
|
+
return canonicalSessionMessages(messages.map(limitMessageProjection));
|
|
1036
1205
|
}
|
|
1037
1206
|
/**
|
|
1038
1207
|
* Enforce PROTOCOL.md's per-message 256 KB ceiling by UTF-8 JSON byte size.
|
|
@@ -1598,13 +1767,14 @@ var HostBridge = class {
|
|
|
1598
1767
|
});
|
|
1599
1768
|
if (!response.result || !response.result.ok) return false;
|
|
1600
1769
|
const result = response.result.value;
|
|
1601
|
-
const
|
|
1770
|
+
const page = limitSessionPageMessages(projectHistory(result.events ?? []));
|
|
1771
|
+
const messages = page.messages;
|
|
1602
1772
|
const oldestSeq = messages.length > 0 ? messages[0].seq : 0;
|
|
1603
1773
|
sink.push("s2c.session.tail", {
|
|
1604
1774
|
sessionId,
|
|
1605
1775
|
messages,
|
|
1606
1776
|
oldestSeq,
|
|
1607
|
-
hasMore: Boolean(result.hasMore)
|
|
1777
|
+
hasMore: Boolean(result.hasMore) || page.dropped > 0
|
|
1608
1778
|
});
|
|
1609
1779
|
this.deriveTitleFallback(sessionId, messages);
|
|
1610
1780
|
return true;
|
|
@@ -1612,7 +1782,7 @@ var HostBridge = class {
|
|
|
1612
1782
|
return false;
|
|
1613
1783
|
}
|
|
1614
1784
|
}
|
|
1615
|
-
async historyPage(
|
|
1785
|
+
async historyPage(sessionId, beforeSeq, limit) {
|
|
1616
1786
|
try {
|
|
1617
1787
|
const response = await this.apiProxy.sessions.history({
|
|
1618
1788
|
rpcId: randomUUID(),
|
|
@@ -1622,17 +1792,16 @@ var HostBridge = class {
|
|
|
1622
1792
|
maxMessages: clampTail(limit)
|
|
1623
1793
|
}
|
|
1624
1794
|
});
|
|
1625
|
-
if (!response.result || !response.result.ok) return
|
|
1795
|
+
if (!response.result || !response.result.ok) return null;
|
|
1626
1796
|
const result = response.result.value;
|
|
1627
|
-
const
|
|
1628
|
-
|
|
1797
|
+
const page = limitSessionPageMessages(projectHistory(result.events ?? []).filter((message) => message.seq < beforeSeq));
|
|
1798
|
+
return {
|
|
1629
1799
|
sessionId,
|
|
1630
|
-
messages,
|
|
1631
|
-
hasMore: Boolean(result.hasMore)
|
|
1632
|
-
}
|
|
1633
|
-
return true;
|
|
1800
|
+
messages: page.messages,
|
|
1801
|
+
hasMore: page.messages.length > 0 && (Boolean(result.hasMore) || page.dropped > 0)
|
|
1802
|
+
};
|
|
1634
1803
|
} catch {
|
|
1635
|
-
return
|
|
1804
|
+
return null;
|
|
1636
1805
|
}
|
|
1637
1806
|
}
|
|
1638
1807
|
/** Result of one attachment read-back for the phone. */
|
|
@@ -2308,6 +2477,365 @@ function projectSessionModels(value) {
|
|
|
2308
2477
|
}
|
|
2309
2478
|
/** Project a history page (raw events) into MessageProjection rows. */
|
|
2310
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"
|
|
2591
|
+
};
|
|
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
|
|
2644
|
+
});
|
|
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
|
|
2669
|
+
});
|
|
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
|
|
2705
|
+
});
|
|
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;
|
|
2769
|
+
}
|
|
2770
|
+
await new Promise((resolve) => {
|
|
2771
|
+
this.wake = resolve;
|
|
2772
|
+
});
|
|
2773
|
+
this.wake = void 0;
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
};
|
|
2777
|
+
function unavailable(message) {
|
|
2778
|
+
return Object.assign(new Error(message), { code: "directory-picker-unavailable" });
|
|
2779
|
+
}
|
|
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
|
+
};
|
|
2837
|
+
}
|
|
2838
|
+
//#endregion
|
|
2311
2839
|
//#region src/report-service.ts
|
|
2312
2840
|
/**
|
|
2313
2841
|
* The Typert receiver the Gateway resolves for the DeepPilot Bridge report.
|
|
@@ -2316,26 +2844,31 @@ function projectSessionModels(value) {
|
|
|
2316
2844
|
*/
|
|
2317
2845
|
var DeepPilotReportService = class extends TypertRemoteService {
|
|
2318
2846
|
snapshot;
|
|
2319
|
-
|
|
2320
|
-
|
|
2847
|
+
pairingStarter;
|
|
2848
|
+
deviceRevoker;
|
|
2849
|
+
deviceScopeUpdater;
|
|
2321
2850
|
relayTester;
|
|
2322
2851
|
pushTester;
|
|
2323
|
-
constructor(ctx, snapshot,
|
|
2852
|
+
constructor(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
|
|
2324
2853
|
super(ctx, "deeppilotReport", { namespace: "deeppilot" });
|
|
2325
2854
|
this.snapshot = snapshot;
|
|
2326
|
-
this.
|
|
2327
|
-
this.
|
|
2855
|
+
this.pairingStarter = pairingStarter;
|
|
2856
|
+
this.deviceRevoker = deviceRevoker;
|
|
2857
|
+
this.deviceScopeUpdater = deviceScopeUpdater;
|
|
2328
2858
|
this.relayTester = relayTester;
|
|
2329
2859
|
this.pushTester = pushTester;
|
|
2330
2860
|
}
|
|
2331
2861
|
async report() {
|
|
2332
2862
|
return this.snapshot();
|
|
2333
2863
|
}
|
|
2334
|
-
async
|
|
2335
|
-
return this.
|
|
2864
|
+
async beginPairing() {
|
|
2865
|
+
return this.pairingStarter();
|
|
2866
|
+
}
|
|
2867
|
+
async revokeDevice(deviceId) {
|
|
2868
|
+
return this.deviceRevoker(deviceId);
|
|
2336
2869
|
}
|
|
2337
|
-
async
|
|
2338
|
-
return this.
|
|
2870
|
+
async setDeviceScopes(deviceId, scopes) {
|
|
2871
|
+
return this.deviceScopeUpdater(deviceId, scopes);
|
|
2339
2872
|
}
|
|
2340
2873
|
async testRelay() {
|
|
2341
2874
|
return this.relayTester();
|
|
@@ -2350,14 +2883,9 @@ var DeepPilotReportService = class extends TypertRemoteService {
|
|
|
2350
2883
|
const REPORT_REMOTE_PACKAGE = "dsh-deeppilot";
|
|
2351
2884
|
/** Canonical `<namespace>/<method>` endpoint of the report Remote. */
|
|
2352
2885
|
const REPORT_ENDPOINT = "deeppilot/report";
|
|
2353
|
-
|
|
2354
|
-
const
|
|
2355
|
-
|
|
2356
|
-
* Explicit, user-triggered endpoint that replaces the pairing secret. The old
|
|
2357
|
-
* token stops working immediately; the fresh one is returned so the page can
|
|
2358
|
-
* show/QR it right away.
|
|
2359
|
-
*/
|
|
2360
|
-
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";
|
|
2361
2889
|
function reject(field) {
|
|
2362
2890
|
throw new TypeError(`deeppilot/report result: invalid ${field}`);
|
|
2363
2891
|
}
|
|
@@ -2405,6 +2933,9 @@ function parseDevice(value) {
|
|
|
2405
2933
|
appVersion: str(s, "appVersion", "device.appVersion"),
|
|
2406
2934
|
firstSeenTs: int(s, "firstSeenTs", "device.firstSeenTs"),
|
|
2407
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") } : {},
|
|
2408
2939
|
...apns ? { apns } : {}
|
|
2409
2940
|
};
|
|
2410
2941
|
}
|
|
@@ -2512,8 +3043,8 @@ function parseReport(value) {
|
|
|
2512
3043
|
...s.updateAvailable === true ? { updateAvailable: true } : {},
|
|
2513
3044
|
...typeof releaseUrl === "string" && releaseUrl.length > 0 ? { releaseUrl } : {},
|
|
2514
3045
|
enabled: bool(s, "enabled", "enabled"),
|
|
2515
|
-
|
|
2516
|
-
|
|
3046
|
+
identityPath: str(s, "identityPath", "identityPath"),
|
|
3047
|
+
pairingReady: bool(s, "pairingReady", "pairingReady"),
|
|
2517
3048
|
activeConnections: int(s, "activeConnections", "activeConnections"),
|
|
2518
3049
|
historyBufferMax: int(s, "historyBufferMax", "historyBufferMax"),
|
|
2519
3050
|
debug: bool(s, "debug", "debug"),
|
|
@@ -2525,14 +3056,33 @@ function parseReport(value) {
|
|
|
2525
3056
|
const reportSchema = { parse: parseReport };
|
|
2526
3057
|
const relayTestSchema = { parse: parseRelayTestResult };
|
|
2527
3058
|
const pushTestSchema = { parse: parsePushTestResult };
|
|
2528
|
-
const
|
|
2529
|
-
|
|
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");
|
|
2530
3071
|
return value;
|
|
2531
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
|
+
} };
|
|
2532
3077
|
const REPORT_HOST_CONTRIBUTION = {
|
|
2533
3078
|
package: REPORT_REMOTE_PACKAGE,
|
|
2534
3079
|
face: "host",
|
|
2535
3080
|
schemas: [],
|
|
3081
|
+
model: {
|
|
3082
|
+
services: [],
|
|
3083
|
+
events: [],
|
|
3084
|
+
objects: []
|
|
3085
|
+
},
|
|
2536
3086
|
invocations: [
|
|
2537
3087
|
{
|
|
2538
3088
|
id: `${REPORT_REMOTE_PACKAGE}#${REPORT_ENDPOINT}`,
|
|
@@ -2548,29 +3098,72 @@ const REPORT_HOST_CONTRIBUTION = {
|
|
|
2548
3098
|
}
|
|
2549
3099
|
},
|
|
2550
3100
|
{
|
|
2551
|
-
id: `${REPORT_REMOTE_PACKAGE}#${
|
|
3101
|
+
id: `${REPORT_REMOTE_PACKAGE}#${BEGIN_PAIRING_ENDPOINT}`,
|
|
2552
3102
|
service: "deeppilotReport",
|
|
2553
3103
|
namespace: "deeppilot",
|
|
2554
|
-
method: "
|
|
3104
|
+
method: "beginPairing",
|
|
2555
3105
|
invocation: { kind: "direct" },
|
|
2556
3106
|
parameters: [],
|
|
2557
3107
|
result: {
|
|
2558
3108
|
mode: "strict",
|
|
2559
|
-
typeSymbol: `${REPORT_REMOTE_PACKAGE}#
|
|
2560
|
-
schema:
|
|
3109
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`,
|
|
3110
|
+
schema: pairingGrantSchema
|
|
2561
3111
|
}
|
|
2562
3112
|
},
|
|
2563
3113
|
{
|
|
2564
|
-
id: `${REPORT_REMOTE_PACKAGE}#${
|
|
3114
|
+
id: `${REPORT_REMOTE_PACKAGE}#${REVOKE_DEVICE_ENDPOINT}`,
|
|
2565
3115
|
service: "deeppilotReport",
|
|
2566
3116
|
namespace: "deeppilot",
|
|
2567
|
-
method: "
|
|
3117
|
+
method: "revokeDevice",
|
|
2568
3118
|
invocation: { kind: "direct" },
|
|
2569
|
-
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
|
+
}],
|
|
2570
3129
|
result: {
|
|
2571
3130
|
mode: "strict",
|
|
2572
|
-
typeSymbol: `${REPORT_REMOTE_PACKAGE}#
|
|
2573
|
-
schema:
|
|
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
|
+
}],
|
|
3163
|
+
result: {
|
|
3164
|
+
mode: "strict",
|
|
3165
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
|
|
3166
|
+
schema: scopesSchema
|
|
2574
3167
|
}
|
|
2575
3168
|
},
|
|
2576
3169
|
{
|
|
@@ -2607,9 +3200,9 @@ const REPORT_HOST_CONTRIBUTION = {
|
|
|
2607
3200
|
* Provide the report service and register its Remote descriptor. Rides an
|
|
2608
3201
|
* optional `typert` inject: profiles without the web stack never activate it.
|
|
2609
3202
|
*/
|
|
2610
|
-
function applyReportRemote(ctx, snapshot,
|
|
3203
|
+
function applyReportRemote(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
|
|
2611
3204
|
ctx.inject(["typert"], (remoteCtx) => {
|
|
2612
|
-
new DeepPilotReportService(remoteCtx, snapshot,
|
|
3205
|
+
new DeepPilotReportService(remoteCtx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester);
|
|
2613
3206
|
const unregister = remoteCtx.typert.register(REPORT_HOST_CONTRIBUTION);
|
|
2614
3207
|
remoteCtx.effect(() => () => void unregister(), "dsh-deeppilot: report remote");
|
|
2615
3208
|
});
|
|
@@ -3027,6 +3620,9 @@ var RelayClient = class {
|
|
|
3027
3620
|
}
|
|
3028
3621
|
}
|
|
3029
3622
|
};
|
|
3623
|
+
function normalizeFunnelConnectionLimit(value) {
|
|
3624
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 16 ? value : 8;
|
|
3625
|
+
}
|
|
3030
3626
|
//#endregion
|
|
3031
3627
|
//#region src/remote-supervisor.ts
|
|
3032
3628
|
const RESTART_DELAYS_MS = [
|
|
@@ -3056,6 +3652,20 @@ function normalizeRemoteHostname(value) {
|
|
|
3056
3652
|
].includes(hostname.toLowerCase())) return DEFAULT_REMOTE_HOSTNAME;
|
|
3057
3653
|
return hostname;
|
|
3058
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
|
+
}
|
|
3059
3669
|
/** Parse one helper IPC line without ever evaluating or interpolating it. */
|
|
3060
3670
|
function parseHelperEvent(line) {
|
|
3061
3671
|
try {
|
|
@@ -3179,16 +3789,7 @@ var RemoteSupervisor = class {
|
|
|
3179
3789
|
phase: "starting",
|
|
3180
3790
|
message: void 0
|
|
3181
3791
|
});
|
|
3182
|
-
const child = spawn(helper,
|
|
3183
|
-
"--origin",
|
|
3184
|
-
originURL,
|
|
3185
|
-
"--hostname",
|
|
3186
|
-
normalizeRemoteHostname(this.options.hostname),
|
|
3187
|
-
"--state-dir",
|
|
3188
|
-
statePath,
|
|
3189
|
-
"--port",
|
|
3190
|
-
String(this.options.funnelPort ?? 443)
|
|
3191
|
-
], {
|
|
3792
|
+
const child = spawn(helper, tunnelHelperArguments(originURL, statePath, this.options), {
|
|
3192
3793
|
stdio: [
|
|
3193
3794
|
"ignore",
|
|
3194
3795
|
"pipe",
|
|
@@ -3539,8 +4140,7 @@ var UpdateChecker = class {
|
|
|
3539
4140
|
const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
|
|
3540
4141
|
const Config = z.object({
|
|
3541
4142
|
enabled: z.boolean().default(true),
|
|
3542
|
-
|
|
3543
|
-
devicesPath: z.string().default(join(bridgeDataDir(), "devices.json")),
|
|
4143
|
+
devicesPath: z.string().default(join(bridgeDataDir(), "devices-v2.json")),
|
|
3544
4144
|
historyBufferMax: z.natural().min(100).default(2e3),
|
|
3545
4145
|
debug: z.boolean().default(false),
|
|
3546
4146
|
remote: z.object({
|
|
@@ -3553,14 +4153,16 @@ const Config = z.object({
|
|
|
3553
4153
|
443,
|
|
3554
4154
|
8443,
|
|
3555
4155
|
1e4
|
|
3556
|
-
]).default(443)
|
|
4156
|
+
]).default(443),
|
|
4157
|
+
maxConnectionsPerSource: z.natural().min(1).max(16).default(8).description("Funnel 每个来源允许的并发连接数(1–16,修改后远程连接会短暂重连)")
|
|
3557
4158
|
}).default({
|
|
3558
4159
|
enabled: false,
|
|
3559
4160
|
provider: "tailscale-funnel",
|
|
3560
4161
|
hostname: DEFAULT_REMOTE_HOSTNAME,
|
|
3561
4162
|
statePath: join(bridgeDataDir(), "tailscale"),
|
|
3562
4163
|
helperPath: "",
|
|
3563
|
-
funnelPort: 443
|
|
4164
|
+
funnelPort: 443,
|
|
4165
|
+
maxConnectionsPerSource: 8
|
|
3564
4166
|
}),
|
|
3565
4167
|
push: z.object({
|
|
3566
4168
|
provider: z.union([
|
|
@@ -3596,7 +4198,8 @@ function normalizeOptions(options) {
|
|
|
3596
4198
|
}
|
|
3597
4199
|
//#endregion
|
|
3598
4200
|
//#region src/phone-http.ts
|
|
3599
|
-
|
|
4201
|
+
const CLIENT_IP_HEADER = "x-deeppilot-client-ip";
|
|
4202
|
+
function rejectUpgrade(socket, status, reason, retryAfterSeconds) {
|
|
3600
4203
|
const body = JSON.stringify({ error: reason });
|
|
3601
4204
|
const statusText = {
|
|
3602
4205
|
401: "Unauthorized",
|
|
@@ -3604,23 +4207,149 @@ function rejectUpgrade(socket, status, reason) {
|
|
|
3604
4207
|
500: "Internal Server Error",
|
|
3605
4208
|
503: "Service Unavailable"
|
|
3606
4209
|
};
|
|
3607
|
-
const
|
|
3608
|
-
socket.end("HTTP/1.1 " + status + " " + (statusText[status] ?? "Error") + "\r\n" +
|
|
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);
|
|
3609
4212
|
}
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
const
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
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;
|
|
3621
4231
|
}
|
|
4232
|
+
return normalizedAddress(req.socket.remoteAddress) ?? "unknown";
|
|
3622
4233
|
}
|
|
3623
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
|
|
3624
4353
|
//#region src/push-policy.ts
|
|
3625
4354
|
/** Prune only when the provider supplies an authoritative token-lifecycle verdict. */
|
|
3626
4355
|
function shouldPrunePushToken(outcome, reason) {
|
|
@@ -3643,12 +4372,13 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
|
|
|
3643
4372
|
* optional health probe (/phone/health) on the existing web server. The web
|
|
3644
4373
|
* UI is never touched.
|
|
3645
4374
|
*
|
|
3646
|
-
* Data plane: an in-process HostBridge consumes
|
|
3647
|
-
*
|
|
3648
|
-
* and fans projected protocol-
|
|
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.
|
|
3649
4379
|
*
|
|
3650
4380
|
* Protocol: PROTOCOL.md is normative; src/protocol.ts and the private app's
|
|
3651
|
-
* Swift models mirror that
|
|
4381
|
+
* Swift models mirror that v2 contract.
|
|
3652
4382
|
*/
|
|
3653
4383
|
const name = "deeppilot";
|
|
3654
4384
|
/** No eager service requirement: profiles without a web stack simply skip. */
|
|
@@ -3682,6 +4412,8 @@ function apply(ctx, options) {
|
|
|
3682
4412
|
const log = (message) => {
|
|
3683
4413
|
console.log("[deeppilot] " + message);
|
|
3684
4414
|
};
|
|
4415
|
+
const auditSalt = randomBytes(32);
|
|
4416
|
+
const auditLabel = (value) => createHash("sha256").update(auditSalt).update(value).digest("hex").slice(0, 12);
|
|
3685
4417
|
/**
|
|
3686
4418
|
* Settings-section source: while a settings service is attached this holds
|
|
3687
4419
|
* the user-edited section value; otherwise the composition defaults. Read
|
|
@@ -3736,9 +4468,9 @@ function apply(ctx, options) {
|
|
|
3736
4468
|
const url = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3737
4469
|
await ensureRelayEnrolled(url);
|
|
3738
4470
|
};
|
|
4471
|
+
const pairingCodes = new PairingCodeManager();
|
|
3739
4472
|
const auth = {
|
|
3740
|
-
|
|
3741
|
-
tokenPath: cfg.authTokenPath ?? join(dataDir, "auth-token"),
|
|
4473
|
+
audience: null,
|
|
3742
4474
|
devices: null
|
|
3743
4475
|
};
|
|
3744
4476
|
const ready = (async () => {
|
|
@@ -3749,13 +4481,13 @@ function apply(ctx, options) {
|
|
|
3749
4481
|
} catch (error) {
|
|
3750
4482
|
log("legacy plugin-state migration skipped: " + String(error));
|
|
3751
4483
|
}
|
|
3752
|
-
|
|
3753
|
-
auth.
|
|
3754
|
-
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"));
|
|
3755
4487
|
{
|
|
3756
4488
|
const rows = auth.devices.list();
|
|
3757
4489
|
const registered = rows.filter((row) => row.apns !== void 0).length;
|
|
3758
|
-
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)`);
|
|
3759
4491
|
}
|
|
3760
4492
|
try {
|
|
3761
4493
|
const raw = JSON.parse(await readFile(pushRelayPath, "utf8"));
|
|
@@ -3765,48 +4497,24 @@ function apply(ctx, options) {
|
|
|
3765
4497
|
if (raw.autoRelay === true) enrollmentCell.autoRelay = true;
|
|
3766
4498
|
} catch {}
|
|
3767
4499
|
} catch (error) {
|
|
3768
|
-
|
|
3769
|
-
if (message.includes("pairing token is malformed at")) console.warn("[deeppilot] " + message);
|
|
3770
|
-
log("auth material unavailable, bridge degraded: " + message);
|
|
4500
|
+
log("auth material unavailable, bridge degraded: " + String(error));
|
|
3771
4501
|
return {
|
|
3772
|
-
|
|
4502
|
+
audience: null,
|
|
3773
4503
|
devices: null
|
|
3774
4504
|
};
|
|
3775
4505
|
}
|
|
3776
4506
|
return {
|
|
3777
|
-
|
|
4507
|
+
audience: auth.audience,
|
|
3778
4508
|
devices: auth.devices
|
|
3779
4509
|
};
|
|
3780
4510
|
})();
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
* Serialized through rotateTail so two overlapping invocations can never
|
|
3789
|
-
* return a token that a later write already invalidated.
|
|
3790
|
-
*/
|
|
3791
|
-
const doRotate = async () => {
|
|
3792
|
-
const { devices } = await ready;
|
|
3793
|
-
if (auth.token === null || devices === null) throw new Error("pairing token unavailable");
|
|
3794
|
-
auth.token = await writeNewToken(auth.tokenPath);
|
|
3795
|
-
devices.clear();
|
|
3796
|
-
let dropped = 0;
|
|
3797
|
-
for (const connection of connections) {
|
|
3798
|
-
connection.terminate();
|
|
3799
|
-
dropped += 1;
|
|
3800
|
-
}
|
|
3801
|
-
connections.clear();
|
|
3802
|
-
log(`pairing token rotated; ${dropped} live phone connection(s) dropped`);
|
|
3803
|
-
return auth.token;
|
|
3804
|
-
};
|
|
3805
|
-
let rotateTail = Promise.resolve();
|
|
3806
|
-
const rotatePairingToken = () => {
|
|
3807
|
-
const next = rotateTail.then(doRotate);
|
|
3808
|
-
rotateTail = next.catch(() => {});
|
|
3809
|
-
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
|
+
};
|
|
3810
4518
|
};
|
|
3811
4519
|
/**
|
|
3812
4520
|
* Settings-page push self-test: force one synthetic notification down the
|
|
@@ -4138,17 +4846,20 @@ function apply(ctx, options) {
|
|
|
4138
4846
|
updateChecker.scheduleInitial();
|
|
4139
4847
|
const updateInfo = () => updateChecker.get();
|
|
4140
4848
|
applyReportRemote(ctx, async () => {
|
|
4141
|
-
let
|
|
4849
|
+
let pairingReady = false;
|
|
4142
4850
|
let devices = [];
|
|
4143
4851
|
try {
|
|
4144
4852
|
await ready;
|
|
4145
|
-
|
|
4146
|
-
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 }) => ({
|
|
4147
4855
|
deviceId,
|
|
4148
4856
|
deviceName,
|
|
4149
4857
|
appVersion,
|
|
4150
4858
|
firstSeenTs,
|
|
4151
4859
|
lastSeenTs,
|
|
4860
|
+
fingerprint,
|
|
4861
|
+
scopes: normalizeDeviceScopes(scopes),
|
|
4862
|
+
...revokedAt !== void 0 ? { revokedAt } : {},
|
|
4152
4863
|
...apns ? { apns: {
|
|
4153
4864
|
environment: apns.environment,
|
|
4154
4865
|
updatedAt: apns.updatedAt
|
|
@@ -4157,14 +4868,14 @@ function apply(ctx, options) {
|
|
|
4157
4868
|
} catch {}
|
|
4158
4869
|
const update = updateInfo();
|
|
4159
4870
|
return {
|
|
4160
|
-
protocolVersion:
|
|
4871
|
+
protocolVersion: 2,
|
|
4161
4872
|
serverVersion: SERVER_VERSION,
|
|
4162
4873
|
pluginVersion: update.currentVersion,
|
|
4163
4874
|
...update.available ? { updateAvailable: true } : {},
|
|
4164
4875
|
...update.releaseUrl !== null ? { releaseUrl: update.releaseUrl } : {},
|
|
4165
4876
|
enabled: currentConfig().enabled === true,
|
|
4166
|
-
|
|
4167
|
-
|
|
4877
|
+
identityPath: expandHome(currentConfig().devicesPath ?? join(bridgeDataDir(), "devices-v2.json")),
|
|
4878
|
+
pairingReady,
|
|
4168
4879
|
activeConnections: connections.size,
|
|
4169
4880
|
historyBufferMax: currentConfig().historyBufferMax ?? 2e3,
|
|
4170
4881
|
debug: currentConfig().debug === true,
|
|
@@ -4172,11 +4883,32 @@ function apply(ctx, options) {
|
|
|
4172
4883
|
remote: remoteStatus(),
|
|
4173
4884
|
devices
|
|
4174
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;
|
|
4175
4911
|
}, async () => {
|
|
4176
|
-
await ready;
|
|
4177
|
-
if (auth.token === null) throw new Error("pairing token unavailable");
|
|
4178
|
-
return auth.token;
|
|
4179
|
-
}, rotatePairingToken, async () => {
|
|
4180
4912
|
const push = currentConfig().push ?? {};
|
|
4181
4913
|
const configured = push.provider ?? "none";
|
|
4182
4914
|
if ((configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured) !== "relay") return {
|
|
@@ -4220,6 +4952,102 @@ function apply(ctx, options) {
|
|
|
4220
4952
|
});
|
|
4221
4953
|
const state = {};
|
|
4222
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
|
+
};
|
|
4223
5051
|
const handleUpgrade = (req, socket, head) => {
|
|
4224
5052
|
(async () => {
|
|
4225
5053
|
try {
|
|
@@ -4233,44 +5061,60 @@ function apply(ctx, options) {
|
|
|
4233
5061
|
}
|
|
4234
5062
|
pendingUpgrades += 1;
|
|
4235
5063
|
try {
|
|
4236
|
-
const { devices } = await ready;
|
|
4237
|
-
|
|
4238
|
-
if (!token || !devices) {
|
|
5064
|
+
const { devices, audience } = await ready;
|
|
5065
|
+
if (!audience || !devices) {
|
|
4239
5066
|
rejectUpgrade(socket, 503, "bridge degraded");
|
|
4240
5067
|
return;
|
|
4241
5068
|
}
|
|
4242
|
-
const
|
|
4243
|
-
|
|
4244
|
-
|
|
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);
|
|
4245
5073
|
return;
|
|
4246
5074
|
}
|
|
4247
5075
|
const bridge = state.bridge;
|
|
4248
5076
|
if (!bridge) {
|
|
5077
|
+
admission.release();
|
|
4249
5078
|
rejectUpgrade(socket, 503, "bridge not ready");
|
|
4250
5079
|
return;
|
|
4251
5080
|
}
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
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
|
+
}
|
|
4271
5113
|
});
|
|
4272
|
-
|
|
4273
|
-
|
|
5114
|
+
} catch (error) {
|
|
5115
|
+
admission.release();
|
|
5116
|
+
throw error;
|
|
5117
|
+
}
|
|
4274
5118
|
} finally {
|
|
4275
5119
|
pendingUpgrades -= 1;
|
|
4276
5120
|
}
|
|
@@ -4283,9 +5127,8 @@ function apply(ctx, options) {
|
|
|
4283
5127
|
const handleHealth = async (req, res) => {
|
|
4284
5128
|
try {
|
|
4285
5129
|
await ready;
|
|
4286
|
-
const token = auth.token;
|
|
4287
5130
|
res.setHeader("Content-Type", "application/json");
|
|
4288
|
-
if (!
|
|
5131
|
+
if (!auth.audience || !auth.devices) {
|
|
4289
5132
|
res.statusCode = 503;
|
|
4290
5133
|
res.end(JSON.stringify({
|
|
4291
5134
|
ok: false,
|
|
@@ -4293,16 +5136,11 @@ function apply(ctx, options) {
|
|
|
4293
5136
|
}));
|
|
4294
5137
|
return;
|
|
4295
5138
|
}
|
|
4296
|
-
if (!tokenMatches(requestToken(req), token)) {
|
|
4297
|
-
res.statusCode = 401;
|
|
4298
|
-
res.end(JSON.stringify({ ok: false }));
|
|
4299
|
-
return;
|
|
4300
|
-
}
|
|
4301
5139
|
res.statusCode = 200;
|
|
4302
5140
|
res.end(JSON.stringify({
|
|
4303
5141
|
ok: true,
|
|
4304
5142
|
enabled: enabledNow(),
|
|
4305
|
-
protocolVersion:
|
|
5143
|
+
protocolVersion: 2,
|
|
4306
5144
|
serverVersion: SERVER_VERSION,
|
|
4307
5145
|
dataPlane: Boolean(state.bridge)
|
|
4308
5146
|
}));
|
|
@@ -4311,15 +5149,17 @@ function apply(ctx, options) {
|
|
|
4311
5149
|
res.end(JSON.stringify({ ok: false }));
|
|
4312
5150
|
}
|
|
4313
5151
|
};
|
|
4314
|
-
ctx.inject(["
|
|
5152
|
+
ctx.inject(["sessionController"], (sub) => {
|
|
4315
5153
|
if (currentConfig().enabled !== true) {
|
|
4316
5154
|
log("bridge disabled; data plane stays inactive");
|
|
4317
5155
|
return;
|
|
4318
5156
|
}
|
|
4319
5157
|
const apiCtx = sub;
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
5158
|
+
let proxy;
|
|
5159
|
+
try {
|
|
5160
|
+
proxy = new Dsh012ApiProxy(apiCtx);
|
|
5161
|
+
} catch (error) {
|
|
5162
|
+
log("dsh 0.1.2 session bridge unavailable: " + String(error));
|
|
4323
5163
|
return;
|
|
4324
5164
|
}
|
|
4325
5165
|
const bridge = new HostBridge(proxy, cfg.historyBufferMax);
|
|
@@ -4349,6 +5189,11 @@ function apply(ctx, options) {
|
|
|
4349
5189
|
path: "/phone/health",
|
|
4350
5190
|
handler: handleHealth
|
|
4351
5191
|
}), "deeppilot: /phone/health");
|
|
5192
|
+
webCtx.effect(() => web.register({
|
|
5193
|
+
kind: "exact",
|
|
5194
|
+
path: "/phone/pair",
|
|
5195
|
+
handler: handlePair
|
|
5196
|
+
}), "deeppilot: /phone/pair");
|
|
4352
5197
|
const sweep = setInterval(() => {
|
|
4353
5198
|
const now = Date.now();
|
|
4354
5199
|
for (const connection of connections) if (connection.isStale(now, 6e4)) {
|
|
@@ -4364,6 +5209,7 @@ function apply(ctx, options) {
|
|
|
4364
5209
|
path = new URL(req.url ?? "/", "http://phone.local").pathname;
|
|
4365
5210
|
} catch {}
|
|
4366
5211
|
if (path === "/phone/health") handleHealth(req, res);
|
|
5212
|
+
else if (path === "/phone/pair") handlePair(req, res);
|
|
4367
5213
|
else {
|
|
4368
5214
|
res.statusCode = 404;
|
|
4369
5215
|
res.end("not found");
|
|
@@ -4395,7 +5241,8 @@ function apply(ctx, options) {
|
|
|
4395
5241
|
hostname: normalizeRemoteHostname(remoteConfig.hostname),
|
|
4396
5242
|
statePath: remoteConfig.statePath?.trim() || join(dataDir, "tailscale"),
|
|
4397
5243
|
helperPath,
|
|
4398
|
-
funnelPort: remotePort
|
|
5244
|
+
funnelPort: remotePort,
|
|
5245
|
+
maxConnectionsPerSource: normalizeFunnelConnectionLimit(remoteConfig.maxConnectionsPerSource)
|
|
4399
5246
|
};
|
|
4400
5247
|
const nextKey = JSON.stringify(next);
|
|
4401
5248
|
if (nextKey === appliedRemoteKey) return;
|
|
@@ -4409,6 +5256,7 @@ function apply(ctx, options) {
|
|
|
4409
5256
|
statePath: next.statePath,
|
|
4410
5257
|
...next.helperPath ? { helperPath: next.helperPath } : {},
|
|
4411
5258
|
funnelPort: next.funnelPort,
|
|
5259
|
+
maxConnectionsPerSource: next.maxConnectionsPerSource,
|
|
4412
5260
|
log
|
|
4413
5261
|
});
|
|
4414
5262
|
remoteSupervisor = supervisor;
|
|
@@ -4456,6 +5304,6 @@ function apply(ctx, options) {
|
|
|
4456
5304
|
}, "deeppilot: process resources");
|
|
4457
5305
|
}
|
|
4458
5306
|
//#endregion
|
|
4459
|
-
export { Config, HostBridge, apply, inject, name,
|
|
5307
|
+
export { Config, HostBridge, apply, inject, name, shouldPrunePushToken, shouldReEnrollRelayToken };
|
|
4460
5308
|
|
|
4461
5309
|
//# sourceMappingURL=index.js.map
|