dsh-deeppilot 0.4.0 → 0.6.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMPATIBILITY.md +22 -17
- package/PRIVACY.md +15 -43
- package/README.md +66 -9
- package/README.zh-CN.md +56 -8
- 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 +1151 -301
- package/lib/index.js.map +1 -1
- package/package.json +56 -13
package/lib/index.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
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";
|
|
8
|
-
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
9
8
|
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
10
9
|
import { connect } from "node:http2";
|
|
11
10
|
import { spawn } from "node:child_process";
|
|
@@ -13,6 +12,128 @@ import { constants } from "node:fs";
|
|
|
13
12
|
import { fileURLToPath } from "node:url";
|
|
14
13
|
import { request } from "node:https";
|
|
15
14
|
import z from "@deepseek-ai/schemastery";
|
|
15
|
+
import { isIP } from "node:net";
|
|
16
|
+
//#region src/device-auth.ts
|
|
17
|
+
const PAIRING_CODE_TTL_MS = 3e5;
|
|
18
|
+
const AUTH_CHALLENGE_TTL_MS = 3e4;
|
|
19
|
+
const DEVICE_SCOPES = [
|
|
20
|
+
"sessions.read",
|
|
21
|
+
"prompt.send",
|
|
22
|
+
"sessions.manage",
|
|
23
|
+
"interactions.respond",
|
|
24
|
+
"notifications.register"
|
|
25
|
+
];
|
|
26
|
+
const DEFAULT_DEVICE_SCOPES = DEVICE_SCOPES;
|
|
27
|
+
async function loadOrCreateHostAudience(path) {
|
|
28
|
+
try {
|
|
29
|
+
const existing = (await readFile(path, "utf8")).trim();
|
|
30
|
+
if (/^deeppilot:[A-Za-z0-9_-]{22}$/.test(existing)) return existing;
|
|
31
|
+
throw new Error(`host audience is malformed at ${path}`);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code !== "ENOENT") throw error;
|
|
34
|
+
}
|
|
35
|
+
const audience = "deeppilot:" + randomBytes(16).toString("base64url");
|
|
36
|
+
await mkdir(dirname(path), { recursive: true });
|
|
37
|
+
await writeFile(path, audience + "\n", { mode: 384 });
|
|
38
|
+
return audience;
|
|
39
|
+
}
|
|
40
|
+
function b64urlText(value) {
|
|
41
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Cross-language signature input. Text fields are base64url encoded before
|
|
45
|
+
* joining so names cannot create ambiguous separators. Decimal timestamps
|
|
46
|
+
* and cursor values are finite integers, or `-` when the cursor is absent.
|
|
47
|
+
*/
|
|
48
|
+
function canonicalAuthChallenge(fields) {
|
|
49
|
+
const cursor = fields.resumeCursor === void 0 ? "-" : String(fields.resumeCursor);
|
|
50
|
+
return Buffer.from([
|
|
51
|
+
"deeppilot-auth-v2",
|
|
52
|
+
`device-id:${b64urlText(fields.deviceId)}`,
|
|
53
|
+
`nonce:${fields.nonce}`,
|
|
54
|
+
`audience:${b64urlText(fields.audience)}`,
|
|
55
|
+
`issued-at:${fields.issuedAt}`,
|
|
56
|
+
`expires-at:${fields.expiresAt}`,
|
|
57
|
+
`device-name:${b64urlText(fields.deviceName)}`,
|
|
58
|
+
`app-version:${b64urlText(fields.appVersion)}`,
|
|
59
|
+
`resume-cursor:${cursor}`
|
|
60
|
+
].join("\n"), "utf8");
|
|
61
|
+
}
|
|
62
|
+
/** Accept only an uncompressed ANSI X9.63 P-256 public key (65 bytes). */
|
|
63
|
+
function parseP256PublicKey(encoded) {
|
|
64
|
+
const raw = Buffer.from(encoded, "base64url");
|
|
65
|
+
if (raw.length !== 65 || raw[0] !== 4) throw new TypeError("publicKey must be an uncompressed P-256 X9.63 key");
|
|
66
|
+
const spkiPrefix = Buffer.from("3059301306072a8648ce3d020106082a8648ce3d030107034200", "hex");
|
|
67
|
+
const key = createPublicKey({
|
|
68
|
+
key: Buffer.concat([spkiPrefix, raw]),
|
|
69
|
+
format: "der",
|
|
70
|
+
type: "spki"
|
|
71
|
+
});
|
|
72
|
+
if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new TypeError("publicKey must use P-256");
|
|
73
|
+
return {
|
|
74
|
+
key,
|
|
75
|
+
raw
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function deviceIdForPublicKey(publicKey) {
|
|
79
|
+
const { raw } = parseP256PublicKey(publicKey);
|
|
80
|
+
return createHash("sha256").update(raw).digest("base64url");
|
|
81
|
+
}
|
|
82
|
+
function fingerprintForPublicKey(publicKey) {
|
|
83
|
+
const { raw } = parseP256PublicKey(publicKey);
|
|
84
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
85
|
+
}
|
|
86
|
+
function verifyAuthProof(publicKey, fields, signature) {
|
|
87
|
+
try {
|
|
88
|
+
const { key } = parseP256PublicKey(publicKey);
|
|
89
|
+
const der = Buffer.from(signature, "base64url");
|
|
90
|
+
if (der.length < 64 || der.length > 80) return false;
|
|
91
|
+
return verify("sha256", canonicalAuthChallenge(fields), key, der);
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function normalizeDeviceScopes(value) {
|
|
97
|
+
if (!Array.isArray(value)) return [...DEFAULT_DEVICE_SCOPES];
|
|
98
|
+
const allowed = new Set(DEVICE_SCOPES);
|
|
99
|
+
return [...new Set(value.filter((scope) => typeof scope === "string" && allowed.has(scope)))];
|
|
100
|
+
}
|
|
101
|
+
/** One active, single-use pairing grant per plugin runtime. */
|
|
102
|
+
var PairingCodeManager = class {
|
|
103
|
+
active = null;
|
|
104
|
+
issue(now = Date.now()) {
|
|
105
|
+
const grant = {
|
|
106
|
+
code: randomBytes(24).toString("base64url"),
|
|
107
|
+
expiresAt: now + PAIRING_CODE_TTL_MS
|
|
108
|
+
};
|
|
109
|
+
this.active = grant;
|
|
110
|
+
return { ...grant };
|
|
111
|
+
}
|
|
112
|
+
consume(presented, now = Date.now()) {
|
|
113
|
+
const active = this.active;
|
|
114
|
+
if (active === null || now > active.expiresAt) {
|
|
115
|
+
this.active = null;
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const expected = Buffer.from(active.code);
|
|
119
|
+
const actual = Buffer.from(presented);
|
|
120
|
+
const matches = expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
121
|
+
if (matches) this.active = null;
|
|
122
|
+
return matches;
|
|
123
|
+
}
|
|
124
|
+
invalidate() {
|
|
125
|
+
this.active = null;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
function createAuthChallenge(audience, now = Date.now()) {
|
|
129
|
+
return {
|
|
130
|
+
nonce: randomBytes(24).toString("base64url"),
|
|
131
|
+
audience,
|
|
132
|
+
issuedAt: now,
|
|
133
|
+
expiresAt: now + AUTH_CHALLENGE_TTL_MS
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
16
137
|
//#region src/token.ts
|
|
17
138
|
/** Expand a leading ~ using the process home directory. */
|
|
18
139
|
function expandHome(p) {
|
|
@@ -29,6 +150,16 @@ function dshDataRoot() {
|
|
|
29
150
|
function bridgeDataDir() {
|
|
30
151
|
return resolve(dshDataRoot(), "deeppilot");
|
|
31
152
|
}
|
|
153
|
+
/** Create or repair the canonical secret-bearing directory as owner-only. */
|
|
154
|
+
async function ensurePrivateBridgeDataDir() {
|
|
155
|
+
const target = bridgeDataDir();
|
|
156
|
+
await mkdir(target, {
|
|
157
|
+
recursive: true,
|
|
158
|
+
mode: 448
|
|
159
|
+
});
|
|
160
|
+
await chmod(target, 448);
|
|
161
|
+
return target;
|
|
162
|
+
}
|
|
32
163
|
/**
|
|
33
164
|
* Move the pre-DeepPilot data directory as one atomic directory rename.
|
|
34
165
|
* Existing canonical data always wins; secrets are never merged or replaced.
|
|
@@ -50,63 +181,6 @@ async function migrateLegacyBridgeDataDir() {
|
|
|
50
181
|
throw error;
|
|
51
182
|
}
|
|
52
183
|
}
|
|
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
184
|
/** Hex shape of an APNs device token as delivered by iOS (usually 64 chars). */
|
|
111
185
|
const APNS_TOKEN_PATTERN = /^[0-9a-f]{32,512}$/;
|
|
112
186
|
function isValidApnsToken(token) {
|
|
@@ -134,32 +208,57 @@ var DeviceStore = class DeviceStore {
|
|
|
134
208
|
}
|
|
135
209
|
return store;
|
|
136
210
|
}
|
|
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
|
-
}
|
|
211
|
+
/** Register one public key after a valid, single-use pairing grant. */
|
|
212
|
+
register(record, now) {
|
|
213
|
+
const deviceId = deviceIdForPublicKey(record.publicKey);
|
|
214
|
+
const existing = this.devices.get(deviceId);
|
|
215
|
+
if (!existing && this.devices.size >= 64) throw new Error("device registry is full");
|
|
216
|
+
const next = {
|
|
217
|
+
deviceId,
|
|
218
|
+
deviceName: record.deviceName,
|
|
219
|
+
appVersion: record.appVersion,
|
|
220
|
+
publicKey: record.publicKey,
|
|
221
|
+
fingerprint: fingerprintForPublicKey(record.publicKey),
|
|
222
|
+
scopes: normalizeDeviceScopes(record.scopes),
|
|
223
|
+
firstSeenTs: existing?.firstSeenTs ?? now,
|
|
224
|
+
lastSeenTs: now,
|
|
225
|
+
...existing?.apns ? { apns: existing.apns } : {}
|
|
226
|
+
};
|
|
227
|
+
this.devices.set(deviceId, next);
|
|
159
228
|
this.flush();
|
|
229
|
+
return structuredClone(next);
|
|
230
|
+
}
|
|
231
|
+
/** Return an active cryptographic identity. */
|
|
232
|
+
authorized(deviceId) {
|
|
233
|
+
const record = this.devices.get(deviceId);
|
|
234
|
+
if (!record?.publicKey || record.revokedAt !== void 0) return void 0;
|
|
235
|
+
return record;
|
|
236
|
+
}
|
|
237
|
+
markAuthenticated(deviceId, deviceName, appVersion, now) {
|
|
238
|
+
const record = this.authorized(deviceId);
|
|
239
|
+
if (!record) return;
|
|
240
|
+
record.deviceName = deviceName || record.deviceName;
|
|
241
|
+
record.appVersion = appVersion || record.appVersion;
|
|
242
|
+
record.lastSeenTs = now;
|
|
243
|
+
this.flush();
|
|
244
|
+
}
|
|
245
|
+
revoke(deviceId, now) {
|
|
246
|
+
const record = this.devices.get(deviceId);
|
|
247
|
+
if (!record || record.revokedAt !== void 0) return false;
|
|
248
|
+
record.revokedAt = now;
|
|
249
|
+
delete record.apns;
|
|
250
|
+
this.flush();
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
setScopes(deviceId, scopes) {
|
|
254
|
+
const record = this.authorized(deviceId);
|
|
255
|
+
if (!record) return null;
|
|
256
|
+
record.scopes = normalizeDeviceScopes(scopes);
|
|
257
|
+
this.flush();
|
|
258
|
+
return [...record.scopes];
|
|
160
259
|
}
|
|
161
260
|
list() {
|
|
162
|
-
return [...this.devices.values()];
|
|
261
|
+
return [...this.devices.values()].map((record) => structuredClone(record));
|
|
163
262
|
}
|
|
164
263
|
/**
|
|
165
264
|
* Store (or refresh) the APNs registration of a paired device. Idempotent:
|
|
@@ -169,17 +268,8 @@ var DeviceStore = class DeviceStore {
|
|
|
169
268
|
setPushToken(deviceId, token, environment, categories, now) {
|
|
170
269
|
const normalized = token.toLowerCase();
|
|
171
270
|
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
|
-
}
|
|
271
|
+
const record = this.authorized(deviceId);
|
|
272
|
+
if (!record) return;
|
|
183
273
|
const next = {
|
|
184
274
|
token: normalized,
|
|
185
275
|
environment,
|
|
@@ -202,15 +292,6 @@ var DeviceStore = class DeviceStore {
|
|
|
202
292
|
delete record.apns;
|
|
203
293
|
this.flush();
|
|
204
294
|
}
|
|
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
295
|
/** Serialized so concurrent touches can never interleave half-written JSON. */
|
|
215
296
|
flush() {
|
|
216
297
|
const next = this.flushTail.then(() => this.writeFile());
|
|
@@ -224,7 +305,7 @@ var DeviceStore = class DeviceStore {
|
|
|
224
305
|
async writeFile() {
|
|
225
306
|
const full = expandHome(this.filePath);
|
|
226
307
|
const body = JSON.stringify({
|
|
227
|
-
version:
|
|
308
|
+
version: 2,
|
|
228
309
|
devices: this.list()
|
|
229
310
|
}, null, 2);
|
|
230
311
|
try {
|
|
@@ -237,7 +318,7 @@ var DeviceStore = class DeviceStore {
|
|
|
237
318
|
};
|
|
238
319
|
//#endregion
|
|
239
320
|
//#region src/connection-policy.ts
|
|
240
|
-
const AUTH_TIMEOUT_MS =
|
|
321
|
+
const AUTH_TIMEOUT_MS = 35e3;
|
|
241
322
|
const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
242
323
|
"image/png",
|
|
243
324
|
"image/jpeg",
|
|
@@ -247,8 +328,13 @@ const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
|
|
|
247
328
|
function sanitizeDeviceField(value, maxChars) {
|
|
248
329
|
return (typeof value === "string" ? value : String(value ?? "")).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxChars);
|
|
249
330
|
}
|
|
250
|
-
function
|
|
251
|
-
|
|
331
|
+
function requiredScope(type) {
|
|
332
|
+
if (type === "c2s.ping" || type === "c2s.resume") return void 0;
|
|
333
|
+
if (type === "c2s.session.sendPrompt") return "prompt.send";
|
|
334
|
+
if (type === "c2s.approval.respond" || type === "c2s.question.respond") return "interactions.respond";
|
|
335
|
+
if (type === "c2s.push.register") return "notifications.register";
|
|
336
|
+
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";
|
|
337
|
+
if (type.startsWith("c2s.")) return "sessions.read";
|
|
252
338
|
}
|
|
253
339
|
function sanitizeImageName(value) {
|
|
254
340
|
return value.replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, 120);
|
|
@@ -283,21 +369,31 @@ function managementErrorCode(kind) {
|
|
|
283
369
|
//#region src/connection.ts
|
|
284
370
|
/**
|
|
285
371
|
* One connected phone. Implements BridgeSink so the HostBridge can push
|
|
286
|
-
* projected frames and replays.
|
|
287
|
-
*
|
|
372
|
+
* projected frames and replays. Every socket starts anonymous, receives one
|
|
373
|
+
* server challenge, and must prove possession of a registered P-256 key.
|
|
288
374
|
*/
|
|
289
375
|
var BridgeConnection = class {
|
|
290
376
|
ws;
|
|
291
377
|
deps;
|
|
292
378
|
authenticated = false;
|
|
379
|
+
authenticationSettled = false;
|
|
293
380
|
closed = false;
|
|
294
381
|
helloTimer;
|
|
295
382
|
openSessions = /* @__PURE__ */ new Set();
|
|
383
|
+
/**
|
|
384
|
+
* Realtime events that arrive while a session's history snapshot is in
|
|
385
|
+
* flight. The wire contract requires tail first; sending these immediately
|
|
386
|
+
* lets the later tail roll the client back over messages it just rendered.
|
|
387
|
+
*/
|
|
388
|
+
openingSessionEvents = /* @__PURE__ */ new Map();
|
|
296
389
|
/** Sanitized device identity from hello; needed for push registration. */
|
|
297
390
|
deviceId;
|
|
391
|
+
scopes = /* @__PURE__ */ new Set();
|
|
392
|
+
authChallenge;
|
|
298
393
|
constructor(ws, deps) {
|
|
299
394
|
this.ws = ws;
|
|
300
395
|
this.deps = deps;
|
|
396
|
+
this.authChallenge = createAuthChallenge(deps.audience);
|
|
301
397
|
ws.on("message", (data) => {
|
|
302
398
|
this.onMessage(String(data));
|
|
303
399
|
});
|
|
@@ -307,8 +403,13 @@ var BridgeConnection = class {
|
|
|
307
403
|
});
|
|
308
404
|
ws.on("error", () => {});
|
|
309
405
|
this.helloTimer = setTimeout(() => {
|
|
310
|
-
if (!this.authenticated)
|
|
406
|
+
if (!this.authenticated) {
|
|
407
|
+
this.settleAuthentication(false, "timeout");
|
|
408
|
+
this.close(4402, "auth timeout");
|
|
409
|
+
}
|
|
311
410
|
}, AUTH_TIMEOUT_MS);
|
|
411
|
+
this.helloTimer.unref();
|
|
412
|
+
this.send("s2c.auth.challenge", this.authChallenge);
|
|
312
413
|
}
|
|
313
414
|
/** Hard-drop the socket (server-side stale sweep). */
|
|
314
415
|
terminate() {
|
|
@@ -332,6 +433,20 @@ var BridgeConnection = class {
|
|
|
332
433
|
return this.authenticated ? this.deviceId : void 0;
|
|
333
434
|
}
|
|
334
435
|
push(type, payload, seq) {
|
|
436
|
+
if (type === "s2c.session.event") {
|
|
437
|
+
const sessionId = payload?.sessionId;
|
|
438
|
+
if (typeof sessionId === "string") {
|
|
439
|
+
const buffered = this.openingSessionEvents.get(sessionId);
|
|
440
|
+
if (buffered) {
|
|
441
|
+
buffered.push({
|
|
442
|
+
type,
|
|
443
|
+
payload,
|
|
444
|
+
...seq !== void 0 ? { seq } : {}
|
|
445
|
+
});
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
335
450
|
if (this.deps.debug === true) this.deps.log("push " + type + " seq=" + String(seq));
|
|
336
451
|
this.send(type, payload, void 0, seq);
|
|
337
452
|
}
|
|
@@ -350,12 +465,19 @@ var BridgeConnection = class {
|
|
|
350
465
|
onClose() {
|
|
351
466
|
if (this.closed) return;
|
|
352
467
|
this.closed = true;
|
|
468
|
+
if (!this.authenticationSettled) this.settleAuthentication(false, "closed");
|
|
353
469
|
if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
|
|
354
470
|
for (const id of this.openSessions) this.deps.bridge.markSinkClosed(this, id);
|
|
355
471
|
this.openSessions.clear();
|
|
472
|
+
this.openingSessionEvents.clear();
|
|
356
473
|
this.deps.bridge.dropSinkSessions(this);
|
|
357
474
|
if (this.authenticated) this.deps.bridge.removeSink(this);
|
|
358
475
|
}
|
|
476
|
+
settleAuthentication(ok, reason) {
|
|
477
|
+
if (this.authenticationSettled) return;
|
|
478
|
+
this.authenticationSettled = true;
|
|
479
|
+
this.deps.onAuthenticationSettled?.(ok, reason);
|
|
480
|
+
}
|
|
359
481
|
close(code, reason) {
|
|
360
482
|
if (this.closed) return;
|
|
361
483
|
try {
|
|
@@ -366,7 +488,7 @@ var BridgeConnection = class {
|
|
|
366
488
|
}
|
|
367
489
|
send(type, payload, id, seq) {
|
|
368
490
|
const envelope = {
|
|
369
|
-
v:
|
|
491
|
+
v: 2,
|
|
370
492
|
type,
|
|
371
493
|
ts: Date.now(),
|
|
372
494
|
...id !== void 0 ? { id } : {},
|
|
@@ -404,7 +526,7 @@ var BridgeConnection = class {
|
|
|
404
526
|
this.fail(void 0, "E_PROTOCOL", "frame is not valid JSON");
|
|
405
527
|
return;
|
|
406
528
|
}
|
|
407
|
-
if (env.v !==
|
|
529
|
+
if (env.v !== 2) {
|
|
408
530
|
this.fail(env.id, "E_UNSUPPORTED", "unsupported protocol version");
|
|
409
531
|
this.close(4500, "protocol version mismatch");
|
|
410
532
|
return;
|
|
@@ -414,13 +536,18 @@ var BridgeConnection = class {
|
|
|
414
536
|
this.send("s2c.pong", { serverTime: Date.now() }, env.id);
|
|
415
537
|
return;
|
|
416
538
|
}
|
|
417
|
-
if (env.type === "c2s.
|
|
418
|
-
await this.
|
|
539
|
+
if (env.type === "c2s.auth.prove") {
|
|
540
|
+
await this.prove(env);
|
|
419
541
|
return;
|
|
420
542
|
}
|
|
421
543
|
this.fail(env.id, "E_PROTOCOL", "authenticate first");
|
|
422
544
|
return;
|
|
423
545
|
}
|
|
546
|
+
const required = requiredScope(env.type);
|
|
547
|
+
if (required !== void 0 && !this.scopes.has(required)) {
|
|
548
|
+
this.fail(env.id, "E_FORBIDDEN", `scope ${required} required`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
424
551
|
switch (env.type) {
|
|
425
552
|
case "c2s.ping":
|
|
426
553
|
this.send("s2c.pong", { serverTime: Date.now() }, env.id);
|
|
@@ -469,18 +596,23 @@ var BridgeConnection = class {
|
|
|
469
596
|
const p = env.payload;
|
|
470
597
|
if (!p?.sessionId || typeof p.sessionId !== "string") return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
471
598
|
const sessionId = p.sessionId;
|
|
472
|
-
|
|
473
|
-
this.
|
|
599
|
+
const bufferedEvents = [];
|
|
600
|
+
this.openingSessionEvents.set(sessionId, bufferedEvents);
|
|
474
601
|
if (!await this.deps.bridge.openSession(this, sessionId, p.tailCount ?? 100)) {
|
|
475
|
-
this.
|
|
476
|
-
this.deps.bridge.markSinkClosed(this, sessionId);
|
|
602
|
+
if (this.openingSessionEvents.get(sessionId) === bufferedEvents) this.openingSessionEvents.delete(sessionId);
|
|
477
603
|
return this.fail(env.id, "E_NOT_FOUND", "session history unavailable");
|
|
478
604
|
}
|
|
605
|
+
if (this.openingSessionEvents.get(sessionId) !== bufferedEvents) return;
|
|
606
|
+
this.openSessions.add(sessionId);
|
|
607
|
+
this.deps.bridge.markSinkOpen(this, sessionId);
|
|
608
|
+
this.openingSessionEvents.delete(sessionId);
|
|
609
|
+
for (const frame of bufferedEvents) this.push(frame.type, frame.payload, frame.seq);
|
|
479
610
|
return;
|
|
480
611
|
}
|
|
481
612
|
case "c2s.session.close": {
|
|
482
613
|
const p = env.payload;
|
|
483
614
|
if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
|
|
615
|
+
this.openingSessionEvents.delete(p.sessionId);
|
|
484
616
|
this.openSessions.delete(p.sessionId);
|
|
485
617
|
this.deps.bridge.markSinkClosed(this, p.sessionId);
|
|
486
618
|
this.send("s2c.ack", {}, env.id);
|
|
@@ -541,7 +673,9 @@ var BridgeConnection = class {
|
|
|
541
673
|
case "c2s.session.history": {
|
|
542
674
|
const p = env.payload;
|
|
543
675
|
if (!p?.sessionId || typeof p.beforeSeq !== "number") return this.fail(env.id, "E_PROTOCOL", "sessionId and beforeSeq required");
|
|
544
|
-
|
|
676
|
+
const page = await this.deps.bridge.historyPage(p.sessionId, p.beforeSeq, Math.min(p.limit ?? 100, 500));
|
|
677
|
+
if (!page) return this.fail(env.id, "E_NOT_FOUND", "history unavailable");
|
|
678
|
+
this.send("s2c.history.page", page, env.id);
|
|
545
679
|
return;
|
|
546
680
|
}
|
|
547
681
|
case "c2s.session.attachment": {
|
|
@@ -646,13 +780,8 @@ var BridgeConnection = class {
|
|
|
646
780
|
default: this.fail(env.id, "E_PROTOCOL", "unknown type: " + env.type);
|
|
647
781
|
}
|
|
648
782
|
}
|
|
649
|
-
async
|
|
783
|
+
async prove(env) {
|
|
650
784
|
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
785
|
if (!p.deviceId) {
|
|
657
786
|
this.fail(env.id, "E_PROTOCOL", "deviceId required");
|
|
658
787
|
this.close(4403, "deviceId required");
|
|
@@ -666,20 +795,36 @@ var BridgeConnection = class {
|
|
|
666
795
|
}
|
|
667
796
|
const deviceName = sanitizeDeviceField(p.deviceName, 64) || "unknown";
|
|
668
797
|
const appVersion = sanitizeDeviceField(p.appVersion, 32) || "unknown";
|
|
798
|
+
const challenge = this.authChallenge;
|
|
799
|
+
const record = this.deps.devices.authorized(deviceId);
|
|
800
|
+
const resumeCursor = typeof p.resumeCursor === "number" && Number.isInteger(p.resumeCursor) && p.resumeCursor >= 0 ? p.resumeCursor : void 0;
|
|
801
|
+
const challengeMatches = p.nonce === challenge.nonce && p.audience === challenge.audience && p.issuedAt === challenge.issuedAt && p.expiresAt === challenge.expiresAt && Date.now() <= challenge.expiresAt;
|
|
802
|
+
if (!(record?.publicKey !== void 0 && typeof p.signature === "string" && challengeMatches && verifyAuthProof(record.publicKey, {
|
|
803
|
+
deviceId,
|
|
804
|
+
deviceName,
|
|
805
|
+
appVersion,
|
|
806
|
+
resumeCursor,
|
|
807
|
+
...challenge
|
|
808
|
+
}, p.signature)) || record === void 0) {
|
|
809
|
+
this.fail(env.id, "E_AUTH", "device proof missing or invalid");
|
|
810
|
+
this.settleAuthentication(false, "invalid-proof");
|
|
811
|
+
this.close(4401, "invalid device proof");
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
this.settleAuthentication(true, "success");
|
|
669
815
|
this.authenticated = true;
|
|
670
816
|
this.deviceId = deviceId;
|
|
817
|
+
this.scopes = new Set(record.scopes ?? []);
|
|
671
818
|
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;
|
|
819
|
+
this.deps.devices.markAuthenticated(deviceId, deviceName, appVersion, Date.now());
|
|
820
|
+
this.deps.onDeviceAuthenticated?.(deviceId);
|
|
821
|
+
const cursor = resumeCursor;
|
|
679
822
|
const canResume = cursor !== void 0 && this.deps.bridge.canResumeFrom(cursor);
|
|
680
823
|
this.send("s2c.welcome", {
|
|
681
|
-
protocolVersion:
|
|
824
|
+
protocolVersion: 2,
|
|
682
825
|
serverVersion: this.deps.serverVersion,
|
|
826
|
+
deviceId,
|
|
827
|
+
scopes: [...this.scopes],
|
|
683
828
|
capabilities: this.deps.bridge.capabilities,
|
|
684
829
|
cursor: this.deps.bridge.currentCursor(),
|
|
685
830
|
resumed: canResume
|
|
@@ -712,6 +857,29 @@ function unwrapStreamItem(item) {
|
|
|
712
857
|
//#endregion
|
|
713
858
|
//#region src/host-event-projection.ts
|
|
714
859
|
const MAX_MESSAGE_PROJECTION_BYTES = 262144;
|
|
860
|
+
/** One durable host event sequence becomes exactly one phone message row.
|
|
861
|
+
* Keep the last projection when a host history response repeats an event. */
|
|
862
|
+
function canonicalSessionMessages(messages) {
|
|
863
|
+
const bySequence = /* @__PURE__ */ new Map();
|
|
864
|
+
for (const message of messages) bySequence.set(message.seq, message);
|
|
865
|
+
return [...bySequence.values()].sort((a, b) => a.seq - b.seq);
|
|
866
|
+
}
|
|
867
|
+
function limitSessionPageMessages(messages) {
|
|
868
|
+
const canonical = canonicalSessionMessages(messages);
|
|
869
|
+
let bytes = 2;
|
|
870
|
+
const kept = [];
|
|
871
|
+
for (let index = canonical.length - 1; index >= 0; index -= 1) {
|
|
872
|
+
const message = canonical[index];
|
|
873
|
+
const candidateBytes = jsonBytes(message) + (kept.length > 0 ? 1 : 0);
|
|
874
|
+
if (bytes + candidateBytes > 921600) break;
|
|
875
|
+
kept.unshift(message);
|
|
876
|
+
bytes += candidateBytes;
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
messages: kept,
|
|
880
|
+
dropped: canonical.length - kept.length
|
|
881
|
+
};
|
|
882
|
+
}
|
|
715
883
|
function projectEvent(sessionId, event) {
|
|
716
884
|
switch (event.type) {
|
|
717
885
|
case "turn/start": return {
|
|
@@ -1032,7 +1200,7 @@ function projectHistory(events) {
|
|
|
1032
1200
|
}
|
|
1033
1201
|
}
|
|
1034
1202
|
}
|
|
1035
|
-
return messages.
|
|
1203
|
+
return canonicalSessionMessages(messages.map(limitMessageProjection));
|
|
1036
1204
|
}
|
|
1037
1205
|
/**
|
|
1038
1206
|
* Enforce PROTOCOL.md's per-message 256 KB ceiling by UTF-8 JSON byte size.
|
|
@@ -1598,13 +1766,14 @@ var HostBridge = class {
|
|
|
1598
1766
|
});
|
|
1599
1767
|
if (!response.result || !response.result.ok) return false;
|
|
1600
1768
|
const result = response.result.value;
|
|
1601
|
-
const
|
|
1769
|
+
const page = limitSessionPageMessages(projectHistory(result.events ?? []));
|
|
1770
|
+
const messages = page.messages;
|
|
1602
1771
|
const oldestSeq = messages.length > 0 ? messages[0].seq : 0;
|
|
1603
1772
|
sink.push("s2c.session.tail", {
|
|
1604
1773
|
sessionId,
|
|
1605
1774
|
messages,
|
|
1606
1775
|
oldestSeq,
|
|
1607
|
-
hasMore: Boolean(result.hasMore)
|
|
1776
|
+
hasMore: Boolean(result.hasMore) || page.dropped > 0
|
|
1608
1777
|
});
|
|
1609
1778
|
this.deriveTitleFallback(sessionId, messages);
|
|
1610
1779
|
return true;
|
|
@@ -1612,7 +1781,7 @@ var HostBridge = class {
|
|
|
1612
1781
|
return false;
|
|
1613
1782
|
}
|
|
1614
1783
|
}
|
|
1615
|
-
async historyPage(
|
|
1784
|
+
async historyPage(sessionId, beforeSeq, limit) {
|
|
1616
1785
|
try {
|
|
1617
1786
|
const response = await this.apiProxy.sessions.history({
|
|
1618
1787
|
rpcId: randomUUID(),
|
|
@@ -1622,17 +1791,16 @@ var HostBridge = class {
|
|
|
1622
1791
|
maxMessages: clampTail(limit)
|
|
1623
1792
|
}
|
|
1624
1793
|
});
|
|
1625
|
-
if (!response.result || !response.result.ok) return
|
|
1794
|
+
if (!response.result || !response.result.ok) return null;
|
|
1626
1795
|
const result = response.result.value;
|
|
1627
|
-
const
|
|
1628
|
-
|
|
1796
|
+
const page = limitSessionPageMessages(projectHistory(result.events ?? []).filter((message) => message.seq < beforeSeq));
|
|
1797
|
+
return {
|
|
1629
1798
|
sessionId,
|
|
1630
|
-
messages,
|
|
1631
|
-
hasMore: Boolean(result.hasMore)
|
|
1632
|
-
}
|
|
1633
|
-
return true;
|
|
1799
|
+
messages: page.messages,
|
|
1800
|
+
hasMore: page.messages.length > 0 && (Boolean(result.hasMore) || page.dropped > 0)
|
|
1801
|
+
};
|
|
1634
1802
|
} catch {
|
|
1635
|
-
return
|
|
1803
|
+
return null;
|
|
1636
1804
|
}
|
|
1637
1805
|
}
|
|
1638
1806
|
/** Result of one attachment read-back for the phone. */
|
|
@@ -2308,6 +2476,365 @@ function projectSessionModels(value) {
|
|
|
2308
2476
|
}
|
|
2309
2477
|
/** Project a history page (raw events) into MessageProjection rows. */
|
|
2310
2478
|
//#endregion
|
|
2479
|
+
//#region src/dsh012-api-proxy.ts
|
|
2480
|
+
/**
|
|
2481
|
+
* Compatibility façade for the DSH 0.1.2 controller API.
|
|
2482
|
+
*
|
|
2483
|
+
* DeepPilot's phone protocol deliberately speaks one stable in-process
|
|
2484
|
+
* `apiProxy` vocabulary. Harness 0.1.2 removed that service in favor of
|
|
2485
|
+
* direct Session/Workspace controllers plus scoped Cordis interaction events.
|
|
2486
|
+
* This adapter rebuilds the small subset the bridge needs from those public
|
|
2487
|
+
* controllers, keeping the protocol implementation isolated from the Host API
|
|
2488
|
+
* migration. It is intentionally Host-only.
|
|
2489
|
+
*/
|
|
2490
|
+
/** Direct-controller facade with the exact legacy shape HostBridge consumes. */
|
|
2491
|
+
var Dsh012ApiProxy = class {
|
|
2492
|
+
ctx;
|
|
2493
|
+
session;
|
|
2494
|
+
workspaceController;
|
|
2495
|
+
directoryPicker;
|
|
2496
|
+
interactions = /* @__PURE__ */ new Map();
|
|
2497
|
+
constructor(ctx) {
|
|
2498
|
+
this.ctx = ctx;
|
|
2499
|
+
const session = ctx.get("sessionController");
|
|
2500
|
+
if (session === void 0) throw new Error("dsh 0.1.2 sessionController is unavailable");
|
|
2501
|
+
this.session = session;
|
|
2502
|
+
this.workspaceController = ctx.get("workspaceController");
|
|
2503
|
+
this.directoryPicker = ctx.get("directoryPickerController");
|
|
2504
|
+
}
|
|
2505
|
+
sessions = {
|
|
2506
|
+
list: async () => this.call(async () => {
|
|
2507
|
+
return { items: (await this.session.list({}, new AbortController().signal)).items.map(toPhoneSessionRow).filter((row) => !isSubagentRow(row)) };
|
|
2508
|
+
}),
|
|
2509
|
+
history: async (request) => this.call(async () => {
|
|
2510
|
+
const sessionId = request.payload.sessionId;
|
|
2511
|
+
let inspected;
|
|
2512
|
+
try {
|
|
2513
|
+
inspected = await this.session.inspect(sessionId);
|
|
2514
|
+
} catch (error) {
|
|
2515
|
+
console.warn(`[deeppilot] session history unavailable for ${JSON.stringify(sessionId)}: ${toError(error).code}: ${toError(error).message}`);
|
|
2516
|
+
throw error;
|
|
2517
|
+
}
|
|
2518
|
+
const before = request.payload?.beforeSeq;
|
|
2519
|
+
const limit = Math.max(1, request.payload?.maxMessages ?? 100);
|
|
2520
|
+
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);
|
|
2521
|
+
let end = source.length;
|
|
2522
|
+
let events = [];
|
|
2523
|
+
while (end > 0 && projectHistory(events).length < limit) {
|
|
2524
|
+
const start = Math.max(0, end - limit);
|
|
2525
|
+
events = [...source.slice(start, end).map((event) => ({ event })), ...events];
|
|
2526
|
+
end = start;
|
|
2527
|
+
}
|
|
2528
|
+
let trimmed = false;
|
|
2529
|
+
while (events.length > 0 && projectHistory(events).length > limit) {
|
|
2530
|
+
events = events.slice(1);
|
|
2531
|
+
trimmed = true;
|
|
2532
|
+
}
|
|
2533
|
+
return {
|
|
2534
|
+
events,
|
|
2535
|
+
hasMore: end > 0 || trimmed
|
|
2536
|
+
};
|
|
2537
|
+
}),
|
|
2538
|
+
prompt: async (request) => this.call(() => this.session.prompt({
|
|
2539
|
+
...request.payload,
|
|
2540
|
+
requestId: request.rpcId ?? randomUUID()
|
|
2541
|
+
}, new AbortController().signal)),
|
|
2542
|
+
create: async (request) => this.call(() => this.session.create(request.payload ?? {})),
|
|
2543
|
+
models: async (request) => this.call(async () => projectModels(await this.session.modelCatalog(), String(request.payload?.sessionId ?? ""), await this.session.list({}, new AbortController().signal))),
|
|
2544
|
+
selectModel: async (request) => this.call(() => this.session.selectModel(request.payload ?? {})),
|
|
2545
|
+
rename: async (request) => this.call(() => this.session.rename(request.payload)),
|
|
2546
|
+
cancel: async (request) => this.call(() => this.session.cancel(request.payload)),
|
|
2547
|
+
attachment: async (request) => this.call(() => this.session.attachment(request.payload))
|
|
2548
|
+
};
|
|
2549
|
+
workspace = {
|
|
2550
|
+
list: async () => this.call(async () => {
|
|
2551
|
+
if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
|
|
2552
|
+
const baseline = await readWorkspaceBaseline(this.workspaceController);
|
|
2553
|
+
return {
|
|
2554
|
+
items: baseline.items.map(toWorkspaceView),
|
|
2555
|
+
archivedSessionIds: baseline.archivedSessionIds.map(String)
|
|
2556
|
+
};
|
|
2557
|
+
}),
|
|
2558
|
+
create: async (request) => this.call(async () => {
|
|
2559
|
+
if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
|
|
2560
|
+
const value = await this.workspaceController.create(request.payload);
|
|
2561
|
+
return {
|
|
2562
|
+
workspace: toWorkspaceView(value.workspace),
|
|
2563
|
+
created: value.created === true
|
|
2564
|
+
};
|
|
2565
|
+
}),
|
|
2566
|
+
archiveSession: async (request) => this.call(async () => {
|
|
2567
|
+
if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
|
|
2568
|
+
return { archivedSessionIds: [...(await this.workspaceController.archiveSession(request.payload)).archivedSessionIds] };
|
|
2569
|
+
})
|
|
2570
|
+
};
|
|
2571
|
+
host = {
|
|
2572
|
+
listDirectory: async (request, signal) => this.call(async () => {
|
|
2573
|
+
if (this.directoryPicker === void 0) throw unavailable("directory picker unavailable");
|
|
2574
|
+
return await this.directoryPicker.list(request.payload?.path, signal ?? new AbortController().signal);
|
|
2575
|
+
}),
|
|
2576
|
+
pickDirectory: async (_request, signal) => this.call(async () => {
|
|
2577
|
+
if (this.directoryPicker === void 0) throw unavailable("directory picker unavailable");
|
|
2578
|
+
return { path: await this.directoryPicker.pick(signal ?? new AbortController().signal) };
|
|
2579
|
+
})
|
|
2580
|
+
};
|
|
2581
|
+
events = {
|
|
2582
|
+
mux: (_request, signal) => this.mux(signal),
|
|
2583
|
+
host: (_request, signal) => this.hostEvents(signal)
|
|
2584
|
+
};
|
|
2585
|
+
async respond(message) {
|
|
2586
|
+
const pending = this.interactions.get(message.rpcId);
|
|
2587
|
+
if (pending === void 0) return {
|
|
2588
|
+
accepted: false,
|
|
2589
|
+
reason: "not-pending"
|
|
2590
|
+
};
|
|
2591
|
+
if (!message.result.ok) return {
|
|
2592
|
+
accepted: false,
|
|
2593
|
+
reason: "bad-response"
|
|
2594
|
+
};
|
|
2595
|
+
this.interactions.delete(message.rpcId);
|
|
2596
|
+
pending.resolve(pending.map(message.result.value));
|
|
2597
|
+
return { accepted: true };
|
|
2598
|
+
}
|
|
2599
|
+
async *mux(signal) {
|
|
2600
|
+
const queue = new AsyncFrameQueue(signal);
|
|
2601
|
+
const offEvent = this.ctx.on("session/event", ((session, event) => {
|
|
2602
|
+
queue.push({
|
|
2603
|
+
type: "session/event",
|
|
2604
|
+
sessionId: String(session.id),
|
|
2605
|
+
event
|
|
2606
|
+
});
|
|
2607
|
+
}), { global: true });
|
|
2608
|
+
const offProjection = this.ctx.get("sessionProjections")?.onChanged?.((session, key, value) => {
|
|
2609
|
+
queue.push({
|
|
2610
|
+
type: "session/projection",
|
|
2611
|
+
sessionId: String(session.id),
|
|
2612
|
+
key,
|
|
2613
|
+
value
|
|
2614
|
+
});
|
|
2615
|
+
});
|
|
2616
|
+
const offApproval = this.ctx.on("approval/request", ((request) => {
|
|
2617
|
+
const rpcId = randomUUID();
|
|
2618
|
+
const sessionId = String(request.agent?.session?.id ?? request.agent?.id ?? "");
|
|
2619
|
+
const response = deferred();
|
|
2620
|
+
const abort = () => response.resolve("cancelled");
|
|
2621
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
2622
|
+
this.interactions.set(rpcId, {
|
|
2623
|
+
resolve: response.resolve,
|
|
2624
|
+
map: (value) => {
|
|
2625
|
+
const outcome = value?.outcome;
|
|
2626
|
+
return outcome === "allowed-once" || outcome === "rejected" ? outcome : "unavailable";
|
|
2627
|
+
}
|
|
2628
|
+
});
|
|
2629
|
+
queue.push({
|
|
2630
|
+
type: "approval/requested",
|
|
2631
|
+
rpcId,
|
|
2632
|
+
sessionId,
|
|
2633
|
+
approvalId: rpcId,
|
|
2634
|
+
toolName: String(request.toolName ?? "tool"),
|
|
2635
|
+
reason: String(request.reason ?? "")
|
|
2636
|
+
});
|
|
2637
|
+
return response.promise.finally(() => {
|
|
2638
|
+
request.signal?.removeEventListener("abort", abort);
|
|
2639
|
+
this.interactions.delete(rpcId);
|
|
2640
|
+
queue.push({
|
|
2641
|
+
type: "approval/resolved",
|
|
2642
|
+
approvalId: rpcId
|
|
2643
|
+
});
|
|
2644
|
+
});
|
|
2645
|
+
}), { global: true });
|
|
2646
|
+
const offQuestion = this.ctx.on("user-questions/request", ((request) => {
|
|
2647
|
+
const rpcId = randomUUID();
|
|
2648
|
+
const sessionId = String(request.agent?.session?.id ?? request.agent?.id ?? "");
|
|
2649
|
+
const response = deferred();
|
|
2650
|
+
const abort = () => response.reject(/* @__PURE__ */ new Error("question cancelled"));
|
|
2651
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
2652
|
+
this.interactions.set(rpcId, {
|
|
2653
|
+
resolve: response.resolve,
|
|
2654
|
+
map: (value) => value?.answer ?? value
|
|
2655
|
+
});
|
|
2656
|
+
queue.push({
|
|
2657
|
+
type: "question/requested",
|
|
2658
|
+
rpcId,
|
|
2659
|
+
sessionId,
|
|
2660
|
+
questions: request.questions ?? []
|
|
2661
|
+
});
|
|
2662
|
+
return response.promise.finally(() => {
|
|
2663
|
+
request.signal?.removeEventListener("abort", abort);
|
|
2664
|
+
this.interactions.delete(rpcId);
|
|
2665
|
+
queue.push({
|
|
2666
|
+
type: "question/resolved",
|
|
2667
|
+
questionRpcId: rpcId
|
|
2668
|
+
});
|
|
2669
|
+
});
|
|
2670
|
+
}), { global: true });
|
|
2671
|
+
try {
|
|
2672
|
+
yield* queue.iterate();
|
|
2673
|
+
} finally {
|
|
2674
|
+
offEvent();
|
|
2675
|
+
offProjection?.();
|
|
2676
|
+
offApproval();
|
|
2677
|
+
offQuestion();
|
|
2678
|
+
queue.close();
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
async *hostEvents(signal) {
|
|
2682
|
+
const queue = new AsyncFrameQueue(signal);
|
|
2683
|
+
const listen = (event, type, project) => this.ctx.on(event, ((...args) => queue.push({
|
|
2684
|
+
type,
|
|
2685
|
+
...project?.(...args) ?? {}
|
|
2686
|
+
})), { global: true });
|
|
2687
|
+
const off = [
|
|
2688
|
+
listen("api-session/added", "host/session-added"),
|
|
2689
|
+
listen("api-session/removed", "host/session-removed"),
|
|
2690
|
+
listen("api-session/status", "host/session-status", (sessionId, running) => ({
|
|
2691
|
+
sessionId: String(sessionId),
|
|
2692
|
+
running: running === true
|
|
2693
|
+
})),
|
|
2694
|
+
listen("api-session/activity", "host/session-added")
|
|
2695
|
+
];
|
|
2696
|
+
const workspaceAbort = new AbortController();
|
|
2697
|
+
const stop = () => workspaceAbort.abort();
|
|
2698
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
2699
|
+
this.workspaceController === void 0 || (async () => {
|
|
2700
|
+
try {
|
|
2701
|
+
for await (const frame of this.workspaceController.follow(workspaceAbort.signal)) if (frame.type === "archived") queue.push({
|
|
2702
|
+
type: "host/archived-sessions-changed",
|
|
2703
|
+
archivedSessionIds: frame.archivedSessionIds
|
|
2704
|
+
});
|
|
2705
|
+
else if (frame.type !== "baseline") queue.push({ type: "host/workspace-changed" });
|
|
2706
|
+
} catch {}
|
|
2707
|
+
})();
|
|
2708
|
+
try {
|
|
2709
|
+
yield* queue.iterate();
|
|
2710
|
+
} finally {
|
|
2711
|
+
for (const dispose of off) dispose();
|
|
2712
|
+
signal.removeEventListener("abort", stop);
|
|
2713
|
+
workspaceAbort.abort();
|
|
2714
|
+
queue.close();
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
async call(invoke) {
|
|
2718
|
+
try {
|
|
2719
|
+
return { result: {
|
|
2720
|
+
ok: true,
|
|
2721
|
+
value: await invoke()
|
|
2722
|
+
} };
|
|
2723
|
+
} catch (error) {
|
|
2724
|
+
return { result: {
|
|
2725
|
+
ok: false,
|
|
2726
|
+
error: toError(error)
|
|
2727
|
+
} };
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
};
|
|
2731
|
+
function deferred() {
|
|
2732
|
+
let resolve;
|
|
2733
|
+
let reject;
|
|
2734
|
+
return {
|
|
2735
|
+
promise: new Promise((ok, fail) => {
|
|
2736
|
+
resolve = ok;
|
|
2737
|
+
reject = fail;
|
|
2738
|
+
}),
|
|
2739
|
+
resolve,
|
|
2740
|
+
reject
|
|
2741
|
+
};
|
|
2742
|
+
}
|
|
2743
|
+
var AsyncFrameQueue = class {
|
|
2744
|
+
frames = [];
|
|
2745
|
+
wake;
|
|
2746
|
+
closed = false;
|
|
2747
|
+
constructor(signal) {
|
|
2748
|
+
signal.addEventListener("abort", () => this.close(), { once: true });
|
|
2749
|
+
}
|
|
2750
|
+
push(frame) {
|
|
2751
|
+
if (!this.closed) {
|
|
2752
|
+
this.frames.push(frame);
|
|
2753
|
+
this.wake?.();
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
close() {
|
|
2757
|
+
if (!this.closed) {
|
|
2758
|
+
this.closed = true;
|
|
2759
|
+
this.wake?.();
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
async *iterate() {
|
|
2763
|
+
while (!this.closed) {
|
|
2764
|
+
const frame = this.frames.shift();
|
|
2765
|
+
if (frame !== void 0) {
|
|
2766
|
+
yield frame;
|
|
2767
|
+
continue;
|
|
2768
|
+
}
|
|
2769
|
+
await new Promise((resolve) => {
|
|
2770
|
+
this.wake = resolve;
|
|
2771
|
+
});
|
|
2772
|
+
this.wake = void 0;
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
};
|
|
2776
|
+
function unavailable(message) {
|
|
2777
|
+
return Object.assign(new Error(message), { code: "directory-picker-unavailable" });
|
|
2778
|
+
}
|
|
2779
|
+
function toError(error) {
|
|
2780
|
+
const value = error;
|
|
2781
|
+
return {
|
|
2782
|
+
code: typeof value?.code === "string" ? value.code : "internal",
|
|
2783
|
+
message: typeof value?.message === "string" ? value.message : String(error)
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
function toPhoneSessionRow(value) {
|
|
2787
|
+
const row = value;
|
|
2788
|
+
return {
|
|
2789
|
+
sessionId: String(row.sessionId ?? ""),
|
|
2790
|
+
updatedAt: Number(row.updatedAt ?? Date.now()),
|
|
2791
|
+
running: row.running === true,
|
|
2792
|
+
...row.blank === true ? { blank: true } : {},
|
|
2793
|
+
...typeof row.cwd === "string" ? { cwd: row.cwd } : {},
|
|
2794
|
+
...typeof row.origin === "string" ? { origin: row.origin } : {},
|
|
2795
|
+
...typeof row.parentSessionId === "string" ? { parentSessionId: row.parentSessionId } : {},
|
|
2796
|
+
...row.projections && typeof row.projections === "object" ? { projections: row.projections } : {}
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
function toWorkspaceView(value) {
|
|
2800
|
+
const row = value;
|
|
2801
|
+
return {
|
|
2802
|
+
workspaceId: String(row.workspaceId ?? ""),
|
|
2803
|
+
title: String(row.title ?? ""),
|
|
2804
|
+
path: String(row.path ?? ""),
|
|
2805
|
+
sessionIds: Array.isArray(row.sessionIds) ? row.sessionIds.map(String) : []
|
|
2806
|
+
};
|
|
2807
|
+
}
|
|
2808
|
+
async function readWorkspaceBaseline(controller) {
|
|
2809
|
+
const abort = new AbortController();
|
|
2810
|
+
const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]();
|
|
2811
|
+
try {
|
|
2812
|
+
const baseline = (await iterator.next()).value;
|
|
2813
|
+
if (baseline?.type !== "baseline") throw new Error("workspace follow did not provide a baseline");
|
|
2814
|
+
return {
|
|
2815
|
+
items: (baseline.value?.items ?? []).map(toWorkspaceView),
|
|
2816
|
+
archivedSessionIds: (baseline.value?.archivedSessionIds ?? []).map(String)
|
|
2817
|
+
};
|
|
2818
|
+
} finally {
|
|
2819
|
+
abort.abort();
|
|
2820
|
+
await iterator.return?.();
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
async function projectModels(catalog, sessionId, list) {
|
|
2824
|
+
const value = catalog;
|
|
2825
|
+
const selected = (list.items.map(toPhoneSessionRow).find((item) => item.sessionId === sessionId)?.projections?.values?.modelSelection)?.next;
|
|
2826
|
+
return {
|
|
2827
|
+
current: {
|
|
2828
|
+
provider: String(selected?.provider ?? value.default?.provider ?? ""),
|
|
2829
|
+
model: String(selected?.model ?? value.default?.model ?? ""),
|
|
2830
|
+
...typeof selected?.reasoningEffort === "string" ? { reasoningEffort: selected.reasoningEffort } : {}
|
|
2831
|
+
},
|
|
2832
|
+
routable: true,
|
|
2833
|
+
groups: Array.isArray(value.groups) ? value.groups : [],
|
|
2834
|
+
failures: Array.isArray(value.failures) ? value.failures : []
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
//#endregion
|
|
2311
2838
|
//#region src/report-service.ts
|
|
2312
2839
|
/**
|
|
2313
2840
|
* The Typert receiver the Gateway resolves for the DeepPilot Bridge report.
|
|
@@ -2316,26 +2843,31 @@ function projectSessionModels(value) {
|
|
|
2316
2843
|
*/
|
|
2317
2844
|
var DeepPilotReportService = class extends TypertRemoteService {
|
|
2318
2845
|
snapshot;
|
|
2319
|
-
|
|
2320
|
-
|
|
2846
|
+
pairingStarter;
|
|
2847
|
+
deviceRevoker;
|
|
2848
|
+
deviceScopeUpdater;
|
|
2321
2849
|
relayTester;
|
|
2322
2850
|
pushTester;
|
|
2323
|
-
constructor(ctx, snapshot,
|
|
2851
|
+
constructor(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
|
|
2324
2852
|
super(ctx, "deeppilotReport", { namespace: "deeppilot" });
|
|
2325
2853
|
this.snapshot = snapshot;
|
|
2326
|
-
this.
|
|
2327
|
-
this.
|
|
2854
|
+
this.pairingStarter = pairingStarter;
|
|
2855
|
+
this.deviceRevoker = deviceRevoker;
|
|
2856
|
+
this.deviceScopeUpdater = deviceScopeUpdater;
|
|
2328
2857
|
this.relayTester = relayTester;
|
|
2329
2858
|
this.pushTester = pushTester;
|
|
2330
2859
|
}
|
|
2331
2860
|
async report() {
|
|
2332
2861
|
return this.snapshot();
|
|
2333
2862
|
}
|
|
2334
|
-
async
|
|
2335
|
-
return this.
|
|
2863
|
+
async beginPairing() {
|
|
2864
|
+
return this.pairingStarter();
|
|
2865
|
+
}
|
|
2866
|
+
async revokeDevice(deviceId) {
|
|
2867
|
+
return this.deviceRevoker(deviceId);
|
|
2336
2868
|
}
|
|
2337
|
-
async
|
|
2338
|
-
return this.
|
|
2869
|
+
async setDeviceScopes(deviceId, scopes) {
|
|
2870
|
+
return this.deviceScopeUpdater(deviceId, scopes);
|
|
2339
2871
|
}
|
|
2340
2872
|
async testRelay() {
|
|
2341
2873
|
return this.relayTester();
|
|
@@ -2350,14 +2882,9 @@ var DeepPilotReportService = class extends TypertRemoteService {
|
|
|
2350
2882
|
const REPORT_REMOTE_PACKAGE = "dsh-deeppilot";
|
|
2351
2883
|
/** Canonical `<namespace>/<method>` endpoint of the report Remote. */
|
|
2352
2884
|
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";
|
|
2885
|
+
const BEGIN_PAIRING_ENDPOINT = "deeppilot/beginPairing";
|
|
2886
|
+
const REVOKE_DEVICE_ENDPOINT = "deeppilot/revokeDevice";
|
|
2887
|
+
const SET_DEVICE_SCOPES_ENDPOINT = "deeppilot/setDeviceScopes";
|
|
2361
2888
|
function reject(field) {
|
|
2362
2889
|
throw new TypeError(`deeppilot/report result: invalid ${field}`);
|
|
2363
2890
|
}
|
|
@@ -2405,6 +2932,9 @@ function parseDevice(value) {
|
|
|
2405
2932
|
appVersion: str(s, "appVersion", "device.appVersion"),
|
|
2406
2933
|
firstSeenTs: int(s, "firstSeenTs", "device.firstSeenTs"),
|
|
2407
2934
|
lastSeenTs: int(s, "lastSeenTs", "device.lastSeenTs"),
|
|
2935
|
+
fingerprint: str(s, "fingerprint", "device.fingerprint"),
|
|
2936
|
+
scopes: normalizeDeviceScopes(s.scopes),
|
|
2937
|
+
...s.revokedAt !== void 0 ? { revokedAt: int(s, "revokedAt", "device.revokedAt") } : {},
|
|
2408
2938
|
...apns ? { apns } : {}
|
|
2409
2939
|
};
|
|
2410
2940
|
}
|
|
@@ -2512,8 +3042,8 @@ function parseReport(value) {
|
|
|
2512
3042
|
...s.updateAvailable === true ? { updateAvailable: true } : {},
|
|
2513
3043
|
...typeof releaseUrl === "string" && releaseUrl.length > 0 ? { releaseUrl } : {},
|
|
2514
3044
|
enabled: bool(s, "enabled", "enabled"),
|
|
2515
|
-
|
|
2516
|
-
|
|
3045
|
+
identityPath: str(s, "identityPath", "identityPath"),
|
|
3046
|
+
pairingReady: bool(s, "pairingReady", "pairingReady"),
|
|
2517
3047
|
activeConnections: int(s, "activeConnections", "activeConnections"),
|
|
2518
3048
|
historyBufferMax: int(s, "historyBufferMax", "historyBufferMax"),
|
|
2519
3049
|
debug: bool(s, "debug", "debug"),
|
|
@@ -2525,14 +3055,33 @@ function parseReport(value) {
|
|
|
2525
3055
|
const reportSchema = { parse: parseReport };
|
|
2526
3056
|
const relayTestSchema = { parse: parseRelayTestResult };
|
|
2527
3057
|
const pushTestSchema = { parse: parsePushTestResult };
|
|
2528
|
-
const
|
|
2529
|
-
|
|
3058
|
+
const pairingGrantSchema = { parse(value) {
|
|
3059
|
+
const s = rec(value, "pairing grant");
|
|
3060
|
+
const code = str(s, "code", "pairingGrant.code");
|
|
3061
|
+
if (code.length < 32) reject("pairingGrant.code");
|
|
3062
|
+
return {
|
|
3063
|
+
code,
|
|
3064
|
+
expiresAt: int(s, "expiresAt", "pairingGrant.expiresAt"),
|
|
3065
|
+
audience: str(s, "audience", "pairingGrant.audience")
|
|
3066
|
+
};
|
|
3067
|
+
} };
|
|
3068
|
+
const deviceIdSchema = { parse(value) {
|
|
3069
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) reject("deviceId");
|
|
2530
3070
|
return value;
|
|
2531
3071
|
} };
|
|
3072
|
+
const scopesSchema = { parse(value) {
|
|
3073
|
+
if (!Array.isArray(value) || value.some((scope) => typeof scope !== "string" || !DEVICE_SCOPES.includes(scope))) reject("scopes");
|
|
3074
|
+
return normalizeDeviceScopes(value);
|
|
3075
|
+
} };
|
|
2532
3076
|
const REPORT_HOST_CONTRIBUTION = {
|
|
2533
3077
|
package: REPORT_REMOTE_PACKAGE,
|
|
2534
3078
|
face: "host",
|
|
2535
3079
|
schemas: [],
|
|
3080
|
+
model: {
|
|
3081
|
+
services: [],
|
|
3082
|
+
events: [],
|
|
3083
|
+
objects: []
|
|
3084
|
+
},
|
|
2536
3085
|
invocations: [
|
|
2537
3086
|
{
|
|
2538
3087
|
id: `${REPORT_REMOTE_PACKAGE}#${REPORT_ENDPOINT}`,
|
|
@@ -2548,29 +3097,72 @@ const REPORT_HOST_CONTRIBUTION = {
|
|
|
2548
3097
|
}
|
|
2549
3098
|
},
|
|
2550
3099
|
{
|
|
2551
|
-
id: `${REPORT_REMOTE_PACKAGE}#${
|
|
3100
|
+
id: `${REPORT_REMOTE_PACKAGE}#${BEGIN_PAIRING_ENDPOINT}`,
|
|
2552
3101
|
service: "deeppilotReport",
|
|
2553
3102
|
namespace: "deeppilot",
|
|
2554
|
-
method: "
|
|
3103
|
+
method: "beginPairing",
|
|
2555
3104
|
invocation: { kind: "direct" },
|
|
2556
3105
|
parameters: [],
|
|
2557
3106
|
result: {
|
|
2558
3107
|
mode: "strict",
|
|
2559
|
-
typeSymbol: `${REPORT_REMOTE_PACKAGE}#
|
|
2560
|
-
schema:
|
|
3108
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`,
|
|
3109
|
+
schema: pairingGrantSchema
|
|
2561
3110
|
}
|
|
2562
3111
|
},
|
|
2563
3112
|
{
|
|
2564
|
-
id: `${REPORT_REMOTE_PACKAGE}#${
|
|
3113
|
+
id: `${REPORT_REMOTE_PACKAGE}#${REVOKE_DEVICE_ENDPOINT}`,
|
|
2565
3114
|
service: "deeppilotReport",
|
|
2566
3115
|
namespace: "deeppilot",
|
|
2567
|
-
method: "
|
|
3116
|
+
method: "revokeDevice",
|
|
2568
3117
|
invocation: { kind: "direct" },
|
|
2569
|
-
parameters: [
|
|
3118
|
+
parameters: [{
|
|
3119
|
+
name: "deviceId",
|
|
3120
|
+
wire: "deviceId",
|
|
3121
|
+
source: "json",
|
|
3122
|
+
codec: {
|
|
3123
|
+
mode: "strict",
|
|
3124
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
|
|
3125
|
+
schema: deviceIdSchema
|
|
3126
|
+
}
|
|
3127
|
+
}],
|
|
3128
|
+
result: {
|
|
3129
|
+
mode: "strict",
|
|
3130
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#Boolean`,
|
|
3131
|
+
schema: { parse(value) {
|
|
3132
|
+
if (typeof value !== "boolean") reject("boolean");
|
|
3133
|
+
return value;
|
|
3134
|
+
} }
|
|
3135
|
+
}
|
|
3136
|
+
},
|
|
3137
|
+
{
|
|
3138
|
+
id: `${REPORT_REMOTE_PACKAGE}#${SET_DEVICE_SCOPES_ENDPOINT}`,
|
|
3139
|
+
service: "deeppilotReport",
|
|
3140
|
+
namespace: "deeppilot",
|
|
3141
|
+
method: "setDeviceScopes",
|
|
3142
|
+
invocation: { kind: "direct" },
|
|
3143
|
+
parameters: [{
|
|
3144
|
+
name: "deviceId",
|
|
3145
|
+
wire: "deviceId",
|
|
3146
|
+
source: "json",
|
|
3147
|
+
codec: {
|
|
3148
|
+
mode: "strict",
|
|
3149
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
|
|
3150
|
+
schema: deviceIdSchema
|
|
3151
|
+
}
|
|
3152
|
+
}, {
|
|
3153
|
+
name: "scopes",
|
|
3154
|
+
wire: "scopes",
|
|
3155
|
+
source: "json",
|
|
3156
|
+
codec: {
|
|
3157
|
+
mode: "strict",
|
|
3158
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
|
|
3159
|
+
schema: scopesSchema
|
|
3160
|
+
}
|
|
3161
|
+
}],
|
|
2570
3162
|
result: {
|
|
2571
3163
|
mode: "strict",
|
|
2572
|
-
typeSymbol: `${REPORT_REMOTE_PACKAGE}#
|
|
2573
|
-
schema:
|
|
3164
|
+
typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
|
|
3165
|
+
schema: scopesSchema
|
|
2574
3166
|
}
|
|
2575
3167
|
},
|
|
2576
3168
|
{
|
|
@@ -2607,9 +3199,9 @@ const REPORT_HOST_CONTRIBUTION = {
|
|
|
2607
3199
|
* Provide the report service and register its Remote descriptor. Rides an
|
|
2608
3200
|
* optional `typert` inject: profiles without the web stack never activate it.
|
|
2609
3201
|
*/
|
|
2610
|
-
function applyReportRemote(ctx, snapshot,
|
|
3202
|
+
function applyReportRemote(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
|
|
2611
3203
|
ctx.inject(["typert"], (remoteCtx) => {
|
|
2612
|
-
new DeepPilotReportService(remoteCtx, snapshot,
|
|
3204
|
+
new DeepPilotReportService(remoteCtx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester);
|
|
2613
3205
|
const unregister = remoteCtx.typert.register(REPORT_HOST_CONTRIBUTION);
|
|
2614
3206
|
remoteCtx.effect(() => () => void unregister(), "dsh-deeppilot: report remote");
|
|
2615
3207
|
});
|
|
@@ -3027,6 +3619,9 @@ var RelayClient = class {
|
|
|
3027
3619
|
}
|
|
3028
3620
|
}
|
|
3029
3621
|
};
|
|
3622
|
+
function normalizeFunnelConnectionLimit(value) {
|
|
3623
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 16 ? value : 8;
|
|
3624
|
+
}
|
|
3030
3625
|
//#endregion
|
|
3031
3626
|
//#region src/remote-supervisor.ts
|
|
3032
3627
|
const RESTART_DELAYS_MS = [
|
|
@@ -3056,6 +3651,20 @@ function normalizeRemoteHostname(value) {
|
|
|
3056
3651
|
].includes(hostname.toLowerCase())) return DEFAULT_REMOTE_HOSTNAME;
|
|
3057
3652
|
return hostname;
|
|
3058
3653
|
}
|
|
3654
|
+
function tunnelHelperArguments(originURL, statePath, options) {
|
|
3655
|
+
return [
|
|
3656
|
+
"--origin",
|
|
3657
|
+
originURL,
|
|
3658
|
+
"--hostname",
|
|
3659
|
+
normalizeRemoteHostname(options.hostname),
|
|
3660
|
+
"--state-dir",
|
|
3661
|
+
statePath,
|
|
3662
|
+
"--port",
|
|
3663
|
+
String(options.funnelPort ?? 443),
|
|
3664
|
+
"--max-connections-per-source",
|
|
3665
|
+
String(normalizeFunnelConnectionLimit(options.maxConnectionsPerSource))
|
|
3666
|
+
];
|
|
3667
|
+
}
|
|
3059
3668
|
/** Parse one helper IPC line without ever evaluating or interpolating it. */
|
|
3060
3669
|
function parseHelperEvent(line) {
|
|
3061
3670
|
try {
|
|
@@ -3179,16 +3788,7 @@ var RemoteSupervisor = class {
|
|
|
3179
3788
|
phase: "starting",
|
|
3180
3789
|
message: void 0
|
|
3181
3790
|
});
|
|
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
|
-
], {
|
|
3791
|
+
const child = spawn(helper, tunnelHelperArguments(originURL, statePath, this.options), {
|
|
3192
3792
|
stdio: [
|
|
3193
3793
|
"ignore",
|
|
3194
3794
|
"pipe",
|
|
@@ -3539,8 +4139,7 @@ var UpdateChecker = class {
|
|
|
3539
4139
|
const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
|
|
3540
4140
|
const Config = z.object({
|
|
3541
4141
|
enabled: z.boolean().default(true),
|
|
3542
|
-
|
|
3543
|
-
devicesPath: z.string().default(join(bridgeDataDir(), "devices.json")),
|
|
4142
|
+
devicesPath: z.string().default(join(bridgeDataDir(), "devices-v2.json")),
|
|
3544
4143
|
historyBufferMax: z.natural().min(100).default(2e3),
|
|
3545
4144
|
debug: z.boolean().default(false),
|
|
3546
4145
|
remote: z.object({
|
|
@@ -3553,14 +4152,16 @@ const Config = z.object({
|
|
|
3553
4152
|
443,
|
|
3554
4153
|
8443,
|
|
3555
4154
|
1e4
|
|
3556
|
-
]).default(443)
|
|
4155
|
+
]).default(443),
|
|
4156
|
+
maxConnectionsPerSource: z.natural().min(1).max(16).default(8).description("Funnel 每个来源允许的并发连接数(1–16,修改后远程连接会短暂重连)")
|
|
3557
4157
|
}).default({
|
|
3558
4158
|
enabled: false,
|
|
3559
4159
|
provider: "tailscale-funnel",
|
|
3560
4160
|
hostname: DEFAULT_REMOTE_HOSTNAME,
|
|
3561
4161
|
statePath: join(bridgeDataDir(), "tailscale"),
|
|
3562
4162
|
helperPath: "",
|
|
3563
|
-
funnelPort: 443
|
|
4163
|
+
funnelPort: 443,
|
|
4164
|
+
maxConnectionsPerSource: 8
|
|
3564
4165
|
}),
|
|
3565
4166
|
push: z.object({
|
|
3566
4167
|
provider: z.union([
|
|
@@ -3596,7 +4197,8 @@ function normalizeOptions(options) {
|
|
|
3596
4197
|
}
|
|
3597
4198
|
//#endregion
|
|
3598
4199
|
//#region src/phone-http.ts
|
|
3599
|
-
|
|
4200
|
+
const CLIENT_IP_HEADER = "x-deeppilot-client-ip";
|
|
4201
|
+
function rejectUpgrade(socket, status, reason, retryAfterSeconds) {
|
|
3600
4202
|
const body = JSON.stringify({ error: reason });
|
|
3601
4203
|
const statusText = {
|
|
3602
4204
|
401: "Unauthorized",
|
|
@@ -3604,23 +4206,149 @@ function rejectUpgrade(socket, status, reason) {
|
|
|
3604
4206
|
500: "Internal Server Error",
|
|
3605
4207
|
503: "Service Unavailable"
|
|
3606
4208
|
};
|
|
3607
|
-
const
|
|
3608
|
-
socket.end("HTTP/1.1 " + status + " " + (statusText[status] ?? "Error") + "\r\n" +
|
|
4209
|
+
const retryAfter = status === 429 && retryAfterSeconds !== void 0 ? `Retry-After: ${Math.max(1, Math.ceil(retryAfterSeconds))}\r\n` : "";
|
|
4210
|
+
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
4211
|
}
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
const
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
4212
|
+
function normalizedAddress(value) {
|
|
4213
|
+
if (!value) return null;
|
|
4214
|
+
const normalized = value.startsWith("::ffff:") ? value.slice(7) : value;
|
|
4215
|
+
return isIP(normalized) === 0 ? null : normalized;
|
|
4216
|
+
}
|
|
4217
|
+
function isLoopback(value) {
|
|
4218
|
+
const address = normalizedAddress(value);
|
|
4219
|
+
return address === "127.0.0.1" || address === "::1";
|
|
4220
|
+
}
|
|
4221
|
+
/**
|
|
4222
|
+
* Resolve a stable rate-limit key. The helper-supplied address is trusted only
|
|
4223
|
+
* on the private loopback hop; direct clients cannot spoof it.
|
|
4224
|
+
*/
|
|
4225
|
+
function requestClientIdentity(req) {
|
|
4226
|
+
if (isLoopback(req.socket.remoteAddress)) {
|
|
4227
|
+
const forwarded = req.headers[CLIENT_IP_HEADER];
|
|
4228
|
+
const address = normalizedAddress(Array.isArray(forwarded) ? forwarded[0] : forwarded);
|
|
4229
|
+
if (address !== null) return address;
|
|
3621
4230
|
}
|
|
4231
|
+
return normalizedAddress(req.socket.remoteAddress) ?? "unknown";
|
|
3622
4232
|
}
|
|
3623
4233
|
//#endregion
|
|
4234
|
+
//#region src/auth-rate-limit.ts
|
|
4235
|
+
const DEFAULT_AUTH_RATE_POLICY = {
|
|
4236
|
+
windowMs: 6e4,
|
|
4237
|
+
attemptsPerSource: 12,
|
|
4238
|
+
globalAttempts: 120,
|
|
4239
|
+
maxUnauthenticatedPerSource: 2,
|
|
4240
|
+
failureWindowMs: 6e5,
|
|
4241
|
+
failuresBeforeBlock: 5,
|
|
4242
|
+
blockMs: 9e5,
|
|
4243
|
+
maxSources: 4096
|
|
4244
|
+
};
|
|
4245
|
+
const noop = () => {};
|
|
4246
|
+
/** Bounded in-memory protection for anonymous authentication attempts. */
|
|
4247
|
+
var AuthRateLimiter = class {
|
|
4248
|
+
policy;
|
|
4249
|
+
sources = /* @__PURE__ */ new Map();
|
|
4250
|
+
globalAttempts = [];
|
|
4251
|
+
constructor(policy = DEFAULT_AUTH_RATE_POLICY) {
|
|
4252
|
+
this.policy = policy;
|
|
4253
|
+
}
|
|
4254
|
+
admit(source, now = Date.now()) {
|
|
4255
|
+
const state = this.source(source, now);
|
|
4256
|
+
if (state === null) return {
|
|
4257
|
+
ok: false,
|
|
4258
|
+
retryAfterMs: this.policy.windowMs,
|
|
4259
|
+
release: noop
|
|
4260
|
+
};
|
|
4261
|
+
this.prune(state, now);
|
|
4262
|
+
state.lastSeen = now;
|
|
4263
|
+
if (state.blockedUntil > now) return {
|
|
4264
|
+
ok: false,
|
|
4265
|
+
retryAfterMs: state.blockedUntil - now,
|
|
4266
|
+
release: noop
|
|
4267
|
+
};
|
|
4268
|
+
if (state.active >= this.policy.maxUnauthenticatedPerSource) return {
|
|
4269
|
+
ok: false,
|
|
4270
|
+
retryAfterMs: this.policy.windowMs,
|
|
4271
|
+
release: noop
|
|
4272
|
+
};
|
|
4273
|
+
if (state.attempts.length >= this.policy.attemptsPerSource) return {
|
|
4274
|
+
ok: false,
|
|
4275
|
+
retryAfterMs: state.attempts[0] + this.policy.windowMs - now,
|
|
4276
|
+
release: noop
|
|
4277
|
+
};
|
|
4278
|
+
this.globalAttempts = this.globalAttempts.filter((ts) => ts > now - this.policy.windowMs);
|
|
4279
|
+
if (this.globalAttempts.length >= this.policy.globalAttempts) return {
|
|
4280
|
+
ok: false,
|
|
4281
|
+
retryAfterMs: this.globalAttempts[0] + this.policy.windowMs - now,
|
|
4282
|
+
release: noop
|
|
4283
|
+
};
|
|
4284
|
+
state.attempts.push(now);
|
|
4285
|
+
this.globalAttempts.push(now);
|
|
4286
|
+
state.active += 1;
|
|
4287
|
+
let released = false;
|
|
4288
|
+
return {
|
|
4289
|
+
ok: true,
|
|
4290
|
+
retryAfterMs: 0,
|
|
4291
|
+
release: () => {
|
|
4292
|
+
if (released) return;
|
|
4293
|
+
released = true;
|
|
4294
|
+
state.active = Math.max(0, state.active - 1);
|
|
4295
|
+
}
|
|
4296
|
+
};
|
|
4297
|
+
}
|
|
4298
|
+
recordFailure(source, now = Date.now()) {
|
|
4299
|
+
const state = this.source(source, now);
|
|
4300
|
+
if (state === null) return {
|
|
4301
|
+
blocked: true,
|
|
4302
|
+
newlyBlocked: false,
|
|
4303
|
+
retryAfterMs: this.policy.blockMs
|
|
4304
|
+
};
|
|
4305
|
+
this.prune(state, now);
|
|
4306
|
+
state.lastSeen = now;
|
|
4307
|
+
const wasBlocked = state.blockedUntil > now;
|
|
4308
|
+
state.failures.push(now);
|
|
4309
|
+
if (state.failures.length >= this.policy.failuresBeforeBlock) state.blockedUntil = Math.max(state.blockedUntil, now + this.policy.blockMs);
|
|
4310
|
+
return {
|
|
4311
|
+
blocked: state.blockedUntil > now,
|
|
4312
|
+
newlyBlocked: !wasBlocked && state.blockedUntil > now,
|
|
4313
|
+
retryAfterMs: Math.max(0, state.blockedUntil - now)
|
|
4314
|
+
};
|
|
4315
|
+
}
|
|
4316
|
+
recordSuccess(source, now = Date.now()) {
|
|
4317
|
+
const state = this.sources.get(source);
|
|
4318
|
+
if (state === void 0) return;
|
|
4319
|
+
state.failures = [];
|
|
4320
|
+
state.blockedUntil = 0;
|
|
4321
|
+
state.lastSeen = now;
|
|
4322
|
+
}
|
|
4323
|
+
source(source, now) {
|
|
4324
|
+
const existing = this.sources.get(source);
|
|
4325
|
+
if (existing !== void 0) return existing;
|
|
4326
|
+
if (this.sources.size >= this.policy.maxSources) this.pruneSources(now);
|
|
4327
|
+
if (this.sources.size >= this.policy.maxSources) return null;
|
|
4328
|
+
const state = {
|
|
4329
|
+
attempts: [],
|
|
4330
|
+
failures: [],
|
|
4331
|
+
blockedUntil: 0,
|
|
4332
|
+
active: 0,
|
|
4333
|
+
lastSeen: now
|
|
4334
|
+
};
|
|
4335
|
+
this.sources.set(source, state);
|
|
4336
|
+
return state;
|
|
4337
|
+
}
|
|
4338
|
+
prune(state, now) {
|
|
4339
|
+
state.attempts = state.attempts.filter((ts) => ts > now - this.policy.windowMs);
|
|
4340
|
+
state.failures = state.failures.filter((ts) => ts > now - this.policy.failureWindowMs);
|
|
4341
|
+
if (state.blockedUntil <= now) state.blockedUntil = 0;
|
|
4342
|
+
}
|
|
4343
|
+
pruneSources(now) {
|
|
4344
|
+
const staleBefore = now - Math.max(this.policy.failureWindowMs, this.policy.blockMs);
|
|
4345
|
+
for (const [source, state] of this.sources) {
|
|
4346
|
+
this.prune(state, now);
|
|
4347
|
+
if (state.active === 0 && state.blockedUntil === 0 && state.lastSeen < staleBefore) this.sources.delete(source);
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
};
|
|
4351
|
+
//#endregion
|
|
3624
4352
|
//#region src/push-policy.ts
|
|
3625
4353
|
/** Prune only when the provider supplies an authoritative token-lifecycle verdict. */
|
|
3626
4354
|
function shouldPrunePushToken(outcome, reason) {
|
|
@@ -3643,12 +4371,13 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
|
|
|
3643
4371
|
* optional health probe (/phone/health) on the existing web server. The web
|
|
3644
4372
|
* UI is never touched.
|
|
3645
4373
|
*
|
|
3646
|
-
* Data plane: an in-process HostBridge consumes
|
|
3647
|
-
*
|
|
3648
|
-
* and fans projected protocol-
|
|
4374
|
+
* Data plane: an in-process HostBridge consumes a local compatibility façade
|
|
4375
|
+
* over DSH 0.1.2 Session/Workspace controllers, mirrors session summaries,
|
|
4376
|
+
* tracks pending approvals/questions, and fans projected protocol-v2 pushes
|
|
4377
|
+
* out to every connected device.
|
|
3649
4378
|
*
|
|
3650
4379
|
* Protocol: PROTOCOL.md is normative; src/protocol.ts and the private app's
|
|
3651
|
-
* Swift models mirror that
|
|
4380
|
+
* Swift models mirror that v2 contract.
|
|
3652
4381
|
*/
|
|
3653
4382
|
const name = "deeppilot";
|
|
3654
4383
|
/** No eager service requirement: profiles without a web stack simply skip. */
|
|
@@ -3682,6 +4411,8 @@ function apply(ctx, options) {
|
|
|
3682
4411
|
const log = (message) => {
|
|
3683
4412
|
console.log("[deeppilot] " + message);
|
|
3684
4413
|
};
|
|
4414
|
+
const auditSalt = randomBytes(32);
|
|
4415
|
+
const auditLabel = (value) => createHash("sha256").update(auditSalt).update(value).digest("hex").slice(0, 12);
|
|
3685
4416
|
/**
|
|
3686
4417
|
* Settings-section source: while a settings service is attached this holds
|
|
3687
4418
|
* the user-edited section value; otherwise the composition defaults. Read
|
|
@@ -3694,12 +4425,15 @@ function apply(ctx, options) {
|
|
|
3694
4425
|
return normalizeOptions(options);
|
|
3695
4426
|
};
|
|
3696
4427
|
const enabledNow = () => currentConfig().enabled === true;
|
|
3697
|
-
|
|
4428
|
+
const settingsHooks = {
|
|
3698
4429
|
setSource: (source) => {
|
|
3699
4430
|
liveSource = source;
|
|
3700
4431
|
queueMicrotask(() => scheduleRemoteReconcile?.());
|
|
3701
4432
|
},
|
|
3702
4433
|
onChange: () => queueMicrotask(() => scheduleRemoteReconcile?.())
|
|
4434
|
+
};
|
|
4435
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
4436
|
+
settingsCtx.settings.installSection(ctx, "deeppilot", Config, normalizeOptions(void 0), settingsHooks);
|
|
3703
4437
|
});
|
|
3704
4438
|
if (currentConfig().enabled !== true) log("disabled via settings; bridge stays inactive (rumors of /phone below are skipped)");
|
|
3705
4439
|
const dataDir = bridgeDataDir();
|
|
@@ -3736,9 +4470,9 @@ function apply(ctx, options) {
|
|
|
3736
4470
|
const url = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3737
4471
|
await ensureRelayEnrolled(url);
|
|
3738
4472
|
};
|
|
4473
|
+
const pairingCodes = new PairingCodeManager();
|
|
3739
4474
|
const auth = {
|
|
3740
|
-
|
|
3741
|
-
tokenPath: cfg.authTokenPath ?? join(dataDir, "auth-token"),
|
|
4475
|
+
audience: null,
|
|
3742
4476
|
devices: null
|
|
3743
4477
|
};
|
|
3744
4478
|
const ready = (async () => {
|
|
@@ -3749,13 +4483,13 @@ function apply(ctx, options) {
|
|
|
3749
4483
|
} catch (error) {
|
|
3750
4484
|
log("legacy plugin-state migration skipped: " + String(error));
|
|
3751
4485
|
}
|
|
3752
|
-
|
|
3753
|
-
auth.
|
|
3754
|
-
auth.devices = await DeviceStore.load(cfg.devicesPath ?? join(dataDir, "devices.json"));
|
|
4486
|
+
await ensurePrivateBridgeDataDir();
|
|
4487
|
+
auth.audience = await loadOrCreateHostAudience(join(dataDir, "host-id"));
|
|
4488
|
+
auth.devices = await DeviceStore.load(cfg.devicesPath ?? join(dataDir, "devices-v2.json"));
|
|
3755
4489
|
{
|
|
3756
4490
|
const rows = auth.devices.list();
|
|
3757
4491
|
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)`);
|
|
4492
|
+
log(`device registry loaded from ${expandHome(cfg.devicesPath ?? join(dataDir, "devices-v2.json"))}: ${rows.length} device(s), ${registered} push registration(s)`);
|
|
3759
4493
|
}
|
|
3760
4494
|
try {
|
|
3761
4495
|
const raw = JSON.parse(await readFile(pushRelayPath, "utf8"));
|
|
@@ -3765,48 +4499,24 @@ function apply(ctx, options) {
|
|
|
3765
4499
|
if (raw.autoRelay === true) enrollmentCell.autoRelay = true;
|
|
3766
4500
|
} catch {}
|
|
3767
4501
|
} catch (error) {
|
|
3768
|
-
|
|
3769
|
-
if (message.includes("pairing token is malformed at")) console.warn("[deeppilot] " + message);
|
|
3770
|
-
log("auth material unavailable, bridge degraded: " + message);
|
|
4502
|
+
log("auth material unavailable, bridge degraded: " + String(error));
|
|
3771
4503
|
return {
|
|
3772
|
-
|
|
4504
|
+
audience: null,
|
|
3773
4505
|
devices: null
|
|
3774
4506
|
};
|
|
3775
4507
|
}
|
|
3776
4508
|
return {
|
|
3777
|
-
|
|
4509
|
+
audience: auth.audience,
|
|
3778
4510
|
devices: auth.devices
|
|
3779
4511
|
};
|
|
3780
4512
|
})();
|
|
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;
|
|
4513
|
+
const beginPairing = async () => {
|
|
4514
|
+
await ready;
|
|
4515
|
+
if (auth.audience === null || auth.devices === null) throw new Error("device authentication unavailable");
|
|
4516
|
+
return {
|
|
4517
|
+
...pairingCodes.issue(),
|
|
4518
|
+
audience: auth.audience
|
|
4519
|
+
};
|
|
3810
4520
|
};
|
|
3811
4521
|
/**
|
|
3812
4522
|
* Settings-page push self-test: force one synthetic notification down the
|
|
@@ -4138,17 +4848,20 @@ function apply(ctx, options) {
|
|
|
4138
4848
|
updateChecker.scheduleInitial();
|
|
4139
4849
|
const updateInfo = () => updateChecker.get();
|
|
4140
4850
|
applyReportRemote(ctx, async () => {
|
|
4141
|
-
let
|
|
4851
|
+
let pairingReady = false;
|
|
4142
4852
|
let devices = [];
|
|
4143
4853
|
try {
|
|
4144
4854
|
await ready;
|
|
4145
|
-
|
|
4146
|
-
devices = (auth.devices?.list() ?? []).map(({ deviceId, deviceName, appVersion, firstSeenTs, lastSeenTs, apns }) => ({
|
|
4855
|
+
pairingReady = auth.audience !== null && auth.devices !== null;
|
|
4856
|
+
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
4857
|
deviceId,
|
|
4148
4858
|
deviceName,
|
|
4149
4859
|
appVersion,
|
|
4150
4860
|
firstSeenTs,
|
|
4151
4861
|
lastSeenTs,
|
|
4862
|
+
fingerprint,
|
|
4863
|
+
scopes: normalizeDeviceScopes(scopes),
|
|
4864
|
+
...revokedAt !== void 0 ? { revokedAt } : {},
|
|
4152
4865
|
...apns ? { apns: {
|
|
4153
4866
|
environment: apns.environment,
|
|
4154
4867
|
updatedAt: apns.updatedAt
|
|
@@ -4157,14 +4870,14 @@ function apply(ctx, options) {
|
|
|
4157
4870
|
} catch {}
|
|
4158
4871
|
const update = updateInfo();
|
|
4159
4872
|
return {
|
|
4160
|
-
protocolVersion:
|
|
4873
|
+
protocolVersion: 2,
|
|
4161
4874
|
serverVersion: SERVER_VERSION,
|
|
4162
4875
|
pluginVersion: update.currentVersion,
|
|
4163
4876
|
...update.available ? { updateAvailable: true } : {},
|
|
4164
4877
|
...update.releaseUrl !== null ? { releaseUrl: update.releaseUrl } : {},
|
|
4165
4878
|
enabled: currentConfig().enabled === true,
|
|
4166
|
-
|
|
4167
|
-
|
|
4879
|
+
identityPath: expandHome(currentConfig().devicesPath ?? join(bridgeDataDir(), "devices-v2.json")),
|
|
4880
|
+
pairingReady,
|
|
4168
4881
|
activeConnections: connections.size,
|
|
4169
4882
|
historyBufferMax: currentConfig().historyBufferMax ?? 2e3,
|
|
4170
4883
|
debug: currentConfig().debug === true,
|
|
@@ -4172,11 +4885,32 @@ function apply(ctx, options) {
|
|
|
4172
4885
|
remote: remoteStatus(),
|
|
4173
4886
|
devices
|
|
4174
4887
|
};
|
|
4888
|
+
}, beginPairing, async (deviceId) => {
|
|
4889
|
+
const { devices } = await ready;
|
|
4890
|
+
if (!devices) throw new Error("device registry unavailable");
|
|
4891
|
+
const revoked = devices.revoke(deviceId, Date.now());
|
|
4892
|
+
if (revoked) {
|
|
4893
|
+
for (const connection of [...connections]) {
|
|
4894
|
+
if (connection.connectedDeviceId !== deviceId) continue;
|
|
4895
|
+
connection.terminate();
|
|
4896
|
+
connections.delete(connection);
|
|
4897
|
+
}
|
|
4898
|
+
log(`device revoked id=${auditLabel(deviceId)}`);
|
|
4899
|
+
}
|
|
4900
|
+
return revoked;
|
|
4901
|
+
}, async (deviceId, scopes) => {
|
|
4902
|
+
const { devices } = await ready;
|
|
4903
|
+
if (!devices) throw new Error("device registry unavailable");
|
|
4904
|
+
const updated = devices.setScopes(deviceId, scopes);
|
|
4905
|
+
if (updated === null) throw new Error("active device not found");
|
|
4906
|
+
for (const connection of [...connections]) {
|
|
4907
|
+
if (connection.connectedDeviceId !== deviceId) continue;
|
|
4908
|
+
connection.terminate();
|
|
4909
|
+
connections.delete(connection);
|
|
4910
|
+
}
|
|
4911
|
+
log(`device scopes updated id=${auditLabel(deviceId)} scopes=${updated.join(",")}`);
|
|
4912
|
+
return updated;
|
|
4175
4913
|
}, async () => {
|
|
4176
|
-
await ready;
|
|
4177
|
-
if (auth.token === null) throw new Error("pairing token unavailable");
|
|
4178
|
-
return auth.token;
|
|
4179
|
-
}, rotatePairingToken, async () => {
|
|
4180
4914
|
const push = currentConfig().push ?? {};
|
|
4181
4915
|
const configured = push.provider ?? "none";
|
|
4182
4916
|
if ((configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured) !== "relay") return {
|
|
@@ -4220,6 +4954,102 @@ function apply(ctx, options) {
|
|
|
4220
4954
|
});
|
|
4221
4955
|
const state = {};
|
|
4222
4956
|
let pendingUpgrades = 0;
|
|
4957
|
+
const authRateLimiter = new AuthRateLimiter();
|
|
4958
|
+
const readJSONBody = async (req, maxBytes = 16384) => {
|
|
4959
|
+
const chunks = [];
|
|
4960
|
+
let size = 0;
|
|
4961
|
+
for await (const chunk of req) {
|
|
4962
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
4963
|
+
size += buffer.length;
|
|
4964
|
+
if (size > maxBytes) throw new Error("request body too large");
|
|
4965
|
+
chunks.push(buffer);
|
|
4966
|
+
}
|
|
4967
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
4968
|
+
};
|
|
4969
|
+
const handlePair = async (req, res) => {
|
|
4970
|
+
res.setHeader("Content-Type", "application/json");
|
|
4971
|
+
if (!enabledNow()) {
|
|
4972
|
+
res.statusCode = 503;
|
|
4973
|
+
res.end(JSON.stringify({
|
|
4974
|
+
ok: false,
|
|
4975
|
+
error: "bridge disabled"
|
|
4976
|
+
}));
|
|
4977
|
+
return;
|
|
4978
|
+
}
|
|
4979
|
+
if (req.method !== "POST") {
|
|
4980
|
+
res.statusCode = 405;
|
|
4981
|
+
res.setHeader("Allow", "POST");
|
|
4982
|
+
res.end(JSON.stringify({
|
|
4983
|
+
ok: false,
|
|
4984
|
+
error: "POST required"
|
|
4985
|
+
}));
|
|
4986
|
+
return;
|
|
4987
|
+
}
|
|
4988
|
+
const source = requestClientIdentity(req);
|
|
4989
|
+
const admission = authRateLimiter.admit(source);
|
|
4990
|
+
if (!admission.ok) {
|
|
4991
|
+
res.statusCode = 429;
|
|
4992
|
+
res.setHeader("Retry-After", String(Math.max(1, Math.ceil(admission.retryAfterMs / 1e3))));
|
|
4993
|
+
res.end(JSON.stringify({
|
|
4994
|
+
ok: false,
|
|
4995
|
+
error: "pairing rate limited"
|
|
4996
|
+
}));
|
|
4997
|
+
return;
|
|
4998
|
+
}
|
|
4999
|
+
try {
|
|
5000
|
+
const { devices, audience } = await ready;
|
|
5001
|
+
if (!devices || !audience) throw new Error("device authentication unavailable");
|
|
5002
|
+
const raw = await readJSONBody(req);
|
|
5003
|
+
if (raw === null || typeof raw !== "object" || raw.v !== 2) throw new TypeError("protocol v2 required");
|
|
5004
|
+
const code = typeof raw.code === "string" ? raw.code : "";
|
|
5005
|
+
const publicKey = typeof raw.publicKey === "string" ? raw.publicKey : "";
|
|
5006
|
+
const deviceName = sanitizeDeviceField(raw.deviceName, 64) || "unknown";
|
|
5007
|
+
const appVersion = sanitizeDeviceField(raw.appVersion, 32) || "unknown";
|
|
5008
|
+
const deviceId = deviceIdForPublicKey(publicKey);
|
|
5009
|
+
if (devices.list().length >= 64 && devices.authorized(deviceId) === void 0) {
|
|
5010
|
+
res.statusCode = 409;
|
|
5011
|
+
res.end(JSON.stringify({
|
|
5012
|
+
ok: false,
|
|
5013
|
+
error: "device registry is full"
|
|
5014
|
+
}));
|
|
5015
|
+
return;
|
|
5016
|
+
}
|
|
5017
|
+
if (!pairingCodes.consume(code)) {
|
|
5018
|
+
const failure = authRateLimiter.recordFailure(source);
|
|
5019
|
+
res.statusCode = failure.blocked ? 429 : 401;
|
|
5020
|
+
if (failure.retryAfterMs > 0) res.setHeader("Retry-After", String(Math.max(1, Math.ceil(failure.retryAfterMs / 1e3))));
|
|
5021
|
+
res.end(JSON.stringify({
|
|
5022
|
+
ok: false,
|
|
5023
|
+
error: failure.blocked ? "pairing rate limited" : "pairing code invalid or expired"
|
|
5024
|
+
}));
|
|
5025
|
+
return;
|
|
5026
|
+
}
|
|
5027
|
+
const record = devices.register({
|
|
5028
|
+
publicKey,
|
|
5029
|
+
deviceName,
|
|
5030
|
+
appVersion,
|
|
5031
|
+
scopes: normalizeDeviceScopes(raw.scopes)
|
|
5032
|
+
}, Date.now());
|
|
5033
|
+
authRateLimiter.recordSuccess(source);
|
|
5034
|
+
log(`device paired id=${auditLabel(record.deviceId)} source=${auditLabel(source)}`);
|
|
5035
|
+
res.statusCode = 201;
|
|
5036
|
+
res.end(JSON.stringify({
|
|
5037
|
+
ok: true,
|
|
5038
|
+
v: 2,
|
|
5039
|
+
deviceId: record.deviceId,
|
|
5040
|
+
audience,
|
|
5041
|
+
scopes: record.scopes ?? []
|
|
5042
|
+
}));
|
|
5043
|
+
} catch (error) {
|
|
5044
|
+
res.statusCode = error instanceof SyntaxError || error instanceof TypeError ? 400 : 503;
|
|
5045
|
+
res.end(JSON.stringify({
|
|
5046
|
+
ok: false,
|
|
5047
|
+
error: error instanceof Error ? error.message : "pairing failed"
|
|
5048
|
+
}));
|
|
5049
|
+
} finally {
|
|
5050
|
+
admission.release();
|
|
5051
|
+
}
|
|
5052
|
+
};
|
|
4223
5053
|
const handleUpgrade = (req, socket, head) => {
|
|
4224
5054
|
(async () => {
|
|
4225
5055
|
try {
|
|
@@ -4233,44 +5063,60 @@ function apply(ctx, options) {
|
|
|
4233
5063
|
}
|
|
4234
5064
|
pendingUpgrades += 1;
|
|
4235
5065
|
try {
|
|
4236
|
-
const { devices } = await ready;
|
|
4237
|
-
|
|
4238
|
-
if (!token || !devices) {
|
|
5066
|
+
const { devices, audience } = await ready;
|
|
5067
|
+
if (!audience || !devices) {
|
|
4239
5068
|
rejectUpgrade(socket, 503, "bridge degraded");
|
|
4240
5069
|
return;
|
|
4241
5070
|
}
|
|
4242
|
-
const
|
|
4243
|
-
|
|
4244
|
-
|
|
5071
|
+
const source = requestClientIdentity(req);
|
|
5072
|
+
const admission = authRateLimiter.admit(source);
|
|
5073
|
+
if (!admission.ok) {
|
|
5074
|
+
rejectUpgrade(socket, 429, "authentication rate limited", admission.retryAfterMs / 1e3);
|
|
4245
5075
|
return;
|
|
4246
5076
|
}
|
|
4247
5077
|
const bridge = state.bridge;
|
|
4248
5078
|
if (!bridge) {
|
|
5079
|
+
admission.release();
|
|
4249
5080
|
rejectUpgrade(socket, 503, "bridge not ready");
|
|
4250
5081
|
return;
|
|
4251
5082
|
}
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
5083
|
+
try {
|
|
5084
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
5085
|
+
if (auth.audience !== audience || state.bridge !== bridge) {
|
|
5086
|
+
admission.release();
|
|
5087
|
+
ws.close(1012, "bridge changed");
|
|
5088
|
+
return;
|
|
5089
|
+
}
|
|
5090
|
+
try {
|
|
5091
|
+
const connection = new BridgeConnection(ws, {
|
|
5092
|
+
bridge,
|
|
5093
|
+
devices,
|
|
5094
|
+
serverVersion: SERVER_VERSION,
|
|
5095
|
+
audience,
|
|
5096
|
+
log,
|
|
5097
|
+
debug: currentConfig().debug === true,
|
|
5098
|
+
onClosed: (closed) => connections.delete(closed),
|
|
5099
|
+
onAuthenticationSettled: (ok) => {
|
|
5100
|
+
admission.release();
|
|
5101
|
+
if (ok) authRateLimiter.recordSuccess(source);
|
|
5102
|
+
else if (authRateLimiter.recordFailure(source).newlyBlocked) log(`authentication source blocked source=${auditLabel(source)}`);
|
|
5103
|
+
},
|
|
5104
|
+
onDeviceAuthenticated: (deviceId) => {
|
|
5105
|
+
log(`device authenticated id=${auditLabel(deviceId)} source=${auditLabel(source)}`);
|
|
5106
|
+
},
|
|
5107
|
+
onPushEnrollKey: handlePushEnrollKey
|
|
5108
|
+
});
|
|
5109
|
+
connections.add(connection);
|
|
5110
|
+
} catch (error) {
|
|
5111
|
+
admission.release();
|
|
5112
|
+
ws.close(1011, "connection setup failed");
|
|
5113
|
+
throw error;
|
|
5114
|
+
}
|
|
4271
5115
|
});
|
|
4272
|
-
|
|
4273
|
-
|
|
5116
|
+
} catch (error) {
|
|
5117
|
+
admission.release();
|
|
5118
|
+
throw error;
|
|
5119
|
+
}
|
|
4274
5120
|
} finally {
|
|
4275
5121
|
pendingUpgrades -= 1;
|
|
4276
5122
|
}
|
|
@@ -4283,9 +5129,8 @@ function apply(ctx, options) {
|
|
|
4283
5129
|
const handleHealth = async (req, res) => {
|
|
4284
5130
|
try {
|
|
4285
5131
|
await ready;
|
|
4286
|
-
const token = auth.token;
|
|
4287
5132
|
res.setHeader("Content-Type", "application/json");
|
|
4288
|
-
if (!
|
|
5133
|
+
if (!auth.audience || !auth.devices) {
|
|
4289
5134
|
res.statusCode = 503;
|
|
4290
5135
|
res.end(JSON.stringify({
|
|
4291
5136
|
ok: false,
|
|
@@ -4293,16 +5138,11 @@ function apply(ctx, options) {
|
|
|
4293
5138
|
}));
|
|
4294
5139
|
return;
|
|
4295
5140
|
}
|
|
4296
|
-
if (!tokenMatches(requestToken(req), token)) {
|
|
4297
|
-
res.statusCode = 401;
|
|
4298
|
-
res.end(JSON.stringify({ ok: false }));
|
|
4299
|
-
return;
|
|
4300
|
-
}
|
|
4301
5141
|
res.statusCode = 200;
|
|
4302
5142
|
res.end(JSON.stringify({
|
|
4303
5143
|
ok: true,
|
|
4304
5144
|
enabled: enabledNow(),
|
|
4305
|
-
protocolVersion:
|
|
5145
|
+
protocolVersion: 2,
|
|
4306
5146
|
serverVersion: SERVER_VERSION,
|
|
4307
5147
|
dataPlane: Boolean(state.bridge)
|
|
4308
5148
|
}));
|
|
@@ -4311,15 +5151,17 @@ function apply(ctx, options) {
|
|
|
4311
5151
|
res.end(JSON.stringify({ ok: false }));
|
|
4312
5152
|
}
|
|
4313
5153
|
};
|
|
4314
|
-
ctx.inject(["
|
|
5154
|
+
ctx.inject(["sessionController"], (sub) => {
|
|
4315
5155
|
if (currentConfig().enabled !== true) {
|
|
4316
5156
|
log("bridge disabled; data plane stays inactive");
|
|
4317
5157
|
return;
|
|
4318
5158
|
}
|
|
4319
5159
|
const apiCtx = sub;
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
5160
|
+
let proxy;
|
|
5161
|
+
try {
|
|
5162
|
+
proxy = new Dsh012ApiProxy(apiCtx);
|
|
5163
|
+
} catch (error) {
|
|
5164
|
+
log("dsh 0.1.2 session bridge unavailable: " + String(error));
|
|
4323
5165
|
return;
|
|
4324
5166
|
}
|
|
4325
5167
|
const bridge = new HostBridge(proxy, cfg.historyBufferMax);
|
|
@@ -4349,6 +5191,11 @@ function apply(ctx, options) {
|
|
|
4349
5191
|
path: "/phone/health",
|
|
4350
5192
|
handler: handleHealth
|
|
4351
5193
|
}), "deeppilot: /phone/health");
|
|
5194
|
+
webCtx.effect(() => web.register({
|
|
5195
|
+
kind: "exact",
|
|
5196
|
+
path: "/phone/pair",
|
|
5197
|
+
handler: handlePair
|
|
5198
|
+
}), "deeppilot: /phone/pair");
|
|
4352
5199
|
const sweep = setInterval(() => {
|
|
4353
5200
|
const now = Date.now();
|
|
4354
5201
|
for (const connection of connections) if (connection.isStale(now, 6e4)) {
|
|
@@ -4364,6 +5211,7 @@ function apply(ctx, options) {
|
|
|
4364
5211
|
path = new URL(req.url ?? "/", "http://phone.local").pathname;
|
|
4365
5212
|
} catch {}
|
|
4366
5213
|
if (path === "/phone/health") handleHealth(req, res);
|
|
5214
|
+
else if (path === "/phone/pair") handlePair(req, res);
|
|
4367
5215
|
else {
|
|
4368
5216
|
res.statusCode = 404;
|
|
4369
5217
|
res.end("not found");
|
|
@@ -4395,7 +5243,8 @@ function apply(ctx, options) {
|
|
|
4395
5243
|
hostname: normalizeRemoteHostname(remoteConfig.hostname),
|
|
4396
5244
|
statePath: remoteConfig.statePath?.trim() || join(dataDir, "tailscale"),
|
|
4397
5245
|
helperPath,
|
|
4398
|
-
funnelPort: remotePort
|
|
5246
|
+
funnelPort: remotePort,
|
|
5247
|
+
maxConnectionsPerSource: normalizeFunnelConnectionLimit(remoteConfig.maxConnectionsPerSource)
|
|
4399
5248
|
};
|
|
4400
5249
|
const nextKey = JSON.stringify(next);
|
|
4401
5250
|
if (nextKey === appliedRemoteKey) return;
|
|
@@ -4409,6 +5258,7 @@ function apply(ctx, options) {
|
|
|
4409
5258
|
statePath: next.statePath,
|
|
4410
5259
|
...next.helperPath ? { helperPath: next.helperPath } : {},
|
|
4411
5260
|
funnelPort: next.funnelPort,
|
|
5261
|
+
maxConnectionsPerSource: next.maxConnectionsPerSource,
|
|
4412
5262
|
log
|
|
4413
5263
|
});
|
|
4414
5264
|
remoteSupervisor = supervisor;
|
|
@@ -4456,6 +5306,6 @@ function apply(ctx, options) {
|
|
|
4456
5306
|
}, "deeppilot: process resources");
|
|
4457
5307
|
}
|
|
4458
5308
|
//#endregion
|
|
4459
|
-
export { Config, HostBridge, apply, inject, name,
|
|
5309
|
+
export { Config, HostBridge, apply, inject, name, shouldPrunePushToken, shouldReEnrollRelayToken };
|
|
4460
5310
|
|
|
4461
5311
|
//# sourceMappingURL=index.js.map
|