dsh-builtin-browser 0.4.0 → 0.4.2

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 (44) hide show
  1. package/README.en.md +68 -30
  2. package/README.md +66 -30
  3. package/client.js +60 -6
  4. package/cordis.patch.yml +21 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +12 -4
  6. package/docs/architecture.md +12 -2
  7. package/docs/tool-reference.md +17 -17
  8. package/docs/user-guide.md +13 -4
  9. package/docs/why-browser.md +2 -2
  10. package/lib/browser/types.d.ts +25 -1
  11. package/lib/browser-electron/bridge-connection.d.ts +31 -5
  12. package/lib/browser-electron/bridge-connection.js +143 -21
  13. package/lib/browser-electron/entry.d.ts +1 -1
  14. package/lib/browser-electron/entry.js +61 -35
  15. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  16. package/lib/browser-electron/ephemeral-profile.js +146 -0
  17. package/lib/browser-electron/history-store.d.ts +25 -0
  18. package/lib/browser-electron/history-store.js +51 -4
  19. package/lib/browser-electron/host-main.js +75 -27
  20. package/lib/browser-electron/provider.d.ts +44 -1
  21. package/lib/browser-electron/provider.js +379 -48
  22. package/lib/browser-electron/remote-host.d.ts +52 -3
  23. package/lib/browser-electron/remote-host.js +244 -23
  24. package/lib/browser-electron/settings-route.d.ts +28 -0
  25. package/lib/browser-electron/settings-route.js +28 -0
  26. package/lib/browser-electron/settings-store.d.ts +40 -0
  27. package/lib/browser-electron/settings-store.js +107 -11
  28. package/lib/browser-electron/system-browser.d.ts +71 -3
  29. package/lib/browser-electron/system-browser.js +355 -38
  30. package/lib/tool-browser/index.js +143 -34
  31. package/package.json +5 -3
  32. package/scripts/verify-host-compat.mjs +195 -0
  33. package/src/browser/types.ts +25 -1
  34. package/src/browser-electron/bridge-connection.ts +142 -21
  35. package/src/browser-electron/entry.ts +58 -33
  36. package/src/browser-electron/ephemeral-profile.ts +152 -0
  37. package/src/browser-electron/history-store.ts +51 -4
  38. package/src/browser-electron/host-main.ts +105 -35
  39. package/src/browser-electron/provider.ts +401 -51
  40. package/src/browser-electron/remote-host.ts +261 -36
  41. package/src/browser-electron/settings-route.ts +46 -0
  42. package/src/browser-electron/settings-store.ts +121 -11
  43. package/src/browser-electron/system-browser.ts +342 -21
  44. package/src/tool-browser/index.ts +144 -36
@@ -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,37 @@ 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
+ * Without this the timer would keep the parent's event loop alive for its full grace period
535
+ * after exactly the ordinary exit it was armed to catch as the bad case — and the child does
536
+ * not need killing once `fail()` has run for it.
537
+ */
538
+ private clearKillGrace(): void {
539
+ if (this.killGraceTimer === undefined) return
540
+ clearTimeout(this.killGraceTimer)
541
+ this.killGraceTimer = undefined
542
+ }
543
+
544
+ /** Drop the "did not authenticate" deadline once it can no longer be meaningful. */
545
+ private clearHelloTimer(): void {
546
+ if (this.helloTimer === undefined) return
547
+ clearTimeout(this.helloTimer)
548
+ this.helloTimer = undefined
549
+ }
550
+
526
551
  /** Reject everything in flight, mark the client dead, and notify the host. */
527
552
  private fail(err: Error): void {
528
553
  if (this.dead) return
529
554
  this.dead = true
530
555
  this.connected = false
556
+ // The authentication deadline outlives the socket it was armed for otherwise: it is
557
+ // keyed on `awaitingHello` alone, so a client that already failed on some other path
558
+ // kept a live timer until it fired (harmless, but it is a dangling timer in a process
559
+ // that may be shutting down).
560
+ this.clearHelloTimer()
561
+ this.clearKillGrace()
531
562
  // Tag every failure as "host gone" so the view layer can tell a dead host
532
563
  // apart from a page-level error and rebuild against a fresh child.
533
564
  const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message)
