dsh-builtin-browser 0.1.21 → 0.1.22

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.
@@ -8,9 +8,9 @@
8
8
  */
9
9
 
10
10
  import { randomUUID } from 'node:crypto'
11
- import { writeFileSync } from 'node:fs'
11
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
12
12
  import { homedir } from 'node:os'
13
- import { isAbsolute, join, resolve, sep } from 'node:path'
13
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
14
14
  import type {
15
15
  BrowserA11yRequest,
16
16
  BrowserA11yResult,
@@ -134,6 +134,20 @@ export interface ElectronBrowserViewHost {
134
134
  * tell which task's page is currently visible.
135
135
  */
136
136
  showView?(handle: ElectronViewHandle, label?: string): void
137
+ /**
138
+ * Optional: (re)present a view and WAIT until the host confirms it. Chromium
139
+ * silently drops CDP-synthesized input (`Input.*`) for a view that has no
140
+ * display surface, which made clicks/typing report success while the page
141
+ * received nothing (and made the first input after a navigation vanish,
142
+ * because the replaced renderer has no surface yet). Unlike
143
+ * {@link showView}, this round-trips an RPC barrier that is ordered AFTER
144
+ * the show on the same socket, so its reply means the view is on screen.
145
+ * Rejects when the view cannot be presented; a host without this method is
146
+ * assumed to present every view (headless/probe hosts).
147
+ * @param handle - the handle to present.
148
+ * @param label - human-readable session/task label, as in {@link showView}.
149
+ */
150
+ presentView?(handle: ElectronViewHandle, label?: string): Promise<void>
137
151
  /**
138
152
  * Optional cheap usability probe (no network): whether the host can back
139
153
  * views at all right now. The self-hosted host checks for a usable Electron
@@ -303,6 +317,31 @@ const KEY_SPECS: Readonly<Record<string, { readonly key: string; readonly code:
303
317
  /** Supported key names, exported for the tool's enum and error messages. */
304
318
  export const BROWSER_KEY_NAMES: readonly string[] = Object.keys(KEY_SPECS)
305
319
 
320
+ /**
321
+ * Localized names a system Downloads folder may carry, in probe order: the
322
+ * English default first, then the Chinese spellings — a zh-CN desktop calls
323
+ * the folder `下载`, a zh-TW one `下載`, and neither is `Downloads`.
324
+ */
325
+ const DOWNLOAD_DIR_NAMES: readonly string[] = ['Downloads', '下载', '下載']
326
+
327
+ /**
328
+ * Resolve the default download directory when the config names none: an
329
+ * existing `XDG_DOWNLOAD_DIR` wins (the freedesktop standard, which is what a
330
+ * localized Linux desktop writes), then the first localized `Downloads` folder
331
+ * that exists under the home directory, and finally the English name — created
332
+ * on first use — when none exists yet.
333
+ * @returns the absolute default download directory.
334
+ */
335
+ function defaultDownloadDir(): string {
336
+ const xdg = process.env.XDG_DOWNLOAD_DIR
337
+ if (typeof xdg === 'string' && xdg !== '' && existsSync(xdg)) return xdg
338
+ for (const name of DOWNLOAD_DIR_NAMES) {
339
+ const candidate = join(homedir(), name)
340
+ if (existsSync(candidate)) return candidate
341
+ }
342
+ return join(homedir(), DOWNLOAD_DIR_NAMES[0] ?? 'Downloads')
343
+ }
344
+
306
345
  /**
307
346
  * Browser provider over Electron views. Sessions hold an ordered list of
308
347
  * tabs; each tab is one view created by the host. The active tab receives
@@ -326,9 +365,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
326
365
  this.httpOnly = config.httpOnly ?? true
327
366
  this.snapshotMaxElements = config.snapshotMaxElements ?? 60
328
367
  this.contentMaxChars = config.contentMaxChars ?? 100_000
329
- // Confine downloads to a dedicated directory by default: the OS Downloads
330
- // folder is the human-visible, browser-natural place for downloaded files.
331
- this.downloadDir = config.downloadDir ?? join(homedir(), 'Downloads')
368
+ // Confine downloads AND screenshot saves to a dedicated directory by
369
+ // default: the OS Downloads folder is the human-visible, browser-natural
370
+ // place for written files, and it is the one directory the DSH file
371
+ // sandbox does not have to arbitrate. The localized name is probed so a
372
+ // Chinese desktop (`~/下载`) works without configuring `downloadDir`.
373
+ this.downloadDir = config.downloadDir ?? defaultDownloadDir()
332
374
  // Route toolbar (host UI) actions into the session model: the human and
333
375
  // the agent then always drive the same tabs, history, and navigation.
334
376
  this.host.onUserAction?.(action => { void this.handleUserAction(action) })
@@ -433,10 +475,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
433
475
  */
434
476
  private locateTab(session: BrowserSessionId, tabId: string): { s: Session; index: number } {
435
477
  const s = this.session(session)
436
- const own = s.tabs.findIndex(tab => tab.id === tabId)
478
+ // Accept the bare uuid as well as the "tab:<uuid>" form the tools print:
479
+ // ids are globally unique, so requiring the printed prefix only produced a
480
+ // misleading "is not open in this session" error that listed the very tab
481
+ // the caller had just named.
482
+ const wanted = stripTabPrefix(tabId)
483
+ const own = s.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
437
484
  if (own >= 0) return { s, index: own }
438
485
  for (const other of this.sessions.values()) {
439
- const index = other.tabs.findIndex(tab => tab.id === tabId)
486
+ const index = other.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
440
487
  if (index >= 0) return { s: other, index }
441
488
  }
442
489
  const ownIds = s.tabs.map(t => t.id).join(', ') || '(none)'
@@ -479,6 +526,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
479
526
  // evaluate paths so a wedged navigation surfaces as an error instead of
480
527
  // blocking the tool call forever.
481
528
  const timeoutMs = 30_000
529
+ // Document identity of the page we are leaving (see settleDocument).
530
+ const before = await this.documentStamp(handle)
482
531
  const result = await withTimeout(
483
532
  handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams),
484
533
  timeoutMs,
@@ -494,7 +543,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
494
543
  if (typeof errorText === 'string' && errorText !== '') {
495
544
  throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
496
545
  }
546
+ // Record as soon as the navigation has committed: history bookkeeping must
547
+ // not wait on the best-effort settle below (which only delays what the
548
+ // NEXT observation sees, not whether this action happened).
497
549
  this.record(s, 'navigate', { url }, true)
550
+ await this.settleDocument(handle, before, signal)
498
551
  this.showActive(s)
499
552
  } catch (error) {
500
553
  if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
@@ -504,6 +557,113 @@ export class ElectronBrowserProvider implements BrowserProvider {
504
557
  }
505
558
  }
506
559
 
560
+ /**
561
+ * A per-document stamp: `performance.timeOrigin` is unique per document load,
562
+ * so it tells a same-URL reload and an A→B→A redirect apart from the document
563
+ * that was current before the navigation. Empty string when unreadable.
564
+ */
565
+ private async documentStamp(handle: ElectronViewHandle): Promise<string> {
566
+ const probe = await this.documentProbe(handle)
567
+ return probe?.stamp ?? ''
568
+ }
569
+
570
+ /**
571
+ * One cheap in-page reading of the document's identity and parse state.
572
+ *
573
+ * Returns null when the page did NOT answer (mid-commit, execution context
574
+ * destroyed) — the caller keeps waiting. A page that answered with an
575
+ * unexpected shape (an override or a non-conforming host; the production
576
+ * expression always yields a string) is reported as `unknown` rather than as
577
+ * "no answer": blocking the navigation for the whole budget on a page that
578
+ * demonstrably responded has no upside.
579
+ */
580
+ private async documentProbe(handle: ElectronViewHandle): Promise<{ stamp: string; readyState: string } | null> {
581
+ try {
582
+ const result = await withTimeout(
583
+ handleSendEvaluate(handle, 'String(performance.timeOrigin) + "|" + document.readyState'),
584
+ 3_000,
585
+ undefined,
586
+ 'browser: document probe timed out',
587
+ )
588
+ if (!result.ok) return null
589
+ if (typeof result.value !== 'string') return { stamp: '', readyState: 'unknown' }
590
+ const [stamp = '', readyState = ''] = result.value.split('|')
591
+ return { stamp, readyState }
592
+ } catch {
593
+ return null
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Page.navigate resolves at navigation COMMIT, not at load: the new document
599
+ * is already current (so location.href and document.title are the new page's)
600
+ * while its DOM is still being parsed. browser_open snapshots immediately
601
+ * after navigating, which is why it could report the right title with zero
602
+ * interactive elements — and why a separate browser_snapshot right after
603
+ * always found them.
604
+ *
605
+ * Wait — bounded, best-effort — for the new document to settle: readyState
606
+ * must reach interactive/complete AND the document identity must have
607
+ * changed. A page that never settles must not fail a navigation that already
608
+ * succeeded, so a timeout here is swallowed.
609
+ */
610
+ private async settleDocument(handle: ElectronViewHandle, before: string, signal?: AbortSignal): Promise<void> {
611
+ const started = Date.now()
612
+ const deadline = started + SETTLE_TIMEOUT_MS
613
+ for (;;) {
614
+ if (signal?.aborted === true) return
615
+ // A probe failure is expected while the navigation commits (the context is
616
+ // being destroyed) — keep polling rather than giving up.
617
+ const probe = await this.documentProbe(handle)
618
+ if (probe !== null) {
619
+ const settled = probe.readyState === 'interactive'
620
+ || probe.readyState === 'complete'
621
+ || probe.readyState === 'unknown'
622
+ const isNewDocument = before === '' || probe.stamp === '' || probe.stamp !== before
623
+ // The common case: the new document exists and is parsed — return now.
624
+ if (settled && isNewDocument) return
625
+ // Same document (hash navigation, cancelled/aborted load): there is no
626
+ // parse to wait for, so do not burn the budget. Outlast the brief
627
+ // window in which the OUTGOING document may still answer instead.
628
+ if (settled && Date.now() - started >= SETTLE_GRACE_MS) return
629
+ }
630
+ if (Date.now() >= deadline) return
631
+ await delay(SETTLE_POLL_MS, signal)
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Make sure the active tab's view can actually receive synthesized input.
637
+ * Chromium drops `Input.*` events for a view with no display surface, so this
638
+ * runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
639
+ * rather than reporting a success the page never saw.
640
+ */
641
+ private async present(s: Session, signal?: AbortSignal): Promise<void> {
642
+ const { handle } = this.activeTab(s)
643
+ const present = this.host.presentView?.bind(this.host)
644
+ if (present === undefined) {
645
+ this.showActive(s)
646
+ return
647
+ }
648
+ signal?.throwIfAborted()
649
+ const timeoutMs = 10_000
650
+ try {
651
+ await withTimeout(
652
+ present(handle, s.label),
653
+ timeoutMs,
654
+ signal,
655
+ `browser: presenting the page view timed out after ${timeoutMs}ms`,
656
+ )
657
+ this.showActive(s)
658
+ } catch (error) {
659
+ throw new BrowserError(
660
+ `browser: the page view is not presented, so synthesized input would be silently dropped (${String(error)})`,
661
+ 'BROWSER_VIEW_NOT_PRESENTED',
662
+ { cause: error },
663
+ )
664
+ }
665
+ }
666
+
507
667
  /** Execute JS in the active tab's page context. */
508
668
  async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
509
669
  const s = this.session(session)
@@ -1033,6 +1193,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1033
1193
  x = request.x
1034
1194
  y = request.y
1035
1195
  }
1196
+ // Input.* is only delivered to a view with a current display surface; the
1197
+ // barrier also re-presents after a navigation replaced the renderer.
1198
+ await this.present(s, signal)
1036
1199
  const press = (type: 'mousePressed' | 'mouseReleased'): Promise<Record<string, unknown>> =>
1037
1200
  handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
1038
1201
  const timeoutMs = 30_000
@@ -1078,6 +1241,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1078
1241
  }
1079
1242
  const text = 'text' in request ? request.text : ''
1080
1243
  const timeoutMs = 30_000
1244
+ // Input.insertText goes through the Input domain: same display-surface
1245
+ // requirement as click/key.
1246
+ await this.present(s, signal)
1081
1247
  try {
1082
1248
  await withTimeout(
1083
1249
  handle.sendCommand('Input.insertText', { text } satisfies CdpInsertTextParams),
@@ -1149,6 +1315,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1149
1315
  }
1150
1316
  const entry = entries[target]
1151
1317
  if (entry?.id === undefined) throw new BrowserError('browser: history entry missing id', 'BROWSER_HISTORY_INVALID')
1318
+ // A history step is a navigation too: the classic A→B→A redirect would
1319
+ // otherwise read the outgoing document's identity (see settleDocument).
1320
+ const before = await this.documentStamp(handle)
1152
1321
  await withTimeout(
1153
1322
  handle.sendCommand('Page.navigateToHistoryEntry', { entryId: entry.id }),
1154
1323
  timeoutMs,
@@ -1157,6 +1326,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1157
1326
  () => { void handle.sendCommand('Page.stopLoading').catch(() => {}) },
1158
1327
  )
1159
1328
  this.record(s, direction === -1 ? 'back' : 'forward', {}, true)
1329
+ await this.settleDocument(handle, before, signal)
1160
1330
  this.showActive(s)
1161
1331
  }
1162
1332
 
@@ -1176,6 +1346,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1176
1346
  const { handle } = this.activeTab(s)
1177
1347
  signal?.throwIfAborted()
1178
1348
  const timeoutMs = 30_000
1349
+ // Same-URL reload: only the per-load document identity can tell the new
1350
+ // document from the old one, so capture it before reloading.
1351
+ const before = await this.documentStamp(handle)
1179
1352
  await withTimeout(
1180
1353
  handle.sendCommand('Page.reload', { ignoreCache: false }),
1181
1354
  timeoutMs,
@@ -1185,6 +1358,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1185
1358
  () => { void handle.sendCommand('Page.stopLoading').catch(() => {}) },
1186
1359
  )
1187
1360
  this.record(s, 'reload', {}, true)
1361
+ await this.settleDocument(handle, before, signal)
1188
1362
  this.showActive(s)
1189
1363
  }
1190
1364
 
@@ -1199,6 +1373,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
1199
1373
  }
1200
1374
  const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} }
1201
1375
  const timeoutMs = 15_000
1376
+ // Input.dispatchKeyEvent is subject to the same display-surface rule.
1377
+ await this.present(s, signal)
1202
1378
  const release = (): Promise<Record<string, unknown>> =>
1203
1379
  handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params })
