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.
@@ -7,9 +7,9 @@
7
7
  * @module dsh-browser/browser-electron
8
8
  */
9
9
  import { randomUUID } from 'node:crypto';
10
- import { writeFileSync } from 'node:fs';
10
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
- import { isAbsolute, join, resolve, sep } from 'node:path';
12
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
13
13
  import { BrowserError } from '../browser/types.js';
14
14
  /**
15
15
  * Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
@@ -95,6 +95,31 @@ const KEY_SPECS = {
95
95
  };
96
96
  /** Supported key names, exported for the tool's enum and error messages. */
97
97
  export const BROWSER_KEY_NAMES = Object.keys(KEY_SPECS);
98
+ /**
99
+ * Localized names a system Downloads folder may carry, in probe order: the
100
+ * English default first, then the Chinese spellings — a zh-CN desktop calls
101
+ * the folder `下载`, a zh-TW one `下載`, and neither is `Downloads`.
102
+ */
103
+ const DOWNLOAD_DIR_NAMES = ['Downloads', '下载', '下載'];
104
+ /**
105
+ * Resolve the default download directory when the config names none: an
106
+ * existing `XDG_DOWNLOAD_DIR` wins (the freedesktop standard, which is what a
107
+ * localized Linux desktop writes), then the first localized `Downloads` folder
108
+ * that exists under the home directory, and finally the English name — created
109
+ * on first use — when none exists yet.
110
+ * @returns the absolute default download directory.
111
+ */
112
+ function defaultDownloadDir() {
113
+ const xdg = process.env.XDG_DOWNLOAD_DIR;
114
+ if (typeof xdg === 'string' && xdg !== '' && existsSync(xdg))
115
+ return xdg;
116
+ for (const name of DOWNLOAD_DIR_NAMES) {
117
+ const candidate = join(homedir(), name);
118
+ if (existsSync(candidate))
119
+ return candidate;
120
+ }
121
+ return join(homedir(), DOWNLOAD_DIR_NAMES[0] ?? 'Downloads');
122
+ }
98
123
  /**
99
124
  * Browser provider over Electron views. Sessions hold an ordered list of
100
125
  * tabs; each tab is one view created by the host. The active tab receives
@@ -115,9 +140,12 @@ export class ElectronBrowserProvider {
115
140
  this.httpOnly = config.httpOnly ?? true;
116
141
  this.snapshotMaxElements = config.snapshotMaxElements ?? 60;
117
142
  this.contentMaxChars = config.contentMaxChars ?? 100_000;
118
- // Confine downloads to a dedicated directory by default: the OS Downloads
119
- // folder is the human-visible, browser-natural place for downloaded files.
120
- this.downloadDir = config.downloadDir ?? join(homedir(), 'Downloads');
143
+ // Confine downloads AND screenshot saves to a dedicated directory by
144
+ // default: the OS Downloads folder is the human-visible, browser-natural
145
+ // place for written files, and it is the one directory the DSH file
146
+ // sandbox does not have to arbitrate. The localized name is probed so a
147
+ // Chinese desktop (`~/下载`) works without configuring `downloadDir`.
148
+ this.downloadDir = config.downloadDir ?? defaultDownloadDir();
121
149
  // Route toolbar (host UI) actions into the session model: the human and
122
150
  // the agent then always drive the same tabs, history, and navigation.
123
151
  this.host.onUserAction?.(action => { void this.handleUserAction(action); });
@@ -218,11 +246,16 @@ export class ElectronBrowserProvider {
218
246
  */
219
247
  locateTab(session, tabId) {
220
248
  const s = this.session(session);
221
- const own = s.tabs.findIndex(tab => tab.id === tabId);
249
+ // Accept the bare uuid as well as the "tab:<uuid>" form the tools print:
250
+ // ids are globally unique, so requiring the printed prefix only produced a
251
+ // misleading "is not open in this session" error that listed the very tab
252
+ // the caller had just named.
253
+ const wanted = stripTabPrefix(tabId);
254
+ const own = s.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted);
222
255
  if (own >= 0)