@@ -600,19 +631,34 @@ class ElectronChildClient {
600
631
  // Non-protocol line; ignore.
601
632
  continue
602
633
  }
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 : '')
634
+ if (msg.op === 'userAction' || msg.op === 'viewClosed') {
635
+ // A fire-and-forget notification from the child's own UI (toolbar). These carry no
636
+ // reply and no pending id, and they hand the provider a session-level verb — so they
637
+ // are dispatched ONLY once the peer has proved it knows the spawn token.
638
+ //
639
+ // They used to be handled before the hello check, while the module comment claimed an
640
+ // unauthenticated peer "is disconnected immediately … a spoofed socket never sees
641
+ // traffic". Both were wrong: any loopback process that won the race for the single
642
+ // accepted socket could inject `viewClosed` and end the human's browser session
643
+ // before the token check rejected it. Only the loopback interface can connect, so
644
+ // this is a same-machine race rather than a remote attack — which is exactly the
645
+ // kind of thing the token exists to close.
646
+ if (this.awaitingHello) {
647
+ this.awaitingHello = false
648
+ this.clearHelloTimer()
649
+ this.socket?.destroy()
650
+ this.fail(new Error('dsh-builtin-browser: browser host sent a notification before authenticating'))
651
+ return
652
+ }
653
+ if (msg.op === 'userAction') {
654
+ // Route it straight to the host's user-action handler (which the provider registered).
655
+ this.onUserAction?.(msg.action as BrowserUserAction)
656
+ } else {
657
+ // The human closed the window, so the interface that session showed is gone. The
658
+ // provider decides what that ends — browsing history and login state survive on
659
+ // disk, only the session itself does not.
660
+ this.onViewClosed?.(typeof msg.windowId === 'string' ? msg.windowId : '')
661
+ }
616
662
  continue
617
663
  }
618
664
  if (this.awaitingHello) {
@@ -620,10 +666,7 @@ class ElectronChildClient {
620
666
  // Anything else (or a wrong token) means a spoofed connection: drop
621
667
  // it and fail every pending call rather than trust the line.
622
668
  this.awaitingHello = false
623
- if (this.helloTimer !== undefined) {
624
- clearTimeout(this.helloTimer)
625
- this.helloTimer = undefined
626
- }
669
+ this.clearHelloTimer()
627
670
  if (msg.op !== 'hello' || msg.token !== this.token) {
628
671
  this.socket?.destroy()
629
672
  this.fail(new Error('dsh-builtin-browser: browser host authentication failed'))
@@ -673,10 +716,38 @@ class ElectronChildClient {
673
716
  })
674
717
  }
675
718
 
676
- /** Terminate the child. */
719
+ /**
720
+ * How long the child gets to exit by itself after the socket closes, before it is killed.
721
+ * The child quits on that signal, so in practice this is a few dozen milliseconds; the timer
722
+ * exists for the case where it does not, and it is deliberately NOT unref'd — the parent must
723
+ * stay alive long enough to enforce it, or the escalation would be dropped with the process.
724
+ */
725
+ private static readonly KILL_GRACE_MS = 500
726
+ private killGraceTimer: NodeJS.Timeout | undefined
727
+
728
+ /**
729
+ * Terminate the child, after giving it the chance to shut down properly.
730
+ *
731
+ * The socket goes first, because that is the signal the child actually handles: its `close`
732
+ * listener quits the app, and a normal quit is what lets Chromium write its profile out. Only
733
+ * a child still alive after the grace period is killed outright.
734
+ *
735
+ * This order used to be the other way round, justified by a comment claiming the child's own
736
+ * path "closes its window and flushes the profile", which made killing first the safer order.
737
+ * Both halves of that were wrong: the path ran `app.exit(0)`, which skips before-quit/will-quit
738
+ * and does not flush Chromium's profile writers at all; and on Windows `child.kill()` is
739
+ * TerminateProcess, which lands before the child can run any handler whatsoever. So the
740
+ * graceful path that comment was protecting never got to start, and the profile went
741
+ * unflushed on every single dispose.
742
+ */
677
743
  kill(): void {
744
+ this.clearHelloTimer()
678
745
  try { this.socket?.destroy() } catch { /* already closed */ }
679
- this.child.kill()
746
+ const child = this.child
747
+ this.clearKillGrace()
748
+ this.killGraceTimer = setTimeout(() => {
749
+ try { child.kill() } catch { /* already gone */ }
750
+ }, ElectronChildClient.KILL_GRACE_MS)
680
751
  }
681
752
  }