1204
1380
  try {
@@ -1677,13 +1853,54 @@ export class ElectronBrowserProvider implements BrowserProvider {
1677
1853
  }
1678
1854
 
1679
1855
  /**
1680
- * Download a URL to a local file, keeping the session's cookies/login.
1681
- * Requires the self-hosted host (which implements view-level download); the
1682
- * desktop shell's embedded views delegate downloads to the real browser UI.
1683
- * Admission: only HTTP(S) targets (the in-page fetch cannot meaningfully
1684
- * fetch anything else), and the save path must be absolute — confined to
1685
- * `downloadDir` when one is configured, so a prompt-injected agent cannot
1686
- * write arbitrary machine paths.
1856
+ * Admit a caller-supplied save path for a file the browser writes to disk.
1857
+ * ONE gate for both `browser_download` and `browser_screenshot`: the path
1858
+ * must be absolute, must resolve inside `downloadDir`, and must not already
1859
+ * exist. Without it a prompt-injected agent could write — or silently
1860
+ * replace — any file the DSH process can reach, which also escapes the file
1861
+ * sandbox every other tool in the set runs under.
1862
+ * @param savePath - the caller's target path.
1863
+ * @param kind - the operation, used in the error code and message.
1864
+ * @returns the resolved absolute target path.
1865
+ * @throws BrowserError when the path is relative, outside the directory, or occupied.
1866
+ */
1867
+ private admitSavePath(savePath: string, kind: 'download' | 'screenshot'): string {
1868
+ const code = kind === 'download' ? 'BROWSER_DOWNLOAD_BLOCKED' : 'BROWSER_SCREENSHOT_BLOCKED'
1869
+ if (!isAbsolute(savePath)) {
1870
+ throw new BrowserError(`browser: ${kind} savePath must be an absolute path`, code)
1871
+ }
1872
+ const file = resolve(savePath)
1873
+ if (this.downloadDir !== undefined) {
1874
+ const dir = resolve(this.downloadDir)
1875
+ // Case-insensitive comparison: Windows paths are case-insensitive, and
1876
+ // resolve() does not normalize case. Without this, C:\Users\X\Downloads
1877
+ // and c:\users\x\downloads would be treated as different roots.
1878
+ const dirLower = dir.toLowerCase()
1879
+ const fileLower = file.toLowerCase()
1880
+ if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
1881
+ throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code)
1882
+ }
1883
+ }
1884
+ // Never replace an existing file: its previous content is unrecoverable,
1885
+ // and the admitted directory may hold files the human put there. A new
1886
+ // name is one tool call away.
1887
+ if (existsSync(file)) {
1888
+ throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code)
1889
+ }
1890
+ return file
1891
+ }
1892
+
1893
+ /**
1894
+ * Download an HTTP(S) URL with the session's cookies to a local file.
1895
+ * Admission is the shared {@link admitSavePath} gate; only the self-hosted
1896
+ * host implements it (the desktop shell's embedded views delegate downloads
1897
+ * to the real browser UI). Both admitted paths and screenshots are confined
1898
+ * to `downloadDir`, so a prompt-injected agent cannot write arbitrary
1899
+ * machine paths.
1900
+ * @param session - the session whose cookies are used.
1901
+ * @param request - the URL plus the absolute target path.
1902
+ * @param signal - optional cancellation.
1903
+ * @returns the path the file was written to.
1687
1904
  */
