@linxin666/dsh-remote-web-ui 0.3.24 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) 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 +263 -129
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +565 -256
  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 +47 -18
  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 +103 -22
  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 +23 -16
  35. package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
  36. package/lib/types/client/plugin-card-seat.js +16 -14
  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/http.d.ts +18 -1
  43. package/lib/types/http.d.ts.map +1 -1
  44. package/lib/types/http.js +22 -1
  45. package/lib/types/index.d.ts +19 -11
  46. package/lib/types/index.d.ts.map +1 -1
  47. package/lib/types/index.js +91 -76
  48. package/lib/types/inner-auth.d.ts +3 -2
  49. package/lib/types/inner-auth.d.ts.map +1 -1
  50. package/lib/types/inner-auth.js +3 -2
  51. package/lib/types/pair-grant.d.ts +66 -0
  52. package/lib/types/pair-grant.d.ts.map +1 -0
  53. package/lib/types/pair-grant.js +95 -0
  54. package/lib/types/pairing.d.ts +44 -12
  55. package/lib/types/pairing.d.ts.map +1 -1
  56. package/lib/types/pairing.js +69 -20
  57. package/lib/types/pairing.persist.test.js +26 -0
  58. package/lib/types/posture.d.ts +11 -3
  59. package/lib/types/posture.d.ts.map +1 -1
  60. package/lib/types/posture.js +8 -3
  61. package/lib/types/remote-api.d.ts +10 -13
  62. package/lib/types/remote-api.d.ts.map +1 -1
  63. package/lib/types/remote-api.js +34 -34
  64. package/lib/types/remote-channel-boot.d.ts +6 -1
  65. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  66. package/lib/types/remote-channel-boot.js +16 -8
  67. package/lib/types/remote-channel-rules.d.ts +14 -0
  68. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  69. package/lib/types/remote-channel-rules.js +8 -0
  70. package/lib/types/remote-methods.d.ts +1 -1
  71. package/lib/types/remote-methods.d.ts.map +1 -1
  72. package/lib/types/remote-methods.js +1 -0
  73. package/lib/types/routes.d.ts +47 -5
  74. package/lib/types/routes.d.ts.map +1 -1
  75. package/lib/types/routes.js +160 -58
  76. package/lib/types/tunnel.d.ts +21 -6
  77. package/lib/types/tunnel.d.ts.map +1 -1
  78. package/lib/types/tunnel.js +58 -11
  79. package/lib/types/update.d.ts +4 -0
  80. package/lib/types/update.d.ts.map +1 -1
  81. package/lib/types/update.js +74 -26
  82. package/package.json +23 -20
  83. package/src/client/FenceNotice.tsx +0 -2
  84. package/src/client/PluginSettingsCard.tsx +7 -2
  85. package/src/client/RemoteEntry.tsx +14 -9
  86. package/src/client/RemotePanel.tsx +17 -7
  87. package/src/client/RemoteSettingsCard.tsx +16 -9
  88. package/src/client/UpdateEntry.tsx +2 -2
  89. package/src/client/UpdatePanel.tsx +3 -3
  90. package/src/client/deep-link.ts +2 -2
  91. package/src/client/index.ts +61 -27
  92. package/src/client/locales.ts +6 -8
  93. package/src/client/mobile-adapt.ts +97 -20
  94. package/src/client/pair-api.ts +47 -7
  95. package/src/client/plugin-card-seat.ts +24 -17
  96. package/src/client/remote-channel.ts +19 -4
  97. package/src/client/remote.module.css +38 -0
  98. package/src/client/settings-form.ts +34 -31
  99. package/src/http.ts +23 -1
  100. package/src/index.ts +102 -79
  101. package/src/inner-auth.ts +3 -2
  102. package/src/pair-grant.ts +131 -0
  103. package/src/pairing.persist.test.ts +36 -0
  104. package/src/pairing.ts +75 -24
  105. package/src/posture.ts +12 -4
  106. package/src/remote-api.ts +34 -41
  107. package/src/remote-channel-boot.ts +16 -8
  108. package/src/remote-channel-rules.ts +17 -0
  109. package/src/remote-methods.ts +1 -0
  110. package/src/routes.ts +165 -57
  111. package/src/tunnel.ts +56 -11
  112. package/src/update.ts +74 -24
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 = {
@@ -3305,6 +3580,35 @@ function decodeConsoleBytes(bytes) {
3305
3580
  return best;
3306
3581
  }
3307
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
+ }
3308
3612
  /** The aggregate package that is the canonical update entry point. */
