dsh-builtin-browser 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.en.md +61 -23
  2. package/README.md +59 -23
  3. package/client.js +60 -5
  4. package/cordis.patch.yml +21 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +12 -4
  6. package/docs/architecture.md +12 -2
  7. package/docs/tool-reference.md +12 -12
  8. package/docs/user-guide.md +10 -3
  9. package/docs/why-browser.md +1 -1
  10. package/lib/browser/types.d.ts +25 -1
  11. package/lib/browser-electron/bridge-connection.d.ts +31 -5
  12. package/lib/browser-electron/bridge-connection.js +143 -21
  13. package/lib/browser-electron/entry.d.ts +1 -1
  14. package/lib/browser-electron/entry.js +61 -35
  15. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  16. package/lib/browser-electron/ephemeral-profile.js +146 -0
  17. package/lib/browser-electron/history-store.d.ts +25 -0
  18. package/lib/browser-electron/history-store.js +51 -4
  19. package/lib/browser-electron/host-main.js +75 -27
  20. package/lib/browser-electron/provider.d.ts +44 -1
  21. package/lib/browser-electron/provider.js +379 -48
  22. package/lib/browser-electron/remote-host.d.ts +52 -3
  23. package/lib/browser-electron/remote-host.js +244 -23
  24. package/lib/browser-electron/settings-route.d.ts +28 -0
  25. package/lib/browser-electron/settings-route.js +28 -0
  26. package/lib/browser-electron/settings-store.d.ts +40 -0
  27. package/lib/browser-electron/settings-store.js +107 -11
  28. package/lib/browser-electron/system-browser.d.ts +71 -3
  29. package/lib/browser-electron/system-browser.js +311 -40
  30. package/lib/tool-browser/index.js +142 -33
  31. package/package.json +5 -3
  32. package/scripts/verify-host-compat.mjs +195 -0
  33. package/src/browser/types.ts +25 -1
  34. package/src/browser-electron/bridge-connection.ts +142 -21
  35. package/src/browser-electron/entry.ts +58 -33
  36. package/src/browser-electron/ephemeral-profile.ts +152 -0
  37. package/src/browser-electron/history-store.ts +51 -4
  38. package/src/browser-electron/host-main.ts +105 -35
  39. package/src/browser-electron/provider.ts +401 -51
  40. package/src/browser-electron/remote-host.ts +261 -36
  41. package/src/browser-electron/settings-route.ts +46 -0
  42. package/src/browser-electron/settings-store.ts +121 -11
  43. package/src/browser-electron/system-browser.ts +302 -26
  44. package/src/tool-browser/index.ts +143 -35
@@ -7,7 +7,7 @@
7
7
  * @module dsh-browser/browser-electron
8
8
  */
9
9
  import { randomUUID } from 'node:crypto';
10
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
10
+ import { existsSync, lstatSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
13
13
  import { HistoryStore } from './history-store.js';
@@ -225,6 +225,58 @@ function defaultDownloadDir() {
225
225
  }
226
226
  return join(homedir(), DOWNLOAD_DIR_NAMES[0] ?? 'Downloads');
227
227
  }
