dsh-builtin-browser 0.4.1 → 0.4.5

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 (57) hide show
  1. package/CHANGELOG.md +1699 -0
  2. package/README.en.md +114 -33
  3. package/README.md +108 -32
  4. package/client.js +478 -29
  5. package/cordis.patch.yml +21 -0
  6. package/desktop-bridge/install.mjs +66 -6
  7. package/desktop-bridge/plugin-browser-bridge.js +426 -167
  8. package/docs/architecture.md +14 -4
  9. package/docs/tool-reference.md +13 -13
  10. package/docs/user-guide.md +58 -3
  11. package/docs/why-browser.md +1 -1
  12. package/lib/browser/types.d.ts +34 -3
  13. package/lib/browser-electron/bridge-connection.d.ts +49 -26
  14. package/lib/browser-electron/bridge-connection.js +185 -22
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  16. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  17. package/lib/browser-electron/entry.d.ts +1 -1
  18. package/lib/browser-electron/entry.js +133 -40
  19. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  20. package/lib/browser-electron/ephemeral-profile.js +146 -0
  21. package/lib/browser-electron/history-store.d.ts +36 -2
  22. package/lib/browser-electron/history-store.js +76 -7
  23. package/lib/browser-electron/host-main.js +86 -28
  24. package/lib/browser-electron/provider.d.ts +106 -3
  25. package/lib/browser-electron/provider.js +407 -60
  26. package/lib/browser-electron/remote-host.d.ts +52 -3
  27. package/lib/browser-electron/remote-host.js +259 -25
  28. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  29. package/lib/browser-electron/rpc-ops.js +36 -0
  30. package/lib/browser-electron/settings-route.d.ts +28 -0
  31. package/lib/browser-electron/settings-route.js +68 -0
  32. package/lib/browser-electron/settings-store.d.ts +40 -0
  33. package/lib/browser-electron/settings-store.js +160 -23
  34. package/lib/browser-electron/system-browser.d.ts +102 -12
  35. package/lib/browser-electron/system-browser.js +411 -51
  36. package/lib/index.js +0 -1
  37. package/lib/tool-browser/element-fields.d.ts +145 -0
  38. package/lib/tool-browser/element-fields.js +152 -0
  39. package/lib/tool-browser/index.js +149 -74
  40. package/package.json +10 -4
  41. package/scripts/verify-host-compat.mjs +195 -0
  42. package/src/browser/types.ts +34 -3
  43. package/src/browser-electron/bridge-connection.ts +179 -22
  44. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  45. package/src/browser-electron/entry.ts +132 -39
  46. package/src/browser-electron/ephemeral-profile.ts +152 -0
  47. package/src/browser-electron/history-store.ts +81 -9
  48. package/src/browser-electron/host-main.ts +115 -36
  49. package/src/browser-electron/provider.ts +492 -69
  50. package/src/browser-electron/remote-host.ts +276 -38
  51. package/src/browser-electron/rpc-ops.ts +41 -0
  52. package/src/browser-electron/settings-route.ts +87 -0
  53. package/src/browser-electron/settings-store.ts +173 -24
  54. package/src/browser-electron/system-browser.ts +408 -39
  55. package/src/index.ts +3 -2
  56. package/src/tool-browser/element-fields.ts +167 -0
  57. package/src/tool-browser/index.ts +155 -76
