@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
@@ -0,0 +1,131 @@
1
+ /**
2
+ * One-time pairing grants for the /pair-app landing.
3
+ *
4
+ * The QR entry (`/pair-accept?pair=<token>`) used to hand the freshly minted
5
+ * device id to the app landing in its own URL (`/pair-app?device=<id>`). A
6
+ * device id is a live session credential: the channel gate authorizes every
7
+ * gated request by it and the cookie it mirrors lives a year, so a URL carrying
8
+ * it leaks that credential into browser history, the address bar, and every log
9
+ * on the path. The landing is reached with a one-time grant instead — 256 bits
10
+ * of entropy, a short TTL, and single-consume semantics — and the device id
11
+ * reaches the client only inside the server-rendered shell (the capture script
12
+ * the landing patches in).
13
+ *
14
+ * Consume semantics follow the reference implementation's one-time host
15
+ * capability (zcode `packages/server/src/hostCapability.ts`): the record is
16
+ * deleted on EVERY consume attempt — success, expiry, or replay — so a captured
17
+ * grant is worthless after its first use and a replayed one can never resolve,
18
+ * and only a first use inside the TTL yields a device.
19
+ *
20
+ * @module @linxin666/dsh-remote-web-ui/pair-grant
21
+ */
22
+
23
+ import { randomBytes } from 'node:crypto'
24
+
25
+ /**
26
+ * Default grant lifetime. The grant only has to survive one redirect chain
27
+ * from the accept page to the app landing, tunnel round trips included, so it
28
+ * is far shorter than the device session it stands in for.
29
+ */
30
+ export const DEFAULT_PAIR_GRANT_TTL_MS = 60_000
31
+
32
+ /**
33
+ * Cap on live grants. The accept page is rate-limited per source, but the
34
+ * table is still caller-driven, so it stays bounded (oldest evicted first)
35
+ * exactly like the device table.
36
+ */
37
+ export const MAX_LIVE_GRANTS = 256
38
+
39
+ /** Clock/entropy injection for tests. */
40
+ export interface PairGrantStoreOptions {
41
+ /** Grant lifetime in ms (default {@link DEFAULT_PAIR_GRANT_TTL_MS}). */
42
+ ttlMs?: number
43
+ /** Clock seam (default Date.now). */
44
+ now?: () => number
45
+ /** Entropy seam (default 32 random bytes, base64url). */
46
+ createGrant?: () => string
47
+ }
48
+
49
+ /** One issued grant and its absolute expiry (both non-secret to the issuer). */
50
+ export interface PairGrantRecord {
51
+ /** The one-time secret carried in the /pair-app URL. */
52
+ grant: string
53
+ /** Absolute expiry (ms epoch); a consume past this resolves to nothing. */
54
+ expiresAt: number
55
+ }
56
+
57
+ /** The grant table surface the routes consume. */
58
+ export interface PairGrantStore {
59
+ /** Mint one grant bound to a device session. */
60
+ issue(deviceId: string): PairGrantRecord
61
+ /** Redeem a grant once, returning its device id (undefined when unusable). */
62
+ consume(grant: string | undefined): string | undefined
63
+ /** Liveness check that does not spend the grant (the fence fallback). */
64
+ peek(grant: string | undefined): string | undefined
65
+ /** Live grant count (tests/diagnostics). */
66
+ readonly size: number
67
+ }
68
+
69
+ /** One live grant. */
70
+ interface GrantEntry {
71
+ deviceId: string
72
+ expiresAt: number
73
+ }
74
+
75
+ /**
76
+ * Build the grant table. Pure TypeScript with injected clock/randomness so the
77
+ * whole one-time semantics are unit-testable without a server.
78
+ */
79
+ export function createPairGrantStore(options: PairGrantStoreOptions = {}): PairGrantStore {
80
+ const ttlMs = options.ttlMs ?? DEFAULT_PAIR_GRANT_TTL_MS
81
+ const now = options.now ?? ((): number => Date.now())
82
+ const createGrant = options.createGrant ?? ((): string => randomBytes(32).toString('base64url'))
83
+ const entries = new Map<string, GrantEntry>()
84
+
85
+ const purgeExpired = (at: number): void => {
86
+ for (const [grant, entry] of entries) {
87
+ if (entry.expiresAt <= at) entries.delete(grant)
88
+ }
89
+ }
90
+
91
+ return {
92
+ issue(deviceId: string): PairGrantRecord {
93
+ const issuedAt = now()
94
+ purgeExpired(issuedAt)
95
+ while (entries.size >= MAX_LIVE_GRANTS) {
96
+ const oldest = entries.keys().next().value
97
+ if (oldest === undefined) break
98
+ entries.delete(oldest)
99
+ }
100
+ const grant = createGrant()
101
+ const expiresAt = issuedAt + ttlMs
102
+ entries.set(grant, { deviceId, expiresAt })
103
+ return { grant, expiresAt }
104
+ },
105
+ consume(grant: string | undefined): string | undefined {
106
+ if (grant === undefined || grant === '') return undefined
107
+ const consumedAt = now()
108
+ const entry = entries.get(grant)
109
+ // Delete before validating: a replayed grant must never resolve, and an
110
+ // expired one must not linger in the table.
111
+ entries.delete(grant)
112
+ purgeExpired(consumedAt)
113
+ if (entry === undefined || entry.expiresAt <= consumedAt) return undefined
114
+ return entry.deviceId
115
+ },
116
+ peek(grant: string | undefined): string | undefined {
117
+ if (grant === undefined || grant === '') return undefined
118
+ const at = now()
119
+ const entry = entries.get(grant)
120
+ if (entry === undefined) return undefined
121
+ if (entry.expiresAt <= at) {
122
+ entries.delete(grant)
123
+ return undefined
124
+ }
125
+ return entry.deviceId
126
+ },
127
+ get size(): number {
128
+ return entries.size
129
+ },
130
+ }
131
+ }
@@ -201,4 +201,40 @@ describe('PairingService device persistence', () => {
201
201
  const deviceId = result.ok ? result.deviceId : ''
202
202
  expect(saved[deviceId]?.userAgent).toBe('Mozilla/5.0 TestPhone')
203
203
  })