223
256
  return { s, index: own };
224
257
  for (const other of this.sessions.values()) {
225
- const index = other.tabs.findIndex(tab => tab.id === tabId);
258
+ const index = other.tabs.findIndex(tab => stripTabPrefix(tab.id) === wanted);
226
259
  if (index >= 0)
227
260
  return { s: other, index };
228
261
  }
@@ -263,6 +296,8 @@ export class ElectronBrowserProvider {
263
296
  // evaluate paths so a wedged navigation surfaces as an error instead of
264
297
  // blocking the tool call forever.
265
298
  const timeoutMs = 30_000;
299
+ // Document identity of the page we are leaving (see settleDocument).
300
+ const before = await this.documentStamp(handle);
266
301
  const result = await withTimeout(handle.sendCommand(CDP_PAGE_NAVIGATE, { url }), timeoutMs, signal, `browser: navigation timed out after ${timeoutMs}ms`,
267
302
  // Best-effort: cancel the in-flight navigation so a wedged load does
268
303
  // not leave the debugger queue busy.
@@ -273,7 +308,11 @@ export class ElectronBrowserProvider {
273
308
  if (typeof errorText === 'string' && errorText !== '') {
274
309
  throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED');
275
310
  }
311
+ // Record as soon as the navigation has committed: history bookkeeping must
312
+ // not wait on the best-effort settle below (which only delays what the
313
+ // NEXT observation sees, not whether this action happened).
276
314
  this.record(s, 'navigate', { url }, true);
315
+ await this.settleDocument(handle, before, signal);
277
316
  this.showActive(s);
278
317
  }
279
318
  catch (error) {
@@ -283,6 +322,124 @@ export class ElectronBrowserProvider {
283
322
  throw error;
284
323
  }
285
324
  }
325
+ /**
326
+ * A per-document stamp: `performance.timeOrigin` is unique per document load,
327
+ * so it tells a same-URL reload and an A→B→A redirect apart from the document
328
+ * that was current before the navigation. Empty string when unreadable.
329
+ */
330
+ async documentStamp(handle) {
331
+ const probe = await this.documentProbe(handle);
332
+ return probe?.stamp ?? '';
333
+ }
334
+ /**
335
+ * One cheap in-page reading of the document's identity and parse state.
336
+ *
337
+ * Returns null when the page did NOT answer (mid-commit, execution context
338
+ * destroyed) — the caller keeps waiting. A page that answered with an
339
+ * unexpected shape (an override or a non-conforming host; the production
340
+ * expression always yields a string) is reported as `unknown` rather than as
341
+ * "no answer": blocking the navigation for the whole budget on a page that
342
+ * demonstrably responded has no upside.
343
+ */
344
+ async documentProbe(handle) {
345
+ try {
346
+ const result = await withTimeout(handleSendEvaluate(handle, 'String(performance.timeOrigin) + "|" + document.readyState'), 3_000, undefined, 'browser: document probe timed out');
347
+ if (!result.ok)
348
+ return null;
349
+ if (typeof result.value !== 'string')
350
+ return { stamp: '', readyState: 'unknown' };
351
+ const [stamp = '', readyState = ''] = result.value.split('|');
352
+ return { stamp, readyState };
353
+ }
354
+ catch {
355
+ return null;
356
+ }
357
+ }
358
+ /**
359
+ * Page.navigate resolves at navigation COMMIT, not at load: the new document
360
+ * is already current (so location.href and document.title are the new page's)
361
+ * while its DOM is still being parsed. browser_open snapshots immediately
362
+ * after navigating, which is why it could report the right title with zero
363
+ * interactive elements — and why a separate browser_snapshot right after
364
+ * always found them.
365
+ *
366
+ * Wait — bounded, best-effort — for the new document to settle: readyState
367
+ * must reach interactive/complete AND the document identity must have
368
+ * changed. A page that never settles must not fail a navigation that already
369
+ * succeeded, so a timeout here is swallowed.
370
+ */
371
+ async settleDocument(handle, before, signal) {
372
+ const started = Date.now();
373
+ const deadline = started + SETTLE_TIMEOUT_MS;
374
+ for (;;) {
375
+ if (signal?.aborted === true)
376
+ return;
377
+ // A probe failure is expected while the navigation commits (the context is
378
+ // being destroyed) — keep polling rather than giving up.
379
+ const probe = await this.documentProbe(handle);
380
+ if (probe !== null) {
381
+ const settled = probe.readyState === 'interactive'
382
+ || probe.readyState === 'complete'
383
+ || probe.readyState === 'unknown';
384
+ const isNewDocument = before === '' || probe.stamp === '' || probe.stamp !== before;
385
+ // The common case: the new document exists and is parsed — return now.
386
+ if (settled && isNewDocument)
387
+ return;
388
+ // Same document (hash navigation, cancelled/aborted load): there is no
389
+ // parse to wait for, so do not burn the budget. Outlast the brief
390
+ // window in which the OUTGOING document may still answer instead.
391
+ if (settled && Date.now() - started >= SETTLE_GRACE_MS)
392
+ return;
393
+ }
394
+ if (Date.now() >= deadline)
395
+ return;
396
+ await delay(SETTLE_POLL_MS, signal);
397
+ }
398
+ }
399
+ /**
400
+ * Make sure the active tab's view can actually receive synthesized input.
401
+ * Chromium drops `Input.*` events for a view with no display surface, so this
402
+ * runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
403
+ * rather than reporting a success the page never saw.
404
+ */
405
+ async present(s, signal) {
406
+ const { handle } = this.activeTab(s);
407
+ const present = this.host.presentView?.bind(this.host);
408
+ if (present === undefined) {
409
+ this.showActive(s);
410
+ return;
411
+ }
412
+ signal?.throwIfAborted();
413
+ const timeoutMs = 10_000;
414
+ try {
415
+ await withTimeout(present(handle, s.label), timeoutMs, signal, `browser: presenting the page view timed out after ${timeoutMs}ms`);
416
+ this.showActive(s);
417
+ }
418
+ catch (error) {
419
+ throw new BrowserError(`browser: the page view is not presented, so synthesized input would be silently dropped (${String(error)})`, 'BROWSER_VIEW_NOT_PRESENTED', { cause: error });
420
+ }
421
+ }
422
+ /**
423
+ * Give the view web focus before synthesizing keyboard input.
424
+ *
425
+ * A renderer only delivers key events to the view that holds web focus. A
426
+ * view that was created but never clicked holds none — and `Input` events
427
+ * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
428
+ * session vanished inside the renderer while the command still answered
429
+ * success. Hosts that cannot focus a view (a test double, a different shell
430
+ * adapter) simply have no `focus`, and nothing changes for them.
431
+ */
432
+ async focusView(handle) {
433
+ if (handle.focus === undefined)
434
+ return;
435
+ try {
436
+ await handle.focus();
437
+ }
438
+ catch {
439
+ // Best-effort: a view that refuses focus must not fail the caller's key
440
+ // press; the dispatch below reports a real channel failure itself.
441
+ }
442
+ }
286
443
  /** Execute JS in the active tab's page context. */
287
444
  async execute(session, request, signal) {
288
445
  const s = this.session(session);
@@ -776,24 +933,51 @@ export class ElectronBrowserProvider {
776
933
  x = request.x;
777
934
  y = request.y;
778
935
  }
779
- const press = (type) => handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
936
+ // Input.* is only delivered to a view with a current display surface; the
937
+ // barrier also re-presents after a navigation replaced the renderer.
938
+ await this.present(s, signal);
780
939
  const timeoutMs = 30_000;
940
+ const send = (params, label) => withTimeout(handle.sendCommand('Input.dispatchMouseEvent', params), timeoutMs, signal, `browser: click ${label} timed out after ${timeoutMs}ms`);
941
+ // Chromium routes a synthesized `mousePressed` to the widget's *current*
942
+ // hover target rather than hit-testing the coordinates, and a real pointer
943
+ // click is always preceded by movement. A view that has never received a
944
+ // mouse event has no hover target yet, so the press of the FIRST click on a
945
+ // fresh tab went nowhere while CDP still answered success; the second click
946
+ // landed because the first had quietly established the target. Move the
947
+ // pointer first so a click is self-contained.
781
948
  try {
782
- await withTimeout(press('mousePressed'), timeoutMs, signal, `browser: click press timed out after ${timeoutMs}ms`);
949
+ await send({ type: 'mouseMoved', x, y }, 'move');
950
+ }
951
+ catch (error) {
952
+ throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
953
+ }
954
+ const release = () => {
955
+ void handle
956
+ .sendCommand('Input.dispatchMouseEvent', {
957
+ type: 'mouseReleased',
958
+ x,
959
+ y,
960
+ button: 'left',
961
+ clickCount: 1,
962
+ })
963
+ .catch(() => { });
964
+ };
965
+ try {
966
+ await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press');
783
967
  }
784
968
  catch (error) {
785
969
  // The press may still land late; release best-effort so the button is
786
970
  // never left in a stuck pressed state.
787
- void press('mouseReleased').catch(() => { });
971
+ release();
788
972
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
789
973
  }
790
974
  try {
791
- await withTimeout(press('mouseReleased'), timeoutMs, signal, `browser: click release timed out after ${timeoutMs}ms`);
975
+ await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release');
792
976
  }
793
977
  catch (error) {
794
978
  // The press already landed; retry the release so the button is not
795
979
  // left pressed before surfacing the failure.
796
- void press('mouseReleased').catch(() => { });
980
+ release();
797
981
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
798
982
  }
799
983
  this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true);
@@ -817,6 +1001,9 @@ export class ElectronBrowserProvider {
817
1001
  }
818
1002
  const text = 'text' in request ? request.text : '';
819
1003
  const timeoutMs = 30_000;
1004
+ // Input.insertText goes through the Input domain: same display-surface
1005
+ // requirement as click/key.
1006
+ await this.present(s, signal);
820
1007
  try {
821
1008
  await withTimeout(handle.sendCommand('Input.insertText', { text }), timeoutMs, signal, `browser: type timed out after ${timeoutMs}ms`);
822
1009
  }
@@ -874,8 +1061,12 @@ export class ElectronBrowserProvider {
874
1061
  const entry = entries[target];
875
1062
  if (entry?.id === undefined)
876
1063
  throw new BrowserError('browser: history entry missing id', 'BROWSER_HISTORY_INVALID');
1064
+ // A history step is a navigation too: the classic A→B→A redirect would
1065
+ // otherwise read the outgoing document's identity (see settleDocument).
1066
+ const before = await this.documentStamp(handle);
877
1067
  await withTimeout(handle.sendCommand('Page.navigateToHistoryEntry', { entryId: entry.id }), timeoutMs, signal, `browser: history navigation timed out after ${timeoutMs}ms`, () => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
878
1068
  this.record(s, direction === -1 ? 'back' : 'forward', {}, true);
1069
+ await this.settleDocument(handle, before, signal);
879
1070
  this.showActive(s);
880
1071
  }
881
1072
  /** Go back in the active tab's history. */
@@ -892,10 +1083,14 @@ export class ElectronBrowserProvider {
892
1083
  const { handle } = this.activeTab(s);
893
1084
  signal?.throwIfAborted();
894
1085
  const timeoutMs = 30_000;
1086
+ // Same-URL reload: only the per-load document identity can tell the new
1087
+ // document from the old one, so capture it before reloading.
1088
+ const before = await this.documentStamp(handle);
895
1089
  await withTimeout(handle.sendCommand('Page.reload', { ignoreCache: false }), timeoutMs, signal, `browser: reload timed out after ${timeoutMs}ms`,
896
1090
  // Best-effort: cancel the reload if it wedges the debugger queue.
897
1091
  () => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
898
1092
  this.record(s, 'reload', {}, true);
1093
+ await this.settleDocument(handle, before, signal);
899
1094
  this.showActive(s);
900
1095
  }
901
1096
  /** Press one named key (Enter/Tab/arrows/…) via CDP key events. */
@@ -909,6 +1104,9 @@ export class ElectronBrowserProvider {
909
1104
  }
910
1105
  const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} };
911
1106
  const timeoutMs = 15_000;
1107
+ // Input.dispatchKeyEvent is subject to the same display-surface rule.
1108
+ await this.present(s, signal);
1109
+ await this.focusView(handle);
912
1110
  const release = () => handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params });
