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
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The ephemeral browser profile: a throwaway directory under the DSH home, used when the
3
+ * user has turned profile persistence off, and deleted when the browser is released.
4
+ *
5
+ * Deleting on release is not enough by itself. A process killed outright — a DSH restart
6
+ * that ends in SIGKILL, "End task" in a task manager, a crashed host — never runs its
7
+ * cleanup, so the profile it was using survives on disk: the user asked for no profile to
8
+ * be kept and the machine keeps one anyway, holding cookies and history. Nothing can run
9
+ * at SIGKILL time, so the cleanup has to happen on the NEXT run instead, which is what
10
+ * this module provides.
11
+ *
12
+ * Ownership is a marker file inside the directory naming the process that claimed it. A
13
+ * directory whose owner is gone AND which has not been written to for a while is
14
+ * abandoned. The age check is what keeps the sweep away from a profile an orphaned
15
+ * browser (a child that outlived the parent which was killed) may still be using: a live
16
+ * Chromium writes to its profile continuously, so a directory that has not changed in an
17
+ * hour is not in use.
18
+ */
19
+ import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ /** The marker file, named once because both the claim and the sweep read it. */
23
+ const OWNER_FILE = '.dsh-ephemeral-owner'
24
+
25
+ /**
26
+ * How long a profile is left alone regardless of ownership. An orphaned browser keeps
27
+ * writing to its profile, so this is what distinguishes "abandoned" from "in use by a
28
+ * process this one did not start".
29
+ */
30
+ const ABANDONED_AFTER_MS = 60 * 60 * 1000
31
+
32
+ /** Test seams; production passes neither. */
33
+ export interface SweepOptions {
34
+ /** The clock, for the age check. */
35
+ readonly now?: number
36
+ /** Liveness test for an owner pid. */
37
+ readonly alive?: (pid: number) => boolean
38
+ }
39
+
40
+ /**
41
+ * Whether a process with this pid exists.
42
+ *
43
+ * A signal of 0 performs the permission and existence checks without delivering anything.
44
+ * EPERM is a live process owned by someone else — alive, not gone.
45
+ * @param pid - the pid to test.
46
+ * @returns true when the process exists.
47
+ */
48
+ function processAlive(pid: number): boolean {
49
+ try {
50
+ process.kill(pid, 0)
51
+ return true
52
+ } catch (error) {
53
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The directory name an ephemeral profile for `kind` gets, before it is claimed.
59
+ *
60
+ * The sweep matches on the same shape, so the two must agree: a name built anywhere else
61
+ * is a directory the sweep will never find.
62
+ * @param kind - the detected browser product (`chrome`, `edge`, ...).
63
+ * @param id - a per-run unique suffix.
64
+ * @returns the directory NAME, not a path.
65
+ */
66
+ export function ephemeralProfileName(kind: string, id: string): string {
67
+ return `${kind}-profile-ephemeral-${id}`
68
+ }
69
+
70
+ /**
71
+ * Mark an ephemeral profile directory as this process's, so a later run can tell whether
72
+ * the process that created it is still there.
73
+ * @param dir - the profile directory (created if it does not exist yet).
74
+ * @param pid - the claiming process, defaulting to this one.
75
+ * @param now - the claim time, defaulting to the current clock.
76
+ * @returns true when the marker was written.
77
+ */
78
+ export function claimEphemeralProfile(dir: string, pid: number = process.pid, now: number = Date.now()): boolean {
79
+ try {
80
+ mkdirSync(dir, { recursive: true })
81
+ writeFileSync(join(dir, OWNER_FILE), JSON.stringify({ pid, at: now }))
82
+ return true
83
+ } catch {
84
+ // The browser creates the directory itself when this fails, and cleanup is best
85
+ // effort throughout: a marker that could not be written must not stop the launch.
86
+ return false
87
+ }
88
+ }
89
+
90
+ /**
91
+ * The claimed owner of an ephemeral profile, when it has one.
92
+ * @param dir - the profile directory.
93
+ * @returns the owner pid, or undefined when there is no readable marker.
94
+ */
95
+ function readOwner(dir: string): number | undefined {
96
+ try {
97
+ const parsed = JSON.parse(readFileSync(join(dir, OWNER_FILE), 'utf8')) as { pid?: unknown }
98
+ return typeof parsed.pid === 'number' ? parsed.pid : undefined
99
+ } catch {
100
+ // No marker: a run from before this existed, or one killed before it wrote one. The
101
+ // age check has already applied, and a directory with no claimant that old is
102
+ // abandoned by any reading.
103
+ return undefined
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Delete the ephemeral profiles under `root` that belong to processes which are gone.
109
+ *
110
+ * Called before a new one is claimed, because that is the only moment that can still act
111
+ * on a profile whose owner was killed: nothing runs when the signal lands.
112
+ * @param root - the plugin's profile root (`<DSH_HOME>/dsh-builtin-browser-host`).
113
+ * @param kind - the browser product, which selects the directory-name prefix.
114
+ * @param options - test seams for the clock and the liveness test.
115
+ * @returns the names of the directories that were removed.
116
+ */
117
+ export function sweepAbandonedEphemeralProfiles(root: string, kind: string, options: SweepOptions = {}): string[] {
118
+ const now = options.now ?? Date.now()
119
+ const alive = options.alive ?? processAlive
120
+ const prefix = `${kind}-profile-ephemeral-`
121
+ const removed: string[] = []
122
+ let names: string[]
123
+ try {
124
+ names = readdirSync(root)
125
+ } catch {
126
+ // No profile root yet: nothing has ever been written here, so nothing to sweep.
127
+ return removed
128
+ }
129
+ for (const name of names) {
130
+ if (!name.startsWith(prefix)) continue
131
+ const dir = join(root, name)
132
+ let ageMs: number
133
+ try {
134
+ ageMs = now - statSync(dir).mtimeMs
135
+ } catch {
136
+ continue
137
+ }
138
+ // Recent writes mean a browser is still working in there. A killed DSH can leave its
139
+ // Chromium child running, and that child's profile is not garbage.
140
+ if (ageMs < ABANDONED_AFTER_MS) continue
141
+ const owner = readOwner(dir)
142
+ if (owner !== undefined && alive(owner)) continue
143
+ try {
144
+ rmSync(dir, { recursive: true, force: true })
145
+ removed.push(name)
146
+ } catch {
147
+ // In use, or not ours to remove. Leaving it is the safe outcome and the next run
148
+ // tries again — the alternative is deleting a profile out from under a live browser.
149
+ }
150
+ }
151
+ return removed
152
+ }
@@ -13,10 +13,11 @@
13
13
  * @module dsh-browser/browser-electron/history-store
14
14
  */
15
15
 
16
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
17
17
  import { homedir } from 'node:os'
18
18
  import { dirname, join } from 'node:path'
19
19
  import type { VisitedPage } from '../browser/types.js'
20
+ import { writeFileAtomic } from './settings-store.js'
20
21
 
21
22
  export type { VisitedPage }
22
23
 
@@ -51,8 +52,24 @@ export function visitedHistoryPath(): string {
51
52
  * a corrupted line must never hide the rest of the history.
52
53
  */
53
54
  export class HistoryStore {
54
- private readonly maxEntries: number
55
- private readonly maxAgeMs: number
55
+ /**
56
+ * File size at which the next rewrite happens.
57
+ *
58
+ * Advanced after each rewrite so the cost is paid once per PRUNE_SLACK appends
59
+ * rather than on every append: asking "is it over the cap?" was true from the first
60
+ * over-cap write onwards, which is what made every later navigation rewrite the file.
61
+ */
62
+ private pruneThreshold: number
63
+ /**
64
+ * Appends since the last rewrite.
65
+ *
66
+ * prune() runs after every navigation, and the answer is almost always "nothing to
67
+ * do" — but working that out from the file costs a full parse. Counting instead makes
68
+ * the common case a comparison.
69
+ */
70
+ private appendedSincePrune = 0
71
+ private maxEntries: number
72
+ private maxAgeMs: number
56
73
 
57
74
  /**
58
75
  * @param file - absolute path of the JSONL history file.
@@ -64,6 +81,23 @@ export class HistoryStore {
64
81
  ) {
65
82
  this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
66
83
  this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
84
+ // The first rewrite is due once the file has grown the slack past the cap.
85
+ this.pruneThreshold = this.maxEntries + this.slack()
86
+ }
87
+
88
+ /**
89
+ * Re-read the retention limits.
90
+ *
91
+ * They are settings, and settings change while the plugin runs — a panel that let the
92
+ * operator edit them while nothing read the new values made the control a lie. Entry count
93
+ * and age are the pair the interface presents, so they are updated together.
94
+ * @param limits - the current retention overrides.
95
+ */
96
+ updateLimits(limits: HistoryLimits): void {
97
+ this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
98
+ this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
99
+ // Recomputed rather than left alone: a smaller cap means the next rewrite is sooner.
100
+ this.pruneThreshold = Math.max(this.pruneThreshold, this.maxEntries + this.slack())
67
101
  }
68
102
 
69
103
  /** Whether the history file exists yet (diagnostics and tests). */
@@ -84,6 +118,7 @@ export class HistoryStore {
84
118
  } catch {
85
119
  // Best-effort: history is not worth failing a navigation over.
86
120
  }
121
+ this.appendedSincePrune++
87
122
  }
88
123
 
89
124
  /**
@@ -123,20 +158,57 @@ export class HistoryStore {
123
158
  * the count cap. Cheap no-op until the file grows past the cap, so callers can
124
159
  * run it after every append.
125
160
  */
161
+ /**
162
+ * How far past the cap the file may grow before it is rewritten.
163
+ *
164
+ * Scaled to the cap rather than fixed: a fixed 500 was meaningless when the cap is
165
+ * small (a caller asking for three entries would never reach 503), and the slack is
166
+ * there to batch rewrites, not to overrule the caller's bound.
167
+ * @returns the number of extra entries tolerated before a rewrite.
168
+ */
169
+ private slack(): number {
170
+ return Math.min(PRUNE_SLACK, Math.max(1, Math.floor(this.maxEntries / 2)))
171
+ }
172
+
126
173
  prune(): void {
174
+ // Cheap first: past the cap but without enough new entries to justify a rewrite.
175
+ // Reading the file to reach this conclusion was the remaining per-navigation cost.
176
+ if (this.appendedSincePrune === 0) return
127
177
  const all = this.readAll()
128
178
  const cutoff = Date.now() - this.maxAgeMs
129
179
  const fresh = all.filter(page => page.at >= cutoff)
130
180
  const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh
131
- // Only rewrite when something actually fell off, and only once the excess
132
- // is worth a full rewrite.
133
- if (kept.length === all.length) return
134
- if (all.length - kept.length < PRUNE_SLACK && fresh.length <= this.maxEntries) return
181
+ // Every early exit has to retire the count it just spent, or the gate above stops gating.
182
+ // It counted appends so that the common "nothing to do" case is a comparison; leaving the
183
+ // counter set on the two paths that DO read the file meant it was never zero again, so
184
+ // every navigation paid the full parse this was written to avoid.
185
+ if (kept.length === all.length) {
186
+ this.appendedSincePrune = 0
187
+ return
188
+ }
189
+ // Only rewrite once the file has grown PRUNE_SLACK past what we last wrote. Asking
190
+ // "is the file over the cap?" instead was true from the first over-cap append
191
+ // onwards, so every single navigation after that rewrote the whole file.
192
+ if (all.length < this.pruneThreshold) {
193
+ // Deliberately NOT reset here: the count is what tracks progress toward the threshold,
194
+ // so clearing it would postpone the rewrite indefinitely. The read is the price of
195
+ // staying under the threshold, and the threshold is what bounds how often that happens.
196
+ return
197
+ }
135
198
  try {
136
199
  const body = kept.map(page => `${JSON.stringify(page)}\n`).join('')
137
- writeFileSync(this.file, body)
200
+ // Atomic, like the settings document. A plain writeFileSync truncates first, and this is
201
+ // the whole history in one call: a TerminateProcess mid-write — the browser host is
202
+ // killed exactly that way on Windows — left a partial file, and the reader drops a
203
+ // half-written TAIL rather than the middle, so everything after the tear was lost.
204
+ writeFileAtomic(this.file, body)
205
+ // The file now holds only what was kept, so the next rewrite is due once
206
+ // PRUNE_SLACK more entries have been appended.
207
+ this.pruneThreshold = kept.length + this.slack()
208
+ this.appendedSincePrune = 0
138
209
  } catch {
139
- // Best-effort: an unpruned file still reads correctly.
210
+ // Best-effort: an unpruned file still reads correctly, and the atomic write leaves the
211
+ // previous one in place when it fails.
140
212
  }
141
213
  }
142
214
 
@@ -50,26 +50,40 @@ try {
50
50
  }
51
51
 
52
52
  /**
53
- * Read the spawn token. The parent sends it two ways and the child prefers
54
- * the first that yields a value:
53
+ * Read the spawn token. The parent sends it two ways:
55
54
  * 1. stdin (first line) — the secure channel on Unix/macOS;
56
55
  * 2. DSH_BROWSER_RPC_TOKEN env — the fallback on Windows, where Electron is
57
56
  * a GUI-subsystem process and never receives piped stdin (the parent
58
57
  * sets it in the spawn env for exactly this reason).
59
- * stdin wins over env so the parent's token never has to touch argv.
58
+ *
59
+ * stdin WINS over env, which is what the ordering here has to express. It used to read env
60
+ * first and only consult stdin when that came up empty, so the stdin branch never ran while
61
+ * env was set — every spawn was told both ways, so the documented preference was silently
62
+ * the other way round. The env value is kept as the default for the common case where stdin
63
+ * delivers nothing; the interface below overwrites it when a line arrives.
60
64
  */
61
65
  let RPC_TOKEN = process.env.DSH_BROWSER_RPC_TOKEN ?? ''
62
- const stdinDelivered = RPC_TOKEN !== ''
63
- if (process.stdin !== null && process.stdin.readable) {
64
- const stdinLines = createInterface({ input: process.stdin, terminal: false })
66
+ /** True once a line actually arrived on stdin (as opposed to env being present). */
67
+ let tokenFromStdin = false
68
+ /**
69
+ * The one and only stdin reader.
70
+ *
71
+ * It is created here and reused by the hello path inside `whenReady` below: two
72
+ * `createInterface` calls on the same stream both consume its lines, so the second one saw
73
+ * nothing at all. A single reader means whoever needs the token from stdin waits on it.
74
+ */
75
+ const stdinLines = process.stdin !== null && process.stdin.readable
76
+ ? createInterface({ input: process.stdin, terminal: false })
77
+ : undefined
78
+ if (stdinLines !== undefined) {
65
79
  stdinLines.on('line', (line) => {
66
- if (RPC_TOKEN === '') {
67
- RPC_TOKEN = line.trim()
68
- stdinLines.close()
69
- }
80
+ if (tokenFromStdin) return
81
+ tokenFromStdin = true
82
+ const token = line.trim()
83
+ if (token !== '') RPC_TOKEN = token
70
84
  })
71
85
  stdinLines.on('close', () => {
72
- if (RPC_TOKEN === '' && !stdinDelivered) {
86
+ if (RPC_TOKEN === '') {
73
87
  process.stderr.write('[dsh-browser host] warning: no token received on stdin or env\n')
74
88
  }
75
89
  })
@@ -794,14 +808,23 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
794
808
  try { win.window.restore() } catch { /* not minimized */ }
795
809
  win.window.focus()
796
810
  let base64 = ''
811
+ let renderedSize: { width: number; height: number } | undefined
797
812
  try {
798
813
  let image
799
814
  try {
800
- image = await entry.webContentsView.webContents.capturePage()
815
+ image = await withTimeout(
816
+ entry.webContentsView.webContents.capturePage(),
817
+ COMMAND_TIMEOUT_MS,
818
+ 'capturePage',
819
+ )
801
820
  } catch (error) {
802
821
  process.stderr.write(`[dsh-browser host] capturePage failed: ${String(error)}\n`)
803
822
  await new Promise(resolve => setTimeout(resolve, 400))
804
- image = await entry.webContentsView.webContents.capturePage()
823
+ image = await withTimeout(
824
+ entry.webContentsView.webContents.capturePage(),
825
+ COMMAND_TIMEOUT_MS,
826
+ 'capturePage',
827
+ )
805
828
  }
806
829
  // Downscale to fit the requested box, preserving aspect ratio.
807
830
  const size = image.getSize()
@@ -810,6 +833,9 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
810
833
  if (maxW > 0 && w > maxW) { h = Math.round(h * maxW / w); w = maxW }
811
834
  if (maxH > 0 && h > maxH) { w = Math.round(w * maxH / h); h = maxH }
812
835
  if (w > 0 && h > 0 && (w !== size.width || h !== size.height)) image = image.resize({ width: w, height: h })
836
+ // What the caller actually receives: after the downscale, not the captured size.
837
+ // Recorded here because this is the last point that knows both.
838
+ if (w > 0 && h > 0) renderedSize = { width: w, height: h }
813
839
  const buf = wantFormat === 'jpeg' ? image.toJPEG(wantQuality) : image.toPNG()
814
840
  if (wantFormat === 'jpeg') mime = 'image/jpeg'
815
841
  if (buf.length > 0) base64 = buf.toString('base64')
@@ -831,7 +857,11 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
831
857
  try { win.window.contentView.removeChildView(toolbar) } catch { /* already gone */ }
832
858
  }
833
859
  try {
834
- const shot = await entry.webContentsView.webContents.debugger.sendCommand('Page.captureScreenshot', {})
860
+ const shot = await withTimeout(
861
+ entry.webContentsView.webContents.debugger.sendCommand('Page.captureScreenshot', {}),
862
+ COMMAND_TIMEOUT_MS,
863
+ 'Page.captureScreenshot',
864
+ )
835
865
  const data = (shot as { data?: unknown }).data
836
866
  if (typeof data === 'string' && data.length > 0) base64 = data
837
867
  } finally {
@@ -850,7 +880,12 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
850
880
  if (base64 === '') {
851
881
  throw new Error('capture produced no image (view not painted)')
852
882
  }
853
- reply(msg.id, { ok: true, result: { base64, mime } })
883
+ // The measured size travels with the image. Without it the native path — which is the
884
+ // default self-hosted one — could never answer `browser_screenshot`'s width/height,
885
+ // because the seam's only channel was {base64, mime} and the provider had nothing to
886
+ // forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
887
+ // place that knows what came back.
888
+ reply(msg.id, { ok: true, result: { base64, mime, width: renderedSize?.width, height: renderedSize?.height } })
854
889
  return
855
890
  }
856
891
  case 'download': {
@@ -869,7 +904,12 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
869
904
  // the parent's memory or hit the single-line cap. This also avoids
870
905
  // Electron's download pipeline entirely (CDP debugger attach can
871
906
  // interfere with will-download).
872
- const result = await entry.webContentsView.webContents.debugger.sendCommand('Runtime.evaluate', {
907
+ // Bounded like every other op. The evaluate awaits a page-side fetch, so a slow or
908
+ // unresponsive server held the serial queue open forever: handle() never settled,
909
+ // every later op queued behind it, and the parent's plain timeout does not restart
910
+ // the host.
911
+ const result = await withTimeout(
912
+ entry.webContentsView.webContents.debugger.sendCommand('Runtime.evaluate', {
873
913
  // Stream the body through a reader so the size cap is enforced as
874
914
  // bytes arrive — never buffer an unbounded download into memory
875
915
  // before checking the limit (a huge URL would otherwise OOM the
@@ -910,7 +950,10 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
910
950
  })()`,
911
951
  awaitPromise: true,
912
952
  returnByValue: true,
913
- })
953
+ }) as Promise<{ result?: { value?: unknown }; exceptionDetails?: { exception?: { description?: string } } }>,
954
+ COMMAND_TIMEOUT_MS,
955
+ 'download evaluate',
956
+ )
914
957
  const value = (result as { result?: { value?: unknown } }).result?.value
915
958
  if (typeof value !== 'string') {
916
959
  const detail = (result as { exceptionDetails?: { exception?: { description?: string } } }).exceptionDetails
@@ -939,7 +982,25 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
939
982
  if (win === undefined || entry === undefined) throw new Error(`flushAuth: unknown view ${viewId}`)
940
983
  // Export the session's cookies so login state can be saved/restored
941
984
  // across browser hosts (or shared with another machine).
942
- const cookies = await entry.webContentsView.webContents.session.cookies.get({})
985
+ //
986
+ // Scoped to this view's own page. The jar is SHARED on purpose: views here are built
987
+ // without a partition and every window drives the same browser, so signing in once
988
+ // works everywhere. What must not be shared is the HANDING OVER — an unfiltered read
989
+ // would put every domain every task has visited, plus whatever the human is signed
990
+ // into, in front of one task's caller. Least exposure, not isolation. An unknown page
991
+ // refuses rather than widening.
992
+ //
993
+ // Bounded like every other op: a renderer that stops answering here would hold the
994
+ // serial queue forever, and the parent's plain timeout does not restart the host.
995
+ const pageUrl = entry.webContentsView.webContents.getURL()
996
+ if (pageUrl === '' || pageUrl === 'about:blank') {
997
+ throw new Error('flushAuth: this view has no page loaded, so the cookie scope is unknown — reading everything would expose other sessions')
998
+ }
999
+ const cookies = await withTimeout(
1000
+ entry.webContentsView.webContents.session.cookies.get({ url: pageUrl }),
1001
+ COMMAND_TIMEOUT_MS,
1002
+ 'cookies.get',
1003
+ )
943
1004
  const exported = cookies.map(c => {
944
1005
  // Electron types the cookie domain as optional; a missing domain
945
1006
  // cannot be exported meaningfully, so default to '' (matches the
@@ -973,16 +1034,22 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
973
1034
  let restored = 0
974
1035
  for (const c of cookies as Array<{ url?: string; name?: string; value?: string; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; expirationDate?: number }>) {
975
1036
  if (typeof c.url !== 'string' || typeof c.name !== 'string' || typeof c.value !== 'string') continue
976
- await entry.webContentsView.webContents.session.cookies.set({
977
- url: c.url,
978
- name: c.name,
979
- value: c.value,
980
- ...typeof c.domain === 'string' ? { domain: c.domain } : {},
981
- ...typeof c.path === 'string' ? { path: c.path } : {},
982
- ...typeof c.secure === 'boolean' ? { secure: c.secure } : {},
983
- ...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
984
- ...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
985
- })
1037
+ // Bounded per cookie: this runs once for every cookie, so a single hang held the
1038
+ // serial queue open forever with no path to recovery.
1039
+ await withTimeout(
1040
+ entry.webContentsView.webContents.session.cookies.set({
1041
+ url: c.url,
1042
+ name: c.name,
1043
+ value: c.value,
1044
+ ...typeof c.domain === 'string' ? { domain: c.domain } : {},
1045
+ ...typeof c.path === 'string' ? { path: c.path } : {},
1046
+ ...typeof c.secure === 'boolean' ? { secure: c.secure } : {},
1047
+ ...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
1048
+ ...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
1049
+ }),
1050
+ COMMAND_TIMEOUT_MS,
1051
+ 'cookies.set',
1052
+ )
986
1053
  restored++
987
1054
  }
988
1055
  reply(msg.id, { ok: true, result: { restored } })
@@ -1050,16 +1117,18 @@ void app.whenReady().then(() => {
1050
1117
  socket.write(JSON.stringify({ id: 0, op: 'hello', token: RPC_TOKEN }) + '\n')
1051
1118
  }
1052
1119
  if (RPC_TOKEN !== '') {
1120
+ // Either stdin already delivered a line (it wins) or env supplied the fallback.
1053
1121
  writeHello()
1054
1122
  } else {
1055
- // No token yet (env absent and stdin still pending): wait for stdin's
1056
- // first line with a bounded fallback, then send whatever we have — an
1057
- // empty hello is refused by the parent, which then fails cleanly.
1123
+ // Neither yet: wait for stdin's first line with a bounded fallback, then send whatever
1124
+ // we have — an empty hello is refused by the parent, which then fails cleanly. The
1125
+ // reader is the module-level one, NOT a second createInterface on the same stream: two
1126
+ // readers of one stdin both consume its lines, so the second one never saw the token.
1058
1127
  const fallback = setTimeout(() => writeHello(), 2_000)
1059
- if (process.stdin !== null && process.stdin.readable) {
1060
- const stdinLines = createInterface({ input: process.stdin, terminal: false })
1061
- stdinLines.once('line', () => { clearTimeout(fallback); writeHello() })
1062
- stdinLines.once('close', () => { clearTimeout(fallback); writeHello() })
1128
+ if (stdinLines !== undefined) {
1129
+ const send = (): void => { clearTimeout(fallback); writeHello() }
1130
+ stdinLines.once('line', send)
1131
+ stdinLines.once('close', send)
1063
1132
  }
1064
1133
  }
1065
1134
  const rl = createInterface({ input: socket })
@@ -1092,7 +1161,17 @@ void app.whenReady().then(() => {
1092
1161
  // without cleanup, exit so no zombie Electron window is left behind.
1093
1162
  socket.on('close', () => {
1094
1163
  process.stderr.write('[dsh-browser host] parent connection closed, exiting\n')
1095
- app.exit(0)
1164
+ // A normal quit, not `app.exit(0)`. exit() skips before-quit/will-quit and does not wait
1165
+ // for Chromium to finish writing the profile — the leveldb files holding cookies and site
1166
+ // data. This path runs on every dispose, and a session that has just logged in is exactly
1167
+ // when the profile holds unwritten state, so a hard exit could leave a half-written leveldb
1168
+ // behind, which is what makes a browser profile fail to open on the next launch.
1169
+ app.quit()
1170
+ // Belt and braces: the parent is already gone, so there is nothing left to serve. If a
1171
+ // listener or a window blocks the quit, forcing the exit after a grace period is still
1172
+ // strictly better than an orphaned Electron window the user has to close by hand.
1173
+ const force = setTimeout(() => app.exit(0), 5000)
1174
+ force.unref()
1096
1175
  })
1097
1176
  // Keep the process alive until the parent closes the socket or kills us.
1098
1177
  })