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/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
- 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
- }
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
- 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
- }
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: 1,
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 = 5e3;
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 helloTokenAccepted(transportAuthenticated, presentedToken, expectedToken) {
251
- return transportAuthenticated === true || tokenMatches(presentedToken, expectedToken);
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. Bearer/query credentials may authenticate the
287
- * HTTP upgrade; otherwise the first hello frame is verified here.
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) this.close(4402, "auth timeout");
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: 1,
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 !== 1) {
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.hello.auth") {
418
- await this.hello(env);
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
- this.openSessions.add(sessionId);
473
- this.deps.bridge.markSinkOpen(this, sessionId);
599
+ const bufferedEvents = [];
600
+ this.openingSessionEvents.set(sessionId, bufferedEvents);
474
601
  if (!await this.deps.bridge.openSession(this, sessionId, p.tailCount ?? 100)) {
475
- this.openSessions.delete(sessionId);
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
- 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");
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 hello(env) {
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.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;
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: 1,
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.sort((a, b) => a.seq - b.seq).map(limitMessageProjection);
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 messages = projectHistory(result.events ?? []);
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(sink, sessionId, beforeSeq, limit) {
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 false;
1794
+ if (!response.result || !response.result.ok) return null;
1626
1795
  const result = response.result.value;
1627
- const messages = projectHistory(result.events ?? []);
1628
- sink.push("s2c.history.page", {
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 false;
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
- pairingToken;
2320
- rotatePairingToken;
2846
+ pairingStarter;
2847
+ deviceRevoker;
2848
+ deviceScopeUpdater;
2321
2849
  relayTester;
2322
2850
  pushTester;
2323
- constructor(ctx, snapshot, pairingToken, rotatePairingToken, relayTester, pushTester) {
2851
+ constructor(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
2324
2852
  super(ctx, "deeppilotReport", { namespace: "deeppilot" });
2325
2853
  this.snapshot = snapshot;
2326
- this.pairingToken = pairingToken;
2327
- this.rotatePairingToken = rotatePairingToken;
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 revealToken() {
2335
- return this.pairingToken();
2863
+ async beginPairing() {
2864
+ return this.pairingStarter();
2865
+ }
2866
+ async revokeDevice(deviceId) {
2867
+ return this.deviceRevoker(deviceId);
2336
2868
  }
2337
- async rotateToken() {
2338
- return this.rotatePairingToken();
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
- /** 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";
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
- tokenPath: str(s, "tokenPath", "tokenPath"),
2516
- tokenReady: bool(s, "tokenReady", "tokenReady"),
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 pairingTokenSchema = { parse(value) {
2529
- if (typeof value !== "string" || value.length < 32) throw new TypeError("deeppilot/revealToken result: invalid token");
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}#${REVEAL_TOKEN_ENDPOINT}`,
3100
+ id: `${REPORT_REMOTE_PACKAGE}#${BEGIN_PAIRING_ENDPOINT}`,
2552
3101
  service: "deeppilotReport",
2553
3102
  namespace: "deeppilot",
2554
- method: "revealToken",
3103
+ method: "beginPairing",
2555
3104
  invocation: { kind: "direct" },
2556
3105
  parameters: [],
2557
3106
  result: {
2558
3107
  mode: "strict",
2559
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingToken`,
2560
- schema: pairingTokenSchema
3108
+ typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`,
3109
+ schema: pairingGrantSchema
2561
3110
  }
2562
3111
  },
2563
3112
  {
2564
- id: `${REPORT_REMOTE_PACKAGE}#${ROTATE_TOKEN_ENDPOINT}`,
3113
+ id: `${REPORT_REMOTE_PACKAGE}#${REVOKE_DEVICE_ENDPOINT}`,
2565
3114
  service: "deeppilotReport",
2566
3115
  namespace: "deeppilot",
2567
- method: "rotateToken",
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}#PairingToken`,
2573
- schema: pairingTokenSchema
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, pairingToken, rotatePairingToken, relayTester, pushTester) {
3202
+ function applyReportRemote(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
2611
3203
  ctx.inject(["typert"], (remoteCtx) => {
2612
- new DeepPilotReportService(remoteCtx, snapshot, pairingToken, rotatePairingToken, relayTester, pushTester);
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
- authTokenPath: z.string().default(join(bridgeDataDir(), "auth-token")),
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
- function rejectUpgrade(socket, status, reason) {
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 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);
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
- /** 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;
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 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.
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 v1 contract.
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
- installSettingsSection(ctx, settingsNamespace("deeppilot"), Config, normalizeOptions(void 0), {
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
- token: null,
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
- 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"));
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
- 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);
4502
+ log("auth material unavailable, bridge degraded: " + String(error));
3771
4503
  return {
3772
- token: null,
4504
+ audience: null,
3773
4505
  devices: null
3774
4506
  };
3775
4507
  }
3776
4508
  return {
3777
- token: auth.token,
4509
+ audience: auth.audience,
3778
4510
  devices: auth.devices
3779
4511
  };
3780
4512
  })();
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;
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 tokenReady = false;
4851
+ let pairingReady = false;
4142
4852
  let devices = [];
4143
4853
  try {
4144
4854
  await ready;
4145
- tokenReady = auth.token !== null;
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: 1,
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
- tokenPath: expandHome(currentConfig().authTokenPath ?? join(bridgeDataDir(), "auth-token")),
4167
- tokenReady,
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
- const token = auth.token;
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 presentedToken = requestToken(req);
4243
- if (presentedToken !== null && !tokenMatches(presentedToken, token)) {
4244
- rejectUpgrade(socket, 401, "invalid token");
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
- 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
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
- connections.add(connection);
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 (!token) {
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: 1,
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(["apiProxy"], (sub) => {
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
- const proxy = apiCtx.apiProxy;
4321
- if (!proxy) {
4322
- log("apiProxy service absent; data plane stays inactive");
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, requestToken, shouldPrunePushToken, shouldReEnrollRelayToken };
5309
+ export { Config, HostBridge, apply, inject, name, shouldPrunePushToken, shouldReEnrollRelayToken };
4460
5310
 
4461
5311
  //# sourceMappingURL=index.js.map