@linxin666/dsh-remote-web-ui 0.3.23 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -10
  3. package/README.zh.md +13 -10
  4. package/icon.svg +3 -0
  5. package/lib/client.js +396 -132
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +604 -262
  8. package/lib/types/client/FenceNotice.d.ts.map +1 -1
  9. package/lib/types/client/FenceNotice.js +0 -3
  10. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.js +3 -2
  13. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  14. package/lib/types/client/RemoteEntry.js +14 -9
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +13 -4
  17. package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +14 -8
  20. package/lib/types/client/UpdateEntry.js +2 -2
  21. package/lib/types/client/UpdatePanel.js +2 -2
  22. package/lib/types/client/deep-link.js +2 -2
  23. package/lib/types/client/index.d.ts +16 -8
  24. package/lib/types/client/index.d.ts.map +1 -1
  25. package/lib/types/client/index.js +56 -32
  26. package/lib/types/client/locales.d.ts +0 -2
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/locales.js +6 -8
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  30. package/lib/types/client/mobile-adapt.js +143 -28
  31. package/lib/types/client/pair-api.d.ts +32 -3
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +44 -6
  34. package/lib/types/client/plugin-card-seat.d.ts +104 -0
  35. package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
  36. package/lib/types/client/plugin-card-seat.js +143 -0
  37. package/lib/types/client/remote-channel.d.ts.map +1 -1
  38. package/lib/types/client/remote-channel.js +18 -5
  39. package/lib/types/client/settings-form.d.ts +17 -17
  40. package/lib/types/client/settings-form.d.ts.map +1 -1
  41. package/lib/types/client/settings-form.js +30 -27
  42. package/lib/types/console-output.d.ts +41 -0
  43. package/lib/types/console-output.d.ts.map +1 -0
  44. package/lib/types/console-output.js +89 -0
  45. package/lib/types/http.d.ts +18 -1
  46. package/lib/types/http.d.ts.map +1 -1
  47. package/lib/types/http.js +22 -1
  48. package/lib/types/index.d.ts +19 -11
  49. package/lib/types/index.d.ts.map +1 -1
  50. package/lib/types/index.js +91 -76
  51. package/lib/types/inner-auth.d.ts +3 -2
  52. package/lib/types/inner-auth.d.ts.map +1 -1
  53. package/lib/types/inner-auth.js +3 -2
  54. package/lib/types/pair-grant.d.ts +66 -0
  55. package/lib/types/pair-grant.d.ts.map +1 -0
  56. package/lib/types/pair-grant.js +95 -0
  57. package/lib/types/pairing.d.ts +44 -12
  58. package/lib/types/pairing.d.ts.map +1 -1
  59. package/lib/types/pairing.js +69 -20
  60. package/lib/types/pairing.persist.test.js +26 -0
  61. package/lib/types/posture.d.ts +11 -3
  62. package/lib/types/posture.d.ts.map +1 -1
  63. package/lib/types/posture.js +8 -3
  64. package/lib/types/remote-api.d.ts +10 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +34 -34
  67. package/lib/types/remote-channel-boot.d.ts +6 -1
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +16 -8
  70. package/lib/types/remote-channel-rules.d.ts +14 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +8 -0
  73. package/lib/types/remote-methods.d.ts +1 -1
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +1 -0
  76. package/lib/types/routes.d.ts +47 -5
  77. package/lib/types/routes.d.ts.map +1 -1
  78. package/lib/types/routes.js +160 -58
  79. package/lib/types/tunnel.d.ts +21 -6
  80. package/lib/types/tunnel.d.ts.map +1 -1
  81. package/lib/types/tunnel.js +58 -11
  82. package/lib/types/update.d.ts +4 -0
  83. package/lib/types/update.d.ts.map +1 -1
  84. package/lib/types/update.js +74 -33
  85. package/package.json +23 -20
  86. package/src/client/FenceNotice.tsx +0 -2
  87. package/src/client/PluginSettingsCard.tsx +7 -2
  88. package/src/client/RemoteEntry.tsx +14 -9
  89. package/src/client/RemotePanel.tsx +17 -7
  90. package/src/client/RemoteSettingsCard.tsx +16 -9
  91. package/src/client/UpdateEntry.tsx +2 -2
  92. package/src/client/UpdatePanel.tsx +3 -3
  93. package/src/client/deep-link.ts +2 -2
  94. package/src/client/index.ts +70 -40
  95. package/src/client/locales.ts +6 -8
  96. package/src/client/mobile-adapt.ts +136 -25
  97. package/src/client/pair-api.ts +47 -7
  98. package/src/client/plugin-card-seat.ts +192 -0
  99. package/src/client/remote-channel.ts +19 -4
  100. package/src/client/remote.module.css +38 -0
  101. package/src/client/settings-form.ts +34 -31
  102. package/src/console-output.ts +97 -0
  103. package/src/http.ts +23 -1
  104. package/src/index.ts +102 -79
  105. package/src/inner-auth.ts +3 -2
  106. package/src/pair-grant.ts +131 -0
  107. package/src/pairing.persist.test.ts +36 -0
  108. package/src/pairing.ts +75 -24
  109. package/src/posture.ts +12 -4
  110. package/src/remote-api.ts +34 -41
  111. package/src/remote-channel-boot.ts +16 -8
  112. package/src/remote-channel-rules.ts +17 -0
  113. package/src/remote-methods.ts +1 -0
  114. package/src/routes.ts +165 -57
  115. package/src/tunnel.ts +56 -11
  116. package/src/update.ts +74 -29
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
3
  import { setInterval, setTimeout as setTimeout$1 } from "node:timers";
4
4
  import z from "schemastery";
5
5
  import { randomBytes } from "node:crypto";
6
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
7
7
  import { homedir, networkInterfaces } from "node:os";
8
8
  import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
9
9
  import { Service } from "@deepseek-ai/cordis";
@@ -23,9 +23,9 @@ import { Tunnel, bin, install } from "cloudflared";
23
23
  * Security invariants:
24
24
  * - One active token at a time; `issue()` replaces it, so a refreshed QR
25
25
  * immediately invalidates the previous link.
26
- * - A token stays re-usable until it expires or is replaced: the first
27
- * successful `accept()` marks it consumed, but the same link may pair
28
- * again within the window (each re-accept mints a fresh device session).
26
+ * - A token is a bearer credential for its whole window: it is refused only
27
+ * when unknown, expired, or after `stop()`, and the same link may pair
28
+ * repeatedly within that window (each accept mints a fresh device session).
29
29
  * Mobile flows routinely split across cookie contexts (camera preview to
30
30
  * in-app browser to the system browser), and the later context must be
31
31
  * able to complete its own pairing from the same link.