682
753
 
@@ -730,6 +801,30 @@ const PROBE_RETRY_MS = (() => {
730
801
  return Number.isFinite(raw) && raw >= 0 ? raw : 30000
731
802
  })()
732
803
 
804
+ /**
805
+ * Retry schedule for a browser host that will not stay up, in milliseconds, indexed by the
806
+ * number of consecutive failed starts (see {@link RemoteElectronViewHost.noteStartFailure}).
807
+ *
808
+ * The first entry is 0 on purpose: an ordinary one-off death must still heal on the very
809
+ * next call, which is the behaviour remote-host-recovery.test.mjs pins. From the second
810
+ * failure on, each attempt is spaced. `available()` can only answer "is there a binary",
811
+ * so a binary that exists and crashes on every launch (`code=1` with empty stderr) would
812
+ * otherwise produce an endless crash loop, one window per tool call, with nothing to make
813
+ * it stop.
814
+ */
815
+ const START_RETRY_SCHEDULE_MS: readonly number[] = [0, 500, 2_000, 8_000, 30_000]
816
+
817
+ /** Attempts after which the host stops retrying immediately and keeps the longest delay. */
818
+ const START_FAILURES_MAX = START_RETRY_SCHEDULE_MS.length
819
+
820
+ /** Two reports within this window describe the same dying child, not two attempts. */
821
+ const START_FAILURE_DEDUPE_MS = 1_000
822
+
823
+ /** Render a delay for a human-readable log line. */
824
+ function formatDelay(ms: number): string {
825
+ return ms >= 1_000 ? `${(ms / 1_000).toFixed(1)}s` : `${String(ms)}ms`
826
+ }
827
+
733
828
  /**
734
829
  * Self-hosted view host: spawns the plugin's Electron child on first use and
735
830
  * keeps it alive until dispose(). Fallback when no desktop shell provides
@@ -747,6 +842,12 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
747
842
  private electronAvailable: boolean | undefined
748
843
  /** Earliest time the next probe may run after a negative one. */
749
844
  private nextProbeAt = 0
845
+ /** Consecutive child starts that did not survive; drives {@link startRetryAt}. */
846
+ private startFailures = 0
847
+ /** Earliest time `ready()` may spawn another child after a failed start. */
848
+ private startRetryAt: number | undefined
849
+ /** When the last start failure was reported, used to keep one death to one log line. */
850
+ private lastStartFailureAt = 0
750
851
  /** Window groups (windowId per view), re-sent on every materialization so
751
852
  * a restarted child still places views in the right windows. */
752
853
  private readonly groups = new Map<string, { windowId: string; label?: string }>()
@@ -799,6 +900,38 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
799
900
  return this.electronAvailable
800
901
  }
801
902
 
