dsh-builtin-browser 0.1.21 → 0.1.23

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
@@ -193,6 +207,12 @@ export interface ElectronViewHandle {
193
207
  * @returns the CDP `result` object.
194
208
  */
195
209
  sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
210
+ /**
211
+ * Give the backing view web focus so keyboard input reaches its page.
212
+ * Optional: an adapter that cannot focus a view omits it, and the provider
213
+ * then behaves exactly as before.
214
+ */
215
+ focus?(): Promise<void>
196
216
  }
197
217
 
198
218
  /** One tab inside a session: its view plus a stable id. */
@@ -240,14 +260,16 @@ export interface CdpNavigateParams {
240
260
  }
241
261
 
242
262
  /**
243
- * CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
263
+ * CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
264
+ * of a click's press+release pair. `button`/`clickCount` belong to the press and
265
+ * release halves; a move carries only the position.
244
266
  */
245
267
  export interface CdpMouseParams {
246
- readonly type: 'mousePressed' | 'mouseReleased'
268
+ readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased'
247
269
  readonly x: number
248
270
  readonly y: number
249
- readonly button: 'left'
250
- readonly clickCount: number
271
+ readonly button?: 'left'
272
+ readonly clickCount?: number
251
273
  }
252
274
 
253
275
  /** CDP method/params for `Input.insertText`. */
@@ -303,6 +325,31 @@ const KEY_SPECS: Readonly<Record<string, { readonly key: string; readonly code:
303
325
  /** Supported key names, exported for the tool's enum and error messages. */
304
326
  export const BROWSER_KEY_NAMES: readonly string[] = Object.keys(KEY_SPECS)
305
327
 
328
+ /**
329
+ * Localized names a system Downloads folder may carry, in probe order: the
330
+ * English default first, then the Chinese spellings — a zh-CN desktop calls
331
+ * the folder `下载`, a zh-TW one `下載`, and neither is `Downloads`.
332
+ */
333
+ const DOWNLOAD_DIR_NAMES: readonly string[] = ['Downloads', '下载', '下載']
334
+
335
+ /**
336
+ * Resolve the default download directory when the config names none: an
337
+ * existing `XDG_DOWNLOAD_DIR` wins (the freedesktop standard, which is what a
338
+ * localized Linux desktop writes), then the first localized `Downloads` folder
339
+ * that exists under the home directory, and finally the English name — created
340
+ * on first use — when none exists yet.
341
+ * @returns the absolute default download directory.
342
+ */
343
+ function defaultDownloadDir(): string {
344
+ const xdg = process.env.XDG_DOWNLOAD_DIR
345
+ if (typeof xdg === 'string' && xdg !== '' && existsSync(xdg)) return xdg
346
+ for (const name of DOWNLOAD_DIR_NAMES) {
347
+ const candidate = join(homedir(), name)
348
+ if (existsSync(candidate)) return candidate
349
+ }
350
+ return join(homedir(), DOWNLOAD_DIR_NAMES[0] ?? 'Downloads')
351
+ }
352
+
306
353
  /**
307
354
  * Browser provider over Electron views. Sessions hold an ordered list of
308
355
  * tabs; each tab is one view created by the host. The active tab receives
@@ -326,9 +373,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
326
373
  this.httpOnly = config.httpOnly ?? true
327
374
  this.snapshotMaxElements = config.snapshotMaxElements ?? 60
328
375
  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')
376
+ // Confine downloads AND screenshot saves to a dedicated directory by
377
+ // default: the OS Downloads folder is the human-visible, browser-natural
378
+ // place for written files, and it is the one directory the DSH file
379
+ // sandbox does not have to arbitrate. The localized name is probed so a
380
+ // Chinese desktop (`~/下载`) works without configuring `downloadDir`.
381
+ this.downloadDir = config.downloadDir ?? defaultDownloadDir()
332
382
  // Route toolbar (host UI) actions into the session model: the human and
333
383
  // the agent then always drive the same tabs, history, and navigation.
334
384
  this.host.onUserAction?.(action => { void this.handleUserAction(action) })
@@ -433,10 +483,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
433
483
  */
434
484
  private locateTab(session: BrowserSessionId, tabId: string): { s: Session; index: number } {
435
485
  const s = this.session(session)
436
- const own = s.tabs.findIndex(tab => tab.id === tabId)
486
+ // Accept the bare uuid as well as the "tab:<uuid>" form the tools print:
487
+ // ids are globally unique, so requiring the printed prefix only produced a
488
+ // misleading "is not open in this session" error that listed the very tab
489
+ // the caller had just named.
490
+ const wanted = stripTabPrefix(tabId)
491
+ const own = s.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
437
492
  if (own >= 0) return { s, index: own }
438
493
  for (const other of this.sessions.values()) {
439
- const index = other.tabs.findIndex(tab => tab.id === tabId)
494
+ const index = other.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted)
440
495
  if (index >= 0) return { s: other, index }
441
496
  }
442
497
  const ownIds = s.tabs.map(t => t.id).join(', ') || '(none)'
@@ -479,6 +534,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
479
534
  // evaluate paths so a wedged navigation surfaces as an error instead of
480
535
  // blocking the tool call forever.
481
536
  const timeoutMs = 30_000
537
+ // Document identity of the page we are leaving (see settleDocument).
538
+ const before = await this.documentStamp(handle)
482
539
  const result = await withTimeout(
483
540
  handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams),
484
541
  timeoutMs,
@@ -494,7 +551,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
494
551
  if (typeof errorText === 'string' && errorText !== '') {
495
552
  throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
496
553
  }
554
+ // Record as soon as the navigation has committed: history bookkeeping must
555
+ // not wait on the best-effort settle below (which only delays what the
556
+ // NEXT observation sees, not whether this action happened).
497
557
  this.record(s, 'navigate', { url }, true)
558
+ await this.settleDocument(handle, before, signal)
498
559
  this.showActive(s)
499
560
  } catch (error) {
500
561
  if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
@@ -504,6 +565,133 @@ export class ElectronBrowserProvider implements BrowserProvider {
504
565
  }
505
566
  }