@@ -75,6 +75,7 @@ const defaultClock = {
75
75
  var PairingService = class {
76
76
  config;
77
77
  clock;
78
+ fs;
78
79
  tokens = /* @__PURE__ */ new Map();
79
80
  devices = /* @__PURE__ */ new Map();
80
81
  listeners = /* @__PURE__ */ new Set();
@@ -97,9 +98,10 @@ var PairingService = class {
97
98
  * the current one.
98
99
  * @param clock - clock/entropy source (injectable for tests).
99
100
  */
100
- constructor(config, clock = defaultClock) {
101
+ constructor(config, clock = defaultClock, fs = {}) {
101
102
  this.config = config;
102
103
  this.clock = clock;
104
+ this.fs = fs;
103
105
  this.loadPersisted();
104
106
  }
105
107
  /**
@@ -126,7 +128,7 @@ var PairingService = class {
126
128
  });
127
129
  }
128
130
  this.clampToMaxDevices();
129
- if (this.evictIdle()) this.persist();
131
+ if (this.evictIdle()) this.persistRevocation();
130
132
  } catch {}
131
133
  }
132
134
  /** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
@@ -159,7 +161,7 @@ var PairingService = class {
159
161
  */
160
162
  persist() {
161
163
  const file = this.config.devicesFile;
162
- if (file === void 0) return;
164
+ if (file === void 0) return true;
163
165
  try {
164
166
  mkdirSync(dirname(file), { recursive: true });
165
167
  const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
@@ -168,8 +170,31 @@ var PairingService = class {
168
170
  writeFileSync(temp, JSON.stringify(payload), { mode: 384 });
169
171
  renameSync(temp, file);
170
172
  this.dirty = false;
173
+ return true;
171
174
  } catch (error) {
172
175
  console.error("remote-web-ui: failed to persist paired devices", error);
176
+ return false;
177
+ }
178
+ }
179
+ /**
180
+ * Persist a table change that REVOKES access (stop, per-device revoke, idle
181
+ * eviction). A failed write would leave the revoked session on disk, and
182
+ * {@link loadPersisted} restores that file verbatim on the next start — the
183
+ * device's still-valid cookie would authorize again after an explicit
184
+ * revocation. When the write cannot be made durable, the stale store is
185
+ * removed instead (a missing file already loads as an empty table), so the
186
+ * failure costs a re-pair rather than silently undoing the revocation.
187
+ */
188
+ persistRevocation() {
189
+ if (this.persist()) return;
190
+ const file = this.config.devicesFile;
191
+ if (file === void 0) return;
192
+ try {
193
+ if (this.fs.removeFile !== void 0) this.fs.removeFile(file);
194
+ else rmSync(file, { force: true });
195
+ console.error("remote-web-ui: the device revocation could not be persisted; removed the stale device store so a restart cannot restore it");
196
+ } catch (error) {
197
+ console.error("remote-web-ui: the device revocation could not be persisted and the stale device store could not be removed", error);
173
198
  }
174
199
  }
175
200
  /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
@@ -193,9 +218,14 @@ var PairingService = class {
193
218
  get publicBaseUrl() {
194
219
  return this.publicBase;
195
220
  }
196
- /** Set or clear the public base URL (a tunnel in front of this server). */
221
+ /**
222
+ * Set or clear the public base URL (a tunnel in front of this server). The
223
+ * value is canonicalized to its origin: a trailing slash (what a browser
224
+ * address-bar copy produces) or a path would mint a dead `//pair-accept`
225
+ * link, which WHATWG resolves as an authority rather than a path.
226
+ */
197
227
  setPublicBaseUrl(url) {
198
- this.publicBase = url;
228
+ this.publicBase = url === void 0 ? void 0 : canonicalBaseUrl(url);
199
229
  this.notify();
200
230
  }
201
231
  /** Set or clear the auto-tunnel status frame (undefined when the feature is off). */
@@ -237,7 +267,6 @@ var PairingService = class {
237
267
  id: `t${this.tokenSerial}`,
238
268
  issuedAt: now,
239
269
  expiresAt: now + this.config.tokenTtlMs,
240
- consumed: false,
241
270
  ...workspaceId !== void 0 ? { workspaceId } : {},
242
271
  ...address !== void 0 ? { address } : {}
243
272
  });
@@ -248,9 +277,11 @@ var PairingService = class {
248
277
  };
249
278
  }
250
279
  /**
251
- * Consume a token and bind a device session. One-time: the second
252
- * successful call for the same token is impossible because the first
253
- * consumes it.
280
+ * Redeem a token and bind a device session. The token is a bearer
281
+ * credential for its whole window, not a single-use nonce: it is refused
282
+ * only when unknown, past its expiry, or after stop(), and every successful
283
+ * call — including a repeat within the same window — mints a fresh device
284
+ * session (see the module doc for the mobile cookie-context rationale).
254
285
  * @param token - the token secret from the QR link.
255
286
  * @param userAgent - optional User-Agent header captured at accept.
256
287
  * @returns the new device id, or a refusal code.
@@ -261,7 +292,6 @@ var PairingService = class {
261
292
  ok: false,
262
293
  code: "invalid"
263
294
  };
264
- record.consumed = true;
265
295
  const deviceId = this.clock.randomToken();
266
296
  const now = this.clock.now();
267
297
  if (this.devices.size >= this.config.maxDevices) {
@@ -293,7 +323,7 @@ var PairingService = class {
293
323
  stop() {
294
324
  this.tokens.clear();
295
325
  this.devices.clear();
296
- this.persist();
326
+ this.persistRevocation();
297
327
  this.stopped = true;
298
328
  this.notify();
299
329
  }
@@ -306,7 +336,7 @@ var PairingService = class {
306
336
  revoke(deviceId) {
307
337
  if (this.stopped) return false;
308
338
  if (!this.devices.delete(deviceId)) return false;
309
- this.persist();
339
+ this.persistRevocation();
310
340
  this.notify();
311
341
  return true;
312
342
  }
@@ -341,7 +371,8 @@ var PairingService = class {
341
371
  * actually changed.
342
372
  */
343
373
  sweep() {
344
- if (this.evictIdle() || this.dirty) this.persist();
374
+ if (this.evictIdle()) this.persistRevocation();
375
+ else if (this.dirty) this.persist();
345
376
  this.notify();
346
377
  }
347
378
  /** The current snapshot (fresh object per call — stable between emits). */
@@ -399,7 +430,7 @@ var PairingService = class {
399
430
  const limit = this.config.idleExpireMs ?? 2592e6;
400
431
  if (this.clock.now() - session.lastSeenAt > limit) {
401
432
  this.devices.delete(deviceId);
402
- this.persist();
433
+ this.persistRevocation();
403
434
  this.notify();
404
435
  return;
405
436
  }
@@ -459,6 +490,19 @@ function relayEqual(a, b) {
459
490
  function sameStrings(a, b) {
460
491
  return a.length === b.length && a.every((value, index) => value === b[index]);
461
492
  }
493
+ /**
494
+ * Canonical origin of a public base URL. An unparsable value is returned
495
+ * verbatim so the caller's malformed-base warning stays the single owner of
496
+ * that case.
497
+ * @param url - the configured public base.
498
+ */
499
+ function canonicalBaseUrl(url) {
500
+ try {
501
+ return new URL(url).origin;
502
+ } catch {
503
+ return url;
504
+ }
505
+ }
462
506
  /** Strip control characters and cap the User-Agent stored with a session. */
463
507
  function sanitizeUserAgent(raw) {
464
508
  if (raw === void 0) return void 0;
@@ -673,6 +717,28 @@ function isJsonObject(value) {
673
717
  return typeof value === "object" && value !== null && !Array.isArray(value);
674
718
  }
675
719
  /**
720
+ * Request init that asks a remote origin for an uncompressed body.
721
+ *
722
+ * The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
723
+ * the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
724
+ * Node's built-in `fetch()` reads. That cross-major wrapper drops the
725
+ * `content-encoding` header and automatic decompression, so a plain fetch
726
+ * resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
727
+ * consumer can read. Every host fetch that parses a remote body must request
728
+ * identity encoding. Caller headers survive; `accept-encoding` is forced to
729
+ * `identity` because a caller value has no decoder behind it in this host.
730
+ * @param init - the caller's request init (signal, headers, method, ...).
731
+ * @returns a copy with `accept-encoding: identity` merged in.
732
+ */
733
+ function withIdentityEncoding(init = {}) {
734
+ const headers = new Headers(init.headers);
735
+ headers.set("accept-encoding", "identity");
736
+ return {
737
+ ...init,
738
+ headers
739
+ };
740
+ }
741
+ /**
676
742
  * Write one JSON response. Default headers are the family defaults
677
743
  * (content-type and referrer-policy); caller headers are appended or
678
744
  * override them.
@@ -685,6 +751,169 @@ function writeJson(res, status, body, headers = {}) {
685
751
  });
686
752
  res.end(payload);
687
753
  }
754
+ /**
755
+ * Build the grant table. Pure TypeScript with injected clock/randomness so the
756
+ * whole one-time semantics are unit-testable without a server.
757
+ */
758
+ function createPairGrantStore(options = {}) {
759
+ const ttlMs = options.ttlMs ?? 6e4;
760
+ const now = options.now ?? (() => Date.now());
761
+ const createGrant = options.createGrant ?? (() => randomBytes(32).toString("base64url"));
762
+ const entries = /* @__PURE__ */ new Map();
763
+ const purgeExpired = (at) => {
764
+ for (const [grant, entry] of entries) if (entry.expiresAt <= at) entries.delete(grant);
765
+ };
766
+ return {
767
+ issue(deviceId) {
768
+ const issuedAt = now();
769
+ purgeExpired(issuedAt);
770
+ while (entries.size >= 256) {
771
+ const oldest = entries.keys().next().value;
772
+ if (oldest === void 0) break;
773
+ entries.delete(oldest);
774
+ }
775
+ const grant = createGrant();
776
+ const expiresAt = issuedAt + ttlMs;
777
+ entries.set(grant, {
778
+ deviceId,
779
+ expiresAt
780
+ });
781
+ return {
782
+ grant,
783
+ expiresAt
784
+ };
785
+ },
786
+ consume(grant) {
787
+ if (grant === void 0 || grant === "") return void 0;
788
+ const consumedAt = now();
789
+ const entry = entries.get(grant);
790
+ entries.delete(grant);
791
+ purgeExpired(consumedAt);
792
+ if (entry === void 0 || entry.expiresAt <= consumedAt) return void 0;
793
+ return entry.deviceId;
794
+ },
795
+ peek(grant) {
796
+ if (grant === void 0 || grant === "") return void 0;
797
+ const at = now();
798
+ const entry = entries.get(grant);
799
+ if (entry === void 0) return void 0;
800
+ if (entry.expiresAt <= at) {
801
+ entries.delete(grant);
802
+ return;
803
+ }
804
+ return entry.deviceId;
805
+ },
806
+ get size() {
807
+ return entries.size;
808
+ }
809
+ };
810
+ }
811
+ //#endregion
812
+ //#region src/remote-methods.ts
813
+ /**
814
+ * Remote desktop channel constants — SDK-independent so tests and the
815
+ * client half can pin them without importing the host SDK graph.
816
+ *
817
+ * Access model on the 0.1.2-alpha.2 line: the host /api surface has no
818
+ * per-method privilege pin — the "configuration plane is local" behavior
819
+ * lives in the browser (client plugins branch on connection.isLoopback), and
820
+ * the paired remote desktop flips into host mode via the transport hook
821
+ * (ownsHost) while every call rides this gated channel as a loopback-shaped
822
+ * request. A paired device is therefore a full-control credential by
823
+ * design; the only paths that stay physically local are the control planes
824
+ * below (pairing control, self-update, plugin install/remove, host power).
825
+ */
826
+ /** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
827
+ const REMOTE_PREFIX$1 = "/remote";
828
+ /** Connection-plugin method prefix under the gated channel. */
829
+ const REMOTE_API_PREFIX$1 = `${REMOTE_PREFIX$1}/api`;
830
+ /**
831
+ * Exact upgrade paths registered on webServer (the SDK matches upgrades by
832
+ * exact path, not prefix). Query strings ride on the request URL.
833
+ */
834
+ const REMOTE_UPGRADE_PATHS = [
835
+ { mux: `${REMOTE_API_PREFIX$1}/remote.mux` }.mux,
836
+ `${REMOTE_PREFIX$1}/sidebar/ws/terminal`,
837
+ `${REMOTE_PREFIX$1}/sidebar/ws/agent-terminals`,
838
+ `${REMOTE_PREFIX$1}/sidebar/ws/agent-opens`,
839
+ `${REMOTE_API_PREFIX$1}/dsh-ssh/terminal`
840
+ ];
841
+ /** Plugin-manager HTTP prefix: install/remove stay physically local. */
842
+ const PLUGIN_MANAGER_PATH = "/api/plugin-manager";
843
+ /**
844
+ * The cookieless device credential: the boot patch reads the device id from
845
+ * the /pair-app URL, keeps it in sessionStorage, and attaches it to every
846
+ * gated HTTP call as this header (and to WebSocket upgrades as the `device`
847
+ * query parameter - WS handshakes cannot carry headers from the Web API).
848
+ * The channel gate accepts it exactly like the device cookie, so the mobile
849
+ * flow works even with browser cookies fully blocked; the cookie remains the
850
+ * primary credential on normal browsers.
851
+ */
852
+ const REMOTE_DEVICE_HEADER = "x-dsh-remote-device";
853
+ const REMOTE_DEVICE_QUERY = "device";
854
+ /**
855
+ * Path prefixes that stay physically local even for a paired device. A
856
+ * paired remote desktop may use the full host API (chat, sessions,
857
+ * settings, credentials, presets — it is a full-control credential), but it
858
+ * must not reach the machine-control planes: pairing control itself, the
859
+ * dsh-web self-update installer, and plugin install/remove.
860
+ */
861
+ const LOCAL_ONLY_PREFIXES = [
862
+ "/api/pair",
863
+ "/api/update",
864
+ PLUGIN_MANAGER_PATH
865
+ ];
866
+ /**
867
+ * Whether a paired inner path must stay physically local.
868
+ * @param innerPath - the rewritten inner path (e.g. `/api/session.list`).
869
+ * @returns a denial message, or undefined when the path may be proxied.
870
+ */
871
+ function localOnlyDenial(innerPath) {
872
+ for (const prefix of LOCAL_ONLY_PREFIXES) if (innerPath === prefix || innerPath.startsWith(`${prefix}/`)) return `${prefix.slice(1)} stays physically local and stays unreachable from a paired remote desktop`;
873
+ }
874
+ //#endregion
875
+ //#region src/remote-channel-rules.ts
876
+ /**
877
+ * The remote-channel rewrite contract as pure data (issue #987): both the
878
+ * browser patch (client/remote-channel.ts) and the parse-time boot patch
879
+ * (remote-channel-boot.ts, inlined into index.html by the host) decide from
880
+ * these tables, so the two can never drift apart.
881
+ * @module @linxin666/dsh-remote-web-ui/remote-channel-rules
882
+ */
883
+ /** The gated mirror prefix (must match src/remote-methods.ts). */
884
+ const REMOTE_PREFIX = "/remote";
885
+ /**
886
+ * The window global the device-gated app landing publishes to grant host mode.
887
+ * Set by the /pair-app capture script, read by the parse-time boot patch: the
888
+ * transport hook (ownsHost) is server-granted, not asserted from the origin.
889
+ */
890
+ const REMOTE_HOST_GRANT_GLOBAL = "__DSH_REMOTE_HOST_GRANT__";
891
+ /** The live rule set. */
892
+ const REMOTE_CHANNEL_RULES = {
893
+ remotePrefix: REMOTE_PREFIX,
894
+ apiPrefix: "/api/",
895
+ pairPrefix: "/api/pair/",
896
+ updatePrefix: "/api/update/",
897
+ settingsBridgePrefix: "/api/dsh-web-ui-settings",
898
+ sidebarPrefix: "/sidebar/",
899
+ gitPrefix: "/git/",
900
+ petPrefix: "/pet/",
901
+ wsPaths: [
902
+ "/api/remote.mux",
903
+ "/sidebar/ws/terminal",
904
+ "/sidebar/ws/agent-terminals",
905
+ "/sidebar/ws/agent-opens",
906
+ "/api/dsh-ssh/terminal"
907
+ ],
908
+ deviceHeader: REMOTE_DEVICE_HEADER,
909
+ deviceKey: "dsh-remote-device",
910
+ deviceQuery: REMOTE_DEVICE_QUERY,
911
+ uploadPath: "/api/session/uploadFileBinary",
912
+ uploadHookGlobal: "__DSH_FILE_UPLOAD__",
913
+ hostGrantGlobal: REMOTE_HOST_GRANT_GLOBAL
914
+ };
915
+ /** The window global the boot patch publishes its seat under. */
916
+ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
688
917
  //#endregion
689
918
  //#region src/routes.ts
690
919
  /**
@@ -733,7 +962,12 @@ function isTrustedHost(request, trustedHosts) {
733
962
  }
734
963
  const hostname = hostUrl.hostname;
735
964
  return isLoopbackClient(request) || trustedHosts.some((entry) => {
736
- const entryUrl = new URL(`http://${entry}`);
965
+ let entryUrl;
966
+ try {
967
+ entryUrl = new URL(`http://${entry}`);
968
+ } catch {
969
+ return false;
970
+ }
737
971
  return entryUrl.port === "" ? entryUrl.hostname === hostname : entryUrl.host === hostUrl.host;
738
972
  });
739
973
  }
@@ -816,17 +1050,29 @@ function privateLanHostOf(request, navigation = false) {
816
1050
  const MAX_BODY_BYTES = 4096;
817
1051
  /**
818
1052
  * The rate-limit bucket key for one accept attempt (pure; unit-tested).
819
- * The first client-visible XFF hop separates buckets behind the auto-tunnel
820
- * (every internet client arrives from 127.0.0.1 there) — but only for
821
- * loopback peers, since a direct LAN client can rotate the header freely.
1053
+ * A forwarded hop separates buckets behind the auto-tunnel (every internet
1054
+ * client arrives from 127.0.0.1 there) — but only for loopback peers, since a
1055
+ * direct LAN client can rotate the header freely. The caller passes the hop it
1056
+ * trusts (the edge-appended LAST one; see rateLimitAccept), and the hop is
1057
+ * truncated so one oversized header cannot mint a huge key.
822
1058
  * @param socketIp - the socket peer address.
823
- * @param forwarded - the first XFF hop, already trimmed, if any.
1059
+ * @param forwarded - the trusted XFF hop, already trimmed, if any.
824
1060
  * @param bucket - page (GET /pair-accept) vs api (POST /api/pair/accept).
825
1061
  */
826
1062
  function acceptLimitKey(socketIp, forwarded, bucket) {
827
- if (isLoopbackAddress(socketIp) && forwarded !== void 0 && forwarded !== "") return `${bucket}|${socketIp}|${forwarded}`;
1063
+ if (isLoopbackAddress(socketIp) && forwarded !== void 0 && forwarded !== "") return `${bucket}|${socketIp}|${forwarded.slice(0, MAX_FORWARDED_KEY_CHARS)}`;
828
1064
  return `${bucket}|${socketIp}`;
829
1065
  }
1066
+ /** Longest forwarded hop kept in a bucket key (the header is caller-sized). */
1067
+ const MAX_FORWARDED_KEY_CHARS = 64;
1068
+ /**
1069
+ * Hard cap on live accept buckets. The key is caller-influenced (a forwarded
1070
+ * hop), so a rotating flood must not grow the table without bound: past the
1071
+ * cap the oldest bucket is evicted, the same FIFO stance as
1072
+ * {@link addBounded}. A legitimate client that loses its bucket merely starts
1073
+ * a fresh window.
1074
+ */
1075
+ const MAX_ACCEPT_BUCKETS = 1024;
830
1076
  /**
831
1077
  * Reasons a phone-facing entry page refused a request, logged once per shape
832
1078
  * so a real device failure is diagnosable from the host console without a
@@ -897,15 +1143,24 @@ const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60;
897
1143
  const APP_DEVICE_STORAGE_KEY = "dsh-remote-device";
898
1144
  function appShellCaptureScript(deviceId) {
899
1145
  const safeId = JSON.stringify(deviceId);
1146
+ const grantGlobal = JSON.stringify(REMOTE_HOST_GRANT_GLOBAL);
900
1147
  const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`;
901
- return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});localStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}${register}})()<\/script>`;
1148
+ return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()<\/script>`;
902
1149
  }
903
- /** Patch the official index document with the device-capture script. */
1150
+ /**
1151
+ * Patch the official index document with the device-capture script. The script
1152
+ * goes immediately after the opening <head> tag, ahead of the harness-injected
1153
+ * parse-time channel boot patch: that patch installs the transport host-mode
1154
+ * hook only when this server-served marker is already set (see
1155
+ * remote-channel-boot.ts), so host mode is granted by the device-gated landing
1156
+ * instead of being asserted from the origin.
1157
+ */
904
1158
  function patchAppShell(html, deviceId) {
905
1159
  const script = appShellCaptureScript(deviceId);
906
- const at = html.indexOf("</head>");
907
- if (at === -1) return script + html;
908
- return html.slice(0, at) + script + html.slice(at);
1160
+ const head = /<head[^>]*>/i.exec(html);
1161
+ if (head === null) return script + html;
1162
+ const end = head.index + head[0].length;
1163
+ return html.slice(0, end) + script + html.slice(end);
909
1164
  }
910
1165
  /**
911
1166
  * The reopen service worker served at PAIR_PATHS.appServiceWorker. A paired
@@ -1102,6 +1357,7 @@ var PairingEventsStream = class {
1102
1357
  function makeRoutes(deps) {
1103
1358
  const { service, requirePairingForLan = true } = deps;
1104
1359
  const pairingRequired = () => typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan;
1360
+ const grants = deps.grants ?? createPairGrantStore();
1105
1361
  const events = new PairingEventsStream(service);
1106
1362
  const dynamicTrustedHosts = /* @__PURE__ */ new Set();
1107
1363
  /** Loopback-only fence: the desktop panel's control endpoints. */
@@ -1127,10 +1383,14 @@ function makeRoutes(deps) {
1127
1383
  if (privateLanHost !== void 0) {
1128
1384
  const cookieDeviceId = readCookie(req.headers.cookie, service.config.cookieName);
1129
1385
  let queryDeviceId = null;
1386
+ let queryGrant;
1130
1387
  try {
1131
- queryDeviceId = new URL(req.url ?? "/", "http://pair.invalid").searchParams.get("device");
1388
+ const params = new URL(req.url ?? "/", "http://pair.invalid").searchParams;
1389
+ queryDeviceId = params.get("device");
1390
+ queryGrant = params.get("grant") ?? void 0;
1132
1391
  } catch {}
1133
- const deviceId = cookieDeviceId ?? queryDeviceId ?? void 0;
1392
+ const granted = grants.peek(queryGrant);
1393
+ const deviceId = cookieDeviceId ?? queryDeviceId ?? granted ?? void 0;
1134
1394
  if (deviceId !== void 0 && service.hasDevice(deviceId)) {
1135
1395
  addBounded(dynamicTrustedHosts, privateLanHost, 64);
1136
1396
  return true;
@@ -1153,14 +1413,38 @@ function makeRoutes(deps) {
1153
1413
  * count separately: a QR re-scan (page navigation) must not consume a
1154
1414
  * brute-force budget that belongs to token guessing (and vice versa).
1155
1415
  */
1416
+ /**
1417
+ * Drop expired buckets from the FRONT of the insertion-ordered table, then
1418
+ * FIFO-evict past the hard cap. The front always holds the oldest window
1419
+ * because a re-armed bucket is re-inserted at the tail, so this is amortized
1420
+ * O(1) per request: every iteration deletes an entry that was inserted once.
1421
+ * It replaces a full-table scan that ran on every pairing request once the
1422
+ * table passed 256 entries (and deleted nothing while all windows were
1423
+ * fresh).
1424
+ */
1425
+ const pruneAcceptAttempts = (nowMs) => {
1426
+ while (acceptAttempts.size > 0) {
1427
+ const oldest = acceptAttempts.keys().next().value;
1428
+ if (oldest === void 0) break;
1429
+ const attempt = acceptAttempts.get(oldest);
1430
+ if (attempt !== void 0 && nowMs - attempt.windowStart <= ACCEPT_WINDOW_MS) break;
1431
+ acceptAttempts.delete(oldest);
1432
+ }
1433
+ while (acceptAttempts.size > MAX_ACCEPT_BUCKETS) {
1434
+ const oldest = acceptAttempts.keys().next().value;
1435
+ if (oldest === void 0) break;
1436
+ acceptAttempts.delete(oldest);
1437
+ }
1438
+ };
1156
1439
  const rateLimitAccept = (req, bucket) => {
1157
- const ip = acceptLimitKey(req.socket?.remoteAddress ?? "unknown", typeof req.headers["x-forwarded-for"] === "string" ? (req.headers["x-forwarded-for"].split(",")[0] ?? "").trim() : void 0, bucket);
1440
+ const socketIp = req.socket?.remoteAddress ?? "unknown";
1441
+ const hops = typeof req.headers["x-forwarded-for"] === "string" ? req.headers["x-forwarded-for"].split(",") : [];
1442
+ const ip = acceptLimitKey(socketIp, (hops[hops.length - 1] ?? "").trim(), bucket);
1158
1443
  const nowMs = Date.now();
1159
- if (acceptAttempts.size > 256) {
1160
- for (const [key, attempt] of acceptAttempts) if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key);
1161
- }
1444
+ pruneAcceptAttempts(nowMs);
1162
1445
  const entry = acceptAttempts.get(ip);
1163
1446
  if (entry === void 0 || nowMs - entry.windowStart > ACCEPT_WINDOW_MS) {
1447
+ acceptAttempts.delete(ip);
1164
1448
  acceptAttempts.set(ip, {
1165
1449
  count: 1,
1166
1450
  windowStart: nowMs
@@ -1251,7 +1535,7 @@ function makeRoutes(deps) {
1251
1535
  });
1252
1536
  return;
1253
1537
  }
1254
- writeJson(res, result.code === "used" ? 409 : 404, {
1538
+ writeJson(res, 404, {
1255
1539
  ok: false,
1256
1540
  code: result.code
1257
1541
  });
@@ -1261,7 +1545,7 @@ function makeRoutes(deps) {
1261
1545
  writeJson(res, 200, {
1262
1546
  ok: true,
1263
1547
  deviceId: result.deviceId
1264
- }, { "set-cookie": [`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`] });
1548
+ }, { "set-cookie": [deviceCookie(req, service.config.cookieName, result.deviceId)] });
1265
1549
  };
1266
1550
  const handleStop = async (req, res) => {
1267
1551
  if (!requireMethod(req, res, "POST")) return;
@@ -1357,12 +1641,12 @@ function makeRoutes(deps) {
1357
1641
  }
1358
1642
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
1359
1643
  const paired = deviceId !== void 0 && service.hasDevice(deviceId);
1644
+ const local = isLoopbackClient(req);
1360
1645
  const snapshot = service.snapshot();
1361
1646
  const { devices: _devices, ...rest } = snapshot;
1362
- const visible = paired ? rest : {
1647
+ const visible = paired || local ? rest : {
1363
1648
  phase: snapshot.phase,
1364
- lanAvailable: snapshot.lanAvailable,
1365
- lanAddresses: snapshot.lanAddresses
1649
+ lanAvailable: snapshot.lanAvailable
1366
1650
  };
1367
1651
  writeJson(res, 200, {
1368
1652
  ok: true,
@@ -1435,8 +1719,9 @@ function makeRoutes(deps) {
1435
1719
  }
1436
1720
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
1437
1721
  if (deviceId !== void 0 && service.hasDevice(deviceId)) {
1722
+ const { grant } = grants.issue(deviceId);
1438
1723
  res.writeHead(303, {
1439
- location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(deviceId)}`,
1724
+ location: `${appOrigin(req)}/pair-app?grant=${encodeURIComponent(grant)}`,
1440
1725
  "cache-control": "no-store",
1441
1726
  "referrer-policy": "no-referrer"
1442
1727
  });
@@ -1452,19 +1737,21 @@ function makeRoutes(deps) {
1452
1737
  return;
1453
1738
  }
1454
1739
  if (privateLanHost !== void 0) addBounded(dynamicTrustedHosts, privateLanHost, 64);
1740
+ const { grant } = grants.issue(result.deviceId);
1455
1741
  res.writeHead(303, {
1456
- location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(result.deviceId)}`,
1742
+ location: `${appOrigin(req)}/pair-app?grant=${encodeURIComponent(grant)}`,
1457
1743
  "cache-control": "no-store",
1458
1744
  "referrer-policy": "no-referrer",
1459
- "set-cookie": [`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`]
1745
+ "set-cookie": [deviceCookie(req, service.config.cookieName, result.deviceId)]
1460
1746
  });
1461
1747
  res.end();
1462
1748
  };
1463
1749
  /**
1464
- * The cookieless app landing. A paired device (device query or live
1465
- * pairing cookie) receives the official index shell patched with the
1466
- * device-capture script; the shell itself is the official document and
1467
- * carries no data, so serving it needs only the device credential.
1750
+ * The cookieless app landing. A paired device (a one-time grant minted by
1751
+ * /pair-accept, or a live pairing cookie on a reopen) receives the official
1752
+ * index shell patched with the device-capture script; the shell itself is
1753
+ * the official document and carries no data, so serving it needs only the
1754
+ * device credential.
1468
1755
  */
1469
1756
  const handleAppPage = async (req, res) => {
1470
1757
  if (!requireMethod(req, res, "GET")) return;
@@ -1479,9 +1766,10 @@ function makeRoutes(deps) {
1479
1766
  res.end("rate limited");
1480
1767
  return;
1481
1768
  }
1482
- const device = new URL(req.url ?? "/", "http://pair.invalid").searchParams.get("device") ?? "";
1769
+ const url = new URL(req.url ?? "/", "http://pair.invalid");
1770
+ const grantedDevice = grants.consume(url.searchParams.get("grant") ?? void 0);
1483
1771
  const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName);
1484
- const id = device !== "" && service.touchDevice(device) ? device : cookieDevice !== void 0 && service.touchDevice(cookieDevice) ? cookieDevice : void 0;
1772
+ const id = grantedDevice !== void 0 && service.touchDevice(grantedDevice) ? grantedDevice : cookieDevice !== void 0 && service.touchDevice(cookieDevice) ? cookieDevice : void 0;
1485
1773
  if (id === void 0) {
1486
1774
  res.writeHead(200, {
1487
1775
  "content-type": "text/html; charset=utf-8",
@@ -1504,7 +1792,7 @@ function makeRoutes(deps) {
1504
1792
  "content-type": "text/html; charset=utf-8",
1505
1793
  "cache-control": "no-store",
1506
1794
  "referrer-policy": "no-referrer",
1507
- "set-cookie": [`${service.config.cookieName}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`]
1795
+ "set-cookie": [deviceCookie(req, service.config.cookieName, id)]
1508
1796
  });
1509
1797
  res.end(patchAppShell(html, id));
1510
1798
  };
@@ -1590,11 +1878,31 @@ function makeRoutes(deps) {
1590
1878
  }
1591
1879
  return routes;
1592
1880
  }
1881
+ /**
1882
+ * Whether the request reached this process over TLS. The harness itself serves
1883
+ * plain HTTP; a tunnel edge terminates TLS and stamps `x-forwarded-proto`
1884
+ * (the same signal {@link appOrigin} trusts when it rebuilds a redirect).
1885
+ */
1886
+ function isHttpsRequest(req) {
1887
+ const forwarded = req.headers["x-forwarded-proto"];
1888
+ if (typeof forwarded === "string") {
1889
+ if (forwarded.split(",")[0]?.trim().toLowerCase() === "https") return true;
1890
+ }
1891
+ return req.socket.encrypted === true;
1892
+ }
1893
+ /**
1894
+ * The device cookie for one request. `Secure` is added only when the request
1895
+ * arrived over TLS: LAN pairing runs on plain HTTP, where a Secure cookie is
1896
+ * simply dropped by the browser and the phone would silently lose its session.
1897
+ */
1898
+ function deviceCookie(req, cookieName, deviceId) {
1899
+ const secure = isHttpsRequest(req) ? "; Secure" : "";
1900
+ return `${cookieName}=${deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}${secure}`;
1901
+ }
1593
1902
  /** The request origin (https when a tunnel edge says so). */
1594
1903
  function appOrigin(req) {
1595
- const proto = req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
1596
1904
  const origin = `http://${req.headers.host ?? "127.0.0.1"}`;
1597
- return proto === "https" ? origin.replace("http://", "https://") : origin;
1905
+ return isHttpsRequest(req) ? origin.replace("http://", "https://") : origin;
1598
1906
  }
1599
1907
  //#endregion
1600
1908
  //#region src/loopback-proxy.ts
@@ -1729,68 +2037,6 @@ function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath, cookie) {
1729
2037
  });
1730
2038
  }
1731
2039
  //#endregion
1732
- //#region src/remote-methods.ts
1733
- /**
1734
- * Remote desktop channel constants — SDK-independent so tests and the
1735
- * client half can pin them without importing the host SDK graph.
1736
- *
1737
- * Access model on the 0.1.2-alpha.2 line: the host /api surface has no
1738
- * per-method privilege pin — the "configuration plane is local" behavior
1739
- * lives in the browser (client plugins branch on connection.isLoopback), and
1740
- * the paired remote desktop flips into host mode via the transport hook
1741
- * (ownsHost) while every call rides this gated channel as a loopback-shaped
1742
- * request. A paired device is therefore a full-control credential by
1743
- * design; the only paths that stay physically local are the control planes
1744
- * below (pairing control, self-update, plugin install/remove, host power).
1745
- */
1746
- /** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
1747
- const REMOTE_PREFIX$1 = "/remote";
1748
- /** Connection-plugin method prefix under the gated channel. */
1749
- const REMOTE_API_PREFIX$1 = `${REMOTE_PREFIX$1}/api`;
1750
- /**
1751
- * Exact upgrade paths registered on webServer (the SDK matches upgrades by
1752
- * exact path, not prefix). Query strings ride on the request URL.
1753
- */
1754
- const REMOTE_UPGRADE_PATHS = [
1755
- { mux: `${REMOTE_API_PREFIX$1}/remote.mux` }.mux,
1756
- `${REMOTE_PREFIX$1}/sidebar/ws/terminal`,
1757
- `${REMOTE_PREFIX$1}/sidebar/ws/agent-terminals`,
1758
- `${REMOTE_API_PREFIX$1}/dsh-ssh/terminal`
1759
- ];
1760
- /** Plugin-manager HTTP prefix: install/remove stay physically local. */
1761
- const PLUGIN_MANAGER_PATH = "/api/plugin-manager";
1762
- /**
1763
- * The cookieless device credential: the boot patch reads the device id from
1764
- * the /pair-app URL, keeps it in sessionStorage, and attaches it to every
1765
- * gated HTTP call as this header (and to WebSocket upgrades as the `device`
1766
- * query parameter - WS handshakes cannot carry headers from the Web API).
1767
- * The channel gate accepts it exactly like the device cookie, so the mobile
1768
- * flow works even with browser cookies fully blocked; the cookie remains the
1769
- * primary credential on normal browsers.
1770
- */
1771
- const REMOTE_DEVICE_HEADER = "x-dsh-remote-device";
1772
- const REMOTE_DEVICE_QUERY = "device";
1773
- /**
1774
- * Path prefixes that stay physically local even for a paired device. A
1775
- * paired remote desktop may use the full host API (chat, sessions,
1776
- * settings, credentials, presets — it is a full-control credential), but it
1777
- * must not reach the machine-control planes: pairing control itself, the
1778
- * dsh-web self-update installer, and plugin install/remove.
1779
- */
1780
- const LOCAL_ONLY_PREFIXES = [
1781
- "/api/pair",
1782
- "/api/update",
1783
- PLUGIN_MANAGER_PATH
1784
- ];
1785
- /**
1786
- * Whether a paired inner path must stay physically local.
1787
- * @param innerPath - the rewritten inner path (e.g. `/api/session.list`).
1788
- * @returns a denial message, or undefined when the path may be proxied.
1789
- */
1790
- function localOnlyDenial(innerPath) {
1791
- for (const prefix of LOCAL_ONLY_PREFIXES) if (innerPath === prefix || innerPath.startsWith(`${prefix}/`)) return `${prefix.slice(1)} stays physically local and stays unreachable from a paired remote desktop`;
1792
- }
1793
- //#endregion
1794
2040
  //#region src/remote-api.ts
1795
2041
  const ALLOWED_METHODS = /* @__PURE__ */ new Set([
1796
2042
  "GET",
@@ -1862,18 +2108,16 @@ function pairedDeviceIdOf(req, service) {
1862
2108
  }
1863
2109
  /**
1864
2110
  * Build the remote desktop channel HTTP routes.
1865
- * @param deps - pairing service + local port + live pairing policy.
2111
+ * @param deps - pairing service + local port + inner credential.
1866
2112
  * @returns the routes to register on webServer.
1867
2113
  */
1868
2114
  function makeRemoteApiRoutes(deps) {
1869
- const { service, port, requirePairingForLan = true } = deps;
2115
+ const { service, port } = deps;
1870
2116
  const handler = (req, res) => {
1871
- if (typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan) {
1872
- if (pairedDeviceIdOf(req, service) === void 0) {
1873
- req.resume();
1874
- envelopeError(res, 403, "invalid-request", "unpaired", "this device is not paired with the desktop");
1875
- return;
1876
- }
2117
+ if (pairedDeviceIdOf(req, service) === void 0) {
2118
+ req.resume();
2119
+ envelopeError(res, 403, "invalid-request", "unpaired", "this device is not paired with the desktop");
2120
+ return;
1877
2121
  }
1878
2122
  const method = req.method ?? "GET";
1879
2123
  if (!ALLOWED_METHODS.has(method)) {
@@ -1924,18 +2168,16 @@ function upgradeInnerPath(reqUrl, fallbackPath) {
1924
2168
  * @returns the upgrade routes to register on webServer.
1925
2169
  */
1926
2170
  function makeRemoteApiUpgradeRoutes(deps) {
1927
- const { service, port, requirePairingForLan = true } = deps;
2171
+ const { service, port } = deps;
1928
2172
  const handlerFor = (fallbackPath) => (req, socket, head) => {
1929
- if (typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan) {
1930
- let queryDevice;
1931
- try {
1932
- queryDevice = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("device") ?? void 0;
1933
- } catch {}
1934
- if ((pairedDeviceIdOf(req, service) ?? (queryDevice !== void 0 && service.touchDevice(queryDevice) ? queryDevice : void 0)) === void 0) {
1935
- socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
1936
- socket.destroy();
1937
- return;
1938
- }
2173
+ let queryDevice;
2174
+ try {
2175
+ queryDevice = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("device") ?? void 0;
2176
+ } catch {}
2177
+ if ((pairedDeviceIdOf(req, service) ?? (queryDevice !== void 0 && service.touchDevice(queryDevice) ? queryDevice : void 0)) === void 0) {
2178
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
2179
+ socket.destroy();
2180
+ return;
1939
2181
  }
1940
2182
  const inner = upgradeInnerPath(req.url, fallbackPath);
1941
2183
  if (loopbackOnlyDenial(inner.split("?")[0] ?? inner) !== void 0) {
@@ -2085,7 +2327,7 @@ async function probeHost(port, hostHeader, request, timeoutMs) {
2085
2327
  },
2086
2328
  timeout: timeoutMs
2087
2329
  }, (status) => {
2088
- finish(status !== 403);
2330
+ finish(status !== 403 && status !== 401);
2089
2331
  });
2090
2332
  const timer = setTimeout(() => {
2091
2333
  finish(false);
@@ -2911,14 +3153,23 @@ function createBinaryReadiness(executable, seams = {}) {
2911
3153
  const runs = seams.runs ?? binaryRuns;
2912
3154
  const installBinary = seams.install ?? install;
2913
3155
  const validated = /* @__PURE__ */ new Set();
3156
+ let installs = 0;
3157
+ let lastFailure;
2914
3158
  return async () => {
2915
3159
  if (validated.has(executable)) return;
3160
+ if (installs >= 3 && lastFailure !== void 0) throw lastFailure;
2916
3161
  if (exists(executable) && await runs(executable)) {
2917
3162
  validated.add(executable);
2918
3163
  return;
2919
3164
  }
2920
- await installBinary(executable);
2921
- if (!await runs(executable)) throw new Error(`the cloudflared binary at ${executable} still does not run after the reinstall`);
3165
+ installs += 1;
3166
+ try {
3167
+ await installBinary(executable);
3168
+ if (!await runs(executable)) throw new Error(`the cloudflared binary at ${executable} still does not run after the reinstall`);
3169
+ } catch (error) {
3170
+ lastFailure = error instanceof Error ? error : new Error(String(error));
3171
+ throw lastFailure;
3172
+ }
2922
3173
  validated.add(executable);
2923
3174
  };
2924
3175
  }
@@ -2948,27 +3199,51 @@ function quickTunnelFlags(originHostHeader) {
2948
3199
  };
2949
3200
  }
2950
3201
  /**
2951
- * Command-line arguments for a named tunnel with a persistent Cloudflare token.
2952
- * Note: `--no-autoupdate` and `--protocol` are flags for the `tunnel` command
2953
- * and must appear BEFORE the `run` subcommand. Putting them after `run` causes
2954
- * cloudflared to exit immediately with "flag provided but not defined: -no-autoupdate"
2955
- * (issues #1432, #1433).
3202
+ * Command-line arguments for a named tunnel. The account token is deliberately
3203
+ * NOT part of the argv: `--token <t>` would expose an account credential to any
3204
+ * local user through ps/proc for the whole tunnel lifetime, while the flag's own
3205
+ * environment variable (TUNNEL_TOKEN, see cloudflared's tunnel run flags) is not
3206
+ * readable that way. Note: `--no-autoupdate` and `--protocol` are flags for the
3207
+ * `tunnel` command and must appear BEFORE the `run` subcommand. Putting them
3208
+ * after `run` causes cloudflared to exit immediately with "flag provided but not
3209
+ * defined: -no-autoupdate" (issues #1432, #1433).
2956
3210
  */
2957
- function namedTunnelArgs(token) {
3211
+ function namedTunnelArgs() {
2958
3212
  return [
2959
3213
  "tunnel",
2960
3214
  "--no-autoupdate",
2961
3215
  "--protocol",
2962
3216
  "http2",
2963
- "run",
2964
- "--token",
2965
- token
3217
+ "run"
2966
3218
  ];
2967
3219
  }
3220
+ /**
3221
+ * Run one spawn call with TUNNEL_TOKEN set, restoring the previous value
3222
+ * afterwards. The cloudflared package's Tunnel spawns with the inherited
3223
+ * environment inside its constructor, so the variable only has to exist for
3224
+ * that call; the child keeps its own copy of the environment.
3225
+ * @param token - the named-tunnel token.
3226
+ * @param spawnTunnel - the call that creates the child process.
3227
+ * @returns whatever the spawn call returns.
3228
+ */
3229
+ function withTunnelTokenEnv(token, spawnTunnel) {
3230
+ const previous = process.env.TUNNEL_TOKEN;
3231
+ process.env.TUNNEL_TOKEN = token;
3232
+ try {
3233
+ return spawnTunnel();
3234
+ } finally {
3235
+ if (previous === void 0) delete process.env.TUNNEL_TOKEN;
3236
+ else process.env.TUNNEL_TOKEN = previous;
3237
+ }
3238
+ }
3239
+ /** Spawn one named tunnel without putting its token in the child's argv. */
3240
+ function spawnNamedTunnel(token) {
3241
+ return withTunnelTokenEnv(token, () => new Tunnel(namedTunnelArgs()));
3242
+ }
2968
3243
  /** Default factory: the cloudflared package's quick and named tunnels. */
2969
3244
  function defaultFactory(target) {
2970
3245
  if (target.kind === "quick") return Tunnel.quick(target.targetUrl, quickTunnelFlags(target.originHostHeader));
2971
- return namedTunnelHandle(new Tunnel(namedTunnelArgs(target.token)), target.publicUrl);
3246
+ return namedTunnelHandle(spawnNamedTunnel(target.token), target.publicUrl);
2972
3247
  }
2973
3248
  /** Node timers. */
2974
3249
  const nodeTimer = {
@@ -3266,6 +3541,74 @@ var PublicBaseKeeper = class {
3266
3541
  this.onChange(this.current());
3267
3542
  }
3268
3543
  };
3544
+ //#endregion
3545
+ //#region src/console-output.ts
3546
+ /** Count the U+FFFD replacement characters one candidate decode produced. */
3547
+ function replacementCount(text) {
3548
+ let count = 0;
3549
+ for (const character of text) if (character === "�") count += 1;
3550
+ return count;
3551
+ }
3552
+ /**
3553
+ * Decode one console byte buffer. Strict UTF-8 wins whenever it is valid;
3554
+ * otherwise the bytes are not UTF-8 at all and the console wrote its own code
3555
+ * page, so the candidate that loses the least text is taken.
3556
+ * @param bytes - the captured bytes.
3557
+ * @returns the decoded text.
3558
+ */
3559
+ function decodeConsoleBytes(bytes) {
3560
+ try {
3561
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
3562
+ } catch {
3563
+ let best;
3564
+ let bestLoss = Number.POSITIVE_INFINITY;
3565
+ for (const label of ["gbk", "utf-8"]) {
3566
+ let text;
3567
+ try {
3568
+ text = new TextDecoder(label).decode(bytes);
3569
+ } catch {
3570
+ continue;
3571
+ }
3572
+ const loss = replacementCount(text);
3573
+ if (loss < bestLoss) {
3574
+ best = text;
3575
+ bestLoss = loss;
3576
+ }
3577
+ }
3578
+ /* v8 ignore next 3 -- reachable only without the legacy code page AND without the lenient pass */
3579
+ if (best === void 0) return Buffer.from(bytes).toString("utf8");
3580
+ return best;
3581
+ }
3582
+ }
3583
+ /**
3584
+ * Create a capture bounded to a byte budget. Overflow drops from the FRONT
3585
+ * (the tail is what carries a failure reason), and the cut never lands inside
3586
+ * a character: leading UTF-8 continuation bytes are dropped with it, and a
3587
+ * legacy code page's dangling lead byte — indistinguishable from a real byte
3588
+ * without knowing the encoding until the whole buffer is decoded — is removed
3589
+ * with the one replacement character it produced, so an over-long stream still
3590
+ * reads cleanly at the seam.
3591
+ * @param maxBytes - maximum retained bytes.
3592
+ * @returns the capture.
3593
+ */
3594
+ function createOutputCapture(maxBytes) {
3595
+ let buffer = Buffer.alloc(0);
3596
+ let trimmed = false;
3597
+ return {
3598
+ push(chunk) {
3599
+ buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, Buffer.from(chunk)]);
3600
+ if (buffer.length <= maxBytes) return;
3601
+ let start = buffer.length - maxBytes;
3602
+ while (start < buffer.length && (buffer[start] & 192) === 128) start += 1;
3603
+ buffer = Buffer.from(buffer.subarray(start));
3604
+ trimmed = true;
3605
+ },
3606
+ read() {
3607
+ const text = decodeConsoleBytes(buffer);
3608
+ return trimmed && text.startsWith("�") ? text.slice(1) : text;
3609
+ }
3610
+ };
3611
+ }
3269
3612
  /** The aggregate package that is the canonical update entry point. */
3270
3613
  const AGGREGATE_PACKAGE = "@linxin666/dsh-web-all";
3271
3614
  /** Fallback anchor: this plugin's own package when the aggregate is absent. */
@@ -3439,7 +3782,7 @@ async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
3439
3782
  controller.abort();
3440
3783
  }, timeoutMs);
3441
3784
  try {
3442
- const response = await fetchImpl("https://registry.npmjs.org/" + name.replace("/", "%2F") + "/latest", { signal: controller.signal });
3785
+ const response = await fetchImpl("https://registry.npmjs.org/" + name.replace("/", "%2F") + "/latest", withIdentityEncoding({ signal: controller.signal }));
3443
3786
  if (!response.ok) return void 0;
3444
3787
  const body = await response.json();
3445
3788
  if (typeof body !== "object" || body === null) return void 0;
@@ -3511,13 +3854,13 @@ async function fetchGitHubReleaseNotes(version, fetchImpl = fetch, timeoutMs = 1
3511
3854
  controller.abort();
3512
3855
  }, timeoutMs);
3513
3856
  try {
3514
- const response = await fetchImpl(`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`, {
3857
+ const response = await fetchImpl(`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`, withIdentityEncoding({
3515
3858
  headers: {
3516
3859
  accept: "application/vnd.github+json",
3517
3860
  "user-agent": "dsh-web-update"
3518
3861
  },
3519
3862
  signal: controller.signal
3520
- });
3863
+ }));
3521
3864
  if (!response.ok) return void 0;
3522
3865
  const body = await response.json();
3523
3866
  if (typeof body !== "object" || body === null) return void 0;
@@ -3611,6 +3954,13 @@ async function checkUpdates(deps) {
3611
3954
  /** Cap on captured pnpm output (keeps error payloads bounded). */
3612
3955
  const OUTPUT_CAP = 16 * 1024;
3613
3956
  /**
3957
+ * Characters a package name may contain before it is handed to the update
3958
+ * command line. Deliberately looser than the strict family shape (the tests
3959
+ * and future callers use short names) but it admits no whitespace, quotes,
3960
+ * shell metacharacters, or path separators beyond the scope slash.
3961
+ */
3962
+ const SHELL_SAFE_PACKAGE_RE = /^@?[A-Za-z0-9][A-Za-z0-9._/-]*$/;
3963
+ /**
3614
3964
  * Windows cmd command-not-found stderr. With shell:true a missing shim
3615
3965
  * exits with code 1 or 9009 (cmd cannot report ENOENT), so the fallback chain
3616
3966
  * detects this message instead of the spawn error event. Supports both English
@@ -3618,20 +3968,14 @@ const OUTPUT_CAP = 16 * 1024;
3618
3968
  */
3619
3969
  const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|不是内部或外部命令/i;
3620
3970
  /**
3621
- * Decode process output with tolerant decoding:
3622
- * Defaults to UTF-8; if invalid byte sequences are encountered (common on
3623
- * Windows consoles using code page 936/GBK), falls back to GBK via TextDecoder.
3971
+ * Fold one decoded candidate output into the running total under the display
3972
+ * cap (the tail carries the failure reason).
3973
+ * @param output - the accumulated text.
3974
+ * @param text - the candidate's decoded text.
3624
3975
  */
3625
- function decodeProcessChunk(chunk) {
3626
- try {
3627
- return new TextDecoder("utf-8", { fatal: true }).decode(chunk);
3628
- } catch {
3629
- try {
3630
- return new TextDecoder("gbk").decode(chunk);
3631
- } catch {
3632
- return chunk.toString("utf8");
3633
- }
3634
- }
3976
+ function capOutput(output, text) {
3977
+ const combined = output + text;
3978
+ return combined.length > OUTPUT_CAP ? combined.slice(combined.length - OUTPUT_CAP) : combined;
3635
3979
  }
3636
3980
  /**
3637
3981
  * Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
@@ -3653,8 +3997,8 @@ const MIN_RELEASE_AGE_OVERRIDE = "--config.minimumReleaseAge=0";
3653
3997
  function runUpdate(deps) {
3654
3998
  return new Promise((resolve) => {
3655
3999
  const spawnImpl = deps.spawnImpl ?? spawn;
3656
- const packages = deps.packages;
3657
4000
  const platform = deps.platform ?? process.platform;
4001
+ const packages = deps.packages.filter((name) => SHELL_SAFE_PACKAGE_RE.test(name));
3658
4002
  const spawnOptions = {
3659
4003
  cwd: deps.profileDir,
3660
4004
  stdio: [
@@ -3662,8 +4006,9 @@ function runUpdate(deps) {
3662
4006
  "pipe",
3663
4007
  "pipe"
3664
4008
  ],
3665
- ...platform === "win32" ? { shell: true } : {}
4009
+ ...platform === "win32" ? { shell: true } : { detached: true }
3666
4010
  };
4011
+ const killImpl = deps.killImpl ?? process.kill;
3667
4012
  const candidates = [
3668
4013
  {
3669
4014
  command: "pnpm",
@@ -3697,13 +4042,18 @@ function runUpdate(deps) {
3697
4042
  }
3698
4043
  ];
3699
4044
  let output = "";
3700
- let currentOutput = "";
4045
+ let capture = createOutputCapture(OUTPUT_CAP);
4046
+ let candidateFolded = false;
3701
4047
  const append = (chunk) => {
3702
- const text = decodeProcessChunk(chunk);
3703
- output += text;
3704
- if (output.length > OUTPUT_CAP) output = output.slice(output.length - OUTPUT_CAP);
3705
- currentOutput += text;
3706
- if (currentOutput.length > OUTPUT_CAP) currentOutput = currentOutput.slice(currentOutput.length - OUTPUT_CAP);
4048
+ capture.push(chunk);
4049
+ };
4050
+ /** This candidate's decoded text (idempotent). */
4051
+ const candidateText = () => capture.read();
4052
+ /** Fold this candidate's text into the running output exactly once. */
4053
+ const foldCandidate = () => {
4054
+ if (candidateFolded) return;
4055
+ candidateFolded = true;
4056
+ output = capOutput(output, capture.read());
3707
4057
  };
3708
4058
  let currentChild;
3709
4059
  let finished = false;
@@ -3723,9 +4073,20 @@ function runUpdate(deps) {
3723
4073
  String(pid),
3724
4074
  "/t",
3725
4075
  "/f"
3726
- ], { stdio: "ignore" });
4076
+ ], { stdio: "ignore" }).on?.("error", () => {});
3727
4077
  } catch {}
3728
- } else currentChild?.kill("SIGTERM");
4078
+ } else {
4079
+ const pid = currentChild?.pid;
4080
+ if (pid !== void 0 && pid > 0) try {
4081
+ killImpl(-pid, "SIGTERM");
4082
+ } catch {
4083
+ try {
4084
+ currentChild?.kill("SIGTERM");
4085
+ } catch {}
4086
+ }
4087
+ else currentChild?.kill("SIGTERM");
4088
+ }
4089
+ foldCandidate();
3729
4090
  finish({
3730
4091
  ok: false,
3731
4092
  exitCode: null,
@@ -3747,7 +4108,8 @@ function runUpdate(deps) {
3747
4108
  return;
3748
4109
  }
3749
4110
  const candidate = candidates[index];
3750
- currentOutput = "";
4111
+ capture = createOutputCapture(OUTPUT_CAP);
4112
+ candidateFolded = false;
3751
4113
  const child = spawnImpl(candidate.command, candidate.args, spawnOptions);
3752
4114
  currentChild = child;
3753
4115
  let settled = false;
@@ -3760,6 +4122,7 @@ function runUpdate(deps) {
3760
4122
  child.stderr?.on("data", append);
3761
4123
  child.on("error", (error) => {
3762
4124
  once(() => {
4125
+ foldCandidate();
3763
4126
  if (error.code === "ENOENT") runCandidate(index + 1);
3764
4127
  else finish({
3765
4128
  ok: false,
@@ -3772,11 +4135,13 @@ function runUpdate(deps) {
3772
4135
  });
3773
4136
  child.on("close", (code) => {
3774
4137
  if (settled || finished) return;
3775
- if (platform === "win32" && (code === 9009 || code !== 0 && WIN_CMD_MISSING_RE.test(currentOutput))) {
4138
+ if (platform === "win32" && (code === 9009 || code !== 0 && WIN_CMD_MISSING_RE.test(candidateText()))) {
4139
+ foldCandidate();
3776
4140
  runCandidate(index + 1);
3777
4141
  return;
3778
4142
  }
3779
4143
  settled = true;
4144
+ foldCandidate();
3780
4145
  finish({
3781
4146
  ok: code === 0,
3782
4147
  exitCode: code,
@@ -3949,30 +4314,6 @@ function mountOnce(packageName, fn) {
3949
4314
  return fn(...args);
3950
4315
  });
3951
4316
  }
3952
- /** The live rule set. */
3953
- const REMOTE_CHANNEL_RULES = {
3954
- remotePrefix: "/remote",
3955
- apiPrefix: "/api/",
3956
- pairPrefix: "/api/pair/",
3957
- updatePrefix: "/api/update/",
3958
- settingsBridgePrefix: "/api/dsh-web-ui-settings",
3959
- sidebarPrefix: "/sidebar/",
3960
- gitPrefix: "/git/",
3961
- petPrefix: "/pet/",
3962
- wsPaths: [
3963
- "/api/remote.mux",
3964
- "/sidebar/ws/terminal",
3965
- "/sidebar/ws/agent-terminals",
3966
- "/api/dsh-ssh/terminal"
3967
- ],
3968
- deviceHeader: REMOTE_DEVICE_HEADER,
3969
- deviceKey: "dsh-remote-device",
3970
- deviceQuery: REMOTE_DEVICE_QUERY,
3971
- uploadPath: "/api/session/uploadFileBinary",
3972
- uploadHookGlobal: "__DSH_FILE_UPLOAD__"
3973
- };
3974
- /** The window global the boot patch publishes its seat under. */
3975
- const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
3976
4317
  //#endregion
3977
4318
  //#region src/remote-channel-boot.ts
3978
4319
  /**
@@ -3994,7 +4335,12 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
3994
4335
  * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
3995
4336
  * connection plugin reads it: the paired remote desktop gets the full
3996
4337
  * settings/credentials/presets surface, and every call still rides the
3997
- * gated /remote channel. It finally publishes the official pre-Cordis
4338
+ * gated /remote channel. Host mode is server-granted, not origin-asserted:
4339
+ * the hook is installed only when the device-gated app landing (/pair-app)
4340
+ * published the grant marker (REMOTE_HOST_GRANT_GLOBAL) ahead of this
4341
+ * script, so a shell served to an unpaired browser - a fence-open
4342
+ * deployment - never presents itself as the machine owner. It finally
4343
+ * publishes the official pre-Cordis
3998
4344
  * upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
3999
4345
  * transport otherwise runs inside a Web Worker whose own globals the
4000
4346
  * main-thread rewrite cannot reach (issue #1580). The script self-skips on
@@ -4009,7 +4355,7 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
4009
4355
  function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
4010
4356
  const json = JSON.stringify(rules);
4011
4357
  const seat = JSON.stringify(REMOTE_CHANNEL_BOOT_GLOBAL);
4012
- return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}var R=" + json + ";function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}function att(init){var dv=rdv();if(dv===null)return init;var h=init&&init.headers;if(typeof Headers!==\"undefined\"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}if(typeof h===\"object\"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}return init}function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,att(init))).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);function uf(u,init){var raw=typeof u===\"string\"?u:u.href;if(typeof raw!==\"string\")return of.call(w,u,init||{});var q=new URL(raw,loc.href);if(so(q)&&q.pathname===R.uploadPath)return w.fetch(raw,init||{});return of.call(w,u,init||{})}try{if(w[R.uploadHookGlobal]===undefined)w[R.uploadHookGlobal]={fetch:uf}}catch(e){}seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat" + buildBootWatchdogScript() + "}catch(e){}})();";
4358
+ return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;var R=" + json + ";try{if(w[R.hostGrantGlobal]===true){if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}}catch(e){}function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}function att(init){var dv=rdv();if(dv===null)return init;var h=init&&init.headers;if(typeof Headers!==\"undefined\"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}if(typeof h===\"object\"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}return init}function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,att(init))).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);function uf(u,init){var raw=typeof u===\"string\"?u:u.href;if(typeof raw!==\"string\")return of.call(w,u,init||{});var q=new URL(raw,loc.href);if(so(q)&&q.pathname===R.uploadPath)return w.fetch(raw,init||{});return of.call(w,u,init||{})}try{if(w[R.uploadHookGlobal]===undefined)w[R.uploadHookGlobal]={fetch:uf}}catch(e){}seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat" + buildBootWatchdogScript() + "}catch(e){}})();";
4013
4359
  }
4014
4360
  /** Boot-watchdog latch: one self-reload per session while the boot is broken. */
4015
4361
  const BOOT_WATCHDOG_KEY = "dsh-remote-boot-reload";
@@ -4081,9 +4427,12 @@ const inject = [
4081
4427
  "connection"
4082
4428
  ];
4083
4429
  /**
4084
- * Settings namespace of the remote-control capability — the section the web
4085
- * settings surface edits. Spelled here rather than imported: the browser
4086
- * half spells the same value and must not depend on a Host package.
4430
+ * Settings namespace of the remote-control capability. Under the 0.1.7
4431
+ * settings model the namespace IS the Host profile entry id, so this is also
4432
+ * the id the browser half asks `ctx.configForms` for (the family binder
4433
+ * resolves the same value to the owning entry). Spelled here rather than
4434
+ * imported: the browser half spells the same value and must not depend on a
4435
+ * Host package.
4087
4436
  */
4088
4437
  const REMOTE_WEB_UI_SETTINGS_NAMESPACE = "remote-web-ui";
4089
4438
  const Config = z.object({
@@ -4106,10 +4455,15 @@ const Config = z.object({
4106
4455
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
4107
4456
  const SWEEP_INTERVAL_MS = 1e4;
4108
4457
  /**
4458
+ * How long one update-status probe answer is reused. The probe fans out one
4459
+ * registry GET per family package plus a GitHub call, and the sidebar update
4460
+ * entry asks for the status on every GUI page load.
4461
+ */
4462
+ const UPDATE_STATUS_TTL_MS = 6e4;
4463
+ /**
4109
4464
  * The single mapping from resolved plugin config to the pairing service
4110
- * config. Both the constructed service and every live settings sync reuse
4111
- * it, so no field can be silently dropped when the web settings surface
4112
- * pushes a new value into the running service.
4465
+ * config. Both the constructed service and every later apply of this row
4466
+ * reuse it, so no field can be silently dropped.
4113
4467
  */
4114
4468
  function pairingConfigOf(resolved) {
4115
4469
  return {
@@ -4168,27 +4522,7 @@ function applyImpl(ctx, config) {
4168
4522
  profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
4169
4523
  enabled: config?.enabled ?? DEFAULTS.enabled
4170
4524
  };
4171
- let current = () => config ?? {};
4172
- const resolve = () => {
4173
- const value = current();
4174
- return {
4175
- tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
4176
- offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
4177
- maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
4178
- idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
4179
- cookieName: value.cookieName ?? DEFAULTS.cookieName,
4180
- requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
4181
- publicBaseUrl: value.publicBaseUrl ?? envPublicBase,
4182
- trustedHosts: value.trustedHosts,
4183
- devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
4184
- autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
4185
- tunnelToken: value.tunnelToken,
4186
- relay: value.relay ?? DEFAULTS.relay,
4187
- lanBind: value.lanBind,
4188
- profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
4189
- enabled: value.enabled ?? DEFAULTS.enabled
4190
- };
4191
- };
4525
+ const resolve = () => resolved;
4192
4526
  const service = new PairingService(pairingConfigOf(resolved));
4193
4527
  const tunnel = new TunnelManager();
4194
4528
  let relayRegistrar;
@@ -4288,14 +4622,31 @@ function applyImpl(ctx, config) {
4288
4622
  });
4289
4623
  return notes;
4290
4624
  };
4291
- const updateRoutes = makeUpdateRoutes({
4292
- fence: (request) => isTrustedApiRequest(request, []),
4293
- check: () => checkUpdates({
4625
+ let updateStatusCache;
4626
+ let updateStatusInFlight;
4627
+ const checkStatusCached = () => {
4628
+ const cached = updateStatusCache;
4629
+ if (cached !== void 0 && Date.now() - cached.at < UPDATE_STATUS_TTL_MS) return Promise.resolve(cached.value);
4630
+ if (updateStatusInFlight !== void 0) return updateStatusInFlight;
4631
+ updateStatusInFlight = checkUpdates({
4294
4632
  anchorManifestPath: resolveAnchorPath(),
4295
4633
  resolve: hostResolve,
4296
4634
  fetchLatest: (name) => fetchLatestVersion(name, fetch),
4297
4635
  fetchReleaseNotes: fetchReleaseNotesCached
4298
- }),
4636
+ }).then((value) => {
4637
+ updateStatusCache = {
4638
+ at: Date.now(),
4639
+ value
4640
+ };
4641
+ return value;
4642
+ }).finally(() => {
4643
+ updateStatusInFlight = void 0;
4644
+ });
4645
+ return updateStatusInFlight;
4646
+ };
4647
+ const updateRoutes = makeUpdateRoutes({
4648
+ fence: (request) => isTrustedApiRequest(request, []),
4649
+ check: () => checkStatusCached(),
4299
4650
  run: async () => {
4300
4651
  const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() });
4301
4652
  if ("error" in target) {
@@ -4308,7 +4659,7 @@ function applyImpl(ctx, config) {
4308
4659
  errorCode: code
4309
4660
  };
4310
4661
  }
4311
- return runUpdateVerified({
4662
+ const result = await runUpdateVerified({
4312
4663
  run: {
4313
4664
  profileDir: target.profileDir,
4314
4665
  packages: target.packages
@@ -4320,6 +4671,8 @@ function applyImpl(ctx, config) {
4320
4671
  fetchReleaseNotes: fetchReleaseNotesCached
4321
4672
  }
4322
4673
  });
4674
+ updateStatusCache = void 0;
4675
+ return result;
4323
4676
  }
4324
4677
  });
4325
4678
  let lastKnownPort;
@@ -4367,8 +4720,11 @@ function applyImpl(ctx, config) {
4367
4720
  if (appShellCache !== void 0 && Date.now() - appShellCache.at < APP_SHELL_TTL_MS) return appShellCache.html;
4368
4721
  const cookie = await innerAuth.ready();
4369
4722
  try {
4370
- const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, { headers: cookie !== void 0 ? { cookie } : void 0 });
4371
- if (!response.ok) return void 0;
4723
+ const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, withIdentityEncoding({ headers: cookie !== void 0 ? { cookie } : void 0 }));
4724
+ if (!response.ok) {
4725
+ if (response.status === 401 || response.status === 403) innerAuth.invalidate();
4726
+ return;
4727
+ }
4372
4728
  const html = await response.text();
4373
4729
  appShellCache = {
4374
4730
  at: Date.now(),
@@ -4405,7 +4761,6 @@ function applyImpl(ctx, config) {
4405
4761
  ...makeRemoteApiRoutes({
4406
4762
  service,
4407
4763
  port: ctx.webServer.port,
4408
- requirePairingForLan: () => resolve().requirePairingForLan,
4409
4764
  auth: innerAuth
4410
4765
  }),
4411
4766
  ...updateRoutes
@@ -4413,7 +4768,6 @@ function applyImpl(ctx, config) {
4413
4768
  const upgrades = makeRemoteApiUpgradeRoutes({
4414
4769
  service,
4415
4770
  port: ctx.webServer.port,
4416
- requirePairingForLan: () => resolve().requirePairingForLan,
4417
4771
  auth: innerAuth
4418
4772
  });
4419
4773
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
@@ -4524,12 +4878,14 @@ function applyImpl(ctx, config) {
4524
4878
  targetUrl: plan.targetUrl,
4525
4879
  originHostHeader
4526
4880
  });
4527
- } else if (plan.mode === "named") tunnel.start({
4528
- kind: "named",
4529
- token: plan.token,
4530
- publicUrl: plan.publicUrl
4531
- });
4532
- else {
4881
+ } else if (plan.mode === "named") {
4882
+ publicBase.markRunning(plan.publicUrl);
4883
+ tunnel.start({
4884
+ kind: "named",
4885
+ token: plan.token,
4886
+ publicUrl: plan.publicUrl
4887
+ });
4888
+ } else {
4533
4889
  tunnel.stop();
4534
4890
  if (value.tunnelToken !== void 0 && value.tunnelToken !== "") console.warn("remote-web-ui: tunnelToken is set but publicBaseUrl is missing or not a valid URL — fill the fixed public hostname of the named tunnel (e.g. https://dsh.example.com) to run it");
4535
4891
  if (value.publicBaseUrl !== void 0 && !isHttpUrl(value.publicBaseUrl)) {
@@ -4538,7 +4894,12 @@ function applyImpl(ctx, config) {
4538
4894
  } else service.setPublicBaseUrl(value.publicBaseUrl);
4539
4895
  }
4540
4896
  const enabled = value.enabled;
4541
- if (!enabled) service.stop();
4897
+ if (!enabled) {
4898
+ service.stop();
4899
+ tunnel.stop();
4900
+ disposeRelayRegistrar();
4901
+ publicBase.reset();
4902
+ }
4542
4903
  if (disposeRoutes === void 0 && enabled) disposeRoutes = ctx.effect(() => {
4543
4904
  const disposers = [...routes.map((route) => ctx.webServer.register(route)), ...upgrades.map((route) => ctx.webServer.registerUpgrade(route))];
4544
4905
  return () => {
@@ -4580,25 +4941,6 @@ function applyImpl(ctx, config) {
4580
4941
  text: REMOTE_CHANNEL_BOOT_SCRIPT
4581
4942
  });
4582
4943
  }), "remote-web-ui: remote channel boot patch");
4583
- ctx.inject(["settings"], (settingsCtx) => {
4584
- try {
4585
- if (typeof settingsCtx.settings?.installSection === "function") settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
4586
- setSource: (source) => {
4587
- current = source;
4588
- sync();
4589
- },
4590
- onChange: sync
4591
- });
4592
- else if (typeof settingsCtx.settings?.register === "function") {
4593
- const scope = settingsCtx.settings.register(REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, { base: config ?? {} });
4594
- current = () => scope?.get?.() ?? config ?? {};
4595
- scope?.watch?.(() => {
4596
- sync();
4597
- });
4598
- sync();
4599
- }
4600
- } catch {}
4601
- });
4602
4944
  sync();
4603
4945
  }
4604
4946
  //#endregion