dsh-builtin-browser 0.4.1 → 0.4.5

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 (57) hide show
  1. package/CHANGELOG.md +1699 -0
  2. package/README.en.md +114 -33
  3. package/README.md +108 -32
  4. package/client.js +478 -29
  5. package/cordis.patch.yml +21 -0
  6. package/desktop-bridge/install.mjs +66 -6
  7. package/desktop-bridge/plugin-browser-bridge.js +426 -167
  8. package/docs/architecture.md +14 -4
  9. package/docs/tool-reference.md +13 -13
  10. package/docs/user-guide.md +58 -3
  11. package/docs/why-browser.md +1 -1
  12. package/lib/browser/types.d.ts +34 -3
  13. package/lib/browser-electron/bridge-connection.d.ts +49 -26
  14. package/lib/browser-electron/bridge-connection.js +185 -22
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  16. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  17. package/lib/browser-electron/entry.d.ts +1 -1
  18. package/lib/browser-electron/entry.js +133 -40
  19. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  20. package/lib/browser-electron/ephemeral-profile.js +146 -0
  21. package/lib/browser-electron/history-store.d.ts +36 -2
  22. package/lib/browser-electron/history-store.js +76 -7
  23. package/lib/browser-electron/host-main.js +86 -28
  24. package/lib/browser-electron/provider.d.ts +106 -3
  25. package/lib/browser-electron/provider.js +407 -60
  26. package/lib/browser-electron/remote-host.d.ts +52 -3
  27. package/lib/browser-electron/remote-host.js +259 -25
  28. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  29. package/lib/browser-electron/rpc-ops.js +36 -0
  30. package/lib/browser-electron/settings-route.d.ts +28 -0
  31. package/lib/browser-electron/settings-route.js +68 -0
  32. package/lib/browser-electron/settings-store.d.ts +40 -0
  33. package/lib/browser-electron/settings-store.js +160 -23
  34. package/lib/browser-electron/system-browser.d.ts +102 -12
  35. package/lib/browser-electron/system-browser.js +411 -51
  36. package/lib/index.js +0 -1
  37. package/lib/tool-browser/element-fields.d.ts +145 -0
  38. package/lib/tool-browser/element-fields.js +152 -0
  39. package/lib/tool-browser/index.js +149 -74
  40. package/package.json +10 -4
  41. package/scripts/verify-host-compat.mjs +195 -0
  42. package/src/browser/types.ts +34 -3
  43. package/src/browser-electron/bridge-connection.ts +179 -22
  44. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  45. package/src/browser-electron/entry.ts +132 -39
  46. package/src/browser-electron/ephemeral-profile.ts +152 -0
  47. package/src/browser-electron/history-store.ts +81 -9
  48. package/src/browser-electron/host-main.ts +115 -36
  49. package/src/browser-electron/provider.ts +492 -69
  50. package/src/browser-electron/remote-host.ts +276 -38
  51. package/src/browser-electron/rpc-ops.ts +41 -0
  52. package/src/browser-electron/settings-route.ts +87 -0
  53. package/src/browser-electron/settings-store.ts +173 -24
  54. package/src/browser-electron/system-browser.ts +408 -39
  55. package/src/index.ts +3 -2
  56. package/src/tool-browser/element-fields.ts +167 -0
  57. package/src/tool-browser/index.ts +155 -76
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { randomUUID } from 'node:crypto'
11
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
11
+ import { existsSync, lstatSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs'
12
12
  import { homedir } from 'node:os'
13
13
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
14
14
  import { HistoryStore, type VisitedPage } from './history-store.js'
@@ -118,8 +118,13 @@ export interface ElectronBrowserViewHost {
118
118
  * Create a new browser view and return a handle to its webContents-like
119
119
  * surface. The host owns windowing (adding the view to the window, sizing,
120
120
  * removal); the provider owns CDP-driven behavior.
121
+ * @param owner - the conversation this view serves. A host shared by several conversations
122
+ * needs it to keep one conversation's page out of another's; a host with one conversation per
123
+ * process may ignore it. It is per VIEW and not per host on purpose: the desktop's host is a
124
+ * single instance that every conversation runs through, so an owner held there would be one
125
+ * conversation's id applied to all of them.
121
126
  */
122
- createView(): ElectronViewHandle
127
+ createView(owner?: string): ElectronViewHandle
123
128
  /**
124
129
  * Destroy a view created by this host. Called on session close; idempotent
125
130
  * for an already-destroyed view.
@@ -159,6 +164,18 @@ export interface ElectronBrowserViewHost {
159
164
  * of failing only at first use.
160
165
  */
161
166
  available?(): boolean
167
+ /**
168
+ * Tell the human that a toolbar action the agent could not complete has failed.
169
+ *
170
+ * Optional. Declared rather than reached through a cast because this is what issue #16 was:
171
+ * the call used to be unbound, so `this` inside the host's implementation was undefined and
172
+ * its first statement threw — inside an async catch, which turned it into an unhandled
173
+ * rejection that took the whole DSH process down. A declared member is checkable; a cast is
174
+ * not.
175
+ * @param windowId - the window the action came from.
176
+ * @param message - what to show the human.
177
+ */
178
+ notifyUserActionError?(windowId: string, message: string): void
162
179
  /**
163
180
  * Optional: associate a view with a window group. Views grouped under the
164
181
  * same `windowId` share one window (one window per browser session); a host
@@ -194,7 +211,12 @@ export interface ElectronBrowserViewHost {
194
211
  * (settings: ui.closeWithSession) has to be asked for explicitly there.
195
212
  * @returns a promise that settles once the release was attempted.
196
213
  */
197
- releasePage?(): Promise<void>
214
+ /**
215
+ * Release this carrier's browser pages for one conversation.
216
+ * @param owner - the conversation whose pages to close. Omit to close every conversation's,
217
+ * which is only correct from a teardown that is itself global.
218
+ */
219
+ releasePage?(owner?: string): Promise<void>
198
220
  /**
199
221
  * Fold the carrier's presentation away while keeping the page alive.
200
222
  *
@@ -257,6 +279,37 @@ export interface ElectronViewHandle {
257
279
  * then behaves exactly as before.
258
280
  */
259
281
  focus?(): Promise<void>
282
+ /**
283
+ * Save a URL straight to disk, when the backing view can do that itself.
284
+ *
285
+ * Optional. Declared here rather than reached through a structural cast at the call site:
286
+ * a cast tells the compiler nothing, so a host that implements the interface without this
287
+ * method compiles and then silently does nothing.
288
+ * @param url - the URL to fetch.
289
+ * @param savePath - where to write it.
290
+ */
291
+ download?(url: string, savePath: string): Promise<void>
292
+ /**
293
+ * Export this view's cookies. Optional; see {@link download} for why it is declared.
294
+ * @returns the cookies, scoped to the view's own page.
295
+ */
296
+ flushAuth?(): Promise<readonly ExportedCookie[]>
297
+ /**
298
+ * Import cookies into this view. Optional.
299
+ * @param cookies - the cookies to write.
300
+ * @returns how many were accepted.
301
+ */
302
+ restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number>
303
+ /**
304
+ * Capture this view natively, returning the image and its measured size.
305
+ *
306
+ * Optional. The size is part of the contract because the caller's own tool schema declares
307
+ * `width`/`height`; a host that can measure the image should say so here rather than leaving
308
+ * the provider to guess.
309
+ * @param opts - format, quality and downscale bounds.
310
+ * @returns the encoded image and the pixel size the caller will receive.
311
+ */
312
+ capture?(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string; width?: number; height?: number }>
260
313
  }
261
314
 
262
315
  /** One tab inside a session: its view plus a stable id. */
@@ -306,7 +359,16 @@ export interface ElectronBrowserProviderConfig {
306
359
  readonly settings?: () => BrowserSettings
307
360
  /** Maximum snapshot elements before truncation. Default 60. */
308
361
  readonly snapshotMaxElements?: number
309
- /** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
362
+ /**
363
+ * Explicit content cap, overriding the per-format default when set.
364
+ *
365
+ * Honoured again, and it is worth saying why that is a fix rather than a feature: this was
366
+ * `config.contentMaxChars ?? 100_000`, one number for every format, and when the caps became
367
+ * per-format (html and json 50 000, otherwise 20 000) the field was left assigned and never
368
+ * read. A configuration knob that silently does nothing is worse than no knob — it is an
369
+ * operator's decision that quietly went nowhere. Leaving it unset means the per-format
370
+ * default; setting it means the operator meant it.
371
+ */
310
372
  readonly contentMaxChars?: number
311
373
  /**
312
374
  * Directory `browser_download` save paths must resolve inside (prevents a
@@ -524,6 +586,57 @@ function defaultDownloadDir(): string {
524
586
  return join(homedir(), DOWNLOAD_DIR_NAMES[0] ?? 'Downloads')
525
587
  }
526
588
 
589
+ /**
590
+ * Whether a directory entry exists at `path`, judging the ENTRY rather than what it
591
+ * points at.
592
+ *
593
+ * `existsSync` follows links, so a DANGLING symlink — a link whose target does not
594
+ * exist — reads as "nothing here", and a write to that path creates the target
595
+ * instead: outside `downloadDir` whenever the link points out of it. `lstatSync`
596
+ * sees the link itself, which is the only correct answer to "is this name free".
597
+ * @param path - the path to judge.
598
+ * @returns true when any entry (file, directory, link) occupies the name.
599
+ */
600
+ function entryExists(path: string): boolean {
601
+ try {
602
+ lstatSync(path)
603
+ return true
604
+ } catch {
605
+ return false
606
+ }
607
+ }
608
+
609
+ /**
610
+ * The real path of the deepest existing ancestor of `path` — `path` itself when it
611
+ * exists, otherwise the nearest directory above it that does.
612
+ *
613
+ * `resolve()` normalizes `..` but never follows a link, so nothing textual can tell
614
+ * that `<downloadDir>/link/x.png` leaves the directory through `link`. The first
615
+ * ancestor that exists (judged by lstat, so a dangling link counts — the write would
616
+ * go through it) is the last component whose real location still determines where
617
+ * the bytes land, so that is the one to resolve and check.
618
+ * @param path - an absolute path that may not exist yet.
619
+ * @returns the resolved real path of its deepest existing ancestor.
620
+ */
621
+ function realAncestorOf(path: string): string {
622
+ let current = path
623
+ for (let depth = 0; depth < 64; depth += 1) {
624
+ if (entryExists(current)) {
625
+ try {
626
+ return realpathSync(current)
627
+ } catch {
628
+ // The entry vanished between the two calls, or cannot be resolved: the
629
+ // textual path is the best available answer, and the write reports the rest.
630
+ return current
631
+ }
632
+ }
633
+ const parent = dirname(current)
634
+ if (parent === current) return current
635
+ current = parent
636
+ }
637
+ return current
638
+ }
639
+
527
640
  /**
528
641
  * Browser provider over Electron views. Sessions hold an ordered list of
529
642
  * tabs; each tab is one view created by the host. The active tab receives
@@ -537,7 +650,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
537
650
  private readonly sessions = new Map<BrowserSessionId, Session>()
538
651
  private readonly httpOnly: boolean
539
652
  private readonly snapshotMaxElements: number
540
- private readonly contentMaxChars: number
653
+ private readonly contentMaxChars: number | undefined
541
654
  private readonly downloadDir: string | undefined
542
655
  /**
543
656
  * Persistent browsing history, or `undefined` when the user turned it off.
@@ -553,7 +666,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
553
666
  ) {
554
667
  this.httpOnly = config.httpOnly ?? true
555
668
  this.snapshotMaxElements = config.snapshotMaxElements ?? 60
556
- this.contentMaxChars = config.contentMaxChars ?? 100_000
669
+ this.contentMaxChars = config.contentMaxChars
557
670
  // Confine downloads AND screenshot saves to a dedicated directory by
558
671
  // default: the OS Downloads folder is the human-visible, browser-natural
559
672
  // place for written files, and it is the one directory the DSH file
@@ -595,7 +708,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
595
708
  * in the window title so a human can tell which task's page is visible.
596
709
  */
597
710
  open(label?: string): Promise<BrowserSessionId> {
598
- const handle = this.host.createView()
711
+ // The label is the calling DSH task, and it is handed to the view as its owner: a host shared
712
+ // by several conversations (the desktop's) keeps one conversation's page out of another's on
713
+ // the strength of it, so it is not decoration.
714
+ const handle = this.host.createView(label)
599
715
  const id = `browser:${randomUUID()}`
600
716
  // Group the session's views under its own window (one window per
601
717
  // session), carrying the label for the window title. Hosts without
@@ -850,13 +966,16 @@ export class ElectronBrowserProvider implements BrowserProvider {
850
966
  * Chromium drops `Input.*` events for a view with no display surface, so this
851
967
  * runs before click/type/key and fails loudly (BROWSER_VIEW_NOT_PRESENTED)
852
968
  * rather than reporting a success the page never saw.
969
+ * @param s - the session whose active view must be presented.
970
+ * @param signal - optional cancellation.
971
+ * @returns the view the session's active tab has AFTER the presentation barrier.
853
972
  */
854
- private async present(s: Session, signal?: AbortSignal): Promise<void> {
973
+ private async present(s: Session, signal?: AbortSignal): Promise<ElectronViewHandle> {
855
974
  const { handle } = this.activeTab(s)
856
975
  const present = this.host.presentView?.bind(this.host)
857
976
  if (present === undefined) {
858
977
  this.showActive(s)
859
- return
978
+ return this.activeTab(s).handle
860
979
  }
861
980
  signal?.throwIfAborted()
862
981
  const timeoutMs = 10_000
@@ -875,6 +994,48 @@ export class ElectronBrowserProvider implements BrowserProvider {
875
994
  { cause: error },
876
995
  )
877
996
  }
997
+ // Read the active tab AGAIN. `present` is a round-trip that can take up to the budget
998
+ // above, and the human is invited to drive the same window (a toolbar tab switch, a
999
+ // closed window) while the agent waits. Chromium answers `Input.*` with success even
1000
+ // for a view that has no display surface, so dispatching to a handle that is no longer
1001
+ // the visible one does not fail — it silently lands nowhere, or on the wrong page.
1002
+ //
1003
+ // Returning the re-read handle was wrong, though: `handle` above is the one that was
1004
+ // actually presented, and if the active tab changed during the round trip then the
1005
+ // re-read one was never presented at all. Handing it back meant the caller dispatched
1006
+ // to a view with no display surface — the exact failure this method exists to prevent,
1007
+ // arrived at from the other side. Either the presented view is still the active one, or
1008
+ // the caller must be told rather than given something else to aim at.
1009
+ const live = this.activeTab(s).handle
1010
+ if (live !== handle) {
1011
+ throw new BrowserError(
1012
+ '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',
1013
+ 'BROWSER_VIEW_CHANGED',
1014
+ )
1015
+ }
1016
+ return handle
1017
+ }
1018
+
1019
+ /**
1020
+ * Refuse to send synthesized input to a view that is no longer the one it was located
1021
+ * against.
1022
+ *
1023
+ * `locateHandle` was captured before a page-side locate that can consume its whole
1024
+ * 10s budget; the session's active tab is re-read afterwards. If they differ, the human
1025
+ * switched tabs mid-flight (the product's whole point is that they can take over), and
1026
+ * the coordinates belong to one page while the dispatch would go to another. Comparing
1027
+ * the handles turns that into a loud, retryable error instead of input the caller
1028
+ * believes landed.
1029
+ * @param session - the session id, for the message.
1030
+ * @param locateHandle - the view the locate ran in.
1031
+ * @param liveHandle - the view that is active now.
1032
+ */
1033
+ private assertSameView(session: BrowserSessionId, locateHandle: ElectronViewHandle, liveHandle: ElectronViewHandle): void {
1034
+ if (locateHandle === liveHandle) return
1035
+ throw new BrowserError(
1036
+ `browser: the session's active view changed while the element was being located (session ${session}); the operation was not dispatched — retry it`,
1037
+ 'BROWSER_VIEW_CHANGED',
1038
+ )
878
1039
  }
879
1040
 
880
1041
  /**
@@ -903,6 +1064,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
903
1064
 
904
1065
  /** Execute JS in the active tab's page context. */
905
1066
  async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
1067
+ this.assertActionAllowed('execute')
906
1068
  const s = this.session(session)
907
1069
  const { handle } = this.activeTab(s)
908
1070
  signal?.throwIfAborted()
@@ -1140,7 +1302,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1140
1302
  const tab = this.activeTab(s)
1141
1303
  signal?.throwIfAborted()
1142
1304
  const includeHidden = request.includeHidden === true
1143
- const maxNodes = Math.max(10, Math.min(5000, Math.floor(request.maxNodes ?? 500)))
1305
+ // 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
1306
+ // characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
1307
+ const maxNodes = Math.max(10, Math.min(5000, Math.floor(request.maxNodes ?? 150)))
1144
1308
  const script = `(() => {
1145
1309
  const includeHidden = ${String(includeHidden)}
1146
1310
  const maxNodes = ${String(maxNodes)}
@@ -1282,6 +1446,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
1282
1446
  value: valueOf(el),
1283
1447
  states: statesOf(el),
1284
1448
  depth: baseDepth + depthOf(el),
1449
+ // Same derivation as the snapshot collector: an id or a name is what makes a node
1450
+ // something the caller can point at without guessing at its text. The tool-layer
1451
+ // schema declared this field before this line existed, so the schema was promising
1452
+ // a value the tool never sent.
1453
+ selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
1285
1454
  tag: el.tagName.toLowerCase(),
1286
1455
  x: Math.round(a.x + a.w / 2),
1287
1456
  y: Math.round(a.y + a.h / 2),
@@ -1330,9 +1499,18 @@ export class ElectronBrowserProvider implements BrowserProvider {
1330
1499
  async content(session: BrowserSessionId, request: BrowserContentRequest, signal?: AbortSignal): Promise<BrowserContentResult> {
1331
1500
  const tab = this.activeTab(this.session(session))
1332
1501
  signal?.throwIfAborted()
1333
- const maxChars = request.maxChars ?? this.contentMaxChars
1334
1502
  const selector = request.selector ?? ''
1335
1503
  const format = request.format
1504
+ // Per-format caps. A full HTML document is enormous, but the same limit applied to
1505
+ // plain text is no limit at all (a whole page of text measured 25,882 characters).
1506
+ // This bounds the common case instead of letting one call spend 25k-33k tokens.
1507
+ //
1508
+ // The plugin config overrides the lot when it is set — see `contentMaxChars`. The caller's
1509
+ // own `maxChars` still outranks both: an explicit per-call bound is the most specific
1510
+ // statement of intent there is.
1511
+ const maxChars = request.maxChars
1512
+ ?? this.contentMaxChars
1513
+ ?? (format === 'html' || format === 'json' ? 50_000 : 20_000)
1336
1514
  const script = `(() => {
1337
1515
  const root = ${selector === '' ? 'document.body' : `document.querySelector(${JSON.stringify(selector)})`}
1338
1516
  if (!root) return { ok: false, reason: 'selector not found' }
@@ -1376,7 +1554,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
1376
1554
  }
1377
1555
  }
1378
1556
  else if (fmt === 'html') content = root.outerHTML || ''
1379
- else if (fmt === 'json') content = JSON.stringify(root)
1557
+ // A DOM element has no own enumerable properties — everything lives on the prototype
1558
+ // chain — so JSON.stringify(root) is always "{}". That is not a structured view of
1559
+ // the page, it is an empty object the caller will read as "no data here" and then
1560
+ // retry with other selectors. Serialise the markup instead, which is what a caller
1561
+ // asking for JSON of an element can actually use.
1562
+ else if (fmt === 'json') content = JSON.stringify({ html: root.outerHTML, tag: root.tagName.toLowerCase() })
1380
1563
  else {
1381
1564
  // markdown: headings, paragraphs, links, lists (best-effort)
1382
1565
  const parts = []
@@ -1431,7 +1614,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1431
1614
  signal?: AbortSignal,
1432
1615
  ): Promise<void> {
1433
1616
  const s = this.session(session)
1434
- const { handle } = this.activeTab(s)
1617
+ // The view the locate runs against. It can stop being the session's active view while
1618
+ // the locate waits (see prepareTarget), so every dispatch below re-reads the live one.
1619
+ const locateHandle = this.activeTab(s).handle
1435
1620
  signal?.throwIfAborted()
1436
1621
  let x = 0
1437
1622
  let y = 0
@@ -1465,8 +1650,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
1465
1650
  y = request.y
1466
1651
  }
1467
1652
  // Input.* is only delivered to a view with a current display surface; the
1468
- // barrier also re-presents after a navigation replaced the renderer.
1469
- await this.present(s, signal)
1653
+ // barrier also re-presents after a navigation replaced the renderer. It returns the
1654
+ // handle that may still be receiving input when it settles.
1655
+ const handle = await this.present(s, signal)
1656
+ // The locate (up to 10s) and the present barrier both ran against the view captured
1657
+ // earlier; if the human switched tabs meanwhile, these coordinates belong to one page
1658
+ // and the dispatch would go to another — and Chromium answers success either way.
1659
+ if ('target' in request) this.assertSameView(session, locateHandle, handle)
1470
1660
  const timeoutMs = 30_000
1471
1661
  const send = (params: Record<string, unknown>, label: string): Promise<unknown> =>
1472
1662
  withTimeout(
@@ -1493,6 +1683,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
1493
1683
  // tab" signal, so the bubble is what lets a human follow along without the log.
1494
1684
  this.showCursor(handle, x, y, 'click', 'click', true)
1495
1685
  const release = (): void => {
1686
+ // Back to the handle the PRESS may have landed on, deliberately not to the currently
1687
+ // active tab: this only runs after a failed press/release, and re-targeting a
1688
+ // compensating half of a click to a view the human just switched to would press a
1689
+ // button there. Best-effort either way.
1496
1690
  void handle
1497
1691
  .sendCommand('Input.dispatchMouseEvent', {
1498
1692
  type: 'mouseReleased',
@@ -1532,7 +1726,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
1532
1726
  signal?: AbortSignal,
1533
1727
  ): Promise<void> {
1534
1728
  const s = this.session(session)
1535
- const { handle } = this.activeTab(s)
1729
+ // The view the locate runs against. It can stop being the session's active view while
1730
+ // the locate waits (the human is invited to drive the same window), so the input below
1731
+ // goes to the handle `present` reports instead of this one.
1732
+ const locateHandle = this.activeTab(s).handle
1536
1733
  signal?.throwIfAborted()
1537
1734
  const hasTarget = 'target' in request
1538
1735
  if (hasTarget) {
@@ -1549,8 +1746,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
1549
1746
  const text = 'text' in request ? request.text : ''
1550
1747
  const timeoutMs = 30_000
1551
1748
  // Input.insertText goes through the Input domain: same display-surface
1552
- // requirement as click/key.
1553
- await this.present(s, signal)
1749
+ // requirement as click/key. It also reports which view is active once the barrier has
1750
+ // settled, which is what makes the tab-switch check below meaningful.
1751
+ const handle = await this.present(s, signal)
1752
+ // Focusing one page and inserting into another is exactly the silent mis-target this
1753
+ // refuses: the element was focused in the located view, and the text would have gone to
1754
+ // whichever page is now visible — possibly a password field there.
1755
+ if (hasTarget) this.assertSameView(session, locateHandle, handle)
1554
1756
  try {
1555
1757
  await withTimeout(
1556
1758
  handle.sendCommand('Input.insertText', { text } satisfies CdpInsertTextParams),
@@ -1674,7 +1876,6 @@ export class ElectronBrowserProvider implements BrowserProvider {
1674
1876
  /** Press one named key (Enter/Tab/arrows/…) via CDP key events. */
1675
1877
  async key(session: BrowserSessionId, request: BrowserKeyRequest, signal?: AbortSignal): Promise<void> {
1676
1878
  const s = this.session(session)
1677
- const { handle } = this.activeTab(s)
1678
1879
  signal?.throwIfAborted()
1679
1880
  const spec = KEY_SPECS[request.key]
1680
1881
  if (spec === undefined) {
@@ -1682,8 +1883,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
1682
1883
  }
1683
1884
  const params = { key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.vk, nativeVirtualKeyCode: spec.vk, ...spec.text !== undefined ? { text: spec.text } : {} }
1684
1885
  const timeoutMs = 15_000
1685
- // Input.dispatchKeyEvent is subject to the same display-surface rule.
1686
- await this.present(s, signal)
1886
+ // Input.dispatchKeyEvent is subject to the same display-surface rule. The handle comes
1887
+ // back from the presentation barrier rather than from a capture taken before it: this
1888
+ // session is shared with the human, who may switch tabs while the barrier round-trips,
1889
+ // and a key sent to the view they left is silently dropped (or lands on the new page).
1890
+ const handle = await this.present(s, signal)
1891
+ // Focus the SAME view the key is dispatched to; focusing the pre-switch handle would
1892
+ // grant web focus to a page that then receives nothing.
1687
1893
  await this.focusView(handle)
1688
1894
  const release = (): Promise<Record<string, unknown>> =>
1689
1895
  handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params })
@@ -1721,7 +1927,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
1721
1927
  name: f.name ?? null,
1722
1928
  label: f.label ?? null,
1723
1929
  placeholder: f.placeholder ?? null,
1724
- kind: f.kind ?? 'text',
1930
+ // A field with NO kind is a field with no kind FILTER: absent, not 'text'. Defaulting
1931
+ // it to 'text' here made `if (spec.kind)` above always true, so every unspecified
1932
+ // field was narrowed to an <input> — browser_fill on a <textarea> reported
1933
+ // `no "text" control matched`, and a contenteditable could not match any kind at all.
1934
+ // The value stays a string or null (never undefined): the in-page check tests truthiness,
1935
+ // and JSON drops an undefined member entirely, which is the same thing here.
1936
+ kind: f.kind ?? null,
1725
1937
  value: f.value,
1726
1938
  })))
1727
1939
  const submitFlag = request.submit === true
@@ -1754,10 +1966,34 @@ export class ElectronBrowserProvider implements BrowserProvider {
1754
1966
  }
1755
1967
  return false
1756
1968
  }
1969
+ // Every root this page exposes: the top document plus its shadow trees (recursively)
1970
+ // and same-origin iframes. The snapshot and waitFor already walk all of them, so an
1971
+ // element inside one is shown to the model and can be waited for — resolving a click or
1972
+ // a fill used to look at the top document alone and answer "not found" for something
1973
+ // the model had just been handed a reference to. The two must agree about what exists.
1974
+ // (Kept in step with the collector in the snapshot script above.)
1975
+ const allRoots = () => {
1976
+ const roots = []
1977
+ const seen = new Set()
1978
+ const visit = doc => {
1979
+ if (seen.has(doc)) return
1980
+ seen.add(doc)
1981
+ roots.push(doc)
1982
+ for (const el of doc.querySelectorAll('*')) {
1983
+ if (el.shadowRoot) visit(el.shadowRoot)
1984
+ if (el.tagName === 'IFRAME') {
1985
+ try { const d = el.contentDocument; if (d) visit(d) } catch { /* cross-origin */ }
1986
+ }
1987
+ }
1988
+ }
1989
+ visit(document)
1990
+ return roots
1991
+ }
1992
+ const roots = allRoots()
1757
1993
  const candidates = (spec) => {
1758
1994
  const all = spec.selector
1759
- ? [...document.querySelectorAll(spec.selector)]
1760
- : [...document.querySelectorAll('input, textarea, select, [contenteditable="true"]')].filter(el => matches(el, spec))
1995
+ ? roots.flatMap(root => [...root.querySelectorAll(spec.selector)])
1996
+ : roots.flatMap(root => [...root.querySelectorAll('input, textarea, select, [contenteditable="true"]')]).filter(el => matches(el, spec))
1761
1997
  const vis = all.filter(visible)
1762
1998
  return vis.length > 0 ? vis : all
1763
1999
  }
@@ -1775,7 +2011,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1775
2011
  let el = els[0]
1776
2012
  // The caller may name a kind. Honor it: the branches below read the element's own
1777
2013
  // tag/type, so without this a selector matching two different controls acted on
1778
- // whichever came first — usually right, which is what hid the mistake.
2014
+ // whichever came first — usually right, which is what hid the mistake. A field that
2015
+ // names NO kind gets no filter at all: the element's own tag then decides the
2016
+ // branch, which is what "kind defaults to text" was always trying to say.
1779
2017
  if (spec.kind) {
1780
2018
  const matchesKind = (node) => {
1781
2019
  const nodeTag = node.tagName
@@ -1783,6 +2021,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
1783
2021
  if (spec.kind === 'select') return nodeTag === 'SELECT'
1784
2022
  if (spec.kind === 'textarea') return nodeTag === 'TEXTAREA'
1785
2023
  if (spec.kind === 'checkbox' || spec.kind === 'radio') return nodeType === spec.kind
2024
+ // 'text': the plain-value branch covers <input> AND <textarea>, and the batch
2025
+ // already collects [contenteditable="true"] as a settable control — so all three
2026
+ // must match. Requiring nodeTag === 'INPUT' here reported "no text control
2027
+ // matched" for a textarea and left the contenteditable branch below unreachable.
2028
+ if (nodeTag === 'TEXTAREA') return true
2029
+ if (node.isContentEditable === true) return true
1786
2030
  return nodeTag === 'INPUT' && nodeType !== 'checkbox' && nodeType !== 'radio' && nodeType !== 'file'
1787
2031
  }
1788
2032
  const wanted = els.find(matchesKind)
@@ -1904,33 +2148,78 @@ export class ElectronBrowserProvider implements BrowserProvider {
1904
2148
  const match = () => {
1905
2149
  const by = typeof spec.by === 'string' ? spec.by : 'css'
1906
2150
  const value = String(spec.value ?? '')
1907
- const index = typeof spec.index === 'number' ? spec.index : 0
2151
+ // Named matchIndex rather than index: this whole function is one host-side template
2152
+ // literal, so a bare name declared here can silently shadow a host binding of the
2153
+ // same name and turn an inner reference into a type error.
2154
+ const matchIndex = typeof spec.index === 'number' ? spec.index : 0
2155
+ // An index that cannot address an element can never match on a later poll, exactly
2156
+ // like a selector that fails to parse — so it must not look like a miss either. A
2157
+ // negative index, the common "last one" shorthand, otherwise burned the caller's
2158
+ // whole locate budget (10s) and then reported "element not found", which reads like a
2159
+ // slow page rather than a bad argument.
2160
+ // (No backticks in this comment: it lives inside a template literal.)
2161
+ if (!Number.isInteger(matchIndex) || matchIndex < 0) {
2162
+ throw new Error('target.index must be a non-negative integer, got ' + JSON.stringify(spec.index))
2163
+ }
1908
2164
  let els = []
2165
+ // Every root this page exposes: the top document plus its shadow trees (recursively)
2166
+ // and same-origin iframes. The snapshot and waitFor already walk all of them, so an
2167
+ // element inside one is shown to the model and can be waited for — resolving a click or
2168
+ // a fill used to look at the top document alone and answer "not found" for something
2169
+ // the model had just been handed a reference to. The two must agree about what exists.
2170
+ // (Kept in step with the collector in the snapshot script above.)
2171
+ const allRoots = () => {
2172
+ const roots = []
2173
+ const seen = new Set()
2174
+ const visit = doc => {
2175
+ if (seen.has(doc)) return
2176
+ seen.add(doc)
2177
+ roots.push(doc)
2178
+ for (const el of doc.querySelectorAll('*')) {
2179
+ if (el.shadowRoot) visit(el.shadowRoot)
2180
+ if (el.tagName === 'IFRAME') {
2181
+ try { const d = el.contentDocument; if (d) visit(d) } catch { /* cross-origin */ }
2182
+ }
2183
+ }
2184
+ }
2185
+ visit(document)
2186
+ return roots
2187
+ }
2188
+ const roots = allRoots()
1909
2189
  if (by === 'css') {
1910
2190
  // A selector that does not PARSE can never match on a later poll, so
1911
2191
  // it must not look like a miss: the old "return null" kept the caller
1912
2192
  // polling until the whole budget was gone, and "element not found
1913
2193
  // after 10s" reads like a slow page while the real cause is a
1914
2194
  // malformed selector (a label or plain text passed where one belongs).
1915
- try { els = Array.from(document.querySelectorAll(value)) }
2195
+ // Parse on the top document first: a selector that does not PARSE must still be
2196
+ // reported as a bad argument rather than as a miss, and that check is about syntax,
2197
+ // not about which root holds the element.
2198
+ try { document.querySelectorAll(value) }
1916
2199
  catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
2200
+ for (const root of roots) els.push(...root.querySelectorAll(value))
1917
2201
  } else if (by === 'xpath') {
1918
2202
  try {
1919
- const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
1920
- for (let i = 0; i < snap.snapshotLength; i++) {
1921
- const n = snap.snapshotItem(i)
1922
- if (n instanceof Element) els.push(n)
2203
+ for (const root of roots) {
2204
+ const snap = document.evaluate(value, root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
2205
+ for (let i = 0; i < snap.snapshotLength; i++) {
2206
+ const n = snap.snapshotItem(i)
2207
+ if (n instanceof Element) els.push(n)
2208
+ }
1923
2209
  }
1924
2210
  } catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1925
2211
  } else {
1926
- const all = Array.from(document.querySelectorAll('body *'))
2212
+ // '*' rather than 'body *': a shadow root and an iframe document have no body of
2213
+ // their own to anchor to, and the visibility and own-text predicates below are what
2214
+ // actually decide whether an element counts.
2215
+ const all = roots.flatMap(root => [...root.querySelectorAll('*')])
1927
2216
  const exact = all.filter(el => isVisible(el) && ownText(el) === value)
1928
2217
  // Exact first, then contains; deepest element preferred (so a
1929
2218
  // button inside a card wins over the card itself).
1930
2219
  const pool = exact.length > 0 ? exact : all.filter(el => isVisible(el) && ownText(el).includes(value))
1931
2220
  els = pool.sort((a, b) => depth(b) - depth(a))
1932
2221
  }
1933
- return els[index] ?? null
2222
+ return els[matchIndex] ?? null
1934
2223
  }
1935
2224
  const deadline = Date.now() + timeoutMs
1936
2225
  let el = null
@@ -2225,6 +2514,35 @@ export class ElectronBrowserProvider implements BrowserProvider {
2225
2514
  return { count: items.length, items }
2226
2515
  }
2227
2516
 
2517
+ /**
2518
+ * Refuse an action the settings have switched off.
2519
+ *
2520
+ * These switches belong to the OPERATOR. The settings document is written through the
2521
+ * plugin's own panel and no tool can reach it, so — unlike `browser_restrict`, which the
2522
+ * model owns and can lift at will — a refusal here stands for as long as the setting does.
2523
+ * That is the point of them: a deployment can take page-script execution, downloads, or
2524
+ * login-state writes off the table without relying on the model's cooperation.
2525
+ *
2526
+ * Read through `settingsSource` on every call rather than captured at construction, so
2527
+ * flipping a switch applies to the next command instead of the next restart — the same
2528
+ * rule the credentials gate follows.
2529
+ * @param action - which switch to consult.
2530
+ * @throws BrowserError when that switch is off.
2531
+ */
2532
+ private assertActionAllowed(action: 'execute' | 'download' | 'credentialWrite'): void {
2533
+ const settings = this.settingsSource?.()
2534
+ if (settings === undefined) return
2535
+ if (action === 'execute' && !settings.actions.allowExecute) {
2536
+ throw new BrowserError('browser: running page scripts is switched off in settings (Browser → actions)', 'BROWSER_EXECUTE_DISABLED')
2537
+ }
2538
+ if (action === 'download' && !settings.actions.allowDownload) {
2539
+ throw new BrowserError('browser: downloads are switched off in settings (Browser → actions)', 'BROWSER_DOWNLOAD_DISABLED')
2540
+ }
2541
+ if (action === 'credentialWrite' && !settings.actions.allowCredentialWrite) {
2542
+ throw new BrowserError('browser: writing cookies is switched off in settings (Browser → actions)', 'BROWSER_AUTH_WRITE_DISABLED')
2543
+ }
2544
+ }
2545
+
2228
2546
  /**
2229
2547
  * Admit a caller-supplied save path for a file the browser writes to disk.
2230
2548
  * ONE gate for both `browser_download` and `browser_screenshot`: the path
@@ -2245,19 +2563,55 @@ export class ElectronBrowserProvider implements BrowserProvider {
2245
2563
  const file = resolve(savePath)
2246
2564
  if (this.downloadDir !== undefined) {
2247
2565
  const dir = resolve(this.downloadDir)
2248
- // Case-insensitive comparison: Windows paths are case-insensitive, and
2249
- // resolve() does not normalize case. Without this, C:\Users\X\Downloads
2250
- // and c:\users\x\downloads would be treated as different roots.
2251
- const dirLower = dir.toLowerCase()
2252
- const fileLower = file.toLowerCase()
2253
- if (fileLower !== dirLower && !fileLower.startsWith(dirLower + sep.toLowerCase())) {
2566
+ // Windows paths are case-insensitive and resolve() does not normalize case, so there
2567
+ // the containment check folds case — otherwise C:\Users\X\Downloads and
2568
+ // c:\users\x\downloads would read as different roots. Everywhere else the fold is
2569
+ // REMOVED: a case-sensitive filesystem (Linux) treats /home/u/DOWNLOADS and
2570
+ // /home/u/Downloads as different directories, so folding case there let a savePath
2571
+ // that merely differed in case pass admission — a directory-confinement escape, and
2572
+ // one that mkdirSync below would then happily create.
2573
+ const fold = process.platform === 'win32' ? (value: string): string => value.toLowerCase() : (value: string): string => value
2574
+ const dirKey = fold(dir)
2575
+ const fileKey = fold(file)
2576
+ // The directory ITSELF is refused, not accepted: a save path that IS downloadDir names
2577
+ // a directory, so there is no file to write — and mkdirSync would have created the
2578
+ // directory when it did not exist. Two paths are "inside" only when the file path is
2579
+ // strictly below the directory, which the separator check below already expresses.
2580
+ if (!fileKey.startsWith(dirKey + fold(sep))) {
2254
2581
  throw new BrowserError(`browser: ${kind} savePath must be inside downloadDir "${dir}"`, code)
2255
2582
  }
2583
+ // The check above is textual, and a symlink defeats it: resolve() normalizes `..`
2584
+ // but never follows a link, so `<downloadDir>/out/x.png` passed every test above
2585
+ // while `out` pointed at a directory outside the gate — and the write then landed
2586
+ // there. Resolve the REAL location of the deepest existing ancestor (which is what
2587
+ // decides where the bytes go) and require it to be inside the real directory too.
2588
+ // A legitimate path is unaffected: its ancestor resolves to the directory itself.
2589
+ //
2590
+ // The directory is created first so that it can be resolved at all — mkdirSync is a
2591
+ // no-op for one that already exists — and a directory that cannot be created or
2592
+ // resolved falls back to its textual form, leaving the real filesystem error to the
2593
+ // write instead of inventing one here.
2594
+ let realDir: string
2595
+ try {
2596
+ mkdirSync(dir, { recursive: true })
2597
+ realDir = fold(realpathSync(dir))
2598
+ } catch {
2599
+ realDir = fold(dir)
2600
+ }
2601
+ const realAncestor = fold(realAncestorOf(file))
2602
+ if (realAncestor !== realDir && !realAncestor.startsWith(realDir + fold(sep))) {
2603
+ throw new BrowserError(`browser: ${kind} savePath resolves outside downloadDir "${dir}" (a symlink leaves it)`, code)
2604
+ }
2256
2605
  }
2257
2606
  // Never replace an existing file: its previous content is unrecoverable,
2258
2607
  // and the admitted directory may hold files the human put there. A new
2259
2608
  // name is one tool call away.
2260
- if (existsSync(file)) {
2609
+ //
2610
+ // Judged with lstat (see {@link entryExists}), not existsSync: existsSync FOLLOWS
2611
+ // links, so a DANGLING symlink read as "nothing here" and the write below created
2612
+ // its target — outside the directory whenever the link pointed out of it. It is the
2613
+ // directory entry that has to be free, whatever that entry points at.
2614
+ if (entryExists(file)) {
2261
2615
  throw new BrowserError(`browser: refusing to overwrite existing file "${file}" — use another name`, code)
2262
2616
  }
2263
2617
  return file
@@ -2276,6 +2630,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
2276
2630
  * @returns the path the file was written to.
2277
2631
  */
2278
2632
  async download(session: BrowserSessionId, request: { readonly url: string; readonly savePath: string }, signal?: AbortSignal): Promise<{ readonly path: string }> {
2633
+ // Before admission, and before the session is even resolved: a switched-off action is
2634
+ // not a path problem, and the caller should hear the real reason.
2635
+ this.assertActionAllowed('download')
2279
2636
  const s = this.session(session)
2280
2637
  const { handle } = this.activeTab(s)
2281
2638
  signal?.throwIfAborted()
@@ -2294,7 +2651,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2294
2651
  // savePath admission: the shared gate — absolute, inside `downloadDir`,
2295
2652
  // and never an existing file. Screenshots use the very same one.
2296
2653
  const savePath = this.admitSavePath(request.savePath, 'download')
2297
- const downloadable = handle as { download?(url: string, savePath: string): Promise<void> }
2654
+ const downloadable = handle
2298
2655
  if (typeof downloadable.download !== 'function') {
2299
2656
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
2300
2657
  }
@@ -2331,7 +2688,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2331
2688
  const timeoutMs = 30_000
2332
2689
  // The self-hosted carrier has a native path (the child reads its own session), which
2333
2690
  // is worth preferring: it reports exactly what the browser persisted.
2334
- const native = handle as { flushAuth?(): Promise<readonly ExportedCookie[]> }
2691
+ const native = handle
2335
2692
  if (typeof native.flushAuth === 'function') {
2336
2693
  const cookies = await withTimeout(native.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
2337
2694
  this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
@@ -2340,8 +2697,29 @@ export class ElectronBrowserProvider implements BrowserProvider {
2340
2697
  // Every other carrier drives a real browser over CDP, and cookies are part of CDP —
2341
2698
  // the sidebar goes through the shell's webContents.debugger, an installed Chrome or
2342
2699
  // Edge is the browser itself. So this works everywhere rather than only self-hosted.
2700
+ // Scoped to the page this session is on. The jar itself is SHARED on purpose — every
2701
+ // window and every task drives the same browser, so signing in once works everywhere,
2702
+ // which is the whole point of "use your own browser". What must not be shared is the
2703
+ // HANDING OVER: an unfiltered read puts every domain every task has visited, plus
2704
+ // whatever the human is signed into, in front of one task's caller. Least exposure,
2705
+ // not isolation.
2706
+ //
2707
+ // Two corrections to the previous attempt. It called Storage.getCookies with a `urls`
2708
+ // parameter, but that method takes only browserContextId and ignores everything else, so
2709
+ // the filter did nothing and the whole jar was still exported; `urls` belongs to
2710
+ // Network.getCookies. And it silently fell back to an unfiltered read when the page URL
2711
+ // was unavailable — an error path that asked for MORE than the normal one. Both are
2712
+ // fixed: the method that honours the filter, and a failure that refuses instead of
2713
+ // widening.
2714
+ const cookiesUrl = await this.currentUrl(handle).catch(() => '')
2715
+ if (cookiesUrl === '') {
2716
+ throw new BrowserError(
2717
+ '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',
2718
+ 'BROWSER_AUTH_SCOPE_UNKNOWN',
2719
+ )
2720
+ }
2343
2721
  const result = await withTimeout(
2344
- handle.sendCommand('Storage.getCookies', {}),
2722
+ handle.sendCommand('Network.getCookies', { urls: [cookiesUrl] }),
2345
2723
  timeoutMs,
2346
2724
  undefined,
2347
2725
  `browser: auth export timed out after ${timeoutMs}ms`,
@@ -2353,16 +2731,16 @@ export class ElectronBrowserProvider implements BrowserProvider {
2353
2731
 
2354
2732
  /** Import cookies into the session (restore login state). Self-hosted only. */
2355
2733
  async restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number> {
2356
- // The settings panel offers "allow the agent to read cookies / export login state".
2357
- // Until now nothing read that switch, so turning it off changed nothing at all —
2358
- // the tool exported every cookie regardless.
2359
- if (this.settingsSource?.().credentials.allowRead === false) {
2360
- throw new BrowserError('browser: reading cookies is switched off in settings (Browser → credentials)', 'BROWSER_AUTH_DISABLED')
2361
- }
2734
+ // Restore WRITES cookies, so it answers to the write switch — not to
2735
+ // `credentials.allowRead`. It used to consult the read switch and report "reading
2736
+ // cookies is switched off", which is both the wrong gate and the wrong sentence: an
2737
+ // operator who allowed reading but wanted logins left alone had no switch at all,
2738
+ // and one who turned reading off lost the restore path for the wrong reason.
2739
+ this.assertActionAllowed('credentialWrite')
2362
2740
  const s = this.session(session)
2363
2741
  const { handle } = this.activeTab(s)
2364
2742
  const timeoutMs = 30_000
2365
- const native = handle as { restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number> }
2743
+ const native = handle
2366
2744
  if (typeof native.restoreAuth === 'function') {
2367
2745
  const restored = await withTimeout(native.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
2368
2746
  this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
@@ -2371,14 +2749,28 @@ export class ElectronBrowserProvider implements BrowserProvider {
2371
2749
  // CDP again, for the same reason as the export: any carrier that drives a real
2372
2750
  // browser can set cookies on it. Storage.setCookies needs the cookie fields spelled
2373
2751
  // the CDP way, including the sameSite spelling and seconds rather than milliseconds.
2752
+ //
2753
+ // toCdpCookie returns undefined for a cookie it cannot place (no usable domain), and
2754
+ // `cookies.map(...)` passed that undefined straight through: the array then held a
2755
+ // JSON `null`, CDP rejected the WHOLE call, and every valid cookie failed with an error
2756
+ // naming none of them. Filter the unusable ones out and say how many went.
2757
+ const mapped = cookies.map(toCdpCookie)
2758
+ const convertible = mapped.filter((cookie): cookie is Record<string, unknown> => cookie !== undefined)
2759
+ const dropped = mapped.length - convertible.length
2760
+ if (dropped > 0) {
2761
+ process.stderr.write(`[dsh-browser] restoreAuth: dropped ${dropped} of ${mapped.length} cookie(s) with no usable domain; the rest were still restored\n`)
2762
+ }
2374
2763
  const result = await withTimeout(
2375
- handle.sendCommand('Storage.setCookies', { cookies: cookies.map(toCdpCookie) }),
2764
+ handle.sendCommand('Storage.setCookies', { cookies: convertible }),
2376
2765
  timeoutMs,
2377
2766
  undefined,
2378
2767
  `browser: auth restore timed out after ${timeoutMs}ms`,
2379
2768
  )
2380
- const restored = toExportedCookies(result.cookies).length
2381
- this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies (CDP)` })
2769
+ // Storage.setCookies returns nothing, so the count comes from what was actually
2770
+ // sent. Reading result.cookies here was always undefined, which rendered as
2771
+ // "Restored 0 cookies." while the write had succeeded.
2772
+ const restored = convertible.length
2773
+ this.record(s, 'restoreAuth', { count: cookies.length, dropped }, true, { result: `${restored} cookies (CDP)${dropped > 0 ? `; ${dropped} dropped` : ''}` })
2382
2774
  return restored
2383
2775
  }
2384
2776
 
@@ -2387,7 +2779,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2387
2779
  session: BrowserSessionId,
2388
2780
  request?: BrowserScreenshotRequest,
2389
2781
  signal?: AbortSignal,
2390
- ): Promise<{ readonly dataUrl: string; readonly path?: string }> {
2782
+ ): Promise<{ readonly dataUrl: string; readonly path?: string; readonly width?: number; readonly height?: number }> {
2391
2783
  const s = this.session(session)
2392
2784
  const { handle } = this.activeTab(s)
2393
2785
  signal?.throwIfAborted()
@@ -2398,7 +2790,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2398
2790
  // immediately (empty) for hidden ones. JPEG and downscaling are encoded
2399
2791
  // in the child from the NativeImage, so this path is the only one that
2400
2792
  // can produce JPEG (CDP JPEG hangs on Electron 43).
2401
- const capturable = handle as { capture?(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string }> }
2793
+ const capturable = handle
2402
2794
  if (request?.fullPage !== true && typeof capturable.capture === 'function') {
2403
2795
  // Ensure the target view is the visible one before capturing.
2404
2796
  this.showActive(s)
@@ -2416,7 +2808,17 @@ export class ElectronBrowserProvider implements BrowserProvider {
2416
2808
  if (shot.base64 === '') {
2417
2809
  throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED')
2418
2810
  }
2419
- return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png')
2811
+ return this.saveScreenshot(
2812
+ shot.base64,
2813
+ request?.savePath,
2814
+ shot.mime ?? 'image/png',
2815
+ // The child measured these after applying any downscale, and they describe the image
2816
+ // the caller receives. Without them this path — the default self-hosted one — could
2817
+ // never answer the width/height its own tool schema declares.
2818
+ shot.width !== undefined && shot.height !== undefined
2819
+ ? { width: shot.width, height: shot.height }
2820
+ : undefined,
2821
+ )
2420
2822
  }
2421
2823
  // Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
2422
2824
  // or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
@@ -2442,6 +2844,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2442
2844
  // to the region it names, so it has to cover the whole document. The page's own size
2443
2845
  // is read first; when that is unavailable the capture proceeds unscaled rather than
2444
2846
  // failing — an image the caller can shrink itself beats no image at all.
2847
+ let rendered: { width: number; height: number } | undefined
2445
2848
  if (request?.maxWidth !== undefined || request?.maxHeight !== undefined) {
2446
2849
  const metrics = await withTimeout(
2447
2850
  handle.sendCommand('Page.getLayoutMetrics', {}),
@@ -2455,6 +2858,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
2455
2858
  const byWidth = request.maxWidth !== undefined && size.width > 0 ? request.maxWidth / size.width : 1
2456
2859
  const byHeight = request.maxHeight !== undefined && height > 0 ? request.maxHeight / height : 1
2457
2860
  const scale = Math.min(1, byWidth, byHeight)
2861
+ // What the caller will actually receive. Recorded before the branch below, because the
2862
+ // size is worth reporting even when no scaling happened (scale 1).
2863
+ rendered = { width: Math.round(size.width * scale), height: Math.round(height * scale) }
2458
2864
  if (scale < 1) {
2459
2865
  params.clip = { x: 0, y: 0, width: size.width, height, scale }
2460
2866
  // The clip defines the captured area; captureBeyondViewport must not also apply.
@@ -2472,7 +2878,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
2472
2878
  if (typeof data !== 'string') {
2473
2879
  throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
2474
2880
  }
2475
- return this.saveScreenshot(data, request?.savePath, 'image/png')
2881
+ // The mime must name the format that was ACTUALLY requested (params.format), not PNG
2882
+ // unconditionally: a carrier that sets supportsCdpJpeg asks for `format: 'jpeg'`, so
2883
+ // hardcoding the PNG mime announced a JPEG body as a PNG — the data URL then carried a
2884
+ // mislabelled image, and a caller that decodes by mime wrote the wrong file.
2885
+ const mime = params.format === 'jpeg' ? 'image/jpeg' : 'image/png'
2886
+ return this.saveScreenshot(data, request?.savePath, mime, rendered)
2476
2887
  }
2477
2888
 
2478
2889
  /**
@@ -2482,7 +2893,12 @@ export class ElectronBrowserProvider implements BrowserProvider {
2482
2893
  * file — so a screenshot cannot be used to write to, or silently replace,
2483
2894
  * files anywhere the DSH process happens to have permission (issue #13).
2484
2895
  */
2485
- private saveScreenshot(base64: string, savePath: string | undefined, mime: string): { dataUrl: string; path?: string } {
2896
+ private saveScreenshot(
2897
+ base64: string,
2898
+ savePath: string | undefined,
2899
+ mime: string,
2900
+ rendered?: { width: number; height: number },
2901
+ ): { dataUrl: string; path?: string; width?: number; height?: number } {
2486
2902
  if (savePath !== undefined) {
2487
2903
  // Admission failures propagate unchanged: they describe the caller's
2488
2904
  // path, not a disk problem.
@@ -2490,13 +2906,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
2490
2906
  try {
2491
2907
  mkdirSync(dirname(target), { recursive: true })
2492
2908
  writeFileSync(target, Buffer.from(base64, 'base64'))
2493
- return { dataUrl: `data:${mime};base64,${base64}`, path: target }
2909
+ return { dataUrl: `data:${mime};base64,${base64}`, path: target, ...rendered }
2494
2910
  } catch (error) {
2495
2911
  // Report the write problem but keep the capture usable.
2496
2912
  throw new BrowserError(`browser: screenshot save to "${target}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
2497
2913
  }
2498
2914
  }
2499
- return { dataUrl: `data:${mime};base64,${base64}` }
2915
+ return { dataUrl: `data:${mime};base64,${base64}`, ...rendered }
2500
2916
  }
2501
2917
 
2502
2918
  /** Append one operation to the session's history. */
@@ -2537,8 +2953,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
2537
2953
  const store = this.historyStore
2538
2954
  if (store === undefined) return
2539
2955
  // The settings panel can switch recording off at runtime; the static config
2540
- // only supplies the startup default.
2541
- if (this.settingsSource !== undefined && !this.settingsSource().history.enabled) return
2956
+ // only supplies the startup default. The RETENTION limits are read here too: they were
2957
+ // parsed and stored but never handed to the store, so editing maxEntries or maxAgeDays in
2958
+ // the file changed nothing — the switch worked and the numbers beside it did not.
2959
+ if (this.settingsSource !== undefined) {
2960
+ const live = this.settingsSource().history
2961
+ if (!live.enabled) return
2962
+ store.updateLimits({ maxEntries: live.maxEntries, maxAgeDays: live.maxAgeDays })
2963
+ }
2542
2964
  void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
2543
2965
  .then(result => {
2544
2966
  if (!result.ok || typeof result.value !== 'string') return
@@ -2665,7 +3087,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
2665
3087
  // the views above; the desktop's sidebar does not, so it is asked explicitly
2666
3088
  // — and its cookies plus the browsing history outlive the page either way.
2667
3089
  if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
2668
- void this.host.releasePage?.().catch(() => undefined)
3090
+ // Named, because one host serves every conversation: an unnamed release would end the
3091
+ // pages of conversations that are still running.
3092
+ void this.host.releasePage?.(existing.label).catch(() => undefined)
2669
3093
  }
2670
3094
  }
2671
3095
  return Promise.resolve()
@@ -2689,7 +3113,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2689
3113
 
2690
3114
  /** Append a fresh tab and make it active. */
2691
3115
  private newTab(s: Session): void {
2692
- const handle = this.host.createView()
3116
+ const handle = this.host.createView(s.label)
2693
3117
  // Same window as the session's other tabs.
2694
3118
  this.host.groupView?.(handle, s.id, s.label)
2695
3119
  s.tabs.push({ id: `tab:${randomUUID()}`, handle })
@@ -2794,11 +3218,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
2794
3218
  const message = `action ${action.type} failed: ${String(error)}`
2795
3219
  let reported = false
2796
3220
  try {
2797
- const host = this.host as { notifyUserActionError?(windowId: string, message: string): void }
2798
- const notify = host.notifyUserActionError
2799
- if (typeof notify === 'function') {
2800
- // Called on its owner: an unbound call here kills the host process.
2801
- notify.call(host, action.windowId, message)
3221
+ const notify = this.host.notifyUserActionError?.bind(this.host)
3222
+ if (notify !== undefined) {
3223
+ // Bound at the call: an unbound call here kills the host process (issue #16).
3224
+ notify(action.windowId, message)
2802
3225
  reported = true
2803
3226
  }
2804
3227
  } catch (notifyError) {