506
567
 
568
+ /**
569
+ * A per-document stamp: `performance.timeOrigin` is unique per document load,
570
+ * so it tells a same-URL reload and an A→B→A redirect apart from the document
571
+ * that was current before the navigation. Empty string when unreadable.
572
+ */
573
+ private async documentStamp(handle: ElectronViewHandle): Promise<string> {
574
+ const probe = await this.documentProbe(handle)
575
+ return probe?.stamp ?? ''
576
+ }
577
+
578
+ /**
579
+ * One cheap in-page reading of the document's identity and parse state.
580
+ *
581
+ * Returns null when the page did NOT answer (mid-commit, execution context
582
+ * destroyed) — the caller keeps waiting. A page that answered with an
583
+ * unexpected shape (an override or a non-conforming host; the production
584
+ * expression always yields a string) is reported as `unknown` rather than as
585
+ * "no answer": blocking the navigation for the whole budget on a page that
586
+ * demonstrably responded has no upside.
587
+ */
588
+ private async documentProbe(handle: ElectronViewHandle): Promise<{ stamp: string; readyState: string } | null> {
589
+ try {
590
+ const result = await withTimeout(
591
+ handleSendEvaluate(handle, 'String(performance.timeOrigin) + "|" + document.readyState'),
592
+ 3_000,
593
+ undefined,
594
+ 'browser: document probe timed out',
595
+ )
596
+ if (!result.ok) return null
597
+ if (typeof result.value !== 'string') return { stamp: '', readyState: 'unknown' }
598
+ const [stamp = '', readyState = ''] = result.value.split('|')
599
+ return { stamp, readyState }
600
+ } catch {
601
+ return null
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Page.navigate resolves at navigation COMMIT, not at load: the new document
607
+ * is already current (so location.href and document.title are the new page's)
608
+ * while its DOM is still being parsed. browser_open snapshots immediately
609
+ * after navigating, which is why it could report the right title with zero
610
+ * interactive elements — and why a separate browser_snapshot right after
611
+ * always found them.
612
+ *
613
+ * Wait — bounded, best-effort — for the new document to settle: readyState
614
+ * must reach interactive/complete AND the document identity must have
615
+ * changed. A page that never settles must not fail a navigation that already
616
+ * succeeded, so a timeout here is swallowed.
617
+ */
618
+ private async settleDocument(handle: ElectronViewHandle, before: string, signal?: AbortSignal): Promise<void> {
619
+ const started = Date.now()
620
+ const deadline = started + SETTLE_TIMEOUT_MS
621
+ for (;;) {
622
+ if (signal?.aborted === true) return
623
+ // A probe failure is expected while the navigation commits (the context is
624
+ // being destroyed) — keep polling rather than giving up.
625
+ const probe = await this.documentProbe(handle)
626
+ if (probe !== null) {
627
+ const settled = probe.readyState === 'interactive'
628
+ || probe.readyState === 'complete'
629
+ || probe.readyState === 'unknown'
630
+ const isNewDocument = before === '' || probe.stamp === '' || probe.stamp !== before
631
+ // The common case: the new document exists and is parsed — return now.
632
+ if (settled && isNewDocument) return
633
+ // Same document (hash navigation, cancelled/aborted load): there is no
634
+ // parse to wait for, so do not burn the budget. Outlast the brief
635
+ // window in which the OUTGOING document may still answer instead.
636
+ if (settled && Date.now() - started >= SETTLE_GRACE_MS) return
637
+ }
638
+ if (Date.now() >= deadline) return
639
+ await delay(SETTLE_POLL_MS, signal)
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Make sure the active tab's view can actually receive synthesized input.
645
+ * Chromium drops `Input.*` events for a view with no display surface, so this
646
+ * runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
647
+ * rather than reporting a success the page never saw.
648
+ */
649
+ private async present(s: Session, signal?: AbortSignal): Promise<void> {
650
+ const { handle } = this.activeTab(s)
651
+ const present = this.host.presentView?.bind(this.host)
652
+ if (present === undefined) {
653
+ this.showActive(s)
654
+ return
655
+ }
656
+ signal?.throwIfAborted()
657
+ const timeoutMs = 10_000
658
+ try {
659
+ await withTimeout(
660
+ present(handle, s.label),
661
+ timeoutMs,
662
+ signal,
663
+ `browser: presenting the page view timed out after ${timeoutMs}ms`,
664
+ )
665
+ this.showActive(s)
666
+ } catch (error) {
667
+ throw new BrowserError(
668
+ `browser: the page view is not presented, so synthesized input would be silently dropped (${String(error)})`,
669
+ 'BROWSER_VIEW_NOT_PRESENTED',
670
+ { cause: error },
671
+ )
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Give the view web focus before synthesizing keyboard input.
677
+ *
678
+ * A renderer only delivers key events to the view that holds web focus. A
679
+ * view that was created but never clicked holds none — and `Input` events
680
+ * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
681
+ * session vanished inside the renderer while the command still answered
682
+ * success. Hosts that cannot focus a view (a test double, a different shell
683
+ * adapter) simply have no `focus`, and nothing changes for them.
684
+ */
685
+ private async focusView(handle: ElectronViewHandle): Promise<void> {
686
+ if (handle.focus === undefined) return
687
+ try {
688
+ await handle.focus()
689
+ } catch {
690
+ // Best-effort: a view that refuses focus must not fail the caller's key
691
+ // press; the dispatch below reports a real channel failure itself.
692
+ }
693
+ }
694
+
507
695
  /** Execute JS in the active tab's page context. */
508
696
  async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
509
697
  const s = this.session(session)
@@ -1033,23 +1221,54 @@ export class ElectronBrowserProvider implements BrowserProvider {
1033
1221
  x = request.x
1034
1222
  y = request.y
1035
1223
  }
1036
- const press = (type: 'mousePressed' | 'mouseReleased'): Promise<Record<string, unknown>> =>
1037
- handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
1224
+ // Input.* is only delivered to a view with a current display surface; the
1225
+ // barrier also re-presents after a navigation replaced the renderer.
1226
+ await this.present(s, signal)
1038
1227
  const timeoutMs = 30_000
1228
+ const send = (params: Record<string, unknown>, label: string): Promise<unknown> =>
1229
+ withTimeout(
1230
+ handle.sendCommand('Input.dispatchMouseEvent', params),
1231
+ timeoutMs,
1232
+ signal,
1233
+ `browser: click ${label} timed out after ${timeoutMs}ms`,
1234
+ )
1235
+ // Chromium routes a synthesized `mousePressed` to the widget's *current*
1236
+ // hover target rather than hit-testing the coordinates, and a real pointer
1237
+ // click is always preceded by movement. A view that has never received a
1238
+ // mouse event has no hover target yet, so the press of the FIRST click on a
1239
+ // fresh tab went nowhere while CDP still answered success; the second click
1240
+ // landed because the first had quietly established the target. Move the
1241
+ // pointer first so a click is self-contained.
1242
+ try {
1243
+ await send({ type: 'mouseMoved', x, y } satisfies CdpMouseParams, 'move')
1244
+ } catch (error) {
1245
+ throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1246
+ }
1247
+ const release = (): void => {
1248
+ void handle
1249
+ .sendCommand('Input.dispatchMouseEvent', {
1250
+ type: 'mouseReleased',
1251
+ x,
1252
+ y,
1253
+ button: 'left',
1254
+ clickCount: 1,
1255
+ } satisfies CdpMouseParams)
1256
+ .catch(() => {})
1257
+ }
1039
1258
  try {
1040
- await withTimeout(press('mousePressed'), timeoutMs, signal, `browser: click press timed out after ${timeoutMs}ms`)
1259
+ await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press')
1041
1260
  } catch (error) {
1042
1261
  // The press may still land late; release best-effort so the button is
1043
1262
  // never left in a stuck pressed state.
1044
- void press('mouseReleased').catch(() => {})
1263
+ release()
1045
1264
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1046
1265
  }
1047
1266
  try {
1048
- await withTimeout(press('mouseReleased'), timeoutMs, signal, `browser: click release timed out after ${timeoutMs}ms`)
1267
+ await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release')
1049
1268
  } catch (error) {
1050
1269
  // The press already landed; retry the release so the button is not
1051
1270
  // left pressed before surfacing the failure.
1052
- void press('mouseReleased').catch(() => {})
1271
+ release()
1053
1272
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1054
1273
  }
1055
1274
  this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true)
@@ -1078,6 +1297,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1078
1297
  }
1079
1298
  const text = 'text' in request ? request.text : ''
1080
1299
  const timeoutMs = 30_000
1300
+ // Input.insertText goes through the Input domain: same display-surface
1301
+ // requirement as click/key.
1302
+ await this.present(s, signal)
1081
1303
  try {
1082
1304
  await withTimeout(
1083
1305
  handle.sendCommand('Input.insertText', { text } satisfies CdpInsertTextParams),
@@ -1149,6 +1371,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1149
1371
  }
1150
1372
  const entry = entries[target]
1151
1373
  if (entry?.id === undefined) throw new BrowserError('browser: history entry missing id', 'BROWSER_HISTORY_INVALID')
1374
+ // A history step is a navigation too: the classic A→B→A redirect would
1375
+ // otherwise read the outgoing document's identity (see settleDocument).
1376
+ const before = await this.documentStamp(handle)
1152
1377
  await withTimeout(
1153
1378
  handle.sendCommand('Page.navigateToHistoryEntry', { entryId: entry.id }),
1154
1379
  timeoutMs,
@@ -1157,6 +1382,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1157
1382
  () => { void handle.sendCommand('Page.stopLoading').catch(() => {}) },
1158
1383
  )
1159
1384
  this.record(s, direction === -1 ? 'back' : 'forward', {}, true)
1385
+ await this.settleDocument(handle, before, signal)
1160
1386
  this.showActive(s)
1161
1387
  }
1162
1388
 
@@ -1176,6 +1402,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1176
1402
  const { handle } = this.activeTab(s)
1177
1403
  signal?.throwIfAborted()
1178
1404
  const timeoutMs = 30_000
1405
+ // Same-URL reload: only the per-load document identity can tell the new
1406
+ // document from the old one, so capture it before reloading.
1407
+ const before = await this.documentStamp(handle)
1179
1408
  await withTimeout(
1180
1409
  handle.sendCommand('Page.reload', { ignoreCache: false }),
1181
1410
  timeoutMs,
@@ -1185,6 +1414,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1185
1414
  () => { void handle.sendCommand('Page.stopLoading').catch(() => {}) },
1186
1415
  )
1187
1416
  this.record(s, 'reload', {}, true)
1417
+ await this.settleDocument(handle, before, signal)
1188
1418
  this.showActive(s)
1189
1419
  }
1190
1420
 
@@ -1199,6 +1429,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1199
1429
  }
1200
1430
  const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} }
1201
1431
  const timeoutMs = 15_000
1432
+ // Input.dispatchKeyEvent is subject to the same display-surface rule.
1433
+ await this.present(s, signal)
1434
+ await this.focusView(handle)
1202
1435
  const release = (): Promise<Record<string, unknown>> =>
1203
1436
  handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params })
