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/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
- touch(record, now) {
138
- const existing = this.devices.get(record.deviceId);
139
- if (existing) {
140
- existing.lastSeenTs = now;
141
- existing.deviceName = record.deviceName || existing.deviceName;
142
- existing.appVersion = record.appVersion || existing.appVersion;
143
- } else {
144
- if (this.devices.size >= 64) {
145
- let oldestId;
146
- let oldestTs = Number.POSITIVE_INFINITY;
147
- for (const [id, value] of this.devices) if (value.lastSeenTs < oldestTs) {
148
- oldestTs = value.lastSeenTs;
149
- oldestId = id;
150
- }
151
- if (oldestId !== void 0) this.devices.delete(oldestId);
152
- }
153
- this.devices.set(record.deviceId, {
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
- let record = this.devices.get(deviceId);
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: 1,
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 = 5e3;
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 helloTokenAccepted(transportAuthenticated, presentedToken, expectedToken) {
251
- return transportAuthenticated === true || tokenMatches(presentedToken, expectedToken);
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. Bearer/query credentials may authenticate the
287
- * HTTP upgrade; otherwise the first hello frame is verified here.
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) this.close(4402, "auth timeout");
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: 1,
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 !== 1) {
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.hello.auth") {
418
- await this.hello(env);
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
- this.openSessions.add(sessionId);
473
- this.deps.bridge.markSinkOpen(this, sessionId);
600
+ const bufferedEvents = [];
601
+ this.openingSessionEvents.set(sessionId, bufferedEvents);
474
602
  if (!await this.deps.bridge.openSession(this, sessionId, p.tailCount ?? 100)) {
475
- this.openSessions.delete(sessionId);
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
- if (!await this.deps.bridge.historyPage(this, p.sessionId, p.beforeSeq, Math.min(p.limit ?? 100, 500))) this.fail(env.id, "E_NOT_FOUND", "history unavailable");
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 hello(env) {
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.touch({
673
- deviceId,
674
- deviceName,
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: 1,
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.sort((a, b) => a.seq - b.seq).map(limitMessageProjection);
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 messages = projectHistory(result.events ?? []);
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(sink, sessionId, beforeSeq, limit) {
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 false;
1795
+ if (!response.result || !response.result.ok) return null;
1626
1796
  const result = response.result.value;
1627
- const messages = projectHistory(result.events ?? []);
1628
- sink.push("s2c.history.page", {
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 false;
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
- pairingToken;
2320
- rotatePairingToken;
2847
+ pairingStarter;
2848
+ deviceRevoker;
2849
+ deviceScopeUpdater;
2321
2850
  relayTester;
2322
2851
  pushTester;
2323
- constructor(ctx, snapshot, pairingToken, rotatePairingToken, relayTester, pushTester) {
2852
+ constructor(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
2324
2853
  super(ctx, "deeppilotReport", { namespace: "deeppilot" });
2325
2854
  this.snapshot = snapshot;
2326
- this.pairingToken = pairingToken;
2327
- this.rotatePairingToken = rotatePairingToken;
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 revealToken() {
2335
- return this.pairingToken();
2864
+ async beginPairing() {
2865
+ return this.pairingStarter();
2866
+ }
2867
+ async revokeDevice(deviceId) {
2868
+ return this.deviceRevoker(deviceId);
2336
2869
  }
2337
- async rotateToken() {
2338
- return this.rotatePairingToken();
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
- /** Explicit, user-triggered endpoint for revealing the pairing secret. */
2354
- const REVEAL_TOKEN_ENDPOINT = "deeppilot/revealToken";
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
- tokenPath: str(s, "tokenPath", "tokenPath"),
2516
- tokenReady: bool(s, "tokenReady", "tokenReady"),
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 pairingTokenSchema = { parse(value) {
2529
- if (typeof value !== "string" || value.length < 32) throw new TypeError("deeppilot/revealToken result: invalid token");
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}#${REVEAL_TOKEN_ENDPOINT}`,
3101
+ id: `${REPORT_REMOTE_PACKAGE}#${BEGIN_PAIRING_ENDPOINT}`,
2552
3102
  service: "deeppilotReport",
2553
3103
  namespace: "deeppilot",
2554
- method: "revealToken",
3104
+ method: "beginPairing",
2555
3105
  invocation: { kind: "direct" },
2556
3106
  parameters: [],
2557
3107
  result: {
2558
3108
  mode: "strict",
2559
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingToken`,
2560
- schema: pairingTokenSchema
3109
+ typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`,
3110
+ schema: pairingGrantSchema
2561
3111
  }
2562
3112
  },
2563
3113
  {
2564
- id: `${REPORT_REMOTE_PACKAGE}#${ROTATE_TOKEN_ENDPOINT}`,
3114
+ id: `${REPORT_REMOTE_PACKAGE}#${REVOKE_DEVICE_ENDPOINT}`,
2565
3115
  service: "deeppilotReport",
2566
3116
  namespace: "deeppilot",
2567
- method: "rotateToken",
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}#PairingToken`,
2573
- schema: pairingTokenSchema
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, pairingToken, rotatePairingToken, relayTester, pushTester) {
3203
+ function applyReportRemote(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
2611
3204
  ctx.inject(["typert"], (remoteCtx) => {
2612
- new DeepPilotReportService(remoteCtx, snapshot, pairingToken, rotatePairingToken, relayTester, pushTester);
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
- authTokenPath: z.string().default(join(bridgeDataDir(), "auth-token")),
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
- function rejectUpgrade(socket, status, reason) {
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 authenticate = status === 401 ? "WWW-Authenticate: Bearer realm=\"deeppilot\"\r\n" : "";
3608
- socket.end("HTTP/1.1 " + status + " " + (statusText[status] ?? "Error") + "\r\n" + authenticate + "Content-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
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
- /** Authorization is preferred; the query form remains for older app builds. */
3611
- function requestToken(req) {
3612
- const authorization = req.headers.authorization;
3613
- if (typeof authorization === "string") {
3614
- const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
3615
- if (match?.[1]) return match[1];
3616
- }
3617
- try {
3618
- return new URL(req.url ?? "/", "http://phone.local").searchParams.get("token");
3619
- } catch {
3620
- return null;
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 apiProxy.events.mux()/host()
3647
- * streams, mirrors session summaries, tracks pending approvals/questions,
3648
- * and fans projected protocol-v1 pushes out to every connected device.
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 v1 contract.
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
- token: null,
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
- auth.tokenPath = cfg.authTokenPath ?? join(dataDir, "auth-token");
3753
- auth.token = await loadOrCreateToken(auth.tokenPath);
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
- const message = String(error);
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
- token: null,
4502
+ audience: null,
3773
4503
  devices: null
3774
4504
  };
3775
4505
  }
3776
4506
  return {
3777
- token: auth.token,
4507
+ audience: auth.audience,
3778
4508
  devices: auth.devices
3779
4509
  };
3780
4510
  })();
3781
- /**
3782
- * Replace the pairing secret: persist a fresh token, clear the paired-device
3783
- * registry, and drop every live phone socket. Handshake-time auth means an
3784
- * already-open socket would otherwise outlive its token; terminating forces
3785
- * each device to re-pair with the new secret. The old token is invalid the
3786
- * moment the file is rewritten.
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 tokenReady = false;
4849
+ let pairingReady = false;
4142
4850
  let devices = [];
4143
4851
  try {
4144
4852
  await ready;
4145
- tokenReady = auth.token !== null;
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: 1,
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
- tokenPath: expandHome(currentConfig().authTokenPath ?? join(bridgeDataDir(), "auth-token")),
4167
- tokenReady,
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
- const token = auth.token;
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 presentedToken = requestToken(req);
4243
- if (presentedToken !== null && !tokenMatches(presentedToken, token)) {
4244
- rejectUpgrade(socket, 401, "invalid token");
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
- if (auth.token !== token) {
4253
- rejectUpgrade(socket, 401, "invalid token");
4254
- return;
4255
- }
4256
- wss.handleUpgrade(req, socket, head, (ws) => {
4257
- if (auth.token !== token || state.bridge !== bridge) {
4258
- ws.close(1012, "bridge changed");
4259
- return;
4260
- }
4261
- const connection = new BridgeConnection(ws, {
4262
- bridge,
4263
- devices,
4264
- serverVersion: SERVER_VERSION,
4265
- expectedToken: token,
4266
- transportAuthenticated: presentedToken !== null,
4267
- log,
4268
- debug: currentConfig().debug === true,
4269
- onClosed: (closed) => connections.delete(closed),
4270
- onPushEnrollKey: handlePushEnrollKey
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
- connections.add(connection);
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 (!token) {
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: 1,
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(["apiProxy"], (sub) => {
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
- const proxy = apiCtx.apiProxy;
4321
- if (!proxy) {
4322
- log("apiProxy service absent; data plane stays inactive");
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, requestToken, shouldPrunePushToken, shouldReEnrollRelayToken };
5307
+ export { Config, HostBridge, apply, inject, name, shouldPrunePushToken, shouldReEnrollRelayToken };
4460
5308
 
4461
5309
  //# sourceMappingURL=index.js.map