1688
1905
  async download(session: BrowserSessionId, request: { readonly url: string; readonly savePath: string }, signal?: AbortSignal): Promise<{ readonly path: string }> {
1689
1906
  const s = this.session(session)
@@ -1701,23 +1918,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1701
1918
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1702
1919
  throw new BrowserError(`browser: refusing download of non-HTTP(S) URL "${request.url}"`, 'BROWSER_DOWNLOAD_BLOCKED')
1703
1920
  }
1704
- // savePath admission: must be absolute; when downloadDir is configured it
1705
- // must resolve inside it (no ..-escape).
1706
- if (!isAbsolute(request.savePath)) {
1707
- throw new BrowserError('browser: download savePath must be an absolute path', 'BROWSER_DOWNLOAD_BLOCKED')
1708
- }
1709
- if (this.downloadDir !== undefined) {
1710
- const dir = resolve(this.downloadDir)
1711
- const file = resolve(request.savePath)
1712
- // Case-insensitive comparison: Windows paths are case-insensitive, and
1713
- // resolve() does not normalize case. Without this, C:\Users\X\Downloads
1714
- // and c:\users\x\downloads would be treated as different roots.
1715
- const dirLower = dir.toLowerCase()
1716
- const fileLower = file.toLowerCase()
1717
- if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
1718
- throw new BrowserError(`browser: download savePath must be inside downloadDir "${dir}"`, 'BROWSER_DOWNLOAD_BLOCKED')
1719
- }
1720
- }
1921
+ // savePath admission: the shared gate — absolute, inside `downloadDir`,
1922
+ // and never an existing file. Screenshots use the very same one.
1923
+ const savePath = this.admitSavePath(request.savePath, 'download')
1721
1924
  const downloadable = handle as { download?(url: string, savePath: string): Promise<void> }
