@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
@@ -62,7 +62,11 @@ export function remoteChannelRequired(
62
62
  * @returns true for localhost, IPv6 loopback, or any 127/8 literal.
63
63
  */
64
64
  export function isLoopbackHostname(hostname: string): boolean {
65
- if (hostname === 'localhost' || hostname === '::1') return true
65
+ // WHATWG location.hostname keeps IPv6 literals bracketed ("[::1]"); the
66
+ // host-side shared predicate accepts that spelling, so this browser copy must
67
+ // too, or an IPv6-loopback origin is judged remote and rewrites every call
68
+ // onto the gated channel where it can never pair.
69
+ if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') return true
66
70
  const parts = hostname.split('.')
67
71
  return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
68
72
  }
@@ -228,8 +232,16 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
228
232
  if (device === null) return init
229
233
  const headers = init?.headers
230
234
  if (typeof Headers !== 'undefined' && headers instanceof Headers) {
231
- try { headers.set(RULES.deviceHeader, device) } catch { /* ignore */ }
232
- return init
235
+ // Copy instead of mutating: the instance belongs to the caller, which may
236
+ // reuse it for a request this channel does not rewrite (the device
237
+ // credential must not ride along there).
238
+ try {
239
+ const copy = new Headers(headers)
240
+ copy.set(RULES.deviceHeader, device)
241
+ return { ...init, headers: copy }
242
+ } catch {
243
+ return init
244
+ }
233
245
  }
234
246
  if (typeof headers === 'object' && headers !== null) {
235
247
  return { ...init, headers: { ...(headers as Record<string, string>), [RULES.deviceHeader]: device } } as RequestInit
@@ -253,7 +265,10 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
253
265
  ? rewritten.toString()
254
266
  : new Request(rewritten, input)
255
267
  return Promise.resolve(originalFetch.call(window, next, attach(init))).then(async (response) => {
256
- if (await isUnpairedDenied(response.clone())) options.onUnpaired?.()
268
+ // The clone tees the response body; only the 403 branch inspects it, so
269
+ // every other status skips the copy (a large upload or session dump
270
+ // would otherwise be duplicated per gated request).
271
+ if (response.status === 403 && await isUnpairedDenied(response.clone())) options.onUnpaired?.()
257
272
  else options.onPaired?.()
258
273
  return response
259
274
  })
@@ -784,6 +784,44 @@
784
784
  gap: 4px;
785
785
  }
786
786
 
787
+ /* ── Wide foot row ─────────────────────────────────────────────────────────
788
+
789
+ The official Settings trigger owns a whole row while the footer actions
790
+ stack above it, so the trigger's right half sits empty. On the wide column
791
+ the two share one line (the Settings trigger, then the update and remote
792
+ triggers) and every other foot
793
+ child (the usage glance card, another plugin's block) keeps a full row of
794
+ its own above them. The rail (data-sidebar-collapsed on the frame) keeps
795
+ the shell's stacked foot, where the circles stack by design. The shell owns
796
+ the container classes; the semantic suffixes survive its rebuilds. */
797
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footArea'] {
798
+ flex-direction: row;
799
+ flex-wrap: wrap;
800
+ align-items: center;
801
+ }
802
+
803
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footArea'] > :not([class*='settingsArea']):not([class*='footerActions']) {
804
+ flex: 1 1 100%;
805
+ min-width: 0;
806
+ }
807
+
808
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='settingsArea'] {
809
+ order: 1;
810
+ /* Content-sized basis: the trigger's own width forces it onto the line after
811
+ a full-width sibling, then flex-shrink gives the actions their space. */
812
+ flex: 1 1 auto;
813
+ width: auto;
814
+ min-width: 0;
815
+ }
816
+
817
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footerActions'] {
818
+ order: 2;
819
+ flex: none;
820
+ width: auto;
821
+ min-width: 0;
822
+ align-items: center;
823
+ }
824
+
787
825
  /* ── update panel extras ─────────────────────────────────────────────── */
788
826
 
