@linxin666/dsh-remote-web-ui 0.3.14 → 0.3.15

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.
@@ -236,7 +236,6 @@ const ADAPT_CSS: readonly string[] = [
236
236
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"task-board\"],`,
237
237
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"git-graph\"],`,
238
238
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"pet\"],`,
239
- `body.${ACTIVE_CLASS} [data-dsh-plugin=\"perf\"],`,
240
239
  `body.${ACTIVE_CLASS} [data-dsh-plugin=\"usage\"]{display:none !important}`,
241
240
  // The official workbench (Files / source control) mounts into a
242
241
  // full-viewport portal layer with no data-dsh-plugin root, so the plugin
@@ -921,15 +920,11 @@ export function startMobileAdapt(): void {
921
920
  }
922
921
  lanOrigFocus.call(this, options)
923
922
  }
924
- let lastComposerTapRegistered = false
925
- if (!lastComposerTapRegistered) {
926
- lastComposerTapRegistered = true
927
- document.addEventListener('pointerdown', (e) => {
928
- if (!active) return
929
- const t = e.target
930
- if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') && t.closest('[class$="_composerSeat"]') !== null) lastComposerTap = Date.now()
931
- }, true)
932
- }
923
+ document.addEventListener('pointerdown', (e) => {
924
+ if (!active) return
925
+ const t = e.target
926
+ if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') && t.closest('[class$="_composerSeat"]') !== null) lastComposerTap = Date.now()
927
+ }, true)
933
928
 
934
929
  // The plugin apply() wires toggleSidebar/closeDetails to ctx.layout once
935
930
  // it is live, and flips the master switch when the settings snapshot