1722
1925
  if (typeof downloadable.download !== 'function') {
1723
1926
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
@@ -1730,13 +1933,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
1730
1933
  // reply is harmlessly consumed by the RPC layer.
1731
1934
  const timeoutMs = 60_000
1732
1935
  await withTimeout(
1733
- downloadable.download(request.url, request.savePath),
1936
+ downloadable.download(request.url, savePath),
1734
1937
  timeoutMs,
1735
1938
  signal,
1736
1939
  `browser: download timed out after ${timeoutMs}ms`,
1737
1940
  )
1738
- this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath })
1739
- return { path: request.savePath }
1941
+ this.record(s, 'download', { url: request.url, savePath }, true, { result: savePath })
1942
+ return { path: savePath }
1740
1943
  }
1741
1944
 
1742
1945
  /**
@@ -1829,15 +2032,25 @@ export class ElectronBrowserProvider implements BrowserProvider {
1829
2032
  return this.saveScreenshot(data, request?.savePath, 'image/png')
1830
2033
  }
1831
2034
 
1832
- /** Build the data URL and optionally write the image to disk. */
2035
+ /**
2036
+ * Build the data URL and optionally write the image to disk. The caller's
2037
+ * path goes through the SAME {@link admitSavePath} gate as a download — it
2038
+ * must be absolute, resolve inside `downloadDir`, and not be an existing
2039
+ * file — so a screenshot cannot be used to write to, or silently replace,
2040
+ * files anywhere the DSH process happens to have permission (issue #13).
2041
+ */
1833
2042
  private saveScreenshot(base64: string, savePath: string | undefined, mime: string): { dataUrl: string; path?: string } {
1834
2043
  if (savePath !== undefined) {
2044
+ // Admission failures propagate unchanged: they describe the caller's
2045
+ // path, not a disk problem.
2046
+ const target = this.admitSavePath(savePath, 'screenshot')
1835
2047
  try {
1836
- writeFileSync(savePath, Buffer.from(base64, 'base64'))
1837
- return { dataUrl: `data:${mime};base64,${base64}`, path: savePath }
2048
+ mkdirSync(dirname(target), { recursive: true })
2049
+ writeFileSync(target, Buffer.from(base64, 'base64'))
2050
+ return { dataUrl: `data:${mime};base64,${base64}`, path: target }
1838
2051
  } catch (error) {
1839
2052
  // Report the write problem but keep the capture usable.
1840
- throw new BrowserError(`browser: screenshot save to "${savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
2053
+ throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
1841
2054
  }
1842
2055
  }
1843
2056
  return { dataUrl: `data:${mime};base64,${base64}` }
@@ -2066,6 +2279,39 @@ export class ElectronBrowserProvider implements BrowserProvider {
2066
2279
  }
2067
2280
  }
2068
2281
 
2282
+ /**
2283
+ * Hard cap on waiting for a navigation's new document to be parsed. Bounded and
2284
+ * best-effort: a page that never settles must not fail the navigation.
2285
+ */
2286
+ const SETTLE_TIMEOUT_MS = 5_000
2287
+
2288
+ /**
2289
+ * When the document already looks settled but its identity has NOT changed
2290
+ * (same-document navigation, or the outgoing document still answering), wait
2291
+ * only this long before returning — there is no parse in flight to wait for.
2292
+ */
2293
+ const SETTLE_GRACE_MS = 150
2294
+
2295
+ /** Poll interval for the document-settle loop. */
2296
+ const SETTLE_POLL_MS = 25
2297
+
2298
+ /** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
2299
+ function stripTabPrefix(id: string): string {
2300
+ return id.startsWith('tab:') ? id.slice(4) : id
2301
+ }
2302
+
2303
+ /** Abortable sleep used by the settle loop. */
2304
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
2305
+ return new Promise<void>((resolve) => {
2306
+ const timer = setTimeout(() => {
2307
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
2308
+ resolve()
2309
+ }, ms)
2310
+ const onAbort = (): void => { clearTimeout(timer); resolve() }
2311
+ if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
2312
+ })
2313
+ }
2314
+
2069
2315
  /**
2070
2316
  * Bound a promise so a wedged CDP call surfaces as an error instead of
2071
2317
  * hanging the tool call forever. The caller's signal, when provided, wins
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { execFileSync, spawn, type ChildProcessByStdio } from 'node:child_process'
29
+ import { appendFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
29
30
  import { randomBytes } from 'node:crypto'
30
31
  import { createRequire } from 'node:module'
31
32
  import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs'
@@ -38,6 +39,9 @@ import type { ExportedCookie } from '../browser/types.js'
38
39
  /** How long to wait for the child to signal readiness before failing. */
39
40
  const READY_TIMEOUT_MS = 20_000
40
41
 
42
+ /** Budget for the present/barrier round-trips (see presentView). */
43
+ const PRESENT_TIMEOUT_MS = 10_000
44
+
41
45
  /** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
42
46
  const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024
43
47
 
@@ -355,23 +359,32 @@ function compareVersions(a: string, b: string): number {
355
359
  return 0
356
360
  }
357
361
 
358
- /** From an electron package entry file, find the dist executable beside it. */
359
- function electronExeBeside(entry: string): string | undefined {
360
- const candidates = [
361
- join(dirname(entry), 'dist', 'electron.exe'),
362
- join(dirname(entry), 'dist', 'electron'),
363
- join(dirname(entry), '..', 'dist', 'electron.exe'),
364
- join(dirname(entry), '..', 'dist', 'electron'),
362
+ /** Candidate executable layouts used by the Electron npm package on one platform. */
363
+ function electronDistCandidates(pkgRoot: string, platform: NodeJS.Platform = process.platform): string[] {
364
+ // macOS keeps the runnable binary inside the Electron.app bundle; unlike
365
+ // Windows/Linux there is no dist/electron(.exe) sibling to probe.
366
+ if (platform === 'darwin') {
367
+ return [join(pkgRoot, 'dist', 'Electron.app', 'Contents', 'MacOS', 'Electron')]
368
+ }
369
+ return [
370
+ join(pkgRoot, 'dist', 'electron.exe'),
371
+ join(pkgRoot, 'dist', 'electron'),
365
372
  ]
366
- for (const candidate of candidates) {
367
- if (existsSync(candidate)) return candidate
373
+ }
374
+
375
+ /** From an electron package entry file, find the dist executable beside it. */
376
+ function electronExeBeside(entry: string, platform: NodeJS.Platform = process.platform): string | undefined {
377
+ const packageRoots = [dirname(entry), join(dirname(entry), '..')]
378
+ for (const packageRoot of packageRoots) {
379
+ const candidate = electronDistExe(packageRoot, platform)
380
+ if (candidate !== undefined) return candidate
368
381
  }
369
382
  return undefined
370
383
  }
371
384
 
372
385
  /** From an electron package root, find its dist executable. */
373
- function electronDistExe(pkgRoot: string): string | undefined {
374
- for (const candidate of [join(pkgRoot, 'dist', 'electron.exe'), join(pkgRoot, 'dist', 'electron')]) {
386
+ function electronDistExe(pkgRoot: string, platform: NodeJS.Platform = process.platform): string | undefined {
387
+ for (const candidate of electronDistCandidates(pkgRoot, platform)) {
375
388
  if (existsSync(candidate)) return candidate
376
389
  }
377
390
  return undefined
@@ -452,8 +465,13 @@ class ElectronChildClient {
452
465
  })
453
466
  this.child.stderr.setEncoding('utf8')
454
467
  this.child.stderr.on('data', chunk => {
455
- // Diagnostics only; never parse stderr as protocol.
456
- process.stderr.write(`[dsh-browser host] ${String(chunk)}`)
468
+ // Diagnostics only; never parse stderr as protocol. Mirror to a log file
469
+ // as well: under a plain `dsh web` self-host these lines only ever reach
470
+ // the DSH process's stderr, so a host that crashes in a restart loop left
471
+ // the user with a string of timeouts and no stack to act on.
472
+ const text = String(chunk)
473
+ process.stderr.write(`[dsh-browser host] ${text}`)
474
+ appendHostLog(text)
457
475
  })
458
476
  // Send the spawn token over stdin (first line). The child reads it from
459
477
  // stdin instead of argv so the token is never visible in the process
@@ -464,11 +482,17 @@ class ElectronChildClient {
464
482
  // A failed spawn (bad/corrupt binary) emits 'error' — without a listener
465
483
  // that would crash the whole DSH process.
466
484
  this.child.on('error', error => {
467
- process.stderr.write(`[dsh-browser host] spawn error: ${String(error)}\n`)
485
+ const line = `spawn error: ${String(error)}`
486
+ process.stderr.write(`[dsh-browser host] ${line}\n`)
487
+ appendHostLog(`${line}\n`)
468
488
  this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
469
489
  })
470
490
  this.child.on('exit', (code, signal) => {
471
- this.fail(new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`))
491
+ // The exit code/signal is the other half of a crash report; without it a
492
+ // restart loop is indistinguishable from a clean stop.
493
+ const line = `browser host exited (code=${String(code)} signal=${String(signal)})`
494
+ appendHostLog(`${line}\n`)
495
+ this.fail(new Error(`dsh-builtin-browser: ${line}`))
472
496
  })
