dsh-browser-plus 0.0.0-stage → 0.5.1

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 (89) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/LICENSE +22 -0
  3. package/NOTICE.md +7 -0
  4. package/README.en.md +119 -0
  5. package/README.md +118 -2
  6. package/assets/dsh-browser-plus-256.png +0 -0
  7. package/assets/dsh-browser-plus-512.png +0 -0
  8. package/assets/dsh-browser-plus-small.svg +9 -0
  9. package/assets/dsh-browser-plus.ico +0 -0
  10. package/assets/dsh-browser-plus.svg +11 -0
  11. package/assets/readme-workspace.png +0 -0
  12. package/client/index.js +185 -0
  13. package/cordis.patch.yml +41 -0
  14. package/docs/MIGRATION.md +48 -0
  15. package/docs/README.md +22 -0
  16. package/docs/SOAK-CHECKLIST.md +98 -0
  17. package/docs/architecture.md +88 -0
  18. package/docs/tool-reference.md +126 -0
  19. package/docs/user-guide.md +137 -0
  20. package/docs/why-browser.md +45 -0
  21. package/lib/browser/runtime.d.ts +238 -0
  22. package/lib/browser/runtime.js +330 -0
  23. package/lib/browser/types.d.ts +758 -0
  24. package/lib/browser/types.js +18 -0
  25. package/lib/browser-electron/auth-cookies.d.ts +54 -0
  26. package/lib/browser-electron/auth-cookies.js +83 -0
  27. package/lib/browser-electron/chrome-state.d.ts +211 -0
  28. package/lib/browser-electron/chrome-state.js +12 -0
  29. package/lib/browser-electron/entry.d.ts +73 -0
  30. package/lib/browser-electron/entry.js +65 -0
  31. package/lib/browser-electron/fingerprint.d.ts +29 -0
  32. package/lib/browser-electron/fingerprint.js +42 -0
  33. package/lib/browser-electron/host-main.d.ts +19 -0
  34. package/lib/browser-electron/host-main.js +2691 -0
  35. package/lib/browser-electron/icon.d.ts +11 -0
  36. package/lib/browser-electron/icon.js +23 -0
  37. package/lib/browser-electron/page-chrome.d.ts +21 -0
  38. package/lib/browser-electron/page-chrome.js +2269 -0
  39. package/lib/browser-electron/provider.d.ts +767 -0
  40. package/lib/browser-electron/provider.js +2825 -0
  41. package/lib/browser-electron/remote-host.d.ts +145 -0
  42. package/lib/browser-electron/remote-host.js +993 -0
  43. package/lib/browser-electron/task-summary.d.ts +2 -0
  44. package/lib/browser-electron/task-summary.js +12 -0
  45. package/lib/browser-electron/task-thumbnail.d.ts +11 -0
  46. package/lib/browser-electron/task-thumbnail.js +9 -0
  47. package/lib/browser-electron/write-guard.d.ts +41 -0
  48. package/lib/browser-electron/write-guard.js +123 -0
  49. package/lib/client.js +185 -0
  50. package/lib/command-browser/index.d.ts +20 -0
  51. package/lib/command-browser/index.js +35 -0
  52. package/lib/http-browser/index.d.ts +28 -0
  53. package/lib/http-browser/index.js +110 -0
  54. package/lib/index.d.ts +27 -0
  55. package/lib/index.js +25 -0
  56. package/lib/task-todos/index.d.ts +25 -0
  57. package/lib/task-todos/index.js +100 -0
  58. package/lib/tool-browser/index.d.ts +31 -0
  59. package/lib/tool-browser/index.js +2026 -0
  60. package/package.json +120 -4
  61. package/screenshots.json +3 -0
  62. package/scripts/build-client.mjs +20 -0
  63. package/scripts/build-icons.mjs +80 -0
  64. package/scripts/capture-window.ps1 +79 -0
  65. package/scripts/crop-image.ps1 +20 -0
  66. package/scripts/smoke-browser-tools.mjs +2051 -0
  67. package/scripts/smoke-chrome-world.mjs +65 -0
  68. package/scripts/smoke-electron-host.mjs +50 -0
  69. package/scripts/test-orb-drag.mjs +83 -0
  70. package/src/browser/runtime.ts +506 -0
  71. package/src/browser/types.ts +741 -0
  72. package/src/browser-electron/auth-cookies.ts +125 -0
  73. package/src/browser-electron/chrome-state.ts +192 -0
  74. package/src/browser-electron/entry.ts +125 -0
  75. package/src/browser-electron/fingerprint.ts +45 -0
  76. package/src/browser-electron/host-main.ts +2526 -0
  77. package/src/browser-electron/icon.ts +26 -0
  78. package/src/browser-electron/page-chrome.ts +2281 -0
  79. package/src/browser-electron/provider.ts +3366 -0
  80. package/src/browser-electron/remote-host.ts +1051 -0
  81. package/src/browser-electron/task-summary.ts +10 -0
  82. package/src/browser-electron/task-thumbnail.ts +17 -0
  83. package/src/browser-electron/write-guard.ts +134 -0
  84. package/src/command-browser/index.ts +61 -0
  85. package/src/http-browser/index.ts +139 -0
  86. package/src/index.ts +65 -0
  87. package/src/task-todos/index.ts +114 -0
  88. package/src/tool-browser/index.ts +2071 -0
  89. package/src/types/electron-shim.d.ts +143 -0