789
827
  .updateStatus {
@@ -8,7 +8,7 @@
8
8
  * card-store pattern.
9
9
  */
10
10
 
11
- import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
11
+ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
12
12
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
13
13
  import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
14
14
 
@@ -114,7 +114,7 @@ interface PlannedWrite {
114
114
  judge: (() => boolean) | undefined
115
115
  }
116
116
 
117
- /** One durable write inside the save's atomic scope mutation. */
117
+ /** One durable write inside the save's atomic form mutation. */
118
118
  export interface BatchedWrite {
119
119
  /** Field this entry writes. */
120
120
  field: string
@@ -211,34 +211,34 @@ export class CardForm<T> {
211
211
  private readonly specs: Map<string, FieldSpec>
212
212
  private readonly staged = new Map<string, StagedEdit>()
213
213
  private readonly listeners = new Set<() => void>()
214
- /** The scope subscription installed in the constructor; released by dispose(). */
215
- private readonly disposeScope: () => void
214
+ /** The form subscription installed in the constructor; released by dispose(). */
215
+ private readonly disposeForm: () => void
216
216
  private disposed = false
217
217
  private saving = false
218
218
  private failed = false
219
219
  private failedReason: string | undefined
220
220
 
221
- /** @param scope - the bound settings scope for this card's namespace. */
221
+ /** @param scope - the bound configuration form for this card's namespace. */
222
222
  constructor(
223
- private readonly scope: SettingsScope<T>,
223
+ private readonly scope: ConfigForm<T>,
224
224
  specs: FieldSpec[],
225
225
  ) {
226
226
  this.specs = new Map(specs.map(spec => [spec.field, spec]))
227
- this.disposeScope = scope.subscribe(() => { this.publish() })
227
+ this.disposeForm = scope.subscribe(() => { this.publish() })
228
228
  }
229
229
 
230
230
  /**
231
- * Release the scope subscription and every bound store listener. The card
231
+ * Release the form subscription and every bound store listener. The card
232
232
  * must call this on teardown; later calls are no-ops.
233
233
  */
234
234
  dispose(): void {
235
235
  if (this.disposed) return
236
236
  this.disposed = true
237
- this.disposeScope()
237
+ this.disposeForm()
238
238
  this.listeners.clear()
239
239
  }
240
240
 
241
- /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
241
+ /** Publish a projection of this form, rebuilt whenever the form or a draft changes. */
242
242
  bind<S>(project: () => S): SnapshotStore<S> {
243
243
  const store = createSnapshotStore(project())
244
244
  this.listeners.add(() => { store.set(project()) })
@@ -295,19 +295,19 @@ export class CardForm<T> {
295
295
  }
296
296
 
297
297
  /**
298
- * Write every staged edit in one atomic scope mutation, then re-seed from
298
+ * Write every staged edit in one atomic form mutation, then re-seed from
299
299
  * what the Host accepted.
300
300
  *
301
301
  * The whole batch rides one mutate, so cross-field validate hooks
302
302
  * (baseURL+model) judge it as a unit: the Host either applies every write
303
- * or refuses the batch. The 0.1.2 scope contract never rejects a refused
304
- * mutation — the scope recovers with a fresh Host view and resolves — so
305
- * resolution alone proves nothing: the outcome is judged by reading the
306
- * settled snapshot back, one planned write at a time, and one missed write
307
- * fails the whole save. A scope that still rejects on refusal (the dsh-web
308
- * bridge scope) reports through the same failure path with its rejection
309
- * message. A save that did not land keeps its drafts, so the user can
310
- * correct them instead of retyping.
303
+ * or refuses the batch. The form contract answers a refusal or a skipped
304
+ * write with `false` (it recovers with a fresh Host view instead of
305
+ * throwing), so the outcome is judged twice: the answer itself, and then the
306
+ * settled snapshot read back one planned write at a time. One missed write
307
+ * fails the whole save. A transport that rejects instead (the dsh-web bridge
308
+ * controller on a dead connection) reports through the same failure path
309
+ * with its rejection message. A save that did not land keeps its drafts, so
310
+ * the user can correct them instead of retyping.
311
311
  * @returns settlement after the mutation and the read-back.
312
312
  */
313
313
  async save(): Promise<void> {
@@ -323,30 +323,33 @@ export class CardForm<T> {
323
323
  this.failed = false
324
324
  this.failedReason = undefined
325
325
  this.publish()
326
- // One atomic namespace mutation: the 0.1.2 scope contract takes ordered
327
- // path operations, so the whole staged batch is validated, persisted, and
326
+ // One atomic namespace mutation: the form contract takes ordered path
327
+ // operations, so the whole staged batch is validated, persisted, and
328
328
  // recovered together — either every write lands or none does.
329
329
  const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
330
330
  ? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
331
331
  : { op: 'unset', path: [item.field] })
332
332
  let failedReason: string | undefined
333
+ let accepted = false
333
334
  try {
334
- await this.scope.mutate(ops)
335
+ accepted = await this.scope.mutate(ops)
335
336
  } catch (error) {
336
337
  failedReason = error instanceof Error ? error.message : String(error)
337
338
  }
338
- // The 0.1.2 scope resolves even a refused mutation (it recovers with a
339
- // fresh view instead of throwing), so resolution alone proves nothing:
340
- // judge every planned write against the settled snapshot. The mutation is
341
- // atomic, so one missed write fails the whole save and keeps the drafts.
342
- const landed = failedReason === undefined && valid.every(item => item.judge())
339
+ // `false` is the contract's refusal/skip answer, and the form still
340
+ // resolves after a refusal (it recovers with a fresh view), so the answer
341
+ // alone is not enough: judge every planned write against the settled
342
+ // snapshot as well. The mutation is atomic, so one missed write fails the
343
+ // whole save and keeps the drafts.
344
+ const landed = accepted && failedReason === undefined && valid.every(item => item.judge())
343
345
  for (const [field, before] of pending) {
344
346
  if (landed && this.staged.get(field) === before) this.staged.delete(field)
345
347
  }
346
348
  this.saving = false
347
349
  this.failed = !landed
348
- // A read-back failure carries no server reason: the card surfaces its
349
- // generic failure copy; a rejecting scope (the bridge) adds its message.
350
+ // A refused or unlanded batch carries no server reason: the card surfaces
351
+ // its generic failure copy; a rejecting transport (the bridge) adds its
352
+ // message.
350
353
  this.failedReason = failedReason
351
354
  this.publish()
352
355
  }
@@ -381,7 +384,7 @@ export class CardForm<T> {
381
384
  */
382
385
  private landedSet(field: string, value: unknown): boolean {
383
386
  // A redacted secret never appears in any wire view layer: the Host strips
384
- // role('secret') fields and reports them through a sidecar the scope
387
+ // role('secret') fields and reports them through a sidecar the form
385
388
  // snapshot does not expose, so there is nothing to compare the draft
386
389
  // against. Settling is the only signal the form has; the rest of the
387
390
  // batch, when one exists, still carries the atomic verdict by read-back.
@@ -412,7 +415,7 @@ export class CardForm<T> {
412
415
  return spec
413
416
  }
414
417
 
415
- private snapshotOf(): SettingsScopeSnapshot<T> {
418
+ private snapshotOf(): ConfigFormSnapshot<T> {
416
419
  return this.scope.getSnapshot()
417
420
  }
418
421
 
package/src/http.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  /**
3
3
  * Shared JSON body/response helpers for the host route families: one strict
4
4
  * bounded body reader, one lenient bounded body reader, one JSON object
5
- * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * narrow, one JSON writer, and the outbound request-init helper that keeps a
6
+ * host fetch body decodable (withIdentityEncoding). Previously the readers
7
+ * were copy-pasted across the
6
8
  * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
9
  * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
10
  * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
88
90
  return isJsonObject(value) ? value : undefined
89
91
  }
90
92
 
93
+ /**
94
+ * Request init that asks a remote origin for an uncompressed body.
95
+ *
96
+ * The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
97
+ * the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
98
+ * Node's built-in `fetch()` reads. That cross-major wrapper drops the
99
+ * `content-encoding` header and automatic decompression, so a plain fetch
100
+ * resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
101
+ * consumer can read. Every host fetch that parses a remote body must request
102
+ * identity encoding. Caller headers survive; `accept-encoding` is forced to
103
+ * `identity` because a caller value has no decoder behind it in this host.
104
+ * @param init - the caller's request init (signal, headers, method, ...).
105
+ * @returns a copy with `accept-encoding: identity` merged in.
106
+ */
107
+ export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
108
+ const headers = new Headers(init.headers)
109
+ headers.set('accept-encoding', 'identity')
110
+ return { ...init, headers }
111
+ }
112
+
91
113
  /**
92
114
  * Write one JSON response. Default headers are the family defaults
93
115
  * (content-type and referrer-policy); caller headers are appended or
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ import { isHttpUrl, tunnelPlanOf } from './tunnel-plan.ts'
36
36
  import { loadRelayIdentity, RelayRegistrar, type RelayState } from './relay-registry.ts'
37
37
  import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
38
38
  import { createInnerAuth } from './inner-auth.ts'
39
+ import { withIdentityEncoding } from './http.ts'
39
40
  import { TunnelManager, type TunnelInfo } from './tunnel.ts'
40
41
  import { PublicBaseKeeper } from './public-base.ts'
41
42
  import {
@@ -84,9 +85,12 @@ export const name = 'remote-web-ui'
84
85
  export const inject = ['webServer', 'typertGateway', 'connection']
85
86
 
86
87
  /**
87
- * Settings namespace of the remote-control capability — the section the web
88
- * settings surface edits. Spelled here rather than imported: the browser
89
- * half spells the same value and must not depend on a Host package.
88
+ * Settings namespace of the remote-control capability. Under the 0.1.7
89
+ * settings model the namespace IS the Host profile entry id, so this is also
90
+ * the id the browser half asks `ctx.configForms` for (the family binder
91
+ * resolves the same value to the owning entry). Spelled here rather than
92
+ * imported: the browser half spells the same value and must not depend on a
93
+ * Host package.
90
94
  */
91
95
  export const REMOTE_WEB_UI_SETTINGS_NAMESPACE = 'remote-web-ui' as SettingsNamespace
92
96
 
@@ -100,22 +104,28 @@ export interface Config {
100
104
  maxDevices?: number
101
105
  /**
102
106
  * Idle sessions older than this (ms) are deleted from memory and disk.
103
- * Default is 7 days; a leftover cookie no longer authorizes after expiry.
107
+ * Default is 30 days; a leftover cookie no longer authorizes after expiry.
104
108
  */
105
109
  idleExpireMs?: number
106
110
  /** Cookie name carrying the paired device id. */
107
111
  cookieName?: string
108
112
  /**
109
113
  * When true (default), a desktop Web GUI opened at a non-loopback origin
110
- * rides the gated `/remote/api` channel and must carry a live paired-device
111
- * cookie — the QR is the only way into remote desktop, and stop()/revoke()
112
- * cut the /remote channel and the pairing cookie off immediately. Scope
114
+ * rides the gated `/remote/api` channel, whose requests must carry a live
115
+ * paired-device cookie — the QR is the only way into remote desktop, and
116
+ * stop()/revoke() cut the channel and the pairing cookie off immediately.
117
+ * This policy selects the transport; it never loosens the channel, whose
118
+ * gate is unconditional (issue #1665). Scope
113
119
  * note for this cohort: direct /api is governed by the harness fence +
114
120
  * browser-auth cookie (the api/gate seam has no emitter on 0.1.2-alpha.2),
115
121
  * so a harness browser credential a device has already redeemed is not
116
122
  * invalidated by stop() — see the README security model. Set false to keep
117
123
  * the desktop on plain `/api` (only useful when that origin is already
118
- * trusted for `/api`).
124
+ * trusted for `/api`). Turning the policy off does NOT open the channel:
125
+ * the /remote proxy attaches the process's own browser-auth credential only
126
+ * when the request itself presents a live paired-device credential, so an
127
+ * unpaired LAN/tunnel caller is forwarded without it and the inner route
128
+ * answers 401.
119
129
  */
120
130
  requirePairingForLan?: boolean
121
131
  /**
@@ -220,6 +230,13 @@ export const Config: z<Config> = z.object({
220
230
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
221
231
  const SWEEP_INTERVAL_MS = 10_000
222
232
 
233
+ /**
234
+ * How long one update-status probe answer is reused. The probe fans out one
235
+ * registry GET per family package plus a GitHub call, and the sidebar update
236
+ * entry asks for the status on every GUI page load.
237
+ */
238
+ const UPDATE_STATUS_TTL_MS = 60_000
239
+
223
240
  /**
224
241
  * Fully resolved config: every field non-optional except `publicBaseUrl`,
225
242
  * which legitimately resolves to `undefined` when unset (the schema keeps it
@@ -237,9 +254,8 @@ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'trustedHosts' | '
237
254
 
238
255
  /**
239
256
  * The single mapping from resolved plugin config to the pairing service
240
- * config. Both the constructed service and every live settings sync reuse
241
- * it, so no field can be silently dropped when the web settings surface
242
- * pushes a new value into the running service.
257
+ * config. Both the constructed service and every later apply of this row
258
+ * reuse it, so no field can be silently dropped.
243
259
  */
244
260
  export function pairingConfigOf(resolved: Pick<
245
261
  ResolvedConfig,
@@ -305,30 +321,12 @@ function applyImpl(ctx: Context, config?: Config): void {
305
321
  profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
306
322
  enabled: config?.enabled ?? DEFAULTS.enabled,
307
323
  }
308
- // The live source the pairing service and the gate read: the settings
309
- // section once the web settings surface is served, the composition entry
310
- // otherwise (installSection swaps it when the namespace registers).
311
- let current: () => Config = () => config ?? {}
312
- const resolve = (): ResolvedConfig => {
313
- const value = current()
314
- return {
315
- tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
316
- offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
317
- maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
318
- idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
319
- cookieName: value.cookieName ?? DEFAULTS.cookieName,
320
- requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
321
- publicBaseUrl: value.publicBaseUrl ?? envPublicBase,
322
- trustedHosts: value.trustedHosts,
323
- devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
324
- autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
325
- tunnelToken: value.tunnelToken,
326
- relay: value.relay ?? DEFAULTS.relay,
327
- lanBind: value.lanBind,
328
- profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
329
- enabled: value.enabled ?? DEFAULTS.enabled,
330
- }
331
- }
324
+ // The effective configuration this activation runs on: under the 0.1.7
325
+ // settings model the plugin's own Config IS its settings document, so the
326
+ // Host serves the settings page from this schema and reloads the row after a
327
+ // save — a live edit re-enters apply() with the new value instead of being
328
+ // pushed into a running instance.
329
+ const resolve = (): ResolvedConfig => resolved
332
330
  const service = new PairingService(pairingConfigOf(resolved))
333
331
 
334
332
  // ── auto tunnel ─────────────────────────────────────────────────────────
@@ -442,13 +440,14 @@ function applyImpl(ctx: Context, config?: Config): void {
442
440
  service.setLanBases(lanBases)
443
441
  }
444
442
 
445
- // Push a committed settings section into the service and gate. The service
443
+ // Push the effective configuration into the service and gate. The service
446
444
  // config object is read per operation (token mint, touch, sweep), and the
447
- // gate re-reads its fence flag per request, so a live edit takes effect
448
- // without a restart. When `enabled` turns off, the pairing routes and
449
- // sweep timer are dropped and all device/token state is revoked, but the
450
- // gate listener stays mounted so a LAN-exposed /api stays behind pairing
451
- // (now vetoing every non-loopback request) instead of opening the fence.
445
+ // gate re-reads its fence flag per request, so a saved edit takes effect
446
+ // without a restart (the Host reloads this row, and apply re-runs). When
447
+ // `enabled` is off, the pairing routes and sweep timer are dropped and all
448
+ // device/token state is revoked, but the gate listener stays mounted so a
449
+ // LAN-exposed /api stays behind pairing (now vetoing every non-loopback
450
+ // request) instead of opening the fence.
452
451
  let disposeRoutes: (() => void) | undefined
453
452
  let disposeSweep: (() => void) | undefined
454
453
  // ── remote update ────────────────────────────────────────────────────────
@@ -478,16 +477,32 @@ function applyImpl(ctx: Context, config?: Config): void {
478
477
  releaseNotesCache.set(version, { at: Date.now(), notes })
479
478
  return notes
480
479
  }
481
- const updateRoutes = makeUpdateRoutes({
482
- // Control endpoints are host-surface only: a LAN/phone origin must never
483
- // trigger a real install on this machine.
484
- fence: request => isTrustedApiRequest(request, []),
485
- check: () => checkUpdates({
480
+ // The status probe fans out one registry GET per family package (plus a
481
+ // GitHub release-notes call), and the sidebar update entry asks for it on
482
+ // every GUI page load — so the answer is memoized briefly and concurrent
483
+ // callers share one probe. A completed update invalidates it.
484
+ let updateStatusCache: { at: number; value: Awaited<ReturnType<typeof checkUpdates>> } | undefined
485
+ let updateStatusInFlight: Promise<Awaited<ReturnType<typeof checkUpdates>>> | undefined
486
+ const checkStatusCached = (): Promise<Awaited<ReturnType<typeof checkUpdates>>> => {
487
+ const cached = updateStatusCache
488
+ if (cached !== undefined && Date.now() - cached.at < UPDATE_STATUS_TTL_MS) return Promise.resolve(cached.value)
489
+ if (updateStatusInFlight !== undefined) return updateStatusInFlight
490
+ updateStatusInFlight = checkUpdates({
486
491
  anchorManifestPath: resolveAnchorPath(),
487
492
  resolve: hostResolve,
488
493
  fetchLatest: name => fetchLatestVersion(name, fetch),
489
494
  fetchReleaseNotes: fetchReleaseNotesCached,
490
- }),
495
+ }).then((value) => {
496
+ updateStatusCache = { at: Date.now(), value }
497
+ return value
498
+ }).finally(() => { updateStatusInFlight = undefined })
499
+ return updateStatusInFlight
500
+ }
501
+ const updateRoutes = makeUpdateRoutes({
502
+ // Control endpoints are host-surface only: a LAN/phone origin must never
503
+ // trigger a real install on this machine.
504
+ fence: request => isTrustedApiRequest(request, []),
505
+ check: () => checkStatusCached(),
491
506
  run: async (): Promise<UpdateRunResult> => {
492
507
  const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() })
493
508
  if ('error' in target) {
@@ -504,7 +519,7 @@ function applyImpl(ctx: Context, config?: Config): void {
504
519
  // 11 minimumReleaseAge gate can silently keep the installed versions
505
520
  // (same-day releases), which a plain exit-0 check would report as
506
521
  // success — the user then restarts and nothing changed.
507
- return runUpdateVerified({
522
+ const result = await runUpdateVerified({
508
523
  run: { profileDir: target.profileDir, packages: target.packages },
509
524
  check: {
510
525
  anchorManifestPath: resolveAnchorPath(),
@@ -513,6 +528,9 @@ function applyImpl(ctx: Context, config?: Config): void {
513
528
  fetchReleaseNotes: fetchReleaseNotesCached,
514
529
  },
515
530
  })
531
+ // The install moved (or failed) the versions the cached status reported.
532
+ updateStatusCache = undefined
533
+ return result
516
534
  },
517
535
  })
518
536
  // LAN-bind facts for the settings card, re-read per request so a hot
@@ -580,10 +598,16 @@ function applyImpl(ctx: Context, config?: Config): void {
580
598
  if (appShellCache !== undefined && Date.now() - appShellCache.at < APP_SHELL_TTL_MS) return appShellCache.html
581
599
  const cookie = await innerAuth.ready()
582
600
  try {
583
- const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, {
601
+ const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, withIdentityEncoding({
584
602
  headers: cookie !== undefined ? { cookie } : undefined,
585
- })
586
- if (!response.ok) return undefined
603
+ }))
604
+ if (!response.ok) {
605
+ // A stale credential (secret rotation, 30-day TTL) must not wedge every
606
+ // later landing: drop it so the next attempt re-redeems, exactly as the
607
+ // proxy path does on an upstream 401.
608
+ if (response.status === 401 || response.status === 403) innerAuth.invalidate()
609
+ return undefined
610
+ }
587
611
  const html = await response.text()
588
612
  appShellCache = { at: Date.now(), html }
589
613
  return html
@@ -618,15 +642,15 @@ function applyImpl(ctx: Context, config?: Config): void {
618
642
  return list
619
643
  },
620
644
  }),
621
- // The remote desktop channel: policy-gated `/remote` prefix that
622
- // re-issues fenced paths to loopback (see remote-api.ts). The live
623
- // requirePairingForLan is re-read per request, same as the gate listener
624
- // and routes above, so a stale client rewrite on an open-LAN deployment
625
- // proxies instead of 403ing.
645
+ // The remote desktop channel: a paired-credential-gated `/remote` prefix
646
+ // that re-issues fenced paths to loopback (see remote-api.ts). The gate is
647
+ // unconditional — requirePairingForLan governs the plain /api surface and
648
+ // the desktop's client-side rewrite only; it is never an authorization
649
+ // input here, because this channel attaches the process's own browser
650
+ // credential to everything it forwards (issue #1665).
626
651
  ...makeRemoteApiRoutes({
627
652
  service,
628
653
  port: ctx.webServer.port,
629
- requirePairingForLan: () => resolve().requirePairingForLan,
630
654
  auth: innerAuth,
631
655
  }),
632
656
  ...updateRoutes,
@@ -634,7 +658,6 @@ function applyImpl(ctx: Context, config?: Config): void {
634
658
  const upgrades = makeRemoteApiUpgradeRoutes({
635
659
  service,
636
660
  port: ctx.webServer.port,
637
- requirePairingForLan: () => resolve().requirePairingForLan,
638
661
  auth: innerAuth,
639
662
  })
640
663
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
@@ -718,6 +741,9 @@ function applyImpl(ctx: Context, config?: Config): void {
718
741
  console.warn('remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)')
719
742
  }
720
743
 
744
+ // Apply the effective configuration to the running surfaces once per
745
+ // activation: the service tunables, the LAN-bind block/firewall, the tunnel
746
+ // plan, the pairing routes, the presence sweep, and the posture probe.
721
747
  const sync = (): void => {
722
748
  const value = resolve()
723
749
  service.config = pairingConfigOf(value)
@@ -805,6 +831,10 @@ function applyImpl(ctx: Context, config?: Config): void {
805
831
  ? plan.targetUrl
806
832
  : { kind: 'quick', targetUrl: plan.targetUrl, originHostHeader })
807
833
  } else if (plan.mode === 'named') {
834
+ // The hostname is fixed and known before the process runs, so publish it
835
+ // now: a named tunnel that fails to start must not leave the previous
836
+ // mode's ephemeral host as the QR and fence base.
837
+ publicBase.markRunning(plan.publicUrl)
808
838
  tunnel.start({ kind: 'named', token: plan.token, publicUrl: plan.publicUrl })
809
839
  } else {
810
840
  tunnel.stop()
@@ -824,7 +854,17 @@ function applyImpl(ctx: Context, config?: Config): void {
824
854
  }
825
855
  }
826
856
  const enabled = value.enabled
827
- if (!enabled) service.stop()
857
+ if (!enabled) {
858
+ service.stop()
859
+ // The tunnel branch above follows the plan, not the master switch, so an
860
+ // explicit "off" must also drop the public ingress it started: otherwise
861
+ // the cloudflared child keeps a live public URL (and the relay keeps its
862
+ // stable row) while the panel says remote control is stopped. The relay
863
+ // row is deliberately kept so re-enabling reuses the same origin.
864
+ tunnel.stop()
865
+ disposeRelayRegistrar()
866
+ publicBase.reset()
867
+ }
828
868
  if (disposeRoutes === undefined && enabled) {
829
869
  disposeRoutes = ctx.effect(
830
870
  () => {
@@ -877,25 +917,8 @@ function applyImpl(ctx: Context, config?: Config): void {
877
917
  table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT })
878
918
  }), 'remote-web-ui: remote channel boot patch')
879
919
 
880
- ctx.inject(['settings'], (settingsCtx) => {
881
- try {
882
- if (typeof settingsCtx.settings?.installSection === 'function') {
883
- settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
884
- setSource: (source) => {
885
- current = source
886
- sync()
887
- },
888
- onChange: sync,
889
- })
890
- } else if (typeof settingsCtx.settings?.register === 'function') {
891
- const scope = settingsCtx.settings.register(REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, { base: config ?? {} })
892
- current = () => scope?.get?.() ?? (config ?? {})
893
- scope?.watch?.(() => { sync() })
894
- sync()
895
- }
896
- } catch {
897
- // Defensive fallback against settings registration differences
898
- }
899
- })
920
+ // No settings registration: the Host derives this plugin's settings page
921
+ // from the exported `Config` schema and reloads the row after a save, so
922
+ // every write re-enters apply() with the new config (see `resolve`).
900
923
  sync()
901
924
  }
package/src/inner-auth.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  * therefore redeems its own launch token (the same one-time exchange every
8
8
  * paired device's first navigation performs) and attaches the resulting
9
9
  * cookie to inner requests. The credential is only ever exercised behind the
10
- * pairing gate: while requirePairingForLan is on, every proxied call already
11
- * carried a live paired-device cookie before this module is consulted.
10
+ * pairing gate: the `/remote` channel always requires a live paired-device
11
+ * credential before this module is consulted, regardless of the
12
+ * requirePairingForLan LAN policy (issue #1665).
12
13
  */
13
14
 
14
15
  /** Browser-auth credential the proxy attaches to loopback-bound requests. */