204
+
205
+ it('operator is not left with a revocation that a restart silently undoes', () => {
206
+ // Given a device store that cannot be written (its parent path is a file)
207
+ // while a stale, readable store sits beside it.
208
+ const blocker = join(dir, 'blocker')
209
+ writeFileSync(blocker, 'not a directory')
210
+ const file = join(blocker, 'devices.json')
211
+ const removed: string[] = []
212
+ const service = new PairingService(
213
+ { ...BASE_CONFIG, devicesFile: file },
214
+ makeClock(),
215
+ { removeFile: (path) => { removed.push(path) } },
216
+ )
217
+ // When the operator stops remote access and the write cannot be made durable.
218
+ service.stop()
219
+ // Then the stale store is handed to the removal step, so a restart cannot
220
+ // restore the revoked sessions from disk.
221
+ expect(removed).toEqual([file])
222
+ })
223
+
224
+ it('operator keeps the device store when a revocation persist succeeds', () => {
225
+ // Given a writable device store with one paired device.
226
+ const file = join(dir, 'devices.json')
227
+ const removed: string[] = []
228
+ const service = new PairingService(
229
+ { ...BASE_CONFIG, devicesFile: file },
230
+ makeClock(),
231
+ { removeFile: (path) => { removed.push(path) } },
232
+ )
233
+ const deviceId = pairDevice(service)
234
+ // When the device is revoked and the write lands.
235
+ expect(service.revoke(deviceId)).toBe(true)
236
+ // Then the store stays (with the row gone) and nothing is removed.
237
+ expect(removed).toEqual([])
238
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({})
239
+ })
204
240
  })