@@ -0,0 +1,3366 @@
1
+ /**
2
+ * Electron-backed browser provider: `WebContentsView` sessions driven over
3
+ * `webContents.debugger` (CDP). The provider itself does not import Electron — it operates through the {@link ElectronBrowserViewHost} seam, which the
4
+ * desktop shell implements with real Electron objects. That keeps this
5
+ * package testable under plain Node and leaves the Electron dependency to the
6
+ * shell that owns the `BrowserWindow`.
7
+ * @module dsh-browser-plus/browser-electron
8
+ */
9
+
10
+ import { randomUUID } from 'node:crypto'
11
+ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import type {
13
+ BrowserChallenge,
14
+ BrowserClearAuthRequest,
15
+ BrowserClearAuthResult,
16
+ BrowserContentRequest,
17
+ BrowserContentResult,
18
+ BrowserControlOwner,
19
+ BrowserDragRequest,
20
+ BrowserDragResult,
21
+ BrowserPointerResult,
22
+ BrowserPointerTarget,
23
+ BrowserExecuteRequest,
24
+ BrowserExecuteResult,
25
+ BrowserFillRequest,
26
+ BrowserFillResult,
27
+ BrowserHandoffState,
28
+ BrowserHistoryEntry,
29
+
30
+ BrowserOpenOptions,
31
+ BrowserOpenRequest,
32
+ BrowserPressKeyRequest,
33
+ BrowserProvider,
34
+ BrowserRefRequest,
35
+ BrowserScrapeRequest,
36
+ BrowserScrapeStatus,
37
+ BrowserScrollIntoViewRequest,
38
+ BrowserScrollRequest,
39
+ BrowserScrollResult,
40
+ BrowserSessionId,
41
+ BrowserSnapshotElement,
42
+ BrowserSnapshotResult,
43
+ BrowserSpaceInfo,
44
+ BrowserTab,
45
+ BrowserTaskInfo,
46
+ BrowserTaskStatus,
47
+ BrowserTaskUpdate,
48
+ BrowserUploadFileRequest,
49
+ BrowserUploadFileResult,
50
+ BrowserPdfRequest,
51
+ BrowserPdfResult,
52
+ BrowserHighlightRequest,
53
+ BrowserHighlightResult,
54
+ BrowserWaitForRequest,
55
+ BrowserWaitForResult,
56
+ BrowserTaskTodo,
57
+ ExportedCookie,
58
+ } from '../browser/types.ts'
59
+ import { BrowserError } from '../browser/types.ts'
60
+ import { PAGE_CHROME_HOST_ID, PAGE_CHROME_SCRIPT } from './page-chrome.ts'
61
+ import { defaultWriteRoots, resolveReadPath, resolveWritePath } from './write-guard.ts'
62
+
63
+ /**
64
+ * Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
65
+ * inside the page; returns `{ blocked, kind?, reason? }`. Marker-based and
66
+ * best-effort: checks for Cloudflare's interstitial, hCaptcha, reCAPTCHA,
67
+ * Turnstile, and generic challenge wording.
68
+ */
69
+ const CHALLENGE_DETECT_EXPRESSION = `(() => {
70
+ const title = (document.title || '').trim()
71
+ const bodyText = (document.body && document.body.innerText || '').slice(0, 4000)
72
+ const lower = (title + '\\n' + bodyText).toLowerCase()
73
+ const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
74
+ const framesLower = frameSrcs.toLowerCase()
75
+ const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl/i.test(lower)
76
+ || !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
77
+ const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
78
+ const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
79
+ const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile|challenge-platform/i.test(lower)
80
+ const verifyWording = /verify you are human|verify you are not a robot|\\u4eba\\u673a\\u9a8c\\u8bc1|\\u5b89\\u5168\\u9a8c\\u8bc1|enable javascript and cookies|\\u8bf7.*\\u9a8c\\u8bc1/i.test(lower)
81
+ if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
82
+ if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
83
+ if (hasRecaptcha) return { blocked: true, kind: 'recaptcha', reason: 'Google reCAPTCHA verification' }
84
+ if (hasTurnstile) return { blocked: true, kind: 'turnstile', reason: 'Cloudflare Turnstile verification' }
85
+ if (verifyWording && /challenge|captcha|verification|security check|access denied|blocked|\\u9a8c\\u8bc1/i.test(lower)) {
86
+ return { blocked: true, kind: 'generic', reason: 'Human-verification challenge' }
87
+ }
88
+ return { blocked: false }
89
+ })()`
90
+
91
+ /** Short suppression window so CDP input is not misclassified as physical user input. */
92
+ const AGENT_INPUT_SUPPRESSION_MS = 900
93
+
94
+ /** Stable provider id registered with `ctx.browser`. */
95
+ export const ELECTRON_BROWSER_PROVIDER_ID = 'electron'
96
+
97
+ /**
98
+ * One tab request raised by the injected chrome because a human used it.
99
+ *
100
+ * The host owns the pixels, the tab strip and the per-view secret, so it is the
101
+ * only party that can authenticate such a request; the provider owns the tab
102
+ * model, so it is the only party that may act on one. `tabId` is the host's own
103
+ * view id, which is also the provider's `ElectronViewHandle.id`, so no extra
104
+ * mapping table is needed.
105
+ */
106
+ export interface ChromeHostEvent {
107
+ /** For a 'move-tab' event: the index the tab was dropped at, after removal. */
108
+ readonly toIndex?: number
109
+ readonly type: 'new-tab' | 'close-tab' | 'activate-tab' | 'move-tab'
110
+ /** Task key of the chrome that raised it. */
111
+ readonly taskKey: string
112
+ /** Host view id of the tab; absent for `new-tab`. */
113
+ readonly tabId?: string
114
+ /** For `new-tab`: open this http(s) url in the new tab (a bookmark click). */
115
+ readonly url?: string
116
+ }
117
+
118
+ /**
119
+ * The minimal Electron surface this provider needs. Implemented by the
120
+ * desktop shell with a real `WebContentsView`; a fake implements it in tests.
121
+ */
122
+ export interface ElectronBrowserViewHost {
123
+ /**
124
+ * Subscribe to tab requests the host raises without being asked (a human
125
+ * clicking the chrome's own `+`, `×` or tab strip). Optional: a host whose
126
+ * chrome cannot speak first simply never raises one.
127
+ * @param listener - invoked once per authenticated chrome request.
128
+ */
129
+ onChromeEvent?(listener: (event: ChromeHostEvent) => void): void
130
+ /**
131
+ * Create a new browser view and return a handle to its webContents-like
132
+ * surface. `key` (default 'default') identifies an isolated browser task in
133
+ * the shared BrowserWindow; `label` names that task. The host owns view
134
+ * attachment, sizing, task visibility, and removal; the provider owns
135
+ * CDP-driven behavior.
136
+ */
137
+ createView(key?: string, label?: string): ElectronViewHandle
138
+ /**
139
+ * Destroy a view created by this host. Called on session close; idempotent
140
+ * for an already-destroyed view.
141
+ * @param handle - the handle returned by {@link createView}.
142
+ */
143
+ destroyView(handle: ElectronViewHandle): void
144
+ /**
145
+ * Notify the host that this session selected a tab. In the shared-window
146
+ * host, a background task updates its active view without changing the
147
+ * human-selected visible task. Optional for headless/probe hosts.
148
+ * @param handle - the handle selected by its session.
149
+ */
150
+ showView?(handle: ElectronViewHandle): void
151
+ /**
152
+ * Send a CDP `Input.*` command to the host's chrome frame view.
153
+ *
154
+ * The frame is not a tab and has no handle, so this is its own channel. It
155
+ * exists because the chrome can live in a view of its own (which is what lets
156
+ * the page viewport really shrink): input aimed at a page never reaches that
157
+ * view, and CDP input targets a webContents regardless of view stacking, so
158
+ * the page cannot stand in for it. Optional for hosts without a frame view.
159
+ * @param method - a CDP Input domain command, e.g. 'Input.dispatchMouseEvent'.
160
+ * @param params - that command's parameters.
161
+ */
162
+ chromeInput?(method: string, params?: Record<string, unknown>): Promise<void>
163
+ /**
164
+ * Evaluate an expression inside the chrome frame's own document.
165
+ *
166
+ * The frame is a view of its own, so no page-directed call can read it. This is
167
+ * how the tests assert the toolbar's own animations instead of inferring them
168
+ * from the page's copy of the chrome. Optional for hosts without a frame view.
169
+ * @param expression - JavaScript evaluated in the frame, by value.
170
+ */
171
+ chromeEval?(expression: string): Promise<unknown>
172
+ /**
173
+ * Append one operation to the human-facing trail for a view. Optional.
174
+ * @param viewId - the view to attribute the operation to.
175
+ * @param entry - the trail entry ({ action, params, ok, at }).
176
+ */
177
+ trace?(viewId: string, entry: unknown): void
178
+ /**
179
+ * Cheap local usability probe for this host. MUST NOT make network calls and
180
+ * MUST NOT throw (a throw is reported as unavailable). Optional: a host that
181
+ * omits it is assumed usable, which keeps a desktop shell's shell-owned
182
+ * viewHost and test fakes working. A self-hosted host reports false when the
183
+ * pinned Electron binary cannot be resolved, so the seam can pick another
184
+ * provider (BROWSER_PROVIDER_UNAVAILABLE / BROWSER_PROVIDER_AMBIGUOUS)
185
+ * instead of failing later on the first open().
186
+ */
187
+ isAvailable?(): boolean
188
+ /** List browser tasks with their labels. Legacy method name retained for compatibility. */
189
+ listWindows?(): Promise<Array<{ key: string; label: string }>>
190
+ /** List task summaries when the host exposes a visible workspace. */
191
+ listTasks?(): Promise<readonly BrowserTaskInfo[]>
192
+ /** Read one task summary from the visible workspace. */
193
+ getTask?(key: string): Promise<BrowserTaskInfo | undefined>
194
+ /** Apply a task status/control update to the visible workspace. */
195
+ updateTask?(key: string, update: BrowserTaskUpdate): Promise<BrowserTaskInfo | undefined>
196
+ /** Mirror one task's Agent todo list into the visible workspace (floating orb). */
197
+ setTaskTodos?(key: string, todos: readonly BrowserTaskTodo[]): Promise<void>
198
+ }
199
+
200
+ /**
201
+ * A CDP-capable view handle. This is the subset of Electron's
202
+ * `WebContents`/`WebContentsView` the provider drives; the shell's real
203
+ * implementation adapts `webContents.debugger` to it.
204
+ */
205
+ export interface ElectronViewHandle {
206
+ /** Unique id of the backing view, used for diagnostics. */
207
+ readonly id: string
208
+ /**
209
+ * Send one CDP command and resolve with its result. Rejects when the
210
+ * debugger is not attached or the command fails.
211
+ * @param method - CDP method, e.g. `Page.navigate`.
212
+ * @param params - CDP command parameters.
213
+ * @returns the CDP `result` object.
214
+ */
215
+ sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
216
+ /**
217
+ * Read the most recent auto-accepted JS dialog for this view (and clear it).
218
+ * Optional: hosts without JS-dialog supervision omit it.
219
+ * @returns the dialog detail ({ type, message, prompt? }) or null.
220
+ */
221
+ clearDialog?(): Promise<unknown>
222
+ /** Optional: hosts without JS-dialog supervision omit it. */
223
+ setDialogPolicy?(policy: DialogPolicy): Promise<unknown>
224
+ /** Optional: bounded console capture. */
225
+ readConsole?(clear?: boolean): Promise<unknown>
226
+ /** Optional: bounded network capture. */
227
+ readNetwork?(clear?: boolean): Promise<unknown>
228
+ /**
229
+ * Remove cookies matching a domain/name filter. Optional: hosts without a
230
+ * deletable cookie store omit it.
231
+ */
232
+ clearCookies?(filter: { readonly domain?: string; readonly name?: string; readonly all?: boolean }): Promise<{ readonly removed: number; readonly names: readonly string[] }>
233
+ /** Set this view's browser task label; it titles the shared window only when selected. Optional. */
234
+ label?(label: string): Promise<void>
235
+ /**
236
+ * Re-apply the host's own page chrome to the current document. Optional: a host
237
+ * that does not own the chrome omits it, and the provider then injects its own
238
+ * tokenless copy as a fallback.
239
+ */
240
+ reinstallChrome?(): Promise<void>
241
+ /**
242
+ * Print this view to a PDF and return it base64-encoded. Optional: a host
243
+ * with no native print path omits it, and the provider falls back to CDP.
244
+ *
245
+ * Electron's debugger does NOT implement CDP `Page.printToPDF` (it answers
246
+ * "'Page.printToPDF' wasn't found"), so the PDF path has to go through the
247
+ * host's own `webContents.printToPDF`.
248
+ * @param options - Electron PrintToPDFOptions (pageSize in microns).
249
+ */
250
+ printToPdf?(options: Record<string, unknown>): Promise<{ readonly base64: string }>
251
+ /**
252
+ * Raise this view's window (show + restore + focus). Optional: a host that
253
+ * owns no window omits it. Distinct from the provider's own show/switch
254
+ * path, which deliberately leaves a human-selected visible task alone.
255
+ */
256
+ focusWindow?(): Promise<void>
257
+ }
258
+
259
+ /** Internal selector and fingerprint captured for one snapshot element. */
260
+ interface SnapshotTarget {
261
+ /** iframe index when the target lives inside a frame. */
262
+ readonly frame?: number
263
+ readonly path: string
264
+ readonly fingerprint: string
265
+ }
266
+
267
+ /** One snapshot retained for exact reference operations. */
268
+ interface SnapshotRecord {
269
+ readonly tabId: string
270
+ readonly url: string
271
+ readonly epoch: number
272
+ readonly targets: ReadonlyMap<number, SnapshotTarget>
273
+ }
274
+
275
+ /** One tab inside a session: its view plus a stable id and short-lived refs. */
276
+ interface Tab {
277
+ readonly id: string
278
+ readonly handle: ElectronViewHandle
279
+ navigationEpoch: number
280
+ readonly snapshots: Map<string, SnapshotRecord>
281
+ }
282
+
283
+ /** Provider-local fallback state when a host has no visible workspace methods. */
284
+ interface LocalTaskState {
285
+ status: BrowserTaskStatus
286
+ control: BrowserControlOwner
287
+ latestAction?: string
288
+ error?: string
289
+ updatedAt: number
290
+ }
291
+
292
+ /** One live browser session: an ordered list of tabs, one active. */
293
+ interface Session {
294
+ readonly id: BrowserSessionId
295
+ readonly taskKey: string
296
+ taskLabel: string
297
+ readonly tabs: Tab[]
298
+ activeIndex: number
299
+ /** Chronological operation log (navigate/execute/click/type/fill/download/auth). */
300
+ readonly history: BrowserHistoryEntry[]
301
+ /** Monotonic sequence counter for history entries (survives truncation). */
302
+ nextSeq: number
303
+ /** The most recent JS dialog the host reported, kept for browser_dialog inspect. */
304
+ lastDialog?: unknown
305
+ /** How the host should answer the next JS dialog. Default: accept. */
306
+ dialogPolicy?: DialogPolicy
307
+ }
308
+
309
+ /**
310
+ * How the host answers a JS dialog (alert/confirm/prompt).
311
+ *
312
+ * A dialog freezes the renderer until it is answered, so the default stays
313
+ * `accept` - automation must never hang on one. `dismiss` is for pages whose
314
+ * confirmation is part of what is being driven (delete prompts and the like),
315
+ * and `promptText` supplies the value for a prompt.
316
+ */
317
+ /** One captured console message. */
318
+ export interface BrowserConsoleMessage { level: string; text: string; at: string }
319
+ /** One captured network request. */
320
+ export interface BrowserNetworkRequest {
321
+ method: string
322
+ url: string
323
+ status?: number
324
+ mime?: string
325
+ kind?: string
326
+ failed?: string
327
+ ms?: number
328
+ at: string
329
+ }
330
+
331
+ /** Device/viewport/media emulation for one tab (browser_emulate). */
332
+ export interface EmulateOptions {
333
+ readonly width?: number
334
+ readonly height?: number
335
+ readonly deviceScaleFactor?: number
336
+ readonly mobile?: boolean
337
+ readonly userAgent?: string
338
+ readonly colorScheme?: 'light' | 'dark' | 'no-preference'
339
+ /** Undo everything this tool set on the tab. */
340
+ readonly clear?: boolean
341
+ }
342
+
343
+ export interface DialogPolicy {
344
+ readonly behavior: 'accept' | 'dismiss'
345
+ readonly promptText?: string
346
+ }
347
+
348
+ /** Provider config: navigation admission defaults and snapshot caps. */
349
+ export interface ElectronBrowserProviderConfig {
350
+ /** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
351
+ readonly httpOnly?: boolean
352
+ /** Maximum snapshot elements before truncation. Default 60. */
353
+ readonly snapshotMaxElements?: number
354
+ /** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
355
+ readonly contentMaxChars?: number
356
+ /**
357
+ * Absolute directories a screenshot or download may write into. Defaults to
358
+ * the workspace and the OS temp directory ({@link defaultWriteRoots}); an
359
+ * empty list denies every write.
360
+ */
361
+ readonly writeRoots?: readonly string[]
362
+ /**
363
+ * Absolute directories `browser_upload_file` may read from. Defaults to the
364
+ * same roots as {@link writeRoots}; an empty list denies every upload.
365
+ */
366
+ readonly readRoots?: readonly string[]
367
+ }
368
+
369
+ /**
370
+ * CDP method/params for `Page.navigate`, as sent to {@link ElectronViewHandle.sendCommand}.
371
+ */
372
+ export interface CdpNavigateParams {
373
+ readonly url: string
374
+ }
375
+
376
+ /**
377
+ * CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
378
+ */
379
+ export interface CdpMouseParams {
380
+ readonly type: 'mousePressed' | 'mouseReleased' | 'mouseMoved'
381
+ readonly x: number
382
+ readonly y: number
383
+ readonly button: 'left' | 'right' | 'middle' | 'none'
384
+ readonly clickCount?: number
385
+ /** Buttons held during the event; 1 while a left drag is in flight. */
386
+ readonly buttons?: number
387
+ /** CDP modifier bitmask (Alt 1, Ctrl 2, Meta 4, Shift 8); see modifierMask. */
388
+ readonly modifiers?: number
389
+ }
390
+
391
+ /** CDP method/params for `Input.insertText`. */
392
+ export interface CdpInsertTextParams {
393
+ readonly text: string
394
+ }
395
+
396
+ /** CDP method/params for `Runtime.evaluate`. */
397
+ export interface CdpEvaluateParams {
398
+ readonly expression: string
399
+ readonly returnByValue: boolean
400
+ readonly awaitPromise?: boolean
401
+ }
402
+
403
+ /** CDP method for a full-page screenshot capture. */
404
+ export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot'
405
+ /** CDP method that renders the current document to PDF. */
406
+ export const CDP_PAGE_PRINT_TO_PDF = 'Page.printToPDF'
407
+ /** CDP methods that draw the DevTools highlight box without touching the DOM. */
408
+ export const CDP_OVERLAY_ENABLE = 'Overlay.enable'
409
+ export const CDP_OVERLAY_HIGHLIGHT_NODE = 'Overlay.highlightNode'
410
+ export const CDP_OVERLAY_HIDE_HIGHLIGHT = 'Overlay.hideHighlight'
411
+ /** CDP method for runtime evaluation (the execute path). */
412
+ export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate'
413
+
414
+ /**
415
+ * Decide how to hand a script to `Runtime.evaluate`.
416
+ *
417
+ * CDP evaluates an *expression*, so a script made of statements is a syntax
418
+ * error there. Try the expression form first - that keeps bare expressions and
419
+ * object literals returning their value, which is what the tool has always
420
+ * done - then fall back to a statement body. `const x = 1; return x` is the
421
+ * shape people actually type, and it used to come back as a bare SyntaxError.
422
+ */
423
+ export function buildEvaluateBody(script: string): { body: string } | { error: string } {
424
+ try {
425
+ // Parses only; nothing is executed here.
426
+ new Function(`return (${script})`)
427
+ return { body: `return (${script})` }
428
+ } catch { /* not an expression - try it as a body */ }
429
+ try {
430
+ new Function(script)
431
+ return { body: script }
432
+ } catch (error) {
433
+ return { error: error instanceof Error ? error.message : String(error) }
434
+ }
435
+ }
436
+
437
+ /** CDP method for keyboard input. */
438
+ export const CDP_INPUT_DISPATCH_KEY_EVENT = 'Input.dispatchKeyEvent'
439
+ /**
440
+ * True when an input command's target view is already gone.
441
+ *
442
+ * The page's own chrome handles Ctrl+W/Ctrl+T by asking the provider to close or
443
+ * open a tab, so the key dispatch that triggered it is still in flight when the
444
+ * view disappears: the host answers `unknown view`, or CDP says the target closed.
445
+ * Measured on the real machine - create and destroy of one view 1.5s apart, then
446
+ * this error on the Ctrl+W that caused the destroy.
447
+ */
448
+ function isClosedInputTarget(error: unknown): boolean {
449
+ if (!(error instanceof Error)) return false
450
+ return /target closed|unknown view/i.test(error.message)
451
+ }
452
+
453
+ const KEY_VK: Record<string, number> = {
454
+ Backspace: 8,
455
+ Tab: 9,
456
+ Enter: 13,
457
+ Shift: 16,
458
+ Control: 17,
459
+ Alt: 18,
460
+ CapsLock: 20,
461
+ Escape: 27,
462
+ Space: 32,
463
+ PageUp: 33,
464
+ PageDown: 34,
465
+ End: 35,
466
+ Home: 36,
467
+ ArrowLeft: 37,
468
+ ArrowUp: 38,
469
+ ArrowRight: 39,
470
+ ArrowDown: 40,
471
+ Insert: 45,
472
+ Delete: 46,
473
+ Meta: 91,
474
+ F1: 112,
475
+ F2: 113,
476
+ F3: 114,
477
+ F4: 115,
478
+ F5: 116,
479
+ F6: 117,
480
+ F7: 118,
481
+ F8: 119,
482
+ F9: 120,
483
+ F10: 121,
484
+ F11: 122,
485
+ F12: 123,
486
+ }
487
+
488
+ /** Printable ASCII with no KEY_VK entry, mapped to its US-layout position. */
489
+ const PRINTABLE_CODES: Record<string, { code: string; vk: number }> = {
490
+ '-': { code: 'Minus', vk: 189 },
491
+ '=': { code: 'Equal', vk: 187 },
492
+ '[': { code: 'BracketLeft', vk: 219 },
493
+ ']': { code: 'BracketRight', vk: 221 },
494
+ ';': { code: 'Semicolon', vk: 186 },
495
+ "'": { code: 'Quote', vk: 222 },
496
+ ',': { code: 'Comma', vk: 188 },
497
+ '.': { code: 'Period', vk: 190 },
498
+ '/': { code: 'Slash', vk: 191 },
499
+ '`': { code: 'Backquote', vk: 192 },
500
+ }
501
+
502
+ /** True for the ASCII range a key event can carry as text. */
503
+ function isPrintable(key: string): boolean {
504
+ if (key.length !== 1) return false
505
+ const code = key.charCodeAt(0)
506
+ return code >= 0x20 && code <= 0x7e
507
+ }
508
+
509
+ function keyText(key: string): string | null {
510
+ switch (key) {
511
+ case 'Enter': return '\r'
512
+ case 'Tab': return '\t'
513
+ case 'Space': return ' '
514
+ default: return isPrintable(key) ? key : null
515
+ }
516
+ }
517
+
518
+ function keyDescriptor(key: string): { key: string; code: string; vk: number } {
519
+ const upper = key.toUpperCase()
520
+ // A physical Space produces e.key === ' ' with code 'Space'.
521
+ if (key === 'Space') {
522
+ return { key: ' ', code: 'Space', vk: KEY_VK.Space }
523
+ }
524
+ if (KEY_VK[key] !== undefined) {
525
+ return { key, code: key, vk: KEY_VK[key] }
526
+ }
527
+ if (/^[a-z]$/i.test(key)) {
528
+ // Unshifted letters deliver e.key lowercase; the code keeps the physical form.
529
+ return { key, code: `Key${upper}`, vk: upper.charCodeAt(0) }
530
+ }
531
+ if (/^[0-9]$/.test(key)) {
532
+ return { key, code: `Digit${key}`, vk: key.charCodeAt(0) }
533
+ }
534
+ if (isPrintable(key)) {
535
+ // Punctuation carries its US-layout position so shortcuts such as Ctrl+- and
536
+ // Ctrl+/ reach the page; anything else printable falls back to its own code.
537
+ const known = PRINTABLE_CODES[key]
538
+ return known === undefined
539
+ ? { key, code: key, vk: key.toUpperCase().charCodeAt(0) }
540
+ : { key, code: known.code, vk: known.vk }
541
+ }
542
+ throw new BrowserError(`browser: unsupported key "${key}"`, 'BROWSER_KEY_UNKNOWN')
543
+ }
544
+
545
+ function modifierMask(modifiers: readonly ('alt' | 'ctrl' | 'meta' | 'shift')[] | undefined): number {
546
+ let mask = 0
547
+ for (const mod of modifiers ?? []) {
548
+ if (mod === 'alt') mask |= 1
549
+ else if (mod === 'ctrl') mask |= 2
550
+ else if (mod === 'meta') mask |= 4
551
+ else if (mod === 'shift') mask |= 8
552
+ }
553
+ return mask
554
+ }
555
+ /** CDP method for navigation. */
556
+ export const CDP_PAGE_NAVIGATE = 'Page.navigate'
557
+ /** CDP methods used by native browser navigation controls. */
558
+ export const CDP_PAGE_GET_NAVIGATION_HISTORY = 'Page.getNavigationHistory'
559
+ export const CDP_PAGE_NAVIGATE_TO_HISTORY_ENTRY = 'Page.navigateToHistoryEntry'
560
+ export const CDP_PAGE_RELOAD = 'Page.reload'
561
+ export const CDP_PAGE_STOP_LOADING = 'Page.stopLoading'
562
+
563
+ /** Cap on content returned by a snapshot fetch to keep the wire bounded. */
564
+
565
+ /** An input dispatch must not outlive this: a blocked renderer never acknowledges. */
566
+ const INPUT_DISPATCH_TIMEOUT_MS = 15_000
567
+
568
+ /**
569
+ * Views whose renderer has already been told to consider itself focused.
570
+ * Weak so a destroyed view does not keep its handle alive.
571
+ */
572
+ const focusEmulatedViews = new WeakSet<ElectronViewHandle>()
573
+
574
+ /** Gap between the move events of a drag; enough to span several frames. */
575
+ const DRAG_STEP_DELAY_MS = 8
576
+
577
+ /** Upper bound on concurrent scrape workers; each one costs a tab. */
578
+ const MAX_SCRAPE_WORKERS = 8
579
+
580
+ /** Mutable progress for one background scrape batch. */
581
+ interface ScrapeJob {
582
+ readonly id: string
583
+ readonly session: Session
584
+ /**
585
+ * The batch's own tabs, created at start and destroyed when it ends. One per
586
+ * worker. Deliberately never activated: a batch must not race a tool call for
587
+ * the session's active tab, and it must not navigate away from the page the
588
+ * human was reading.
589
+ */
590
+ readonly tabs: readonly Tab[]
591
+ readonly path: string
592
+ state: 'running' | 'done' | 'stopped'
593
+ readonly total: number
594
+ done: number
595
+ failed: number
596
+ error?: string
597
+ }
598
+
599
+ /** The immutable view of a job the seam hands out. */
600
+ function scrapeStatusOf(job: ScrapeJob): BrowserScrapeStatus {
601
+ return {
602
+ id: job.id,
603
+ state: job.state,
604
+ total: job.total,
605
+ done: job.done,
606
+ failed: job.failed,
607
+ path: job.path,
608
+ ...job.error === undefined ? {} : { error: job.error },
609
+ }
610
+ }
611
+
612
+ /**
613
+ * One JSONL row. A page can return a value JSON cannot carry (a circular object,
614
+ * a BigInt); that must not kill a batch that has already written hundreds of rows.
615
+ */
616
+ function scrapeRow(row: Record<string, unknown>): string {
617
+ try {
618
+ return JSON.stringify(row) + '\n'
619
+ } catch (error) {
620
+ return JSON.stringify({
621
+ url: row.url,
622
+ ok: false,
623
+ error: `unserializable result: ${String((error as Error)?.message ?? error)}`,
624
+ }) + '\n'
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Normalize one entry of a cookie export, or undefined when it cannot be used.
630
+ *
631
+ * Our own flushAuth emits `url`. Browser cookie editors (Cookie-Editor,
632
+ * EditThisCookie) and Edge's own export emit `domain` + `path` and no `url` at
633
+ * all — so requiring `url` rejected a file straight out of a browser wholesale,
634
+ * which is exactly the workflow this feature exists for. cookies.set wants a
635
+ * URL, so derive one when only the domain is present.
636
+ */
637
+ function normalizeExportedCookie(value: unknown): ExportedCookie | undefined {
638
+ if (typeof value !== 'object' || value === null) return undefined
639
+ const record = value as Record<string, unknown>
640
+ if (typeof record.name !== 'string' || typeof record.value !== 'string') return undefined
641
+ const path = typeof record.path === 'string' && record.path.startsWith('/') ? record.path : '/'
642
+ const url = typeof record.url === 'string' && record.url !== ''
643
+ ? record.url
644
+ : typeof record.domain === 'string' && record.domain !== ''
645
+ // A leading dot marks a domain-wide cookie; the URL host must not carry it.
646
+ ? `${record.secure === true ? 'https' : 'http'}://${record.domain.replace(/^\./, '')}${path}`
647
+ : undefined
648
+ if (url === undefined) return undefined
649
+ const sameSite = normalizeSameSite(record.sameSite)
650
+ return {
651
+ url,
652
+ name: record.name,
653
+ value: record.value,
654
+ ...typeof record.domain === 'string' ? { domain: record.domain } : {},
655
+ ...typeof record.path === 'string' ? { path: record.path } : {},
656
+ ...typeof record.secure === 'boolean' ? { secure: record.secure } : {},
657
+ ...typeof record.httpOnly === 'boolean' ? { httpOnly: record.httpOnly } : {},
658
+ ...typeof record.expirationDate === 'number' ? { expirationDate: record.expirationDate } : {},
659
+ ...sameSite === undefined ? {} : { sameSite },
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Cookie editors emit Playwright's spelling (None/Lax/Strict) and Chromium's
665
+ * (no_restriction/lax/strict). cookies.set wants the latter.
666
+ */
667
+ function normalizeSameSite(value: unknown): ExportedCookie['sameSite'] {
668
+ if (typeof value !== 'string') return undefined
669
+ switch (value.toLowerCase()) {
670
+ case 'none':
671
+ case 'no_restriction': return 'no_restriction'
672
+ case 'lax': return 'lax'
673
+ case 'strict': return 'strict'
674
+ case 'unspecified': return 'unspecified'
675
+ default: return undefined
676
+ }
677
+ }
678
+
679
+ /** Total budget for the snapshot's empty-inventory retries. */
680
+ const SNAPSHOT_RETRY_BUDGET_MS = 3_000
681
+
682
+ /**
683
+ * Longest script or typed text kept in one history entry. Entries exist to be
684
+ * replayed, so an over-long value is stored clipped and marked: replay then
685
+ * refuses outright rather than re-issuing a silently shortened script.
686
+ */
687
+ const HISTORY_PARAM_MAX_CHARS = 32_768
688
+
689
+ /** Clip the replay payloads that would otherwise pin unbounded text in memory. */
690
+ function clampHistoryParams(params: Record<string, unknown>): Record<string, unknown> {
691
+ const tooLong = (value: unknown): value is string => typeof value === 'string' && value.length > HISTORY_PARAM_MAX_CHARS
692
+ if (!tooLong(params.script) && !tooLong(params.text)) return params
693
+ const clipped: Record<string, unknown> = { ...params }
694
+ if (tooLong(params.script)) {
695
+ clipped.script = params.script.slice(0, HISTORY_PARAM_MAX_CHARS)
696
+ clipped.scriptTruncated = true
697
+ }
698
+ if (tooLong(params.text)) {
699
+ clipped.text = params.text.slice(0, HISTORY_PARAM_MAX_CHARS)
700
+ clipped.textTruncated = true
701
+ }
702
+ return clipped
703
+ }
704
+
705
+ /**
706
+ * Browser provider over Electron views. Sessions hold an ordered list of
707
+ * tabs; each tab is one view created by the host. The active tab receives
708
+ * every operation; switching tabs calls the host's optional `showView` and
709
+ * never loses state. Navigation is admitted only for HTTP(S) targets unless
710
+ * {@link ElectronBrowserProviderConfig.httpOnly} is disabled.
711
+ */
712
+ export class ElectronBrowserProvider implements BrowserProvider {
713
+ readonly id = ELECTRON_BROWSER_PROVIDER_ID
714
+
715
+ private readonly sessions = new Map<BrowserSessionId, Session>()
716
+ /** Stable task-key index so callers can recover a session after tool-layer state loss. */
717
+ private readonly sessionsByTask = new Map<string, BrowserSessionId>()
718
+ private readonly taskStates = new Map<string, LocalTaskState>()
719
+ /**
720
+ * The Agent's todo list per task, kept here as well as in the host.
721
+ *
722
+ * The host's copy is in memory and dies with the host process, and the bridge
723
+ * only pushes on a todo change — so without this cache a host restart left the
724
+ * floating orb with no plan until the Agent happened to write its list again.
725
+ */
726
+ private readonly taskTodos = new Map<string, readonly BrowserTaskTodo[]>()
727
+ private readonly httpOnly: boolean
728
+ private readonly snapshotMaxElements: number
729
+ private readonly contentMaxChars: number
730
+ private readonly writeRoots: readonly string[]
731
+ private readonly readRoots: readonly string[]
732
+ /** Background scrape batches, keyed by id; rows live on disk, not here. */
733
+ private readonly scrapes = new Map<string, ScrapeJob>()
734
+
735
+ constructor(
736
+ private readonly host: ElectronBrowserViewHost,
737
+ config: ElectronBrowserProviderConfig = {},
738
+ ) {
739
+ this.httpOnly = config.httpOnly ?? true
740
+ this.snapshotMaxElements = config.snapshotMaxElements ?? 60
741
+ this.contentMaxChars = config.contentMaxChars ?? 100_000
742
+ this.writeRoots = config.writeRoots ?? defaultWriteRoots()
743
+ this.readRoots = config.readRoots ?? defaultWriteRoots()
744
+ // A human clicking the injected chrome's own tabs is the one case where the
745
+ // host must tell the provider something: the host can show a different view,
746
+ // but only the provider owns the session's tab list and active index.
747
+ this.host.onChromeEvent?.(event => this.handleChromeEvent(event))
748
+ }
749
+
750
+ /**
751
+ * Apply one authenticated chrome request to the session that owns its task.
752
+ *
753
+ * Every field is re-checked here: the host authenticates the sender, this
754
+ * method decides whether the request still makes sense against the live tab
755
+ * model. A request that resolves to nothing (a stale strip, a tab closed a
756
+ * moment ago, a task with no session) is dropped rather than thrown, because
757
+ * a human click must never surface as an error inside a running tool call.
758
+ */
759
+ private handleChromeEvent(event: ChromeHostEvent): void {
760
+ if (typeof event !== 'object' || event === null) return
761
+ if (typeof event.taskKey !== 'string' || event.taskKey === '') return
762
+ const sessionId = this.sessionsByTask.get(event.taskKey)
763
+ if (sessionId === undefined) return
764
+ const s = this.sessions.get(sessionId)
765
+ if (s === undefined) return
766
+ try {
767
+ if (event.type === 'new-tab') {
768
+ this.newTab(s)
769
+ // 点收藏栏来的新标签会带 url —— 建完再导航(新标签已经是 active)。
770
+ if (typeof event.url === 'string' && event.url !== '') {
771
+ void this.navigate(sessionId, { url: event.url }).catch(() => undefined)
772
+ }
773
+ return
774
+ }
775
+ if (typeof event.tabId !== 'string') return
776
+ const tab = s.tabs.find(candidate => candidate.handle.id === event.tabId)
777
+ if (tab === undefined) return
778
+ if (event.type === 'close-tab') {
779
+ void this.closeTab(sessionId, tab.id)
780
+ return
781
+ }
782
+ if (event.type === 'move-tab' && typeof event.toIndex === 'number') {
783
+ // The human dragged this tab: mirror the host's order so the session's tab
784
+ // list (what browser_list_tabs reports) matches the strip.
785
+ const from = s.tabs.indexOf(tab)
786
+ if (from >= 0) {
787
+ const active = s.tabs[s.activeIndex]
788
+ const [moved] = s.tabs.splice(from, 1)
789
+ const to = Math.max(0, Math.min(Math.trunc(event.toIndex), s.tabs.length))
790
+ s.tabs.splice(to, 0, moved)
791
+ // activeIndex is positional, so keep it pointing at the same tab.
792
+ const activeNow = s.tabs.indexOf(active)
793
+ if (activeNow >= 0) s.activeIndex = activeNow
794
+ }
795
+ return
796
+ }
797
+ if (event.type === 'activate-tab') {
798
+ const index = s.tabs.indexOf(tab)
799
+ if (index >= 0) s.activeIndex = index
800
+ }
801
+ } catch {
802
+ // A chrome request is best-effort: never let one break the provider.
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Usable whenever the host can create views. A host that exposes a local
808
+ * {@link ElectronBrowserViewHost.isAvailable} probe is believed; a host that
809
+ * omits it (a desktop shell's known-good viewHost, or a test fake) is assumed
810
+ * usable. The probe is cheap and local, so this stays callable from the seam's
811
+ * provider-selection path; the host owns any caching it needs.
812
+ */
813
+ available(): boolean {
814
+ const probe = this.host.isAvailable
815
+ if (typeof probe !== 'function') return true
816
+ try {
817
+ return probe.call(this.host) === true
818
+ } catch {
819
+ // A probe that throws (e.g. a binary resolver error) means "not usable";
820
+ // provider selection must never surface that error itself.
821
+ return false
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Open or recover the browser session for a task key. The tool layer normally
827
+ * caches this id, but the Provider is authoritative so a scoped tool reload or
828
+ * a lost cache cannot create a second task session with a different tab set.
829
+ * Sessions keep isolated tabs, active tab, and history while the host keeps one
830
+ * human-selected task view visible in the shared BrowserWindow.
831
+ */
832
+ async open(options?: BrowserOpenOptions): Promise<BrowserSessionId> {
833
+ const taskKey = options?.key ?? 'default'
834
+ const taskLabel = options?.label ?? ''
835
+ const existing = this.sessionForTask(taskKey)
836
+ if (existing !== undefined) {
837
+ if (taskLabel !== '' && existing.taskLabel !== taskLabel) {
838
+ existing.taskLabel = taskLabel
839
+ const active = existing.tabs[existing.activeIndex]?.handle
840
+ const labelable = active as { label?(label: string): Promise<void> } | undefined
841
+ if (typeof labelable?.label === 'function') await labelable.label(taskLabel).catch(() => undefined)
842
+ }
843
+ // 宿主重启后它内存里那份计划是空的,而这是**已存在会话**唯一会走到的入口
844
+ // (ensureWindowVisible 也补推一次)—— 不推的话球上会一直空着。
845
+ const cached = this.taskTodos.get(taskKey)
846
+ if (cached !== undefined && typeof this.host.setTaskTodos === 'function') {
847
+ void this.host.setTaskTodos(taskKey, cached).catch(() => undefined)
848
+ }
849
+ return existing.id
850
+ }
851
+ const handle = this.host.createView(taskKey, taskLabel === '' ? undefined : taskLabel)
852
+ const id = `browser:${randomUUID()}`
853
+ this.sessions.set(id, { id, taskKey, taskLabel, tabs: [this.createTab(handle)], activeIndex: 0, history: [], nextSeq: 1 })
854
+ this.sessionsByTask.set(taskKey, id)
855
+ // A fresh view means a host that may never have seen this task's plan (a host
856
+ // restart drops the host's copy), so replay what the Agent last wrote.
857
+ const cachedTodos = this.taskTodos.get(taskKey)
858
+ if (cachedTodos !== undefined && typeof this.host.setTaskTodos === 'function') {
859
+ void this.host.setTaskTodos(taskKey, cachedTodos).catch(() => undefined)
860
+ }
861
+ if (!this.taskStates.has(taskKey)) {
862
+ this.taskStates.set(taskKey, { status: 'idle', control: 'agent', updatedAt: Date.now() })
863
+ }
864
+ return id
865
+ }
866
+
867
+ /** Open a URL in the active tab (default) or a new tab. */
868
+ async openUrl(session: BrowserSessionId, request: BrowserOpenRequest, signal?: AbortSignal): Promise<void> {
869
+ const s = this.session(session)
870
+ if (request.newTab === true) {
871
+ this.newTab(s)
872
+ }
873
+ await this.navigate(session, { url: request.url }, signal)
874
+ }
875
+
876
+ /** List the session's tabs with their titles. */
877
+ async listTabs(session: BrowserSessionId): Promise<readonly BrowserTab[]> {
878
+ const s = this.session(session)
879
+ const result: BrowserTab[] = []
880
+ for (let i = 0; i < s.tabs.length; i++) {
881
+ const tab = s.tabs[i]
882
+ if (tab === undefined) continue // defensive: array can shift under concurrency
883
+ result.push({
884
+ id: tab.id,
885
+ url: await this.currentUrl(tab.handle).catch(() => ''),
886
+ active: i === s.activeIndex,
887
+ })
888
+ }
889
+ return result
890
+ }
891
+
892
+ /** Switch to a tab by id; background task tabs stay hidden until user-selected. */
893
+ switchTab(session: BrowserSessionId, tabId: string): Promise<void> {
894
+ const s = this.session(session)
895
+ const index = s.tabs.findIndex(tab => tab.id === tabId)
896
+ if (index < 0) {
897
+ throw new BrowserError(`browser: tab "${tabId}" is not open in this session`, 'BROWSER_TAB_UNKNOWN')
898
+ }
899
+ s.activeIndex = index
900
+ this.showActive(s)
901
+ return Promise.resolve()
902
+ }
903
+
904
+ /**
905
+ * Close one tab; closing the active tab activates the next. Resolves false when
906
+ * the id is not open in this session, so a miss is distinguishable from a close.
907
+ */
908
+ async closeTab(session: BrowserSessionId, tabId: string): Promise<boolean> {
909
+ const s = this.session(session)
910
+ const index = s.tabs.findIndex(tab => tab.id === tabId)
911
+ if (index < 0) return Promise.resolve(false) // idempotent
912
+ const removed = s.tabs[index]
913
+ if (removed !== undefined) {
914
+ s.tabs.splice(index, 1)
915
+ this.ignoreHostFailure(this.host.destroyView(removed.handle))
916
+ }
917
+ if (s.tabs.length === 0) {
918
+ // Session keeps one blank tab so it stays usable. **必须等**:newTab 建宿主视图是异步的,
919
+ // 先 showActive 会去显示一个还不存在的视图 → 宿主报 unknown view(而且是条没人接的 rejection,
920
+ // 会串到别的工具调用上报错)。
921
+ await this.newTab(s)
922
+ } else if (index < s.activeIndex) {
923
+ // Closing a tab before the active one shifts the array left; keep the
924
+ // same tab active by decrementing the index.
925
+ s.activeIndex -= 1
926
+ } else if (s.activeIndex >= s.tabs.length) {
927
+ // The active tab itself was closed; activate the last remaining one.
928
+ s.activeIndex = s.tabs.length - 1
929
+ }
930
+ this.showActive(s)
931
+ return true
932
+ }
933
+
934
+ /** Close every tab and reset to one blank tab. */
935
+ reset(session: BrowserSessionId): Promise<void> {
936
+ const s = this.session(session)
937
+ for (const tab of s.tabs) this.ignoreHostFailure(this.host.destroyView(tab.handle))
938
+ s.tabs.length = 0
939
+ this.newTab(s)
940
+ s.activeIndex = 0
941
+ this.showActive(s)
942
+ return Promise.resolve()
943
+ }
944
+
945
+ /**
946
+ * Dispatch one input command under the same hang guard as the CDP reads. A
947
+ * renderer blocked in synchronous JS never acknowledges, so an unbounded await
948
+ * here would hang the tool call until the caller's budget expired.
949
+ * @param handle - the view to dispatch into.
950
+ * @param method - the CDP input method.
951
+ * @param params - its parameters.
952
+ * @param signal - optional caller signal.
953
+ */
954
+ private async dispatchInput(
955
+ handle: ElectronViewHandle,
956
+ method: string,
957
+ params: Record<string, unknown>,
958
+ signal?: AbortSignal,
959
+ ): Promise<void> {
960
+ try {
961
+ await this.ensureInputFocus(handle)
962
+ await withTimeout(
963
+ handle.sendCommand(method, params),
964
+ INPUT_DISPATCH_TIMEOUT_MS,
965
+ signal,
966
+ `browser: ${method} timed out after ${INPUT_DISPATCH_TIMEOUT_MS}ms`,
967
+ )
968
+ } catch (error) {
969
+ // A tab closed under the keystroke that closed it is the requested outcome,
970
+ // not a failure: there is nothing left for this event to act on.
971
+ if (!isClosedInputTarget(error)) throw error
972
+ }
973
+ }
974
+
975
+ /**
976
+ * Tell a renderer it is focused, once, before synthesized input.
977
+ *
978
+ * Chromium drops a synthesized mouse *press* when the renderer does not
979
+ * believe it has focus — which is the normal state for a background task's
980
+ * view, and on a real page even for the visible one while its window is not
981
+ * active. Moves are not gated, so hover looked fine while every click
982
+ * resolved its target, reported success, and left the page untouched.
983
+ *
984
+ * Focus emulation keeps this on the trusted CDP input path: no synthetic
985
+ * DOM click, so the events stay isTrusted and nothing about the page's
986
+ * view of the browser changes.
987
+ */
988
+ private async ensureInputFocus(handle: ElectronViewHandle): Promise<void> {
989
+ if (focusEmulatedViews.has(handle)) return
990
+ await handle.sendCommand('Emulation.setFocusEmulationEnabled', { enabled: true })
991
+ .then(() => { focusEmulatedViews.add(handle) })
992
+ .catch(() => undefined)
993
+ }
994
+
995
+ /**
996
+ * Admit one URL for a provider-driven fetch (navigation or download).
997
+ * The whole check is gated by `httpOnly`: when it is disabled, callers are
998
+ * trusted with any scheme. When it is enabled, only HTTP(S) is admitted and
999
+ * URL-embedded credentials are refused, so a target can never be reached
1000
+ * with in-URL auth.
1001
+ * @param url - the candidate URL.
1002
+ * @param subject - the operation name used in the error text.
1003
+ */
1004
+ private admitUrl(url: string, subject: 'navigation' | 'download'): void {
1005
+ if (!this.httpOnly) return
1006
+ let parsed: URL
1007
+ try {
1008
+ parsed = new URL(url)
1009
+ } catch {
1010
+ throw new BrowserError(`browser: refusing ${subject} to unparseable URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED')
1011
+ }
1012
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1013
+ throw new BrowserError(`browser: refusing ${subject} to non-HTTP(S) URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED')
1014
+ }
1015
+ if (parsed.username !== '' || parsed.password !== '') {
1016
+ throw new BrowserError(`browser: refusing ${subject} to a URL with embedded credentials`, 'BROWSER_NAVIGATION_BLOCKED')
1017
+ }
1018
+ }
1019
+
1020
+ /** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
1021
+ async navigate(session: BrowserSessionId, request: { readonly url: string }, signal?: AbortSignal): Promise<void> {
1022
+ const s = this.session(session)
1023
+ return this.navigateTab(s, this.activeTab(s), request.url, signal)
1024
+ }
1025
+
1026
+ /**
1027
+ * Navigate one tab. A scrape worker passes its own tab so a batch never races
1028
+ * a tool call for the session's active tab.
1029
+ * @param show - bring the tab to the front; a background worker passes false.
1030
+ * @param settleMs - post-ready paint delay; a DOM-only reader passes 0.
1031
+ */
1032
+ private async navigateTab(s: Session, tab: Tab, url: string, signal?: AbortSignal, show = true, settleMs = 250): Promise<void> {
1033
+ const { handle } = tab
1034
+ try {
1035
+ this.admitUrl(url, 'navigation')
1036
+ signal?.throwIfAborted()
1037
+ // Page.navigate can hang on an unreachable/slow host; bound it like the
1038
+ // evaluate paths so a wedged navigation surfaces as an error instead of
1039
+ // blocking the tool call forever.
1040
+ const timeoutMs = 30_000
1041
+ const result = await withTimeout(
1042
+ handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams),
1043
+ timeoutMs,
1044
+ signal,
1045
+ `browser: navigation timed out after ${timeoutMs}ms`,
1046
+ )
1047
+ // Page.navigate resolves even when the navigation fails; surface the
1048
+ // failure instead of leaving a silent white screen.
1049
+ const errorText = (result as { errorText?: string }).errorText
1050
+ if (typeof errorText === 'string' && errorText !== '') {
1051
+ throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
1052
+ }
1053
+ this.invalidateSnapshots(tab)
1054
+ this.record(s, 'navigate', { url }, true)
1055
+ if (show) this.showActive(s)
1056
+ // Page.navigate resolves on commit. Wait best-effort for page load so
1057
+ // browser_open does not snapshot a still-blank renderer.
1058
+ await waitForDocumentReady(handle, signal, settleMs)
1059
+ // Human chrome is page-injected: reapply after every document commit so
1060
+ // the toolbar is present after each navigation.
1061
+ void reinstallPageChrome(handle)
1062
+ } catch (error) {
1063
+ if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
1064
+ this.record(s, 'navigate', { url }, false, { error: String(error) })
1065
+ }
1066
+ throw error
1067
+ }
1068
+ }
1069
+
1070
+ /** Navigate to the previous history entry when one exists. */
1071
+ async back(session: BrowserSessionId, signal?: AbortSignal): Promise<boolean> {
1072
+ return this.navigateHistory(session, -1, 'back', signal)
1073
+ }
1074
+
1075
+ /** Navigate to the next history entry when one exists. */
1076
+ async forward(session: BrowserSessionId, signal?: AbortSignal): Promise<boolean> {
1077
+ return this.navigateHistory(session, 1, 'forward', signal)
1078
+ }
1079
+
1080
+ /** Reload the active page and restore the browser chrome afterwards. */
1081
+ async reload(session: BrowserSessionId, signal?: AbortSignal): Promise<void> {
1082
+ const s = this.session(session)
1083
+ const tab = this.activeTab(s)
1084
+ signal?.throwIfAborted()
1085
+ await withTimeout(tab.handle.sendCommand(CDP_PAGE_RELOAD, {}), 30_000, signal, 'browser: reload timed out after 30000ms')
1086
+ this.invalidateSnapshots(tab)
1087
+ this.record(s, 'reload', {}, true)
1088
+ this.showActive(s)
1089
+ await waitForDocumentReady(tab.handle, signal)
1090
+ void reinstallPageChrome(tab.handle)
1091
+ }
1092
+
1093
+ /** Stop loading the active page. */
1094
+ async stopLoading(session: BrowserSessionId, signal?: AbortSignal): Promise<void> {
1095
+ const s = this.session(session)
1096
+ const { handle } = this.activeTab(s)
1097
+ signal?.throwIfAborted()
1098
+ await withTimeout(handle.sendCommand(CDP_PAGE_STOP_LOADING, {}), 10_000, signal, 'browser: stop loading timed out after 10000ms')
1099
+ this.record(s, 'stop', {}, true)
1100
+ }
1101
+
1102
+ /** Execute JS in the active tab's page context. */
1103
+ async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
1104
+ const s = this.session(session)
1105
+ return this.executeTab(s, this.activeTab(s), request, signal)
1106
+ }
1107
+
1108
+ /** Evaluate in one tab's page context. */
1109
+ private async executeTab(s: Session, tab: Tab, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
1110
+ const { handle } = tab
1111
+ signal?.throwIfAborted()
1112
+ await this.drainDialog(s, handle)
1113
+ try {
1114
+ // Wrap the script in a Function so `return` statements are legal and
1115
+ // request.args arrive as `arguments[0..n]` (a real function, not an
1116
+ // arrow, so `arguments` resolves). Args are embedded as a JSON array
1117
+ // literal; unserializable members become null.
1118
+ const built = buildEvaluateBody(request.script)
1119
+ if ('error' in built) {
1120
+ const exception = `browser: execute could not parse the script as an expression or as a statement body: ${built.error}`
1121
+ this.record(s, 'execute', { script: request.script }, false, { error: exception })
1122
+ return { ok: false, exception }
1123
+ }
1124
+ const body = built.body
1125
+ const hasArgs = request.args !== undefined && request.args.length > 0
1126
+ const expression = hasArgs
1127
+ ? `(function(){ const __dshArgs = ${JSON.stringify(request.args)}; return Function(${JSON.stringify(body)}).apply(null, __dshArgs) })()`
1128
+ : `(function(){ return Function(${JSON.stringify(body)})() })()`
1129
+ // CDP Runtime.evaluate can hang indefinitely on a not-yet-loaded page
1130
+ // (navigate returned but the renderer has not committed). Bound it so a
1131
+ // stuck call surfaces as BROWSER_EXECUTE_TIMEOUT instead of wedging the
1132
+ // whole tool call. The caller's signal wins when it fires first.
1133
+ const timeoutMs = request.timeoutMs ?? 30_000
1134
+ const result = await withTimeout(
1135
+ handle.sendCommand(CDP_RUNTIME_EVALUATE, {
1136
+ expression,
1137
+ returnByValue: true,
1138
+ awaitPromise: true,
1139
+ } satisfies CdpEvaluateParams),
1140
+ timeoutMs,
1141
+ signal,
1142
+ `browser: execute timed out after ${timeoutMs}ms`,
1143
+ )
1144
+ if (result.exceptionDetails !== undefined) {
1145
+ const detail = result.exceptionDetails as { text?: string; exception?: { description?: string } }
1146
+ const exception = detail.exception?.description ?? detail.text ?? 'unknown exception'
1147
+ this.record(s, 'execute', { script: request.script }, false, { error: exception })
1148
+ return { ok: false, exception }
1149
+ }
1150
+ const value = (result.result as { value?: unknown } | undefined)?.value ?? null
1151
+ this.record(s, 'execute', {
1152
+ script: request.script,
1153
+ ...request.args !== undefined && request.args.length > 0 ? { args: request.args } : {},
1154
+ }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
1155
+ return { ok: true, value }
1156
+ } catch (error) {
1157
+ if (error instanceof Error && error.name === 'TimeoutError') {
1158
+ throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error })
1159
+ }
1160
+ throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error })
1161
+ }
1162
+ }
1163
+
1164
+ /** Produce an AI-friendly snapshot of the active tab. */
1165
+ async snapshot(session: BrowserSessionId, options: { query?: string; limit?: number } = {}, signal?: AbortSignal): Promise<BrowserSnapshotResult> {
1166
+ const s = this.session(session)
1167
+ const tab = this.activeTab(s)
1168
+ signal?.throwIfAborted()
1169
+ await this.drainDialog(s, tab.handle)
1170
+ const requested = options.limit === undefined ? this.snapshotMaxElements : Math.max(1, Math.min(1000, Math.trunc(options.limit)))
1171
+ const cap = options.limit === undefined ? this.snapshotMaxElements : requested
1172
+ const query = (options.query ?? '').trim().toLowerCase()
1173
+ const script = `(() => {
1174
+ const cap = ${String(cap)}
1175
+ const query = ${JSON.stringify(query)}
1176
+ const locatorOf = (el) => {
1177
+ if (el.id) return '#' + CSS.escape(el.id)
1178
+ if (el.name) return '[name=' + JSON.stringify(el.name) + ']'
1179
+ const aria = el.getAttribute('aria-label')
1180
+ if (aria) return '[aria-label=' + JSON.stringify(aria) + ']'
1181
+ const tag = el.tagName.toLowerCase()
1182
+ const text = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 30)
1183
+ if (text) return tag + ':has-text("' + text.replace(/"/g, '\\\\"') + '")'
1184
+ return tag
1185
+ }
1186
+ const pathOf = (el, doc) => {
1187
+ if (el.id) return '#' + CSS.escape(el.id)
1188
+ const parts = []
1189
+ let node = el
1190
+ while (node && node.nodeType === Node.ELEMENT_NODE) {
1191
+ let part = node.tagName.toLowerCase()
1192
+ const parent = node.parentElement
1193
+ if (parent) {
1194
+ const siblings = [...parent.children].filter(sibling => sibling.tagName === node.tagName)
1195
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')'
1196
+ }
1197
+ parts.unshift(part)
1198
+ if (node === doc.body) break
1199
+ node = parent
1200
+ }
1201
+ return parts.join(' > ')
1202
+ }
1203
+ const fingerprintOf = (el) => [
1204
+ el.tagName,
1205
+ el.getAttribute('type') || '',
1206
+ el.id || '',
1207
+ el.getAttribute('name') || '',
1208
+ el.getAttribute('aria-label') || '',
1209
+ (el.textContent || el.value || '').toString().replace(/\s+/g, ' ').trim().slice(0, 120),
1210
+ ].join('\u001f')
1211
+ const url = location.href
1212
+ const title = document.title || undefined
1213
+ const out = []
1214
+ let capped = false
1215
+ // 同一套判定,用在顶层文档和每个同源子框架上;dx/dy 把框架内的坐标换算成页面坐标。
1216
+ const collect = (doc, frameIndex, dx, dy) => {
1217
+ const els = [...doc.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [role="link"], [contenteditable="true"]')]
1218
+ for (const el of els) {
1219
+ if (el.closest('[data-dsh-browser-chrome]')) continue
1220
+ const r = el.getBoundingClientRect()
1221
+ const cs = getComputedStyle(el)
1222
+ if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
1223
+ const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'text'))
1224
+ : el.tagName === 'TEXTAREA' ? 'textarea'
1225
+ : el.tagName === 'SELECT' ? 'select'
1226
+ : el.tagName === 'BUTTON' ? 'button'
1227
+ : el.tagName === 'A' ? 'link' : 'other'
1228
+ const label = (el.getAttribute('aria-label') || el.placeholder || el.textContent || el.value || el.name || el.id || '').replace(/\s+/g, ' ').trim().slice(0, 120)
1229
+ if (!label && kind !== 'link') continue
1230
+ // 过滤放在计上限**之前**:默认上限是 60,先截断就永远搜不到后面的元素。
1231
+ if (query !== '' && (kind + ' ' + label).toLowerCase().indexOf(query) === -1) continue
1232
+ if (out.length >= cap) { capped = true; return }
1233
+ out.push({
1234
+ ref: out.length + 1,
1235
+ kind,
1236
+ label,
1237
+ ...(frameIndex === undefined ? {} : { frame: frameIndex }),
1238
+ selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
1239
+ loc: locatorOf(el),
1240
+ path: pathOf(el, doc),
1241
+ fingerprint: fingerprintOf(el),
1242
+ x: Math.round(r.x + r.width / 2 + dx),
1243
+ y: Math.round(r.y + r.height / 2 + dy),
1244
+ })
1245
+ }
1246
+ }
1247
+ collect(document, undefined, 0, 0)
1248
+ // 子框架:同源的走进去(元素带 frame 序号、坐标加上框架自己的矩形);
1249
+ // 跨源的 contentDocument 读不到,就只记一条 readable:false —— 不假装能读。
1250
+ const frames = []
1251
+ const frameEls = [...document.querySelectorAll('iframe')]
1252
+ for (let i = 0; i < frameEls.length; i++) {
1253
+ const frame = frameEls[i]
1254
+ const src = frame.getAttribute('src') || ''
1255
+ let inner = null
1256
+ try { inner = frame.contentDocument } catch (error) { inner = null }
1257
+ if (inner === null) {
1258
+ frames.push({ index: i, url: src, readable: false })
1259
+ continue
1260
+ }
1261
+ const fr = frame.getBoundingClientRect()
1262
+ frames.push({ index: i, url: inner.location.href || src, readable: true })
1263
+ if (out.length >= cap) { capped = true; continue }
1264
+ collect(inner, i, fr.left, fr.top)
1265
+ }
1266
+ const challenge = ${CHALLENGE_DETECT_EXPRESSION}
1267
+ const chromeHost = document.getElementById('__dsh_browser_chrome_host__')
1268
+ const userControlling = chromeHost?.getAttribute('data-dsh-user-active') === '1'
1269
+ // Only an early break means truncation: exactly cap candidates is a
1270
+ // complete inventory, not a truncated one.
1271
+ return { url, title, elements: out, truncated: capped, frames, challenge, userControlling }
1272
+ })()`
1273
+ // Same hang guard as execute: a renderer that has not committed after
1274
+ // navigate would otherwise block snapshot forever.
1275
+ const timeoutMs = 30_000
1276
+ const result = await withTimeout(
1277
+ handleSendEvaluate(tab.handle, script),
1278
+ timeoutMs,
1279
+ signal,
1280
+ `browser: snapshot timed out after ${timeoutMs}ms`,
1281
+ )
1282
+ if (!result.ok) throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED')
1283
+ type RawSnapshotElement = BrowserSnapshotElement & { readonly path: string; readonly fingerprint: string }
1284
+ type RawSnapshot = Omit<BrowserSnapshotResult, 'snapshotId' | 'elements'> & { readonly elements: readonly RawSnapshotElement[] }
1285
+ let value = result.value as RawSnapshot
1286
+ // Framework apps often hydrate controls after the load event. A short
1287
+ // bounded retry turns premature empty inventories into useful snapshots.
1288
+ // The phase is budgeted because each attempt may itself wait the evaluation
1289
+ // timeout, and an aborted call must surface rather than be retried on.
1290
+ const retryDeadline = Date.now() + SNAPSHOT_RETRY_BUDGET_MS
1291
+ for (let attempt = 0; attempt < 5 && value.elements.length === 0 && value.truncated !== true; attempt++) {
1292
+ const remaining = retryDeadline - Date.now()
1293
+ if (remaining <= 0) break
1294
+ signal?.throwIfAborted()
1295
+ await new Promise(resolve => setTimeout(resolve, Math.min(400, remaining)))
1296
+ if (signal?.aborted === true) break
1297
+ const retry = await withTimeout(
1298
+ handleSendEvaluate(tab.handle, script, signal),
1299
+ Math.min(timeoutMs, Math.max(retryDeadline - Date.now(), 500)),
1300
+ signal,
1301
+ `browser: snapshot timed out after ${timeoutMs}ms`,
1302
+ ).catch((error: unknown) => {
1303
+ if (signal?.aborted === true) throw error
1304
+ return undefined
1305
+ })
1306
+ if (retry?.ok) value = retry.value as RawSnapshot
1307
+ }
1308
+ const snapshotId = `snapshot:${randomUUID()}`
1309
+ const targets = new Map<number, SnapshotTarget>()
1310
+ for (const element of value.elements) {
1311
+ targets.set(element.ref, {
1312
+ path: element.path,
1313
+ fingerprint: element.fingerprint,
1314
+ ...element.frame !== undefined ? { frame: element.frame } : {},
1315
+ })
1316
+ }
1317
+ tab.snapshots.set(snapshotId, { tabId: tab.id, url: value.url, epoch: tab.navigationEpoch, targets })
1318
+ while (tab.snapshots.size > 10) {
1319
+ const oldest = tab.snapshots.keys().next().value as string | undefined
1320
+ if (oldest === undefined) break
1321
+ tab.snapshots.delete(oldest)
1322
+ }
1323
+ return {
1324
+ snapshotId,
1325
+ url: value.url,
1326
+ ...value.title !== undefined ? { title: value.title } : {},
1327
+ elements: value.elements.map(({ path: _path, fingerprint: _fingerprint, ...element }) => element),
1328
+ ...value.frames !== undefined ? { frames: value.frames } : {},
1329
+ truncated: value.truncated,
1330
+ ...value.challenge !== undefined ? { challenge: value.challenge } : {},
1331
+ ...value.userControlling !== undefined ? { userControlling: value.userControlling } : {},
1332
+ }
1333
+ }
1334
+
1335
+ /** Click one element that belongs to a retained exact page snapshot. */
1336
+ async clickRef(session: BrowserSessionId, request: BrowserRefRequest, signal?: AbortSignal): Promise<void> {
1337
+ const s = this.session(session)
1338
+ const tab = this.activeTab(s)
1339
+ signal?.throwIfAborted()
1340
+ await this.drainDialog(s, tab.handle)
1341
+ const point = await this.resolveSnapshotTarget(tab, request, 'center', signal)
1342
+ await suppressAutoUserControl(tab.handle, signal)
1343
+ await this.dispatchInput(tab.handle, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 } satisfies CdpMouseParams, signal)
1344
+ await this.dispatchInput(tab.handle, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 } satisfies CdpMouseParams, signal)
1345
+ this.record(s, 'clickRef', { snapshotId: request.snapshotId, ref: request.ref }, true)
1346
+ }
1347
+
1348
+ /** Scroll one element that belongs to a retained exact page snapshot into view. */
1349
+ async scrollIntoView(session: BrowserSessionId, request: BrowserScrollIntoViewRequest, signal?: AbortSignal): Promise<BrowserScrollResult> {
1350
+ const s = this.session(session)
1351
+ const tab = this.activeTab(s)
1352
+ signal?.throwIfAborted()
1353
+ const result = await this.resolveSnapshotTarget(tab, request, request.block ?? 'center', signal)
1354
+ this.record(s, 'scrollIntoView', { snapshotId: request.snapshotId, ref: request.ref, block: request.block ?? 'center' }, true)
1355
+ return result
1356
+ }
1357
+
1358
+ /** Check whether a human-verification challenge is blocking the active tab. */
1359
+ async detectChallenge(session: BrowserSessionId, signal?: AbortSignal): Promise<BrowserChallenge> {
1360
+ const s = this.session(session)
1361
+ const tab = this.activeTab(s)
1362
+ signal?.throwIfAborted()
1363
+ const timeoutMs = 15_000
1364
+ const result = await withTimeout(
1365
+ handleSendEvaluate(tab.handle, CHALLENGE_DETECT_EXPRESSION),
1366
+ timeoutMs,
1367
+ signal,
1368
+ `browser: challenge detection timed out after ${timeoutMs}ms`,
1369
+ )
1370
+ if (!result.ok) {
1371
+ throw new BrowserError(`browser: challenge detection failed: ${result.exception}`, 'BROWSER_CHALLENGE_DETECT_FAILED')
1372
+ }
1373
+ const value = result.value as BrowserChallenge
1374
+ return { blocked: value.blocked === true, kind: value.kind, reason: value.reason }
1375
+ }
1376
+
1377
+ /** Fetch page content in a requested format. */
1378
+ async content(session: BrowserSessionId, request: BrowserContentRequest, signal?: AbortSignal): Promise<BrowserContentResult> {
1379
+ const s = this.session(session)
1380
+ const tab = this.activeTab(s)
1381
+ signal?.throwIfAborted()
1382
+ await this.drainDialog(s, tab.handle)
1383
+ const maxChars = request.maxChars ?? this.contentMaxChars
1384
+ const selector = request.selector ?? ''
1385
+ const format = request.format
1386
+ const script = `(() => {
1387
+ const root = ${selector === '' ? 'document.body' : `document.querySelector(${JSON.stringify(selector)})`}
1388
+ if (!root) return { ok: false, reason: 'selector not found' }
1389
+ const fmt = ${JSON.stringify(format)}
1390
+ let content = ''
1391
+ if (fmt === 'txt') content = root.innerText || ''
1392
+ else if (fmt === 'html') content = root.outerHTML || ''
1393
+ else if (fmt === 'json') {
1394
+ // An element has no own enumerable properties, so JSON.stringify(root)
1395
+ // always produced "{}". Serialize a bounded structural view instead, and
1396
+ // pass a genuine JSON payload through unchanged.
1397
+ const raw = (root.textContent || '').trim()
1398
+ let parsed
1399
+ try { parsed = JSON.parse(raw) } catch { parsed = undefined }
1400
+ if (parsed !== undefined) content = JSON.stringify(parsed)
1401
+ else {
1402
+ const shape = (el, depth) => {
1403
+ const node = { tag: el.tagName ? el.tagName.toLowerCase() : undefined }
1404
+ if (depth >= 8) return node
1405
+ if (el.id) node.id = el.id
1406
+ if (typeof el.className === 'string' && el.className !== '') node.class = el.className
1407
+ const kids = el.children ? [...el.children].slice(0, 40) : []
1408
+ if (kids.length > 0) node.children = kids.map(child => shape(child, depth + 1))
1409
+ else {
1410
+ const text = (el.textContent || '').trim().slice(0, 200)
1411
+ if (text !== '') node.text = text
1412
+ }
1413
+ return node
1414
+ }
1415
+ content = JSON.stringify(shape(root, 0))
1416
+ }
1417
+ }
1418
+ // markdown: headings, links, lists, paragraphs (best-effort). The
1419
+ // renderer is embedded from its own source, so the function under test
1420
+ // is byte-for-byte the one that runs in the page.
1421
+ else content = (${renderMarkdown.toString()})(root)
1422
+ const truncated = content.length > ${String(maxChars)}
1423
+ return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
1424
+ })()`
1425
+ // Honor a per-call timeout: content evaluation can hang on a heavy page,
1426
+ // so a caller-supplied budget bounds it. Unlike a bare signal entry check,
1427
+ // withTimeout also interrupts a call already in flight.
1428
+ const timeoutMs = request.timeoutMs ?? 30_000
1429
+ const result = await withTimeout(
1430
+ handleSendEvaluate(tab.handle, script),
1431
+ timeoutMs,
1432
+ signal,
1433
+ `browser: content timed out after ${timeoutMs}ms`,
1434
+ )
1435
+ if (!result.ok) throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED')
1436
+ const value = result.value as { ok: boolean; reason?: string; content?: string; truncated?: boolean }
1437
+ if (!value.ok) throw new BrowserError(`browser: content fetch failed: ${value.reason ?? 'unknown'}`, 'BROWSER_CONTENT_FAILED')
1438
+ return { content: value.content ?? '', truncated: value.truncated ?? false }
1439
+ }
1440
+
1441
+ /** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
1442
+ async click(session: BrowserSessionId, target: BrowserPointerTarget, signal?: AbortSignal): Promise<BrowserPointerResult> {
1443
+ const s = this.session(session)
1444
+ const { handle } = this.activeTab(s)
1445
+ signal?.throwIfAborted()
1446
+ await this.drainDialog(s, handle)
1447
+ const point = await resolvePointerTarget(handle, target, signal)
1448
+ await suppressAutoUserControl(handle, signal)
1449
+ // Electron installs no native context menu, so a right-click reaches the
1450
+ // page's own handler — which is exactly what an agent wants to drive.
1451
+ const button = target.button ?? 'left'
1452
+ const modifiers = modifierMask(target.modifiers)
1453
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button, clickCount: 1, modifiers } satisfies CdpMouseParams, signal)
1454
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button, clickCount: 1, modifiers } satisfies CdpMouseParams, signal)
1455
+ this.record(s, 'click', { x: point.x, y: point.y, ...point.target === undefined ? {} : { target: point.target }, button, ...target.modifiers !== undefined && target.modifiers.length > 0 ? { modifiers: target.modifiers } : {} }, true)
1456
+ return point
1457
+ }
1458
+
1459
+ /** Double-click a target (physical input; clickCount 2). */
1460
+ async doubleClick(session: BrowserSessionId, target: BrowserPointerTarget, signal?: AbortSignal): Promise<BrowserPointerResult> {
1461
+ const s = this.session(session)
1462
+ const { handle } = this.activeTab(s)
1463
+ signal?.throwIfAborted()
1464
+ await this.drainDialog(s, handle)
1465
+ const point = await resolvePointerTarget(handle, target, signal)
1466
+ await suppressAutoUserControl(handle, signal)
1467
+ const button = target.button ?? 'left'
1468
+ const modifiers = modifierMask(target.modifiers)
1469
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button, clickCount: 2, modifiers } satisfies CdpMouseParams, signal)
1470
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button, clickCount: 2, modifiers } satisfies CdpMouseParams, signal)
1471
+ this.record(s, 'doubleClick', { x: point.x, y: point.y, ...point.target === undefined ? {} : { target: point.target }, button, ...target.modifiers !== undefined && target.modifiers.length > 0 ? { modifiers: target.modifiers } : {} }, true)
1472
+ return point
1473
+ }
1474
+
1475
+ /** Move the pointer over a target (no click). */
1476
+ async hover(session: BrowserSessionId, target: BrowserPointerTarget, signal?: AbortSignal): Promise<BrowserPointerResult> {
1477
+ const s = this.session(session)
1478
+ const { handle } = this.activeTab(s)
1479
+ signal?.throwIfAborted()
1480
+ await this.drainDialog(s, handle)
1481
+ const point = await resolvePointerTarget(handle, target, signal)
1482
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y, button: 'none', modifiers: modifierMask(target.modifiers) } satisfies CdpMouseParams, signal)
1483
+ this.record(s, 'hover', { x: point.x, y: point.y, ...point.target === undefined ? {} : { target: point.target } }, true)
1484
+ return point
1485
+ }
1486
+
1487
+ /**
1488
+ * Press on one target, move to another, release.
1489
+ *
1490
+ * A hand does not teleport: the intermediate moves are what pointer-based
1491
+ * sliders and sortable libraries listen for, so a press straight onto the
1492
+ * destination would be ignored. Note this drives *pointer* drags only —
1493
+ * HTML5 drag-and-drop needs dragstart/drop, which synthesized mouse moves do
1494
+ * not produce; use the page's own controls, or a click-based reorder, there.
1495
+ */
1496
+ async drag(session: BrowserSessionId, request: BrowserDragRequest, signal?: AbortSignal): Promise<BrowserDragResult> {
1497
+ const s = this.session(session)
1498
+ const { handle } = this.activeTab(s)
1499
+ signal?.throwIfAborted()
1500
+ await this.drainDialog(s, handle)
1501
+ const from = await resolvePointerTarget(handle, request.from, signal)
1502
+ const to = await resolvePointerTarget(handle, request.to, signal)
1503
+ await suppressAutoUserControl(handle, signal)
1504
+ const steps = Math.max(1, Math.min(Math.trunc(request.steps ?? 12) || 12, 60))
1505
+ // Hover the source first: some libraries only arm on an enter.
1506
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: from.x, y: from.y, button: 'none', buttons: 0 } satisfies CdpMouseParams, signal)
1507
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: from.x, y: from.y, button: 'left', clickCount: 1, buttons: 1 } satisfies CdpMouseParams, signal)
1508
+ for (let step = 1; step <= steps; step += 1) {
1509
+ const ratio = step / steps
1510
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', {
1511
+ type: 'mouseMoved',
1512
+ x: from.x + (to.x - from.x) * ratio,
1513
+ y: from.y + (to.y - from.y) * ratio,
1514
+ button: 'left',
1515
+ buttons: 1,
1516
+ } satisfies CdpMouseParams, signal)
1517
+ // Distinct events, not one burst: a listener that reads positions per
1518
+ // frame needs the gesture to span more than a single tick.
1519
+ await new Promise(resolve => setTimeout(resolve, DRAG_STEP_DELAY_MS))
1520
+ }
1521
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: to.x, y: to.y, button: 'left', clickCount: 1, buttons: 0 } satisfies CdpMouseParams, signal)
1522
+ this.record(s, 'drag', {
1523
+ from: { x: Math.round(from.x), y: Math.round(from.y), ...from.target === undefined ? {} : { target: from.target } },
1524
+ to: { x: Math.round(to.x), y: Math.round(to.y), ...to.target === undefined ? {} : { target: to.target } },
1525
+ steps,
1526
+ }, true)
1527
+ return { from, to }
1528
+ }
1529
+
1530
+ /** Scroll the active page by CSS-pixel deltas and return the final position. */
1531
+ async scroll(session: BrowserSessionId, request: BrowserScrollRequest, signal?: AbortSignal): Promise<BrowserScrollResult> {
1532
+ const s = this.session(session)
1533
+ const tab = this.activeTab(s)
1534
+ signal?.throwIfAborted()
1535
+ const deltaX = request.deltaX ?? 0
1536
+ const deltaY = request.deltaY ?? 0
1537
+ const hasExplicitDelta = request.deltaX !== undefined || request.deltaY !== undefined
1538
+ const script = `(() => {
1539
+ const deltaX = ${JSON.stringify(deltaX)}
1540
+ const deltaY = ${JSON.stringify(deltaY)}
1541
+ const hasExplicitDelta = ${JSON.stringify(hasExplicitDelta)}
1542
+ const effectiveDeltaY = hasExplicitDelta ? deltaY : Math.max(window.innerHeight * 0.8, 480)
1543
+ window.scrollBy(deltaX, effectiveDeltaY)
1544
+ const root = document.documentElement
1545
+ return {
1546
+ x: window.scrollX,
1547
+ y: window.scrollY,
1548
+ maxX: Math.max(0, root.scrollWidth - window.innerWidth),
1549
+ maxY: Math.max(0, root.scrollHeight - window.innerHeight),
1550
+ }
1551
+ })()`
1552
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), 15_000, signal, 'browser: scroll timed out')
1553
+ if (!result.ok) throw new BrowserError(`browser: scroll failed: ${result.exception}`, 'BROWSER_SCROLL_FAILED')
1554
+ const value = result.value as Partial<BrowserScrollResult>
1555
+ if (typeof value.x !== 'number' || typeof value.y !== 'number' || typeof value.maxX !== 'number' || typeof value.maxY !== 'number') {
1556
+ throw new BrowserError('browser: scroll returned invalid coordinates', 'BROWSER_SCROLL_FAILED')
1557
+ }
1558
+ this.record(s, 'scroll', { deltaX, deltaY: hasExplicitDelta ? deltaY : 'viewport' }, true)
1559
+ return { x: value.x, y: value.y, maxX: value.maxX, maxY: value.maxY }
1560
+ }
1561
+
1562
+ /**
1563
+ * Attach a local file to the first matching file input. Uses the CDP DOM
1564
+ * domain (nodeId path), which — unlike a synthetic change event — makes the
1565
+ * input's files list true (real file selection), so pages that read
1566
+ * input.files or upload on change behave exactly like a real pick.
1567
+ */
1568
+ async uploadFile(session: BrowserSessionId, request: BrowserUploadFileRequest, signal?: AbortSignal): Promise<BrowserUploadFileResult> {
1569
+ const s = this.session(session)
1570
+ const { handle } = this.activeTab(s)
1571
+ signal?.throwIfAborted()
1572
+ await this.drainDialog(s, handle)
1573
+ // Same input semantics as click/type: do not let the page hand control to
1574
+ // the human while an agent-driven file selection is in flight.
1575
+ await suppressAutoUserControl(handle, signal)
1576
+ const selector = request.selector ?? 'input[type="file"]'
1577
+ // A file handed to a page leaves the machine, so admission happens before
1578
+ // any DOM work: the path must exist and sit inside the configured roots.
1579
+ const filePath = resolveReadPath(request.filePath, this.readRoots)
1580
+ // Bound the whole DOM sequence: a wedged renderer must not hang the tool.
1581
+ const timeoutMs = 30_000
1582
+ await withTimeout((async () => {
1583
+ const doc = await handle.sendCommand('DOM.getDocument', {})
1584
+ const root = (doc as { root?: { nodeId?: number } }).root
1585
+ const rootId = root?.nodeId
1586
+ if (rootId === undefined) {
1587
+ throw new BrowserError('browser: could not resolve the document node', 'BROWSER_UPLOAD_FAILED')
1588
+ }
1589
+ const query = await handle.sendCommand('DOM.querySelector', { nodeId: rootId, selector })
1590
+ const nodeId = (query as { nodeId?: number }).nodeId
1591
+ if (nodeId === undefined || nodeId === 0) {
1592
+ throw new BrowserError(`browser: no file input matches "${selector}"`, 'BROWSER_UPLOAD_NO_INPUT')
1593
+ }
1594
+ await handle.sendCommand('DOM.setFileInputFiles', { files: [filePath], nodeId })
1595
+ })(), timeoutMs, signal, `browser: upload timed out after ${timeoutMs}ms`)
1596
+ this.record(s, 'uploadFile', { filePath: request.filePath, selector }, true, { result: '1 file attached' })
1597
+ return { path: request.filePath }
1598
+ }
1599
+
1600
+ /**
1601
+ * Poll until an element matching the selector exists (and is visible).
1602
+ * Bounds the total wait; a timeout surfaces as BROWSER_WAIT_TIMEOUT.
1603
+ */
1604
+ async waitForElement(session: BrowserSessionId, request: BrowserWaitForRequest, signal?: AbortSignal): Promise<BrowserWaitForResult> {
1605
+ const s = this.session(session)
1606
+ return this.waitForElementTab(s, this.activeTab(s), request, signal)
1607
+ }
1608
+
1609
+ /** Poll one tab until the selector and/or text reaches the requested state. */
1610
+ private async waitForElementTab(s: Session, tab: Tab, request: BrowserWaitForRequest, signal?: AbortSignal): Promise<BrowserWaitForResult> {
1611
+ const { handle } = tab
1612
+ signal?.throwIfAborted()
1613
+ const timeoutMs = request.timeoutMs ?? 15_000
1614
+ const selector = request.selector ?? ''
1615
+ const wanted = request.text
1616
+ const state: 'visible' | 'attached' | 'hidden' | 'detached' = request.state ?? (request.visible === false ? 'attached' : 'visible')
1617
+ if (selector === '' && wanted === undefined) {
1618
+ throw new BrowserError('browser: waitForElement needs a selector, some text, or both', 'BROWSER_WAIT_INVALID')
1619
+ }
1620
+ if (selector === '' && (state === 'hidden' || state === 'detached')) {
1621
+ throw new BrowserError(`browser: state "${state}" needs a selector to watch disappear`, 'BROWSER_WAIT_INVALID')
1622
+ }
1623
+ const script = `(() => {
1624
+ const selector = ${JSON.stringify(selector)}
1625
+ const wanted = ${JSON.stringify(wanted ?? null)}
1626
+ const state = ${JSON.stringify(state)}
1627
+ let el = null
1628
+ if (selector !== '') {
1629
+ try { el = document.querySelector(selector) } catch (e) { return { error: String(e) } }
1630
+ }
1631
+ const visibleNow = node => {
1632
+ if (node === null) return false
1633
+ const r = node.getBoundingClientRect()
1634
+ if (r.width < 4 || r.height < 4) return false
1635
+ const cs = getComputedStyle(node)
1636
+ return cs.visibility !== 'hidden' && cs.display !== 'none'
1637
+ }
1638
+ const textOf = node => (node === null ? '' : (node.textContent || '')).replace(/\\s+/g, ' ').trim()
1639
+ const attached = el !== null
1640
+ const visible = visibleNow(el)
1641
+ let met
1642
+ if (selector === '') {
1643
+ // 只等文字时没有元素可量:文字在不在,本身就是那个状态。
1644
+ // (以前这里仍按 visibleNow(null) === false 判定,于是默认的 visible 永远不成立。)
1645
+ const present = textOf(document.body).includes(wanted)
1646
+ met = state === 'hidden' || state === 'detached' ? !present : present
1647
+ } else {
1648
+ met = state === 'attached' ? attached : state === 'visible' ? visible : state === 'hidden' ? !visible : state === 'detached' ? !attached : false
1649
+ if (met && wanted !== null) met = textOf(el).includes(wanted)
1650
+ }
1651
+ if (!met) return null
1652
+ return {
1653
+ found: true,
1654
+ state,
1655
+ selector,
1656
+ tag: attached ? el.tagName.toLowerCase() : '',
1657
+ text: (wanted !== null && !attached ? wanted : textOf(selector === '' ? document.body : el)).slice(0, 200),
1658
+ }
1659
+ })()`
1660
+ const deadline = Date.now() + timeoutMs
1661
+ let lastError: string | undefined
1662
+ while (Date.now() <= deadline) {
1663
+ signal?.throwIfAborted()
1664
+ const result = await withTimeout(handleSendEvaluate(handle, script, signal), Math.max(deadline - Date.now(), 250), signal, 'browser: wait poll timed out').catch((error: unknown): BrowserExecuteResult => ({ ok: false, exception: String(error) }))
1665
+ if (!result.ok) {
1666
+ lastError = result.exception
1667
+ } else {
1668
+ const value = result.value as { found?: boolean; tag?: string; text?: string; error?: string } | null
1669
+ if (value?.found === true && typeof value.tag === 'string') {
1670
+ this.record(s, 'waitForElement', { selector, text: wanted, state, timeoutMs }, true, { result: value.tag ?? '' })
1671
+ return { found: true, state, selector, tag: value.tag, text: value.text ?? '' }
1672
+ }
1673
+ if (value?.error !== undefined) {
1674
+ // A malformed selector can never start matching, so fail now instead of
1675
+ // polling to the deadline and reporting a misleading timeout.
1676
+ throw new BrowserError(`browser: invalid selector "${selector}": ${value.error}`, 'BROWSER_SELECTOR_INVALID')
1677
+ }
1678
+ }
1679
+ await new Promise(resolve => setTimeout(resolve, Math.min(250, Math.max(deadline - Date.now(), 1))))
1680
+ }
1681
+ const what = selector === '' ? `text ${JSON.stringify(wanted)}` : `"${selector}"`
1682
+ throw new BrowserError(`browser: ${what} did not reach state "${state}" within ${timeoutMs}ms${lastError !== undefined ? ` (${lastError})` : ''}`, 'BROWSER_WAIT_TIMEOUT')
1683
+ }
1684
+
1685
+ /** Type into the focused element. */
1686
+ async type(session: BrowserSessionId, request: { readonly text: string }, signal?: AbortSignal): Promise<void> {
1687
+ const s = this.session(session)
1688
+ const { handle } = this.activeTab(s)
1689
+ signal?.throwIfAborted()
1690
+ await this.drainDialog(s, handle)
1691
+ await suppressAutoUserControl(handle, signal)
1692
+ await this.dispatchInput(handle, 'Input.insertText', { text: request.text } satisfies CdpInsertTextParams, signal)
1693
+ // Store the full text so replay re-issues the same input; the history
1694
+ // tool truncates long values when rendering.
1695
+ this.record(s, 'type', { text: request.text }, true)
1696
+ }
1697
+
1698
+ /**
1699
+ * Drive the host's chrome frame view with a raw CDP command.
1700
+ *
1701
+ * The chrome can live in a view of its own so the page viewport can really
1702
+ * shrink; that view is not a tab, so this is the only way to click the toolbar
1703
+ * (the click tests use it, and so does anything that needs to exercise the
1704
+ * chrome the way a person does).
1705
+ */
1706
+ async chromeInput(method: string, params: Record<string, unknown> = {}): Promise<void> {
1707
+ const host = this.host
1708
+ if (typeof host.chromeInput !== 'function') throw new Error('this browser host has no chrome frame view')
1709
+ await host.chromeInput(method, params)
1710
+ }
1711
+
1712
+ /**
1713
+ * Read a value back out of the chrome frame's own document.
1714
+ *
1715
+ * The frame is not a tab, so a page-directed evaluate cannot see it; without
1716
+ * this the toolbar's animations could only be inferred from the page's copy.
1717
+ */
1718
+ async chromeEval(expression: string): Promise<unknown> {
1719
+ const host = this.host
1720
+ if (typeof host.chromeEval !== 'function') throw new Error('this browser host has no chrome frame view')
1721
+ return await host.chromeEval(expression)
1722
+ }
1723
+
1724
+ /** Press a key into the page (keyDown + keyUp), as a physical-input path
1725
+ * for shortcuts and keyboard-driven UI. */
1726
+ async pressKey(session: BrowserSessionId, request: BrowserPressKeyRequest, signal?: AbortSignal): Promise<void> {
1727
+ const s = this.session(session)
1728
+ const { handle } = this.activeTab(s)
1729
+ signal?.throwIfAborted()
1730
+ await this.drainDialog(s, handle)
1731
+ await suppressAutoUserControl(handle, signal)
1732
+ const { key, code, vk } = keyDescriptor(request.key)
1733
+ const modifiers = modifierMask(request.modifiers)
1734
+ const text = keyText(request.key)
1735
+ const down: Record<string, unknown> = { type: text === null ? 'rawKeyDown' : 'keyDown', key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, modifiers }
1736
+ if (text !== null) { down.text = text; down.unmodifiedText = text }
1737
+ await this.dispatchInput(handle, CDP_INPUT_DISPATCH_KEY_EVENT, down, signal)
1738
+ await this.dispatchInput(handle, CDP_INPUT_DISPATCH_KEY_EVENT, { type: 'keyUp', key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, modifiers }, signal)
1739
+ this.record(s, 'pressKey', { key: request.key, ...(request.modifiers !== undefined && request.modifiers.length > 0 ? { modifiers: request.modifiers } : {}) }, true)
1740
+ }
1741
+
1742
+ /**
1743
+ * Fill a form's fields in one batch. Runs one page-context script that
1744
+ * resolves each field (selector, or name/label/placeholder among visible
1745
+ * controls), sets its value with the native prototype setter (React/Vue
1746
+ * controlled inputs included) plus input/change events, handles
1747
+ * select/checkbox/radio/contenteditable, and optionally submits the form.
1748
+ */
1749
+ async fillForm(session: BrowserSessionId, request: BrowserFillRequest, signal?: AbortSignal): Promise<BrowserFillResult> {
1750
+ const s = this.session(session)
1751
+ const tab = this.activeTab(s)
1752
+ signal?.throwIfAborted()
1753
+ await this.drainDialog(s, tab.handle)
1754
+ const specs = JSON.stringify(request.fields.map(f => ({
1755
+ selector: f.selector ?? null,
1756
+ name: f.name ?? null,
1757
+ label: f.label ?? null,
1758
+ placeholder: f.placeholder ?? null,
1759
+ kind: f.kind ?? 'text',
1760
+ value: f.value,
1761
+ })))
1762
+ const submitFlag = request.submit === true
1763
+ const script = `(() => {
1764
+ const specs = ${specs}
1765
+ const out = []
1766
+ const setNative = (el, proto, value) => {
1767
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
1768
+ if (setter) setter.call(el, value)
1769
+ else el.value = value
1770
+ }
1771
+ const visible = (el) => {
1772
+ const r = el.getBoundingClientRect()
1773
+ const cs = getComputedStyle(el)
1774
+ return r.width >= 4 && r.height >= 4 && cs.visibility !== 'hidden' && cs.display !== 'none'
1775
+ }
1776
+ const describe = (spec) => spec.selector || spec.name || spec.label || spec.placeholder || '(unspecified)'
1777
+ const matches = (el, spec) => {
1778
+ if (spec.selector) { try { return el.matches(spec.selector) } catch { return false } }
1779
+ if (spec.name && el.name === spec.name) return true
1780
+ if (spec.placeholder && el.placeholder === spec.placeholder) return true
1781
+ if (spec.label) {
1782
+ if (el.getAttribute('aria-label') === spec.label) return true
1783
+ if (el.id) {
1784
+ const lbl = document.querySelector('label[for=' + JSON.stringify(el.id) + ']')
1785
+ if (lbl && (lbl.textContent || '').trim() === spec.label) return true
1786
+ }
1787
+ const wrap = el.closest('label')
1788
+ if (wrap && (wrap.textContent || '').trim() === spec.label) return true
1789
+ }
1790
+ return false
1791
+ }
1792
+ const candidates = (spec) => {
1793
+ const raw = spec.selector
1794
+ ? [...document.querySelectorAll(spec.selector)]
1795
+ : [...document.querySelectorAll('input, textarea, select, [contenteditable="true"]')].filter(el => matches(el, spec))
1796
+ const all = raw.filter(el => !el.closest('[data-dsh-browser-chrome]'))
1797
+ const vis = all.filter(visible)
1798
+ return vis.length > 0 ? vis : all
1799
+ }
1800
+ const filledEls = []
1801
+ for (const spec of specs) {
1802
+ let els
1803
+ try {
1804
+ els = candidates(spec)
1805
+ } catch (e) {
1806
+ // A malformed selector must not abort the whole batch; report the
1807
+ // field as failed and continue with the rest.
1808
+ out.push({ ok: false, error: String(e), target: describe(spec) })
1809
+ continue
1810
+ }
1811
+ if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
1812
+ const el = els[0]
1813
+ const tag = el.tagName
1814
+ const type = (el.type || '').toLowerCase()
1815
+ const before = out.length
1816
+ try {
1817
+ if (tag === 'SELECT') {
1818
+ const wanted = String(spec.value)
1819
+ if (el.multiple) {
1820
+ const wantedList = wanted.split(',').map(x => x.trim())
1821
+ let hit = false
1822
+ for (const o of [...el.options]) {
1823
+ o.selected = wantedList.includes(o.value) || wantedList.includes((o.textContent || '').trim())
1824
+ if (o.selected) hit = true
1825
+ }
1826
+ if (!hit) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
1827
+ } else {
1828
+ let opt = [...el.options].find(o => o.value === wanted)
1829
+ if (!opt) opt = [...el.options].find(o => (o.textContent || '').trim() === wanted)
1830
+ if (!opt) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
1831
+ setNative(el, HTMLSelectElement.prototype, opt.value)
1832
+ }
1833
+ el.dispatchEvent(new Event('input', { bubbles: true }))
1834
+ el.dispatchEvent(new Event('change', { bubbles: true }))
1835
+ out.push({ ok: true, method: 'select', target: describe(spec) })
1836
+ } else if (type === 'file') {
1837
+ out.push({ ok: false, error: 'file inputs cannot be set from script; use browser_download or ask the human', target: describe(spec) })
1838
+ } else if (type === 'checkbox') {
1839
+ const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
1840
+ if (el.checked !== want) el.click()
1841
+ if (el.checked !== want) { out.push({ ok: false, error: 'checkbox did not change (disabled, or a handler prevented it)', target: describe(spec) }); continue }
1842
+ out.push({ ok: true, method: 'checkbox', target: describe(spec) })
1843
+ } else if (type === 'radio') {
1844
+ const wanted = String(spec.value)
1845
+ const radio = [...document.querySelectorAll('input[type="radio"][name=' + JSON.stringify(el.name || '') + ']')]
1846
+ .find(r => r.value === wanted || (r === el && (spec.value === true || spec.value === 'true')))
1847
+ if (!radio) { out.push({ ok: false, error: 'radio option not found: ' + wanted, target: describe(spec) }); continue }
1848
+ if (!radio.checked) radio.click()
1849
+ if (!radio.checked) { out.push({ ok: false, error: 'radio did not change (disabled, or a handler prevented it)', target: describe(spec) }); continue }
1850
+ out.push({ ok: true, method: 'radio', target: describe(spec) })
1851
+ } else if (el.isContentEditable) {
1852
+ el.textContent = String(spec.value)
1853
+ el.dispatchEvent(new Event('input', { bubbles: true }))
1854
+ out.push({ ok: true, method: 'contenteditable', target: describe(spec) })
1855
+ } else if (tag === 'TEXTAREA') {
1856
+ const wanted = String(spec.value)
1857
+ setNative(el, HTMLTextAreaElement.prototype, wanted)
1858
+ el.dispatchEvent(new Event('input', { bubbles: true }))
1859
+ el.dispatchEvent(new Event('change', { bubbles: true }))
1860
+ if (wanted !== '' && el.value === '') { out.push({ ok: false, error: 'the field rejected the value', target: describe(spec) }); continue }
1861
+ out.push({ ok: true, method: 'textarea', target: describe(spec) })
1862
+ } else {
1863
+ const wanted = String(spec.value)
1864
+ setNative(el, HTMLInputElement.prototype, wanted)
1865
+ el.dispatchEvent(new Event('input', { bubbles: true }))
1866
+ el.dispatchEvent(new Event('change', { bubbles: true }))
1867
+ // A constrained input (number/date/email) silently blanks a value it
1868
+ // will not accept. Only the empty outcome is unambiguous: the DOM may
1869
+ // legitimately normalise a value it did accept.
1870
+ if (wanted !== '' && el.value === '') { out.push({ ok: false, error: 'the field rejected the value', target: describe(spec) }); continue }
1871
+ out.push({ ok: true, method: 'input', target: describe(spec) })
1872
+ }
1873
+ } catch (e) {
1874
+ out.push({ ok: false, error: String(e), target: describe(spec) })
1875
+ }
1876
+ // Remember what actually landed, so submit anchors on a form the caller
1877
+ // really filled rather than the first field the page happens to expose.
1878
+ if (out.length > before && out[out.length - 1].ok === true) filledEls.push(el)
1879
+ }
1880
+ let submitted = false
1881
+ let blockReason = ''
1882
+ if (${submitFlag}) {
1883
+ let anchor = null
1884
+ for (let index = filledEls.length - 1; index >= 0; index--) {
1885
+ const candidate = filledEls[index]
1886
+ if (candidate.form || candidate.closest('form')) { anchor = candidate; break }
1887
+ }
1888
+ const form = anchor === null ? null : (anchor.form || anchor.closest('form'))
1889
+ if (form === null) blockReason = 'no containing form'
1890
+ // requestSubmit() runs constraint validation: on an invalid form it
1891
+ // neither throws nor submits, so reporting submitted:true was a silent
1892
+ // false success.
1893
+ else if (typeof form.checkValidity === 'function' && form.checkValidity() !== true) blockReason = 'the form is invalid'
1894
+ else { form.requestSubmit(); submitted = true }
1895
+ }
1896
+ return { fields: out, submitted, blockReason }
1897
+ })()`
1898
+ const timeoutMs = request.timeoutMs ?? 30_000
1899
+ const result = await withTimeout(
1900
+ handleSendEvaluate(tab.handle, script),
1901
+ timeoutMs,
1902
+ signal,
1903
+ `browser: fillForm timed out after ${timeoutMs}ms`,
1904
+ )
1905
+ if (!result.ok) {
1906
+ throw new BrowserError(`browser: fillForm evaluation failed: ${result.exception}`, 'BROWSER_FILL_FAILED')
1907
+ }
1908
+ const value = result.value as BrowserFillResult & { readonly blockReason?: string }
1909
+ const okCount = value.fields.filter(f => f.ok).length
1910
+ const submitNote = value.submitted
1911
+ ? ', form submitted'
1912
+ : value.blockReason !== undefined && value.blockReason !== ''
1913
+ ? `, submit blocked: ${value.blockReason}`
1914
+ : ''
1915
+ this.record(s, 'fill', { fields: request.fields.length, submit: submitFlag }, okCount === value.fields.length, {
1916
+ result: `${okCount}/${value.fields.length} fields filled${submitNote}`,
1917
+ })
1918
+ return { fields: value.fields, submitted: value.submitted === true }
1919
+ }
1920
+
1921
+ /**
1922
+ * Download a URL to a local file, keeping the session's cookies/login.
1923
+ * Requires the self-hosted host (which implements view-level download); the
1924
+ * desktop shell's embedded views delegate downloads to the real browser UI.
1925
+ */
1926
+ async download(session: BrowserSessionId, request: { readonly url: string; readonly savePath: string }, signal?: AbortSignal): Promise<{ readonly path: string }> {
1927
+ const s = this.session(session)
1928
+ const { handle } = this.activeTab(s)
1929
+ signal?.throwIfAborted()
1930
+ const downloadable = handle as { download?(url: string, savePath: string): Promise<void> }
1931
+ if (typeof downloadable.download !== 'function') {
1932
+ throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
1933
+ }
1934
+ // A download reaches the network with the session's cookies, so it passes
1935
+ // the same URL admission as navigation, and may only write inside the
1936
+ // configured roots.
1937
+ this.admitUrl(request.url, 'download')
1938
+ const target = resolveWritePath(request.savePath, this.writeRoots)
1939
+ // The child fetches in-page with awaitPromise; a slow/hung network can
1940
+ // block it well past the tool budget, so bound it like every other call.
1941
+ const timeoutMs = 60_000
1942
+ await withTimeout(
1943
+ downloadable.download(request.url, target),
1944
+ timeoutMs,
1945
+ signal,
1946
+ `browser: download timed out after ${timeoutMs}ms`,
1947
+ )
1948
+ this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath })
1949
+ return { path: request.savePath }
1950
+ }
1951
+
1952
+ /**
1953
+ * Export the session's cookies (login state) as serializable objects.
1954
+ * Self-hosted only; the desktop shell's embedded views use the real profile.
1955
+ */
1956
+ async flushAuth(session: BrowserSessionId): Promise<readonly ExportedCookie[]> {
1957
+ const s = this.session(session)
1958
+ const { handle } = this.activeTab(s)
1959
+ const host = handle as { flushAuth?(): Promise<ExportedCookie[]> }
1960
+ if (typeof host.flushAuth !== 'function') {
1961
+ throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
1962
+ }
1963
+ const timeoutMs = 30_000
1964
+ const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
1965
+ this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
1966
+ return cookies
1967
+ }
1968
+
1969
+ /**
1970
+ * Remove cookies for one site scope. Challenge cookies that rotate their names
1971
+ * (WAF challenges) otherwise pile up generation after generation, and two live
1972
+ * generations in one request can be rejected by the site. Self-hosted only.
1973
+ */
1974
+ async clearAuth(session: BrowserSessionId, request: BrowserClearAuthRequest): Promise<BrowserClearAuthResult> {
1975
+ const s = this.session(session)
1976
+ const { handle } = this.activeTab(s)
1977
+ const clearable = handle as { clearCookies?(filter: BrowserClearAuthRequest): Promise<{ removed: number; names: readonly string[] }> }
1978
+ if (typeof clearable.clearCookies !== 'function') {
1979
+ throw new BrowserError('browser: cookie clearing is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
1980
+ }
1981
+ const timeoutMs = 30_000
1982
+ const result = await withTimeout(
1983
+ clearable.clearCookies(request),
1984
+ timeoutMs,
1985
+ undefined,
1986
+ 'browser: cookie clear timed out after ' + String(timeoutMs) + 'ms',
1987
+ )
1988
+ this.record(s, 'clearAuth', {
1989
+ ...request.domain !== undefined ? { domain: request.domain } : {},
1990
+ ...request.name !== undefined ? { name: request.name } : {},
1991
+ ...request.all === true ? { all: true } : {},
1992
+ }, true, { result: String(result.removed) + ' cookies' })
1993
+ return { removed: result.removed, names: [...result.names] }
1994
+ }
1995
+
1996
+ /**
1997
+ * Import cookies from a JSON export on disk.
1998
+ *
1999
+ * The path is read-guarded exactly like browser_upload_file: a prompt-injected
2000
+ * path must not turn this into a way to read a file the operator never allowed.
2001
+ * A browser cookie export cannot be produced automatically — Chrome and Edge
2002
+ * 127+ encrypt cookie values with App-Bound Encryption, so a copied profile
2003
+ * yields nothing — which is why this takes a file the user exported.
2004
+ */
2005
+ async importAuth(session: BrowserSessionId, path: string): Promise<{ restored: number; failed: number }> {
2006
+ const s = this.session(session)
2007
+ const target = resolveReadPath(path, this.readRoots)
2008
+ let parsed: unknown
2009
+ try {
2010
+ parsed = JSON.parse(readFileSync(target, 'utf8')) as unknown
2011
+ } catch (error) {
2012
+ throw new BrowserError(`browser: cannot read the cookie file: ${(error as Error).message}`, 'BROWSER_AUTH_FILE_INVALID')
2013
+ }
2014
+ // Accept both a bare array and the {"cookies": [...]} shape editors emit.
2015
+ const list = Array.isArray(parsed)
2016
+ ? parsed
2017
+ : typeof parsed === 'object' && parsed !== null && Array.isArray((parsed as { cookies?: unknown }).cookies)
2018
+ ? (parsed as { cookies: unknown[] }).cookies
2019
+ : undefined
2020
+ if (list === undefined) {
2021
+ throw new BrowserError('browser: the cookie file must be a JSON array or {"cookies": [...]}', 'BROWSER_AUTH_FILE_INVALID')
2022
+ }
2023
+ const usable = list.map(normalizeExportedCookie).filter((cookie): cookie is ExportedCookie => cookie !== undefined)
2024
+ if (usable.length === 0) {
2025
+ throw new BrowserError(`browser: the cookie file has no usable entries (${list.length} read)`, 'BROWSER_AUTH_FILE_INVALID')
2026
+ }
2027
+ const restored = await this.restoreAuth(session, usable)
2028
+ this.record(s, 'importAuth', { count: usable.length }, true, { result: `${restored} cookies` })
2029
+ return { restored, failed: list.length - usable.length }
2030
+ }
2031
+
2032
+ /** Import cookies into the session (restore login state). Self-hosted only. */
2033
+ async restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number> {
2034
+ const s = this.session(session)
2035
+ const { handle } = this.activeTab(s)
2036
+ const host = handle as { restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number> }
2037
+ if (typeof host.restoreAuth !== 'function') {
2038
+ throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
2039
+ }
2040
+ const timeoutMs = 30_000
2041
+ const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
2042
+ this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
2043
+ return restored
2044
+ }
2045
+
2046
+ /**
2047
+ * Start a background scrape batch.
2048
+ *
2049
+ * It runs detached on purpose: one tool call has a ~60s budget while a large
2050
+ * batch takes minutes. Progress is polled with scrapeStatus, and each row is
2051
+ * appended the moment it is produced, so a stopped or interrupted batch keeps
2052
+ * everything it managed. `outPath` is write-guarded like any other browser
2053
+ * write, and truncated up front so a re-run never mixes two batches.
2054
+ */
2055
+ async startScrape(session: BrowserSessionId, request: BrowserScrapeRequest): Promise<BrowserScrapeStatus> {
2056
+ const s = this.session(session)
2057
+ const urls = request.urls.filter(url => typeof url === 'string' && url.trim() !== '')
2058
+ if (urls.length === 0) {
2059
+ throw new BrowserError('browser: scrape needs at least one URL', 'BROWSER_SCRAPE_EMPTY')
2060
+ }
2061
+ if (typeof request.script !== 'string' || request.script.trim() === '') {
2062
+ throw new BrowserError('browser: scrape needs an extraction script', 'BROWSER_SCRAPE_EMPTY')
2063
+ }
2064
+ const target = resolveWritePath(request.outPath, this.writeRoots)
2065
+ writeFileSync(target, '')
2066
+ // Created only after every guard has passed, so a rejected start leaves no
2067
+ // orphaned view behind.
2068
+ const workers = Math.max(1, Math.min(Math.trunc(request.concurrency ?? 1) || 1, MAX_SCRAPE_WORKERS))
2069
+ const tabs: Tab[] = []
2070
+ for (let i = 0; i < workers; i += 1) {
2071
+ const handle = this.host.createView(s.taskKey, s.taskLabel === '' ? undefined : s.taskLabel)
2072
+ const tab = this.createTab(handle)
2073
+ s.tabs.push(tab)
2074
+ tabs.push(tab)
2075
+ }
2076
+ const job: ScrapeJob = {
2077
+ id: `scrape:${randomUUID()}`,
2078
+ session: s,
2079
+ tabs,
2080
+ path: target,
2081
+ state: 'running',
2082
+ total: urls.length,
2083
+ done: 0,
2084
+ failed: 0,
2085
+ }
2086
+ this.scrapes.set(job.id, job)
2087
+ void this.runScrape(job, urls, request).catch(error => {
2088
+ job.state = 'done'
2089
+ job.error = String((error as Error)?.message ?? error)
2090
+ })
2091
+ this.record(s, 'scrape', { total: urls.length }, true, { result: `${urls.length} urls` })
2092
+ return scrapeStatusOf(job)
2093
+ }
2094
+
2095
+ /** Progress of one batch. */
2096
+ async scrapeStatus(id: string): Promise<BrowserScrapeStatus> {
2097
+ return scrapeStatusOf(this.scrapeJob(id))
2098
+ }
2099
+
2100
+ /** Ask a running batch to stop; rows already written stay. */
2101
+ async stopScrape(id: string): Promise<BrowserScrapeStatus> {
2102
+ const job = this.scrapeJob(id)
2103
+ if (job.state === 'running') job.state = 'stopped'
2104
+ return scrapeStatusOf(job)
2105
+ }
2106
+
2107
+ /** Every batch this process knows about, oldest first. */
2108
+ async listScrapes(): Promise<readonly BrowserScrapeStatus[]> {
2109
+ return [...this.scrapes.values()].map(scrapeStatusOf)
2110
+ }
2111
+
2112
+ private scrapeJob(id: string): ScrapeJob {
2113
+ const job = this.scrapes.get(id)
2114
+ if (job === undefined) {
2115
+ throw new BrowserError(`browser: unknown scrape ${id}`, 'BROWSER_SCRAPE_UNKNOWN')
2116
+ }
2117
+ return job
2118
+ }
2119
+
2120
+ /**
2121
+ * Visit each URL once, appending one JSONL row per page.
2122
+ *
2123
+ * Workers pull from one shared index, so `concurrency` sets the throughput
2124
+ * without changing the work. Rows therefore land in completion order; each row
2125
+ * carries its URL's index as `seq` so the caller can restore the original.
2126
+ */
2127
+ private async runScrape(job: ScrapeJob, urls: readonly string[], request: BrowserScrapeRequest): Promise<void> {
2128
+ const perUrl = request.timeoutMs ?? 30_000
2129
+ let next = 0
2130
+ const take = (): number | undefined => (next < urls.length ? next++ : undefined)
2131
+
2132
+ const worker = async (tab: Tab): Promise<void> => {
2133
+ for (;;) {
2134
+ if (job.state !== 'running') return
2135
+ const index = take()
2136
+ if (index === undefined) return
2137
+ const url = urls[index] ?? ''
2138
+ let row: Record<string, unknown>
2139
+ try {
2140
+ // show: false — the batch works in the background tab it owns.
2141
+ // settleMs: 0 — it reads the DOM, so it must not pay the paint delay.
2142
+ await this.navigateTab(job.session, tab, url, undefined, false, 0)
2143
+ if (request.waitFor !== undefined) {
2144
+ await this.waitForElementTab(job.session, tab, { selector: request.waitFor, timeoutMs: perUrl })
2145
+ }
2146
+ const result = await this.executeTab(job.session, tab, { script: request.script, timeoutMs: perUrl })
2147
+ if (result.ok) {
2148
+ row = { seq: index, url, ok: true, data: result.value }
2149
+ } else {
2150
+ job.failed += 1
2151
+ row = { seq: index, url, ok: false, error: result.exception }
2152
+ }
2153
+ } catch (error) {
2154
+ // One bad page must not end the batch: record it and keep going.
2155
+ job.failed += 1
2156
+ row = { seq: index, url, ok: false, error: String((error as Error)?.message ?? error) }
2157
+ }
2158
+ // appendFileSync blocks, so two workers can never interleave a row.
2159
+ appendFileSync(job.path, scrapeRow(row))
2160
+ job.done += 1
2161
+ }
2162
+ }
2163
+
2164
+ try {
2165
+ await Promise.all(job.tabs.map(tab => worker(tab)))
2166
+ job.state = 'done'
2167
+ } finally {
2168
+ // The tabs outlive the loop only until here; a stopped batch cleans up too.
2169
+ this.destroyScrapeTabs(job)
2170
+ }
2171
+ }
2172
+
2173
+ /** Drop a batch's private tabs (and their views) once the batch is over. */
2174
+ private destroyScrapeTabs(job: ScrapeJob): void {
2175
+ const s = job.session
2176
+ for (const tab of job.tabs) {
2177
+ const index = s.tabs.findIndex(candidate => candidate.id === tab.id)
2178
+ if (index < 0) continue
2179
+ s.tabs.splice(index, 1)
2180
+ this.ignoreHostFailure(this.host.destroyView(tab.handle))
2181
+ // Same index bookkeeping as closeTab: a batch tab is normally not active,
2182
+ // but a tool call could have activated one mid-batch.
2183
+ if (s.tabs.length === 0) {
2184
+ this.newTab(s)
2185
+ } else if (index < s.activeIndex) {
2186
+ s.activeIndex -= 1
2187
+ } else if (s.activeIndex >= s.tabs.length) {
2188
+ s.activeIndex = s.tabs.length - 1
2189
+ }
2190
+ }
2191
+ this.showActive(s)
2192
+ }
2193
+
2194
+ /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
2195
+ /**
2196
+ * Draw the DevTools highlight box over the first match of a selector, or clear it.
2197
+ * Uses CDP Overlay, so the page DOM is never touched.
2198
+ */
2199
+ async highlight(
2200
+ session: BrowserSessionId,
2201
+ request: BrowserHighlightRequest,
2202
+ signal?: AbortSignal,
2203
+ ): Promise<BrowserHighlightResult> {
2204
+ const s = this.session(session)
2205
+ const { handle } = this.activeTab(s)
2206
+ signal?.throwIfAborted()
2207
+ if (request.clear === true) {
2208
+ this.ignoreHostFailure(handle.sendCommand(CDP_OVERLAY_HIDE_HIGHLIGHT, {}))
2209
+ return { matched: false, cleared: true }
2210
+ }
2211
+ const selector = request.selector ?? ''
2212
+ if (selector === '') {
2213
+ throw new BrowserError('browser: highlight needs a selector, or clear: true', 'BROWSER_HIGHLIGHT_INVALID')
2214
+ }
2215
+ const doc = await handle.sendCommand('DOM.getDocument', {})
2216
+ const rootId = (doc as { root?: { nodeId?: number } }).root?.nodeId
2217
+ if (typeof rootId !== 'number') {
2218
+ throw new BrowserError('browser: could not read the document root', 'BROWSER_HIGHLIGHT_FAILED')
2219
+ }
2220
+ const found = await handle.sendCommand('DOM.querySelector', { nodeId: rootId, selector })
2221
+ const nodeId = (found as { nodeId?: number }).nodeId ?? 0
2222
+ if (nodeId === 0) return { matched: false, cleared: false }
2223
+ let box: { x: number; y: number; width: number; height: number } | undefined
2224
+ try {
2225
+ const model = await handle.sendCommand('DOM.getBoxModel', { nodeId })
2226
+ const quad = (model as { model?: { content?: number[] } }).model?.content
2227
+ if (Array.isArray(quad) && quad.length >= 8) {
2228
+ const xs = [quad[0], quad[2], quad[4], quad[6]]
2229
+ const ys = [quad[1], quad[3], quad[5], quad[7]]
2230
+ const x = Math.min(...xs)
2231
+ const y = Math.min(...ys)
2232
+ box = { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y }
2233
+ }
2234
+ } catch { /* a zero-size element has no box model; the box is still highlighted */ }
2235
+ await handle.sendCommand(CDP_OVERLAY_ENABLE, {})
2236
+ await handle.sendCommand(CDP_OVERLAY_HIGHLIGHT_NODE, {
2237
+ nodeId,
2238
+ highlightConfig: {
2239
+ showInfo: true,
2240
+ contentColor: { r: 77, g: 107, b: 254, a: 0.28 },
2241
+ borderColor: { r: 77, g: 107, b: 254, a: 0.9 },
2242
+ },
2243
+ })
2244
+ this.record(s, 'highlight', { selector }, true, { result: `node ${nodeId}` })
2245
+ return { matched: true, cleared: false, nodeId, ...box !== undefined ? { box } : {} }
2246
+ }
2247
+
2248
+
2249
+ /** Print the active tab to a PDF file (Chrome's "Save as PDF"). */
2250
+ async pdf(
2251
+ session: BrowserSessionId,
2252
+ request: BrowserPdfRequest,
2253
+ signal?: AbortSignal,
2254
+ ): Promise<BrowserPdfResult> {
2255
+ const s = this.session(session)
2256
+ const { handle } = this.activeTab(s)
2257
+ signal?.throwIfAborted()
2258
+ // Resolve first: a bad path must fail before we spend time printing.
2259
+ const target = resolveWritePath(request.savePath, this.writeRoots)
2260
+ // Electron's debugger does not implement CDP `Page.printToPDF`, so a host
2261
+ // that can print natively does it and hands back base64. Paper size arrives
2262
+ // in inches; Electron wants microns.
2263
+ const timeoutMs = 60_000
2264
+ const printOnce = async (): Promise<string> => {
2265
+ if (handle.printToPdf !== undefined) {
2266
+ const options: Record<string, unknown> = {
2267
+ // Backgrounds off by default would print dark pages as white paper.
2268
+ printBackground: request.printBackground !== false,
2269
+ landscape: request.landscape === true,
2270
+ }
2271
+ if (request.paperWidth !== undefined || request.paperHeight !== undefined) {
2272
+ options.pageSize = {
2273
+ width: Math.round((request.paperWidth ?? 8.5) * 25_400),
2274
+ height: Math.round((request.paperHeight ?? 11) * 25_400),
2275
+ }
2276
+ }
2277
+ const printed = await handle.printToPdf(options)
2278
+ return printed.base64
2279
+ }
2280
+ const params: Record<string, unknown> = {
2281
+ printBackground: request.printBackground !== false,
2282
+ landscape: request.landscape === true,
2283
+ }
2284
+ if (request.paperWidth !== undefined) params.paperWidth = request.paperWidth
2285
+ if (request.paperHeight !== undefined) params.paperHeight = request.paperHeight
2286
+ const result = await handle.sendCommand(CDP_PAGE_PRINT_TO_PDF, params)
2287
+ return typeof result.data === 'string' ? result.data : ''
2288
+ }
2289
+ const data = await withTimeout(printOnce(), timeoutMs, signal, `browser: pdf timed out after ${timeoutMs}ms`)
2290
+ if (data === '') {
2291
+ throw new BrowserError('browser: printToPDF returned no data', 'BROWSER_PDF_FAILED')
2292
+ }
2293
+ const bytes = Buffer.from(data, 'base64')
2294
+ writeFileSync(target, bytes)
2295
+ this.record(s, 'pdf', { savePath: request.savePath, landscape: request.landscape === true }, true, { result: `${bytes.length} bytes` })
2296
+ return { path: request.savePath, bytes: bytes.length }
2297
+ }
2298
+
2299
+
2300
+ async screenshot(
2301
+ session: BrowserSessionId,
2302
+ request?: { readonly fullPage?: boolean; readonly savePath?: string },
2303
+ signal?: AbortSignal,
2304
+ ): Promise<{ readonly dataUrl: string; readonly path?: string }> {
2305
+ const s = this.session(session)
2306
+ const { handle } = this.activeTab(s)
2307
+ signal?.throwIfAborted()
2308
+ // Native capturePage path (self-hosted): CDP Page.captureScreenshot can
2309
+ // hang indefinitely on a view once another (hidden) WebContentsView exists
2310
+ // in the shared window; capturePage is fast for the visible task view and resolves
2311
+ // immediately (empty) for hidden ones.
2312
+ const capturable = handle as { capture?(): Promise<{ base64: string; width: number; height: number }> }
2313
+ if (request?.fullPage !== true && typeof capturable.capture === 'function') {
2314
+ // Ensure the target view is the visible one before capturing.
2315
+ this.showActive(s)
2316
+ const timeoutMs = 30_000
2317
+ const shot = await withTimeout(
2318
+ capturable.capture(),
2319
+ timeoutMs,
2320
+ signal,
2321
+ `browser: screenshot timed out after ${timeoutMs}ms`,
2322
+ )
2323
+ if (shot.base64 === '') {
2324
+ throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED')
2325
+ }
2326
+ return this.saveScreenshot(shot.base64, request?.savePath)
2327
+ }
2328
+ // Fallback: a desktop-shell handle (no capture()) or a full-page capture
2329
+ // uses CDP; full-page needs `captureBeyondViewport` which capturePage lacks.
2330
+ const params: Record<string, unknown> = {}
2331
+ if (request?.fullPage === true) {
2332
+ // `captureBeyondViewport` captures the full scrollable content; without
2333
+ // a clip this yields the full-page image (CDP default is the viewport).
2334
+ params.captureBeyondViewport = true
2335
+ }
2336
+ const timeoutMs = 30_000
2337
+ const result = await withTimeout(
2338
+ handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params),
2339
+ timeoutMs,
2340
+ signal,
2341
+ `browser: screenshot timed out after ${timeoutMs}ms`,
2342
+ )
2343
+ const data = result.data
2344
+ if (typeof data !== 'string') {
2345
+ throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
2346
+ }
2347
+ return this.saveScreenshot(data, request?.savePath)
2348
+ }
2349
+
2350
+ /** Build the data URL and optionally write the PNG to disk. */
2351
+ private saveScreenshot(base64: string, savePath?: string): { dataUrl: string; path?: string } {
2352
+ if (savePath !== undefined) {
2353
+ // Admit before writing so a denied path never touches disk.
2354
+ const target = resolveWritePath(savePath, this.writeRoots)
2355
+ try {
2356
+ writeFileSync(target, Buffer.from(base64, 'base64'))
2357
+ return { dataUrl: `data:image/png;base64,${base64}`, path: savePath }
2358
+ } catch (error) {
2359
+ // Report the write problem but keep the capture usable.
2360
+ throw new BrowserError(`browser: screenshot save to "${savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
2361
+ }
2362
+ }
2363
+ return { dataUrl: `data:image/png;base64,${base64}` }
2364
+ }
2365
+
2366
+ /**
2367
+ * Pick up (and forget) any JS dialog the host auto-accepted, so the
2368
+ * operation trail shows the human/agent what the page asked. Best-effort.
2369
+ */
2370
+ private async drainDialog(s: Session, handle: ElectronViewHandle): Promise<void> {
2371
+ const drainable = handle as { clearDialog?(): Promise<unknown> }
2372
+ if (typeof drainable.clearDialog !== 'function') return
2373
+ try {
2374
+ const dialog = await drainable.clearDialog()
2375
+ if (dialog !== null && dialog !== undefined) {
2376
+ // Keep it: browser_dialog inspect reports the last one, and drainDialog is the
2377
+ // only place the host hands it over.
2378
+ s.lastDialog = dialog
2379
+ this.record(s, 'dialog', dialog as Record<string, unknown>, true)
2380
+ }
2381
+ } catch {
2382
+ // Dialog supervision is cosmetic; never fail a page operation for it.
2383
+ }
2384
+ }
2385
+
2386
+ /**
2387
+ * Set how the host answers the next JS dialog, and report the resulting state.
2388
+ *
2389
+ * The policy lives in the host (it answers the CDP event there, where a
2390
+ * round-trip would already be too late), so this is a push, not a pull.
2391
+ */
2392
+ async setDialogPolicy(session: BrowserSessionId, policy: DialogPolicy): Promise<{ dialog: unknown; policy: DialogPolicy }> {
2393
+ const s = this.session(session)
2394
+ const { handle } = this.activeTab(s)
2395
+ const normalized: DialogPolicy = policy.behavior === 'dismiss'
2396
+ ? (policy.promptText === undefined ? { behavior: 'dismiss' } : { behavior: 'dismiss', promptText: policy.promptText })
2397
+ : (policy.promptText === undefined ? { behavior: 'accept' } : { behavior: 'accept', promptText: policy.promptText })
2398
+ const pushable = handle as { setDialogPolicy?(policy: DialogPolicy): Promise<unknown> }
2399
+ if (typeof pushable.setDialogPolicy === 'function') {
2400
+ await pushable.setDialogPolicy(normalized)
2401
+ }
2402
+ s.dialogPolicy = normalized
2403
+ this.record(s, 'dialog-policy', { ...normalized }, true)
2404
+ return { dialog: s.lastDialog ?? null, policy: normalized }
2405
+ }
2406
+
2407
+ /**
2408
+ * Console messages the host captured for the active tab.
2409
+ *
2410
+ * Reading does NOT clear by default: debugging is usually a look-again loop, so
2411
+ * `clear: true` is explicit. The host keeps a bounded ring, so old entries fall
2412
+ * off on their own.
2413
+ */
2414
+ async consoleMessages(session: BrowserSessionId, options: { limit?: number; level?: string; clear?: boolean } = {}): Promise<{ messages: BrowserConsoleMessage[] }> {
2415
+ const s = this.session(session)
2416
+ const { handle } = this.activeTab(s)
2417
+ const reader = handle as { readConsole?(clear?: boolean): Promise<unknown> }
2418
+ if (typeof reader.readConsole !== 'function') return { messages: [] }
2419
+ const raw = await reader.readConsole(options.clear === true) as { messages?: unknown } | null | undefined
2420
+ const list = Array.isArray(raw?.messages) ? raw.messages as BrowserConsoleMessage[] : []
2421
+ const filtered = options.level === undefined ? list : list.filter(entry => entry.level === options.level)
2422
+ const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)))
2423
+ return { messages: filtered.slice(-limit) }
2424
+ }
2425
+
2426
+ /** Network requests the host captured for the active tab (bounded ring). */
2427
+ async networkRequests(session: BrowserSessionId, options: { limit?: number; failedOnly?: boolean; urlContains?: string; clear?: boolean } = {}): Promise<{ requests: BrowserNetworkRequest[] }> {
2428
+ const s = this.session(session)
2429
+ const { handle } = this.activeTab(s)
2430
+ const reader = handle as { readNetwork?(clear?: boolean): Promise<unknown> }
2431
+ if (typeof reader.readNetwork !== 'function') return { requests: [] }
2432
+ const raw = await reader.readNetwork(options.clear === true) as { requests?: unknown } | null | undefined
2433
+ const list = Array.isArray(raw?.requests) ? raw.requests as BrowserNetworkRequest[] : []
2434
+ const needle = (options.urlContains ?? '').toLowerCase()
2435
+ const filtered = list.filter(entry => (options.failedOnly !== true || entry.failed !== undefined)
2436
+ && (needle === '' || entry.url.toLowerCase().includes(needle)))
2437
+ const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)))
2438
+ return { requests: filtered.slice(-limit) }
2439
+ }
2440
+
2441
+ /**
2442
+ * Apply device/viewport/media emulation to the active tab.
2443
+ *
2444
+ * Plain CDP through the existing command path, so no host change was needed.
2445
+ * `clear` undoes all three: metrics, user agent, and emulated media.
2446
+ */
2447
+ async emulate(session: BrowserSessionId, options: EmulateOptions = {}): Promise<{ applied: string[] }> {
2448
+ const s = this.session(session)
2449
+ const { handle } = this.activeTab(s)
2450
+ const applied: string[] = []
2451
+ if (options.clear === true) {
2452
+ await handle.sendCommand('Emulation.clearDeviceMetricsOverride', {})
2453
+ await handle.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
2454
+ await handle.sendCommand('Emulation.setEmulatedMedia', { media: '', features: [] })
2455
+ this.record(s, 'emulate', { clear: true }, true)
2456
+ return { applied: ['cleared'] }
2457
+ }
2458
+ if (options.width !== undefined && options.height !== undefined) {
2459
+ const width = Math.max(1, Math.trunc(options.width))
2460
+ const height = Math.max(1, Math.trunc(options.height))
2461
+ await handle.sendCommand('Emulation.setDeviceMetricsOverride', {
2462
+ width,
2463
+ height,
2464
+ deviceScaleFactor: options.deviceScaleFactor ?? 0,
2465
+ mobile: options.mobile === true,
2466
+ })
2467
+ applied.push('viewport ' + String(width) + 'x' + String(height) + (options.mobile === true ? ' mobile' : ''))
2468
+ }
2469
+ if (options.userAgent !== undefined) {
2470
+ await handle.sendCommand('Emulation.setUserAgentOverride', { userAgent: options.userAgent })
2471
+ applied.push('user-agent')
2472
+ }
2473
+ if (options.colorScheme !== undefined) {
2474
+ await handle.sendCommand('Emulation.setEmulatedMedia', {
2475
+ media: '',
2476
+ features: [{ name: 'prefers-color-scheme', value: options.colorScheme }],
2477
+ })
2478
+ applied.push('color-scheme ' + options.colorScheme)
2479
+ }
2480
+ this.record(s, 'emulate', { ...applied.length === 0 ? { noop: true } : {} }, true)
2481
+ return { applied }
2482
+ }
2483
+
2484
+ /**
2485
+ * Drain any dialog that opened since the last input call, then report the last
2486
+ * one and the current policy.
2487
+ *
2488
+ * `dialogState` alone is not enough for the tool: the host only hands a dialog
2489
+ * over when something drains it, so an inspect that does not drain misses exactly
2490
+ * the dialog the caller just triggered. (Found on the real machine.)
2491
+ */
2492
+ async inspectDialog(session: BrowserSessionId): Promise<{ dialog: unknown; policy: DialogPolicy }> {
2493
+ const s = this.session(session)
2494
+ const { handle } = this.activeTab(s)
2495
+ await this.drainDialog(s, handle)
2496
+ return { dialog: s.lastDialog ?? null, policy: s.dialogPolicy ?? { behavior: 'accept' } }
2497
+ }
2498
+
2499
+ /** The last JS dialog the host reported, plus the current policy. */
2500
+ dialogState(session: BrowserSessionId): { dialog: unknown; policy: DialogPolicy } {
2501
+ const s = this.session(session)
2502
+ return { dialog: s.lastDialog ?? null, policy: s.dialogPolicy ?? { behavior: 'accept' } }
2503
+ }
2504
+
2505
+ /** Name this browser task (space). */
2506
+ async setSpace(session: BrowserSessionId, label: string): Promise<void> {
2507
+ const s = this.session(session)
2508
+ const { handle } = this.activeTab(s)
2509
+ const labelable = handle as { label?(label: string): Promise<void> }
2510
+ if (typeof labelable.label === 'function') {
2511
+ await labelable.label(label)
2512
+ } else {
2513
+ throw new BrowserError('browser: space naming is only available on the self-hosted browser', 'BROWSER_SPACE_UNSUPPORTED')
2514
+ }
2515
+ s.taskLabel = label
2516
+ this.record(s, 'setSpace', { label }, true)
2517
+ }
2518
+
2519
+ /** List every browser task (space) with its label. */
2520
+ async listSpaces(): Promise<readonly BrowserSpaceInfo[]> {
2521
+ const host = this.host as { listWindows?(): Promise<Array<{ key: string; label: string }>> }
2522
+ if (typeof host.listWindows !== 'function') return []
2523
+ return host.listWindows()
2524
+ }
2525
+
2526
+ /** List browser tasks with live collaboration status. */
2527
+ async listTasks(): Promise<readonly BrowserTaskInfo[]> {
2528
+ if (typeof this.host.listTasks === 'function') {
2529
+ const tasks = await this.host.listTasks()
2530
+ for (const task of tasks) this.rememberHostedTask(task)
2531
+ return tasks
2532
+ }
2533
+ const tasks = new Map<string, BrowserTaskInfo>()
2534
+ for (const session of this.sessions.values()) {
2535
+ const current = tasks.get(session.taskKey)
2536
+ const next = this.localTaskInfo(session)
2537
+ tasks.set(session.taskKey, current === undefined
2538
+ ? next
2539
+ : { ...next, tabs: current.tabs + next.tabs, active: current.active || next.active })
2540
+ }
2541
+ return [...tasks.values()]
2542
+ }
2543
+
2544
+ /** Read the collaboration state for one session's task. */
2545
+ async getTask(session: BrowserSessionId): Promise<BrowserTaskInfo> {
2546
+ const s = this.session(session)
2547
+ const hosted = typeof this.host.getTask === 'function' ? await this.host.getTask(s.taskKey) : undefined
2548
+ if (hosted !== undefined) {
2549
+ this.rememberHostedTask(hosted)
2550
+ return hosted
2551
+ }
2552
+ return this.localTaskInfo(s)
2553
+ }
2554
+
2555
+ /** Apply one visible task state update and mirror it to a supporting host. */
2556
+ async updateTask(session: BrowserSessionId, update: BrowserTaskUpdate): Promise<BrowserTaskInfo> {
2557
+ const s = this.session(session)
2558
+ const previous = this.taskStates.get(s.taskKey) ?? { status: 'idle' as const, control: 'agent' as const, updatedAt: Date.now() }
2559
+ const next: LocalTaskState = {
2560
+ ...previous,
2561
+ ...update.status !== undefined ? { status: update.status } : {},
2562
+ ...update.control !== undefined ? { control: update.control } : {},
2563
+ ...update.latestAction !== undefined ? { latestAction: update.latestAction } : {},
2564
+ ...update.error !== undefined ? { error: update.error.slice(0, 180) } : {},
2565
+ updatedAt: Date.now(),
2566
+ }
2567
+ if (next.status !== 'failed' && update.error === undefined) delete next.error
2568
+ this.taskStates.set(s.taskKey, next)
2569
+ // Only send fields this call intentionally changes. A page-side handoff
2570
+ // can update the host between two Agent operations; replaying a stale
2571
+ // cached control field here would overwrite the newer human choice.
2572
+ const hosted = typeof this.host.updateTask === 'function'
2573
+ ? await this.host.updateTask(s.taskKey, {
2574
+ ...update.status !== undefined ? { status: update.status } : {},
2575
+ ...update.control !== undefined ? { control: update.control } : {},
2576
+ ...update.latestAction !== undefined ? { latestAction: update.latestAction } : {},
2577
+ ...update.error !== undefined ? { error: update.error.slice(0, 180) } : {},
2578
+ })
2579
+ : undefined
2580
+ if (hosted !== undefined) {
2581
+ this.rememberHostedTask(hosted)
2582
+ return hosted
2583
+ }
2584
+ return this.localTaskInfo(s)
2585
+ }
2586
+
2587
+ /**
2588
+ * Mirror one task's Agent todo list into the host so the floating orb can
2589
+ * render it. The list is the DSH session projection for that task's session;
2590
+ * only this side can read it, and only the host can reach the chrome.
2591
+ *
2592
+ * Unknown task keys are still stored: an Agent usually writes its plan before
2593
+ * it opens a page, and the orb should show that plan the moment the task has a
2594
+ * view rather than wait for the next write.
2595
+ */
2596
+ async pushTaskTodos(taskKey: string, todos: readonly BrowserTaskTodo[]): Promise<void> {
2597
+ this.taskTodos.set(taskKey, todos)
2598
+ if (typeof this.host.setTaskTodos !== 'function') return
2599
+ // 只在「这个任务真的开过浏览器」时才转发:宿主 RPC 的第一步是 ready(),
2600
+ // 而 ready() 会 spawn 一个 Electron 子进程 —— 一个和浏览器无关的 todo_write
2601
+ // 不该把几百 MB 的宿主拉起来。缓存照存,open() 会给新会话补推。
2602
+ if (!this.sessionsByTask.has(taskKey)) return
2603
+ await this.host.setTaskTodos(taskKey, todos).catch(() => undefined)
2604
+ }
2605
+
2606
+ /** Hand control to the user or return it to Agent-driven actions. */
2607
+ async setHandoff(session: BrowserSessionId, state: BrowserHandoffState): Promise<BrowserTaskInfo> {
2608
+ return this.updateTask(session, state === 'waiting-user'
2609
+ ? { status: 'waiting-user', control: 'human', latestAction: 'waiting for user' }
2610
+ : { status: 'idle', control: 'agent', latestAction: 'agent resumed' })
2611
+ }
2612
+
2613
+ /** Append one operation to the session's history. */
2614
+ private record(
2615
+ s: Session,
2616
+ action: string,
2617
+ params: Record<string, unknown>,
2618
+ ok: boolean,
2619
+ detail?: { result?: string; error?: string },
2620
+ ): void {
2621
+ const entry: BrowserHistoryEntry = {
2622
+ seq: s.nextSeq++,
2623
+ action,
2624
+ params: clampHistoryParams(params),
2625
+ ok,
2626
+ ...detail?.result !== undefined ? { result: detail.result } : {},
2627
+ ...detail?.error !== undefined ? { error: detail.error } : {},
2628
+ at: Date.now(),
2629
+ }
2630
+ s.history.push(entry)
2631
+ // Bound memory: keep the last 500 operations.
2632
+ if (s.history.length > 500) s.history.splice(0, s.history.length - 500)
2633
+ // Mirror onto the human-facing trail in the shared window (best-effort).
2634
+ const tab = s.tabs[s.activeIndex]
2635
+ const state = this.taskStates.get(s.taskKey)
2636
+ if (state !== undefined) {
2637
+ state.latestAction = action
2638
+ state.updatedAt = entry.at
2639
+ }
2640
+ try {
2641
+ this.host.trace?.(tab?.handle.id ?? 'default', { action, params, ok, at: entry.at })
2642
+ const pending = this.host.updateTask?.(s.taskKey, { latestAction: action })
2643
+ void pending?.catch(() => undefined)
2644
+ } catch { /* trail is cosmetic */ }
2645
+ }
2646
+
2647
+ /** Return the session's chronological operation log (newest last). */
2648
+ async history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]> {
2649
+ return this.session(session).history
2650
+ }
2651
+
2652
+ /**
2653
+ * Replay one recorded operation by sequence number. Navigate/click/type are
2654
+ * re-issued against the current page; execute re-runs its script. The
2655
+ * replayed step is appended to history as a new entry.
2656
+ * @param session - the session id.
2657
+ * @param seq - the recorded entry's sequence number to replay.
2658
+ */
2659
+ async replay(session: BrowserSessionId, seq: number): Promise<void> {
2660
+ const s = this.session(session)
2661
+ const entry = s.history.find(e => e.seq === seq)
2662
+ if (entry === undefined) {
2663
+ throw new BrowserError(`browser: no history entry with seq ${seq}`, 'BROWSER_HISTORY_UNKNOWN')
2664
+ }
2665
+ switch (entry.action) {
2666
+ case 'navigate': {
2667
+ const url = entry.params.url
2668
+ if (typeof url !== 'string') throw new BrowserError(`browser: history seq ${seq} navigate has no url`, 'BROWSER_HISTORY_INVALID')
2669
+ await this.navigate(session, { url })
2670
+ this.record(s, 'replay', { seq, of: entry.action, url }, true)
2671
+ return
2672
+ }
2673
+ case 'click': {
2674
+ const x = entry.params.x
2675
+ const y = entry.params.y
2676
+ if (typeof x !== 'number' || typeof y !== 'number') throw new BrowserError(`browser: history seq ${seq} click has no coordinates`, 'BROWSER_HISTORY_INVALID')
2677
+ await this.click(session, { x, y })
2678
+ this.record(s, 'replay', { seq, of: entry.action, x, y }, true)
2679
+ return
2680
+ }
2681
+ case 'type': {
2682
+ const text = entry.params.text
2683
+ if (typeof text !== 'string') throw new BrowserError(`browser: history seq ${seq} type has no text`, 'BROWSER_HISTORY_INVALID')
2684
+ if (entry.params.textTruncated === true) {
2685
+ throw new BrowserError(`browser: history seq ${seq} text was too long to keep in full; replay is not possible`, 'BROWSER_HISTORY_TRUNCATED')
2686
+ }
2687
+ await this.type(session, { text })
2688
+ this.record(s, 'replay', { seq, of: entry.action, text }, true)
2689
+ return
2690
+ }
2691
+ case 'execute': {
2692
+ const script = entry.params.script
2693
+ if (typeof script !== 'string') throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID')
2694
+ if (entry.params.scriptTruncated === true) {
2695
+ throw new BrowserError(`browser: history seq ${seq} script was too long to keep in full; replay is not possible`, 'BROWSER_HISTORY_TRUNCATED')
2696
+ }
2697
+ const recordedArgs = entry.params.args
2698
+ const args = Array.isArray(recordedArgs) ? recordedArgs.filter((a): a is string => typeof a === 'string') : undefined
2699
+ const result = await this.execute(session, { script, ...args !== undefined && args.length > 0 ? { args } : {} })
2700
+ this.record(s, 'replay', { seq, of: entry.action, script, ...args !== undefined && args.length > 0 ? { args } : {} }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception })
2701
+ return
2702
+ }
2703
+ default:
2704
+ throw new BrowserError(`browser: history seq ${seq} action "${entry.action}" is not replayable`, 'BROWSER_HISTORY_NOT_REPLAYABLE')
2705
+ }
2706
+ }
2707
+
2708
+ /**
2709
+ * Put the shared window on screen and raise it. With no session yet this
2710
+ * opens the default one, which spawns the host and creates the first view —
2711
+ * that is what makes the window appear. With a session already open the
2712
+ * window exists, so it only has to be raised.
2713
+ */
2714
+ async ensureWindowVisible(): Promise<void> {
2715
+ // With no session yet this opens the default one. Its handle is lazy —
2716
+ // `createView` only records a deferred view — so the raise below is ALSO
2717
+ // what spawns the host and puts the window on screen. Returning right after
2718
+ // `open()` would leave the window to appear whenever some later call
2719
+ // happened to materialize the view.
2720
+ if (this.sessions.size === 0) await this.open()
2721
+ for (const s of this.sessions.values()) {
2722
+ const tab = s.tabs[s.activeIndex] ?? s.tabs[0]
2723
+ if (tab === undefined) continue
2724
+ if (tab.handle.focusWindow !== undefined) await tab.handle.focusWindow()
2725
+ break
2726
+ }
2727
+ // 「把窗口带出来」也是「有人要看了」——顺手把手里存的计划补推一遍。
2728
+ // 宿主重启会丢掉它内存里那份,而桥接只在清单**变化**时推;没有这一步,
2729
+ // 重启后球上会一直空着,直到 Agent 再写一次 todo(review 报的 M1)。
2730
+ for (const [taskKey, todos] of this.taskTodos) {
2731
+ if (!this.sessionsByTask.has(taskKey)) continue
2732
+ if (typeof this.host.setTaskTodos !== 'function') break
2733
+ await this.host.setTaskTodos(taskKey, todos).catch(() => undefined)
2734
+ }
2735
+ }
2736
+
2737
+ /** Close the session and destroy all its views. Idempotent. */
2738
+ close(session: BrowserSessionId): Promise<void> {
2739
+ const existing = this.sessions.get(session)
2740
+ if (existing !== undefined) {
2741
+ this.sessions.delete(session)
2742
+ for (const tab of existing.tabs) this.ignoreHostFailure(this.host.destroyView(tab.handle))
2743
+ const replacement = [...this.sessions.values()].find(candidate => candidate.taskKey === existing.taskKey)
2744
+ if (this.sessionsByTask.get(existing.taskKey) === session) {
2745
+ if (replacement === undefined) this.sessionsByTask.delete(existing.taskKey)
2746
+ else this.sessionsByTask.set(existing.taskKey, replacement.id)
2747
+ }
2748
+ if (replacement === undefined) {
2749
+ this.taskStates.delete(existing.taskKey)
2750
+ // 这张表和 taskStates 一样是「每个任务一条」,关掉最后一个会话就该回收。
2751
+ this.taskTodos.delete(existing.taskKey)
2752
+ }
2753
+ }
2754
+ return Promise.resolve()
2755
+ }
2756
+
2757
+ /** Recover the live session associated with a stable task key. */
2758
+ private sessionForTask(taskKey: string): Session | undefined {
2759
+ const indexed = this.sessionsByTask.get(taskKey)
2760
+ if (indexed !== undefined) {
2761
+ const session = this.sessions.get(indexed)
2762
+ if (session !== undefined) return session
2763
+ this.sessionsByTask.delete(taskKey)
2764
+ }
2765
+ for (const session of this.sessions.values()) {
2766
+ if (session.taskKey === taskKey) {
2767
+ this.sessionsByTask.set(taskKey, session.id)
2768
+ return session
2769
+ }
2770
+ }
2771
+ return undefined
2772
+ }
2773
+
2774
+ /** Look up a session or throw the unknown-session error. */
2775
+ private session(session: BrowserSessionId): Session {
2776
+ const existing = this.sessions.get(session)
2777
+ if (existing === undefined) {
2778
+ throw new BrowserError(`browser: session "${session}" is not open`, 'BROWSER_SESSION_UNKNOWN')
2779
+ }
2780
+ return existing
2781
+ }
2782
+
2783
+ /** The active tab of a session. */
2784
+ private activeTab(s: Session): Tab {
2785
+ const tab = s.tabs[s.activeIndex]
2786
+ if (tab === undefined) throw new BrowserError('browser: session has no active tab', 'BROWSER_TAB_UNKNOWN')
2787
+ return tab
2788
+ }
2789
+
2790
+ /** Navigate through the browser history while preserving page readiness behavior. */
2791
+ private async navigateHistory(
2792
+ session: BrowserSessionId,
2793
+ direction: -1 | 1,
2794
+ action: 'back' | 'forward',
2795
+ signal?: AbortSignal,
2796
+ ): Promise<boolean> {
2797
+ const s = this.session(session)
2798
+ const tab = this.activeTab(s)
2799
+ signal?.throwIfAborted()
2800
+ const history = await withTimeout(
2801
+ tab.handle.sendCommand(CDP_PAGE_GET_NAVIGATION_HISTORY, {}),
2802
+ 15_000,
2803
+ signal,
2804
+ 'browser: history lookup timed out',
2805
+ )
2806
+ const currentIndex = typeof history.currentIndex === 'number' ? history.currentIndex : -1
2807
+ const entries = Array.isArray(history.entries) ? history.entries as Array<{ id?: unknown }> : []
2808
+ const target = entries[currentIndex + direction]
2809
+ if (target === undefined || typeof target.id !== 'number') {
2810
+ this.record(s, action, { navigated: false }, true)
2811
+ return false
2812
+ }
2813
+ await withTimeout(
2814
+ tab.handle.sendCommand(CDP_PAGE_NAVIGATE_TO_HISTORY_ENTRY, { entryId: target.id }),
2815
+ 30_000,
2816
+ signal,
2817
+ `browser: ${action} timed out after 30000ms`,
2818
+ )
2819
+ this.invalidateSnapshots(tab)
2820
+ this.record(s, action, { navigated: true }, true)
2821
+ this.showActive(s)
2822
+ await waitForDocumentReady(tab.handle, signal)
2823
+ void reinstallPageChrome(tab.handle)
2824
+ return true
2825
+ }
2826
+
2827
+ /** Drop every reference that was captured before a document transition. */
2828
+ private invalidateSnapshots(tab: Tab): void {
2829
+ tab.navigationEpoch += 1
2830
+ tab.snapshots.clear()
2831
+ }
2832
+
2833
+ /** Resolve one exact snapshot reference, rejecting any changed or missing target. */
2834
+ private async resolveSnapshotTarget(
2835
+ tab: Tab,
2836
+ request: BrowserRefRequest,
2837
+ block: 'start' | 'center' | 'end' | 'nearest',
2838
+ signal?: AbortSignal,
2839
+ ): Promise<BrowserScrollResult & { readonly x: number; readonly y: number }> {
2840
+ const record = tab.snapshots.get(request.snapshotId)
2841
+ if (record === undefined) {
2842
+ throw new BrowserError(`browser: snapshot "${request.snapshotId}" is not available in this tab`, 'BROWSER_SNAPSHOT_UNKNOWN')
2843
+ }
2844
+ if (record.tabId !== tab.id || record.epoch !== tab.navigationEpoch) {
2845
+ throw new BrowserError(`browser: snapshot "${request.snapshotId}" is stale`, 'BROWSER_SNAPSHOT_STALE')
2846
+ }
2847
+ const target = record.targets.get(request.ref)
2848
+ if (target === undefined) {
2849
+ throw new BrowserError(`browser: snapshot "${request.snapshotId}" has no element ref ${request.ref}`, 'BROWSER_REF_UNKNOWN')
2850
+ }
2851
+ const script = `(() => {
2852
+ if (location.href !== ${JSON.stringify(record.url)}) return { stale: 'url changed' }
2853
+ // 框架里的元素:先进入对应的同源 iframe,坐标再加上框架自己的矩形。
2854
+ const frameIndex = ${JSON.stringify(target.frame ?? null)}
2855
+ let scope = document
2856
+ let dx = 0
2857
+ let dy = 0
2858
+ if (frameIndex !== null) {
2859
+ const frame = document.querySelectorAll('iframe')[frameIndex]
2860
+ if (!frame) return { stale: 'frame missing' }
2861
+ let inner = null
2862
+ try { inner = frame.contentDocument } catch { inner = null }
2863
+ if (inner === null) return { stale: 'frame is cross-origin' }
2864
+ scope = inner
2865
+ const fr = frame.getBoundingClientRect()
2866
+ dx = fr.left
2867
+ dy = fr.top
2868
+ }
2869
+ let el
2870
+ try { el = scope.querySelector(${JSON.stringify(target.path)}) } catch { return { stale: 'selector invalid' } }
2871
+ if (!el || el.closest('[data-dsh-browser-chrome]')) return { stale: 'element missing' }
2872
+ const fingerprint = [
2873
+ el.tagName,
2874
+ el.getAttribute('type') || '',
2875
+ el.id || '',
2876
+ el.getAttribute('name') || '',
2877
+ el.getAttribute('aria-label') || '',
2878
+ (el.textContent || el.value || '').toString().replace(/\s+/g, ' ').trim().slice(0, 120),
2879
+ ].join('\u001f')
2880
+ if (fingerprint !== ${JSON.stringify(target.fingerprint)}) return { stale: 'element changed' }
2881
+ el.scrollIntoView({ block: ${JSON.stringify(block)}, inline: 'nearest', behavior: 'auto' })
2882
+ const rect = el.getBoundingClientRect()
2883
+ const style = getComputedStyle(el)
2884
+ if (rect.width < 4 || rect.height < 4 || style.visibility === 'hidden' || style.display === 'none') return { stale: 'element hidden' }
2885
+ const root = document.documentElement
2886
+ return {
2887
+ x: Math.round(rect.x + rect.width / 2 + dx),
2888
+ y: Math.round(rect.y + rect.height / 2 + dy),
2889
+ scrollX: window.scrollX,
2890
+ scrollY: window.scrollY,
2891
+ maxX: Math.max(0, root.scrollWidth - window.innerWidth),
2892
+ maxY: Math.max(0, root.scrollHeight - window.innerHeight),
2893
+ }
2894
+ })()`
2895
+ const result = await withTimeout(
2896
+ handleSendEvaluate(tab.handle, script),
2897
+ 15_000,
2898
+ signal,
2899
+ 'browser: snapshot reference resolution timed out',
2900
+ )
2901
+ if (!result.ok) {
2902
+ throw new BrowserError(`browser: snapshot reference resolution failed: ${result.exception}`, 'BROWSER_REF_RESOLVE_FAILED')
2903
+ }
2904
+ const value = result.value as { stale?: string; x?: number; y?: number; scrollX?: number; scrollY?: number; maxX?: number; maxY?: number }
2905
+ if (typeof value.stale === 'string'
2906
+ || typeof value.x !== 'number'
2907
+ || typeof value.y !== 'number'
2908
+ || typeof value.scrollX !== 'number'
2909
+ || typeof value.scrollY !== 'number'
2910
+ || typeof value.maxX !== 'number'
2911
+ || typeof value.maxY !== 'number') {
2912
+ throw new BrowserError(`browser: snapshot "${request.snapshotId}" is stale${typeof value.stale === 'string' ? `: ${value.stale}` : ''}`, 'BROWSER_SNAPSHOT_STALE')
2913
+ }
2914
+ return { x: value.x, y: value.y, maxX: value.maxX, maxY: value.maxY }
2915
+ }
2916
+
2917
+ /** Sync provider fallback cache from the host's authoritative workspace state. */
2918
+ private rememberHostedTask(task: BrowserTaskInfo): void {
2919
+ this.taskStates.set(task.key, {
2920
+ status: task.status,
2921
+ control: task.control,
2922
+ ...task.latestAction !== undefined ? { latestAction: task.latestAction } : {},
2923
+ ...task.error !== undefined ? { error: task.error } : {},
2924
+ updatedAt: task.updatedAt,
2925
+ })
2926
+ }
2927
+
2928
+ /** Build the provider-side task summary when a host has no richer workspace. */
2929
+ private localTaskInfo(s: Session): BrowserTaskInfo {
2930
+ const state = this.taskStates.get(s.taskKey) ?? { status: 'idle' as const, control: 'agent' as const, updatedAt: Date.now() }
2931
+ return {
2932
+ key: s.taskKey,
2933
+ label: s.taskLabel,
2934
+ active: this.sessions.size === 1,
2935
+ tabs: s.tabs.length,
2936
+ status: state.status,
2937
+ control: state.control,
2938
+ ...state.latestAction !== undefined ? { latestAction: state.latestAction } : {},
2939
+ updatedAt: state.updatedAt,
2940
+ ...state.error !== undefined ? { error: state.error } : {},
2941
+ }
2942
+ }
2943
+
2944
+ /** Create a tab with its short-lived snapshot reference store. */
2945
+ private createTab(handle: ElectronViewHandle): Tab {
2946
+ return { id: `tab:${randomUUID()}`, handle, navigationEpoch: 0, snapshots: new Map() }
2947
+ }
2948
+
2949
+ /** Append a fresh tab and make it active. */
2950
+ private newTab(s: Session): void {
2951
+ const handle = this.host.createView(s.taskKey, s.taskLabel === '' ? undefined : s.taskLabel)
2952
+ s.tabs.push(this.createTab(handle))
2953
+ s.activeIndex = s.tabs.length - 1
2954
+ this.showActive(s)
2955
+ }
2956
+
2957
+ /** Notify the host of the active tab; it preserves the human-selected task view. */
2958
+ /**
2959
+ * Fire-and-forget host call: these run while the provider keeps going, so a rejection
2960
+ * must never escape. Electron's host answers `unknown view` for a handle it no longer
2961
+ * knows (a host restart leaves the provider holding stale ones), and an unhandled
2962
+ * rejection surfaces as *some other* tool call failing - Ctrl+W did exactly that.
2963
+ * The desired end state (view gone) holds either way, so swallowing is right here.
2964
+ */
2965
+ private ignoreHostFailure(promise: unknown): void {
2966
+ void Promise.resolve(promise).catch(() => undefined)
2967
+ }
2968
+ private showActive(s: Session): void {
2969
+ // 让陈旧的一次显示安静地失败:紧接着的操作/切换会把它纠正回来,而一条没人接的
2970
+ // rejection 会以「别的工具调用失败了」的形式冒出来(实测 Ctrl+W 关最后一个标签就是这样)。
2971
+ void Promise.resolve(this.host.showView?.(this.activeTab(s).handle)).catch(() => undefined)
2972
+ }
2973
+
2974
+ /** Read the current URL of a view through CDP. */
2975
+ private async currentUrl(handle: ElectronViewHandle): Promise<string> {
2976
+ // Bound the read: a wedged renderer would otherwise hang listTabs.
2977
+ const timeoutMs = 10_000
2978
+ const result = await withTimeout(
2979
+ handleSendEvaluate(handle, 'location.href'),
2980
+ timeoutMs,
2981
+ undefined,
2982
+ `browser: url read timed out after ${timeoutMs}ms`,
2983
+ )
2984
+ return result.ok && typeof result.value === 'string' ? result.value : ''
2985
+ }
2986
+ }
2987
+
2988
+ /**
2989
+ * Bound a promise so a wedged CDP call surfaces as an error instead of
2990
+ * hanging the tool call forever. The caller's signal, when provided, wins
2991
+ * over the timeout if it fires first.
2992
+ * @param promise - the operation to bound.
2993
+ * @param ms - the timeout budget.
2994
+ * @param signal - optional caller signal.
2995
+ * @param message - the timeout error message.
2996
+ * @returns the promise's value, or a rejected promise on timeout/abort.
2997
+ */
2998
+ function withTimeout<T>(
2999
+ promise: Promise<T>,
3000
+ ms: number,
3001
+ signal: AbortSignal | undefined,
3002
+ message: string,
3003
+ ): Promise<T> {
3004
+ return new Promise<T>((resolve, reject) => {
3005
+ let done = false
3006
+ const timer = setTimeout(() => {
3007
+ if (done) return
3008
+ done = true
3009
+ // A fired timeout must also release the abort listener; { once: true }
3010
+ // only releases it on the next abort, which may never come.
3011
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
3012
+ // A stable code lets callers branch on a timeout; the name is preserved for
3013
+ // the one call site that already matched on it.
3014
+ const error = new BrowserError(message, 'BROWSER_OPERATION_TIMEOUT')
3015
+ error.name = 'TimeoutError'
3016
+ reject(error)
3017
+ }, ms)
3018
+ const finish = (fn: () => void): void => {
3019
+ if (done) return
3020
+ done = true
3021
+ clearTimeout(timer)
3022
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
3023
+ fn()
3024
+ }
3025
+ const onAbort = (): void => {
3026
+ if (done) return
3027
+ done = true
3028
+ clearTimeout(timer)
3029
+ reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'))
3030
+ }
3031
+ if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
3032
+ promise.then(
3033
+ value => finish(() => resolve(value)),
3034
+ error => finish(() => reject(error)),
3035
+ )
3036
+ })
3037
+ }
3038
+
3039
+ /**
3040
+ * Run a `Runtime.evaluate` through a view handle and normalize the result.
3041
+ * Shared by execute, snapshot, content, and internal URL reads.
3042
+ * @param handle - the view handle to evaluate in.
3043
+ * @param expression - the JS expression.
3044
+ * @param signal - optional abort signal; a fired signal rejects the call.
3045
+ */
3046
+ /** Best-effort injection of the human chrome into the current document. */
3047
+ async function reinstallPageChrome(handle: ElectronViewHandle): Promise<void> {
3048
+ // Prefer the host's own injection: only it holds the per-view binding token, so
3049
+ // its copy can still authenticate actions the human triggers. The tokenless
3050
+ // script below is a fallback for hosts that do not own the chrome.
3051
+ if (typeof handle.reinstallChrome === 'function') {
3052
+ try {
3053
+ await handle.reinstallChrome()
3054
+ return
3055
+ } catch {
3056
+ // Fall through rather than leaving the document without any chrome.
3057
+ }
3058
+ }
3059
+ try {
3060
+ await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
3061
+ expression: PAGE_CHROME_SCRIPT,
3062
+ returnByValue: true,
3063
+ } satisfies CdpEvaluateParams)
3064
+ } catch {
3065
+ // Chrome is cosmetic; never fail navigation for it.
3066
+ }
3067
+ }
3068
+
3069
+ /**
3070
+ * The in-page half of {@link resolvePointerTarget}: resolve the element, scroll
3071
+ * it into view, and return its centre.
3072
+ *
3073
+ * Exported so it can be exercised. The matching rule — the innermost visible
3074
+ * element whose label contains the text wins — is the part most likely to be
3075
+ * wrong, and Node has no DOM to check it against.
3076
+ */
3077
+ export function pointerTargetScript(selector: string | undefined, text: string | undefined): string {
3078
+ return `(() => {
3079
+ const selector = ${JSON.stringify(selector ?? null)}
3080
+ const needle = ${JSON.stringify(text ?? null)}
3081
+ const visible = (el) => {
3082
+ const r = el.getBoundingClientRect()
3083
+ const cs = getComputedStyle(el)
3084
+ return r.width >= 4 && r.height >= 4 && cs.visibility !== 'hidden' && cs.display !== 'none'
3085
+ }
3086
+ const usable = (el) => !el.closest('[data-dsh-browser-chrome]') && visible(el)
3087
+ const labelOf = (el) => (el.getAttribute('aria-label') || el.textContent || el.value || '').toString().replace(/\\s+/g, ' ').trim()
3088
+ const describe = (el) => labelOf(el).slice(0, 80)
3089
+ let el = null
3090
+ if (selector !== null) {
3091
+ let matches
3092
+ try { matches = [...document.querySelectorAll(selector)] } catch (e) { return { error: 'invalid selector: ' + String(e) } }
3093
+ el = matches.find(usable) ?? null
3094
+ } else {
3095
+ const lower = needle.toLowerCase()
3096
+ // One bottom-up pass gives every element its own text. Matching a list of
3097
+ // tag names is not enough: plenty of text lives in tags nobody thinks to
3098
+ // list (p, h1, dd, figcaption, legend, option...), and a site that splits a
3099
+ // label into one span per character leaves every leaf holding a single
3100
+ // character while its container holds the whole phrase. Reading
3101
+ // textContent per element instead would re-walk each subtree.
3102
+ const texts = new Map()
3103
+ const walk = (node) => {
3104
+ let text = ''
3105
+ for (const child of node.childNodes) {
3106
+ if (child.nodeType === 3) text += child.nodeValue ?? ''
3107
+ else if (child.nodeType === 1) text += walk(child)
3108
+ }
3109
+ if (node.nodeType === 1) texts.set(node, text)
3110
+ return text
3111
+ }
3112
+ if (document.body !== null) walk(document.body)
3113
+ let best = null
3114
+ for (const [candidate, own] of texts) {
3115
+ // Cheapest rejection first: a big page has far more elements than matches.
3116
+ const label = (candidate.getAttribute('aria-label') || own || candidate.value || '').replace(/\\s+/g, ' ').trim()
3117
+ if (label === '' || !label.toLowerCase().includes(lower)) continue
3118
+ if (!usable(candidate)) continue
3119
+ let depth = 0
3120
+ for (let node = candidate; node !== null; node = node.parentElement) depth += 1
3121
+ // The shortest label is the innermost element still containing the text;
3122
+ // on a tie the deeper one wins, so a <button> beats its wrapper.
3123
+ if (best === null || label.length < best.label.length || (label.length === best.label.length && depth > best.depth)) {
3124
+ best = { el: candidate, label, depth }
3125
+ }
3126
+ }
3127
+ el = best?.el ?? null
3128
+ }
3129
+ if (el === null) return { missing: true }
3130
+ el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'auto' })
3131
+ const r = el.getBoundingClientRect()
3132
+ if (r.width < 4 || r.height < 4) return { missing: true }
3133
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, target: describe(el) }
3134
+ })()`
3135
+ }
3136
+ /**
3137
+ * Resolve a pointer target to a viewport point.
3138
+ *
3139
+ * Coordinates pass straight through. A selector or text is resolved inside the
3140
+ * page and scrolled into view first, and the element is described in the result
3141
+ * so the caller can confirm what it actually hit — a bare coordinate click
3142
+ * cannot tell you that.
3143
+ */
3144
+ async function resolvePointerTarget(
3145
+ handle: ElectronViewHandle,
3146
+ target: BrowserPointerTarget,
3147
+ signal?: AbortSignal,
3148
+ ): Promise<BrowserPointerResult> {
3149
+ if (target.selector === undefined && target.text === undefined) {
3150
+ if (typeof target.x !== 'number' || typeof target.y !== 'number') {
3151
+ throw new BrowserError('browser: a pointer action needs x and y, a selector, or text', 'BROWSER_TARGET_MISSING')
3152
+ }
3153
+ // Coordinates are NOT scrolled into view (only selector/text are), so a point
3154
+ // below the fold is dropped by the renderer and the call looks like it worked.
3155
+ // Measured on a real page: the same centre that hits at 100% is off-screen at
3156
+ // 110% because the page reflows taller. Refuse, and say what is actually there.
3157
+ const view = await withTimeout(
3158
+ handleSendEvaluate(handle, `(function () {
3159
+ var x = ${JSON.stringify(target.x)}, y = ${JSON.stringify(target.y)};
3160
+ var el = document.elementFromPoint(x, y);
3161
+ return { iw: window.innerWidth, ih: window.innerHeight,
3162
+ hit: el === null ? '' : (el.tagName + (el.id ? '#' + el.id : '') + (el.textContent ? ' ' + el.textContent.replace(/\\s+/g, ' ').trim().slice(0, 40) : '')) };
3163
+ })()`, signal),
3164
+ 5_000,
3165
+ signal,
3166
+ 'browser: coordinate probe timed out after 5000ms',
3167
+ )
3168
+ const info = view.ok ? view.value as { iw?: number; ih?: number; hit?: string } | null : null
3169
+ // A 0x0 viewport means "not laid out yet" (hidden view), not "the point is off
3170
+ // screen": clicks still land there (the smoke's input checks prove it), so only a
3171
+ // real, non-zero viewport may refuse.
3172
+ if (info !== null && typeof info?.iw === 'number' && typeof info?.ih === 'number' && info.iw > 0 && info.ih > 0) {
3173
+ if (target.x >= info.iw || target.y >= info.ih || target.x < 0 || target.y < 0) {
3174
+ throw new BrowserError(
3175
+ `browser: (${target.x}, ${target.y}) is outside the visible viewport (${info.iw}x${info.ih}) - a coordinate click does not scroll, so nothing would be clicked; scroll it into view first, or address the element with a selector/text (those scroll automatically)`,
3176
+ 'BROWSER_TARGET_OFFSCREEN',
3177
+ )
3178
+ }
3179
+ }
3180
+ return {
3181
+ x: target.x,
3182
+ y: target.y,
3183
+ ...info?.hit !== undefined && info.hit !== '' ? { target: info.hit } : {},
3184
+ }
3185
+ }
3186
+ const script = pointerTargetScript(target.selector, target.text)
3187
+ const result = await withTimeout(
3188
+ handleSendEvaluate(handle, script, signal),
3189
+ 10_000,
3190
+ signal,
3191
+ 'browser: target resolution timed out after 10000ms',
3192
+ )
3193
+ if (!result.ok) {
3194
+ throw new BrowserError(`browser: could not resolve the target: ${result.exception}`, 'BROWSER_TARGET_FAILED')
3195
+ }
3196
+ const value = result.value as { x?: number; y?: number; target?: string; missing?: boolean; error?: string } | null
3197
+ if (value?.error !== undefined) {
3198
+ throw new BrowserError(`browser: ${value.error}`, 'BROWSER_TARGET_FAILED')
3199
+ }
3200
+ if (value?.missing === true || typeof value?.x !== 'number' || typeof value?.y !== 'number') {
3201
+ const what = target.selector !== undefined ? `selector "${target.selector}"` : `text "${target.text ?? ''}"`
3202
+ throw new BrowserError(`browser: no visible element matches ${what}`, 'BROWSER_TARGET_NOT_FOUND')
3203
+ }
3204
+ return { x: value.x, y: value.y, ...value.target === undefined ? {} : { target: value.target } }
3205
+ }
3206
+
3207
+ /**
3208
+ * Wait until the main document reports complete, without turning an otherwise
3209
+ * successful navigation into a failure when a page is slow or never settles.
3210
+ */
3211
+ async function waitForDocumentReady(
3212
+ handle: ElectronViewHandle,
3213
+ signal?: AbortSignal,
3214
+ settleMs = 250,
3215
+ ): Promise<void> {
3216
+ const deadline = Date.now() + 12_000
3217
+ try {
3218
+ await withTimeout((async () => {
3219
+ while (Date.now() <= deadline) {
3220
+ const result = await handleSendEvaluate(handle, 'document.readyState', signal).catch(() => undefined)
3221
+ if (result?.ok && result.value === 'complete') {
3222
+ // The settle delay exists so a screenshot or snapshot does not catch a
3223
+ // still-blank renderer. A scrape reads the DOM, not pixels, so it
3224
+ // passes 0 — otherwise a thousand-page batch would idle 250s.
3225
+ if (settleMs > 0) await new Promise(resolve => setTimeout(resolve, settleMs))
3226
+ return
3227
+ }
3228
+ await new Promise(resolve => setTimeout(resolve, 150))
3229
+ }
3230
+ })(), 13_000, signal, 'readiness wait exceeded')
3231
+ } catch {
3232
+ // Readiness is an optimization: never fail a valid navigation for it.
3233
+ }
3234
+ }
3235
+
3236
+ /** Mark a short window in which CDP input must not transfer control to the user. */
3237
+ async function suppressAutoUserControl(handle: ElectronViewHandle, signal?: AbortSignal): Promise<void> {
3238
+ const expression = '(() => { const host = document.getElementById(' + JSON.stringify(PAGE_CHROME_HOST_ID)
3239
+ + '); if (!host) return false; host.setAttribute("data-dsh-agent-input-until", String(Date.now() + ' + String(AGENT_INPUT_SUPPRESSION_MS) + ')); return true })()'
3240
+ await withTimeout(
3241
+ handleSendEvaluate(handle, expression, signal),
3242
+ 2_000,
3243
+ signal,
3244
+ 'browser: agent input suppression timed out',
3245
+ ).catch(() => undefined)
3246
+ }
3247
+
3248
+ /**
3249
+ * Minimal DOM shape {@link renderMarkdown} reads; a real DOM node fits it.
3250
+ */
3251
+ export interface MarkdownNode {
3252
+ readonly nodeType?: number
3253
+ readonly tagName?: string | null
3254
+ readonly textContent?: string | null
3255
+ readonly childNodes?: ArrayLike<MarkdownNode> | null
3256
+ readonly href?: string | null
3257
+ readonly src?: string | null
3258
+ readonly alt?: string | null
3259
+ }
3260
+
3261
+ /**
3262
+ * Best-effort markdown rendering of a DOM subtree, used by
3263
+ * {@link ElectronBrowserProvider.content} for `format: 'markdown'`.
3264
+ *
3265
+ * Deliberately self-contained (no closures over module state, no imports):
3266
+ * the provider embeds this function's source in the page with
3267
+ * `Function.prototype.toString`, so the tests exercise the very code the page
3268
+ * runs.
3269
+ *
3270
+ * Block containers (div/p/section/article/li/headings/...) recurse into their
3271
+ * children and are joined with newlines, while adjacent inline runs are
3272
+ * concatenated — text split by <b>/<span> stays one paragraph, and a container
3273
+ * never emits its own `textContent` on top of its children (the old walker did,
3274
+ * which flattened real pages — everything is wrapped in divs — to plain text).
3275
+ * @param root - the subtree root (an element, or a text node).
3276
+ * @returns the markdown text.
3277
+ */
3278
+ export function renderMarkdown(root: MarkdownNode): string {
3279
+ const BLOCK_TAGS = new Set([
3280
+ 'address', 'article', 'aside', 'blockquote', 'dd', 'details', 'dialog',
3281
+ 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form',
3282
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li',
3283
+ 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary', 'table',
3284
+ 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul',
3285
+ ])
3286
+ const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template'])
3287
+ const collapse = (text: string): string => text.replace(/\s+/g, ' ').trim()
3288
+ const childrenOf = (node: MarkdownNode): MarkdownNode[] => {
3289
+ const list = node.childNodes
3290
+ if (list === undefined || list === null) return []
3291
+ const out: MarkdownNode[] = []
3292
+ for (let index = 0; index < list.length; index++) out.push(list[index])
3293
+ return out
3294
+ }
3295
+ /** Inline rendering: concatenates descendants, keeping links/images inline. */
3296
+ const inline = (node: MarkdownNode): string => {
3297
+ // Whitespace is collapsed but NOT trimmed: trimming here would eat the space
3298
+ // that separates two inline runs ('Hello ' + <b>world</b>).
3299
+ if (node.nodeType === 3) return (node.textContent ?? '').replace(/\s+/g, ' ')
3300
+ if (node.nodeType !== 1) return ''
3301
+ const tag = (node.tagName ?? '').toLowerCase()
3302
+ if (SKIP_TAGS.has(tag)) return ''
3303
+ if (tag === 'br') return '\n'
3304
+ if (tag === 'img') return node.src ? '![' + collapse(node.alt ?? '') + '](' + node.src + ')' : ''
3305
+ if (tag === 'a') {
3306
+ const text = collapse(inlineChildren(node))
3307
+ return text === '' ? '' : '[' + text + '](' + (node.href ?? '') + ')'
3308
+ }
3309
+ return inlineChildren(node)
3310
+ }
3311
+ const inlineChildren = (node: MarkdownNode): string => childrenOf(node).map(inline).join('')
3312
+ /** Render one child as a block piece (own line) or an inline piece (merged). */
3313
+ const renderBlock = (node: MarkdownNode): { text: string; block: boolean } => {
3314
+ if (node.nodeType === 3) return { text: inline(node), block: false }
3315
+ if (node.nodeType !== 1) return { text: '', block: false }
3316
+ const tag = (node.tagName ?? '').toLowerCase()
3317
+ if (SKIP_TAGS.has(tag)) return { text: '', block: false }
3318
+ if (tag === 'br') return { text: '\n', block: false }
3319
+ if (tag === 'hr') return { text: '---', block: true }
3320
+ const heading = /^h([1-6])$/.exec(tag)
3321
+ if (heading !== null) {
3322
+ const text = collapse(inline(node))
3323
+ return { text: text === '' ? '' : '#'.repeat(Number(heading[1])) + ' ' + text, block: true }
3324
+ }
3325
+ if (tag === 'li') {
3326
+ const text = collapse(inline(node))
3327
+ return { text: text === '' ? '' : '- ' + text, block: true }
3328
+ }
3329
+ if (BLOCK_TAGS.has(tag)) return { text: renderChildren(node), block: true }
3330
+ return { text: inline(node), block: false }
3331
+ }
3332
+ /** Join a node's children: inline neighbours merge, block boundaries newline. */
3333
+ const renderChildren = (node: MarkdownNode): string => {
3334
+ const out: Array<{ text: string; block: boolean }> = []
3335
+ for (const child of childrenOf(node)) {
3336
+ const piece = renderBlock(child)
3337
+ if (collapse(piece.text) === '') continue
3338
+ const previous = out[out.length - 1]
3339
+ if (previous !== undefined && !previous.block && !piece.block) previous.text += piece.text
3340
+ else out.push({ text: piece.text, block: piece.block })
3341
+ }
3342
+ // Inline runs are trimmed once, after merging, so their inner spacing stays.
3343
+ return out.map(piece => piece.block ? piece.text : collapse(piece.text)).join('\n')
3344
+ }
3345
+ if (root.nodeType === 3) return collapse(root.textContent ?? '')
3346
+ return renderChildren(root).replace(/\n{3,}/g, '\n\n').trim()
3347
+ }
3348
+
3349
+ async function handleSendEvaluate(
3350
+ handle: ElectronViewHandle,
3351
+ expression: string,
3352
+ signal?: AbortSignal,
3353
+ ): Promise<BrowserExecuteResult> {
3354
+ signal?.throwIfAborted()
3355
+ const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
3356
+ expression,
3357
+ returnByValue: true,
3358
+ awaitPromise: true,
3359
+ } satisfies CdpEvaluateParams)
3360
+ if (result.exceptionDetails !== undefined) {
3361
+ const detail = result.exceptionDetails as { text?: string; exception?: { description?: string } }
3362
+ return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' }
3363
+ }
3364
+ return { ok: true, value: (result.result as { value?: unknown } | undefined)?.value ?? null }
3365
+ }
3366
+