@linxin666/dsh-remote-web-ui 0.2.3 → 0.2.5

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.
Files changed (95) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +148 -77
  3. package/README.zh.md +32 -26
  4. package/lib/client.js +610 -74
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +676 -35
  7. package/lib/mobile.js +10 -7
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/FenceNotice.d.ts +21 -0
  10. package/lib/types/client/FenceNotice.d.ts.map +1 -0
  11. package/lib/types/client/FenceNotice.js +10 -0
  12. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  13. package/lib/types/client/RemoteEntry.js +18 -11
  14. package/lib/types/client/RemotePanel.d.ts +10 -4
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +7 -6
  17. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +3 -1
  20. package/lib/types/client/device-name.d.ts +3 -0
  21. package/lib/types/client/device-name.d.ts.map +1 -0
  22. package/lib/types/client/device-name.js +30 -0
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +48 -0
  25. package/lib/types/client/locales.d.ts +51 -1
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +83 -33
  28. package/lib/types/client/pair-api.d.ts +35 -0
  29. package/lib/types/client/pair-api.d.ts.map +1 -1
  30. package/lib/types/client/pair-api.js +39 -0
  31. package/lib/types/client/remote-channel.d.ts +93 -0
  32. package/lib/types/client/remote-channel.d.ts.map +1 -0
  33. package/lib/types/client/remote-channel.js +246 -0
  34. package/lib/types/client/settings-form.d.ts.map +1 -1
  35. package/lib/types/client/settings-form.js +9 -6
  36. package/lib/types/dsh-home.d.ts +17 -0
  37. package/lib/types/dsh-home.d.ts.map +1 -0
  38. package/lib/types/dsh-home.js +34 -0
  39. package/lib/types/gate.d.ts.map +1 -1
  40. package/lib/types/gate.js +5 -2
  41. package/lib/types/index.d.ts +19 -12
  42. package/lib/types/index.d.ts.map +1 -1
  43. package/lib/types/index.js +73 -5
  44. package/lib/types/loopback-proxy.d.ts +28 -0
  45. package/lib/types/loopback-proxy.d.ts.map +1 -0
  46. package/lib/types/loopback-proxy.js +112 -0
  47. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  48. package/lib/types/mobile/views/SessionListView.js +6 -1
  49. package/lib/types/mobile-api.d.ts.map +1 -1
  50. package/lib/types/mobile-api.js +6 -2
  51. package/lib/types/mobile-routes.js +1 -1
  52. package/lib/types/pairing.d.ts +63 -11
  53. package/lib/types/pairing.d.ts.map +1 -1
  54. package/lib/types/pairing.js +137 -20
  55. package/lib/types/pairing.persist.test.js +60 -5
  56. package/lib/types/posture.d.ts +75 -0
  57. package/lib/types/posture.d.ts.map +1 -0
  58. package/lib/types/posture.js +116 -0
  59. package/lib/types/remote-api.d.ts +60 -0
  60. package/lib/types/remote-api.d.ts.map +1 -0
  61. package/lib/types/remote-api.js +181 -0
  62. package/lib/types/remote-methods.d.ts +33 -0
  63. package/lib/types/remote-methods.d.ts.map +1 -0
  64. package/lib/types/remote-methods.js +54 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/routes.d.ts.map +1 -1
  67. package/lib/types/routes.js +33 -4
  68. package/lib/types/update.d.ts.map +1 -1
  69. package/lib/types/update.js +10 -2
  70. package/package.json +13 -13
  71. package/src/client/FenceNotice.tsx +43 -0
  72. package/src/client/RemoteEntry.tsx +19 -9
  73. package/src/client/RemotePanel.tsx +83 -10
  74. package/src/client/RemoteSettingsCard.tsx +16 -0
  75. package/src/client/device-name.ts +31 -0
  76. package/src/client/index.ts +46 -0
  77. package/src/client/locales.ts +83 -33
  78. package/src/client/pair-api.ts +60 -0
  79. package/src/client/remote-channel.ts +269 -0
  80. package/src/client/remote.module.css +240 -16
  81. package/src/client/settings-form.ts +8 -6
  82. package/src/dsh-home.ts +36 -0
  83. package/src/gate.ts +5 -2
  84. package/src/index.ts +89 -17
  85. package/src/loopback-proxy.ts +126 -0
  86. package/src/mobile/views/SessionListView.tsx +6 -1
  87. package/src/mobile-api.ts +6 -2
  88. package/src/mobile-routes.ts +1 -1
  89. package/src/pairing.persist.test.ts +64 -5
  90. package/src/pairing.ts +167 -20
  91. package/src/posture.ts +154 -0
  92. package/src/remote-api.ts +217 -0
  93. package/src/remote-methods.ts +62 -0
  94. package/src/routes.ts +33 -4
  95. package/src/update.ts +14 -1
package/lib/index.js CHANGED
@@ -1,18 +1,20 @@
1
1
  import { createRequire } from "node:module";
2
- import { setInterval as setInterval$1 } from "node:timers";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import { setInterval as setInterval$1, setTimeout as setTimeout$1 } from "node:timers";
3
4
  import { Service } from "@deepseek-ai/cordis";
4
5
  import z from "schemastery";
5
6
  import { randomBytes } from "node:crypto";
6
7
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
- import { dirname, join } from "node:path";
8
+ import { homedir, networkInterfaces } from "node:os";
8
9
  import { z as z$1 } from "zod";
9
10
  import { readFile } from "node:fs/promises";
10
11
  import { fileURLToPath } from "node:url";
11
12
  import { RpcId } from "@deepseek-ai/dsh-host-apiproxy/api/rpc";
12
- import { networkInterfaces } from "node:os";
13
+ import http, { request } from "node:http";
14
+ import { connect } from "node:net";
13
15
  import { Tunnel, bin, install } from "cloudflared";
14
16
  import { spawn } from "node:child_process";
15
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-settings@0.1.0-rc.7_@deepseek-ai+cordis@4.0.1_@deepseek-ai+dsh-brand@0_6b0eb868fb92d854799855c05c5fa43c/node_modules/@deepseek-ai/dsh-settings/lib/index.js
17
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-settings@0.1.0-rc.8_@deepseek-ai+cordis@4.0.1_@deepseek-ai+dsh-brand@0_7616943ecf171cb97db9e2088de3356f/node_modules/@deepseek-ai/dsh-settings/lib/index.js
16
18
  /**
17
19
  * Structural secret redaction for settings values. `role('secret')` fields are
18
20
  * removed from a value before it crosses a wire boundary; a sidecar records
@@ -225,7 +227,13 @@ function installSettingsSection(ctx, ns, schema, entry, hooks) {
225
227
  * unknown one (no oracle for validity).
226
228
  * - `stop()` revokes every device session and clears the token, so paired
227
229
  * devices are cut off on their next gated request.
230
+ * - `revoke()` drops one device session; idle sessions older than
231
+ * `idleExpireMs` are deleted on sweep, load, and the next gated request.
228
232
  */