package/src/index.ts CHANGED
@@ -126,6 +126,13 @@ export interface Config {
126
126
  * warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
127
127
  */
128
128
  publicBaseUrl?: string
129
+ /**
130
+ * Extra trusted host authorities (exact host:port or port-less host) allowed
131
+ * to access pairing endpoints and remote web services. Useful in Docker, reverse proxy,
132
+ * or NAT environments where incoming request Host headers differ from the container's
133
+ * internal network addresses. Can also be set via the DSH_REMOTE_TRUSTED_HOSTS env var.
134
+ */
135
+ trustedHosts?: string[]
129
136
  /**
130
137
  * Absolute path to a JSON file where paired device sessions are persisted.
131
138
  * Defaults to `$DSH_HOME/remote-web-ui-devices.json` so a paired device
@@ -199,6 +206,7 @@ export const Config: z<Config> = z.object({
199
206
  cookieName: z.string().min(1).default('dsh_pair'),
200
207
  requirePairingForLan: z.boolean().default(true),
201
208
  publicBaseUrl: z.string(),
209
+ trustedHosts: z.array(z.string()),
202
210
  devicesFile: z.string(),
203
211
  autoTunnel: z.boolean().default(false),
204
212
  tunnelToken: z.string().role('secret'),
@@ -216,8 +224,9 @@ const SWEEP_INTERVAL_MS = 10_000
216
224
  * which legitimately resolves to `undefined` when unset (the schema keeps it
217
225
  * optional, so `Required` alone would over-narrow it to `string`).
218
226
  */
219
- type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile' | 'lanBind' | 'profile' | 'tunnelToken'>> & {
227
+ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'trustedHosts' | 'devicesFile' | 'lanBind' | 'profile' | 'tunnelToken'>> & {
220
228
  publicBaseUrl: string | undefined
229
+ trustedHosts: string[] | undefined
221
230
  devicesFile: string
222
231
  /** undefined until the user flips the toggle once; undefined never writes the patch. */
223
232
  lanBind: boolean | undefined
@@ -259,6 +268,7 @@ const DEFAULTS: ResolvedConfig = {
259
268
  cookieName: 'dsh_pair',
260
269
  requirePairingForLan: true,
261
270
  publicBaseUrl: undefined,
271
+ trustedHosts: undefined,
262
272
  devicesFile: defaultDevicesFile(),
263
273
  autoTunnel: false,
264
274
  tunnelToken: undefined,
@@ -276,6 +286,7 @@ const DEFAULTS: ResolvedConfig = {
276
286
  export const apply = mountOnce('@linxin666/dsh-remote-web-ui', applyImpl)
277
287
 
278
288
  function applyImpl(ctx: Context, config?: Config): void {
289
+ const envPublicBase = process.env.DSH_REMOTE_PUBLIC_BASE_URL?.trim() || undefined
279
290
  const resolved: ResolvedConfig = {
280
291
  tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
281
292
  offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
@@ -283,7 +294,8 @@ function applyImpl(ctx: Context, config?: Config): void {
283
294
  idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
284
295
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
285
296
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
286
- publicBaseUrl: config?.publicBaseUrl,
297
+ publicBaseUrl: config?.publicBaseUrl ?? envPublicBase,
298
+ trustedHosts: config?.trustedHosts,
287
299
  devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
288
300
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
289
301
  tunnelToken: config?.tunnelToken,
@@ -305,7 +317,8 @@ function applyImpl(ctx: Context, config?: Config): void {
305
317
  idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
306
318
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
307
319
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
308
- publicBaseUrl: value.publicBaseUrl,
320
+ publicBaseUrl: value.publicBaseUrl ?? envPublicBase,
321
+ trustedHosts: value.trustedHosts,
309
322
  devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
310
323
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
311
324
  tunnelToken: value.tunnelToken,
@@ -445,13 +458,15 @@ function applyImpl(ctx: Context, config?: Config): void {
445
458
  // directory on update, so a boot-time captured path would fail to read
446
459
  // after a successful update; versions are re-read from disk per check.
447
460
  const requireFromHost = createRequire(import.meta.url)
448
- const resolveAnchorPath = (): string | undefined => resolveAnchorManifest(specifier => {
461
+ /** Host-process resolve that degrades to "not installed" (undefined) instead of throwing. */
462
+ const hostResolve = (specifier: string): string | undefined => {
449
463
  try {
450
464
  return requireFromHost.resolve(specifier)
451
465
  } catch {
452
466
  return undefined
453
467
  }
454
- })
468
+ }
469
+ const resolveAnchorPath = (): string | undefined => resolveAnchorManifest(hostResolve)
455
470
 
456
471
  const releaseNotesCache = new Map<string, { at: number; notes?: UpdateReleaseNotes }>()
457
472
  const fetchReleaseNotesCached = async (version: string): Promise<UpdateReleaseNotes | undefined> => {
@@ -467,13 +482,7 @@ function applyImpl(ctx: Context, config?: Config): void {
467
482
  fence: request => isTrustedApiRequest(request, []),
468
483
  check: () => checkUpdates({
469
484
  anchorManifestPath: resolveAnchorPath(),
470
- resolve: specifier => {
471
- try {
472
- return requireFromHost.resolve(specifier)
473
- } catch {
474
- return undefined
475
- }
476
- },
485
+ resolve: hostResolve,
477
486
  fetchLatest: name => fetchLatestVersion(name, fetch),
478
487
  fetchReleaseNotes: fetchReleaseNotesCached,
479
488
  }),
@@ -497,13 +506,7 @@ function applyImpl(ctx: Context, config?: Config): void {
497
506
  run: { profileDir: target.profileDir, packages: target.packages },
498
507
  check: {
499
508
  anchorManifestPath: resolveAnchorPath(),
500
- resolve: specifier => {
501
- try {
502
- return requireFromHost.resolve(specifier)
503
- } catch {
504
- return undefined
505
- }
506
- },
509
+ resolve: hostResolve,
507
510
  fetchLatest: name => fetchLatestVersion(name, fetch),
508
511
  fetchReleaseNotes: fetchReleaseNotesCached,
509
512
  },
@@ -589,10 +592,29 @@ function applyImpl(ctx: Context, config?: Config): void {
589
592
  const routes = [
590
593
  ...makeRoutes({
591
594
  service,
592
- lanAddresses: service.lanAddresses,
593
595
  requirePairingForLan: () => resolve().requirePairingForLan,
594
596
  lanBindStatus,
595
597
  indexDocument: fetchAppShell,
598
+ trustedHosts: () => {
599
+ const list: string[] = []
600
+ const envHosts = process.env.DSH_REMOTE_TRUSTED_HOSTS
601
+ if (typeof envHosts === 'string' && envHosts.trim() !== '') {
602
+ for (const item of envHosts.split(',')) {
603
+ const trimmed = item.trim()
604
+ if (trimmed !== '') list.push(trimmed)
605
+ }
606
+ }
607
+ const cfgHosts = resolve().trustedHosts
608
+ if (Array.isArray(cfgHosts)) {
609
+ for (const item of cfgHosts) {
610
+ if (typeof item === 'string') {
611
+ const trimmed = item.trim()
612
+ if (trimmed !== '') list.push(trimmed)
613
+ }
614
+ }
615
+ }
616
+ return list
617
+ },
596
618
  }),
597
619
  // The remote desktop channel: policy-gated `/remote` prefix that
598
620
  // re-issues fenced paths to loopback (see remote-api.ts). The live
@@ -852,13 +874,24 @@ function applyImpl(ctx: Context, config?: Config): void {
852
874
  }), 'remote-web-ui: remote channel boot patch')
853
875
 
854
876
  ctx.inject(['settings'], (settingsCtx) => {
855
- settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
856
- setSource: (source) => {
857
- current = source
877
+ try {
878
+ if (typeof settingsCtx.settings?.installSection === 'function') {
879
+ settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
880
+ setSource: (source) => {
881
+ current = source
882
+ sync()
883
+ },
884
+ onChange: sync,
885
+ })
886
+ } else if (typeof settingsCtx.settings?.register === 'function') {
887
+ const scope = settingsCtx.settings.register(REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, { base: config ?? {} })
888
+ current = () => scope?.get?.() ?? (config ?? {})
889
+ scope?.watch?.(() => { sync() })
858
890
  sync()
859
- },
860
- onChange: sync,
861
- })
891
+ }
892
+ } catch {
893
+ // Defensive fallback against settings registration differences
894
+ }
862
895
  })
863
896
  sync()
864
897
  }
@@ -93,6 +93,17 @@ function pipeLoopbackHttp(
93
93
  }
94
94
  res.writeHead(upstreamRes.statusCode ?? 502, out)
95
95
  upstreamRes.pipe(res)
96
+ // Inner leg died mid-response (reset, truncation): tear the outer leg
97
+ // down instead of letting the truncated response raise an unhandled
98
+ // 'error' with no listener.
99
+ upstreamRes.on('error', () => { res.destroy() })
100
+ // Outer leg died before the response finished (phone left mid-request):
101
+ // reset the inner request so the loopback server stops working on a call
102
+ // nobody will read. Guarded so a normal completion never destroys a
103
+ // keep-alive socket the agent would reuse.
104
+ res.on('close', () => {
105
+ if (!upstreamRes.readableEnded) upstream.destroy()
106
+ })
96
107
  })
97
108
  upstream.on('error', () => {
98
109
  if (!res.headersSent) {
@@ -101,6 +112,10 @@ function pipeLoopbackHttp(
101
112
  }
102
113
  res.destroy()
103
114
  })
115
+ // The outer request aborted mid-body: reset the inner request instead of
116
+ // feeding it a truncated body that still gets processed (mirror of the
117
+ // upgrade path's two-leg teardown).
118
+ req.on('error', () => { upstream.destroy() })
104
119
  req.pipe(upstream)
105
120
  }
106
121
 
package/src/routes.ts CHANGED
@@ -66,6 +66,77 @@ export function isTrustedApiRequest(request: IncomingMessage, trustedHosts: read
66
66
  }
67
67
  }
68
68
 
69
+ /**
70
+ * Test whether a hostname represents a private local-area network (RFC 1918 / ULA / mDNS / loopback).
71
+ * Supports pairing in container-bridged (Docker) or NAT-proxied topologies where the host
72
+ * machine's LAN IP or proxy domain differs from the container's internal sampled network interface.
73
+ */
74
+ export function isPrivateOrLocalHostname(hostname: string): boolean {
75
+ const normalized = hostname.toLowerCase().trim()
76
+ if (
77
+ normalized === 'localhost' ||
78
+ normalized.endsWith('.local') ||
79
+ normalized.endsWith('.lan') ||
80
+ normalized.endsWith('.internal') ||
81
+ normalized.endsWith('.home.arpa')
82
+ ) {
83
+ return true
84
+ }
85
+ let ip = normalized
86
+ if (ip.startsWith('[') && ip.endsWith(']')) ip = ip.slice(1, -1)
87
+ if (ip === '::1' || ip === '127.0.0.1') return true
88
+ if (ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true
89
+ const parts = ip.split('.').map(Number)
90
+ if (parts.length === 4 && parts.every(n => Number.isInteger(n) && n >= 0 && n <= 255)) {
91
+ const [a, b] = parts
92
+ if (a === 10) return true
93
+ if (a === 127) return true
94
+ if (a === 169 && b === 254) return true
95
+ if (a === 172 && b >= 16 && b <= 31) return true
96
+ if (a === 192 && b === 168) return true
97
+ }
98
+ return false
99
+ }
100
+
101
+ /**
102
+ * Validates that an incoming request is non-cross-site (rejects cross-site fetch metadata
103
+ * and ensures Origin matches Host when present).
104
+ */
105
+ export function isNonCrossSite(request: IncomingMessage): boolean {
106
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
107
+ const host = request.headers.host
108
+ if (typeof host !== 'string') return false
109
+ const origin = request.headers.origin
110
+ if (origin === undefined) return true
111
+ try {
112
+ const originUrl = new URL(origin)
113
+ const hostUrl = new URL(`http://${host}`)
114
+ return originUrl.host === hostUrl.host
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The private-LAN fallback authority of a request: its Host header when the
122
+ * hostname is private/local (RFC 1918 / ULA / mDNS / loopback) and the
123
+ * browser markers are non-cross-site, else undefined. The accept paths and
124
+ * the lanFence fallback use this to serve container-bridged and reverse-proxy
125
+ * topologies whose Host is not among the advertised or configured authorities.
126
+ */
127
+ function privateLanHostOf(request: IncomingMessage): string | undefined {
128
+ const host = request.headers.host
129
+ if (typeof host !== 'string') return undefined
130
+ let hostName = ''
131
+ try {
132
+ hostName = new URL(`http://${host}`).hostname
133
+ } catch {
134
+ return undefined
135
+ }
136
+ if (hostName === '' || !isPrivateOrLocalHostname(hostName) || !isNonCrossSite(request)) return undefined
137
+ return host
138
+ }
139
+
69
140
  /** Cap on pairing request bodies (tokens and workspace ids are tiny). */
70
141
  const MAX_BODY_BYTES = 4096
71
142
 
@@ -85,6 +156,28 @@ export function acceptLimitKey(socketIp: string, forwarded: string | undefined,
85
156
  return `${bucket}|${socketIp}`
86
157
  }
87
158
 
159
+ /**
160
+ * Cap on the dynamically trusted hosts (see {@link addBounded}): room for
161
+ * every legitimate router/proxy aliasing shape, small enough that a flood of
162
+ * distinct private Host headers cannot grow the table unboundedly.
163
+ */
164
+ export const MAX_DYNAMIC_TRUSTED_HOSTS = 64
165
+
166
+ /**
167
+ * FIFO-bounded Set insert: past `max` entries the oldest one is evicted
168
+ * (a Set iterates in insertion order). The dynamic trusted-host table is fed
169
+ * by caller-controlled Host headers, so it must stay bounded; an evicted
170
+ * legitimate host re-adds itself on that device's next gated request.
171
+ */
172
+ export function addBounded(set: Set<string>, value: string, max: number): void {
173
+ if (set.has(value)) return
174
+ if (set.size >= max) {
175
+ const oldest = set.values().next().value
176
+ if (oldest !== undefined) set.delete(oldest)
177
+ }
178
+ set.add(value)
179
+ }
180
+
88
181
  /**
89
182
  * The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
90
183
  * from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
@@ -339,8 +432,6 @@ export class PairingEventsStream {
339
432
  export interface PairRoutesDeps {
340
433
  /** The pairing service. */
341
434
  service: PairingService
342
- /** The LAN IP literals the fence accepts (derived from the bind host). */
343
- lanAddresses: readonly string[]
344
435
  /** Current desktop gate policy, re-read for every status response. */
345
436
  requirePairingForLan?: boolean | (() => boolean)
346
437
  /**
@@ -359,6 +450,8 @@ export interface PairRoutesDeps {
359
450
  * cookieless header/query credential). Undefined drops the route (tests).
360
451
  */
361
452
  indexDocument?: (deviceId: string) => Promise<string | undefined>
453
+ /** Extra trusted non-loopback hosts (from config or environment). */
454
+ trustedHosts?: readonly string[] | (() => readonly string[])
362
455
  }
363
456
 
364
457
  /**
@@ -367,21 +460,45 @@ export interface PairRoutesDeps {
367
460
  * @returns the exact routes to register on webServer.
368
461
  */
369
462
  export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
370
- const { service, lanAddresses, requirePairingForLan = true } = deps
463
+ const { service, requirePairingForLan = true } = deps
371
464
  const pairingRequired = (): boolean => typeof requirePairingForLan === 'function'
372
465
  ? requirePairingForLan()
373
466
  : requirePairingForLan
374
467
  const events = new PairingEventsStream(service)
468
+ const dynamicTrustedHosts = new Set<string>()
375
469
 
376
470
  /** Loopback-only fence: the desktop panel's control endpoints. */
377
471
  const loopbackFence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, [])
378
- /** Phone-facing fence: loopback, the service's live LAN literals, or the configured public host. */
472
+ /** Phone-facing fence: loopback, the service's live LAN literals, configured public host, extra trusted hosts, or dynamically paired hosts. */
379
473
  const lanFence = (req: IncomingMessage): boolean => {
380
474
  const publicHost = publicHostOf(service.publicBaseUrl)
381
475
  // The service's LAN bases re-read per request: a hot rebind (the lan-bind
382
476
  // toggle) updates them mid-process, and the fence must follow.
383
477
  const bases = service.lanAddresses
384
- return isTrustedApiRequest(req, publicHost === undefined ? bases : [...bases, publicHost])
478
+ const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? [])
479
+ const combined = [
480
+ ...bases,
481
+ ...(publicHost !== undefined ? [publicHost] : []),
482
+ ...extraHosts,
483
+ ...dynamicTrustedHosts,
484
+ ]
485
+ if (isTrustedApiRequest(req, combined)) return true
486
+
487
+ // If request carries a valid paired device cookie from a private LAN host (e.g. after service restart), trust and remember it
488
+ const privateLanHost = privateLanHostOf(req)
489
+ if (privateLanHost !== undefined) {
490
+ const cookieDeviceId = readCookie(req.headers.cookie, service.config.cookieName)
491
+ let queryDeviceId: string | null = null
492
+ try {
493
+ queryDeviceId = new URL(req.url ?? '/', 'http://pair.invalid').searchParams.get('device')
494
+ } catch {}
495
+ const deviceId = cookieDeviceId ?? queryDeviceId ?? undefined
496
+ if (deviceId !== undefined && service.hasDevice(deviceId)) {
497
+ addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
498
+ return true
499
+ }
500
+ }
501
+ return false
385
502
  }
386
503
 
387
504
  const requireMethod = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
@@ -476,7 +593,8 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
476
593
 
477
594
  const handleAccept = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
478
595
  if (!requireMethod(req, res, 'POST')) return
479
- if (!lanFence(req)) {
596
+ const privateLanHost = privateLanHostOf(req)
597
+ if (!lanFence(req) && privateLanHost === undefined) {
480
598
  writeJson(res, 403, { ok: false, code: 'forbidden' })
481
599
  return
482
600
  }
@@ -493,9 +611,16 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
493
611
  const ua = req.headers['user-agent']
494
612
  const result = service.accept(payload.token, typeof ua === 'string' ? ua : undefined)
495
613
  if (!result.ok) {
614
+ if (!lanFence(req)) {
615
+ writeJson(res, 403, { ok: false, code: 'forbidden' })
616
+ return
617
+ }
496
618
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
497
619
  return
498
620
  }
621
+ if (privateLanHost !== undefined) {
622
+ addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
623
+ }
499
624
  // No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
500
625
  // work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
501
626
  // top-level navigations working while blocking cross-site subrequests.
@@ -612,7 +737,8 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
612
737
  */
613
738
  const handleAcceptPage = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
614
739
  if (!requireMethod(req, res, 'GET')) return
615
- if (!lanFence(req)) {
740
+ const privateLanHost = privateLanHostOf(req)
741
+ if (!lanFence(req) && privateLanHost === undefined) {
616
742
  res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
617
743
  res.end('forbidden')
618
744
  return
@@ -627,6 +753,11 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
627
753
  const ua = req.headers['user-agent']
628
754
  const result = token === '' ? { ok: false as const, code: 'invalid' as const } : service.accept(token, typeof ua === 'string' ? ua : undefined)
629
755
  if (!result.ok) {
756
+ if (!lanFence(req)) {
757
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
758
+ res.end('forbidden')
759
+ return
760
+ }
630
761
  // accept() now refuses only expired/unknown/stopped tokens: a consumed
631
762
  // token stays re-usable until its expiry or replacement, so a mobile
632
763
  // flow that split across cookie contexts (camera preview, in-app
@@ -646,6 +777,9 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
646
777
  res.end(pairingFailurePage())
647
778
  return
648
779
  }
780
+ if (privateLanHost !== undefined) {
781
+ addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
782
+ }
649
783
  // Land the paired device on the cookieless app page: the official shell
650
784
  // served by this plugin, with the device id in the URL. No harness index
651
785
  // gate, no browser-auth cookie hop - the channel gate accepts the device