228
+ /**
229
+ * Whether a directory entry exists at `path`, judging the ENTRY rather than what it
230
+ * points at.
231
+ *
232
+ * `existsSync` follows links, so a DANGLING symlink — a link whose target does not
233
+ * exist — reads as "nothing here", and a write to that path creates the target
234
+ * instead: outside `downloadDir` whenever the link points out of it. `lstatSync`
235
+ * sees the link itself, which is the only correct answer to "is this name free".
236
+ * @param path - the path to judge.
237
+ * @returns true when any entry (file, directory, link) occupies the name.
238
+ */
239
+ function entryExists(path) {
240
+ try {
241
+ lstatSync(path);
242
+ return true;
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ }
248
+ /**
249
+ * The real path of the deepest existing ancestor of `path` — `path` itself when it
250
+ * exists, otherwise the nearest directory above it that does.
251
+ *
252
+ * `resolve()` normalizes `..` but never follows a link, so nothing textual can tell
253
+ * that `<downloadDir>/link/x.png` leaves the directory through `link`. The first
254
+ * ancestor that exists (judged by lstat, so a dangling link counts — the write would
255
+ * go through it) is the last component whose real location still determines where
256
+ * the bytes land, so that is the one to resolve and check.
257
+ * @param path - an absolute path that may not exist yet.
258
+ * @returns the resolved real path of its deepest existing ancestor.
259
+ */
260
+ function realAncestorOf(path) {
261
+ let current = path;
262
+ for (let depth = 0; depth < 64; depth += 1) {
263
+ if (entryExists(current)) {
264
+ try {
265
+ return realpathSync(current);
266
+ }
267
+ catch {
268
+ // The entry vanished between the two calls, or cannot be resolved: the
269
+ // textual path is the best available answer, and the write reports the rest.
270
+ return current;
271
+ }
272
+ }
273
+ const parent = dirname(current);
274
+ if (parent === current)
275
+ return current;
276
+ current = parent;
277
+ }
278
+ return current;
279
+ }
228
280
  /**
229
281
  * Browser provider over Electron views. Sessions hold an ordered list of
230
282
  * tabs; each tab is one view created by the host. The active tab receives
@@ -251,7 +303,7 @@ export class ElectronBrowserProvider {
251
303
  this.host = host;
252
304
  this.httpOnly = config.httpOnly ?? true;
253
305
  this.snapshotMaxElements = config.snapshotMaxElements ?? 60;
254
- this.contentMaxChars = config.contentMaxChars ?? 100_000;
306
+ this.contentMaxChars = config.contentMaxChars;
255
307
  // Confine downloads AND screenshot saves to a dedicated directory by
256
308
  // default: the OS Downloads folder is the human-visible, browser-natural
257
309
  // place for written files, and it is the one directory the DSH file
@@ -536,13 +588,16 @@ export class ElectronBrowserProvider {
536
588
  * Chromium drops `Input.*` events for a view with no display surface, so this
537
589
  * runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
538
590
  * rather than reporting a success the page never saw.
591
+ * @param s - the session whose active view must be presented.
592
+ * @param signal - optional cancellation.
593
+ * @returns the view the session's active tab has AFTER the presentation barrier.
539
594
  */
540
595
  async present(s, signal) {
541
596
  const { handle } = this.activeTab(s);
542
597
  const present = this.host.presentView?.bind(this.host);
543
598
  if (present === undefined) {
544
599
  this.showActive(s);
545
- return;
600
+ return this.activeTab(s).handle;
546
601
  }
547
602
  signal?.throwIfAborted();
548
603
  const timeoutMs = 10_000;
@@ -553,6 +608,42 @@ export class ElectronBrowserProvider {
553
608
  catch (error) {
554
609
  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 });
555
610
  }
611
+ // Read the active tab AGAIN. `present` is a round-trip that can take up to the budget
612
+ // above, and the human is invited to drive the same window (a toolbar tab switch, a
613
+ // closed window) while the agent waits. Chromium answers `Input.*` with success even
614
+ // for a view that has no display surface, so dispatching to a handle that is no longer
615
+ // the visible one does not fail — it silently lands nowhere, or on the wrong page.
616
+ //
617
+ // Returning the re-read handle was wrong, though: `handle` above is the one that was
618
+ // actually presented, and if the active tab changed during the round trip then the
619
+ // re-read one was never presented at all. Handing it back meant the caller dispatched
620
+ // to a view with no display surface — the exact failure this method exists to prevent,
621
+ // arrived at from the other side. Either the presented view is still the active one, or
622
+ // the caller must be told rather than given something else to aim at.
623
+ const live = this.activeTab(s).handle;
624
+ if (live !== handle) {
625
+ throw new BrowserError('browser: the active tab changed while the page view was being presented, so synthesized input would go to an unshown view — retry, and the new page will be presented instead', 'BROWSER_VIEW_CHANGED');
626
+ }
627
+ return handle;
628
+ }
629
+ /**
630
+ * Refuse to send synthesized input to a view that is no longer the one it was located
631
+ * against.
632
+ *
633
+ * `locateHandle` was captured before a page-side locate that can consume its whole
634
+ * 10s budget; the session's active tab is re-read afterwards. If they differ, the human
635
+ * switched tabs mid-flight (the product's whole point is that they can take over), and
636
+ * the coordinates belong to one page while the dispatch would go to another. Comparing
637
+ * the handles turns that into a loud, retryable error instead of input the caller
638
+ * believes landed.
639
+ * @param session - the session id, for the message.
640
+ * @param locateHandle - the view the locate ran in.
641
+ * @param liveHandle - the view that is active now.
642
+ */
643
+ assertSameView(session, locateHandle, liveHandle) {
644
+ if (locateHandle === liveHandle)
645
+ return;
646
+ throw new BrowserError(`browser: the session's active view changed while the element was being located (session ${session}); the operation was not dispatched — retry it`, 'BROWSER_VIEW_CHANGED');
556
647
  }
557
648
  /**
558
649
  * Give the view web focus before synthesizing keyboard input.
@@ -581,6 +672,7 @@ export class ElectronBrowserProvider {
581
672
  }
582
673
  /** Execute JS in the active tab's page context. */