913
1111
  try {
914
1112
  await withTimeout(handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', ...params }), timeoutMs, signal, `browser: key press timed out after ${timeoutMs}ms`);
@@ -1079,10 +1277,16 @@ export class ElectronBrowserProvider {
1079
1277
  * (polling until it appears or the budget runs out) and then runs `body`
1080
1278
  * with `el` in scope. Shared by the target-based tools: click/type (②),
1081
1279
  * setValue/check/select/clear/getValue (③), and the scrape item wait.
1280
+ * A selector that fails to PARSE is reported immediately instead of being
1281
+ * polled until the budget expires — see the comment in `match`.
1082
1282
  */
1083
1283
  buildTargetScript(spec, timeoutMs, body) {
1284
+ // `by` defaults to css in-page; report the strategy that WAS used, or a
1285
+ // miss says only `{"value":"Learn more"}` and the caller cannot tell that a
1286
+ // plain label was queried as a CSS selector.
1287
+ const resolvedSpec = { ...spec, by: spec.by ?? 'css' };
1084
1288
  return `(async () => {
1085
- const spec = ${JSON.stringify(spec)}
1289
+ const spec = ${JSON.stringify(resolvedSpec)}
1086
1290
  const timeoutMs = ${String(timeoutMs)}
1087
1291
  const sleep = ms => new Promise(r => setTimeout(r, ms))
1088
1292
  const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
@@ -1094,7 +1298,13 @@ export class ElectronBrowserProvider {
1094
1298
  const index = typeof spec.index === 'number' ? spec.index : 0
1095
1299
  let els = []
1096
1300
  if (by === 'css') {
1097
- try { els = Array.from(document.querySelectorAll(value)) } catch { return null }
1301
+ // A selector that does not PARSE can never match on a later poll, so
1302
+ // it must not look like a miss: the old "return null" kept the caller
1303
+ // polling until the whole budget was gone, and "element not found
1304
+ // after 10s" reads like a slow page while the real cause is a
1305
+ // malformed selector (a label or plain text passed where one belongs).
1306
+ try { els = Array.from(document.querySelectorAll(value)) }
1307
+ catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1098
1308
  } else if (by === 'xpath') {
1099
1309
  try {
1100
1310
  const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
@@ -1102,7 +1312,7 @@ export class ElectronBrowserProvider {
1102
1312
  const n = snap.snapshotItem(i)
1103
1313
  if (n instanceof Element) els.push(n)
1104
1314
  }
1105
- } catch { return null }
1315
+ } catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1106
1316
  } else {
1107
1317
  const all = Array.from(document.querySelectorAll('body *'))
1108
1318
  const exact = all.filter(el => isVisible(el) && ownText(el) === value)
@@ -1116,9 +1326,10 @@ export class ElectronBrowserProvider {
1116
1326
  const deadline = Date.now() + timeoutMs
1117
1327
  let el = null
1118
1328
  for (;;) {
1119
- el = match()
1329
+ // Only a parse error escapes the poll: see the comment in match() above.
1330
+ try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
1120
1331
  if (el !== null) break
1121
- if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
1332
+ if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
1122
1333
  await sleep(100)
1123
1334
  }
1124
1335
  ${body}
@@ -1130,7 +1341,12 @@ export class ElectronBrowserProvider {
1130
1341
  * evaluation failure or an in-page `{ ok: false, error }` verdict.
1131
1342
  */
1132
1343
  async runTargetScript(tab, script, timeoutMs, signal, errorCode, errorLabel) {
1133
- const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `${errorLabel} timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1344
+ // The script polls for its whole `timeoutMs` and only then answers, so an
1345
+ // outer budget of the same size fires at the same instant and wins: the
1346
+ // caller saw `click timed out after 10000ms` while the page had already
1347
+ // decided WHY (selector not found / malformed). Give the answer a chance to
1348
+ // get back before the generic timeout replaces it.
1349
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs + TARGET_SCRIPT_GRACE_MS, signal, `${errorLabel} timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1134
1350
  if (!result.ok)
1135
1351
  throw new BrowserError(`${errorLabel} failed: ${result.exception}`, errorCode);
1136
1352
  const value = result.value;
@@ -1322,7 +1538,9 @@ export class ElectronBrowserProvider {
1322
1538
  const deadline = Date.now() + timeoutMs
1323
1539
  let items = []
1324
1540
  for (;;) {
1325
- try { items = Array.from(document.querySelectorAll(itemSel)) } catch (e) { return { ok: false, error: String(e) } }
1541
+ // Immediate, like match() above: an unparseable selector can never match.
1542
+ try { items = Array.from(document.querySelectorAll(itemSel)) }
1543
+ catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
1326
1544
  if (items.length > 0) break
1327
1545
  if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
1328
1546
  await sleep(100)
@@ -1357,13 +1575,53 @@ export class ElectronBrowserProvider {
1357
1575
  return { count: items.length, items };
1358
1576
  }
1359
1577
  /**
1360
- * Download a URL to a local file, keeping the session's cookies/login.
1361
- * Requires the self-hosted host (which implements view-level download); the
1362
- * desktop shell's embedded views delegate downloads to the real browser UI.
1363
- * Admission: only HTTP(S) targets (the in-page fetch cannot meaningfully
1364
- * fetch anything else), and the save path must be absolute — confined to
1365
- * `downloadDir` when one is configured, so a prompt-injected agent cannot
1366
- * write arbitrary machine paths.
1578
+ * Admit a caller-supplied save path for a file the browser writes to disk.
1579
+ * ONE gate for both `browser_download` and `browser_screenshot`: the path
1580
+ * must be absolute, must resolve inside `downloadDir`, and must not already
1581
+ * exist. Without it a prompt-injected agent could write — or silently
1582
+ * replace — any file the DSH process can reach, which also escapes the file
1583
+ * sandbox every other tool in the set runs under.
1584
+ * @param savePath - the caller's target path.
1585
+ * @param kind - the operation, used in the error code and message.
1586
+ * @returns the resolved absolute target path.
1587
+ * @throws BrowserError when the path is relative, outside the directory, or occupied.
1588
+ */
1589
+ admitSavePath(savePath, kind) {
1590
+ const code = kind === 'download' ? 'BROWSER_DOWNLOAD_BLOCKED' : 'BROWSER_SCREENSHOT_BLOCKED';
1591
+ if (!isAbsolute(savePath)) {
1592
+ throw new BrowserError(`browser: ${kind} savePath must be an absolute path`, code);
1593
+ }
1594
+ const file = resolve(savePath);
1595
+ if (this.downloadDir !== undefined) {
1596
+ const dir = resolve(this.downloadDir);
1597
+ // Case-insensitive comparison: Windows paths are case-insensitive, and
1598
+ // resolve() does not normalize case. Without this, C:\Users\X\Downloads
1599
+ // and c:\users\x\downloads would be treated as different roots.
1600
+ const dirLower = dir.toLowerCase();
1601
+ const fileLower = file.toLowerCase();
1602
+ if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
1603
+ throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code);
1604
+ }
1605
+ }
1606
+ // Never replace an existing file: its previous content is unrecoverable,
1607
+ // and the admitted directory may hold files the human put there. A new
1608
+ // name is one tool call away.
1609
+ if (existsSync(file)) {
1610
+ throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code);
1611
+ }
1612
+ return file;
1613
+ }
1614
+ /**
1615
+ * Download an HTTP(S) URL with the session's cookies to a local file.
1616
+ * Admission is the shared {@link admitSavePath} gate; only the self-hosted
1617
+ * host implements it (the desktop shell's embedded views delegate downloads
1618
+ * to the real browser UI). Both admitted paths and screenshots are confined
1619
+ * to `downloadDir`, so a prompt-injected agent cannot write arbitrary
1620
+ * machine paths.
1621
+ * @param session - the session whose cookies are used.
1622
+ * @param request - the URL plus the absolute target path.
1623
+ * @param signal - optional cancellation.
1624
+ * @returns the path the file was written to.
1367
1625
  */
1368
1626
  async download(session, request, signal) {
1369
1627
  const s = this.session(session);
@@ -1382,23 +1640,9 @@ export class ElectronBrowserProvider {
1382
1640
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1383
1641
  throw new BrowserError(`browser: refusing download of non-HTTP(S) URL "${request.url}"`, 'BROWSER_DOWNLOAD_BLOCKED');
1384
1642
  }
1385
- // savePath admission: must be absolute; when downloadDir is configured it
1386
- // must resolve inside it (no ..-escape).
1387
- if (!isAbsolute(request.savePath)) {
1388
- throw new BrowserError('browser: download savePath must be an absolute path', 'BROWSER_DOWNLOAD_BLOCKED');
1389
- }
1390
- if (this.downloadDir !== undefined) {
1391
- const dir = resolve(this.downloadDir);
1392
- const file = resolve(request.savePath);
1393
- // Case-insensitive comparison: Windows paths are case-insensitive, and
1394
- // resolve() does not normalize case. Without this, C:\Users\X\Downloads
1395
- // and c:\users\x\downloads would be treated as different roots.
1396
- const dirLower = dir.toLowerCase();
1397
- const fileLower = file.toLowerCase();
1398
- if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
1399
- throw new BrowserError(`browser: download savePath must be inside downloadDir "${dir}"`, 'BROWSER_DOWNLOAD_BLOCKED');
1400
- }
1401
- }
1643
+ // savePath admission: the shared gate — absolute, inside `downloadDir`,
1644
+ // and never an existing file. Screenshots use the very same one.
1645
+ const savePath = this.admitSavePath(request.savePath, 'download');
1402
1646
  const downloadable = handle;
1403
1647
  if (typeof downloadable.download !== 'function') {
1404
1648
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED');
@@ -1410,9 +1654,9 @@ export class ElectronBrowserProvider {
1410
1654
  // entry is cleaned up (the promise settles) and the child's eventual
1411
1655
  // reply is harmlessly consumed by the RPC layer.
1412
1656
  const timeoutMs = 60_000;
1413
- await withTimeout(downloadable.download(request.url, request.savePath), timeoutMs, signal, `browser: download timed out after ${timeoutMs}ms`);
1414
- this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath });
1415
- return { path: request.savePath };
1657
+ await withTimeout(downloadable.download(request.url, savePath), timeoutMs, signal, `browser: download timed out after ${timeoutMs}ms`);
1658
+ this.record(s, 'download', { url: request.url, savePath }, true, { result: savePath });
1659
+ return { path: savePath };
1416
1660
  }
1417
1661
  /**
1418
1662
  * Export the session's cookies (login state) as serializable objects.
@@ -1487,16 +1731,26 @@ export class ElectronBrowserProvider {
1487
1731
  }
1488
1732
  return this.saveScreenshot(data, request?.savePath, 'image/png');
1489
1733
  }
1490
- /** Build the data URL and optionally write the image to disk. */
1734
+ /**
1735
+ * Build the data URL and optionally write the image to disk. The caller's
1736
+ * path goes through the SAME {@link admitSavePath} gate as a download — it
1737
+ * must be absolute, resolve inside `downloadDir`, and not be an existing
1738
+ * file — so a screenshot cannot be used to write to, or silently replace,
1739
+ * files anywhere the DSH process happens to have permission (issue #13).
1740
+ */
1491
1741
  saveScreenshot(base64, savePath, mime) {
1492
1742
  if (savePath !== undefined) {
1743
+ // Admission failures propagate unchanged: they describe the caller's
1744
+ // path, not a disk problem.
1745
+ const target = this.admitSavePath(savePath, 'screenshot');
1493
1746
  try {
1494
- writeFileSync(savePath, Buffer.from(base64, 'base64'));
1495
- return { dataUrl: `data:${mime};base64,${base64}`, path: savePath };
1747
+ mkdirSync(dirname(target), { recursive: true });
1748
+ writeFileSync(target, Buffer.from(base64, 'base64'));
1749
+ return { dataUrl: `data:${mime};base64,${base64}`, path: target };
1496
1750
  }
1497
1751
  catch (error) {
1498
1752
  // Report the write problem but keep the capture usable.
1499
- throw new BrowserError(`browser: screenshot save to "${savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
1753
+ throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
1500
1754
  }
1501
1755
  }
1502
1756
  return { dataUrl: `data:${mime};base64,${base64}` };
@@ -1712,6 +1966,42 @@ export class ElectronBrowserProvider {
1712
1966
  return result.ok && typeof result.value === 'string' ? result.value : '';
1713
1967
  }
1714
1968
  }
1969
+ /**
1970
+ * Hard cap on waiting for a navigation's new document to be parsed. Bounded and
1971
+ * best-effort: a page that never settles must not fail the navigation.
1972
+ */
1973
+ const SETTLE_TIMEOUT_MS = 5_000;
1974
+ /**
1975
+ * When the document already looks settled but its identity has NOT changed
1976
+ * (same-document navigation, or the outgoing document still answering), wait
1977
+ * only this long before returning — there is no parse in flight to wait for.
1978
+ */
1979
+ const SETTLE_GRACE_MS = 150;
1980
+ /** Poll interval for the document-settle loop. */
1981
+ const SETTLE_POLL_MS = 25;
1982
+ /**
1983
+ * Extra time the transport gets over an in-page locate script's own budget: the
1984
+ * script polls for `timeoutMs` and answers only afterwards, so the outer wait
1985
+ * must outlast it or its generic timeout hides the in-page reason.
1986
+ */
1987
+ const TARGET_SCRIPT_GRACE_MS = 2_000;
1988
+ /** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
1989
+ function stripTabPrefix(id) {
1990
+ return id.startsWith('tab:') ? id.slice(4) : id;
1991
+ }
1992
+ /** Abortable sleep used by the settle loop. */
1993
+ function delay(ms, signal) {
1994
+ return new Promise((resolve) => {
1995
+ const timer = setTimeout(() => {
1996
+ if (signal !== undefined)
1997
+ signal.removeEventListener('abort', onAbort);
1998
+ resolve();
1999
+ }, ms);
2000
+ const onAbort = () => { clearTimeout(timer); resolve(); };
2001
+ if (signal !== undefined)
2002
+ signal.addEventListener('abort', onAbort, { once: true });
2003
+ });
2004
+ }
1715
2005
  /**
1716
2006
  * Bound a promise so a wedged CDP call surfaces as an error instead of
1717
2007
  * hanging the tool call forever. The caller's signal, when provided, wins