1204
1437
  try {
@@ -1376,10 +1609,16 @@ export class ElectronBrowserProvider implements BrowserProvider {
1376
1609
  * (polling until it appears or the budget runs out) and then runs `body`
1377
1610
  * with `el` in scope. Shared by the target-based tools: click/type (②),
1378
1611
  * setValue/check/select/clear/getValue (③), and the scrape item wait.
1612
+ * A selector that fails to PARSE is reported immediately instead of being
1613
+ * polled until the budget expires — see the comment in `match`.
1379
1614
  */
1380
1615
  private buildTargetScript(spec: BrowserElementTarget, timeoutMs: number, body: string): string {
1616
+ // `by` defaults to css in-page; report the strategy that WAS used, or a
1617
+ // miss says only `{"value":"Learn more"}` and the caller cannot tell that a
1618
+ // plain label was queried as a CSS selector.
1619
+ const resolvedSpec = { ...spec, by: spec.by ?? 'css' }
1381
1620
  return `(async () => {
1382
- const spec = ${JSON.stringify(spec)}
1621
+ const spec = ${JSON.stringify(resolvedSpec)}
1383
1622
  const timeoutMs = ${String(timeoutMs)}
1384
1623
  const sleep = ms => new Promise(r => setTimeout(r, ms))
1385
1624
  const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
@@ -1391,7 +1630,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
1391
1630
  const index = typeof spec.index === 'number' ? spec.index : 0
1392
1631
  let els = []
1393
1632
  if (by === 'css') {
1394
- try { els = Array.from(document.querySelectorAll(value)) } catch { return null }
1633
+ // A selector that does not PARSE can never match on a later poll, so
1634
+ // it must not look like a miss: the old "return null" kept the caller
1635
+ // polling until the whole budget was gone, and "element not found
1636
+ // after 10s" reads like a slow page while the real cause is a
1637
+ // malformed selector (a label or plain text passed where one belongs).
1638
+ try { els = Array.from(document.querySelectorAll(value)) }
1639
+ catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1395
1640
  } else if (by === 'xpath') {
1396
1641
  try {
1397
1642
  const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
@@ -1399,7 +1644,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1399
1644
  const n = snap.snapshotItem(i)
1400
1645
  if (n instanceof Element) els.push(n)
1401
1646
  }
1402
- } catch { return null }
1647
+ } catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1403
1648
  } else {
1404
1649
  const all = Array.from(document.querySelectorAll('body *'))
1405
1650
  const exact = all.filter(el => isVisible(el) && ownText(el) === value)
@@ -1413,9 +1658,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
1413
1658
  const deadline = Date.now() + timeoutMs
1414
1659
  let el = null
1415
1660
  for (;;) {
1416
- el = match()
1661
+ // Only a parse error escapes the poll: see the comment in match() above.
1662
+ try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
1417
1663
  if (el !== null) break
1418
- if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
1664
+ if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
1419
1665
  await sleep(100)
1420
1666
  }
1421
1667
  ${body}
@@ -1435,9 +1681,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
1435
1681
  errorCode: string,
1436
1682
  errorLabel: string,
1437
1683
  ): Promise<Record<string, unknown>> {
1684
+ // The script polls for its whole `timeoutMs` and only then answers, so an
1685
+ // outer budget of the same size fires at the same instant and wins: the
1686
+ // caller saw `click timed out after 10000ms` while the page had already
1687
+ // decided WHY (selector not found / malformed). Give the answer a chance to
1688
+ // get back before the generic timeout replaces it.
1438
1689
  const result = await withTimeout(
1439
1690
  handleSendEvaluate(tab.handle, script),
1440
- timeoutMs,
1691
+ timeoutMs + TARGET_SCRIPT_GRACE_MS,
1441
1692
  signal,
1442
1693
  `${errorLabel} timed out after ${timeoutMs}ms`,
1443
1694
  () => terminatePage(tab.handle),
@@ -1637,7 +1888,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1637
1888
  const deadline = Date.now() + timeoutMs
1638
1889
  let items = []
1639
1890
  for (;;) {
1640
- try { items = Array.from(document.querySelectorAll(itemSel)) } catch (e) { return { ok: false, error: String(e) } }
1891
+ // Immediate, like match() above: an unparseable selector can never match.
1892
+ try { items = Array.from(document.querySelectorAll(itemSel)) }
1893
+ catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
1641
1894
  if (items.length > 0) break
1642
1895
  if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
1643
1896
  await sleep(100)
@@ -1677,13 +1930,54 @@ export class ElectronBrowserProvider implements BrowserProvider {
1677
1930
  }
1678
1931
 
1679
1932
  /**
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.
1933
+ * Admit a caller-supplied save path for a file the browser writes to disk.
1934
+ * ONE gate for both `browser_download` and `browser_screenshot`: the path
1935
+ * must be absolute, must resolve inside `downloadDir`, and must not already
1936
+ * exist. Without it a prompt-injected agent could write — or silently
1937
+ * replace — any file the DSH process can reach, which also escapes the file
1938
+ * sandbox every other tool in the set runs under.
1939
+ * @param savePath - the caller's target path.
1940
+ * @param kind - the operation, used in the error code and message.
1941
+ * @returns the resolved absolute target path.
1942
+ * @throws BrowserError when the path is relative, outside the directory, or occupied.
1943
+ */
1944
+ private admitSavePath(savePath: string, kind: 'download' | 'screenshot'): string {
1945
+ const code = kind === 'download' ? 'BROWSER_DOWNLOAD_BLOCKED' : 'BROWSER_SCREENSHOT_BLOCKED'
1946
+ if (!isAbsolute(savePath)) {
1947
+ throw new BrowserError(`browser: ${kind} savePath must be an absolute path`, code)
1948
+ }
1949
+ const file = resolve(savePath)
1950
+ if (this.downloadDir !== undefined) {
1951
+ const dir = resolve(this.downloadDir)
1952
+ // Case-insensitive comparison: Windows paths are case-insensitive, and
1953
+ // resolve() does not normalize case. Without this, C:\Users\X\Downloads
1954
+ // and c:\users\x\downloads would be treated as different roots.
1955
+ const dirLower = dir.toLowerCase()
1956
+ const fileLower = file.toLowerCase()
1957
+ if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
1958
+ throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code)
1959
+ }
1960
+ }
1961
+ // Never replace an existing file: its previous content is unrecoverable,
1962
+ // and the admitted directory may hold files the human put there. A new
1963
+ // name is one tool call away.
1964
+ if (existsSync(file)) {
1965
+ throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code)
1966
+ }
1967
+ return file
1968
+ }
1969
+
1970
+ /**
1971
+ * Download an HTTP(S) URL with the session's cookies to a local file.
1972
+ * Admission is the shared {@link admitSavePath} gate; only the self-hosted
1973
+ * host implements it (the desktop shell's embedded views delegate downloads
1974
+ * to the real browser UI). Both admitted paths and screenshots are confined
1975
+ * to `downloadDir`, so a prompt-injected agent cannot write arbitrary
1976
+ * machine paths.
1977
+ * @param session - the session whose cookies are used.
1978
+ * @param request - the URL plus the absolute target path.
1979
+ * @param signal - optional cancellation.
1980
+ * @returns the path the file was written to.
1687
1981
  */
1688
1982
  async download(session: BrowserSessionId, request: { readonly url: string; readonly savePath: string }, signal?: AbortSignal): Promise<{ readonly path: string }> {
1689
1983
  const s = this.session(session)
@@ -1701,23 +1995,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1701
1995
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1702
1996
  throw new BrowserError(`browser: refusing download of non-HTTP(S) URL "${request.url}"`, 'BROWSER_DOWNLOAD_BLOCKED')
1703
1997
  }
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
- }
1998
+ // savePath admission: the shared gate — absolute, inside `downloadDir`,
1999
+ // and never an existing file. Screenshots use the very same one.
2000
+ const savePath = this.admitSavePath(request.savePath, 'download')
1721
2001
  const downloadable = handle as { download?(url: string, savePath: string): Promise<void> }
1722
2002
  if (typeof downloadable.download !== 'function') {
1723
2003
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
@@ -1730,13 +2010,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
1730
2010
  // reply is harmlessly consumed by the RPC layer.
1731
2011
  const timeoutMs = 60_000
1732
2012
  await withTimeout(
1733
- downloadable.download(request.url, request.savePath),
2013
+ downloadable.download(request.url, savePath),
1734
2014
  timeoutMs,
1735
2015
  signal,
1736
2016
  `browser: download timed out after ${timeoutMs}ms`,
1737
2017
  )
1738
- this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath })
1739
- return { path: request.savePath }
2018
+ this.record(s, 'download', { url: request.url, savePath }, true, { result: savePath })
2019
+ return { path: savePath }
1740
2020
  }
1741
2021
 
1742
2022
  /**
@@ -1829,15 +2109,25 @@ export class ElectronBrowserProvider implements BrowserProvider {
1829
2109
  return this.saveScreenshot(data, request?.savePath, 'image/png')
1830
2110
  }
1831
2111
 
1832
- /** Build the data URL and optionally write the image to disk. */
2112
+ /**
2113
+ * Build the data URL and optionally write the image to disk. The caller's
2114
+ * path goes through the SAME {@link admitSavePath} gate as a download — it
2115
+ * must be absolute, resolve inside `downloadDir`, and not be an existing
2116
+ * file — so a screenshot cannot be used to write to, or silently replace,
2117
+ * files anywhere the DSH process happens to have permission (issue #13).
2118
+ */
1833
2119
  private saveScreenshot(base64: string, savePath: string | undefined, mime: string): { dataUrl: string; path?: string } {
1834
2120
  if (savePath !== undefined) {
2121
+ // Admission failures propagate unchanged: they describe the caller's
2122
+ // path, not a disk problem.
2123
+ const target = this.admitSavePath(savePath, 'screenshot')
1835
2124
  try {
1836
- writeFileSync(savePath, Buffer.from(base64, 'base64'))
1837
- return { dataUrl: `data:${mime};base64,${base64}`, path: savePath }
2125
+ mkdirSync(dirname(target), { recursive: true })
2126
+ writeFileSync(target, Buffer.from(base64, 'base64'))
2127
+ return { dataUrl: `data:${mime};base64,${base64}`, path: target }
1838
2128
  } catch (error) {
1839
2129
  // 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 })
2130
+ throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
1841
2131
  }
1842
2132
  }
1843
2133
  return { dataUrl: `data:${mime};base64,${base64}` }
@@ -2066,6 +2356,46 @@ export class ElectronBrowserProvider implements BrowserProvider {
2066
2356
  }
2067
2357
  }
2068
2358
 
2359
+ /**
2360
+ * Hard cap on waiting for a navigation's new document to be parsed. Bounded and
2361
+ * best-effort: a page that never settles must not fail the navigation.
2362
+ */
2363
+ const SETTLE_TIMEOUT_MS = 5_000
2364
+
2365
+ /**
2366
+ * When the document already looks settled but its identity has NOT changed
2367
+ * (same-document navigation, or the outgoing document still answering), wait
2368
+ * only this long before returning — there is no parse in flight to wait for.
2369
+ */
2370
+ const SETTLE_GRACE_MS = 150
2371
+
2372
+ /** Poll interval for the document-settle loop. */
2373
+ const SETTLE_POLL_MS = 25
2374
+
2375
+ /**
2376
+ * Extra time the transport gets over an in-page locate script's own budget: the
2377
+ * script polls for `timeoutMs` and answers only afterwards, so the outer wait
2378
+ * must outlast it or its generic timeout hides the in-page reason.
2379
+ */
2380
+ const TARGET_SCRIPT_GRACE_MS = 2_000
2381
+
2382
+ /** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
2383
+ function stripTabPrefix(id: string): string {
2384
+ return id.startsWith('tab:') ? id.slice(4) : id
2385
+ }
2386
+
2387
+ /** Abortable sleep used by the settle loop. */
2388
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
2389
+ return new Promise<void>((resolve) => {
2390
+ const timer = setTimeout(() => {
2391
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
2392
+ resolve()
2393
+ }, ms)
2394
+ const onAbort = (): void => { clearTimeout(timer); resolve() }
2395
+ if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
2396
+ })
2397
+ }
2398
+
2069
2399
  /**
2070
2400
  * Bound a promise so a wedged CDP call surfaces as an error instead of
2071
2401
  * hanging the tool call forever. The caller's signal, when provided, wins