583
674
  async execute(session, request, signal) {
675
+ this.assertActionAllowed('execute');
584
676
  const s = this.session(session);
585
677
  const { handle } = this.activeTab(s);
586
678
  signal?.throwIfAborted();
@@ -804,7 +896,9 @@ export class ElectronBrowserProvider {
804
896
  const tab = this.activeTab(s);
805
897
  signal?.throwIfAborted();
806
898
  const includeHidden = request.includeHidden === true;
807
- const maxNodes = Math.max(10, Math.min(5000, Math.floor(request.maxNodes ?? 500)));
899
+ // 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
900
+ // characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
901
+ const maxNodes = Math.max(10, Math.min(5000, Math.floor(request.maxNodes ?? 150)));
808
902
  const script = `(() => {
809
903
  const includeHidden = ${String(includeHidden)}
810
904
  const maxNodes = ${String(maxNodes)}
@@ -946,6 +1040,11 @@ export class ElectronBrowserProvider {
946
1040
  value: valueOf(el),
947
1041
  states: statesOf(el),
948
1042
  depth: baseDepth + depthOf(el),
1043
+ // Same derivation as the snapshot collector: an id or a name is what makes a node
1044
+ // something the caller can point at without guessing at its text. The tool-layer
1045
+ // schema declared this field before this line existed, so the schema was promising
1046
+ // a value the tool never sent.
1047
+ selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
949
1048
  tag: el.tagName.toLowerCase(),
950
1049
  x: Math.round(a.x + a.w / 2),
951
1050
  y: Math.round(a.y + a.h / 2),
@@ -981,9 +1080,18 @@ export class ElectronBrowserProvider {
981
1080
  async content(session, request, signal) {
982
1081
  const tab = this.activeTab(this.session(session));
983
1082
  signal?.throwIfAborted();
984
- const maxChars = request.maxChars ?? this.contentMaxChars;
985
1083
  const selector = request.selector ?? '';
986
1084
  const format = request.format;
1085
+ // Per-format caps. A full HTML document is enormous, but the same limit applied to
1086
+ // plain text is no limit at all (a whole page of text measured 25,882 characters).
1087
+ // This bounds the common case instead of letting one call spend 25k-33k tokens.
1088
+ //
1089
+ // The plugin config overrides the lot when it is set — see `contentMaxChars`. The caller's
1090
+ // own `maxChars` still outranks both: an explicit per-call bound is the most specific
1091
+ // statement of intent there is.
1092
+ const maxChars = request.maxChars
1093
+ ?? this.contentMaxChars
1094
+ ?? (format === 'html' || format === 'json' ? 50_000 : 20_000);
987
1095
  const script = `(() => {
988
1096
  const root = ${selector === '' ? 'document.body' : `document.querySelector(${JSON.stringify(selector)})`}
989
1097
  if (!root) return { ok: false, reason: 'selector not found' }
@@ -1027,7 +1135,12 @@ export class ElectronBrowserProvider {
1027
1135
  }
1028
1136
  }
1029
1137
  else if (fmt === 'html') content = root.outerHTML || ''
1030
- else if (fmt === 'json') content = JSON.stringify(root)
1138
+ // A DOM element has no own enumerable properties — everything lives on the prototype
1139
+ // chain — so JSON.stringify(root) is always "{}". That is not a structured view of
1140
+ // the page, it is an empty object the caller will read as "no data here" and then
1141
+ // retry with other selectors. Serialise the markup instead, which is what a caller
1142
+ // asking for JSON of an element can actually use.
1143
+ else if (fmt === 'json') content = JSON.stringify({ html: root.outerHTML, tag: root.tagName.toLowerCase() })
1031
1144
  else {
1032
1145
  // markdown: headings, paragraphs, links, lists (best-effort)
1033
1146
  const parts = []
@@ -1073,7 +1186,9 @@ export class ElectronBrowserProvider {
1073
1186
  */
1074
1187
  async click(session, request, signal) {
1075
1188
  const s = this.session(session);
1076
- const { handle } = this.activeTab(s);
1189
+ // The view the locate runs against. It can stop being the session's active view while
1190
+ // the locate waits (see prepareTarget), so every dispatch below re-reads the live one.
1191
+ const locateHandle = this.activeTab(s).handle;
1077
1192
  signal?.throwIfAborted();
1078
1193
  let x = 0;
1079
1194
  let y = 0;
@@ -1105,8 +1220,14 @@ export class ElectronBrowserProvider {
1105
1220
  y = request.y;
1106
1221
  }
1107
1222
  // Input.* is only delivered to a view with a current display surface; the
1108
- // barrier also re-presents after a navigation replaced the renderer.
1109
- await this.present(s, signal);
1223
+ // barrier also re-presents after a navigation replaced the renderer. It returns the
1224
+ // handle that may still be receiving input when it settles.
1225
+ const handle = await this.present(s, signal);
1226
+ // The locate (up to 10s) and the present barrier both ran against the view captured
1227
+ // earlier; if the human switched tabs meanwhile, these coordinates belong to one page
1228
+ // and the dispatch would go to another — and Chromium answers success either way.
1229
+ if ('target' in request)
1230
+ this.assertSameView(session, locateHandle, handle);
1110
1231
  const timeoutMs = 30_000;
1111
1232
  const send = (params, label) => withTimeout(handle.sendCommand('Input.dispatchMouseEvent', params), timeoutMs, signal, `browser: click ${label} timed out after ${timeoutMs}ms`);
1112
1233
  // Chromium routes a synthesized `mousePressed` to the widget's *current*
@@ -1128,6 +1249,10 @@ export class ElectronBrowserProvider {
1128
1249
  // tab" signal, so the bubble is what lets a human follow along without the log.
1129
1250
  this.showCursor(handle, x, y, 'click', 'click', true);
1130
1251
  const release = () => {
1252
+ // Back to the handle the PRESS may have landed on, deliberately not to the currently
1253
+ // active tab: this only runs after a failed press/release, and re-targeting a
1254
+ // compensating half of a click to a view the human just switched to would press a
1255
+ // button there. Best-effort either way.
1131
1256
  void handle
1132
1257
  .sendCommand('Input.dispatchMouseEvent', {
1133
1258
  type: 'mouseReleased',
@@ -1164,7 +1289,10 @@ export class ElectronBrowserProvider {
1164
1289
  */
1165
1290
  async type(session, request, signal) {
1166
1291
  const s = this.session(session);
1167
- const { handle } = this.activeTab(s);
1292
+ // The view the locate runs against. It can stop being the session's active view while
1293
+ // the locate waits (the human is invited to drive the same window), so the input below
1294
+ // goes to the handle `present` reports instead of this one.
1295
+ const locateHandle = this.activeTab(s).handle;
1168
1296
  signal?.throwIfAborted();
1169
1297
  const hasTarget = 'target' in request;
1170
1298
  if (hasTarget) {
@@ -1182,8 +1310,14 @@ export class ElectronBrowserProvider {
1182
1310
  const text = 'text' in request ? request.text : '';
1183
1311
  const timeoutMs = 30_000;
1184
1312
  // Input.insertText goes through the Input domain: same display-surface
1185
- // requirement as click/key.
1186
- await this.present(s, signal);
1313
+ // requirement as click/key. It also reports which view is active once the barrier has
1314
+ // settled, which is what makes the tab-switch check below meaningful.
1315
+ const handle = await this.present(s, signal);
1316
+ // Focusing one page and inserting into another is exactly the silent mis-target this
1317
+ // refuses: the element was focused in the located view, and the text would have gone to
1318
+ // whichever page is now visible — possibly a password field there.
1319
+ if (hasTarget)
1320
+ this.assertSameView(session, locateHandle, handle);
1187
1321
  try {
1188
1322
  await withTimeout(handle.sendCommand('Input.insertText', { text }), timeoutMs, signal, `browser: type timed out after ${timeoutMs}ms`);
1189
1323
  }
@@ -1278,7 +1412,6 @@ export class ElectronBrowserProvider {
1278
1412
  /** Press one named key (Enter/Tab/arrows/…) via CDP key events. */
1279
1413
  async key(session, request, signal) {
1280
1414
  const s = this.session(session);
1281
- const { handle } = this.activeTab(s);
1282
1415
  signal?.throwIfAborted();
1283
1416
  const spec = KEY_SPECS[request.key];
1284
1417
  if (spec === undefined) {
@@ -1286,8 +1419,13 @@ export class ElectronBrowserProvider {
1286
1419
  }
1287
1420
  const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} };
1288
1421
  const timeoutMs = 15_000;
1289
- // Input.dispatchKeyEvent is subject to the same display-surface rule.
1290
- await this.present(s, signal);
1422
+ // Input.dispatchKeyEvent is subject to the same display-surface rule. The handle comes
1423
+ // back from the presentation barrier rather than from a capture taken before it: this
1424
+ // session is shared with the human, who may switch tabs while the barrier round-trips,
1425
+ // and a key sent to the view they left is silently dropped (or lands on the new page).
1426
+ const handle = await this.present(s, signal);
1427
+ // Focus the SAME view the key is dispatched to; focusing the pre-switch handle would
1428
+ // grant web focus to a page that then receives nothing.
1291
1429
  await this.focusView(handle);
1292
1430
  const release = () => handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params });
1293
1431
  try {
@@ -1325,7 +1463,13 @@ export class ElectronBrowserProvider {
1325
1463
  name: f.name ?? null,
1326
1464
  label: f.label ?? null,
1327
1465
  placeholder: f.placeholder ?? null,
1328
- kind: f.kind ?? 'text',
1466
+ // A field with NO kind is a field with no kind FILTER: absent, not 'text'. Defaulting
1467
+ // it to 'text' here made `if (spec.kind)` above always true, so every unspecified
1468
+ // field was narrowed to an <input> — browser_fill on a <textarea> reported
1469
+ // `no "text" control matched`, and a contenteditable could not match any kind at all.
1470
+ // The value stays a string or null (never undefined): the in-page check tests truthiness,
1471
+ // and JSON drops an undefined member entirely, which is the same thing here.
1472
+ kind: f.kind ?? null,
1329
1473
  value: f.value,
1330
1474
  })));
1331
1475
  const submitFlag = request.submit === true;
@@ -1358,10 +1502,34 @@ export class ElectronBrowserProvider {
1358
1502
  }
1359
1503
  return false
1360
1504
  }
1505
+ // Every root this page exposes: the top document plus its shadow trees (recursively)
1506
+ // and same-origin iframes. The snapshot and waitFor already walk all of them, so an
1507
+ // element inside one is shown to the model and can be waited for — resolving a click or
1508
+ // a fill used to look at the top document alone and answer "not found" for something
1509
+ // the model had just been handed a reference to. The two must agree about what exists.
1510
+ // (Kept in step with the collector in the snapshot script above.)
1511
+ const allRoots = () => {
1512
+ const roots = []
1513
+ const seen = new Set()
1514
+ const visit = doc => {
1515
+ if (seen.has(doc)) return
1516
+ seen.add(doc)
1517
+ roots.push(doc)
1518
+ for (const el of doc.querySelectorAll('*')) {
1519
+ if (el.shadowRoot) visit(el.shadowRoot)
1520
+ if (el.tagName === 'IFRAME') {
1521
+ try { const d = el.contentDocument; if (d) visit(d) } catch { /* cross-origin */ }
1522
+ }
1523
+ }
1524
+ }
1525
+ visit(document)
1526
+ return roots
1527
+ }
1528
+ const roots = allRoots()
1361
1529
  const candidates = (spec) => {
1362
1530
  const all = spec.selector
1363
- ? [...document.querySelectorAll(spec.selector)]
1364
- : [...document.querySelectorAll('input, textarea, select, [contenteditable="true"]')].filter(el => matches(el, spec))
1531
+ ? roots.flatMap(root => [...root.querySelectorAll(spec.selector)])
1532
+ : roots.flatMap(root => [...root.querySelectorAll('input, textarea, select, [contenteditable="true"]')]).filter(el => matches(el, spec))
1365
1533
  const vis = all.filter(visible)
1366
1534
  return vis.length > 0 ? vis : all
1367
1535
  }
@@ -1379,7 +1547,9 @@ export class ElectronBrowserProvider {
1379
1547
  let el = els[0]
1380
1548
  // The caller may name a kind. Honor it: the branches below read the element's own
1381
1549
  // tag/type, so without this a selector matching two different controls acted on
1382
- // whichever came first — usually right, which is what hid the mistake.
1550
+ // whichever came first — usually right, which is what hid the mistake. A field that
1551
+ // names NO kind gets no filter at all: the element's own tag then decides the
1552
+ // branch, which is what "kind defaults to text" was always trying to say.
1383
1553
  if (spec.kind) {
1384
1554
  const matchesKind = (node) => {
1385
1555
  const nodeTag = node.tagName
@@ -1387,6 +1557,12 @@ export class ElectronBrowserProvider {
1387
1557
  if (spec.kind === 'select') return nodeTag === 'SELECT'
1388
1558
  if (spec.kind === 'textarea') return nodeTag === 'TEXTAREA'
1389
1559
  if (spec.kind === 'checkbox' || spec.kind === 'radio') return nodeType === spec.kind
1560
+ // 'text': the plain-value branch covers <input> AND <textarea>, and the batch
1561
+ // already collects [contenteditable="true"] as a settable control — so all three
1562
+ // must match. Requiring nodeTag === 'INPUT' here reported "no text control
1563
+ // matched" for a textarea and left the contenteditable branch below unreachable.
1564
+ if (nodeTag === 'TEXTAREA') return true
1565
+ if (node.isContentEditable === true) return true
1390
1566
  return nodeTag === 'INPUT' && nodeType !== 'checkbox' && nodeType !== 'radio' && nodeType !== 'file'
1391
1567
  }
1392
1568
  const wanted = els.find(matchesKind)
@@ -1501,33 +1677,78 @@ export class ElectronBrowserProvider {
1501
1677
  const match = () => {
1502
1678
  const by = typeof spec.by === 'string' ? spec.by : 'css'
1503
1679
  const value = String(spec.value ?? '')
1504
- const index = typeof spec.index === 'number' ? spec.index : 0
1680
+ // Named matchIndex rather than index: this whole function is one host-side template
1681
+ // literal, so a bare name declared here can silently shadow a host binding of the
1682
+ // same name and turn an inner reference into a type error.
1683
+ const matchIndex = typeof spec.index === 'number' ? spec.index : 0
1684
+ // An index that cannot address an element can never match on a later poll, exactly
1685
+ // like a selector that fails to parse — so it must not look like a miss either. A
1686
+ // negative index, the common "last one" shorthand, otherwise burned the caller's
1687
+ // whole locate budget (10s) and then reported "element not found", which reads like a
1688
+ // slow page rather than a bad argument.
1689
+ // (No backticks in this comment: it lives inside a template literal.)
1690
+ if (!Number.isInteger(matchIndex) || matchIndex < 0) {
1691
+ throw new Error('target.index must be a non-negative integer, got ' + JSON.stringify(spec.index))
1692
+ }
1505
1693
  let els = []
1694
+ // Every root this page exposes: the top document plus its shadow trees (recursively)
1695
+ // and same-origin iframes. The snapshot and waitFor already walk all of them, so an
1696
+ // element inside one is shown to the model and can be waited for — resolving a click or
1697
+ // a fill used to look at the top document alone and answer "not found" for something
1698
+ // the model had just been handed a reference to. The two must agree about what exists.
1699
+ // (Kept in step with the collector in the snapshot script above.)
1700
+ const allRoots = () => {
1701
+ const roots = []
1702
+ const seen = new Set()
1703
+ const visit = doc => {
1704
+ if (seen.has(doc)) return
1705
+ seen.add(doc)
1706
+ roots.push(doc)
1707
+ for (const el of doc.querySelectorAll('*')) {
1708
+ if (el.shadowRoot) visit(el.shadowRoot)
1709
+ if (el.tagName === 'IFRAME') {
1710
+ try { const d = el.contentDocument; if (d) visit(d) } catch { /* cross-origin */ }
1711
+ }
1712
+ }
1713
+ }
1714
+ visit(document)
1715
+ return roots
1716
+ }
1717
+ const roots = allRoots()
1506
1718
  if (by === 'css') {
1507
1719
  // A selector that does not PARSE can never match on a later poll, so
1508
1720
  // it must not look like a miss: the old "return null" kept the caller
1509
1721
  // polling until the whole budget was gone, and "element not found
1510
1722
  // after 10s" reads like a slow page while the real cause is a
1511
1723
  // malformed selector (a label or plain text passed where one belongs).
1512
- try { els = Array.from(document.querySelectorAll(value)) }
1724
+ // Parse on the top document first: a selector that does not PARSE must still be
1725
+ // reported as a bad argument rather than as a miss, and that check is about syntax,
1726
+ // not about which root holds the element.
1727
+ try { document.querySelectorAll(value) }
1513
1728
  catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1729
+ for (const root of roots) els.push(...root.querySelectorAll(value))
1514
1730
  } else if (by === 'xpath') {
1515
1731
  try {
1516
- const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
1517
- for (let i = 0; i < snap.snapshotLength; i++) {
1518
- const n = snap.snapshotItem(i)
1519
- if (n instanceof Element) els.push(n)
1732
+ for (const root of roots) {
1733
+ const snap = document.evaluate(value, root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
1734
+ for (let i = 0; i < snap.snapshotLength; i++) {
1735
+ const n = snap.snapshotItem(i)
1736
+ if (n instanceof Element) els.push(n)
1737
+ }
1520
1738
  }
1521
1739
  } catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1522
1740
  } else {
1523
- const all = Array.from(document.querySelectorAll('body *'))
1741
+ // '*' rather than 'body *': a shadow root and an iframe document have no body of
1742
+ // their own to anchor to, and the visibility and own-text predicates below are what
1743
+ // actually decide whether an element counts.
1744
+ const all = roots.flatMap(root => [...root.querySelectorAll('*')])
1524
1745
  const exact = all.filter(el => isVisible(el) && ownText(el) === value)
1525
1746
  // Exact first, then contains; deepest element preferred (so a
1526
1747
  // button inside a card wins over the card itself).
1527
1748
  const pool = exact.length > 0 ? exact : all.filter(el => isVisible(el) && ownText(el).includes(value))
1528
1749
  els = pool.sort((a, b) => depth(b) - depth(a))
1529
1750
  }
1530
- return els[index] ?? null
1751
+ return els[matchIndex] ?? null
1531
1752
  }
1532
1753
  const deadline = Date.now() + timeoutMs
1533
1754
  let el = null
@@ -1803,6 +2024,35 @@ export class ElectronBrowserProvider {
1803
2024
  this.record(s, 'scrape', { item: request.item, fields: request.fields.map(f => f.name) }, true, { result: `${items.length} items` });
1804
2025
  return { count: items.length, items };
1805
2026
  }
2027
+ /**
2028
+ * Refuse an action the settings have switched off.
2029
+ *
2030
+ * These switches belong to the OPERATOR. The settings document is written through the
2031
+ * plugin's own panel and no tool can reach it, so — unlike `browser_restrict`, which the
2032
+ * model owns and can lift at will — a refusal here stands for as long as the setting does.
2033
+ * That is the point of them: a deployment can take page-script execution, downloads, or
2034
+ * login-state writes off the table without relying on the model's cooperation.
2035
+ *
2036
+ * Read through `settingsSource` on every call rather than captured at construction, so
2037
+ * flipping a switch applies to the next command instead of the next restart — the same
2038
+ * rule the credentials gate follows.
2039
+ * @param action - which switch to consult.
2040
+ * @throws BrowserError when that switch is off.
2041
+ */
2042
+ assertActionAllowed(action) {
2043
+ const settings = this.settingsSource?.();
2044
+ if (settings === undefined)
2045
+ return;
2046
+ if (action === 'execute' && !settings.actions.allowExecute) {
2047
+ throw new BrowserError('browser: running page scripts is switched off in settings (Browser → actions)', 'BROWSER_EXECUTE_DISABLED');
2048
+ }
2049
+ if (action === 'download' && !settings.actions.allowDownload) {
2050
+ throw new BrowserError('browser: downloads are switched off in settings (Browser → actions)', 'BROWSER_DOWNLOAD_DISABLED');
2051
+ }
2052
+ if (action === 'credentialWrite' && !settings.actions.allowCredentialWrite) {
2053
+ throw new BrowserError('browser: writing cookies is switched off in settings (Browser → actions)', 'BROWSER_AUTH_WRITE_DISABLED');
2054
+ }
2055
+ }
1806
2056
  /**
1807
2057
  * Admit a caller-supplied save path for a file the browser writes to disk.
1808
2058
  * ONE gate for both `browser_download` and `browser_screenshot`: the path
@@ -1823,19 +2073,56 @@ export class ElectronBrowserProvider {
1823
2073
  const file = resolve(savePath);
1824
2074
  if (this.downloadDir !== undefined) {
1825
2075
  const dir = resolve(this.downloadDir);
1826
- // Case-insensitive comparison: Windows paths are case-insensitive, and
1827
- // resolve() does not normalize case. Without this, C:\Users\X\Downloads
1828
- // and c:\users\x\downloads would be treated as different roots.
1829
- const dirLower = dir.toLowerCase();
1830
- const fileLower = file.toLowerCase();
1831
- if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
2076
+ // Windows paths are case-insensitive and resolve() does not normalize case, so there
2077
+ // the containment check folds case — otherwise C:\Users\X\Downloads and
2078
+ // c:\users\x\downloads would read as different roots. Everywhere else the fold is
2079
+ // REMOVED: a case-sensitive filesystem (Linux) treats /home/u/DOWNLOADS and
2080
+ // /home/u/Downloads as different directories, so folding case there let a savePath
2081
+ // that merely differed in case pass admission — a directory-confinement escape, and
2082
+ // one that mkdirSync below would then happily create.
2083
+ const fold = process.platform === 'win32' ? (value) => value.toLowerCase() : (value) => value;
2084
+ const dirKey = fold(dir);
2085
+ const fileKey = fold(file);
2086
+ // The directory ITSELF is refused, not accepted: a save path that IS downloadDir names
2087
+ // a directory, so there is no file to write — and mkdirSync would have created the
2088
+ // directory when it did not exist. Two paths are "inside" only when the file path is
2089
+ // strictly below the directory, which the separator check below already expresses.
2090
+ if (!fileKey.startsWith(dirKey + fold(sep))) {
1832
2091
  throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code);
1833
2092
  }
2093
+ // The check above is textual, and a symlink defeats it: resolve() normalizes `..`
2094
+ // but never follows a link, so `<downloadDir>/out/x.png` passed every test above
2095
+ // while `out` pointed at a directory outside the gate — and the write then landed
2096
+ // there. Resolve the REAL location of the deepest existing ancestor (which is what
2097
+ // decides where the bytes go) and require it to be inside the real directory too.
2098
+ // A legitimate path is unaffected: its ancestor resolves to the directory itself.
2099
+ //
2100
+ // The directory is created first so that it can be resolved at all — mkdirSync is a
2101
+ // no-op for one that already exists — and a directory that cannot be created or
2102
+ // resolved falls back to its textual form, leaving the real filesystem error to the
2103
+ // write instead of inventing one here.
2104
+ let realDir;
2105
+ try {
2106
+ mkdirSync(dir, { recursive: true });
2107
+ realDir = fold(realpathSync(dir));
2108
+ }
2109
+ catch {
2110
+ realDir = fold(dir);
2111
+ }
2112
+ const realAncestor = fold(realAncestorOf(file));
2113
+ if (realAncestor !== realDir && !realAncestor.startsWith(realDir + fold(sep))) {
2114
+ throw new BrowserError(`browser: ${kind} savePath resolves outside downloadDir "${dir}" (a symlink leaves it)`, code);
2115
+ }
1834
2116
  }
1835
2117
  // Never replace an existing file: its previous content is unrecoverable,
1836
2118
  // and the admitted directory may hold files the human put there. A new
1837
2119
  // name is one tool call away.
1838
- if (existsSync(file)) {
2120
+ //
2121
+ // Judged with lstat (see {@link entryExists}), not existsSync: existsSync FOLLOWS
2122
+ // links, so a DANGLING symlink read as "nothing here" and the write below created
2123
+ // its target — outside the directory whenever the link pointed out of it. It is the
2124
+ // directory entry that has to be free, whatever that entry points at.
2125
+ if (entryExists(file)) {
1839
2126
  throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code);
1840
2127
  }
1841
2128
  return file;
@@ -1853,6 +2140,9 @@ export class ElectronBrowserProvider {
1853
2140
  * @returns the path the file was written to.
1854
2141
  */
1855
2142
  async download(session, request, signal) {
2143
+ // Before admission, and before the session is even resolved: a switched-off action is
2144
+ // not a path problem, and the caller should hear the real reason.
2145
+ this.assertActionAllowed('download');
1856
2146
  const s = this.session(session);
1857
2147
  const { handle } = this.activeTab(s);
1858
2148
  signal?.throwIfAborted();
@@ -1912,19 +2202,37 @@ export class ElectronBrowserProvider {
1912
2202
  // Every other carrier drives a real browser over CDP, and cookies are part of CDP —
1913
2203
  // the sidebar goes through the shell's webContents.debugger, an installed Chrome or
1914
2204
  // Edge is the browser itself. So this works everywhere rather than only self-hosted.
1915
- const result = await withTimeout(handle.sendCommand('Storage.getCookies', {}), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
2205
+ // Scoped to the page this session is on. The jar itself is SHARED on purpose — every
2206
+ // window and every task drives the same browser, so signing in once works everywhere,
2207
+ // which is the whole point of "use your own browser". What must not be shared is the
2208
+ // HANDING OVER: an unfiltered read puts every domain every task has visited, plus
2209
+ // whatever the human is signed into, in front of one task's caller. Least exposure,
2210
+ // not isolation.
2211
+ //
2212
+ // Two corrections to the previous attempt. It called Storage.getCookies with a `urls`
2213
+ // parameter, but that method takes only browserContextId and ignores everything else, so
2214
+ // the filter did nothing and the whole jar was still exported; `urls` belongs to
2215
+ // Network.getCookies. And it silently fell back to an unfiltered read when the page URL
2216
+ // was unavailable — an error path that asked for MORE than the normal one. Both are
2217
+ // fixed: the method that honours the filter, and a failure that refuses instead of
2218
+ // widening.
2219
+ const cookiesUrl = await this.currentUrl(handle).catch(() => '');
2220
+ if (cookiesUrl === '') {
2221
+ throw new BrowserError('browser: cannot export cookies without knowing which page this session is on — the URL was unavailable, and reading the whole cookie jar would expose every other session\'s logins', 'BROWSER_AUTH_SCOPE_UNKNOWN');
2222
+ }
2223
+ const result = await withTimeout(handle.sendCommand('Network.getCookies', { urls: [cookiesUrl] }), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
1916
2224
  const cookies = toExportedCookies(result.cookies);
1917
2225
  this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies (CDP)` });
1918
2226
  return cookies;
1919
2227
  }
1920
2228
  /** Import cookies into the session (restore login state). Self-hosted only. */
1921
2229
  async restoreAuth(session, cookies) {
1922
- // The settings panel offers "allow the agent to read cookies / export login state".
1923
- // Until now nothing read that switch, so turning it off changed nothing at all —
1924
- // the tool exported every cookie regardless.
1925
- if (this.settingsSource?.().credentials.allowRead === false) {
1926
- throw new BrowserError('browser: reading cookies is switched off in settings (Browser → credentials)', 'BROWSER_AUTH_DISABLED');
1927
- }
2230
+ // Restore WRITES cookies, so it answers to the write switch — not to
2231
+ // `credentials.allowRead`. It used to consult the read switch and report "reading
2232
+ // cookies is switched off", which is both the wrong gate and the wrong sentence: an
2233
+ // operator who allowed reading but wanted logins left alone had no switch at all,
2234
+ // and one who turned reading off lost the restore path for the wrong reason.
2235
+ this.assertActionAllowed('credentialWrite');
1928
2236
  const s = this.session(session);
1929
2237
  const { handle } = this.activeTab(s);
1930
2238
  const timeoutMs = 30_000;
@@ -1937,9 +2245,23 @@ export class ElectronBrowserProvider {
1937
2245
  // CDP again, for the same reason as the export: any carrier that drives a real
1938
2246
  // browser can set cookies on it. Storage.setCookies needs the cookie fields spelled
1939
2247
  // the CDP way, including the sameSite spelling and seconds rather than milliseconds.
1940
- const result = await withTimeout(handle.sendCommand('Storage.setCookies', { cookies: cookies.map(toCdpCookie) }), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
1941
- const restored = toExportedCookies(result.cookies).length;
1942
- this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies (CDP)` });
2248
+ //
2249
+ // toCdpCookie returns undefined for a cookie it cannot place (no usable domain), and
2250
+ // `cookies.map(...)` passed that undefined straight through: the array then held a
2251
+ // JSON `null`, CDP rejected the WHOLE call, and every valid cookie failed with an error
2252
+ // naming none of them. Filter the unusable ones out and say how many went.
2253
+ const mapped = cookies.map(toCdpCookie);
2254
+ const convertible = mapped.filter((cookie) => cookie !== undefined);
2255
+ const dropped = mapped.length - convertible.length;
2256
+ if (dropped > 0) {
2257
+ process.stderr.write(`[dsh-browser] restoreAuth: dropped ${dropped} of ${mapped.length} cookie(s) with no usable domain; the rest were still restored\n`);
2258
+ }
2259
+ const result = await withTimeout(handle.sendCommand('Storage.setCookies', { cookies: convertible }), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
2260
+ // Storage.setCookies returns nothing, so the count comes from what was actually
2261
+ // sent. Reading result.cookies here was always undefined, which rendered as
2262
+ // "Restored 0 cookies." while the write had succeeded.
2263
+ const restored = convertible.length;
2264
+ this.record(s, 'restoreAuth', { count: cookies.length, dropped }, true, { result: `${restored} cookies (CDP)${dropped > 0 ? `; ${dropped} dropped` : ''}` });
1943
2265
  return restored;
1944
2266
  }
1945
2267
  /** Capture the current page, optionally full-page, PNG or JPEG, scalable. */
@@ -1994,6 +2316,7 @@ export class ElectronBrowserProvider {
1994
2316
  // to the region it names, so it has to cover the whole document. The page's own size
1995
2317
  // is read first; when that is unavailable the capture proceeds unscaled rather than
1996
2318
  // failing — an image the caller can shrink itself beats no image at all.
2319
+ let rendered;
1997
2320
  if (request?.maxWidth !== undefined || request?.maxHeight !== undefined) {
1998
2321
  const metrics = await withTimeout(handle.sendCommand('Page.getLayoutMetrics', {}), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
1999
2322
  const size = layoutSize(metrics);
@@ -2002,6 +2325,9 @@ export class ElectronBrowserProvider {
2002
2325
  const byWidth = request.maxWidth !== undefined && size.width > 0 ? request.maxWidth / size.width : 1;
2003
2326
  const byHeight = request.maxHeight !== undefined && height > 0 ? request.maxHeight / height : 1;
2004
2327
  const scale = Math.min(1, byWidth, byHeight);
2328
+ // What the caller will actually receive. Recorded before the branch below, because the
2329
+ // size is worth reporting even when no scaling happened (scale 1).
2330
+ rendered = { width: Math.round(size.width * scale), height: Math.round(height * scale) };
2005
2331
  if (scale < 1) {
2006
2332
  params.clip = { x: 0, y: 0, width: size.width, height, scale };
2007
2333
  // The clip defines the captured area; captureBeyondViewport must not also apply.
@@ -2014,7 +2340,12 @@ export class ElectronBrowserProvider {
2014
2340
  if (typeof data !== 'string') {
2015
2341
  throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
2016
2342
  }
2017
- return this.saveScreenshot(data, request?.savePath, 'image/png');
2343
+ // The mime must name the format that was ACTUALLY requested (params.format), not PNG
2344
+ // unconditionally: a carrier that sets supportsCdpJpeg asks for `format: 'jpeg'`, so
2345
+ // hardcoding the PNG mime announced a JPEG body as a PNG — the data URL then carried a
2346
+ // mislabelled image, and a caller that decodes by mime wrote the wrong file.
2347
+ const mime = params.format === 'jpeg' ? 'image/jpeg' : 'image/png';
2348
+ return this.saveScreenshot(data, request?.savePath, mime, rendered);
2018
2349
  }
2019
2350
  /**
2020
2351
  * Build the data URL and optionally write the image to disk. The caller's
@@ -2023,7 +2354,7 @@ export class ElectronBrowserProvider {
2023
2354
  * file — so a screenshot cannot be used to write to, or silently replace,
2024
2355
  * files anywhere the DSH process happens to have permission (issue #13).
2025
2356
  */
2026
- saveScreenshot(base64, savePath, mime) {
2357
+ saveScreenshot(base64, savePath, mime, rendered) {
2027
2358
  if (savePath !== undefined) {
2028
2359
  // Admission failures propagate unchanged: they describe the caller's
2029
2360
  // path, not a disk problem.
@@ -2031,14 +2362,14 @@ export class ElectronBrowserProvider {
2031
2362
  try {
2032
2363
  mkdirSync(dirname(target), { recursive: true });
2033
2364
  writeFileSync(target, Buffer.from(base64, 'base64'));
2034
- return { dataUrl: `data:${mime};base64,${base64}`, path: target };
2365
+ return { dataUrl: `data:${mime};base64,${base64}`, path: target, ...rendered };
2035
2366
  }
2036
2367
  catch (error) {
2037
2368
  // Report the write problem but keep the capture usable.
2038
2369
  throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
2039
2370
  }
2040
2371
  }
2041
- return { dataUrl: `data:${mime};base64,${base64}` };
2372
+ return { dataUrl: `data:${mime};base64,${base64}`, ...rendered };
2042
2373
  }
2043
2374
  /** Append one operation to the session's history. */
2044
2375
  record(s, action, params, ok, detail) {