473
497
  }
474
498
 
@@ -860,6 +884,39 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
860
884
  .catch(() => { /* host unavailable */ })
861
885
  }
862
886
 
887
+ /**
888
+ * (Re)present a view and WAIT for the child to confirm it, so the provider
889
+ * can dispatch CDP `Input.*` knowing the view has a display surface.
890
+ *
891
+ * Two things this fixes, both silent before:
892
+ * - showing a view that does not exist yet is a no-op in the child, so the
893
+ * view must be materialized first (a fresh tab's createView races the
894
+ * provider's showActive);
895
+ * - a view whose renderer was replaced by a navigation is \"already\n * visible\" and therefore skipped by the child's flicker-avoidance fast
896
+ * path, leaving the new renderer without a surface.
897
+ *
898
+ * The barrier ping is meaningful because the child handles messages strictly
899
+ * in order: its reply is queued behind the showView and so only arrives once
900
+ * the visibility change has been applied.
901
+ */
902
+ async presentView(handle: ElectronViewHandle, label?: string): Promise<void> {
903
+ await this.ready()
904
+ const client = this.client
905
+ if (client === undefined) throw new HostGoneError('browser host unavailable')
906
+ // A view created after the last createView does not exist in the child yet.
907
+ // ensureMaterialized also rebuilds it against a fresh child after a host
908
+ // death, which is the path that used to leave sessions timing out forever.
909
+ if (handle instanceof DeferredRemoteView) {
910
+ await withTimeout(handle.ensureMaterialized(), PRESENT_TIMEOUT_MS, 'browser host did not materialize the view')
911
+ }
912
+ await withTimeout(
913
+ client.call('showView', { viewId: handle.id, ...label !== undefined ? { label } : {} }),
914
+ PRESENT_TIMEOUT_MS,
915
+ 'browser host did not confirm the view presentation',
916
+ )
917
+ await withTimeout(client.call('ping'), PRESENT_TIMEOUT_MS, 'browser host stopped answering')
918
+ }
919
+
863
920
  /** Route a view into its session's own window (one window per session). */