package/src/pairing.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  * Security invariants:
8
8
  * - One active token at a time; `issue()` replaces it, so a refreshed QR
9
9
  * immediately invalidates the previous link.
10
- * - A token stays re-usable until it expires or is replaced: the first
11
- * successful `accept()` marks it consumed, but the same link may pair
12
- * again within the window (each re-accept mints a fresh device session).
10
+ * - A token is a bearer credential for its whole window: it is refused only
11
+ * when unknown, expired, or after `stop()`, and the same link may pair
12
+ * repeatedly within that window (each accept mints a fresh device session).
13
13
  * Mobile flows routinely split across cookie contexts (camera preview to
14
14
  * in-app browser to the system browser), and the later context must be
15
15
  * able to complete its own pairing from the same link.
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { randomBytes } from 'node:crypto'
25
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
25
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
26
26
  import { dirname } from 'node:path'
27
27
  import type { PostureSnapshot } from './posture.ts'
28
28
 
@@ -45,8 +45,6 @@ export interface TokenRecord {
45
45
  issuedAt: number
46
46
  /** Absolute expiry (ms epoch); accept() past this is refused. */
47
47
  expiresAt: number
48
- /** Consumed by the first successful accept(). */
49
- consumed: boolean
50
48
  /** Opaque, non-secret identifier surfaced in snapshots (never the pairing secret). */
51
49
  id: string
52
50
  /** Workspace the QR link should land the phone in (optional). */
@@ -167,10 +165,14 @@ export interface PairingConfig {
167
165
  devicesFile?: string
168
166
  }
169
167
 
170
- /** Result of one accept() attempt. */
168
+ /**
169
+ * Result of one accept() attempt. A token is a bearer credential for its whole
170
+ * window (see the module doc), so the only refusal is `invalid`: unknown,
171
+ * expired, or after stop(). There is no "already used" outcome.
172
+ */
171
173
  export type AcceptResult =
172
174
  | { ok: true; deviceId: string }
173
- | { ok: false; code: 'invalid' | 'used' }
175
+ | { ok: false; code: 'invalid' }
174
176
 
175
177
  /** Thrown by issue() for an address outside the sampled LAN literals. */
176
178
  export class UnknownLanAddressError extends Error {
@@ -189,6 +191,12 @@ export interface PairingClock {
189
191
  randomToken(): string
190
192
  }
191
193
 
194
+ /** File-operation seam for the revocation-durability step (tests). */
195
+ export interface PairingFsSeams {
196
+ /** Remove the stale device store (defaults to fs.rmSync with force). */
197
+ removeFile?(path: string): void
198
+ }
199
+
192
200
  /** Real clock/entropy: 32 random hex chars per token. */
193
201
  export const defaultClock: PairingClock = {
194
202
  now: () => Date.now(),
@@ -231,6 +239,7 @@ export class PairingService {
231
239
  constructor(
232
240
  public config: PairingConfig,
233
241
  private readonly clock: PairingClock = defaultClock,
242
+ private readonly fs: PairingFsSeams = {},
234
243
  ) {
235
244
  this.loadPersisted()
236
245
  }
@@ -263,7 +272,7 @@ export class PairingService {
263
272
  })
264
273
  }
265
274
  this.clampToMaxDevices()
266
- if (this.evictIdle()) this.persist()
275
+ if (this.evictIdle()) this.persistRevocation()
267
276
  } catch {
268
277
  // Unreadable/corrupt: start empty rather than refusing to boot.
269
278
  }
@@ -301,9 +310,9 @@ export class PairingService {
301
310
  * cookie's device id), so the file is written 0600 via a temp file and
302
311
  * atomic rename; a crash mid-write can never leave a half-written store.
303
312
  */
304
- private persist(): void {
313
+ private persist(): boolean {
305
314
  const file = this.config.devicesFile
306
- if (file === undefined) return
315
+ if (file === undefined) return true
307
316
  try {
308
317
  mkdirSync(dirname(file), { recursive: true })
309
318
  const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
@@ -312,8 +321,32 @@ export class PairingService {
312
321
  writeFileSync(temp, JSON.stringify(payload), { mode: 0o600 })
313
322
  renameSync(temp, file)
314
323
  this.dirty = false
324
+ return true
315
325
  } catch (error) {
316
326
  console.error('remote-web-ui: failed to persist paired devices', error)
327
+ return false
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Persist a table change that REVOKES access (stop, per-device revoke, idle
333
+ * eviction). A failed write would leave the revoked session on disk, and
334
+ * {@link loadPersisted} restores that file verbatim on the next start — the
335
+ * device's still-valid cookie would authorize again after an explicit
336
+ * revocation. When the write cannot be made durable, the stale store is
337
+ * removed instead (a missing file already loads as an empty table), so the
338
+ * failure costs a re-pair rather than silently undoing the revocation.
339
+ */
340
+ private persistRevocation(): void {
341
+ if (this.persist()) return
342
+ const file = this.config.devicesFile
343
+ if (file === undefined) return
344
+ try {
345
+ if (this.fs.removeFile !== undefined) this.fs.removeFile(file)
346
+ else rmSync(file, { force: true })
347
+ console.error('remote-web-ui: the device revocation could not be persisted; removed the stale device store so a restart cannot restore it')
348
+ } catch (error) {
349
+ console.error('remote-web-ui: the device revocation could not be persisted and the stale device store could not be removed', error)
317
350
  }
318
351
  }
319
352
 
@@ -343,9 +376,14 @@ export class PairingService {
343
376
  return this.publicBase
344
377
  }
345
378
 
346
- /** Set or clear the public base URL (a tunnel in front of this server). */
379
+ /**
380
+ * Set or clear the public base URL (a tunnel in front of this server). The
381
+ * value is canonicalized to its origin: a trailing slash (what a browser
382
+ * address-bar copy produces) or a path would mint a dead `//pair-accept`
383
+ * link, which WHATWG resolves as an authority rather than a path.
384
+ */
347
385
  setPublicBaseUrl(url: string | undefined): void {
348
- this.publicBase = url
386
+ this.publicBase = url === undefined ? undefined : canonicalBaseUrl(url)
349
387
  this.notify()
350
388
  }
351
389
 
@@ -395,7 +433,6 @@ export class PairingService {
395
433
  id: `t${this.tokenSerial}`,
396
434
  issuedAt: now,
397
435
  expiresAt: now + this.config.tokenTtlMs,
398
- consumed: false,
399
436
  ...(workspaceId !== undefined ? { workspaceId } : {}),
400
437
  ...(address !== undefined ? { address } : {}),
401
438
  })
@@ -404,21 +441,20 @@ export class PairingService {
404
441
  }
405
442
 
406
443
  /**
407
- * Consume a token and bind a device session. One-time: the second
408
- * successful call for the same token is impossible because the first
409
- * consumes it.
444
+ * Redeem a token and bind a device session. The token is a bearer
445
+ * credential for its whole window, not a single-use nonce: it is refused
446
+ * only when unknown, past its expiry, or after stop(), and every successful
447
+ * call — including a repeat within the same window — mints a fresh device
448
+ * session (see the module doc for the mobile cookie-context rationale).
410
449
  * @param token - the token secret from the QR link.
411
450
  * @param userAgent - optional User-Agent header captured at accept.
412
451
  * @returns the new device id, or a refusal code.
413
452
  */
414
453
  accept(token: string, userAgent?: string): AcceptResult {
415
454
  const record = this.tokens.get(token)
416
- // A consumed token stays a valid bearer credential until expiry or
417
- // replacement (see the module doc): re-accept mints a fresh device.
418
455
  if (record === undefined || this.stopped || this.clock.now() > record.expiresAt) {
419
456
  return { ok: false, code: 'invalid' }
420
457
  }
421
- record.consumed = true
422
458
  const deviceId = this.clock.randomToken()
423
459
  const now = this.clock.now()
424
460
  if (this.devices.size >= this.config.maxDevices) {
@@ -448,7 +484,7 @@ export class PairingService {
448
484
  stop(): void {
449
485
  this.tokens.clear()
450
486
  this.devices.clear()
451
- this.persist()
487
+ this.persistRevocation()
452
488
  this.stopped = true
453
489
  this.notify()
454
490
  }
@@ -462,7 +498,7 @@ export class PairingService {
462
498
  revoke(deviceId: string): boolean {
463
499
  if (this.stopped) return false
464
500
  if (!this.devices.delete(deviceId)) return false
465
- this.persist()
501
+ this.persistRevocation()
466
502
  this.notify()
467
503
  return true
468
504
  }
@@ -501,7 +537,8 @@ export class PairingService {
501
537
  */
502
538
  sweep(): void {
503
539
  const evicted = this.evictIdle()
504
- if (evicted || this.dirty) this.persist()
540
+ if (evicted) this.persistRevocation()
541
+ else if (this.dirty) this.persist()
505
542
  this.notify()
506
543
  }
507
544
 
@@ -559,7 +596,7 @@ export class PairingService {
559
596
  const limit = this.config.idleExpireMs ?? DEFAULT_IDLE_EXPIRE_MS
560
597
  if (this.clock.now() - session.lastSeenAt > limit) {
561
598
  this.devices.delete(deviceId)
562
- this.persist()
599
+ this.persistRevocation()
563
600
  this.notify()
564
601
  return undefined
565
602
  }
@@ -649,6 +686,20 @@ function sameStrings(a: readonly string[], b: readonly string[]): boolean {
649
686
  return a.length === b.length && a.every((value, index) => value === b[index])
650
687
  }
651
688
 
689
+ /**
690
+ * Canonical origin of a public base URL. An unparsable value is returned
691
+ * verbatim so the caller's malformed-base warning stays the single owner of
692
+ * that case.
693
+ * @param url - the configured public base.
694
+ */
695
+ export function canonicalBaseUrl(url: string): string {
696
+ try {
697
+ return new URL(url).origin
698
+ } catch {
699
+ return url
700
+ }
701
+ }
702
+
652
703
  /** Strip control characters and cap the User-Agent stored with a session. */
653
704
  export function sanitizeUserAgent(raw: string | undefined): string | undefined {
654
705
  if (raw === undefined) return undefined
package/src/posture.ts CHANGED
@@ -9,8 +9,13 @@
9
9
  *
10
10
  * The probe issues loopback requests with a forged Host header — the exact
11
11
  * shape a tunnel or LAN client produces — and treats anything other than a
12
- * 403 as exposed: the fence is documented to refuse with 403, so any other
13
- * status means the request reached the RPC bridge.
12
+ * 403 or a 401 as exposed. The fence is documented to refuse with 403; the
13
+ * harness answers 401 when the Host fence PASSED but the browser-auth cookie
14
+ * is missing (the connection plugin checks the fence first and the cookie
15
+ * second). A 401 therefore means the /api surface is still gated — reporting
16
+ * it as open produced a false CRITICAL on exactly the supported LAN
17
+ * deployment (0.0.0.0 bind auto-trusts the LAN literals) and desensitized the
18
+ * one alarm that should mean a real exposure.
14
19
  */
15
20
 
16
21
  import type { ClientRequest, ClientRequestArgs } from 'node:http'
@@ -20,7 +25,10 @@ import http from 'node:http'
20
25
  export interface PostureHost {
21
26
  /** The forged Host header value that was probed. */
22
27
  host: string
23
- /** True when the probe reached past the fence (anything but 403). */
28
+ /**
29
+ * True when the probe reached the RPC bridge without any gate refusing it
30
+ * (a status that is neither the fence's 403 nor the browser-auth 401).
31
+ */
24
32
  exposed: boolean
25
33
  }
26
34
 
@@ -97,7 +105,7 @@ async function probeHost(port: number, hostHeader: string, request: ProbeRequest
97
105
  path: '/api/session.list',
98
106
  headers: { host: hostHeader, 'content-type': 'application/json' },
99
107
  timeout: timeoutMs,
100
- }, status => { finish(status !== 403) })
108
+ }, status => { finish(status !== 403 && status !== 401) })
101
109
  const timer = setTimeout(() => { finish(false) }, timeoutMs + 1_000)
102
110
  handle.on('error', () => { finish(false) })
103
111
  handle.end('{}')
package/src/remote-api.ts CHANGED
@@ -7,10 +7,14 @@
7
7
  * consult.
8
8
  *
9
9
  * Security model:
10
- * - While `requirePairingForLan` is on (default), every request must carry a
11
- * live paired-device cookie, enforced before any bytes are forwarded and
12
- * before any host call. With the policy off, the cookie gate is skipped
13
- * (the local-only denials below still apply).
10
+ * - Every request must carry a live paired-device credential, enforced before
11
+ * any bytes are forwarded and before any host call. This gate is
12
+ * unconditional and does NOT follow `requirePairingForLan`: the channel
13
+ * re-issues traffic with the process's own browser-auth credential, so it is
14
+ * an authentication amplifier — an unpaired caller admitted here would reach
15
+ * the full host API without ever holding that credential. The LAN policy
16
+ * governs the plain `/api` surface only (whether the harness fence's LAN
17
+ * auto-trust stands on its own); it can never widen this channel.
14
18
  * - A paired remote desktop is a full-control credential: the browser half
15
19
  * flips the official UI into host mode (the transport ownsHost hook), so
16
20
  * the configuration plane (settings, credentials, presets, deliverables)
@@ -69,15 +73,8 @@ function isSafeSegment(segment: string): boolean {
69
73
  export interface RemoteApiDeps {
70
74
  /** The pairing service (device gate + cookie name). */
71
75
  service: PairingService
72
- /** The local webServer port the loopback proxy connects to. */
76
+ /** Local webServer port the loopback proxy connects to. */
73
77
  port: number
74
- /**
75
- * Live policy: whether the paired-device cookie gates the /remote channel.
76
- * When false, requests are proxied without a cookie (the loopback-only
77
- * denials still apply). A function is re-read per request, so a settings
78
- * edit takes effect without a restart. Defaults to true.
79
- */
80
- requirePairingForLan?: boolean | (() => boolean)
81
78
  /**
82
79
  * The process's inner browser-auth credential attached to re-issued
83
80
  * requests (the connection plugin's /api route enforces that cookie and
@@ -139,24 +136,22 @@ export function pairedDeviceIdOf(req: IncomingMessage, service: PairingService):
139
136
 
140
137
  /**
141
138
  * Build the remote desktop channel HTTP routes.
142
- * @param deps - pairing service + local port + live pairing policy.
139
+ * @param deps - pairing service + local port + inner credential.
143
140
  * @returns the routes to register on webServer.
144
141
  */
145
142
  export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
146
- const { service, port, requirePairingForLan = true } = deps
143
+ const { service, port } = deps
147
144
 
148
145
  const handler = (req: IncomingMessage, res: ServerResponse): void => {
149
- // Cookie gate first — same order as /m/api. Do not buffer an unpaired body.
150
- // With the live policy off, untrusted-but-policy-open callers are proxied
151
- // (a stale client rewrite must not 403); loopback-only denials stay below.
152
- const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
153
- if (require) {
154
- const paired = pairedDeviceIdOf(req, service)
155
- if (paired === undefined) {
156
- req.resume()
157
- envelopeError(res, 403, 'invalid-request', 'unpaired', 'this device is not paired with the desktop')
158
- return
159
- }
146
+ // Paired credential first — before any bytes are buffered or forwarded.
147
+ // Unconditional by design: this channel attaches the process's own
148
+ // browser-auth cookie to every re-issued call, so admitting an unpaired
149
+ // caller would hand out that credential's authority (issue #1665).
150
+ const paired = pairedDeviceIdOf(req, service)
151
+ if (paired === undefined) {
152
+ req.resume()
153
+ envelopeError(res, 403, 'invalid-request', 'unpaired', 'this device is not paired with the desktop')
154
+ return
160
155
  }
161
156
 
162
157
  const method = req.method ?? 'GET'
@@ -209,24 +204,22 @@ export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: strin
209
204
  * @returns the upgrade routes to register on webServer.
210
205
  */
211
206
  export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute[] {
212
- const { service, port, requirePairingForLan = true } = deps
207
+ const { service, port } = deps
213
208
 
214
209
  const handlerFor = (fallbackPath: string) => (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
215
- const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
216
- if (require) {
217
- // WebSocket handshakes cannot carry headers from the Web API, so the
218
- // cookieless credential rides the query; the cookie stays the primary.
219
- let queryDevice: string | undefined
220
- try {
221
- queryDevice = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get(REMOTE_DEVICE_QUERY) ?? undefined
222
- } catch { /* fall through to the cookie */ }
223
- const deviceId = pairedDeviceIdOf(req, service)
224
- const paired = deviceId ?? (queryDevice !== undefined && service.touchDevice(queryDevice) ? queryDevice : undefined)
225
- if (paired === undefined) {
226
- socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
227
- socket.destroy()
228
- return
229
- }
210
+ // WebSocket handshakes cannot carry headers from the Web API, so the
211
+ // cookieless credential rides the query; the cookie stays the primary.
212
+ // The gate is unconditional here too (issue #1665).
213
+ let queryDevice: string | undefined
214
+ try {
215
+ queryDevice = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get(REMOTE_DEVICE_QUERY) ?? undefined
216
+ } catch { /* fall through to the cookie */ }
217
+ const deviceId = pairedDeviceIdOf(req, service)
218
+ const paired = deviceId ?? (queryDevice !== undefined && service.touchDevice(queryDevice) ? queryDevice : undefined)
219
+ if (paired === undefined) {
220
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
221
+ socket.destroy()
222
+ return
230
223
  }
231
224
  const inner = upgradeInnerPath(req.url, fallbackPath)
232
225
  const denied = loopbackOnlyDenial(inner.split('?')[0] ?? inner)
@@ -17,7 +17,12 @@
17
17
  * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
18
18
  * connection plugin reads it: the paired remote desktop gets the full
19
19
  * settings/credentials/presets surface, and every call still rides the
20
- * gated /remote channel. It finally publishes the official pre-Cordis
20
+ * gated /remote channel. Host mode is server-granted, not origin-asserted:
21
+ * the hook is installed only when the device-gated app landing (/pair-app)
22
+ * published the grant marker (REMOTE_HOST_GRANT_GLOBAL) ahead of this
23
+ * script, so a shell served to an unpaired browser - a fence-open
24
+ * deployment - never presents itself as the machine owner. It finally
25
+ * publishes the official pre-Cordis
21
26
  * upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
22
27
  * transport otherwise runs inside a Web Worker whose own globals the
23
28
  * main-thread rewrite cannot reach (issue #1580). The script self-skips on
@@ -40,14 +45,17 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
40
45
  return '(function(){' +
41
46
  'try{' +
42
47
  'var w=window,loc=w.location,h=loc.hostname;' +
43
- // Loopback origins keep the original paths (mirrors isLoopbackHostname).
44
- "if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
45
- // Host mode: the paired remote desktop presents itself as the machine
46
- // owner, so the official UI keeps its full configuration surface (the
47
- // settings mirror, document controller, and deliverables open actions
48
- // all branch on connection.isLoopback). Must run before any boot entry.
49
- 'try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}' +
48
+ // Loopback origins (including the bracketed IPv6 literal WHATWG returns)
49
+ // keep the original paths (mirrors isLoopbackHostname).
50
+ "if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
50
51
  'var R=' + json + ';' +
52
+ // Host mode is server-granted: only the device-gated app landing
53
+ // publishes the grant marker (in a capture script that runs ahead of
54
+ // this one), so a shell that merely sits on a non-loopback origin - an
55
+ // unpaired browser reaching a fence-open deployment - keeps the official
56
+ // UI's memory-scope presentation instead of posing as the machine owner.
57
+ // Must run before any boot entry.
58
+ 'try{if(w[R.hostGrantGlobal]===true){if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}}catch(e){}' +
51
59
  // The cookieless device credential: read lazily per call - the
52
60
  // /pair-app capture script sets it in head AFTER this boot script ran,
53
61
  // so a parse-time read would always see null.
@@ -16,6 +16,13 @@ export { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY }
16
16
  /** Connection-plugin method prefix under the gated channel. */
17
17
  export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`
18
18
 
19
+ /**
20
+ * The window global the device-gated app landing publishes to grant host mode.
21
+ * Set by the /pair-app capture script, read by the parse-time boot patch: the
22
+ * transport hook (ownsHost) is server-granted, not asserted from the origin.
23
+ */
24
+ export const REMOTE_HOST_GRANT_GLOBAL = '__DSH_REMOTE_HOST_GRANT__'
25
+
19
26
  /** Every decision input of the remote-channel rewrite, JSON-serializable. */
20
27
  export interface RemoteChannelRules {
21
28
  readonly remotePrefix: string
@@ -37,6 +44,14 @@ export interface RemoteChannelRules {
37
44
  readonly uploadPath: string
38
45
  /** Page global the pre-Cordis upload hook is published under. */
39
46
  readonly uploadHookGlobal: string
47
+ /**
48
+ * Page global carrying the server-issued host-mode grant. Only the plugin's
49
+ * own device-gated app landing (/pair-app) publishes it; the boot patch
50
+ * therefore enables the official UI's host mode only for a shell the server
51
+ * actually granted, never for one that merely happens to sit on a
52
+ * non-loopback origin.
53
+ */
54
+ readonly hostGrantGlobal: string
40
55
  }
41
56
 
42
57
  /** The live rule set. */
@@ -56,6 +71,7 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
56
71
  '/api/remote.mux',
57
72
  '/sidebar/ws/terminal',
58
73
  '/sidebar/ws/agent-terminals',
74
+ '/sidebar/ws/agent-opens',
59
75
  '/api/dsh-ssh/terminal',
60
76
  ],
61
77
  deviceHeader: REMOTE_DEVICE_HEADER,
@@ -63,6 +79,7 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
63
79
  deviceQuery: REMOTE_DEVICE_QUERY,
64
80
  uploadPath: '/api/session/uploadFileBinary',
65
81
  uploadHookGlobal: '__DSH_FILE_UPLOAD__',
82
+ hostGrantGlobal: REMOTE_HOST_GRANT_GLOBAL,
66
83
  }
67
84
 
68
85
  /** The window global the boot patch publishes its seat under. */
@@ -39,6 +39,7 @@ export const REMOTE_UPGRADE_PATHS = [
39
39
  REMOTE_API_PATHS.mux,
40
40
  `${REMOTE_PREFIX}/sidebar/ws/terminal`,
41
41
  `${REMOTE_PREFIX}/sidebar/ws/agent-terminals`,
42
+ `${REMOTE_PREFIX}/sidebar/ws/agent-opens`,
42
43
  `${REMOTE_API_PREFIX}/dsh-ssh/terminal`,
43
44
  ] as const
44
45