903
+ /**
904
+ * Retire the cached "an Electron binary is here" answer.
905
+ *
906
+ * A success is cached for the host's lifetime because rescanning on every tool call was
907
+ * the original bug — but the probe reads the FILESYSTEM, so it cannot see a binary that
908
+ * stops being usable afterwards. Only a real spawn attempt can observe that, which is why
909
+ * the two failure paths in {@link RemoteElectronViewHost.start} call this: the next
910
+ * `available()` rescans and reports what is actually on disk, instead of advertising a
911
+ * provider that cannot start.
912
+ */
913
+ private forgetBinary(): void {
914
+ this.electronAvailable = undefined
915
+ this.nextProbeAt = 0
916
+ }
917
+
918
+ /**
919
+ * Whether the binary the host would spawn — or the entry it needs — is no longer there.
920
+ *
921
+ * Only asked after a launch actually failed, so a healthy path never pays for it. The
922
+ * entry script counts because Electron exits 1 with empty stderr when it cannot load it,
923
+ * and resolution itself throws exactly when it can find nothing, which is the same answer.
924
+ * @returns true when the next `available()` must rescan instead of reusing its cache.
925
+ */
926
+ private binaryIsGone(): boolean {
927
+ if (!existsSync(this.hostMainPath)) return true
928
+ try {
929
+ return !existsSync(this.spawnExecutable ?? resolveElectronPath())
930
+ } catch {
931
+ return true
932
+ }
933
+ }
934
+
802
935
  /**
803
936
  * Ensure the child is up and ready (lazy on first use; restarts after a crash).
804
937
  *
@@ -821,25 +954,105 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
821
954
  return gone
822
955
  }
823
956
  if (this.readyPromise !== undefined) return this.readyPromise
957
+ // Backoff gate for a host that keeps failing to START (see START_RETRY_SCHEDULE_MS).
958
+ // The client is undefined exactly when the previous child is gone — a start failure, or
959
+ // a child that died after it came up — so this only delays restarts, never first use.
960
+ const retryAt = this.startRetryAt
961
+ if (retryAt !== undefined && Date.now() < retryAt && this.client === undefined) {
962
+ const waitMs = retryAt - Date.now()
963
+ const waiting = Promise.reject(new HostGoneError(
964
+ `browser host did not start; retrying in ${formatDelay(waitMs)} `
965
+ + `(attempt ${String(this.startFailures)}${this.startFailures >= START_FAILURES_MAX ? ', start attempts are now spaced out rather than immediate' : ''})`,
966
+ ))
967
+ // Keep the rejection observable to callers while marking it handled here: the
968
+ // fire-and-forget callers that drive this path attach their own handler, but one that
969
+ // forgets must not turn the recovery gate into an unhandled rejection.
970
+ waiting.catch(() => undefined)
971
+ return waiting
972
+ }
824
973
  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
- })
974
+ const wrapped = started.then(
975
+ () => {
976
+ // A child that came up clears the record: the next death is a new failure, not
977
+ // another entry in this run of them.
978
+ this.startFailures = 0
979
+ this.startRetryAt = undefined
980
+ },
981
+ error => {
982
+ // Record the failure BEFORE tearing anything down, and gate the teardown on the
983
+ // retry state being gone rather than on readyPromise alone. Ordering matters: a
984
+ // child that exits while the readiness call is still in flight fires the client's
985
+ // 'exit' handler first (onChildExit invalidates this promise and starts the retry
986
+ // clock), and the rejection then lands here — at which point clearing startRetryAt
987
+ // would erase the very backoff that was just armed, so every tool call spawned
988
+ // another dying child. noteStartFailure's dedupe window counts that pair once.
989
+ this.noteStartFailure(error)
990
+ // A failed startup must not poison the host forever: tear down whatever
991
+ // was half-created and let the next call retry from scratch.
992
+ if (this.readyPromise === wrapped) {
993
+ this.readyPromise = undefined
994
+ this.client?.kill()
995
+ this.client = undefined
996
+ this.server?.close()
997
+ this.server = undefined
998
+ this.pendingSocket?.destroy()
999
+ this.pendingSocket = undefined
1000
+ }
1001
+ throw error
1002
+ },
1003
+ )
839
1004
  this.readyPromise = wrapped
840
1005
  return wrapped
841
1006
  }
842
1007
 
1008
+ /**
1009
+ * Record one child that did not survive, and set the earliest retry time.
1010
+ *
1011
+ * `available()` answers "is there a binary to spawn" from the FILESYSTEM, and a success
1012
+ * there is cached for the host's lifetime (correctly — rescanning on every tool call was
1013
+ * the bug). That probe therefore cannot see the failure mode where the binary exists and
1014
+ * crashes on every launch: the signature `code=1` with empty stderr that host-log.test.mjs
1015
+ * asserts. Each browser call then spawned a child that died within milliseconds, with no
1016
+ * backoff and nothing that ever gives up — an endless crash loop, each iteration a window
1017
+ * flashing on screen.
1018
+ *
1019
+ * The first retry is immediate, so the ordinary transient death (the DSH restart in
1020
+ * remote-host-recovery.test.mjs) still heals on the very next call. After that the delay
1021
+ * grows: once {@link START_FAILURES_MAX} attempts have failed, every later attempt is
1022
+ * spaced by the table's last entry — the host keeps trying, but a permanently broken
1023
+ * install now costs one spawn per 30s instead of one per tool call. Nothing is marked
1024
+ * dead for good: a repaired installation heals on the next attempt, and `dispose()`
1025
+ * still stops everything immediately.
1026
+ * @param error - what the failed attempt reported.
1027
+ */
1028
+ private noteStartFailure(error: unknown): void {
1029
+ // The same death arrives twice: the child's exit reports it, and ready() rejecting
1030
+ // reports it again. Counting both pushed the first retry to the 500ms step and made
1031
+ // the log say "attempt 2" for a first failure, while START_RETRY_SCHEDULE_MS[0] = 0
1032
+ // exists precisely so an ordinary death heals on the next call. So a report inside the
1033
+ // window is dropped ENTIRELY — the count and the log line alike.
1034
+ //
1035
+ // That last part used to be split in two: the guard below, and a second dedupe that only
1036
+ // suppressed the LOG. The second one read `now - this.lastStartFailureAt` after the line
1037
+ // above had already stored `failureAt` into that same field, so its window was 0-1ms wide
1038
+ // and the comparison was unconditionally true — the log line was never written at all.
1039
+ // Nothing noticed, because this function exists to make a silent failure visible and its
1040
+ // only output was the thing being dropped: a child that exists and dies instantly (code=1,
1041
+ // empty stderr) left no trace anywhere.
1042
+ const failureAt = Date.now()
1043
+ if (this.lastStartFailureAt !== undefined && failureAt - this.lastStartFailureAt < START_FAILURE_DEDUPE_MS) return
1044
+ this.lastStartFailureAt = failureAt
1045
+ this.startFailures += 1
1046
+ const index = Math.min(this.startFailures, START_RETRY_SCHEDULE_MS.length) - 1
1047
+ const delayMs = START_RETRY_SCHEDULE_MS[index] ?? 0
1048
+ this.startRetryAt = delayMs > 0 ? failureAt + delayMs : undefined
1049
+ const line = `${hostStamp()} browser host start failed (attempt ${String(this.startFailures)}`
1050
+ + `${this.startFailures >= START_FAILURES_MAX ? ', giving up on immediate retries' : ''}): ${String(error)}`
1051
+ + `${delayMs > 0 ? ` — next attempt in ${formatDelay(delayMs)}` : ''}`
1052
+ process.stderr.write(`[dsh-browser host] ${line}\n`)
1053
+ appendHostLog(`${line}\n`)
1054
+ }
1055
+
843
1056
  private async start(): Promise<void> {
844
1057
  // Listen on an ephemeral loopback port; the child connects back. The
845
1058
  // server accepts exactly one connection: the child's. Any further
@@ -912,6 +1125,18 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
912
1125
  // ever kill it. Only the restart is suppressed, not the cleanup.
913
1126
  const wasDisposed = this.disposed
914
1127
  if (!wasDisposed) process.stderr.write('[dsh-browser host] browser host gone; will restart on next use\n')
1128
+ // A child that started and then died is also a failed start, and it is the shape a crash
1129
+ // loop takes (the child comes up far enough to be pending on `ping`, then exits): count
1130
+ // it so the restart is spaced instead of attempted on every single tool call.
1131
+ if (!wasDisposed) this.noteStartFailure('the browser host process exited')
1132
+ // The cached `available()` answer came from the FILESYSTEM, so only a real attempt can
1133
+ // contradict it. A binary that stops being usable after the probe said yes — an
1134
+ // interrupted reinstall, a removed mount, a wiped cache — kept the provider advertised
1135
+ // as available for the rest of the process's life, because provider selection reads
1136
+ // that same cached yes; every later call then paid a doomed spawn. Both failure routes
1137
+ // (a spawn error, and a child that exits) arrive here, so this is the one place that
1138
+ // observes the loss, and it costs two stats on a path that is already failing.
1139
+ if (this.binaryIsGone()) this.forgetBinary()
915
1140
  this.client = undefined
916
1141
  this.server?.close()
917
1142
  this.server = undefined
@@ -0,0 +1,46 @@
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 — `Sec-Fetch-Site`, plus `Origin` on every write, same-origin included.
13
+ *
14
+ * What that buys and what it does not:
15
+ * - a plain HTTP client (curl, a script, another process) cannot WRITE any more, because it
16
+ * sends no `Origin`;
17
+ * - a page of THIS origin still can, because a browser sending a same-origin write looks
18
+ * exactly like the panel. That is inherent to exposing an HTTP endpoint at all, and it is
19
+ * why the settings panel's own text must not promise a lock that does not exist.
20
+ */
21
+ import type { IncomingMessage } from 'node:http'
22
+
23
+ /**
24
+ * Whether a request may touch the settings document.
25
+ * @param request - the incoming request.
26
+ * @param writing - true for PUT/POST, which is held to the stricter rule.
27
+ * @returns whether the request is admitted.
28
+ */
29
+ export function sameOrigin(request: IncomingMessage, writing = false): boolean {
30
+ const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase()
31
+ // `cross-site` is another origin entirely; `same-site` is another origin of the same site
32
+ // (a.example.com -> b.example.com), which is not this panel either. Both are refused.
33
+ if (site === 'cross-site' || site === 'same-site') return false
34
+ const origin = String(request.headers.origin ?? '').trim()
35
+ // `Origin: null` is what a sandboxed or opaque-origin document sends. Never this panel.
36
+ if (origin === 'null') return false
37
+ // Reads are let through without an Origin: they return the document the panel is already
38
+ // showing, and refusing them would only break hand-inspection of a file that sits readable
39
+ // on disk anyway. Writes are not: a browser always sends `Origin` on one.
40
+ if (origin === '') return !writing
41
+ try {
42
+ return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase()
43
+ } catch {
44
+ return false
45
+ }
46
+ }
@@ -9,7 +9,7 @@
9
9
  * @module dsh-browser/browser-electron/settings-store
10
10
  */
11
11
 
12
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
13
13
  import { homedir } from 'node:os'
14
14
  import { dirname, join } from 'node:path'
15
15
  import type { BrowserChannel } from './system-browser.js'
@@ -50,6 +50,22 @@ export interface BrowserSettings {
50
50
  readonly credentials: {
51
51
  readonly allowRead: boolean
52
52
  }
53
+ /**
54
+ * What the agent is allowed to DO, as opposed to what it may look at.
55
+ *
56
+ * These are OPERATOR switches: the settings panel is not reachable from the model, so a
57
+ * value here refuses whatever the model asks for, and no tool can lift it. That is what
58
+ * separates them from `browser_restrict`, which is the model's own soft guardrail. All
59
+ * three default to on, which is the documented out-of-the-box behaviour.
60
+ */
61
+ readonly actions: {
62
+ /** Run arbitrary page scripts in the active tab (`browser_execute`). */
63
+ readonly allowExecute: boolean
64
+ /** Download a URL to disk (`browser_download`). */
65
+ readonly allowDownload: boolean
66
+ /** Write cookies — importing login state (`browser_auth` restore). */
67
+ readonly allowCredentialWrite: boolean
68
+ }
53
69
  }
54
70
 
55
71
  /** Defaults for every switch: the documented out-of-the-box behaviour. */
@@ -63,6 +79,7 @@ export const DEFAULT_SETTINGS: BrowserSettings = {
63
79
  // it starts a real Chrome/Edge with its own profile.
64
80
  browser: { channel: 'bundled' },
65
81
  credentials: { allowRead: true },
82
+ actions: { allowExecute: true, allowDownload: true, allowCredentialWrite: true },
66
83
  }
67
84
 
68
85
  /**
@@ -80,6 +97,38 @@ function bool(value: unknown, fallback: boolean): boolean {
80
97
  return typeof value === 'boolean' ? value : fallback
81
98
  }
82
99
 
100
+ /**
101
+ * Coerce one boolean field that GATES A CAPABILITY.
102
+ *
103
+ * The difference from {@link bool} is what a mistyped value means. Absent still takes the
104
+ * default — a document written before the switch existed is the documented behaviour. But
105
+ * present-and-not-a-boolean is a document this code cannot read, and for a switch that gates
106
+ * something the operator chose to limit, unreadable resolves to OFF. `bool`'s fallback made
107
+ * `"false"` (a string), `null` (a truncated write) and `1` all mean the same as `true`, so a
108
+ * single dropped byte reopened every capability that had been turned off.
109
+ * @param value - the raw value from the settings document.
110
+ * @param fallback - the default, used only when the field is absent.
111
+ * @returns the value when it is a boolean, `false` when it is anything else.
112
+ */
113
+ function gate(value: unknown, fallback: boolean): boolean {
114
+ if (value === undefined) return fallback
115
+ return value === true
116
+ }
117
+
118
+ /**
119
+ * What an unreadable document resolves to.
120
+ *
121
+ * Only the capability gates are refused; everything else keeps its default. The distinction is
122
+ * deliberate: refusing history, presentation or browser choice would punish the user for a
123
+ * corrupt file without protecting anything, while refusing the gates is the whole point —
124
+ * "the operator's intent cannot be read" must not resolve to "the operator permits".
125
+ */
126
+ const SETTINGS_WHEN_UNREADABLE: BrowserSettings = {
127
+ ...DEFAULT_SETTINGS,
128
+ credentials: { allowRead: false },
129
+ actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
130
+ }
131
+
83
132
  /**
84
133
  * Coerce the browser channel, keeping the default when it is not one of the
85
134
  * known choices. A hand-edited typo must not silently select a different browser.
@@ -118,6 +167,7 @@ export function resolveSettings(raw: unknown): BrowserSettings {
118
167
  const vision = section(source, 'vision')
119
168
  const credentials = section(source, 'credentials')
120
169
  const browser = section(source, 'browser')
170
+ const actions = section(source, 'actions')
121
171
  return {
122
172
  history: {
123
173
  enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
@@ -132,7 +182,12 @@ export function resolveSettings(raw: unknown): BrowserSettings {
132
182
  },
133
183
  vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
134
184
  browser: { channel: resolveChannel(browser.channel) },
135
- credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
185
+ credentials: { allowRead: gate(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
186
+ actions: {
187
+ allowExecute: gate(actions.allowExecute, DEFAULT_SETTINGS.actions.allowExecute),
188
+ allowDownload: gate(actions.allowDownload, DEFAULT_SETTINGS.actions.allowDownload),
189
+ allowCredentialWrite: gate(actions.allowCredentialWrite, DEFAULT_SETTINGS.actions.allowCredentialWrite),
190
+ },
136
191
  }
137
192
  }
138
193
 
@@ -163,18 +218,33 @@ export class SettingsStore {
163
218
  */
164
219
  get(): BrowserSettings {
165
220
  let mtimeMs = -1
166
- let text: string | undefined
167
221
  try {
168
- if (existsSync(this.file)) {
169
- mtimeMs = statSync(this.file).mtimeMs
170
- text = readFileSync(this.file, 'utf8')
171
- }
222
+ // stat directly: existsSync costs about thirteen times what the stat does, and the
223
+ // stat already throws when the file is missing — which the catch below handles.
224
+ mtimeMs = statSync(this.file).mtimeMs
172
225
  } catch {
173
226
  // Unreadable file: fall through to defaults below.
174
227
  mtimeMs = -1
175
- text = undefined
176
228
  }
229
+ // Return the cache BEFORE reading. The read used to happen first "just in case", which
230
+ // made the cache pointless: every call paid for a synchronous read of the whole file and
231
+ // then discarded it. This is on the path of every tool call that consults a setting, so
232
+ // it was a stat plus a read per call where a single stat will do.
177
233
  if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs) return this.cached
234
+
235
+ let text: string | undefined
236
+ if (mtimeMs !== -1) {
237
+ try {
238
+ text = readFileSync(this.file, 'utf8')
239
+ } catch {
240
+ // It was there a moment ago (the stat above) and cannot be read now: permissions, a
241
+ // lock, a directory in the way. That is not "no document" — the file is not absent, so
242
+ // what it said is unknown, and unknown resolves to the refusing document.
243
+ this.cached = SETTINGS_WHEN_UNREADABLE
244
+ this.cachedMtimeMs = mtimeMs
245
+ return this.cached
246
+ }
247
+ }
178
248
  try {
179
249
  // A leading BOM is what ordinary Windows editors (Notepad, PowerShell's
180
250
  // `Set-Content -Encoding utf8`) leave behind, and `JSON.parse` rejects it.
@@ -184,8 +254,12 @@ export class SettingsStore {
184
254
  const body = text?.replace(/^\uFEFF/, '')
185
255
  this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body))
186
256
  } catch {
187
- // Malformed JSON: behave as defaults rather than fail startup.
188
- this.cached = DEFAULT_SETTINGS
257
+ // A document that EXISTS but cannot be parsed is not the same as no document at all: the
258
+ // first is a corrupted or half-written file whose contents were the operator's decisions,
259
+ // the second is a first run. Resolving both to the defaults meant a kill during the write,
260
+ // or one stray character, silently reopened the capabilities that had been switched off —
261
+ // so this side refuses them instead (see SETTINGS_WHEN_UNREADABLE).
262
+ this.cached = SETTINGS_WHEN_UNREADABLE
189
263
  }