@@ -19,9 +19,11 @@
19
19
  * that connection proves knowledge of the random per-spawn token (passed to
20
20
  * the child via its stdin (first line, never in argv). A local process that
21
21
  * connects to the loopback port without the token can neither impersonate the
22
- * child nor inject replies — it is disconnected immediately. Commands are
23
- * only written after the hello authenticates, so a spoofed socket never
24
- * sees traffic.
22
+ * child nor inject replies — it is disconnected as soon as its first line
23
+ * arrives. Commands are only written after the hello authenticates, and every
24
+ * other inbound op (including the fire-and-forget `userAction` / `viewClosed`
25
+ * notifications) is dispatched only once that check has passed, so an
26
+ * unauthenticated peer can neither read traffic nor drive a session.
25
27
  * @module dsh-browser/browser-electron/remote-host
26
28
  */
27
29
 
@@ -501,6 +503,9 @@ class ElectronChildClient {
501
503
  const line = `${hostStamp()} spawn error: ${String(error)} electron=${electron} hostMain=${this.hostMainPath}`
502
504
  process.stderr.write(`[dsh-browser host] ${line}\n`)
503
505
  appendHostLog(`${line}\n`)
506
+ // The cached availability answer is retired by `onExit` below, which this reaches
507
+ // through fail(): the decision needs the host's view of what is on disk, not the
508
+ // client's.
504
509
  this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
505
510
  })
506
511
  this.child.on('exit', (code, signal) => {
@@ -523,11 +528,43 @@ class ElectronChildClient {
523
528
  })
524
529
  }
525
530
 
531
+ /**
532
+ * Drop the escalation kill once there is nothing left for it to target.
533
+ *
534
+ * Called on the paths where the child is known to be gone — after an observed exit, and on
535
+ * release. It is deliberately NOT called from `fail()`: a dead socket says nothing about the
536
+ * process on the other end, and the timer exists for exactly the case where the child ignores
537
+ * the socket closing. Clearing it there disarmed the only escalation there was, which is how
538
+ * a child that ignored its socket could outlive dispose() and keep holding the profile.
539
+ */
540
+ private clearKillGrace(): void {
541
+ if (this.killGraceTimer === undefined) return
542
+ clearTimeout(this.killGraceTimer)
543
+ this.killGraceTimer = undefined
544
+ }
545
+
546
+ /** Drop the "did not authenticate" deadline once it can no longer be meaningful. */
547
+ private clearHelloTimer(): void {
548
+ if (this.helloTimer === undefined) return
549
+ clearTimeout(this.helloTimer)
550
+ this.helloTimer = undefined
551
+ }
552
+
526
553
  /** Reject everything in flight, mark the client dead, and notify the host. */
527
554
  private fail(err: Error): void {
528
555
  if (this.dead) return
529
556
  this.dead = true
530
557
  this.connected = false
558
+ // The authentication deadline outlives the socket it was armed for otherwise: it is
559
+ // keyed on `awaitingHello` alone, so a client that already failed on some other path
560
+ // kept a live timer until it fired (harmless, but it is a dangling timer in a process
561
+ // that may be shutting down).
562
+ this.clearHelloTimer()
563
+ // NOT clearKillGrace(): this path runs when the SOCKET died, which says nothing about
564
+ // whether the process did. Clearing the escalation here disarmed it for exactly the case it
565
+ // exists for — a child that ignores its socket closing — so dispose() could return with the
566
+ // child still running and still holding the profile. The timer clears itself when the child
567
+ // exits, and `kill()` replaces it on an explicit release.
531
568
  // Tag every failure as "host gone" so the view layer can tell a dead host
532
569
  // apart from a page-level error and rebuild against a fresh child.
533
570
  const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message)
@@ -600,19 +637,34 @@ class ElectronChildClient {
600
637
  // Non-protocol line; ignore.
601
638
  continue
602
639
  }