3309
3613
  const AGGREGATE_PACKAGE = "@linxin666/dsh-web-all";
3310
3614
  /** Fallback anchor: this plugin's own package when the aggregate is absent. */
@@ -3478,7 +3782,7 @@ async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
3478
3782
  controller.abort();
3479
3783
  }, timeoutMs);
3480
3784
  try {
3481
- 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 }));
3482
3786
  if (!response.ok) return void 0;
3483
3787
  const body = await response.json();
3484
3788
  if (typeof body !== "object" || body === null) return void 0;
@@ -3550,13 +3854,13 @@ async function fetchGitHubReleaseNotes(version, fetchImpl = fetch, timeoutMs = 1
3550
3854
  controller.abort();
3551
3855
  }, timeoutMs);
3552
3856
  try {
3553
- 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({
3554
3858
  headers: {
3555
3859
  accept: "application/vnd.github+json",
3556
3860
  "user-agent": "dsh-web-update"
3557
3861
  },
3558
3862
  signal: controller.signal
3559
- });
3863
+ }));
3560
3864
  if (!response.ok) return void 0;
3561
3865
  const body = await response.json();
3562
3866
  if (typeof body !== "object" || body === null) return void 0;
@@ -3650,6 +3954,13 @@ async function checkUpdates(deps) {
3650
3954
  /** Cap on captured pnpm output (keeps error payloads bounded). */
3651
3955
  const OUTPUT_CAP = 16 * 1024;
3652
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
+ /**
3653
3964
  * Windows cmd command-not-found stderr. With shell:true a missing shim
3654
3965
  * exits with code 1 or 9009 (cmd cannot report ENOENT), so the fallback chain
3655
3966
  * detects this message instead of the spawn error event. Supports both English
@@ -3657,14 +3968,14 @@ const OUTPUT_CAP = 16 * 1024;
3657
3968
  */
3658
3969
  const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|不是内部或外部命令/i;
3659
3970
  /**
3660
- * Decode process output with tolerant decoding:
3661
- * Defaults to UTF-8; if invalid byte sequences are encountered (common on
3662
- * Windows consoles using code page 936/GBK), falls back to the code page that
3663
- * loses the least text. Bytes are decoded once per whole chunk by the caller's
3664
- * accumulator — decoding per \`data\` event would split multi-byte characters.
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.
3665
3975
  */
3666
- function decodeProcessChunk(chunk) {
3667
- return decodeConsoleBytes(chunk);
3976
+ function capOutput(output, text) {
3977
+ const combined = output + text;
3978
+ return combined.length > OUTPUT_CAP ? combined.slice(combined.length - OUTPUT_CAP) : combined;
3668
3979
  }
3669
3980
  /**
3670
3981
  * Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
@@ -3686,8 +3997,8 @@ const MIN_RELEASE_AGE_OVERRIDE = "--config.minimumReleaseAge=0";
3686
3997
  function runUpdate(deps) {
3687
3998
  return new Promise((resolve) => {
3688
3999
  const spawnImpl = deps.spawnImpl ?? spawn;
3689
- const packages = deps.packages;
3690
4000
  const platform = deps.platform ?? process.platform;
4001
+ const packages = deps.packages.filter((name) => SHELL_SAFE_PACKAGE_RE.test(name));
3691
4002
  const spawnOptions = {
3692
4003
  cwd: deps.profileDir,
3693
4004
  stdio: [
@@ -3695,8 +4006,9 @@ function runUpdate(deps) {
3695
4006
  "pipe",
3696
4007
  "pipe"
3697
4008
  ],
3698
- ...platform === "win32" ? { shell: true } : {}
4009
+ ...platform === "win32" ? { shell: true } : { detached: true }
3699
4010
  };
4011
+ const killImpl = deps.killImpl ?? process.kill;
3700
4012
  const candidates = [
3701
4013
  {
3702
4014
  command: "pnpm",
@@ -3730,13 +4042,18 @@ function runUpdate(deps) {
3730
4042
  }
3731
4043
  ];
3732
4044
  let output = "";
3733
- let currentOutput = "";
4045
+ let capture = createOutputCapture(OUTPUT_CAP);
4046
+ let candidateFolded = false;
3734
4047
  const append = (chunk) => {
3735
- const text = decodeProcessChunk(chunk);
3736
- output += text;
3737
- if (output.length > OUTPUT_CAP) output = output.slice(output.length - OUTPUT_CAP);
3738
- currentOutput += text;
3739
- 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());
3740
4057
  };
3741
4058
  let currentChild;
3742
4059
  let finished = false;
@@ -3756,9 +4073,20 @@ function runUpdate(deps) {
3756
4073
  String(pid),
3757
4074
  "/t",
3758
4075
  "/f"
3759
- ], { stdio: "ignore" });
4076
+ ], { stdio: "ignore" }).on?.("error", () => {});
3760
4077
  } catch {}
3761
- } 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();
3762
4090
  finish({
3763
4091
  ok: false,
3764
4092
  exitCode: null,
@@ -3780,7 +4108,8 @@ function runUpdate(deps) {
3780
4108
  return;
3781
4109
  }
3782
4110
  const candidate = candidates[index];
3783
- currentOutput = "";
4111
+ capture = createOutputCapture(OUTPUT_CAP);
4112
+ candidateFolded = false;
3784
4113
  const child = spawnImpl(candidate.command, candidate.args, spawnOptions);
3785
4114
  currentChild = child;
3786
4115
  let settled = false;
@@ -3793,6 +4122,7 @@ function runUpdate(deps) {
3793
4122
  child.stderr?.on("data", append);
3794
4123
  child.on("error", (error) => {
3795
4124
  once(() => {
4125
+ foldCandidate();
3796
4126
  if (error.code === "ENOENT") runCandidate(index + 1);
3797
4127
  else finish({
3798
4128
  ok: false,
@@ -3805,11 +4135,13 @@ function runUpdate(deps) {
3805
4135
  });
3806
4136
  child.on("close", (code) => {
3807
4137
  if (settled || finished) return;
3808
- 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();
3809
4140
  runCandidate(index + 1);
3810
4141
  return;
3811
4142
  }
3812
4143
  settled = true;
4144
+ foldCandidate();
3813
4145
  finish({
3814
4146
  ok: code === 0,
3815
4147
  exitCode: code,
@@ -3982,30 +4314,6 @@ function mountOnce(packageName, fn) {
3982
4314
  return fn(...args);
3983
4315
  });
3984
4316
  }
3985
- /** The live rule set. */
3986
- const REMOTE_CHANNEL_RULES = {
3987
- remotePrefix: "/remote",
3988
- apiPrefix: "/api/",
3989
- pairPrefix: "/api/pair/",
3990
- updatePrefix: "/api/update/",
3991
- settingsBridgePrefix: "/api/dsh-web-ui-settings",
3992
- sidebarPrefix: "/sidebar/",
3993
- gitPrefix: "/git/",
3994
- petPrefix: "/pet/",
3995
- wsPaths: [
3996
- "/api/remote.mux",
3997
- "/sidebar/ws/terminal",
3998
- "/sidebar/ws/agent-terminals",
3999
- "/api/dsh-ssh/terminal"
4000
- ],
4001
- deviceHeader: REMOTE_DEVICE_HEADER,
4002
- deviceKey: "dsh-remote-device",
4003
- deviceQuery: REMOTE_DEVICE_QUERY,
4004
- uploadPath: "/api/session/uploadFileBinary",
4005
- uploadHookGlobal: "__DSH_FILE_UPLOAD__"
4006
- };
4007
- /** The window global the boot patch publishes its seat under. */
4008
- const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
4009
4317
  //#endregion
4010
4318
  //#region src/remote-channel-boot.ts
4011
4319
  /**
@@ -4027,7 +4335,12 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
4027
4335
  * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
4028
4336
  * connection plugin reads it: the paired remote desktop gets the full
4029
4337
  * settings/credentials/presets surface, and every call still rides the
4030
- * 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
4031
4344
  * upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
4032
4345
  * transport otherwise runs inside a Web Worker whose own globals the
4033
4346
  * main-thread rewrite cannot reach (issue #1580). The script self-skips on
@@ -4042,7 +4355,7 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
4042
4355
  function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
4043
4356
  const json = JSON.stringify(rules);
4044
4357
  const seat = JSON.stringify(REMOTE_CHANNEL_BOOT_GLOBAL);
4045
- 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){}})();";
4046
4359
  }
4047
4360
  /** Boot-watchdog latch: one self-reload per session while the boot is broken. */
4048
4361
  const BOOT_WATCHDOG_KEY = "dsh-remote-boot-reload";
@@ -4114,9 +4427,12 @@ const inject = [
4114
4427
  "connection"
4115
4428
  ];
4116
4429
  /**
4117
- * Settings namespace of the remote-control capability — the section the web
4118
- * settings surface edits. Spelled here rather than imported: the browser
4119
- * 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.
4120
4436
  */
4121
4437
  const REMOTE_WEB_UI_SETTINGS_NAMESPACE = "remote-web-ui";
4122
4438
  const Config = z.object({
@@ -4139,10 +4455,15 @@ const Config = z.object({
4139
4455
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
4140
4456
  const SWEEP_INTERVAL_MS = 1e4;
4141
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
+ /**
4142
4464
  * The single mapping from resolved plugin config to the pairing service
4143
- * config. Both the constructed service and every live settings sync reuse
4144
- * it, so no field can be silently dropped when the web settings surface
4145
- * 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.
4146
4467
  */
4147
4468
  function pairingConfigOf(resolved) {
4148
4469
  return {
@@ -4201,27 +4522,7 @@ function applyImpl(ctx, config) {
4201
4522
  profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
4202
4523
  enabled: config?.enabled ?? DEFAULTS.enabled
4203
4524
  };
4204
- let current = () => config ?? {};
4205
- const resolve = () => {
4206
- const value = current();
4207
- return {
4208
- tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
4209
- offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
4210
- maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
4211
- idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
4212
- cookieName: value.cookieName ?? DEFAULTS.cookieName,
4213
- requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
4214
- publicBaseUrl: value.publicBaseUrl ?? envPublicBase,
4215
- trustedHosts: value.trustedHosts,
4216
- devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
4217
- autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
4218
- tunnelToken: value.tunnelToken,
4219
- relay: value.relay ?? DEFAULTS.relay,
4220
- lanBind: value.lanBind,
4221
- profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
4222
- enabled: value.enabled ?? DEFAULTS.enabled
4223
- };
4224
- };
4525
+ const resolve = () => resolved;
4225
4526
  const service = new PairingService(pairingConfigOf(resolved));
4226
4527
  const tunnel = new TunnelManager();
4227
4528
  let relayRegistrar;
@@ -4321,14 +4622,31 @@ function applyImpl(ctx, config) {
4321
4622
  });
4322
4623
  return notes;
4323
4624
  };
4324
- const updateRoutes = makeUpdateRoutes({
4325
- fence: (request) => isTrustedApiRequest(request, []),
4326
- 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({
4327
4632
  anchorManifestPath: resolveAnchorPath(),
4328
4633
  resolve: hostResolve,
4329
4634
  fetchLatest: (name) => fetchLatestVersion(name, fetch),
4330
4635
  fetchReleaseNotes: fetchReleaseNotesCached
4331
- }),
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(),
4332
4650
  run: async () => {
4333
4651
  const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() });
4334
4652
  if ("error" in target) {
@@ -4341,7 +4659,7 @@ function applyImpl(ctx, config) {
4341
4659
  errorCode: code
4342
4660
  };
4343
4661
  }
4344
- return runUpdateVerified({
4662
+ const result = await runUpdateVerified({
4345
4663
  run: {
4346
4664
  profileDir: target.profileDir,
4347
4665
  packages: target.packages
@@ -4353,6 +4671,8 @@ function applyImpl(ctx, config) {
4353
4671
  fetchReleaseNotes: fetchReleaseNotesCached
4354
4672
  }
4355
4673
  });
4674
+ updateStatusCache = void 0;
4675
+ return result;
4356
4676
  }
4357
4677
  });
4358
4678
  let lastKnownPort;
@@ -4400,8 +4720,11 @@ function applyImpl(ctx, config) {
4400
4720
  if (appShellCache !== void 0 && Date.now() - appShellCache.at < APP_SHELL_TTL_MS) return appShellCache.html;
4401
4721
  const cookie = await innerAuth.ready();
4402
4722
  try {
4403
- const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, { headers: cookie !== void 0 ? { cookie } : void 0 });
4404
- 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
+ }
4405
4728
  const html = await response.text();
4406
4729
  appShellCache = {
4407
4730
  at: Date.now(),
@@ -4438,7 +4761,6 @@ function applyImpl(ctx, config) {
4438
4761
  ...makeRemoteApiRoutes({
4439
4762
  service,
4440
4763
  port: ctx.webServer.port,
4441
- requirePairingForLan: () => resolve().requirePairingForLan,
4442
4764
  auth: innerAuth
4443
4765
  }),
4444
4766
  ...updateRoutes
@@ -4446,7 +4768,6 @@ function applyImpl(ctx, config) {
4446
4768
  const upgrades = makeRemoteApiUpgradeRoutes({
4447
4769
  service,
4448
4770
  port: ctx.webServer.port,
4449
- requirePairingForLan: () => resolve().requirePairingForLan,
4450
4771
  auth: innerAuth
4451
4772
  });
4452
4773
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
@@ -4557,12 +4878,14 @@ function applyImpl(ctx, config) {
4557
4878
  targetUrl: plan.targetUrl,
4558
4879
  originHostHeader
4559
4880
  });
4560
- } else if (plan.mode === "named") tunnel.start({
4561
- kind: "named",
4562
- token: plan.token,
4563
- publicUrl: plan.publicUrl
4564
- });
4565
- 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 {
4566
4889
  tunnel.stop();
4567
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");
4568
4891
  if (value.publicBaseUrl !== void 0 && !isHttpUrl(value.publicBaseUrl)) {
@@ -4571,7 +4894,12 @@ function applyImpl(ctx, config) {
4571
4894
  } else service.setPublicBaseUrl(value.publicBaseUrl);
4572
4895
  }
4573
4896
  const enabled = value.enabled;
4574
- if (!enabled) service.stop();
4897
+ if (!enabled) {
4898
+ service.stop();
4899
+ tunnel.stop();
4900
+ disposeRelayRegistrar();
4901
+ publicBase.reset();
4902
+ }
4575
4903
  if (disposeRoutes === void 0 && enabled) disposeRoutes = ctx.effect(() => {
4576
4904
  const disposers = [...routes.map((route) => ctx.webServer.register(route)), ...upgrades.map((route) => ctx.webServer.registerUpgrade(route))];
4577
4905
  return () => {
@@ -4613,25 +4941,6 @@ function applyImpl(ctx, config) {
4613
4941
  text: REMOTE_CHANNEL_BOOT_SCRIPT
4614
4942
  });
4615
4943
  }), "remote-web-ui: remote channel boot patch");
4616
- ctx.inject(["settings"], (settingsCtx) => {
4617
- try {
4618
- if (typeof settingsCtx.settings?.installSection === "function") settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
4619
- setSource: (source) => {
4620
- current = source;
4621
- sync();
4622
- },
4623
- onChange: sync
4624
- });
4625
- else if (typeof settingsCtx.settings?.register === "function") {
4626
- const scope = settingsCtx.settings.register(REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, { base: config ?? {} });
4627
- current = () => scope?.get?.() ?? config ?? {};
4628
- scope?.watch?.(() => {
4629
- sync();
4630
- });
4631
- sync();
4632
- }
4633
- } catch {}
4634
- });
4635
4944
  sync();
4636
4945
  }
4637
4946
  //#endregion