190
264
  this.cachedMtimeMs = mtimeMs
191
265
  return this.cached
@@ -201,7 +275,7 @@ export class SettingsStore {
201
275
  const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch))
202
276
  try {
203
277
  mkdirSync(dirname(this.file), { recursive: true })
204
- writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`)
278
+ writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`)
205
279
  } catch (error) {
206
280
  // Do NOT publish the new value as cached, and do not pretend the file changed: a
207
281
  // reader compares the file's mtime against this stamp, so stamping "now" on a failed
@@ -218,6 +292,42 @@ export class SettingsStore {
218
292
  }
219
293
  }
220
294
 
295
+ /**
296
+ * Replace a file's contents atomically: write a sibling temporary file, then rename it over
297
+ * the target.
298
+ *
299
+ * `writeFileSync(target, …)` truncates and rewrites in place, so a crash, a full disk or an
300
+ * antivirus lock in the middle leaves HALF a JSON document. That used to be silent: the reader
301
+ * resolved an unparseable file to the defaults, so a torn write flipped `credentials.allowRead`
302
+ * and the action switches back ON, and the next successful update wrote those defaults to disk
303
+ * for good. The read side no longer does that (see {@link SETTINGS_WHEN_UNREADABLE}) — but the
304
+ * write side still has to stop producing torn files at all, because half a document is not
305
+ * something this plugin should ever leave behind.
306
+ *
307
+ * The rename is the commit point: a reader sees either the old document or the new one,
308
+ * never a partial one. It stays in the same directory so the rename is atomic on Windows
309
+ * as well as POSIX (`renameSync` maps to MoveFileEx there: same-volume replaces are
310
+ * atomic, cross-volume ones are not supported at all).
311
+ *
312
+ * The temporary is written with a unique suffix rather than a fixed `.tmp`, so two writers
313
+ * (the settings panel and a second DSH process) cannot rename each other's half-written
314
+ * file into place. Shared with the history store, which had the same hole.
315
+ * @param file - the target path.
316
+ * @param contents - the complete new document.
317
+ */
318
+ export function writeFileAtomic(file: string, contents: string): void {
319
+ const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`
320
+ try {
321
+ writeFileSync(temporary, contents)
322
+ renameSync(temporary, file)
323
+ } catch (error) {
324
+ // The rename is the commit point, so a failure before it leaves the target untouched —
325
+ // only the temporary has to go, and it must not be left behind as garbage.
326
+ try { rmSync(temporary, { force: true }) } catch { /* the temp is already the least of it */ }
327
+ throw error
328
+ }
329
+ }
330
+
221
331
  /** One-level-deep merge over the settings sections (patch wins). */
222
332
  function deepMerge(base: Record<string, unknown>, patch: unknown): Record<string, unknown> {
223
333
  if (typeof patch !== 'object' || patch === null) return base