dsh-builtin-browser 0.1.6 → 0.1.7

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.
@@ -0,0 +1,922 @@
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/browser-electron
8
+ */
9
+
10
+ import { randomUUID } from 'node:crypto'
11
+ import { writeFileSync } from 'node:fs'
12
+ import type {
13
+ BrowserChallenge,
14
+ BrowserContentRequest,
15
+ BrowserContentResult,
16
+ BrowserExecuteRequest,
17
+ BrowserExecuteResult,
18
+ BrowserFillRequest,
19
+ BrowserFillResult,
20
+ BrowserHistoryEntry,
21
+ BrowserOpenRequest,
22
+ BrowserProvider,
23
+ BrowserSessionId,
24
+ BrowserSnapshotResult,
25
+ BrowserTab,
26
+ ExportedCookie,
27
+ } from '../browser/types.ts'
28
+ import { BrowserError } from '../browser/types.ts'
29
+
30
+ /**
31
+ * Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
32
+ * inside the page; returns `{ blocked, kind?, reason? }`. Marker-based and
33
+ * best-effort: checks for Cloudflare's interstitial, hCaptcha, reCAPTCHA,
34
+ * Turnstile, and generic challenge wording.
35
+ */
36
+ const CHALLENGE_DETECT_EXPRESSION = `(() => {
37
+ const title = (document.title || '').trim()
38
+ const bodyText = (document.body && document.body.innerText || '').slice(0, 4000)
39
+ const lower = (title + '\\n' + bodyText).toLowerCase()
40
+ const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
41
+ const framesLower = frameSrcs.toLowerCase()
42
+ const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl|challenge-platform/i.test(lower)
43
+ || !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
44
+ const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
45
+ const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
46
+ const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile/i.test(lower)
47
+ 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)
48
+ if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
49
+ if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
50
+ if (hasRecaptcha) return { blocked: true, kind: 'recaptcha', reason: 'Google reCAPTCHA verification' }
51
+ if (hasTurnstile) return { blocked: true, kind: 'turnstile', reason: 'Cloudflare Turnstile verification' }
52
+ if (verifyWording && /challenge|captcha|verification|security check|access denied|blocked|\\u9a8c\\u8bc1/i.test(lower)) {
53
+ return { blocked: true, kind: 'generic', reason: 'Human-verification challenge' }
54
+ }
55
+ return { blocked: false }
56
+ })()`
57
+
58
+ /** Stable provider id registered with `ctx.browser`. */
59
+ export const ELECTRON_BROWSER_PROVIDER_ID = 'electron'
60
+
61
+ /**
62
+ * The minimal Electron surface this provider needs. Implemented by the
63
+ * desktop shell with a real `WebContentsView`; a fake implements it in tests.
64
+ */
65
+ export interface ElectronBrowserViewHost {
66
+ /**
67
+ * Create a new browser view and return a handle to its webContents-like
68
+ * surface. The host owns windowing (adding the view to the window, sizing,
69
+ * removal); the provider owns CDP-driven behavior.
70
+ */
71
+ createView(): ElectronViewHandle
72
+ /**
73
+ * Destroy a view created by this host. Called on session close; idempotent
74
+ * for an already-destroyed view.
75
+ * @param handle - the handle returned by {@link createView}.
76
+ */
77
+ destroyView(handle: ElectronViewHandle): void
78
+ /**
79
+ * Show one view as the session's visible surface. The host keeps exactly
80
+ * one visible; switching tabs reorders visibility without losing state.
81
+ * Optional: a host without visible-tab switching treats every view as
82
+ * always present (acceptable for headless/probe hosts).
83
+ * @param handle - the handle to make visible.
84
+ */
85
+ showView?(handle: ElectronViewHandle): void
86
+ }
87
+
88
+ /**
89
+ * A CDP-capable view handle. This is the subset of Electron's
90
+ * `WebContents`/`WebContentsView` the provider drives; the shell's real
91
+ * implementation adapts `webContents.debugger` to it.
92
+ */
93
+ export interface ElectronViewHandle {
94
+ /** Unique id of the backing view, used for diagnostics. */
95
+ readonly id: string
96
+ /**
97
+ * Send one CDP command and resolve with its result. Rejects when the
98
+ * debugger is not attached or the command fails.
99
+ * @param method - CDP method, e.g. `Page.navigate`.
100
+ * @param params - CDP command parameters.
101
+ * @returns the CDP `result` object.
102
+ */
103
+ sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
104
+ }
105
+
106
+ /** One tab inside a session: its view plus a stable id. */
107
+ interface Tab {
108
+ readonly id: string
109
+ readonly handle: ElectronViewHandle
110
+ }
111
+
112
+ /** One live browser session: an ordered list of tabs, one active. */
113
+ interface Session {
114
+ readonly id: BrowserSessionId
115
+ readonly tabs: Tab[]
116
+ activeIndex: number
117
+ /** Chronological operation log (navigate/execute/click/type/screenshot). */
118
+ readonly history: BrowserHistoryEntry[]
119
+ }
120
+
121
+ /** Provider config: navigation admission defaults and snapshot caps. */
122
+ export interface ElectronBrowserProviderConfig {
123
+ /** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
124
+ readonly httpOnly?: boolean
125
+ /** Maximum snapshot elements before truncation. Default 60. */
126
+ readonly snapshotMaxElements?: number
127
+ /** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
128
+ readonly contentMaxChars?: number
129
+ }
130
+
131
+ /**
132
+ * CDP method/params for `Page.navigate`, as sent to {@link ElectronViewHandle.sendCommand}.
133
+ */
134
+ export interface CdpNavigateParams {
135
+ readonly url: string
136
+ }
137
+
138
+ /**
139
+ * CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
140
+ */
141
+ export interface CdpMouseParams {
142
+ readonly type: 'mousePressed' | 'mouseReleased'
143
+ readonly x: number
144
+ readonly y: number
145
+ readonly button: 'left'
146
+ readonly clickCount: number
147
+ }
148
+
149
+ /** CDP method/params for `Input.insertText`. */
150
+ export interface CdpInsertTextParams {
151
+ readonly text: string
152
+ }
153
+
154
+ /** CDP method/params for `Runtime.evaluate`. */
155
+ export interface CdpEvaluateParams {
156
+ readonly expression: string
157
+ readonly returnByValue: boolean
158
+ readonly awaitPromise?: boolean
159
+ }
160
+
161
+ /** CDP method for a full-page screenshot capture. */
162
+ export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot'
163
+ /** CDP method for runtime evaluation (the execute path). */
164
+ export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate'
165
+ /** CDP method for navigation. */
166
+ export const CDP_PAGE_NAVIGATE = 'Page.navigate'
167
+
168
+ /** Cap on content returned by a snapshot fetch to keep the wire bounded. */
169
+ const SNAPSHOT_LABEL_MAX = 120
170
+
171
+ /**
172
+ * Browser provider over Electron views. Sessions hold an ordered list of
173
+ * tabs; each tab is one view created by the host. The active tab receives
174
+ * every operation; switching tabs calls the host's optional `showView` and
175
+ * never loses state. Navigation is admitted only for HTTP(S) targets unless
176
+ * {@link ElectronBrowserProviderConfig.httpOnly} is disabled.
177
+ */
178
+ export class ElectronBrowserProvider implements BrowserProvider {
179
+ readonly id = ELECTRON_BROWSER_PROVIDER_ID
180
+
181
+ private readonly sessions = new Map<BrowserSessionId, Session>()
182
+ private readonly httpOnly: boolean
183
+ private readonly snapshotMaxElements: number
184
+ private readonly contentMaxChars: number
185
+
186
+ constructor(
187
+ private readonly host: ElectronBrowserViewHost,
188
+ config: ElectronBrowserProviderConfig = {},
189
+ ) {
190
+ this.httpOnly = config.httpOnly ?? true
191
+ this.snapshotMaxElements = config.snapshotMaxElements ?? 60
192
+ this.contentMaxChars = config.contentMaxChars ?? 100_000
193
+ }
194
+
195
+ /** Usable whenever the host can create views (always in the desktop shell). */
196
+ available(): boolean {
197
+ return true
198
+ }
199
+
200
+ /**
201
+ * Open a NEW browser session with its own view. Every call mints a fresh
202
+ * session id and backing view; per-task reuse is owned by the caller (the
203
+ * tool layer caches one session per DSH task). Sessions are isolated from
204
+ * each other: each keeps its own tabs, active tab, and history, and only
205
+ * the active tab of a session is made visible.
206
+ */
207
+ open(): Promise<BrowserSessionId> {
208
+ const handle = this.host.createView()
209
+ const id = `browser:${randomUUID()}`
210
+ this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [] })
211
+ return Promise.resolve(id)
212
+ }
213
+
214
+ /** Open a URL in the active tab (default) or a new tab. */
215
+ async openUrl(session: BrowserSessionId, request: BrowserOpenRequest, signal?: AbortSignal): Promise<void> {
216
+ const s = this.session(session)
217
+ if (request.newTab === true) {
218
+ this.newTab(s)
219
+ }
220
+ await this.navigate(session, { url: request.url }, signal)
221
+ }
222
+
223
+ /** List the session's tabs with their titles. */
224
+ async listTabs(session: BrowserSessionId): Promise<readonly BrowserTab[]> {
225
+ const s = this.session(session)
226
+ const result: BrowserTab[] = []
227
+ for (let i = 0; i < s.tabs.length; i++) {
228
+ const tab = s.tabs[i]
229
+ if (tab === undefined) continue // defensive: array can shift under concurrency
230
+ result.push({
231
+ id: tab.id,
232
+ url: await this.currentUrl(tab.handle).catch(() => ''),
233
+ active: i === s.activeIndex,
234
+ })
235
+ }
236
+ return result
237
+ }
238
+
239
+ /** Switch to a tab by id, making its view visible. */
240
+ switchTab(session: BrowserSessionId, tabId: string): Promise<void> {
241
+ const s = this.session(session)
242
+ const index = s.tabs.findIndex(tab => tab.id === tabId)
243
+ if (index < 0) {
244
+ throw new BrowserError(`browser: tab "${tabId}" is not open in this session`, 'BROWSER_TAB_UNKNOWN')
245
+ }
246
+ s.activeIndex = index
247
+ this.showActive(s)
248
+ return Promise.resolve()
249
+ }
250
+
251
+ /** Close one tab; closing the active tab activates the next. */
252
+ closeTab(session: BrowserSessionId, tabId: string): Promise<void> {
253
+ const s = this.session(session)
254
+ const index = s.tabs.findIndex(tab => tab.id === tabId)
255
+ if (index < 0) return Promise.resolve() // idempotent
256
+ const removed = s.tabs[index]
257
+ if (removed !== undefined) {
258
+ s.tabs.splice(index, 1)
259
+ this.host.destroyView(removed.handle)
260
+ }
261
+ if (s.tabs.length === 0) {
262
+ // Session keeps one blank tab so it stays usable.
263
+ this.newTab(s)
264
+ } else if (index < s.activeIndex) {
265
+ // Closing a tab before the active one shifts the array left; keep the
266
+ // same tab active by decrementing the index.
267
+ s.activeIndex -= 1
268
+ } else if (s.activeIndex >= s.tabs.length) {
269
+ // The active tab itself was closed; activate the last remaining one.
270
+ s.activeIndex = s.tabs.length - 1
271
+ }
272
+ this.showActive(s)
273
+ return Promise.resolve()
274
+ }
275
+
276
+ /** Close every tab and reset to one blank tab. */
277
+ reset(session: BrowserSessionId): Promise<void> {
278
+ const s = this.session(session)
279
+ for (const tab of s.tabs) this.host.destroyView(tab.handle)
280
+ s.tabs.length = 0
281
+ this.newTab(s)
282
+ s.activeIndex = 0
283
+ this.showActive(s)
284
+ return Promise.resolve()
285
+ }
286
+
287
+ /** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
288
+ async navigate(session: BrowserSessionId, request: { readonly url: string }, signal?: AbortSignal): Promise<void> {
289
+ const s = this.session(session)
290
+ const { handle } = this.activeTab(s)
291
+ const url = request.url
292
+ try {
293
+ if (this.httpOnly) {
294
+ let parsed: URL
295
+ try {
296
+ parsed = new URL(url)
297
+ } catch {
298
+ throw new BrowserError(`browser: refusing navigation to unparseable URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED')
299
+ }
300
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
301
+ throw new BrowserError(`browser: refusing navigation to non-HTTP(S) URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED')
302
+ }
303
+ }
304
+ signal?.throwIfAborted()
305
+ const result = await handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams)
306
+ // Page.navigate resolves even when the navigation fails; surface the
307
+ // failure instead of leaving a silent white screen.
308
+ const errorText = (result as { errorText?: string }).errorText
309
+ if (typeof errorText === 'string' && errorText !== '') {
310
+ this.record(s, 'navigate', { url }, false, { error: errorText })
311
+ throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
312
+ }
313
+ this.record(s, 'navigate', { url }, true)
314
+ } catch (error) {
315
+ if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
316
+ this.record(s, 'navigate', { url }, false, { error: String(error) })
317
+ }
318
+ throw error
319
+ }
320
+ }
321
+
322
+ /** Execute JS in the active tab's page context. */
323
+ async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
324
+ const s = this.session(session)
325
+ const { handle } = this.activeTab(s)
326
+ signal?.throwIfAborted()
327
+ try {
328
+ // Wrap the script in a Function so `return` statements are legal and
329
+ // request.args arrive as `arguments[0..n]`. A bare script handed to CDP
330
+ // Runtime.evaluate is an expression context — a leading `return` would
331
+ // be a syntax error, and an object-literal script (`{...}`) would parse
332
+ // as a block. So: if the script already starts with `return`, use it as
333
+ // the body verbatim; otherwise wrap it as `return (expr)` so both
334
+ // expression and object-literal forms evaluate to their value. Args are
335
+ // embedded as a JSON array literal; unserializable members become null.
336
+ const body = /^\s*return\b/.test(request.script)
337
+ ? request.script
338
+ : `return (${request.script})`
339
+ const hasArgs = request.args !== undefined && request.args.length > 0
340
+ const expression = hasArgs
341
+ ? `(function(){ const __dshArgs = ${JSON.stringify(request.args)}; return Function(${JSON.stringify(body)}).apply(null, __dshArgs) })()`
342
+ : `(function(){ return Function(${JSON.stringify(body)})() })()`
343
+ // CDP Runtime.evaluate can hang indefinitely on a not-yet-loaded page
344
+ // (navigate returned but the renderer has not committed). Bound it so a
345
+ // stuck call surfaces as BROWSER_EXECUTE_TIMEOUT instead of wedging the
346
+ // whole tool call. The caller's signal wins when it fires first.
347
+ const timeoutMs = request.timeoutMs ?? 30_000
348
+ const result = await withTimeout(
349
+ handle.sendCommand(CDP_RUNTIME_EVALUATE, {
350
+ expression,
351
+ returnByValue: true,
352
+ awaitPromise: true,
353
+ } satisfies CdpEvaluateParams),
354
+ timeoutMs,
355
+ signal,
356
+ `browser: execute timed out after ${timeoutMs}ms`,
357
+ )
358
+ if (result.exceptionDetails !== undefined) {
359
+ const detail = result.exceptionDetails as { text?: string; exception?: { description?: string } }
360
+ const exception = detail.exception?.description ?? detail.text ?? 'unknown exception'
361
+ this.record(s, 'execute', { script: request.script }, false, { error: exception })
362
+ return { ok: false, exception }
363
+ }
364
+ const value = (result.result as { value?: unknown } | undefined)?.value ?? null
365
+ this.record(s, 'execute', { script: request.script }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
366
+ return { ok: true, value }
367
+ } catch (error) {
368
+ throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error })
369
+ }
370
+ }
371
+
372
+ /** Produce an AI-friendly snapshot of the active tab. */
373
+ async snapshot(session: BrowserSessionId, signal?: AbortSignal): Promise<BrowserSnapshotResult> {
374
+ const s = this.session(session)
375
+ const tab = this.activeTab(s)
376
+ signal?.throwIfAborted()
377
+ const script = `(() => {
378
+ const cap = ${String(this.snapshotMaxElements)}
379
+ const url = location.href
380
+ const title = document.title || undefined
381
+ const els = [...document.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [contenteditable="true"]')]
382
+ const out = []
383
+ for (const el of els) {
384
+ if (out.length >= cap) break
385
+ const r = el.getBoundingClientRect()
386
+ const cs = getComputedStyle(el)
387
+ if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
388
+ const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'input'))
389
+ : el.tagName === 'TEXTAREA' ? 'textarea'
390
+ : el.tagName === 'SELECT' ? 'select'
391
+ : el.tagName === 'BUTTON' ? 'button'
392
+ : el.tagName === 'A' ? 'link' : 'other'
393
+ const label = (el.getAttribute('aria-label') || el.placeholder || el.textContent || el.value || el.name || el.id || '').toString().replace(/\\s+/g, ' ').trim().slice(0, ${String(SNAPSHOT_LABEL_MAX)})
394
+ if (!label && kind !== 'link') continue
395
+ out.push({
396
+ ref: out.length + 1,
397
+ kind,
398
+ label,
399
+ selector: el.id ? '#' + el.id : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
400
+ x: Math.round(r.x + r.width / 2),
401
+ y: Math.round(r.y + r.height / 2),
402
+ })
403
+ }
404
+ const challenge = ${CHALLENGE_DETECT_EXPRESSION}
405
+ return { url, title, elements: out, truncated: out.length >= cap, challenge }
406
+ })()`
407
+ // Same hang guard as execute: a renderer that has not committed after
408
+ // navigate would otherwise block snapshot forever.
409
+ const timeoutMs = 30_000
410
+ const result = await withTimeout(
411
+ handleSendEvaluate(tab.handle, script),
412
+ timeoutMs,
413
+ signal,
414
+ `browser: snapshot timed out after ${timeoutMs}ms`,
415
+ )
416
+ if (!result.ok) throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED')
417
+ const value = result.value as BrowserSnapshotResult
418
+ return value
419
+ }
420
+
421
+ /** Check whether a human-verification challenge is blocking the active tab. */
422
+ async detectChallenge(session: BrowserSessionId, signal?: AbortSignal): Promise<BrowserChallenge> {
423
+ const s = this.session(session)
424
+ const tab = this.activeTab(s)
425
+ signal?.throwIfAborted()
426
+ const timeoutMs = 15_000
427
+ const result = await withTimeout(
428
+ handleSendEvaluate(tab.handle, CHALLENGE_DETECT_EXPRESSION),
429
+ timeoutMs,
430
+ signal,
431
+ `browser: challenge detection timed out after ${timeoutMs}ms`,
432
+ )
433
+ if (!result.ok) {
434
+ throw new BrowserError(`browser: challenge detection failed: ${result.exception}`, 'BROWSER_CHALLENGE_DETECT_FAILED')
435
+ }
436
+ const value = result.value as BrowserChallenge
437
+ return { blocked: value.blocked === true, kind: value.kind, reason: value.reason }
438
+ }
439
+
440
+ /** Fetch page content in a requested format. */
441
+ async content(session: BrowserSessionId, request: BrowserContentRequest, signal?: AbortSignal): Promise<BrowserContentResult> {
442
+ const tab = this.activeTab(this.session(session))
443
+ signal?.throwIfAborted()
444
+ const maxChars = request.maxChars ?? this.contentMaxChars
445
+ const selector = request.selector ?? ''
446
+ const format = request.format
447
+ const script = `(() => {
448
+ const root = ${selector === '' ? 'document.body' : `document.querySelector(${JSON.stringify(selector)})`}
449
+ if (!root) return { ok: false, reason: 'selector not found' }
450
+ const fmt = ${JSON.stringify(format)}
451
+ let content = ''
452
+ if (fmt === 'txt') content = root.innerText || ''
453
+ else if (fmt === 'html') content = root.outerHTML || ''
454
+ else if (fmt === 'json') content = JSON.stringify(root)
455
+ else {
456
+ // markdown: headings, paragraphs, links, lists (best-effort)
457
+ const parts = []
458
+ const walk = (node) => {
459
+ if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
460
+ if (node.nodeType !== Node.ELEMENT_NODE) return
461
+ const tag = node.tagName.toLowerCase()
462
+ if (tag === 'script' || tag === 'style' || tag === 'noscript') return
463
+ if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4') parts.push('\\n' + '#'.repeat(Number(tag[1])) + ' ' + (node.textContent || '').trim() + '\\n')
464
+ else if (tag === 'a') { const t = (node.textContent || '').trim(); if (t) parts.push('[' + t + '](' + (node.href || '') + ')') }
465
+ else if (tag === 'li') parts.push(' - ' + (node.textContent || '').trim())
466
+ else if (tag === 'p' || tag === 'div' || tag === 'section' || tag === 'article') { const t = (node.textContent || '').trim(); if (t) parts.push(t + '\\n') }
467
+ else { for (const child of node.childNodes) walk(child) }
468
+ }
469
+ if (root.nodeType === Node.TEXT_NODE) walk(root)
470
+ else for (const child of root.childNodes) walk(child)
471
+ // Join without a separator: each part already carries its own trailing
472
+ // newline, so a space join would smear headings/links into run-on text.
473
+ content = parts.join('')
474
+ }
475
+ const truncated = content.length > ${String(maxChars)}
476
+ return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
477
+ })()`
478
+ // Honor a per-call timeout: content evaluation can hang on a heavy page,
479
+ // so a caller-supplied budget bounds it. AbortSignal.any merges the
480
+ // caller's signal with a timeout.
481
+ const timeoutMs = request.timeoutMs ?? 30_000
482
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
483
+ const result = signal !== undefined
484
+ ? await handleSendEvaluate(tab.handle, script, AbortSignal.any([signal, timeoutSignal]))
485
+ : await handleSendEvaluate(tab.handle, script, timeoutSignal)
486
+ if (!result.ok) throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED')
487
+ const value = result.value as { ok: boolean; reason?: string; content?: string; truncated?: boolean }
488
+ if (!value.ok) throw new BrowserError(`browser: content fetch failed: ${value.reason ?? 'unknown'}`, 'BROWSER_CONTENT_FAILED')
489
+ return { content: value.content ?? '', truncated: value.truncated ?? false }
490
+ }
491
+
492
+ /** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
493
+ async click(session: BrowserSessionId, request: { readonly x: number; readonly y: number }, signal?: AbortSignal): Promise<void> {
494
+ const s = this.session(session)
495
+ const { handle } = this.activeTab(s)
496
+ signal?.throwIfAborted()
497
+ await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x: request.x, y: request.y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
498
+ await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x: request.x, y: request.y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
499
+ this.record(s, 'click', { x: request.x, y: request.y }, true)
500
+ }
501
+
502
+ /** Type into the focused element. */
503
+ async type(session: BrowserSessionId, request: { readonly text: string }, signal?: AbortSignal): Promise<void> {
504
+ const s = this.session(session)
505
+ const { handle } = this.activeTab(s)
506
+ signal?.throwIfAborted()
507
+ await handle.sendCommand('Input.insertText', { text: request.text } satisfies CdpInsertTextParams)
508
+ this.record(s, 'type', { text: request.text.slice(0, 200) }, true)
509
+ }
510
+
511
+ /**
512
+ * Fill a form's fields in one batch. Runs one page-context script that
513
+ * resolves each field (selector, or name/label/placeholder among visible
514
+ * controls), sets its value with the native prototype setter (React/Vue
515
+ * controlled inputs included) plus input/change events, handles
516
+ * select/checkbox/radio/contenteditable, and optionally submits the form.
517
+ */
518
+ async fillForm(session: BrowserSessionId, request: BrowserFillRequest, signal?: AbortSignal): Promise<BrowserFillResult> {
519
+ const s = this.session(session)
520
+ const tab = this.activeTab(s)
521
+ signal?.throwIfAborted()
522
+ const specs = JSON.stringify(request.fields.map(f => ({
523
+ selector: f.selector ?? null,
524
+ name: f.name ?? null,
525
+ label: f.label ?? null,
526
+ placeholder: f.placeholder ?? null,
527
+ kind: f.kind ?? 'text',
528
+ value: f.value,
529
+ })))
530
+ const submitFlag = request.submit === true
531
+ const script = `(() => {
532
+ const specs = ${specs}
533
+ const out = []
534
+ const setNative = (el, proto, value) => {
535
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
536
+ if (setter) setter.call(el, value)
537
+ else el.value = value
538
+ }
539
+ const visible = (el) => {
540
+ const r = el.getBoundingClientRect()
541
+ const cs = getComputedStyle(el)
542
+ return r.width >= 4 && r.height >= 4 && cs.visibility !== 'hidden' && cs.display !== 'none'
543
+ }
544
+ const describe = (spec) => spec.selector || spec.name || spec.label || spec.placeholder || '(unspecified)'
545
+ const matches = (el, spec) => {
546
+ if (spec.selector) { try { return el.matches(spec.selector) } catch { return false } }
547
+ if (spec.name && el.name === spec.name) return true
548
+ if (spec.placeholder && el.placeholder === spec.placeholder) return true
549
+ if (spec.label) {
550
+ if (el.getAttribute('aria-label') === spec.label) return true
551
+ if (el.id) {
552
+ const lbl = document.querySelector('label[for=' + JSON.stringify(el.id) + ']')
553
+ if (lbl && (lbl.textContent || '').trim() === spec.label) return true
554
+ }
555
+ const wrap = el.closest('label')
556
+ if (wrap && (wrap.textContent || '').trim() === spec.label) return true
557
+ }
558
+ return false
559
+ }
560
+ const candidates = (spec) => {
561
+ const all = spec.selector
562
+ ? [...document.querySelectorAll(spec.selector)]
563
+ : [...document.querySelectorAll('input, textarea, select, [contenteditable="true"]')].filter(el => matches(el, spec))
564
+ const vis = all.filter(visible)
565
+ return vis.length > 0 ? vis : all
566
+ }
567
+ for (const spec of specs) {
568
+ const els = candidates(spec)
569
+ if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
570
+ const el = els[0]
571
+ const tag = el.tagName
572
+ const type = (el.type || '').toLowerCase()
573
+ try {
574
+ if (tag === 'SELECT') {
575
+ const wanted = String(spec.value)
576
+ if (el.multiple) {
577
+ const wantedList = wanted.split(',').map(x => x.trim())
578
+ let hit = false
579
+ for (const o of [...el.options]) {
580
+ o.selected = wantedList.includes(o.value) || wantedList.includes((o.textContent || '').trim())
581
+ if (o.selected) hit = true
582
+ }
583
+ if (!hit) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
584
+ } else {
585
+ let opt = [...el.options].find(o => o.value === wanted)
586
+ if (!opt) opt = [...el.options].find(o => (o.textContent || '').trim() === wanted)
587
+ if (!opt) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
588
+ setNative(el, HTMLSelectElement.prototype, opt.value)
589
+ }
590
+ el.dispatchEvent(new Event('input', { bubbles: true }))
591
+ el.dispatchEvent(new Event('change', { bubbles: true }))
592
+ out.push({ ok: true, method: 'select', target: describe(spec) })
593
+ } else if (type === 'file') {
594
+ out.push({ ok: false, error: 'file inputs cannot be set from script; use browser_download or ask the human', target: describe(spec) })
595
+ } else if (type === 'checkbox') {
596
+ const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
597
+ if (el.checked !== want) el.click()
598
+ out.push({ ok: true, method: 'checkbox', target: describe(spec) })
599
+ } else if (type === 'radio') {
600
+ const wanted = String(spec.value)
601
+ const radio = [...document.querySelectorAll('input[type="radio"][name=' + JSON.stringify(el.name || '') + ']')]
602
+ .find(r => r.value === wanted || (r === el && (spec.value === true || spec.value === 'true')))
603
+ if (!radio) { out.push({ ok: false, error: 'radio option not found: ' + wanted, target: describe(spec) }); continue }
604
+ if (!radio.checked) radio.click()
605
+ out.push({ ok: true, method: 'radio', target: describe(spec) })
606
+ } else if (el.isContentEditable) {
607
+ el.textContent = String(spec.value)
608
+ el.dispatchEvent(new Event('input', { bubbles: true }))
609
+ out.push({ ok: true, method: 'contenteditable', target: describe(spec) })
610
+ } else if (tag === 'TEXTAREA') {
611
+ setNative(el, HTMLTextAreaElement.prototype, String(spec.value))
612
+ el.dispatchEvent(new Event('input', { bubbles: true }))
613
+ el.dispatchEvent(new Event('change', { bubbles: true }))
614
+ out.push({ ok: true, method: 'textarea', target: describe(spec) })
615
+ } else {
616
+ setNative(el, HTMLInputElement.prototype, String(spec.value))
617
+ el.dispatchEvent(new Event('input', { bubbles: true }))
618
+ el.dispatchEvent(new Event('change', { bubbles: true }))
619
+ out.push({ ok: true, method: 'input', target: describe(spec) })
620
+ }
621
+ } catch (e) {
622
+ out.push({ ok: false, error: String(e), target: describe(spec) })
623
+ }
624
+ }
625
+ let submitted = false
626
+ if (${submitFlag}) {
627
+ let last = null
628
+ for (const spec of specs) { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } }
629
+ const form = last && (last.form || last.closest('form'))
630
+ if (form) { form.requestSubmit(); submitted = true }
631
+ }
632
+ return { fields: out, submitted }
633
+ })()`
634
+ const timeoutMs = request.timeoutMs ?? 30_000
635
+ const result = await withTimeout(
636
+ handleSendEvaluate(tab.handle, script),
637
+ timeoutMs,
638
+ signal,
639
+ `browser: fillForm timed out after ${timeoutMs}ms`,
640
+ )
641
+ if (!result.ok) {
642
+ throw new BrowserError(`browser: fillForm evaluation failed: ${result.exception}`, 'BROWSER_FILL_FAILED')
643
+ }
644
+ const value = result.value as BrowserFillResult
645
+ const okCount = value.fields.filter(f => f.ok).length
646
+ this.record(s, 'fill', { fields: request.fields.length, submit: submitFlag }, okCount === value.fields.length, {
647
+ result: `${okCount}/${value.fields.length} fields filled${value.submitted ? ', form submitted' : ''}`,
648
+ })
649
+ return { fields: value.fields, submitted: value.submitted === true }
650
+ }
651
+
652
+ /**
653
+ * Download a URL to a local file, keeping the session's cookies/login.
654
+ * Requires the self-hosted host (which implements view-level download); the
655
+ * desktop shell's embedded views delegate downloads to the real browser UI.
656
+ */
657
+ async download(session: BrowserSessionId, request: { readonly url: string; readonly savePath: string }, signal?: AbortSignal): Promise<{ readonly path: string }> {
658
+ const s = this.session(session)
659
+ const { handle } = this.activeTab(s)
660
+ signal?.throwIfAborted()
661
+ const downloadable = handle as { download?(url: string, savePath: string): Promise<void> }
662
+ if (typeof downloadable.download !== 'function') {
663
+ throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
664
+ }
665
+ await downloadable.download(request.url, request.savePath)
666
+ this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath })
667
+ return { path: request.savePath }
668
+ }
669
+
670
+ /**
671
+ * Export the session's cookies (login state) as serializable objects.
672
+ * Self-hosted only; the desktop shell's embedded views use the real profile.
673
+ */
674
+ async flushAuth(session: BrowserSessionId): Promise<readonly ExportedCookie[]> {
675
+ const s = this.session(session)
676
+ const { handle } = this.activeTab(s)
677
+ const host = handle as { flushAuth?(): Promise<ExportedCookie[]> }
678
+ if (typeof host.flushAuth !== 'function') {
679
+ throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
680
+ }
681
+ const cookies = await host.flushAuth()
682
+ this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
683
+ return cookies
684
+ }
685
+
686
+ /** Import cookies into the session (restore login state). Self-hosted only. */
687
+ async restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number> {
688
+ const s = this.session(session)
689
+ const { handle } = this.activeTab(s)
690
+ const host = handle as { restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number> }
691
+ if (typeof host.restoreAuth !== 'function') {
692
+ throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED')
693
+ }
694
+ const restored = await host.restoreAuth(cookies)
695
+ this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
696
+ return restored
697
+ }
698
+
699
+ /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
700
+ async screenshot(
701
+ session: BrowserSessionId,
702
+ request?: { readonly fullPage?: boolean; readonly savePath?: string },
703
+ signal?: AbortSignal,
704
+ ): Promise<{ readonly dataUrl: string; readonly path?: string }> {
705
+ const { handle } = this.activeTab(this.session(session))
706
+ signal?.throwIfAborted()
707
+ const params: Record<string, unknown> = {}
708
+ if (request?.fullPage === true) {
709
+ // `captureBeyondViewport` captures the full scrollable content; without
710
+ // a clip this yields the full-page image (CDP default is the viewport).
711
+ params.captureBeyondViewport = true
712
+ }
713
+ const result = await handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params)
714
+ const data = result.data
715
+ if (typeof data !== 'string') {
716
+ throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
717
+ }
718
+ // Optional disk write so a vision tool (e.g. modlens read_image) can read
719
+ // the screenshot from a file path. Fire-and-forget: a write failure must
720
+ // not fail the capture.
721
+ if (request?.savePath !== undefined) {
722
+ try {
723
+ writeFileSync(request.savePath, Buffer.from(data, 'base64'))
724
+ return { dataUrl: `data:image/png;base64,${data}`, path: request.savePath }
725
+ } catch (error) {
726
+ // Report the write problem but keep the capture usable.
727
+ throw new BrowserError(`browser: screenshot save to "${request.savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error })
728
+ }
729
+ }
730
+ return { dataUrl: `data:image/png;base64,${data}` }
731
+ }
732
+
733
+ /** Append one operation to the session's history. */
734
+ private record(
735
+ s: Session,
736
+ action: string,
737
+ params: Record<string, unknown>,
738
+ ok: boolean,
739
+ detail?: { result?: string; error?: string },
740
+ ): void {
741
+ const entry: BrowserHistoryEntry = {
742
+ seq: s.history.length + 1,
743
+ action,
744
+ params,
745
+ ok,
746
+ ...detail?.result !== undefined ? { result: detail.result } : {},
747
+ ...detail?.error !== undefined ? { error: detail.error } : {},
748
+ at: Date.now(),
749
+ }
750
+ s.history.push(entry)
751
+ // Bound memory: keep the last 500 operations.
752
+ if (s.history.length > 500) s.history.splice(0, s.history.length - 500)
753
+ }
754
+
755
+ /** Return the session's chronological operation log (newest last). */
756
+ async history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]> {
757
+ return this.session(session).history
758
+ }
759
+
760
+ /**
761
+ * Replay one recorded operation by sequence number. Navigate/click/type are
762
+ * re-issued against the current page; execute re-runs its script. The
763
+ * replayed step is appended to history as a new entry.
764
+ * @param session - the session id.
765
+ * @param seq - the recorded entry's sequence number to replay.
766
+ */
767
+ async replay(session: BrowserSessionId, seq: number): Promise<void> {
768
+ const s = this.session(session)
769
+ const entry = s.history.find(e => e.seq === seq)
770
+ if (entry === undefined) {
771
+ throw new BrowserError(`browser: no history entry with seq ${seq}`, 'BROWSER_HISTORY_UNKNOWN')
772
+ }
773
+ switch (entry.action) {
774
+ case 'navigate': {
775
+ const url = entry.params.url
776
+ if (typeof url !== 'string') throw new BrowserError(`browser: history seq ${seq} navigate has no url`, 'BROWSER_HISTORY_INVALID')
777
+ await this.navigate(session, { url })
778
+ this.record(s, 'replay', { seq, of: entry.action, url }, true)
779
+ return
780
+ }
781
+ case 'click': {
782
+ const x = entry.params.x
783
+ const y = entry.params.y
784
+ if (typeof x !== 'number' || typeof y !== 'number') throw new BrowserError(`browser: history seq ${seq} click has no coordinates`, 'BROWSER_HISTORY_INVALID')
785
+ await this.click(session, { x, y })
786
+ this.record(s, 'replay', { seq, of: entry.action, x, y }, true)
787
+ return
788
+ }
789
+ case 'type': {
790
+ const text = entry.params.text
791
+ if (typeof text !== 'string') throw new BrowserError(`browser: history seq ${seq} type has no text`, 'BROWSER_HISTORY_INVALID')
792
+ await this.type(session, { text })
793
+ this.record(s, 'replay', { seq, of: entry.action, text }, true)
794
+ return
795
+ }
796
+ case 'execute': {
797
+ const script = entry.params.script
798
+ if (typeof script !== 'string') throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID')
799
+ const result = await this.execute(session, { script })
800
+ this.record(s, 'replay', { seq, of: entry.action, script }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception })
801
+ return
802
+ }
803
+ default:
804
+ throw new BrowserError(`browser: history seq ${seq} action "${entry.action}" is not replayable`, 'BROWSER_HISTORY_NOT_REPLAYABLE')
805
+ }
806
+ }
807
+
808
+ /** Close the session and destroy all its views. Idempotent. */
809
+ close(session: BrowserSessionId): Promise<void> {
810
+ const existing = this.sessions.get(session)
811
+ if (existing !== undefined) {
812
+ this.sessions.delete(session)
813
+ for (const tab of existing.tabs) this.host.destroyView(tab.handle)
814
+ }
815
+ return Promise.resolve()
816
+ }
817
+
818
+ /** Look up a session or throw the unknown-session error. */
819
+ private session(session: BrowserSessionId): Session {
820
+ const existing = this.sessions.get(session)
821
+ if (existing === undefined) {
822
+ throw new BrowserError(`browser: session "${session}" is not open`, 'BROWSER_SESSION_UNKNOWN')
823
+ }
824
+ return existing
825
+ }
826
+
827
+ /** The active tab of a session. */
828
+ private activeTab(s: Session): Tab {
829
+ const tab = s.tabs[s.activeIndex]
830
+ if (tab === undefined) throw new BrowserError('browser: session has no active tab', 'BROWSER_TAB_UNKNOWN')
831
+ return tab
832
+ }
833
+
834
+ /** Append a fresh tab and make it active. */
835
+ private newTab(s: Session): void {
836
+ const handle = this.host.createView()
837
+ s.tabs.push({ id: `tab:${randomUUID()}`, handle })
838
+ s.activeIndex = s.tabs.length - 1
839
+ this.showActive(s)
840
+ }
841
+
842
+ /** Ask the host to show the active tab's view. */
843
+ private showActive(s: Session): void {
844
+ this.host.showView?.(this.activeTab(s).handle)
845
+ }
846
+
847
+ /** Read the current URL of a view through CDP. */
848
+ private async currentUrl(handle: ElectronViewHandle): Promise<string> {
849
+ const result = await handleSendEvaluate(handle, 'location.href')
850
+ return result.ok && typeof result.value === 'string' ? result.value : ''
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Bound a promise so a wedged CDP call surfaces as an error instead of
856
+ * hanging the tool call forever. The caller's signal, when provided, wins
857
+ * over the timeout if it fires first.
858
+ * @param promise - the operation to bound.
859
+ * @param ms - the timeout budget.
860
+ * @param signal - optional caller signal.
861
+ * @param message - the timeout error message.
862
+ * @returns the promise's value, or a rejected promise on timeout/abort.
863
+ */
864
+ function withTimeout<T>(
865
+ promise: Promise<T>,
866
+ ms: number,
867
+ signal: AbortSignal | undefined,
868
+ message: string,
869
+ ): Promise<T> {
870
+ return new Promise<T>((resolve, reject) => {
871
+ let done = false
872
+ const timer = setTimeout(() => {
873
+ if (done) return
874
+ done = true
875
+ reject(new Error(message))
876
+ }, ms)
877
+ const finish = (fn: () => void): void => {
878
+ if (done) return
879
+ done = true
880
+ clearTimeout(timer)
881
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
882
+ fn()
883
+ }
884
+ const onAbort = (): void => {
885
+ if (done) return
886
+ done = true
887
+ clearTimeout(timer)
888
+ reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'))
889
+ }
890
+ if (signal !== undefined) signal.addEventListener('abort', onAbort, { once: true })
891
+ promise.then(
892
+ value => finish(() => resolve(value)),
893
+ error => finish(() => reject(error)),
894
+ )
895
+ })
896
+ }
897
+
898
+ /**
899
+ * Run a `Runtime.evaluate` through a view handle and normalize the result.
900
+ * Shared by execute, snapshot, content, and internal URL reads.
901
+ * @param handle - the view handle to evaluate in.
902
+ * @param expression - the JS expression.
903
+ * @param signal - optional abort signal; a fired signal rejects the call.
904
+ */
905
+ async function handleSendEvaluate(
906
+ handle: ElectronViewHandle,
907
+ expression: string,
908
+ signal?: AbortSignal,
909
+ ): Promise<BrowserExecuteResult> {
910
+ signal?.throwIfAborted()
911
+ const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
912
+ expression,
913
+ returnByValue: true,
914
+ awaitPromise: true,
915
+ } satisfies CdpEvaluateParams)
916
+ if (result.exceptionDetails !== undefined) {
917
+ const detail = result.exceptionDetails as { text?: string; exception?: { description?: string } }
918
+ return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' }
919
+ }
920
+ return { ok: true, value: (result.result as { value?: unknown } | undefined)?.value ?? null }
921
+ }
922
+