603
- if (msg.op === 'userAction') {
604
- // Fire-and-forget notification from the child's own UI (toolbar):
605
- // there is no reply and no pending id — route it straight to the
606
- // host's user-action handler (which the provider registered).
607
- this.onUserAction?.(msg.action as BrowserUserAction)
608
- continue
609
- }
610
- if (msg.op === 'viewClosed') {
611
- // Fire-and-forget notification from the child's own UI: the human closed
612
- // the window, so the interface that session showed is gone. The provider
613
- // decides what that ends — browsing history and login state survive on
614
- // disk, only the session itself does not.
615
- this.onViewClosed?.(typeof msg.windowId === 'string' ? msg.windowId : '')
640
+ if (msg.op === 'userAction' || msg.op === 'viewClosed') {
641
+ // A fire-and-forget notification from the child's own UI (toolbar). These carry no
642
+ // reply and no pending id, and they hand the provider a session-level verb — so they
643
+ // are dispatched ONLY once the peer has proved it knows the spawn token.
644
+ //
645
+ // They used to be handled before the hello check, while the module comment claimed an
646
+ // unauthenticated peer "is disconnected immediately … a spoofed socket never sees
647
+ // traffic". Both were wrong: any loopback process that won the race for the single
648
+ // accepted socket could inject `viewClosed` and end the human's browser session
649
+ // before the token check rejected it. Only the loopback interface can connect, so
650
+ // this is a same-machine race rather than a remote attack — which is exactly the
651
+ // kind of thing the token exists to close.
652
+ if (this.awaitingHello) {
653
+ this.awaitingHello = false
654
+ this.clearHelloTimer()
655
+ this.socket?.destroy()
656
+ this.fail(new Error('dsh-builtin-browser: browser host sent a notification before authenticating'))
657
+ return
658
+ }
659
+ if (msg.op === 'userAction') {
660
+ // Route it straight to the host's user-action handler (which the provider registered).
661
+ this.onUserAction?.(msg.action as BrowserUserAction)
662
+ } else {
663
+ // The human closed the window, so the interface that session showed is gone. The
664
+ // provider decides what that ends — browsing history and login state survive on
665
+ // disk, only the session itself does not.
666
+ this.onViewClosed?.(typeof msg.windowId === 'string' ? msg.windowId : '')
667
+ }
616
668
  continue
617
669
  }
618
670
  if (this.awaitingHello) {
@@ -620,10 +672,7 @@ class ElectronChildClient {
620
672
  // Anything else (or a wrong token) means a spoofed connection: drop
621
673
  // it and fail every pending call rather than trust the line.
622
674
  this.awaitingHello = false
623
- if (this.helloTimer !== undefined) {
624
- clearTimeout(this.helloTimer)
625
- this.helloTimer = undefined
626
- }
675
+ this.clearHelloTimer()
627
676
  if (msg.op !== 'hello' || msg.token !== this.token) {
628
677
  this.socket?.destroy()
629
678
  this.fail(new Error('dsh-builtin-browser: browser host authentication failed'))
@@ -673,10 +722,38 @@ class ElectronChildClient {
673
722
  })
674
723
  }
675
724
 
676
- /** Terminate the child. */
725
+ /**
726
+ * How long the child gets to exit by itself after the socket closes, before it is killed.
727
+ * The child quits on that signal, so in practice this is a few dozen milliseconds; the timer
728
+ * exists for the case where it does not, and it is deliberately NOT unref'd — the parent must
729
+ * stay alive long enough to enforce it, or the escalation would be dropped with the process.
730
+ */
731
+ private static readonly KILL_GRACE_MS = 500
732
+ private killGraceTimer: NodeJS.Timeout | undefined
733
+
734
+ /**
735
+ * Terminate the child, after giving it the chance to shut down properly.
736
+ *
737
+ * The socket goes first, because that is the signal the child actually handles: its `close`
738
+ * listener quits the app, and a normal quit is what lets Chromium write its profile out. Only
739
+ * a child still alive after the grace period is killed outright.
740
+ *
741
+ * This order used to be the other way round, justified by a comment claiming the child's own
742
+ * path "closes its window and flushes the profile", which made killing first the safer order.
743
+ * Both halves of that were wrong: the path ran `app.exit(0)`, which skips before-quit/will-quit
744
+ * and does not flush Chromium's profile writers at all; and on Windows `child.kill()` is
745
+ * TerminateProcess, which lands before the child can run any handler whatsoever. So the
746
+ * graceful path that comment was protecting never got to start, and the profile went
747
+ * unflushed on every single dispose.
748
+ */
677
749
  kill(): void {
750
+ this.clearHelloTimer()
678
751
  try { this.socket?.destroy() } catch { /* already closed */ }
679
- this.child.kill()
752
+ const child = this.child
753
+ this.clearKillGrace()
754
+ this.killGraceTimer = setTimeout(() => {
755
+ try { child.kill() } catch { /* already gone */ }
756
+ }, ElectronChildClient.KILL_GRACE_MS)
680
757
  }
681
758
  }
682
759
 
@@ -730,6 +807,30 @@ const PROBE_RETRY_MS = (() => {
730
807
  return Number.isFinite(raw) && raw >= 0 ? raw : 30000
731
808
  })()
732
809
 
810
+ /**
811
+ * Retry schedule for a browser host that will not stay up, in milliseconds, indexed by the
812
+ * number of consecutive failed starts (see {@link RemoteElectronViewHost.noteStartFailure}).
813
+ *
814
+ * The first entry is 0 on purpose: an ordinary one-off death must still heal on the very
815
+ * next call, which is the behaviour remote-host-recovery.test.mjs pins. From the second
816
+ * failure on, each attempt is spaced. `available()` can only answer "is there a binary",
817
+ * so a binary that exists and crashes on every launch (`code=1` with empty stderr) would
818
+ * otherwise produce an endless crash loop, one window per tool call, with nothing to make
819
+ * it stop.
820
+ */
821
+ const START_RETRY_SCHEDULE_MS: readonly number[] = [0, 500, 2_000, 8_000, 30_000]
822
+
823
+ /** Attempts after which the host stops retrying immediately and keeps the longest delay. */
824
+ const START_FAILURES_MAX = START_RETRY_SCHEDULE_MS.length
825
+
826
+ /** Two reports within this window describe the same dying child, not two attempts. */
827
+ const START_FAILURE_DEDUPE_MS = 1_000
828
+
829
+ /** Render a delay for a human-readable log line. */
830
+ function formatDelay(ms: number): string {
831
+ return ms >= 1_000 ? `${(ms / 1_000).toFixed(1)}s` : `${String(ms)}ms`
832
+ }
833
+
733
834
  /**
734
835
  * Self-hosted view host: spawns the plugin's Electron child on first use and
735
836
  * keeps it alive until dispose(). Fallback when no desktop shell provides
@@ -747,6 +848,12 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
747
848
  private electronAvailable: boolean | undefined
748
849
  /** Earliest time the next probe may run after a negative one. */
749
850
  private nextProbeAt = 0
851
+ /** Consecutive child starts that did not survive; drives {@link startRetryAt}. */
852
+ private startFailures = 0
853
+ /** Earliest time `ready()` may spawn another child after a failed start. */
854
+ private startRetryAt: number | undefined
855
+ /** When the last start failure was reported, used to keep one death to one log line. */
856
+ private lastStartFailureAt = 0
750
857
  /** Window groups (windowId per view), re-sent on every materialization so
751
858
  * a restarted child still places views in the right windows. */
752
859
  private readonly groups = new Map<string, { windowId: string; label?: string }>()
@@ -799,6 +906,38 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
799
906
  return this.electronAvailable
800
907
  }
