dsh-builtin-browser 0.4.1 → 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 +61 -23
  2. package/README.md +59 -23
  3. package/client.js +60 -5
  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 +12 -12
  8. package/docs/user-guide.md +10 -3
  9. package/docs/why-browser.md +1 -1
  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 +311 -40
  30. package/lib/tool-browser/index.js +142 -33
  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 +302 -26
  44. package/src/tool-browser/index.ts +143 -35
@@ -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,6 +52,22 @@ export function visitedHistoryPath(): string {
51
52
  * a corrupted line must never hide the rest of the history.
52
53
  */
53
54
  export class HistoryStore {
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
54
71
  private readonly maxEntries: number
55
72
  private readonly maxAgeMs: number
56
73
 
@@ -63,6 +80,8 @@ export class HistoryStore {
63
80
  limits: HistoryLimits = {},
64
81
  ) {
65
82
  this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
83
+ // The first rewrite is due once the file has grown PRUNE_SLACK past the cap.
84
+ this.pruneThreshold = this.maxEntries + this.slack()
66
85
  this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
67
86
  }
68
87
 
@@ -84,6 +103,7 @@ export class HistoryStore {
84
103
  } catch {
85
104
  // Best-effort: history is not worth failing a navigation over.
86
105
  }
106
+ this.appendedSincePrune++
87
107
  }
88
108
 
89
109
  /**
@@ -123,7 +143,22 @@ export class HistoryStore {
123
143
  * the count cap. Cheap no-op until the file grows past the cap, so callers can
124
144
  * run it after every append.
125
145
  */
146
+ /**
147
+ * How far past the cap the file may grow before it is rewritten.
148
+ *
149
+ * Scaled to the cap rather than fixed: a fixed 500 was meaningless when the cap is
150
+ * small (a caller asking for three entries would never reach 503), and the slack is
151
+ * there to batch rewrites, not to overrule the caller's bound.
152
+ * @returns the number of extra entries tolerated before a rewrite.
153
+ */
154
+ private slack(): number {
155
+ return Math.min(PRUNE_SLACK, Math.max(1, Math.floor(this.maxEntries / 2)))
156
+ }
157
+
126
158
  prune(): void {
159
+ // Cheap first: past the cap but without enough new entries to justify a rewrite.
160
+ // Reading the file to reach this conclusion was the remaining per-navigation cost.
161
+ if (this.appendedSincePrune === 0) return
127
162
  const all = this.readAll()
128
163
  const cutoff = Date.now() - this.maxAgeMs
129
164
  const fresh = all.filter(page => page.at >= cutoff)
@@ -131,12 +166,24 @@ export class HistoryStore {
131
166
  // Only rewrite when something actually fell off, and only once the excess
132
167
  // is worth a full rewrite.
133
168
  if (kept.length === all.length) return
134
- if (all.length - kept.length < PRUNE_SLACK && fresh.length <= this.maxEntries) return
169
+ // Only rewrite once the file has grown PRUNE_SLACK past what we last wrote. Asking
170
+ // "is the file over the cap?" instead was true from the first over-cap append
171
+ // onwards, so every single navigation after that rewrote the whole file.
172
+ if (all.length < this.pruneThreshold) return
135
173
  try {
136
174
  const body = kept.map(page => `${JSON.stringify(page)}\n`).join('')
137
- writeFileSync(this.file, body)
175
+ // Atomic, like the settings document. A plain writeFileSync truncates first, and this is
176
+ // the whole history in one call: a TerminateProcess mid-write — the browser host is
177
+ // killed exactly that way on Windows — left a partial file, and the reader drops a
178
+ // half-written TAIL rather than the middle, so everything after the tear was lost.
179
+ writeFileAtomic(this.file, body)
180
+ // The file now holds only what was kept, so the next rewrite is due once
181
+ // PRUNE_SLACK more entries have been appended.
182
+ this.pruneThreshold = kept.length + this.slack()
183
+ this.appendedSincePrune = 0
138
184
  } catch {
139
- // Best-effort: an unpruned file still reads correctly.
185
+ // Best-effort: an unpruned file still reads correctly, and the atomic write leaves the
186
+ // previous one in place when it fails.
140
187
  }
141
188
  }
142
189
 
@@ -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
  })