864
921
  groupView(handle: ElectronViewHandle, windowId: string, label?: string): void {
865
922
  this.groups.set(handle.id, { windowId, ...label !== undefined ? { label } : {} })
@@ -977,6 +1034,33 @@ class DeferredRemoteView implements ElectronViewHandle {
977
1034
  async restoreAuth(cookies: ExportedCookie[]): Promise<number> {
978
1035
  return this.withRecovery(view => view.restoreAuth(cookies))
979
1036
  }
1037
+
1038
+ /** Materialize this view in the child (no-op when already there). */
1039
+ async ensureMaterialized(): Promise<void> {
1040
+ await this.withRecovery(async () => undefined)
1041
+ }
1042
+ }
1043
+
1044
+ /**
1045
+ * Append one host-diagnostics chunk to `$DSH_HOME/logs/dsh-builtin-browser-host.log`.
1046
+ * Best-effort and self-limiting: diagnostics must never break the host or grow
1047
+ * without bound, so failures are swallowed and the file is truncated when it
1048
+ * gets large (a crash loop writes quickly).
1049
+ */
1050
+ const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024
1051
+
1052
+ function appendHostLog(text: string): void {
1053
+ const home = process.env.DSH_HOME
1054
+ if (home === undefined || home === '') return
1055
+ try {
1056
+ const dir = join(home, 'logs')
1057
+ const file = join(dir, 'dsh-builtin-browser-host.log')
1058
+ try {
1059
+ if (statSync(file).size > HOST_LOG_MAX_BYTES) writeFileSync(file, '')
1060
+ } catch { /* first write, or unreadable: just append */ }
1061
+ mkdirSync(dir, { recursive: true })
1062
+ appendFileSync(file, text)
1063
+ } catch { /* diagnostics are best-effort only */ }
980
1064
  }
981
1065
 
982
1066
  /** Reject a promise if it does not settle within the budget. */
@@ -1001,6 +1085,9 @@ export function defaultHostMainPath(): string {
1001
1085
  * steps; `resolveElectronPath` is the full resolution order.
1002
1086
  */
1003
1087
  export const internals = {
1088
+ electronDistCandidates,
1089
+ electronDistExe,
1090
+ electronExeBeside,
1004
1091
  isBareElectron,
1005
1092
  resolveElectronPath,
1006
1093
  resolveElectronPathImpl,