801
908
 
909
+ /**
910
+ * Retire the cached "an Electron binary is here" answer.
911
+ *
912
+ * A success is cached for the host's lifetime because rescanning on every tool call was
913
+ * the original bug — but the probe reads the FILESYSTEM, so it cannot see a binary that
914
+ * stops being usable afterwards. Only a real spawn attempt can observe that, which is why
915
+ * the two failure paths in {@link RemoteElectronViewHost.start} call this: the next
916
+ * `available()` rescans and reports what is actually on disk, instead of advertising a
917
+ * provider that cannot start.
918
+ */
919
+ private forgetBinary(): void {
920
+ this.electronAvailable = undefined
921
+ this.nextProbeAt = 0
922
+ }
923
+
924
+ /**
925
+ * Whether the binary the host would spawn — or the entry it needs — is no longer there.
926
+ *
927
+ * Only asked after a launch actually failed, so a healthy path never pays for it. The
928
+ * entry script counts because Electron exits 1 with empty stderr when it cannot load it,
929
+ * and resolution itself throws exactly when it can find nothing, which is the same answer.
930
+ * @returns true when the next `available()` must rescan instead of reusing its cache.
931
+ */
932
+ private binaryIsGone(): boolean {
933
+ if (!existsSync(this.hostMainPath)) return true
934
+ try {
935
+ return !existsSync(this.spawnExecutable ?? resolveElectronPath())
936
+ } catch {
937
+ return true
938
+ }
939
+ }
940
+
802
941
  /**
803
942
  * Ensure the child is up and ready (lazy on first use; restarts after a crash).
804
943
  *
@@ -821,25 +960,105 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
821
960
  return gone
822
961
  }
823
962
  if (this.readyPromise !== undefined) return this.readyPromise
963
+ // Backoff gate for a host that keeps failing to START (see START_RETRY_SCHEDULE_MS).
964
+ // The client is undefined exactly when the previous child is gone — a start failure, or
965
+ // a child that died after it came up — so this only delays restarts, never first use.
966
+ const retryAt = this.startRetryAt
967
+ if (retryAt !== undefined && Date.now() < retryAt && this.client === undefined) {
968
+ const waitMs = retryAt - Date.now()
969
+ const waiting = Promise.reject(new HostGoneError(
970
+ `browser host did not start; retrying in ${formatDelay(waitMs)} `
971
+ + `(attempt ${String(this.startFailures)}${this.startFailures >= START_FAILURES_MAX ? ', start attempts are now spaced out rather than immediate' : ''})`,
972
+ ))
973
+ // Keep the rejection observable to callers while marking it handled here: the
974
+ // fire-and-forget callers that drive this path attach their own handler, but one that
975
+ // forgets must not turn the recovery gate into an unhandled rejection.
976
+ waiting.catch(() => undefined)
977
+ return waiting
978
+ }
824
979
  const started = this.start()
825
- const wrapped = started.catch(error => {
826
- // A failed startup must not poison the host forever: tear down whatever
827
- // was half-created and let the next call retry from scratch.
828
- if (this.readyPromise === wrapped) {
829
- this.readyPromise = undefined
830
- this.client?.kill()
831
- this.client = undefined
832
- this.server?.close()
833
- this.server = undefined
834
- this.pendingSocket?.destroy()
835
- this.pendingSocket = undefined
836
- }
837
- throw error
838
- })
980
+ const wrapped = started.then(
981
+ () => {
982
+ // A child that came up clears the record: the next death is a new failure, not
983
+ // another entry in this run of them.
984
+ this.startFailures = 0
985
+ this.startRetryAt = undefined
986
+ },
987
+ error => {
988
+ // Record the failure BEFORE tearing anything down, and gate the teardown on the
989
+ // retry state being gone rather than on readyPromise alone. Ordering matters: a
990
+ // child that exits while the readiness call is still in flight fires the client's
991
+ // 'exit' handler first (onChildExit invalidates this promise and starts the retry
992
+ // clock), and the rejection then lands here — at which point clearing startRetryAt
993
+ // would erase the very backoff that was just armed, so every tool call spawned
994
+ // another dying child. noteStartFailure's dedupe window counts that pair once.
995
+ this.noteStartFailure(error)
996
+ // A failed startup must not poison the host forever: tear down whatever
997
+ // was half-created and let the next call retry from scratch.
998
+ if (this.readyPromise === wrapped) {
999
+ this.readyPromise = undefined
1000
+ this.client?.kill()
1001
+ this.client = undefined
1002
+ this.server?.close()
1003
+ this.server = undefined
1004
+ this.pendingSocket?.destroy()
1005
+ this.pendingSocket = undefined
1006
+ }
1007
+ throw error
1008
+ },
1009
+ )
839
1010
  this.readyPromise = wrapped
840
1011
  return wrapped
841
1012
  }
842
1013
 
1014
+ /**
1015
+ * Record one child that did not survive, and set the earliest retry time.
1016
+ *
1017
+ * `available()` answers "is there a binary to spawn" from the FILESYSTEM, and a success
1018
+ * there is cached for the host's lifetime (correctly — rescanning on every tool call was
1019
+ * the bug). That probe therefore cannot see the failure mode where the binary exists and
1020
+ * crashes on every launch: the signature `code=1` with empty stderr that host-log.test.mjs
1021
+ * asserts. Each browser call then spawned a child that died within milliseconds, with no
1022
+ * backoff and nothing that ever gives up — an endless crash loop, each iteration a window
1023
+ * flashing on screen.
1024
+ *
1025
+ * The first retry is immediate, so the ordinary transient death (the DSH restart in
1026
+ * remote-host-recovery.test.mjs) still heals on the very next call. After that the delay
1027
+ * grows: once {@link START_FAILURES_MAX} attempts have failed, every later attempt is
1028
+ * spaced by the table's last entry — the host keeps trying, but a permanently broken
1029
+ * install now costs one spawn per 30s instead of one per tool call. Nothing is marked
1030
+ * dead for good: a repaired installation heals on the next attempt, and `dispose()`
1031
+ * still stops everything immediately.
1032
+ * @param error - what the failed attempt reported.
1033
+ */
1034
+ private noteStartFailure(error: unknown): void {
1035
+ // The same death arrives twice: the child's exit reports it, and ready() rejecting
1036
+ // reports it again. Counting both pushed the first retry to the 500ms step and made
1037
+ // the log say "attempt 2" for a first failure, while START_RETRY_SCHEDULE_MS[0] = 0
1038
+ // exists precisely so an ordinary death heals on the next call. So a report inside the
1039
+ // window is dropped ENTIRELY — the count and the log line alike.
1040
+ //
1041
+ // That last part used to be split in two: the guard below, and a second dedupe that only
1042
+ // suppressed the LOG. The second one read `now - this.lastStartFailureAt` after the line
1043
+ // above had already stored `failureAt` into that same field, so its window was 0-1ms wide
1044
+ // and the comparison was unconditionally true — the log line was never written at all.
1045
+ // Nothing noticed, because this function exists to make a silent failure visible and its
1046
+ // only output was the thing being dropped: a child that exists and dies instantly (code=1,
1047
+ // empty stderr) left no trace anywhere.
1048
+ const failureAt = Date.now()
1049
+ if (this.lastStartFailureAt !== undefined && failureAt - this.lastStartFailureAt < START_FAILURE_DEDUPE_MS) return
1050
+ this.lastStartFailureAt = failureAt
1051
+ this.startFailures += 1
1052
+ const index = Math.min(this.startFailures, START_RETRY_SCHEDULE_MS.length) - 1
1053
+ const delayMs = START_RETRY_SCHEDULE_MS[index] ?? 0
1054
+ this.startRetryAt = delayMs > 0 ? failureAt + delayMs : undefined
1055
+ const line = `${hostStamp()} browser host start failed (attempt ${String(this.startFailures)}`
1056
+ + `${this.startFailures >= START_FAILURES_MAX ? ', giving up on immediate retries' : ''}): ${String(error)}`
1057
+ + `${delayMs > 0 ? ` — next attempt in ${formatDelay(delayMs)}` : ''}`
1058
+ process.stderr.write(`[dsh-browser host] ${line}\n`)
1059
+ appendHostLog(`${line}\n`)
1060
+ }
1061
+
843
1062
  private async start(): Promise<void> {
844
1063
  // Listen on an ephemeral loopback port; the child connects back. The
845
1064
  // server accepts exactly one connection: the child's. Any further
@@ -912,6 +1131,18 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
912
1131
  // ever kill it. Only the restart is suppressed, not the cleanup.
913
1132
  const wasDisposed = this.disposed
914
1133
  if (!wasDisposed) process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n')
1134
+ // A child that started and then died is also a failed start, and it is the shape a crash
1135
+ // loop takes (the child comes up far enough to be pending on `ping`, then exits): count
1136
+ // it so the restart is spaced instead of attempted on every single tool call.
1137
+ if (!wasDisposed) this.noteStartFailure('the browser host process exited')
1138
+ // The cached `available()` answer came from the FILESYSTEM, so only a real attempt can
1139
+ // contradict it. A binary that stops being usable after the probe said yes — an
1140
+ // interrupted reinstall, a removed mount, a wiped cache — kept the provider advertised
1141
+ // as available for the rest of the process's life, because provider selection reads
1142
+ // that same cached yes; every later call then paid a doomed spawn. Both failure routes
1143
+ // (a spawn error, and a child that exits) arrive here, so this is the one place that
1144
+ // observes the loss, and it costs two stats on a path that is already failing.
1145
+ if (this.binaryIsGone()) this.forgetBinary()
915
1146
  this.client = undefined
916
1147
  this.server?.close()
917
1148
  this.server = undefined
@@ -1072,10 +1303,17 @@ class DeferredRemoteView implements ElectronViewHandle {
1072
1303
  private materializeOnce(): Promise<RemoteView> {
1073
1304
  if (this.materialized === undefined) {
1074
1305
  const pending = this.materialize()
1075
- this.materialized = pending.catch(error => {
1076
- if (this.materialized === pending) this.materialized = undefined
1306
+ // Compare against the promise actually stored — the derived one — not against `pending`.
1307
+ // Storing `pending.catch(...)` and comparing `this.materialized === pending` compared two
1308
+ // different objects, so the test was never true and the reset never ran: a materialization
1309
+ // that failed with anything other than a HostGoneError (the child alive but not answering
1310
+ // a ping, say) poisoned the handle for good, and the retry the comment below promises was
1311
+ // unreachable.
1312
+ const guarded = pending.catch(error => {
1313
+ if (this.materialized === guarded) this.materialized = undefined
1077
1314
  throw error
1078
1315
  })
1316
+ this.materialized = guarded
1079
1317
  }
1080
1318
  return this.materialized
1081
1319
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The RPC operation vocabulary, in one place.
3
+ *
4
+ * The child (`host-main.ts`) switches on these names and the parent (`remote-host.ts`) sends
5
+ * them. Both spelled them out independently and nothing connected the two, so a rename on one
6
+ * side compiled fine and failed at run time as "unknown op" — and this pair has already
7
+ * produced one silent failure of that shape (`noteStartFailure`'s dedupe comparison, which was
8
+ * never true and meant a log line that never appeared).
9
+ *
10
+ * The lists are the source of truth for a test that reads both switch statements and asserts
11
+ * they agree, which is what makes a one-sided rename fail loudly instead of at run time.
12
+ */
13
+
14
+ /** Operations the parent asks the child to perform. */
15
+ export const RPC_OPS = [
16
+ 'ping',
17
+ 'createView',
18
+ 'destroyView',
19
+ 'showView',
20
+ 'groupView',
21
+ 'focus',
22
+ 'command',
23
+ 'capture',
24
+ 'download',
25
+ 'flushAuth',
26
+ 'restoreAuth',
27
+ 'navigate',
28
+ 'new-tab',
29
+ 'activate',
30
+ 'close',
31
+ 'back',
32
+ 'forward',
33
+ 'reload',
34
+ 'userActionError',
35
+ ] as const
36
+
37
+ /** Notifications the child sends the parent, which carry no reply. */
38
+ export const RPC_NOTIFICATIONS = ['userAction', 'viewClosed'] as const
39
+
40
+ /** One operation name. */
41
+ export type RpcOp = (typeof RPC_OPS)[number]
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Admission for the settings route: who may read the document, and who may write it.
3
+ *
4
+ * Its own module for a blunt reason — `entry.ts` imports the host's peer packages (cordis, the
5
+ * tools registry) and therefore cannot be loaded by the test suite, while this is precisely the
6
+ * logic whose failure is invisible in a browser: too strict and the panel silently stops being
7
+ * able to save, too loose and any process on the machine can rewrite the operator's switches.
8
+ *
9
+ * It is NOT authentication, and cannot be. The plugin has no credential to check: DSH hands
10
+ * plugin routes the raw request (`packages/host/webserver/src/index.ts` registers handlers with
11
+ * no gate of its own), so the strongest same-origin evidence available is what a browser
12
+ * reliably sends.
13
+ *
14
+ * What that buys and what it does not:
15
+ * - a plain HTTP client (curl, a script, another process) cannot WRITE, because it sends no
16
+ * `user-agent` and no `accept-language`;
17
+ * - a page of THIS origin can, because a browser sending a same-origin write looks exactly
18
+ * like the panel. That is inherent to exposing an HTTP endpoint at all, and it is why the
19
+ * settings panel's own text must not promise a lock that does not exist.
20
+ */
21
+ import type { IncomingMessage } from 'node:http'
22
+
23
+ /** The shell's own URL schemes. Only the shell can mint a document on one of these. */
24
+ const APP_SCHEMES = ['dsh-app:', 'dsh-desktop:']
25
+
26
+ /**
27
+ * The scheme of a header value, when it names a URL.
28
+ * @param value - a raw header value.
29
+ * @returns the scheme, or undefined when the value is absent, opaque or unparseable.
30
+ */
31
+ function schemeOf(value: string): string | undefined {
32
+ if (value === '' || value === 'null') return undefined
33
+ try {
34
+ return new URL(value).protocol
35
+ } catch {
36
+ return undefined
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Whether a request may touch the settings document.
42
+ * @param request - the incoming request.
43
+ * @param writing - true for PUT/POST, which is held to the stricter rule.
44
+ * @returns whether the request is admitted.
45
+ */
46
+ export function sameOrigin(request: IncomingMessage, writing = false): boolean {
47
+ const origin = String(request.headers.origin ?? '').trim()
48
+ const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase()
49
+
50
+ // A shell URL, in either the Origin or the referrer: the strongest evidence there is.
51
+ if (APP_SCHEMES.includes(schemeOf(origin) ?? '')) return true
52
+ if (APP_SCHEMES.includes(schemeOf(String(request.headers.referer ?? '')) ?? '')) return true
53
+
54
+ // MEASURED on the running desktop, and what three earlier attempts each guessed wrong: a
55
+ // request from the shell's own settings panel carries NO origin, NO sec-fetch-site and NO
56
+ // referer. It is not "dsh-app://app" and it is not "null" — the headers are simply absent,
57
+ // because Chromium attaches no cross-origin metadata to a request leaving a custom scheme.
58
+ //
59
+ // So the rule for that shape cannot be "a request without an Origin is a script", which is
60
+ // what refused every desktop save, and it cannot lean on `Sec-Fetch-Site` either. It has to
61
+ // test what a hand-rolled client does not send:
62
+ // - a renderer sends a full Mozilla-compatible `user-agent` AND an `accept-language`;
63
+ // - curl sends neither unless told to, and a local process that forges both can also read
64
+ // the settings document directly — it sits readable on disk, as the panel's own text
65
+ // says. This was never the boundary it looked like.
66
+ if (origin === '') {
67
+ const agent = String(request.headers['user-agent'] ?? '')
68
+ const language = String(request.headers['accept-language'] ?? '')
69
+ if (/^Mozilla\/5\.0/.test(agent) && language !== '') return true
70
+ // Reads stay open: they return the document the panel is already showing, and refusing them
71
+ // would only break hand-inspection of a file that sits readable on disk anyway.
72
+ return !writing
73
+ }
74
+
75
+ // `cross-site` is another origin entirely; `same-site` is another origin of the same site
76
+ // (a.example.com -> b.example.com), which is not this panel either. Both are refused.
77
+ if (site === 'cross-site' || site === 'same-site') return false
78
+ // `Origin: null` is a sandboxed or opaque-origin document. Never this panel.
79
+ if (origin === 'null') return false
80
+ try {
81
+ const parsed = new URL(origin)
82
+ // The host comparison is for http(s), where a page and its route share one host.
83
+ return parsed.host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase()
84
+ } catch {
85
+ return false
86
+ }
87
+ }