233
+ /** Default idle-expiry window: 7 days without heartbeat or a gated request. */
234
+ const DEFAULT_IDLE_EXPIRE_MS = 10080 * 60 * 1e3;
235
+ /** Cap on the persisted/displayed User-Agent string. */
236
+ const MAX_USER_AGENT_CHARS = 180;
229
237
  /** Thrown by issue() for an address outside the sampled LAN literals. */
230
238
  var UnknownLanAddressError = class extends Error {
231
239
  /**
@@ -262,6 +270,9 @@ var PairingService = class {
262
270
  publicBase;
263
271
  /** Auto-tunnel status, while the auto-tunnel feature is active. */
264
272
  tunnelStatus;
273
+ posture;
274
+ /** True when lastSeenAt changed since the last persist (flushed on sweep). */
275
+ dirty = false;
265
276
  /**
266
277
  * @param config - tunables. The settings surface replaces the object (a
267
278
  * fresh literal) when a committed section changes; every operation reads
@@ -287,14 +298,17 @@ var PairingService = class {
287
298
  for (const [deviceId, session] of Object.entries(saved)) {
288
299
  if (typeof deviceId !== "string") continue;
289
300
  if (typeof session !== "object" || session === null) continue;
290
- const { createdAt, lastSeenAt } = session;
301
+ const { createdAt, lastSeenAt, userAgent } = session;
291
302
  if (typeof createdAt !== "number" || typeof lastSeenAt !== "number") continue;
303
+ const label = typeof userAgent === "string" ? sanitizeUserAgent(userAgent) : void 0;
292
304
  this.devices.set(deviceId, {
293
305
  createdAt,
294
- lastSeenAt
306
+ lastSeenAt,
307
+ ...label !== void 0 ? { userAgent: label } : {}
295
308
  });
296
309
  }
297
310
  this.clampToMaxDevices();
311
+ if (this.evictIdle()) this.persist();
298
312
  } catch {}
299
313
  }
300
314
  /** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
@@ -304,12 +318,22 @@ var PairingService = class {
304
318
  const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
305
319
  for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id);
306
320
  }
321
+ /** Drop sessions whose lastSeenAt is older than idleExpireMs. */
322
+ evictIdle() {
323
+ const now = this.clock.now();
324
+ const limit = this.config.idleExpireMs ?? 6048e5;
325
+ let removed = false;
326
+ for (const [id, session] of [...this.devices]) if (now - session.lastSeenAt > limit) {
327
+ this.devices.delete(id);
328
+ removed = true;
329
+ }
330
+ return removed;
331
+ }
307
332
  /**
308
333
  * Write the current device table to the configured file. Called on the
309
- * mutation boundaries that change the set of live sessions (accept and
310
- * stop); `lastSeenAt` refreshes are deliberately not persisted here so a
311
- * per-request write storm is avoided — after a restart the phone's first
312
- * heartbeat re-warms its own session.
334
+ * mutation boundaries that change the set of live sessions (accept, stop,
335
+ * revoke, idle eviction) and, throttled, from sweep() so lastSeenAt
336
+ * survives a restart without a write per request.
313
337
  *
314
338
  * Device ids are session credentials (the gate authorizes requests by the
315
339
  * cookie's device id), so the file is written 0600 via a temp file and
@@ -321,8 +345,11 @@ var PairingService = class {
321
345
  try {
322
346
  mkdirSync(dirname(file), { recursive: true });
323
347
  const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
324
- writeFileSync(temp, JSON.stringify(Object.fromEntries(this.devices)), { mode: 384 });
348
+ const payload = {};
349
+ for (const [id, session] of this.devices) payload[id] = session;
350
+ writeFileSync(temp, JSON.stringify(payload), { mode: 384 });
325
351
  renameSync(temp, file);
352
+ this.dirty = false;
326
353
  } catch (error) {
327
354
  console.error("remote-web-ui: failed to persist paired devices", error);
328
355
  }
@@ -358,6 +385,11 @@ var PairingService = class {
358
385
  this.tunnelStatus = status;
359
386
  this.notify();
360
387
  }
388
+ /** Set the latest /api posture probe result (see posture.ts). */
389
+ setPosture(snapshot) {
390
+ this.posture = snapshot;
391
+ this.notify();
392
+ }
361
393
  /**
362
394
  * Issue a fresh token, replacing (invalidating) any previous one. A
363
395
  * stopped service re-arms through this call (the panel's refresh button).
@@ -397,9 +429,10 @@ var PairingService = class {
397
429
  * successful call for the same token is impossible because the first
398
430
  * consumes it.
399
431
  * @param token - the token secret from the QR link.
432
+ * @param userAgent - optional User-Agent header captured at accept.
400
433
  * @returns the new device id, or a refusal code.
401
434
  */
402
- accept(token) {
435
+ accept(token, userAgent) {
403
436
  const record = this.tokens.get(token);
404
437
  if (record === void 0 || record.consumed || this.stopped || this.clock.now() > record.expiresAt) return {
405
438
  ok: false,
@@ -416,9 +449,11 @@ var PairingService = class {
416
449
  };
417
450
  if (oldest !== void 0) this.devices.delete(oldest.id);
418
451
  }
452
+ const label = sanitizeUserAgent(userAgent);
419
453
  this.devices.set(deviceId, {
420
454
  createdAt: now,
421
- lastSeenAt: now
455
+ lastSeenAt: now,
456
+ ...label !== void 0 ? { userAgent: label } : {}
422
457
  });
423
458
  this.persist();
424
459
  this.notify();
@@ -440,16 +475,30 @@ var PairingService = class {
440
475
  this.notify();
441
476
  }
442
477
  /**
478
+ * Revoke one paired device. The next gated request from that cookie is
479
+ * refused; other sessions stay live. Unknown ids are a no-op.
480
+ * @param deviceId - the cookie value of the device to drop.
481
+ * @returns true when a live session was removed.
482
+ */
483
+ revoke(deviceId) {
484
+ if (this.stopped) return false;
485
+ if (!this.devices.delete(deviceId)) return false;
486
+ this.persist();
487
+ this.notify();
488
+ return true;
489
+ }
490
+ /**
443
491
  * The api/gate path: record activity for a device id and report whether
444
492
  * the request may proceed. Unknown or revoked ids (including any device
445
- * after stop()) are refused.
493
+ * after stop() or idle expiry) are refused.
446
494
  * @param deviceId - the cookie value of the requesting device.
447
495
  * @returns true when the device session is live and was refreshed.
448
496
  */
449
497
  touchDevice(deviceId) {
450
- const session = this.devices.get(deviceId);
451
- if (session === void 0 || this.stopped) return false;
498
+ const session = this.liveSession(deviceId);
499
+ if (session === void 0) return false;
452
500
  session.lastSeenAt = this.clock.now();
501
+ this.dirty = true;
453
502
  this.notify();
454
503
  return true;
455
504
  }
@@ -458,17 +507,20 @@ var PairingService = class {
458
507
  return this.touchDevice(deviceId);
459
508
  }
460
509
  /**
461
- * Periodic sweep: re-evaluate the derived snapshot (a device aging past
462
- * the offline window flips the phase to disconnected). Emits only when
463
- * the snapshot actually changed.
510
+ * Periodic sweep: drop idle sessions, flush a dirty lastSeenAt, and
511
+ * re-evaluate the derived snapshot (a device aging past the offline
512
+ * window flips the phase to disconnected). Emits only when the snapshot
513
+ * actually changed.
464
514
  */
465
515
  sweep() {
516
+ if (this.evictIdle() || this.dirty) this.persist();
466
517
  this.notify();
467
518
  }
468
519
  /** The current snapshot (fresh object per call — stable between emits). */
469
520
  snapshot() {
470
521
  const now = this.clock.now();
471
- const onlineCount = [...this.devices.values()].filter((session) => this.isOnlineAt(session, now)).length;
522
+ const devices = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt).map(([id, session]) => this.toDeviceSnapshot(id, session, now));
523
+ const onlineCount = devices.filter((device) => device.online).length;
472
524
  const token = this.activeToken();
473
525
  return {
474
526
  phase: this.derivePhase(onlineCount, token !== void 0),
@@ -476,17 +528,19 @@ var PairingService = class {
476
528
  lanAddresses: [...this.lanBases.keys()],
477
529
  ...this.publicBase !== void 0 ? { publicUrl: this.publicBase } : {},
478
530
  ...this.tunnelStatus !== void 0 ? { tunnel: this.tunnelStatus } : {},
531
+ ...this.posture !== void 0 ? { posture: this.posture } : {},
479
532
  ...token !== void 0 ? {
480
533
  tokenId: token.record.id,
481
534
  tokenExpiresAt: token.record.expiresAt
482
535
  } : {},
483
536
  deviceCount: this.devices.size,
484
- onlineCount
537
+ onlineCount,
538
+ devices
485
539
  };
486
540
  }
487
- /** Whether a cookie value names a currently live device session. */
541
+ /** Whether a cookie value names a currently live (non-idle) device session. */
488
542
  hasDevice(deviceId) {
489
- return this.devices.get(deviceId) !== void 0 && !this.stopped;
543
+ return this.liveSession(deviceId) !== void 0;
490
544
  }
491
545
  /** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
492
546
  onState(listener) {
@@ -505,6 +559,32 @@ var PairingService = class {
505
559
  };
506
560
  }
507
561
  }
562
+ /**
563
+ * Return a live session, deleting it first when idle-expired. Side-effecting
564
+ * so a stale cookie cannot pass the gate between sweeps.
565
+ */
566
+ liveSession(deviceId) {
567
+ if (this.stopped) return void 0;
568
+ const session = this.devices.get(deviceId);
569
+ if (session === void 0) return void 0;
570
+ const limit = this.config.idleExpireMs ?? 6048e5;
571
+ if (this.clock.now() - session.lastSeenAt > limit) {
572
+ this.devices.delete(deviceId);
573
+ this.persist();
574
+ this.notify();
575
+ return;
576
+ }
577
+ return session;
578
+ }
579
+ toDeviceSnapshot(id, session, now) {
580
+ return {
581
+ id,
582
+ createdAt: session.createdAt,
583
+ lastSeenAt: session.lastSeenAt,
584
+ online: this.isOnlineAt(session, now),
585
+ ...session.userAgent !== void 0 ? { userAgent: session.userAgent } : {}
586
+ };
587
+ }
508
588
  derivePhase(onlineCount, hasToken) {
509
589
  if (this.lanBases.size === 0 && this.publicBase === void 0) return "lan-required";
510
590
  if (this.stopped) return "stopped";
@@ -529,7 +609,14 @@ var PairingService = class {
529
609
  };
530
610
  /** Structural equality over the snapshot's wire fields. */
531
611
  function snapshotsEqual(a, b) {
532
- return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount;
612
+ return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount && devicesEqual(a.devices, b.devices);
613
+ }
614
+ /** Per-device roster equality (order is pairing time). */
615
+ function devicesEqual(a, b) {
616
+ return a.length === b.length && a.every((device, index) => {
617
+ const other = b[index];
618
+ return other !== void 0 && device.id === other.id && device.createdAt === other.createdAt && device.lastSeenAt === other.lastSeenAt && device.online === other.online && device.userAgent === other.userAgent;
619
+ });
533
620
  }
534
621
  /** Tunnel frame equality (undefined equals undefined; fields compared shallowly). */
535
622
  function tunnelEqual(a, b) {
@@ -539,6 +626,44 @@ function tunnelEqual(a, b) {
539
626
  function sameStrings(a, b) {
540
627
  return a.length === b.length && a.every((value, index) => value === b[index]);
541
628
  }
629
+ /** Strip control characters and cap the User-Agent stored with a session. */
630
+ function sanitizeUserAgent(raw) {
631
+ if (raw === void 0) return void 0;
632
+ const cleaned = raw.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\s+/g, " ").trim();
633
+ if (cleaned === "") return void 0;
634
+ return cleaned.length <= MAX_USER_AGENT_CHARS ? cleaned : cleaned.slice(0, MAX_USER_AGENT_CHARS);
635
+ }
636
+ //#endregion
637
+ //#region src/dsh-home.ts
638
+ /**
639
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
640
+ * environment override wins, the platform home fallback follows. Mirrors
641
+ * what dsh-pet and dsh-liangshen each used to implement locally.
642
+ */
643
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
644
+ function expandHome(path, home = homedir()) {
645
+ if (path === "~") return home;
646
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
647
+ return path;
648
+ }
649
+ /**
650
+ * Resolve the DSH home directory.
651
+ * @param env - process environment to read DSH_HOME from.
652
+ * @param home - platform home directory fallback (test seam).
653
+ * @returns the absolute DSH home path.
654
+ */
655
+ function resolveDshHome(env = process.env, home = homedir()) {
656
+ const raw = env.DSH_HOME;
657
+ if (raw !== void 0 && raw.trim() !== "") {
658
+ const expanded = expandHome(raw.trim(), home);
659
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
660
+ }
661
+ return join(home, ".dsh");
662
+ }
663
+ /** Resolve the DSH home directory from the live environment. */
664
+ function dshHome() {
665
+ return resolveDshHome();
666
+ }
542
667
  //#endregion
543
668
  //#region src/loopback.ts
544
669
  /** IPv4 127/8 predicate (four decimal octets, first == 127). */
@@ -756,6 +881,7 @@ const PAIR_PATHS = {
756
881
  issue: "/api/pair/issue",
757
882
  accept: "/api/pair/accept",
758
883
  stop: "/api/pair/stop",
884
+ revoke: "/api/pair/revoke",
759
885
  heartbeat: "/api/pair/heartbeat",
760
886
  status: "/api/pair/status",
761
887
  events: "/api/pair/events"
@@ -773,6 +899,7 @@ const issuePayloadSchema = z$1.object({
773
899
  address: z$1.string().min(1).optional()
774
900
  });
775
901
  const acceptPayloadSchema = z$1.object({ token: z$1.string().default("") });
902
+ const revokePayloadSchema = z$1.object({ deviceId: z$1.string().min(1) });
776
903
  const pairActionPayloadSchema = z$1.object({}).passthrough();
777
904
  /**
778
905
  * Parse a pair request body through schema. A missing/empty or non-object
@@ -950,7 +1077,8 @@ function makeRoutes(deps) {
950
1077
  });
951
1078
  return;
952
1079
  }
953
- const result = service.accept(payload.token);
1080
+ const ua = req.headers["user-agent"];
1081
+ const result = service.accept(payload.token, typeof ua === "string" ? ua : void 0);
954
1082
  if (!result.ok) {
955
1083
  writeJson(res, result.code === "used" ? 409 : 404, {
956
1084
  ok: false,
@@ -987,6 +1115,33 @@ function makeRoutes(deps) {
987
1115
  service.stop();
988
1116
  writeJson(res, 200, { ok: true });
989
1117
  };
1118
+ const handleRevoke = async (req, res) => {
1119
+ if (!requireMethod(req, res, "POST")) return;
1120
+ if (!loopbackFence(req)) {
1121
+ writeJson(res, 403, {
1122
+ ok: false,
1123
+ code: "forbidden"
1124
+ });
1125
+ return;
1126
+ }
1127
+ const body = await readJsonBody$1(req);
1128
+ const payload = parsePairPayload(revokePayloadSchema, body);
1129
+ if (payload === void 0) {
1130
+ writeJson(res, 400, {
1131
+ ok: false,
1132
+ code: "bad-payload"
1133
+ });
1134
+ return;
1135
+ }
1136
+ if (!service.revoke(payload.deviceId)) {
1137
+ writeJson(res, 404, {
1138
+ ok: false,
1139
+ code: "unknown-device"
1140
+ });
1141
+ return;
1142
+ }
1143
+ writeJson(res, 200, { ok: true });
1144
+ };
990
1145
  const handleHeartbeat = async (req, res) => {
991
1146
  if (!requireMethod(req, res, "POST")) return;
992
1147
  if (!lanFence(req)) {
@@ -1026,10 +1181,11 @@ function makeRoutes(deps) {
1026
1181
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
1027
1182
  const paired = deviceId !== void 0 && service.hasDevice(deviceId);
1028
1183
  const snapshot = service.snapshot();
1184
+ const { devices: _devices, ...rest } = snapshot;
1029
1185
  writeJson(res, 200, {
1030
1186
  ok: true,
1031
1187
  paired,
1032
- ...paired ? snapshot : {
1188
+ ...paired ? rest : {
1033
1189
  phase: snapshot.phase,
1034
1190
  lanAvailable: snapshot.lanAvailable,
1035
1191
  lanAddresses: snapshot.lanAddresses
@@ -1064,6 +1220,11 @@ function makeRoutes(deps) {
1064
1220
  path: PAIR_PATHS.stop,
1065
1221
  handler: handleStop
1066
1222
  },
1223
+ {
1224
+ kind: "exact",
1225
+ path: PAIR_PATHS.revoke,
1226
+ handler: handleRevoke
1227
+ },
1067
1228
  {
1068
1229
  kind: "exact",
1069
1230
  path: PAIR_PATHS.heartbeat,
@@ -1117,7 +1278,7 @@ function pageHtml(bundleUrl) {
1117
1278
  "<meta name=\"apple-mobile-web-app-title\" content=\"DSH Remote\">",
1118
1279
  "<link rel=\"manifest\" href=\"/m/manifest.webmanifest\">",
1119
1280
  "<link rel=\"apple-touch-icon\" href=\"/m/apple-touch-icon.png\">",
1120
- "<title>移动端远程控制</title>",
1281
+ "<title>远程访问</title>",
1121
1282
  "</head>",
1122
1283
  "<body>",
1123
1284
  "<div id=\"root\"></div>",
@@ -1319,7 +1480,8 @@ function makeMobileRoutes() {
1319
1480
  * note the paired-device cookie also passes the global api/gate for the full
1320
1481
  * ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
1321
1482
  * the allowlist only constrains this /m/api proxy, not the cookie's reach.
1322
- * stop() revokes every device; there is no per-device revocation yet.
1483
+ * stop() revokes every device; the loopback panel can also revoke one
1484
+ * device at a time.
1323
1485
  */
1324
1486
  const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
1325
1487
  "workspace.list",
@@ -1559,7 +1721,11 @@ async function dispatch(apiProxy, method, payload, rpcId, signal) {
1559
1721
  };
1560
1722
  if (method === "session.list") {
1561
1723
  const full = await apiProxy.sessions.list(request);
1562
- if (!full.result.ok) return full;
1724
+ if (!full.result.ok) return {
1725
+ type: "server-response",
1726
+ rpcId,
1727
+ result: full.result
1728
+ };
1563
1729
  const items = full.result.value.items;
1564
1730
  const cursor = payload?.cursor;
1565
1731
  items.sort((a, b) => b.updatedAt - a.updatedAt || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
@@ -1599,6 +1765,421 @@ async function dispatch(apiProxy, method, payload, rpcId, signal) {
1599
1765
  throw new Error(`unhandled allowlisted method ${method}`);
1600
1766
  }
1601
1767
  //#endregion
1768
+ //#region src/loopback-proxy.ts
1769
+ /** WebSocket handshake headers forwarded to the loopback upstream. */
1770
+ const WS_FORWARD_HEADERS = [
1771
+ "sec-websocket-key",
1772
+ "sec-websocket-version",
1773
+ "sec-websocket-extensions",
1774
+ "sec-websocket-protocol"
1775
+ ];
1776
+ /** Response headers copied from the loopback upstream (no hop-by-hop). */
1777
+ const HTTP_FORWARD_RESPONSE_HEADERS = [
1778
+ "content-type",
1779
+ "content-length",
1780
+ "content-disposition",
1781
+ "cache-control",
1782
+ "etag",
1783
+ "last-modified"
1784
+ ];
1785
+ /**
1786
+ * Pipe one HTTP request to loopback and stream the response back.
1787
+ * @param req - the already-gated outer request.
1788
+ * @param res - the outer response.
1789
+ * @param port - local webServer port.
1790
+ * @param upstreamPath - path + query on 127.0.0.1 (must start with `/`).
1791
+ */
1792
+ function proxyLoopbackHttp(req, res, port, upstreamPath) {
1793
+ const headers = {
1794
+ host: `127.0.0.1:${String(port)}`,
1795
+ "sec-fetch-site": "same-origin"
1796
+ };
1797
+ const contentType = req.headers["content-type"];
1798
+ if (typeof contentType === "string") headers["content-type"] = contentType;
1799
+ const contentLength = req.headers["content-length"];
1800
+ if (typeof contentLength === "string") headers["content-length"] = contentLength;
1801
+ const accept = req.headers.accept;
1802
+ if (typeof accept === "string") headers.accept = accept;
1803
+ const upstream = request({
1804
+ host: "127.0.0.1",
1805
+ port,
1806
+ path: upstreamPath,
1807
+ method: req.method,
1808
+ headers
1809
+ }, (upstreamRes) => {
1810
+ const out = {};
1811
+ for (const name of HTTP_FORWARD_RESPONSE_HEADERS) {
1812
+ const value = upstreamRes.headers[name];
1813
+ if (value !== void 0) out[name] = value;
1814
+ }
1815
+ res.writeHead(upstreamRes.statusCode ?? 502, out);
1816
+ upstreamRes.pipe(res);
1817
+ });
1818
+ upstream.on("error", () => {
1819
+ if (!res.headersSent) {
1820
+ writeJson(res, 502, {
1821
+ ok: false,
1822
+ error: {
1823
+ code: "upstream-failure",
1824
+ message: "upstream request failed"
1825
+ }
1826
+ });
1827
+ return;
1828
+ }
1829
+ res.destroy();
1830
+ });
1831
+ req.pipe(upstream);
1832
+ }
1833
+ /**
1834
+ * Rebuild a WebSocket handshake as loopback-shaped and pipe both directions.
1835
+ * @param req - the already-gated upgrade request.
1836
+ * @param socket - the client duplex.
1837
+ * @param head - bytes already read past the handshake.
1838
+ * @param port - local webServer port.
1839
+ * @param upstreamPath - path + query on 127.0.0.1.
1840
+ */
1841
+ function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath) {
1842
+ const lines = [
1843
+ `GET ${upstreamPath} HTTP/1.1`,
1844
+ `Host: 127.0.0.1:${String(port)}`,
1845
+ "Upgrade: websocket",
1846
+ "Connection: Upgrade"
1847
+ ];
1848
+ for (const name of WS_FORWARD_HEADERS) {
1849
+ const value = req.headers[name];
1850
+ if (value === void 0) continue;
1851
+ lines.push(`${name}: ${Array.isArray(value) ? value.join(", ") : value}`);
1852
+ }
1853
+ const handshake = `${lines.join("\r\n")}\r\n\r\n`;
1854
+ const upstream = connect(port, "127.0.0.1");
1855
+ const tearDown = () => {
1856
+ upstream.destroy();
1857
+ socket.destroy();
1858
+ };
1859
+ upstream.on("error", tearDown);
1860
+ socket.on("error", tearDown);
1861
+ upstream.on("close", () => {
1862
+ socket.destroy();
1863
+ });
1864
+ socket.on("close", () => {
1865
+ upstream.destroy();
1866
+ });
1867
+ upstream.on("connect", () => {
1868
+ upstream.write(handshake);
1869
+ if (head.length > 0) upstream.write(head);
1870
+ socket.pipe(upstream);
1871
+ upstream.pipe(socket);
1872
+ });
1873
+ }
1874
+ //#endregion
1875
+ //#region src/remote-methods.ts
1876
+ /**
1877
+ * Remote desktop channel constants — SDK-independent so tests and the
1878
+ * client half can pin them without importing the host SDK graph.
1879
+ */
1880
+ /** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
1881
+ const REMOTE_PREFIX = "/remote";
1882
+ /** Connection-plugin method prefix under the gated channel. */
1883
+ const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`;
1884
+ /** WebSocket event-stream paths served by the channel (client rewrites to these). */
1885
+ const REMOTE_API_PATHS = {
1886
+ mux: `${REMOTE_API_PREFIX}/events.mux`,
1887
+ host: `${REMOTE_API_PREFIX}/events.host`
1888
+ };
1889
+ /**
1890
+ * Exact upgrade paths registered on webServer (the SDK matches upgrades by
1891
+ * exact path, not prefix). Query strings ride on the request URL.
1892
+ */
1893
+ const REMOTE_UPGRADE_PATHS = [
1894
+ REMOTE_API_PATHS.mux,
1895
+ REMOTE_API_PATHS.host,
1896
+ `${REMOTE_PREFIX}/sidebar/ws/terminal`,
1897
+ `${REMOTE_PREFIX}/sidebar/ws/agent-terminals`,
1898
+ `${REMOTE_API_PREFIX}/dsh-ssh/terminal`
1899
+ ];
1900
+ /**
1901
+ * Loopback-only methods of the host API surface, mirrored from
1902
+ * client-connection's `PRIVILEGED_METHODS` (pinned by
1903
+ * tests/remote-contract.spec.ts against the installed SDK). They stay
1904
+ * unreachable from a paired remote desktop, matching the SDK's own stance
1905
+ * that the configuration plane is loopback-same-origin only.
1906
+ */
1907
+ const LOOPBACK_ONLY_METHODS = /* @__PURE__ */ new Set([
1908
+ "agentPreset.read",
1909
+ "agentPreset.copy",
1910
+ "agentPreset.openDocument",
1911
+ "agentPreset.remove",
1912
+ "host.pickDirectory",
1913
+ "host.openPath",
1914
+ "settings.describe",
1915
+ "settings.openDocument",
1916
+ "settings.update",
1917
+ "settings.replace",
1918
+ "settings.mutate",
1919
+ "credentials.describe",
1920
+ "credentials.set",
1921
+ "credentials.unset",
1922
+ "llm.discoverModels"
1923
+ ]);
1924
+ //#endregion
1925
+ //#region src/remote-api.ts
1926
+ const ALLOWED_METHODS = /* @__PURE__ */ new Set([
1927
+ "GET",
1928
+ "HEAD",
1929
+ "POST",
1930
+ "PUT",
1931
+ "PATCH",
1932
+ "DELETE"
1933
+ ]);
1934
+ /** Reject traversal and empty segments; allow plugin file-path characters. */
1935
+ function isSafeSegment(segment) {
1936
+ if (segment === "") return false;
1937
+ let decoded;
1938
+ try {
1939
+ decoded = decodeURIComponent(segment);
1940
+ } catch {
1941
+ return false;
1942
+ }
1943
+ return decoded !== "." && decoded !== ".." && !decoded.includes("/") && !decoded.includes("\\") && !decoded.includes("\0");
1944
+ }
1945
+ /** One SDK-shaped error envelope (keeps the desktop client's parse path intact). */
1946
+ function envelopeError(res, status, rpcId, code, message) {
1947
+ writeJson(res, status, {
1948
+ type: "server-response",
1949
+ rpcId,
1950
+ result: {
1951
+ ok: false,
1952
+ error: {
1953
+ code,
1954
+ message,
1955
+ details: { issues: [] }
1956
+ }
1957
+ }
1958
+ });
1959
+ }
1960
+ /**
1961
+ * Map `/remote/...` to the inner path, or undefined when the outer path is
1962
+ * not a safe rewrite target.
1963
+ */
1964
+ function innerPathOf(pathname) {
1965
+ if (pathname === "/remote" || pathname === `/remote/`) return void 0;
1966
+ if (!pathname.startsWith(`/remote/`)) return void 0;
1967
+ const rest = pathname.slice(7);
1968
+ if (!rest.startsWith("/")) return void 0;
1969
+ const segments = rest.slice(1).split("/");
1970
+ if (segments.length === 0 || segments.some((segment) => !isSafeSegment(segment))) return;
1971
+ return rest;
1972
+ }
1973
+ /**
1974
+ * Whether a paired inner path must stay physically local.
1975
+ * @returns a denial message, or undefined when the path may be proxied.
1976
+ */
1977
+ function loopbackOnlyDenial(innerPath) {
1978
+ if (innerPath === "/api/pair" || innerPath.startsWith("/api/pair/")) return "pairing endpoints stay loopback-only and stay unreachable from a paired remote desktop";
1979
+ if (innerPath === "/api/update" || innerPath.startsWith("/api/update/")) return "update endpoints stay loopback-only and stay unreachable from a paired remote desktop";
1980
+ if (innerPath === "/api/plugin-manager" || innerPath.startsWith(`/api/plugin-manager/`)) return "plugin-manager stays loopback-only and stays unreachable from a paired remote desktop";
1981
+ if (innerPath === "/api/dsh-desktop-launcher" || innerPath.startsWith(`/api/dsh-desktop-launcher/`)) return "desktop-launcher endpoints stay loopback-only and stay unreachable from a paired remote desktop";
1982
+ if (innerPath === "/api/dsh-web-ui-settings" || innerPath.startsWith(`/api/dsh-web-ui-settings/`)) return "settings-bridge endpoints stay loopback-only and stay unreachable from a paired remote desktop";
1983
+ if (!innerPath.startsWith("/api/")) return void 0;
1984
+ const method = innerPath.slice(5);
1985
+ if (method !== "" && !method.includes("/") && LOOPBACK_ONLY_METHODS.has(method)) return `${method} is loopback-only and stays unreachable from a paired remote desktop`;
1986
+ }
1987
+ /**
1988
+ * Build the remote desktop channel HTTP routes.
1989
+ * @param deps - pairing service + local port.
1990
+ * @returns the routes to register on webServer.
1991
+ */
1992
+ function makeRemoteApiRoutes(deps) {
1993
+ const { service, port } = deps;
1994
+ const handler = (req, res) => {
1995
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
1996
+ if (!(deviceId !== void 0 && service.touchDevice(deviceId))) {
1997
+ req.resume();
1998
+ envelopeError(res, 403, "invalid-request", "unpaired", "this device is not paired with the desktop");
1999
+ return;
2000
+ }
2001
+ const method = req.method ?? "GET";
2002
+ if (!ALLOWED_METHODS.has(method)) {
2003
+ req.resume();
2004
+ res.writeHead(405).end();
2005
+ return;
2006
+ }
2007
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
2008
+ const inner = innerPathOf(url.pathname);
2009
+ if (inner === void 0) {
2010
+ req.resume();
2011
+ res.writeHead(404).end();
2012
+ return;
2013
+ }
2014
+ const denied = loopbackOnlyDenial(inner);
2015
+ if (denied !== void 0) {
2016
+ req.resume();
2017
+ envelopeError(res, 403, "invalid-request", "forbidden", denied);
2018
+ return;
2019
+ }
2020
+ proxyLoopbackHttp(req, res, port, `${inner}${url.search}`);
2021
+ };
2022
+ return [{
2023
+ kind: "prefix",
2024
+ path: REMOTE_PREFIX,
2025
+ handler
2026
+ }];
2027
+ }
2028
+ /**
2029
+ * Map one outer upgrade URL onto the loopback path (query string included).
2030
+ */
2031
+ function upgradeInnerPath(reqUrl, fallbackPath) {
2032
+ if (reqUrl === void 0 || reqUrl === "") return fallbackPath;
2033
+ let url;
2034
+ try {
2035
+ url = new URL(reqUrl, "http://127.0.0.1");
2036
+ } catch {
2037
+ return fallbackPath;
2038
+ }
2039
+ const inner = innerPathOf(url.pathname);
2040
+ if (inner === void 0) return fallbackPath;
2041
+ return `${inner}${url.search}`;
2042
+ }
2043
+ /**
2044
+ * Build the WebSocket upgrade routes for the event streams and known plugin
2045
+ * sockets. webServer matches upgrades by exact path.
2046
+ * @param deps - pairing service + local port.
2047
+ * @returns the upgrade routes to register on webServer.
2048
+ */
2049
+ function makeRemoteApiUpgradeRoutes(deps) {
2050
+ const { service, port } = deps;
2051
+ const handlerFor = (fallbackPath) => (req, socket, head) => {
2052
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
2053
+ if (deviceId === void 0 || !service.touchDevice(deviceId)) {
2054
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
2055
+ socket.destroy();
2056
+ return;
2057
+ }
2058
+ const inner = upgradeInnerPath(req.url, fallbackPath);
2059
+ if (loopbackOnlyDenial(inner.split("?")[0] ?? inner) !== void 0) {
2060
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
2061
+ socket.destroy();
2062
+ return;
2063
+ }
2064
+ proxyLoopbackUpgrade(req, socket, head, port, inner);
2065
+ };
2066
+ return REMOTE_UPGRADE_PATHS.map((path) => ({
2067
+ path,
2068
+ handler: handlerFor(path.slice(7))
2069
+ }));
2070
+ }
2071
+ //#endregion
2072
+ //#region src/posture.ts
2073
+ /**
2074
+ * Build the forged Host values to probe: the public base authority (host or
2075
+ * host:port as written in the URL) plus every LAN base literal.
2076
+ * @param publicBaseUrl - the configured public base URL (or undefined).
2077
+ * @param lanAddresses - the LAN interface addresses the QR advertises.
2078
+ * @param port - the local webServer port (LAN hosts are probed as host:port).
2079
+ * @returns Host header values, de-duplicated.
2080
+ */
2081
+ function postureTargets(publicBaseUrl, lanAddresses, port) {
2082
+ const targets = [];
2083
+ if (publicBaseUrl !== void 0) try {
2084
+ const url = new URL(publicBaseUrl);
2085
+ const authority = url.port === "" ? url.hostname : `${url.hostname}:${url.port}`;
2086
+ if (authority !== "") targets.push(authority);
2087
+ } catch {}
2088
+ for (const address of lanAddresses) targets.push(`${address}:${String(port)}`);
2089
+ return [...new Set(targets)];
2090
+ }
2091
+ const defaultRequest = (options, onStatus) => {
2092
+ const request = http.request(options, (response) => {
2093
+ onStatus(response.statusCode ?? 0);
2094
+ });
2095
+ request.on("error", () => {
2096
+ onStatus(0);
2097
+ });
2098
+ return request;
2099
+ };
2100
+ /**
2101
+ * Probe one forged Host against the local `/api` fence.
2102
+ * @param port - the local webServer port.
2103
+ * @param hostHeader - the Host header to forge.
2104
+ * @param request - transport seam.
2105
+ * @param timeoutMs - give up after this long (counts as not exposed; the
2106
+ * fence being unreachable is not evidence it is open).
2107
+ * @returns true when the probe got past the fence.
2108
+ */
2109
+ async function probeHost(port, hostHeader, request, timeoutMs) {
2110
+ return await new Promise((resolve) => {
2111
+ let settled = false;
2112
+ const finish = (exposed) => {
2113
+ if (settled) return;
2114
+ settled = true;
2115
+ clearTimeout(timer);
2116
+ handle.destroy();
2117
+ resolve(exposed);
2118
+ };
2119
+ const handle = request({
2120
+ host: "127.0.0.1",
2121
+ port,
2122
+ method: "POST",
2123
+ path: "/api/session.list",
2124
+ headers: {
2125
+ host: hostHeader,
2126
+ "content-type": "application/json"
2127
+ },
2128
+ timeout: timeoutMs
2129
+ }, (status) => {
2130
+ finish(status !== 403);
2131
+ });
2132
+ const timer = setTimeout(() => {
2133
+ finish(false);
2134
+ }, timeoutMs + 1e3);
2135
+ handle.on("error", () => {
2136
+ finish(false);
2137
+ });
2138
+ handle.end("{}");
2139
+ });
2140
+ }
2141
+ /**
2142
+ * Run one posture probe round.
2143
+ * @returns the snapshot for this round.
2144
+ */
2145
+ async function probePosture(options) {
2146
+ const { port, targets } = options;
2147
+ const request = options.request ?? defaultRequest;
2148
+ const timeoutMs = options.timeoutMs ?? 3e3;
2149
+ const now = options.now ?? (() => Date.now());
2150
+ const hosts = [];
2151
+ for (const target of targets) hosts.push({
2152
+ host: target,
2153
+ exposed: await probeHost(port, target, request, timeoutMs)
2154
+ });
2155
+ return {
2156
+ checkedAt: now(),
2157
+ hosts
2158
+ };
2159
+ }
2160
+ /**
2161
+ * Reserve an advertised-target key so a second trigger with the same set
2162
+ * does not overlap an in-flight round. Pair with {@link releasePostureKey}
2163
+ * on failure — otherwise that key never retries.
2164
+ */
2165
+ function claimPostureKey(current, key) {
2166
+ if (current === key) return {
2167
+ run: false,
2168
+ next: current
2169
+ };
2170
+ return {
2171
+ run: true,
2172
+ next: key
2173
+ };
2174
+ }
2175
+ /**
2176
+ * Drop a failed in-flight key so the next trigger re-probes the same targets.
2177
+ * A newer key that started meanwhile is left alone.
2178
+ */
2179
+ function releasePostureKey(current, attempted) {
2180
+ return current === attempted ? void 0 : current;
2181
+ }
2182
+ //#endregion
1602
2183
  //#region src/lan.ts
1603
2184
  /**
1604
2185
  * LAN address derivation for the pairing URLs. Mirrors the dsh CLI's
@@ -1918,6 +2499,12 @@ function isLinkedSpec(spec) {
1918
2499
  if (typeof spec !== "string") return false;
1919
2500
  return /^(?:link|file):|^\.{1,2}(?:[/\\]|$)/.test(spec);
1920
2501
  }
2502
+ /** Whether a direct local dependency overrides one of the aggregate's children. */
2503
+ function hasLinkedFamilyOverride(anchorManifest, profileManifest) {
2504
+ const dependencies = profileManifest?.dependencies;
2505
+ if (typeof dependencies !== "object" || dependencies === null) return false;
2506
+ return familyChildren(anchorManifest).some((name) => isLinkedSpec(dependencies[name]));
2507
+ }
1921
2508
  /**
1922
2509
  * Resolve the anchor package's manifest path. The aggregate package is the
1923
2510
  * canonical entry point; this plugin's own package is the fallback. Both a
@@ -1950,7 +2537,7 @@ function resolveUpdateTarget(deps) {
1950
2537
  if (profile === void 0) return { error: "link" };
1951
2538
  const profileManifest = readManifest(join(profile.dir, "package.json"));
1952
2539
  const spec = (profileManifest?.dependencies)?.[anchor];
1953
- if (isLinkedSpec(spec)) return { error: "link" };
2540
+ if (isLinkedSpec(spec) || hasLinkedFamilyOverride(manifest, profileManifest)) return { error: "link" };
1954
2541
  return {
1955
2542
  profileName: profile.name,
1956
2543
  profileDir: profile.dir,
@@ -2045,7 +2632,7 @@ async function checkUpdates(deps) {
2045
2632
  };
2046
2633
  const profile = findProfile(manifestPath);
2047
2634
  const profileManifest = profile === void 0 ? void 0 : readManifest(join(profile.dir, "package.json"));
2048
- const linked = profile === void 0 || isLinkedSpec((profileManifest?.dependencies)?.[anchor]);
2635
+ const linked = profile === void 0 || isLinkedSpec((profileManifest?.dependencies)?.[anchor]) || hasLinkedFamilyOverride(manifest, profileManifest);
2049
2636
  if (profile === void 0) return {
2050
2637
  mode: "link",
2051
2638
  packages: [],
@@ -2424,6 +3011,7 @@ const Config = z.object({
2424
3011
  tokenTtlMs: z.number().step(1).min(6e4).default(10 * 6e4),
2425
3012
  offlineAfterMs: z.number().step(1).min(5e3).default(25e3),
2426
3013
  maxDevices: z.number().step(1).min(1).max(64).default(4),
3014
+ idleExpireMs: z.number().step(1).min(6e4).default(DEFAULT_IDLE_EXPIRE_MS),
2427
3015
  cookieName: z.string().min(1).default("dsh_pair"),
2428
3016
  requirePairingForLan: z.boolean().default(true),
2429
3017
  publicBaseUrl: z.string(),
@@ -2445,19 +3033,25 @@ function pairingConfigOf(resolved) {
2445
3033
  tokenTtlMs: resolved.tokenTtlMs,
2446
3034
  offlineAfterMs: resolved.offlineAfterMs,
2447
3035
  maxDevices: resolved.maxDevices,
3036
+ idleExpireMs: resolved.idleExpireMs,
2448
3037
  cookieName: resolved.cookieName,
2449
3038
  devicesFile: resolved.devicesFile
2450
3039
  };
2451
3040
  }
3041
+ /** Default paired-session store: `$DSH_HOME/remote-web-ui-devices.json`. */
3042
+ function defaultDevicesFile(home = dshHome()) {
3043
+ return join(home, "remote-web-ui-devices.json");
3044
+ }
2452
3045
  /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
2453
3046
  const DEFAULTS = {
2454
3047
  tokenTtlMs: 10 * 6e4,
2455
3048
  offlineAfterMs: 25e3,
2456
3049
  maxDevices: 4,
3050
+ idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
2457
3051
  cookieName: "dsh_pair",
2458
3052
  requirePairingForLan: true,
2459
3053
  publicBaseUrl: void 0,
2460
- devicesFile: void 0,
3054
+ devicesFile: defaultDevicesFile(),
2461
3055
  autoTunnel: false,
2462
3056
  mobileEnterToSend: true,
2463
3057
  enabled: true
@@ -2473,10 +3067,11 @@ function applyImpl(ctx, config) {
2473
3067
  tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
2474
3068
  offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
2475
3069
  maxDevices: config?.maxDevices ?? DEFAULTS.maxDevices,
3070
+ idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
2476
3071
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
2477
3072
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
2478
3073
  publicBaseUrl: config?.publicBaseUrl,
2479
- devicesFile: config?.devicesFile,
3074
+ devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
2480
3075
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
2481
3076
  mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
2482
3077
  enabled: config?.enabled ?? DEFAULTS.enabled
@@ -2488,6 +3083,7 @@ function applyImpl(ctx, config) {
2488
3083
  tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
2489
3084
  offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
2490
3085
  maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
3086
+ idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
2491
3087
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
2492
3088
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
2493
3089
  publicBaseUrl: value.publicBaseUrl,
@@ -2508,6 +3104,7 @@ function applyImpl(ctx, config) {
2508
3104
  state: "running",
2509
3105
  url: info.url
2510
3106
  });
3107
+ runPostureProbe();
2511
3108
  } else if (info.phase === "starting") {
2512
3109
  service.setPublicBaseUrl(void 0);
2513
3110
  service.setTunnelStatus({ state: "starting" });
@@ -2595,10 +3192,53 @@ function applyImpl(ctx, config) {
2595
3192
  apiProxy,
2596
3193
  mobileEnterToSend: () => resolve().mobileEnterToSend
2597
3194
  }) : [],
3195
+ ...makeRemoteApiRoutes({
3196
+ service,
3197
+ port: ctx.webServer.port
3198
+ }),
2598
3199
  ...updateRoutes
2599
3200
  ];
3201
+ const upgrades = makeRemoteApiUpgradeRoutes({
3202
+ service,
3203
+ port: ctx.webServer.port
3204
+ });
2600
3205
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
2601
3206
  ctx.effect(() => ctx.on("api/gate", gate), "remote-web-ui: api gate");
3207
+ let postureKey;
3208
+ let postureWasExposed = false;
3209
+ const runPostureProbe = () => {
3210
+ if (!resolve().enabled) return;
3211
+ const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, ctx.webServer.port);
3212
+ if (targets.length === 0) {
3213
+ postureKey = void 0;
3214
+ service.setPosture(void 0);
3215
+ return;
3216
+ }
3217
+ const key = targets.join("|");
3218
+ const claim = claimPostureKey(postureKey, key);
3219
+ if (!claim.run) return;
3220
+ postureKey = claim.next;
3221
+ probePosture({
3222
+ port: ctx.webServer.port,
3223
+ targets
3224
+ }).then((snapshot) => {
3225
+ service.setPosture(snapshot);
3226
+ const exposedHosts = snapshot.hosts.filter((host) => host.exposed).map((host) => host.host);
3227
+ const exposed = exposedHosts.length > 0;
3228
+ if (exposed && !postureWasExposed) console.error(`remote-web-ui: CRITICAL — the /api fence is OPEN for [${exposedHosts.join(", ")}]: unpaired clients reach the full host API. Remove --trusted-host for these hosts (pairing covers them) or bind loopback.`);
3229
+ else if (!exposed && postureWasExposed) console.log("remote-web-ui: the /api posture probe is clean again (every advertised origin refused with 403).");
3230
+ postureWasExposed = exposed;
3231
+ }).catch(() => {
3232
+ postureKey = releasePostureKey(postureKey, key);
3233
+ });
3234
+ };
3235
+ const initialPostureTimer = setTimeout$1(() => {
3236
+ runPostureProbe();
3237
+ }, 5e3);
3238
+ initialPostureTimer.unref();
3239
+ ctx.effect(() => () => {
3240
+ clearTimeout(initialPostureTimer);
3241
+ }, "remote-web-ui: posture probe boot");
2602
3242
  new RemoteWebUiPairing(ctx, (request) => {
2603
3243
  if (!resolve().enabled) return false;
2604
3244
  return isPairedDeviceRequest(service, request);
@@ -2620,7 +3260,7 @@ function applyImpl(ctx, config) {
2620
3260
  const enabled = value.enabled;
2621
3261
  if (!enabled) service.stop();
2622
3262
  if (disposeRoutes === void 0 && enabled) disposeRoutes = ctx.effect(() => {
2623
- const disposers = routes.map((route) => ctx.webServer.register(route));
3263
+ const disposers = [...routes.map((route) => ctx.webServer.register(route)), ...upgrades.map((route) => ctx.webServer.registerUpgrade(route))];
2624
3264
  return () => {
2625
3265
  for (const dispose of disposers) dispose();
2626
3266
  };
@@ -2642,6 +3282,7 @@ function applyImpl(ctx, config) {
2642
3282
  disposeSweep();
2643
3283
  disposeSweep = void 0;
2644
3284
  }
3285
+ runPostureProbe();
2645
3286
  };
2646
3287
  installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
2647
3288
  setSource: (source) => {
@@ -2662,4 +3303,4 @@ function isHttpUrl(value) {
2662
3303
  }
2663
3304
  }
2664
3305
  //#endregion
2665
- export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, inject, name, pairingConfigOf };
3306
+ export { Config, REMOTE_WEB_UI_SETTINGS_NAMESPACE, apply, defaultDevicesFile, inject, name, pairingConfigOf };