@@ -797,11 +811,19 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
797
811
  try {
798
812
  let image
799
813
  try {
800
- image = await entry.webContentsView.webContents.capturePage()
814
+ image = await withTimeout(
815
+ entry.webContentsView.webContents.capturePage(),
816
+ COMMAND_TIMEOUT_MS,
817
+ 'capturePage',
818
+ )
801
819
  } catch (error) {
802
820
  process.stderr.write(`[dsh-browser host] capturePage failed: ${String(error)}\n`)
803
821
  await new Promise(resolve => setTimeout(resolve, 400))
804
- image = await entry.webContentsView.webContents.capturePage()
822
+ image = await withTimeout(
823
+ entry.webContentsView.webContents.capturePage(),
824
+ COMMAND_TIMEOUT_MS,
825
+ 'capturePage',
826
+ )
805
827
  }
806
828
  // Downscale to fit the requested box, preserving aspect ratio.
807
829
  const size = image.getSize()
@@ -831,7 +853,11 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
831
853
  try { win.window.contentView.removeChildView(toolbar) } catch { /* already gone */ }
832
854
  }
833
855
  try {
834
- const shot = await entry.webContentsView.webContents.debugger.sendCommand('Page.captureScreenshot', {})
856
+ const shot = await withTimeout(
857
+ entry.webContentsView.webContents.debugger.sendCommand('Page.captureScreenshot', {}),
858
+ COMMAND_TIMEOUT_MS,
859
+ 'Page.captureScreenshot',
860
+ )
835
861
  const data = (shot as { data?: unknown }).data
836
862
  if (typeof data === 'string' && data.length > 0) base64 = data
837
863
  } finally {
@@ -869,7 +895,12 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
869
895
  // the parent's memory or hit the single-line cap. This also avoids
870
896
  // Electron's download pipeline entirely (CDP debugger attach can
871
897
  // interfere with will-download).
872
- const result = await entry.webContentsView.webContents.debugger.sendCommand('Runtime.evaluate', {
898
+ // Bounded like every other op. The evaluate awaits a page-side fetch, so a slow or
899
+ // unresponsive server held the serial queue open forever: handle() never settled,
900
+ // every later op queued behind it, and the parent's plain timeout does not restart
901
+ // the host.
902
+ const result = await withTimeout(
903
+ entry.webContentsView.webContents.debugger.sendCommand('Runtime.evaluate', {
873
904
  // Stream the body through a reader so the size cap is enforced as
874
905
  // bytes arrive — never buffer an unbounded download into memory
875
906
  // before checking the limit (a huge URL would otherwise OOM the
@@ -910,7 +941,10 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
910
941
  })()`,
911
942
  awaitPromise: true,
912
943
  returnByValue: true,
913
- })
944
+ }) as Promise<{ result?: { value?: unknown }; exceptionDetails?: { exception?: { description?: string } } }>,
945
+ COMMAND_TIMEOUT_MS,
946
+ 'download evaluate',
947
+ )
914
948
  const value = (result as { result?: { value?: unknown } }).result?.value
915
949
  if (typeof value !== 'string') {
916
950
  const detail = (result as { exceptionDetails?: { exception?: { description?: string } } }).exceptionDetails
@@ -939,7 +973,25 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
939
973
  if (win === undefined || entry === undefined) throw new Error(`flushAuth: unknown view ${viewId}`)
940
974
  // Export the session's cookies so login state can be saved/restored
941
975
  // across browser hosts (or shared with another machine).
942
- const cookies = await entry.webContentsView.webContents.session.cookies.get({})
976
+ //
977
+ // Scoped to this view's own page. The jar is SHARED on purpose: views here are built
978
+ // without a partition and every window drives the same browser, so signing in once
979
+ // works everywhere. What must not be shared is the HANDING OVER — an unfiltered read
980
+ // would put every domain every task has visited, plus whatever the human is signed
981
+ // into, in front of one task's caller. Least exposure, not isolation. An unknown page
982
+ // refuses rather than widening.
983
+ //
984
+ // Bounded like every other op: a renderer that stops answering here would hold the
985
+ // serial queue forever, and the parent's plain timeout does not restart the host.
986
+ const pageUrl = entry.webContentsView.webContents.getURL()
987
+ if (pageUrl === '' || pageUrl === 'about:blank') {
988
+ throw new Error('flushAuth: this view has no page loaded, so the cookie scope is unknown — reading everything would expose other sessions')
989
+ }
990
+ const cookies = await withTimeout(
991
+ entry.webContentsView.webContents.session.cookies.get({ url: pageUrl }),
992
+ COMMAND_TIMEOUT_MS,
993
+ 'cookies.get',
994
+ )
943
995
  const exported = cookies.map(c => {
944
996
  // Electron types the cookie domain as optional; a missing domain
945
997
  // cannot be exported meaningfully, so default to '' (matches the
@@ -973,16 +1025,22 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
973
1025
  let restored = 0
974
1026
  for (const c of cookies as Array<{ url?: string; name?: string; value?: string; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; expirationDate?: number }>) {
975
1027
  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
- })
1028
+ // Bounded per cookie: this runs once for every cookie, so a single hang held the
1029
+ // serial queue open forever with no path to recovery.
1030
+ await withTimeout(
1031
+ entry.webContentsView.webContents.session.cookies.set({
1032
+ url: c.url,
1033
+ name: c.name,
1034
+ value: c.value,
1035
+ ...typeof c.domain === 'string' ? { domain: c.domain } : {},
1036
+ ...typeof c.path === 'string' ? { path: c.path } : {},
1037
+ ...typeof c.secure === 'boolean' ? { secure: c.secure } : {},
1038
+ ...typeof c.httpOnly === 'boolean' ? { httpOnly: c.httpOnly } : {},
1039
+ ...typeof c.expirationDate === 'number' ? { expirationDate: c.expirationDate } : {},
1040
+ }),
1041
+ COMMAND_TIMEOUT_MS,
1042
+ 'cookies.set',
1043
+ )
986
1044
  restored++
987
1045
  }
988
1046
  reply(msg.id, { ok: true, result: { restored } })
@@ -1050,16 +1108,18 @@ void app.whenReady().then(() => {
1050
1108
  socket.write(JSON.stringify({ id: 0, op: 'hello', token: RPC_TOKEN }) + '\n')
1051
1109
  }
1052
1110
  if (RPC_TOKEN !== '') {
1111
+ // Either stdin already delivered a line (it wins) or env supplied the fallback.
1053
1112
  writeHello()
1054
1113
  } 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.
1114
+ // Neither yet: wait for stdin's first line with a bounded fallback, then send whatever
1115
+ // we have — an empty hello is refused by the parent, which then fails cleanly. The
1116
+ // reader is the module-level one, NOT a second createInterface on the same stream: two
1117
+ // readers of one stdin both consume its lines, so the second one never saw the token.
1058
1118
  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() })
1119
+ if (stdinLines !== undefined) {
1120
+ const send = (): void => { clearTimeout(fallback); writeHello() }
1121
+ stdinLines.once('line', send)
1122
+ stdinLines.once('close', send)
1063
1123
  }
1064
1124
  }
1065
1125
  const rl = createInterface({ input: socket })
@@ -1092,7 +1152,17 @@ void app.whenReady().then(() => {
1092
1152
  // without cleanup, exit so no zombie Electron window is left behind.
1093
1153
  socket.on('close', () => {
1094
1154
  process.stderr.write('[dsh-browser host] parent connection closed, exiting\n')
1095
- app.exit(0)
1155
+ // A normal quit, not `app.exit(0)`. exit() skips before-quit/will-quit and does not wait
1156
+ // for Chromium to finish writing the profile — the leveldb files holding cookies and site
1157
+ // data. This path runs on every dispose, and a session that has just logged in is exactly
1158
+ // when the profile holds unwritten state, so a hard exit could leave a half-written leveldb
1159
+ // behind, which is what makes a browser profile fail to open on the next launch.
1160
+ app.quit()
1161
+ // Belt and braces: the parent is already gone, so there is nothing left to serve. If a
1162
+ // listener or a window blocks the quit, forcing the exit after a grace period is still
1163
+ // strictly better than an orphaned Electron window the user has to close by hand.
1164
+ const force = setTimeout(() => app.exit(0), 5000)
1165
+ force.unref()
1096
1166
  })
1097
1167
  // Keep the process alive until the parent closes the socket or kills us.
1098
1168
  })