dsh-browser-plus 0.0.0-stage → 0.5.0

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 (76) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/LICENSE +22 -0
  3. package/NOTICE.md +7 -0
  4. package/README.en.md +100 -0
  5. package/README.md +99 -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/cordis.patch.yml +17 -0
  13. package/docs/MIGRATION.md +48 -0
  14. package/docs/README.md +22 -0
  15. package/docs/SOAK-CHECKLIST.md +98 -0
  16. package/docs/architecture.md +88 -0
  17. package/docs/tool-reference.md +124 -0
  18. package/docs/user-guide.md +121 -0
  19. package/docs/why-browser.md +45 -0
  20. package/lib/browser/runtime.d.ts +225 -0
  21. package/lib/browser/runtime.js +302 -0
  22. package/lib/browser/types.d.ts +668 -0
  23. package/lib/browser/types.js +18 -0
  24. package/lib/browser-electron/auth-cookies.d.ts +54 -0
  25. package/lib/browser-electron/auth-cookies.js +83 -0
  26. package/lib/browser-electron/chrome-state.d.ts +187 -0
  27. package/lib/browser-electron/chrome-state.js +12 -0
  28. package/lib/browser-electron/entry.d.ts +66 -0
  29. package/lib/browser-electron/entry.js +62 -0
  30. package/lib/browser-electron/fingerprint.d.ts +29 -0
  31. package/lib/browser-electron/fingerprint.js +42 -0
  32. package/lib/browser-electron/host-main.d.ts +18 -0
  33. package/lib/browser-electron/host-main.js +2494 -0
  34. package/lib/browser-electron/icon.d.ts +11 -0
  35. package/lib/browser-electron/icon.js +23 -0
  36. package/lib/browser-electron/page-chrome.d.ts +21 -0
  37. package/lib/browser-electron/page-chrome.js +2034 -0
  38. package/lib/browser-electron/provider.d.ts +709 -0
  39. package/lib/browser-electron/provider.js +2575 -0
  40. package/lib/browser-electron/remote-host.d.ts +143 -0
  41. package/lib/browser-electron/remote-host.js +952 -0
  42. package/lib/browser-electron/task-summary.d.ts +2 -0
  43. package/lib/browser-electron/task-summary.js +12 -0
  44. package/lib/browser-electron/task-thumbnail.d.ts +11 -0
  45. package/lib/browser-electron/task-thumbnail.js +9 -0
  46. package/lib/browser-electron/write-guard.d.ts +41 -0
  47. package/lib/browser-electron/write-guard.js +123 -0
  48. package/lib/index.d.ts +16 -0
  49. package/lib/index.js +14 -0
  50. package/lib/tool-browser/index.d.ts +31 -0
  51. package/lib/tool-browser/index.js +1931 -0
  52. package/package.json +95 -4
  53. package/screenshots.json +3 -0
  54. package/scripts/build-icons.mjs +80 -0
  55. package/scripts/capture-window.ps1 +79 -0
  56. package/scripts/crop-image.ps1 +20 -0
  57. package/scripts/smoke-browser-tools.mjs +1968 -0
  58. package/scripts/smoke-chrome-world.mjs +63 -0
  59. package/scripts/smoke-electron-host.mjs +50 -0
  60. package/src/browser/runtime.ts +470 -0
  61. package/src/browser/types.ts +649 -0
  62. package/src/browser-electron/auth-cookies.ts +125 -0
  63. package/src/browser-electron/chrome-state.ts +174 -0
  64. package/src/browser-electron/entry.ts +115 -0
  65. package/src/browser-electron/fingerprint.ts +45 -0
  66. package/src/browser-electron/host-main.ts +2330 -0
  67. package/src/browser-electron/icon.ts +26 -0
  68. package/src/browser-electron/page-chrome.ts +2046 -0
  69. package/src/browser-electron/provider.ts +3088 -0
  70. package/src/browser-electron/remote-host.ts +1004 -0
  71. package/src/browser-electron/task-summary.ts +10 -0
  72. package/src/browser-electron/task-thumbnail.ts +17 -0
  73. package/src/browser-electron/write-guard.ts +134 -0
  74. package/src/index.ts +52 -0
  75. package/src/tool-browser/index.ts +1974 -0
  76. package/src/types/electron-shim.d.ts +143 -0
@@ -0,0 +1,2575 @@
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
+ import { randomUUID } from 'node:crypto';
10
+ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { BrowserError } from "../browser/types.js";
12
+ import { PAGE_CHROME_HOST_ID, PAGE_CHROME_SCRIPT } from "./page-chrome.js";
13
+ import { defaultWriteRoots, resolveReadPath, resolveWritePath } from "./write-guard.js";
14
+ /**
15
+ * Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
16
+ * inside the page; returns `{ blocked, kind?, reason? }`. Marker-based and
17
+ * best-effort: checks for Cloudflare's interstitial, hCaptcha, reCAPTCHA,
18
+ * Turnstile, and generic challenge wording.
19
+ */
20
+ const CHALLENGE_DETECT_EXPRESSION = `(() => {
21
+ const title = (document.title || '').trim()
22
+ const bodyText = (document.body && document.body.innerText || '').slice(0, 4000)
23
+ const lower = (title + '\\n' + bodyText).toLowerCase()
24
+ const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
25
+ const framesLower = frameSrcs.toLowerCase()
26
+ const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl/i.test(lower)
27
+ || !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
28
+ const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
29
+ const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
30
+ const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile|challenge-platform/i.test(lower)
31
+ 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)
32
+ if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
33
+ if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
34
+ if (hasRecaptcha) return { blocked: true, kind: 'recaptcha', reason: 'Google reCAPTCHA verification' }
35
+ if (hasTurnstile) return { blocked: true, kind: 'turnstile', reason: 'Cloudflare Turnstile verification' }
36
+ if (verifyWording && /challenge|captcha|verification|security check|access denied|blocked|\\u9a8c\\u8bc1/i.test(lower)) {
37
+ return { blocked: true, kind: 'generic', reason: 'Human-verification challenge' }
38
+ }
39
+ return { blocked: false }
40
+ })()`;
41
+ /** Short suppression window so CDP input is not misclassified as physical user input. */
42
+ const AGENT_INPUT_SUPPRESSION_MS = 900;
43
+ /** Stable provider id registered with `ctx.browser`. */
44
+ export const ELECTRON_BROWSER_PROVIDER_ID = 'electron';
45
+ /** CDP method for a full-page screenshot capture. */
46
+ export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot';
47
+ /** CDP method for runtime evaluation (the execute path). */
48
+ export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate';
49
+ /**
50
+ * Decide how to hand a script to `Runtime.evaluate`.
51
+ *
52
+ * CDP evaluates an *expression*, so a script made of statements is a syntax
53
+ * error there. Try the expression form first - that keeps bare expressions and
54
+ * object literals returning their value, which is what the tool has always
55
+ * done - then fall back to a statement body. `const x = 1; return x` is the
56
+ * shape people actually type, and it used to come back as a bare SyntaxError.
57
+ */
58
+ export function buildEvaluateBody(script) {
59
+ try {
60
+ // Parses only; nothing is executed here.
61
+ new Function(`return (${script})`);
62
+ return { body: `return (${script})` };
63
+ }
64
+ catch { /* not an expression - try it as a body */ }
65
+ try {
66
+ new Function(script);
67
+ return { body: script };
68
+ }
69
+ catch (error) {
70
+ return { error: error instanceof Error ? error.message : String(error) };
71
+ }
72
+ }
73
+ /** CDP method for keyboard input. */
74
+ export const CDP_INPUT_DISPATCH_KEY_EVENT = 'Input.dispatchKeyEvent';
75
+ /**
76
+ * True when an input command's target view is already gone.
77
+ *
78
+ * The page's own chrome handles Ctrl+W/Ctrl+T by asking the provider to close or
79
+ * open a tab, so the key dispatch that triggered it is still in flight when the
80
+ * view disappears: the host answers `unknown view`, or CDP says the target closed.
81
+ * Measured on the real machine - create and destroy of one view 1.5s apart, then
82
+ * this error on the Ctrl+W that caused the destroy.
83
+ */
84
+ function isClosedInputTarget(error) {
85
+ if (!(error instanceof Error))
86
+ return false;
87
+ return /target closed|unknown view/i.test(error.message);
88
+ }
89
+ const KEY_VK = {
90
+ Backspace: 8,
91
+ Tab: 9,
92
+ Enter: 13,
93
+ Shift: 16,
94
+ Control: 17,
95
+ Alt: 18,
96
+ CapsLock: 20,
97
+ Escape: 27,
98
+ Space: 32,
99
+ PageUp: 33,
100
+ PageDown: 34,
101
+ End: 35,
102
+ Home: 36,
103
+ ArrowLeft: 37,
104
+ ArrowUp: 38,
105
+ ArrowRight: 39,
106
+ ArrowDown: 40,
107
+ Insert: 45,
108
+ Delete: 46,
109
+ Meta: 91,
110
+ F1: 112,
111
+ F2: 113,
112
+ F3: 114,
113
+ F4: 115,
114
+ F5: 116,
115
+ F6: 117,
116
+ F7: 118,
117
+ F8: 119,
118
+ F9: 120,
119
+ F10: 121,
120
+ F11: 122,
121
+ F12: 123,
122
+ };
123
+ /** Printable ASCII with no KEY_VK entry, mapped to its US-layout position. */
124
+ const PRINTABLE_CODES = {
125
+ '-': { code: 'Minus', vk: 189 },
126
+ '=': { code: 'Equal', vk: 187 },
127
+ '[': { code: 'BracketLeft', vk: 219 },
128
+ ']': { code: 'BracketRight', vk: 221 },
129
+ ';': { code: 'Semicolon', vk: 186 },
130
+ "'": { code: 'Quote', vk: 222 },
131
+ ',': { code: 'Comma', vk: 188 },
132
+ '.': { code: 'Period', vk: 190 },
133
+ '/': { code: 'Slash', vk: 191 },
134
+ '`': { code: 'Backquote', vk: 192 },
135
+ };
136
+ /** True for the ASCII range a key event can carry as text. */
137
+ function isPrintable(key) {
138
+ if (key.length !== 1)
139
+ return false;
140
+ const code = key.charCodeAt(0);
141
+ return code >= 0x20 && code <= 0x7e;
142
+ }
143
+ function keyText(key) {
144
+ switch (key) {
145
+ case 'Enter': return '\r';
146
+ case 'Tab': return '\t';
147
+ case 'Space': return ' ';
148
+ default: return isPrintable(key) ? key : null;
149
+ }
150
+ }
151
+ function keyDescriptor(key) {
152
+ const upper = key.toUpperCase();
153
+ // A physical Space produces e.key === ' ' with code 'Space'.
154
+ if (key === 'Space') {
155
+ return { key: ' ', code: 'Space', vk: KEY_VK.Space };
156
+ }
157
+ if (KEY_VK[key] !== undefined) {
158
+ return { key, code: key, vk: KEY_VK[key] };
159
+ }
160
+ if (/^[a-z]$/i.test(key)) {
161
+ // Unshifted letters deliver e.key lowercase; the code keeps the physical form.
162
+ return { key, code: `Key${upper}`, vk: upper.charCodeAt(0) };
163
+ }
164
+ if (/^[0-9]$/.test(key)) {
165
+ return { key, code: `Digit${key}`, vk: key.charCodeAt(0) };
166
+ }
167
+ if (isPrintable(key)) {
168
+ // Punctuation carries its US-layout position so shortcuts such as Ctrl+- and
169
+ // Ctrl+/ reach the page; anything else printable falls back to its own code.
170
+ const known = PRINTABLE_CODES[key];
171
+ return known === undefined
172
+ ? { key, code: key, vk: key.toUpperCase().charCodeAt(0) }
173
+ : { key, code: known.code, vk: known.vk };
174
+ }
175
+ throw new BrowserError(`browser: unsupported key "${key}"`, 'BROWSER_KEY_UNKNOWN');
176
+ }
177
+ function modifierMask(modifiers) {
178
+ let mask = 0;
179
+ for (const mod of modifiers ?? []) {
180
+ if (mod === 'alt')
181
+ mask |= 1;
182
+ else if (mod === 'ctrl')
183
+ mask |= 2;
184
+ else if (mod === 'meta')
185
+ mask |= 4;
186
+ else if (mod === 'shift')
187
+ mask |= 8;
188
+ }
189
+ return mask;
190
+ }
191
+ /** CDP method for navigation. */
192
+ export const CDP_PAGE_NAVIGATE = 'Page.navigate';
193
+ /** CDP methods used by native browser navigation controls. */
194
+ export const CDP_PAGE_GET_NAVIGATION_HISTORY = 'Page.getNavigationHistory';
195
+ export const CDP_PAGE_NAVIGATE_TO_HISTORY_ENTRY = 'Page.navigateToHistoryEntry';
196
+ export const CDP_PAGE_RELOAD = 'Page.reload';
197
+ export const CDP_PAGE_STOP_LOADING = 'Page.stopLoading';
198
+ /** Cap on content returned by a snapshot fetch to keep the wire bounded. */
199
+ const SNAPSHOT_LABEL_MAX = 120;
200
+ /** An input dispatch must not outlive this: a blocked renderer never acknowledges. */
201
+ const INPUT_DISPATCH_TIMEOUT_MS = 15_000;
202
+ /**
203
+ * Views whose renderer has already been told to consider itself focused.
204
+ * Weak so a destroyed view does not keep its handle alive.
205
+ */
206
+ const focusEmulatedViews = new WeakSet();
207
+ /** Gap between the move events of a drag; enough to span several frames. */
208
+ const DRAG_STEP_DELAY_MS = 8;
209
+ /** Upper bound on concurrent scrape workers; each one costs a tab. */
210
+ const MAX_SCRAPE_WORKERS = 8;
211
+ /** The immutable view of a job the seam hands out. */
212
+ function scrapeStatusOf(job) {
213
+ return {
214
+ id: job.id,
215
+ state: job.state,
216
+ total: job.total,
217
+ done: job.done,
218
+ failed: job.failed,
219
+ path: job.path,
220
+ ...job.error === undefined ? {} : { error: job.error },
221
+ };
222
+ }
223
+ /**
224
+ * One JSONL row. A page can return a value JSON cannot carry (a circular object,
225
+ * a BigInt); that must not kill a batch that has already written hundreds of rows.
226
+ */
227
+ function scrapeRow(row) {
228
+ try {
229
+ return JSON.stringify(row) + '\n';
230
+ }
231
+ catch (error) {
232
+ return JSON.stringify({
233
+ url: row.url,
234
+ ok: false,
235
+ error: `unserializable result: ${String(error?.message ?? error)}`,
236
+ }) + '\n';
237
+ }
238
+ }
239
+ /**
240
+ * Normalize one entry of a cookie export, or undefined when it cannot be used.
241
+ *
242
+ * Our own flushAuth emits `url`. Browser cookie editors (Cookie-Editor,
243
+ * EditThisCookie) and Edge's own export emit `domain` + `path` and no `url` at
244
+ * all — so requiring `url` rejected a file straight out of a browser wholesale,
245
+ * which is exactly the workflow this feature exists for. cookies.set wants a
246
+ * URL, so derive one when only the domain is present.
247
+ */
248
+ function normalizeExportedCookie(value) {
249
+ if (typeof value !== 'object' || value === null)
250
+ return undefined;
251
+ const record = value;
252
+ if (typeof record.name !== 'string' || typeof record.value !== 'string')
253
+ return undefined;
254
+ const path = typeof record.path === 'string' && record.path.startsWith('/') ? record.path : '/';
255
+ const url = typeof record.url === 'string' && record.url !== ''
256
+ ? record.url
257
+ : typeof record.domain === 'string' && record.domain !== ''
258
+ // A leading dot marks a domain-wide cookie; the URL host must not carry it.
259
+ ? `${record.secure === true ? 'https' : 'http'}://${record.domain.replace(/^\./, '')}${path}`
260
+ : undefined;
261
+ if (url === undefined)
262
+ return undefined;
263
+ const sameSite = normalizeSameSite(record.sameSite);
264
+ return {
265
+ url,
266
+ name: record.name,
267
+ value: record.value,
268
+ ...typeof record.domain === 'string' ? { domain: record.domain } : {},
269
+ ...typeof record.path === 'string' ? { path: record.path } : {},
270
+ ...typeof record.secure === 'boolean' ? { secure: record.secure } : {},
271
+ ...typeof record.httpOnly === 'boolean' ? { httpOnly: record.httpOnly } : {},
272
+ ...typeof record.expirationDate === 'number' ? { expirationDate: record.expirationDate } : {},
273
+ ...sameSite === undefined ? {} : { sameSite },
274
+ };
275
+ }
276
+ /**
277
+ * Cookie editors emit Playwright's spelling (None/Lax/Strict) and Chromium's
278
+ * (no_restriction/lax/strict). cookies.set wants the latter.
279
+ */
280
+ function normalizeSameSite(value) {
281
+ if (typeof value !== 'string')
282
+ return undefined;
283
+ switch (value.toLowerCase()) {
284
+ case 'none':
285
+ case 'no_restriction': return 'no_restriction';
286
+ case 'lax': return 'lax';
287
+ case 'strict': return 'strict';
288
+ case 'unspecified': return 'unspecified';
289
+ default: return undefined;
290
+ }
291
+ }
292
+ /** Total budget for the snapshot's empty-inventory retries. */
293
+ const SNAPSHOT_RETRY_BUDGET_MS = 3_000;
294
+ /**
295
+ * Longest script or typed text kept in one history entry. Entries exist to be
296
+ * replayed, so an over-long value is stored clipped and marked: replay then
297
+ * refuses outright rather than re-issuing a silently shortened script.
298
+ */
299
+ const HISTORY_PARAM_MAX_CHARS = 32_768;
300
+ /** Clip the replay payloads that would otherwise pin unbounded text in memory. */
301
+ function clampHistoryParams(params) {
302
+ const tooLong = (value) => typeof value === 'string' && value.length > HISTORY_PARAM_MAX_CHARS;
303
+ if (!tooLong(params.script) && !tooLong(params.text))
304
+ return params;
305
+ const clipped = { ...params };
306
+ if (tooLong(params.script)) {
307
+ clipped.script = params.script.slice(0, HISTORY_PARAM_MAX_CHARS);
308
+ clipped.scriptTruncated = true;
309
+ }
310
+ if (tooLong(params.text)) {
311
+ clipped.text = params.text.slice(0, HISTORY_PARAM_MAX_CHARS);
312
+ clipped.textTruncated = true;
313
+ }
314
+ return clipped;
315
+ }
316
+ /**
317
+ * Browser provider over Electron views. Sessions hold an ordered list of
318
+ * tabs; each tab is one view created by the host. The active tab receives
319
+ * every operation; switching tabs calls the host's optional `showView` and
320
+ * never loses state. Navigation is admitted only for HTTP(S) targets unless
321
+ * {@link ElectronBrowserProviderConfig.httpOnly} is disabled.
322
+ */
323
+ export class ElectronBrowserProvider {
324
+ host;
325
+ id = ELECTRON_BROWSER_PROVIDER_ID;
326
+ sessions = new Map();
327
+ /** Stable task-key index so callers can recover a session after tool-layer state loss. */
328
+ sessionsByTask = new Map();
329
+ taskStates = new Map();
330
+ httpOnly;
331
+ snapshotMaxElements;
332
+ contentMaxChars;
333
+ writeRoots;
334
+ readRoots;
335
+ /** Background scrape batches, keyed by id; rows live on disk, not here. */
336
+ scrapes = new Map();
337
+ constructor(host, config = {}) {
338
+ this.host = host;
339
+ this.httpOnly = config.httpOnly ?? true;
340
+ this.snapshotMaxElements = config.snapshotMaxElements ?? 60;
341
+ this.contentMaxChars = config.contentMaxChars ?? 100_000;
342
+ this.writeRoots = config.writeRoots ?? defaultWriteRoots();
343
+ this.readRoots = config.readRoots ?? defaultWriteRoots();
344
+ // A human clicking the injected chrome's own tabs is the one case where the
345
+ // host must tell the provider something: the host can show a different view,
346
+ // but only the provider owns the session's tab list and active index.
347
+ this.host.onChromeEvent?.(event => this.handleChromeEvent(event));
348
+ }
349
+ /**
350
+ * Apply one authenticated chrome request to the session that owns its task.
351
+ *
352
+ * Every field is re-checked here: the host authenticates the sender, this
353
+ * method decides whether the request still makes sense against the live tab
354
+ * model. A request that resolves to nothing (a stale strip, a tab closed a
355
+ * moment ago, a task with no session) is dropped rather than thrown, because
356
+ * a human click must never surface as an error inside a running tool call.
357
+ */
358
+ handleChromeEvent(event) {
359
+ if (typeof event !== 'object' || event === null)
360
+ return;
361
+ if (typeof event.taskKey !== 'string' || event.taskKey === '')
362
+ return;
363
+ const sessionId = this.sessionsByTask.get(event.taskKey);
364
+ if (sessionId === undefined)
365
+ return;
366
+ const s = this.sessions.get(sessionId);
367
+ if (s === undefined)
368
+ return;
369
+ try {
370
+ if (event.type === 'new-tab') {
371
+ this.newTab(s);
372
+ // 点收藏栏来的新标签会带 url —— 建完再导航(新标签已经是 active)。
373
+ if (typeof event.url === 'string' && event.url !== '') {
374
+ void this.navigate(sessionId, { url: event.url }).catch(() => undefined);
375
+ }
376
+ return;
377
+ }
378
+ if (typeof event.tabId !== 'string')
379
+ return;
380
+ const tab = s.tabs.find(candidate => candidate.handle.id === event.tabId);
381
+ if (tab === undefined)
382
+ return;
383
+ if (event.type === 'close-tab') {
384
+ void this.closeTab(sessionId, tab.id);
385
+ return;
386
+ }
387
+ if (event.type === 'move-tab' && typeof event.toIndex === 'number') {
388
+ // The human dragged this tab: mirror the host's order so the session's tab
389
+ // list (what browser_list_tabs reports) matches the strip.
390
+ const from = s.tabs.indexOf(tab);
391
+ if (from >= 0) {
392
+ const active = s.tabs[s.activeIndex];
393
+ const [moved] = s.tabs.splice(from, 1);
394
+ const to = Math.max(0, Math.min(Math.trunc(event.toIndex), s.tabs.length));
395
+ s.tabs.splice(to, 0, moved);
396
+ // activeIndex is positional, so keep it pointing at the same tab.
397
+ const activeNow = s.tabs.indexOf(active);
398
+ if (activeNow >= 0)
399
+ s.activeIndex = activeNow;
400
+ }
401
+ return;
402
+ }
403
+ if (event.type === 'activate-tab') {
404
+ const index = s.tabs.indexOf(tab);
405
+ if (index >= 0)
406
+ s.activeIndex = index;
407
+ }
408
+ }
409
+ catch {
410
+ // A chrome request is best-effort: never let one break the provider.
411
+ }
412
+ }
413
+ /**
414
+ * Usable whenever the host can create views. A host that exposes a local
415
+ * {@link ElectronBrowserViewHost.isAvailable} probe is believed; a host that
416
+ * omits it (a desktop shell's known-good viewHost, or a test fake) is assumed
417
+ * usable. The probe is cheap and local, so this stays callable from the seam's
418
+ * provider-selection path; the host owns any caching it needs.
419
+ */
420
+ available() {
421
+ const probe = this.host.isAvailable;
422
+ if (typeof probe !== 'function')
423
+ return true;
424
+ try {
425
+ return probe.call(this.host) === true;
426
+ }
427
+ catch {
428
+ // A probe that throws (e.g. a binary resolver error) means "not usable";
429
+ // provider selection must never surface that error itself.
430
+ return false;
431
+ }
432
+ }
433
+ /**
434
+ * Open or recover the browser session for a task key. The tool layer normally
435
+ * caches this id, but the Provider is authoritative so a scoped tool reload or
436
+ * a lost cache cannot create a second task session with a different tab set.
437
+ * Sessions keep isolated tabs, active tab, and history while the host keeps one
438
+ * human-selected task view visible in the shared BrowserWindow.
439
+ */
440
+ async open(options) {
441
+ const taskKey = options?.key ?? 'default';
442
+ const taskLabel = options?.label ?? '';
443
+ const existing = this.sessionForTask(taskKey);
444
+ if (existing !== undefined) {
445
+ if (taskLabel !== '' && existing.taskLabel !== taskLabel) {
446
+ existing.taskLabel = taskLabel;
447
+ const active = existing.tabs[existing.activeIndex]?.handle;
448
+ const labelable = active;
449
+ if (typeof labelable?.label === 'function')
450
+ await labelable.label(taskLabel).catch(() => undefined);
451
+ }
452
+ return existing.id;
453
+ }
454
+ const handle = this.host.createView(taskKey, taskLabel === '' ? undefined : taskLabel);
455
+ const id = `browser:${randomUUID()}`;
456
+ this.sessions.set(id, { id, taskKey, taskLabel, tabs: [this.createTab(handle)], activeIndex: 0, history: [], nextSeq: 1 });
457
+ this.sessionsByTask.set(taskKey, id);
458
+ if (!this.taskStates.has(taskKey)) {
459
+ this.taskStates.set(taskKey, { status: 'idle', control: 'agent', updatedAt: Date.now() });
460
+ }
461
+ return id;
462
+ }
463
+ /** Open a URL in the active tab (default) or a new tab. */
464
+ async openUrl(session, request, signal) {
465
+ const s = this.session(session);
466
+ if (request.newTab === true) {
467
+ this.newTab(s);
468
+ }
469
+ await this.navigate(session, { url: request.url }, signal);
470
+ }
471
+ /** List the session's tabs with their titles. */
472
+ async listTabs(session) {
473
+ const s = this.session(session);
474
+ const result = [];
475
+ for (let i = 0; i < s.tabs.length; i++) {
476
+ const tab = s.tabs[i];
477
+ if (tab === undefined)
478
+ continue; // defensive: array can shift under concurrency
479
+ result.push({
480
+ id: tab.id,
481
+ url: await this.currentUrl(tab.handle).catch(() => ''),
482
+ active: i === s.activeIndex,
483
+ });
484
+ }
485
+ return result;
486
+ }
487
+ /** Switch to a tab by id; background task tabs stay hidden until user-selected. */
488
+ switchTab(session, tabId) {
489
+ const s = this.session(session);
490
+ const index = s.tabs.findIndex(tab => tab.id === tabId);
491
+ if (index < 0) {
492
+ throw new BrowserError(`browser: tab "${tabId}" is not open in this session`, 'BROWSER_TAB_UNKNOWN');
493
+ }
494
+ s.activeIndex = index;
495
+ this.showActive(s);
496
+ return Promise.resolve();
497
+ }
498
+ /**
499
+ * Close one tab; closing the active tab activates the next. Resolves false when
500
+ * the id is not open in this session, so a miss is distinguishable from a close.
501
+ */
502
+ async closeTab(session, tabId) {
503
+ const s = this.session(session);
504
+ const index = s.tabs.findIndex(tab => tab.id === tabId);
505
+ if (index < 0)
506
+ return Promise.resolve(false); // idempotent
507
+ const removed = s.tabs[index];
508
+ if (removed !== undefined) {
509
+ s.tabs.splice(index, 1);
510
+ this.ignoreHostFailure(this.host.destroyView(removed.handle));
511
+ }
512
+ if (s.tabs.length === 0) {
513
+ // Session keeps one blank tab so it stays usable. **必须等**:newTab 建宿主视图是异步的,
514
+ // 先 showActive 会去显示一个还不存在的视图 → 宿主报 unknown view(而且是条没人接的 rejection,
515
+ // 会串到别的工具调用上报错)。
516
+ await this.newTab(s);
517
+ }
518
+ else if (index < s.activeIndex) {
519
+ // Closing a tab before the active one shifts the array left; keep the
520
+ // same tab active by decrementing the index.
521
+ s.activeIndex -= 1;
522
+ }
523
+ else if (s.activeIndex >= s.tabs.length) {
524
+ // The active tab itself was closed; activate the last remaining one.
525
+ s.activeIndex = s.tabs.length - 1;
526
+ }
527
+ this.showActive(s);
528
+ return true;
529
+ }
530
+ /** Close every tab and reset to one blank tab. */
531
+ reset(session) {
532
+ const s = this.session(session);
533
+ for (const tab of s.tabs)
534
+ this.ignoreHostFailure(this.host.destroyView(tab.handle));
535
+ s.tabs.length = 0;
536
+ this.newTab(s);
537
+ s.activeIndex = 0;
538
+ this.showActive(s);
539
+ return Promise.resolve();
540
+ }
541
+ /**
542
+ * Dispatch one input command under the same hang guard as the CDP reads. A
543
+ * renderer blocked in synchronous JS never acknowledges, so an unbounded await
544
+ * here would hang the tool call until the caller's budget expired.
545
+ * @param handle - the view to dispatch into.
546
+ * @param method - the CDP input method.
547
+ * @param params - its parameters.
548
+ * @param signal - optional caller signal.
549
+ */
550
+ async dispatchInput(handle, method, params, signal) {
551
+ try {
552
+ await this.ensureInputFocus(handle);
553
+ await withTimeout(handle.sendCommand(method, params), INPUT_DISPATCH_TIMEOUT_MS, signal, `browser: ${method} timed out after ${INPUT_DISPATCH_TIMEOUT_MS}ms`);
554
+ }
555
+ catch (error) {
556
+ // A tab closed under the keystroke that closed it is the requested outcome,
557
+ // not a failure: there is nothing left for this event to act on.
558
+ if (!isClosedInputTarget(error))
559
+ throw error;
560
+ }
561
+ }
562
+ /**
563
+ * Tell a renderer it is focused, once, before synthesized input.
564
+ *
565
+ * Chromium drops a synthesized mouse *press* when the renderer does not
566
+ * believe it has focus — which is the normal state for a background task's
567
+ * view, and on a real page even for the visible one while its window is not
568
+ * active. Moves are not gated, so hover looked fine while every click
569
+ * resolved its target, reported success, and left the page untouched.
570
+ *
571
+ * Focus emulation keeps this on the trusted CDP input path: no synthetic
572
+ * DOM click, so the events stay isTrusted and nothing about the page's
573
+ * view of the browser changes.
574
+ */
575
+ async ensureInputFocus(handle) {
576
+ if (focusEmulatedViews.has(handle))
577
+ return;
578
+ await handle.sendCommand('Emulation.setFocusEmulationEnabled', { enabled: true })
579
+ .then(() => { focusEmulatedViews.add(handle); })
580
+ .catch(() => undefined);
581
+ }
582
+ /**
583
+ * Admit one URL for a provider-driven fetch (navigation or download).
584
+ * The whole check is gated by `httpOnly`: when it is disabled, callers are
585
+ * trusted with any scheme. When it is enabled, only HTTP(S) is admitted and
586
+ * URL-embedded credentials are refused, so a target can never be reached
587
+ * with in-URL auth.
588
+ * @param url - the candidate URL.
589
+ * @param subject - the operation name used in the error text.
590
+ */
591
+ admitUrl(url, subject) {
592
+ if (!this.httpOnly)
593
+ return;
594
+ let parsed;
595
+ try {
596
+ parsed = new URL(url);
597
+ }
598
+ catch {
599
+ throw new BrowserError(`browser: refusing ${subject} to unparseable URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
600
+ }
601
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
602
+ throw new BrowserError(`browser: refusing ${subject} to non-HTTP(S) URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
603
+ }
604
+ if (parsed.username !== '' || parsed.password !== '') {
605
+ throw new BrowserError(`browser: refusing ${subject} to a URL with embedded credentials`, 'BROWSER_NAVIGATION_BLOCKED');
606
+ }
607
+ }
608
+ /** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
609
+ async navigate(session, request, signal) {
610
+ const s = this.session(session);
611
+ return this.navigateTab(s, this.activeTab(s), request.url, signal);
612
+ }
613
+ /**
614
+ * Navigate one tab. A scrape worker passes its own tab so a batch never races
615
+ * a tool call for the session's active tab.
616
+ * @param show - bring the tab to the front; a background worker passes false.
617
+ * @param settleMs - post-ready paint delay; a DOM-only reader passes 0.
618
+ */
619
+ async navigateTab(s, tab, url, signal, show = true, settleMs = 250) {
620
+ const { handle } = tab;
621
+ try {
622
+ this.admitUrl(url, 'navigation');
623
+ signal?.throwIfAborted();
624
+ // Page.navigate can hang on an unreachable/slow host; bound it like the
625
+ // evaluate paths so a wedged navigation surfaces as an error instead of
626
+ // blocking the tool call forever.
627
+ const timeoutMs = 30_000;
628
+ const result = await withTimeout(handle.sendCommand(CDP_PAGE_NAVIGATE, { url }), timeoutMs, signal, `browser: navigation timed out after ${timeoutMs}ms`);
629
+ // Page.navigate resolves even when the navigation fails; surface the
630
+ // failure instead of leaving a silent white screen.
631
+ const errorText = result.errorText;
632
+ if (typeof errorText === 'string' && errorText !== '') {
633
+ throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED');
634
+ }
635
+ this.invalidateSnapshots(tab);
636
+ this.record(s, 'navigate', { url }, true);
637
+ if (show)
638
+ this.showActive(s);
639
+ // Page.navigate resolves on commit. Wait best-effort for page load so
640
+ // browser_open does not snapshot a still-blank renderer.
641
+ await waitForDocumentReady(handle, signal, settleMs);
642
+ // Human chrome is page-injected: reapply after every document commit so
643
+ // the toolbar is present after each navigation.
644
+ void reinstallPageChrome(handle);
645
+ }
646
+ catch (error) {
647
+ if (!(error instanceof BrowserError && error.code === 'BROWSER_NAVIGATION_BLOCKED')) {
648
+ this.record(s, 'navigate', { url }, false, { error: String(error) });
649
+ }
650
+ throw error;
651
+ }
652
+ }
653
+ /** Navigate to the previous history entry when one exists. */
654
+ async back(session, signal) {
655
+ return this.navigateHistory(session, -1, 'back', signal);
656
+ }
657
+ /** Navigate to the next history entry when one exists. */
658
+ async forward(session, signal) {
659
+ return this.navigateHistory(session, 1, 'forward', signal);
660
+ }
661
+ /** Reload the active page and restore the browser chrome afterwards. */
662
+ async reload(session, signal) {
663
+ const s = this.session(session);
664
+ const tab = this.activeTab(s);
665
+ signal?.throwIfAborted();
666
+ await withTimeout(tab.handle.sendCommand(CDP_PAGE_RELOAD, {}), 30_000, signal, 'browser: reload timed out after 30000ms');
667
+ this.invalidateSnapshots(tab);
668
+ this.record(s, 'reload', {}, true);
669
+ this.showActive(s);
670
+ await waitForDocumentReady(tab.handle, signal);
671
+ void reinstallPageChrome(tab.handle);
672
+ }
673
+ /** Stop loading the active page. */
674
+ async stopLoading(session, signal) {
675
+ const s = this.session(session);
676
+ const { handle } = this.activeTab(s);
677
+ signal?.throwIfAborted();
678
+ await withTimeout(handle.sendCommand(CDP_PAGE_STOP_LOADING, {}), 10_000, signal, 'browser: stop loading timed out after 10000ms');
679
+ this.record(s, 'stop', {}, true);
680
+ }
681
+ /** Execute JS in the active tab's page context. */
682
+ async execute(session, request, signal) {
683
+ const s = this.session(session);
684
+ return this.executeTab(s, this.activeTab(s), request, signal);
685
+ }
686
+ /** Evaluate in one tab's page context. */
687
+ async executeTab(s, tab, request, signal) {
688
+ const { handle } = tab;
689
+ signal?.throwIfAborted();
690
+ await this.drainDialog(s, handle);
691
+ try {
692
+ // Wrap the script in a Function so `return` statements are legal and
693
+ // request.args arrive as `arguments[0..n]` (a real function, not an
694
+ // arrow, so `arguments` resolves). Args are embedded as a JSON array
695
+ // literal; unserializable members become null.
696
+ const built = buildEvaluateBody(request.script);
697
+ if ('error' in built) {
698
+ const exception = `browser: execute could not parse the script as an expression or as a statement body: ${built.error}`;
699
+ this.record(s, 'execute', { script: request.script }, false, { error: exception });
700
+ return { ok: false, exception };
701
+ }
702
+ const body = built.body;
703
+ const hasArgs = request.args !== undefined && request.args.length > 0;
704
+ const expression = hasArgs
705
+ ? `(function(){ const __dshArgs = ${JSON.stringify(request.args)}; return Function(${JSON.stringify(body)}).apply(null, __dshArgs) })()`
706
+ : `(function(){ return Function(${JSON.stringify(body)})() })()`;
707
+ // CDP Runtime.evaluate can hang indefinitely on a not-yet-loaded page
708
+ // (navigate returned but the renderer has not committed). Bound it so a
709
+ // stuck call surfaces as BROWSER_EXECUTE_TIMEOUT instead of wedging the
710
+ // whole tool call. The caller's signal wins when it fires first.
711
+ const timeoutMs = request.timeoutMs ?? 30_000;
712
+ const result = await withTimeout(handle.sendCommand(CDP_RUNTIME_EVALUATE, {
713
+ expression,
714
+ returnByValue: true,
715
+ awaitPromise: true,
716
+ }), timeoutMs, signal, `browser: execute timed out after ${timeoutMs}ms`);
717
+ if (result.exceptionDetails !== undefined) {
718
+ const detail = result.exceptionDetails;
719
+ const exception = detail.exception?.description ?? detail.text ?? 'unknown exception';
720
+ this.record(s, 'execute', { script: request.script }, false, { error: exception });
721
+ return { ok: false, exception };
722
+ }
723
+ const value = result.result?.value ?? null;
724
+ this.record(s, 'execute', {
725
+ script: request.script,
726
+ ...request.args !== undefined && request.args.length > 0 ? { args: request.args } : {},
727
+ }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) });
728
+ return { ok: true, value };
729
+ }
730
+ catch (error) {
731
+ if (error instanceof Error && error.name === 'TimeoutError') {
732
+ throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error });
733
+ }
734
+ throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error });
735
+ }
736
+ }
737
+ /** Produce an AI-friendly snapshot of the active tab. */
738
+ async snapshot(session, options = {}, signal) {
739
+ const s = this.session(session);
740
+ const tab = this.activeTab(s);
741
+ signal?.throwIfAborted();
742
+ await this.drainDialog(s, tab.handle);
743
+ const requested = options.limit === undefined ? this.snapshotMaxElements : Math.max(1, Math.min(1000, Math.trunc(options.limit)));
744
+ const cap = options.limit === undefined ? this.snapshotMaxElements : requested;
745
+ const query = (options.query ?? '').trim().toLowerCase();
746
+ const script = `(() => {
747
+ const cap = ${String(cap)}
748
+ const query = ${JSON.stringify(query)}
749
+ const locatorOf = (el) => {
750
+ if (el.id) return '#' + CSS.escape(el.id)
751
+ if (el.name) return '[name=' + JSON.stringify(el.name) + ']'
752
+ const aria = el.getAttribute('aria-label')
753
+ if (aria) return '[aria-label=' + JSON.stringify(aria) + ']'
754
+ const tag = el.tagName.toLowerCase()
755
+ const text = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 30)
756
+ if (text) return tag + ':has-text("' + text.replace(/"/g, '\\\\"') + '")'
757
+ return tag
758
+ }
759
+ const pathOf = (el) => {
760
+ if (el.id) return '#' + CSS.escape(el.id)
761
+ const parts = []
762
+ let node = el
763
+ while (node && node.nodeType === Node.ELEMENT_NODE) {
764
+ let part = node.tagName.toLowerCase()
765
+ const parent = node.parentElement
766
+ if (parent) {
767
+ const siblings = [...parent.children].filter(sibling => sibling.tagName === node.tagName)
768
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')'
769
+ }
770
+ parts.unshift(part)
771
+ if (node === document.body) break
772
+ node = parent
773
+ }
774
+ return parts.join(' > ')
775
+ }
776
+ const fingerprintOf = (el) => [
777
+ el.tagName,
778
+ el.getAttribute('type') || '',
779
+ el.id || '',
780
+ el.getAttribute('name') || '',
781
+ el.getAttribute('aria-label') || '',
782
+ (el.textContent || el.value || '').toString().replace(/\s+/g, ' ').trim().slice(0, 120),
783
+ ].join('\u001f')
784
+ const url = location.href
785
+ const title = document.title || undefined
786
+ const els = [...document.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [contenteditable="true"]')]
787
+ const out = []
788
+ let capped = false
789
+ for (const el of els) {
790
+ if (el.closest('[data-dsh-browser-chrome]')) continue
791
+ const r = el.getBoundingClientRect()
792
+ const cs = getComputedStyle(el)
793
+ if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
794
+ const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'input'))
795
+ : el.tagName === 'TEXTAREA' ? 'textarea'
796
+ : el.tagName === 'SELECT' ? 'select'
797
+ : el.tagName === 'BUTTON' ? 'button'
798
+ : el.tagName === 'A' ? 'link' : 'other'
799
+ 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)})
800
+ if (!label && kind !== 'link') continue
801
+ // 过滤放在计上限**之前**:默认上限是 60,先截断就永远搜不到后面的元素。
802
+ if (query !== '' && (kind + ' ' + label).toLowerCase().indexOf(query) === -1) continue
803
+ if (out.length >= cap) { capped = true; break }
804
+ out.push({
805
+ ref: out.length + 1,
806
+ kind,
807
+ label,
808
+ selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
809
+ loc: locatorOf(el),
810
+ path: pathOf(el),
811
+ fingerprint: fingerprintOf(el),
812
+ x: Math.round(r.x + r.width / 2),
813
+ y: Math.round(r.y + r.height / 2),
814
+ })
815
+ }
816
+ const challenge = ${CHALLENGE_DETECT_EXPRESSION}
817
+ const chromeHost = document.getElementById('__dsh_browser_chrome_host__')
818
+ const userControlling = chromeHost?.getAttribute('data-dsh-user-active') === '1'
819
+ // Only an early break means truncation: exactly cap candidates is a
820
+ // complete inventory, not a truncated one.
821
+ return { url, title, elements: out, truncated: capped, challenge, userControlling }
822
+ })()`;
823
+ // Same hang guard as execute: a renderer that has not committed after
824
+ // navigate would otherwise block snapshot forever.
825
+ const timeoutMs = 30_000;
826
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: snapshot timed out after ${timeoutMs}ms`);
827
+ if (!result.ok)
828
+ throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED');
829
+ let value = result.value;
830
+ // Framework apps often hydrate controls after the load event. A short
831
+ // bounded retry turns premature empty inventories into useful snapshots.
832
+ // The phase is budgeted because each attempt may itself wait the evaluation
833
+ // timeout, and an aborted call must surface rather than be retried on.
834
+ const retryDeadline = Date.now() + SNAPSHOT_RETRY_BUDGET_MS;
835
+ for (let attempt = 0; attempt < 5 && value.elements.length === 0 && value.truncated !== true; attempt++) {
836
+ const remaining = retryDeadline - Date.now();
837
+ if (remaining <= 0)
838
+ break;
839
+ signal?.throwIfAborted();
840
+ await new Promise(resolve => setTimeout(resolve, Math.min(400, remaining)));
841
+ if (signal?.aborted === true)
842
+ break;
843
+ const retry = await withTimeout(handleSendEvaluate(tab.handle, script, signal), Math.min(timeoutMs, Math.max(retryDeadline - Date.now(), 500)), signal, `browser: snapshot timed out after ${timeoutMs}ms`).catch((error) => {
844
+ if (signal?.aborted === true)
845
+ throw error;
846
+ return undefined;
847
+ });
848
+ if (retry?.ok)
849
+ value = retry.value;
850
+ }
851
+ const snapshotId = `snapshot:${randomUUID()}`;
852
+ const targets = new Map();
853
+ for (const element of value.elements) {
854
+ targets.set(element.ref, { path: element.path, fingerprint: element.fingerprint });
855
+ }
856
+ tab.snapshots.set(snapshotId, { tabId: tab.id, url: value.url, epoch: tab.navigationEpoch, targets });
857
+ while (tab.snapshots.size > 10) {
858
+ const oldest = tab.snapshots.keys().next().value;
859
+ if (oldest === undefined)
860
+ break;
861
+ tab.snapshots.delete(oldest);
862
+ }
863
+ return {
864
+ snapshotId,
865
+ url: value.url,
866
+ ...value.title !== undefined ? { title: value.title } : {},
867
+ elements: value.elements.map(({ path: _path, fingerprint: _fingerprint, ...element }) => element),
868
+ truncated: value.truncated,
869
+ ...value.challenge !== undefined ? { challenge: value.challenge } : {},
870
+ ...value.userControlling !== undefined ? { userControlling: value.userControlling } : {},
871
+ };
872
+ }
873
+ /** Click one element that belongs to a retained exact page snapshot. */
874
+ async clickRef(session, request, signal) {
875
+ const s = this.session(session);
876
+ const tab = this.activeTab(s);
877
+ signal?.throwIfAborted();
878
+ await this.drainDialog(s, tab.handle);
879
+ const point = await this.resolveSnapshotTarget(tab, request, 'center', signal);
880
+ await suppressAutoUserControl(tab.handle, signal);
881
+ await this.dispatchInput(tab.handle, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 }, signal);
882
+ await this.dispatchInput(tab.handle, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 }, signal);
883
+ this.record(s, 'clickRef', { snapshotId: request.snapshotId, ref: request.ref }, true);
884
+ }
885
+ /** Scroll one element that belongs to a retained exact page snapshot into view. */
886
+ async scrollIntoView(session, request, signal) {
887
+ const s = this.session(session);
888
+ const tab = this.activeTab(s);
889
+ signal?.throwIfAborted();
890
+ const result = await this.resolveSnapshotTarget(tab, request, request.block ?? 'center', signal);
891
+ this.record(s, 'scrollIntoView', { snapshotId: request.snapshotId, ref: request.ref, block: request.block ?? 'center' }, true);
892
+ return result;
893
+ }
894
+ /** Check whether a human-verification challenge is blocking the active tab. */
895
+ async detectChallenge(session, signal) {
896
+ const s = this.session(session);
897
+ const tab = this.activeTab(s);
898
+ signal?.throwIfAborted();
899
+ const timeoutMs = 15_000;
900
+ const result = await withTimeout(handleSendEvaluate(tab.handle, CHALLENGE_DETECT_EXPRESSION), timeoutMs, signal, `browser: challenge detection timed out after ${timeoutMs}ms`);
901
+ if (!result.ok) {
902
+ throw new BrowserError(`browser: challenge detection failed: ${result.exception}`, 'BROWSER_CHALLENGE_DETECT_FAILED');
903
+ }
904
+ const value = result.value;
905
+ return { blocked: value.blocked === true, kind: value.kind, reason: value.reason };
906
+ }
907
+ /** Fetch page content in a requested format. */
908
+ async content(session, request, signal) {
909
+ const s = this.session(session);
910
+ const tab = this.activeTab(s);
911
+ signal?.throwIfAborted();
912
+ await this.drainDialog(s, tab.handle);
913
+ const maxChars = request.maxChars ?? this.contentMaxChars;
914
+ const selector = request.selector ?? '';
915
+ const format = request.format;
916
+ const script = `(() => {
917
+ const root = ${selector === '' ? 'document.body' : `document.querySelector(${JSON.stringify(selector)})`}
918
+ if (!root) return { ok: false, reason: 'selector not found' }
919
+ const fmt = ${JSON.stringify(format)}
920
+ let content = ''
921
+ if (fmt === 'txt') content = root.innerText || ''
922
+ else if (fmt === 'html') content = root.outerHTML || ''
923
+ else if (fmt === 'json') {
924
+ // An element has no own enumerable properties, so JSON.stringify(root)
925
+ // always produced "{}". Serialize a bounded structural view instead, and
926
+ // pass a genuine JSON payload through unchanged.
927
+ const raw = (root.textContent || '').trim()
928
+ let parsed
929
+ try { parsed = JSON.parse(raw) } catch { parsed = undefined }
930
+ if (parsed !== undefined) content = JSON.stringify(parsed)
931
+ else {
932
+ const shape = (el, depth) => {
933
+ const node = { tag: el.tagName ? el.tagName.toLowerCase() : undefined }
934
+ if (depth >= 8) return node
935
+ if (el.id) node.id = el.id
936
+ if (typeof el.className === 'string' && el.className !== '') node.class = el.className
937
+ const kids = el.children ? [...el.children].slice(0, 40) : []
938
+ if (kids.length > 0) node.children = kids.map(child => shape(child, depth + 1))
939
+ else {
940
+ const text = (el.textContent || '').trim().slice(0, 200)
941
+ if (text !== '') node.text = text
942
+ }
943
+ return node
944
+ }
945
+ content = JSON.stringify(shape(root, 0))
946
+ }
947
+ }
948
+ // markdown: headings, links, lists, paragraphs (best-effort). The
949
+ // renderer is embedded from its own source, so the function under test
950
+ // is byte-for-byte the one that runs in the page.
951
+ else content = (${renderMarkdown.toString()})(root)
952
+ const truncated = content.length > ${String(maxChars)}
953
+ return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
954
+ })()`;
955
+ // Honor a per-call timeout: content evaluation can hang on a heavy page,
956
+ // so a caller-supplied budget bounds it. Unlike a bare signal entry check,
957
+ // withTimeout also interrupts a call already in flight.
958
+ const timeoutMs = request.timeoutMs ?? 30_000;
959
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: content timed out after ${timeoutMs}ms`);
960
+ if (!result.ok)
961
+ throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED');
962
+ const value = result.value;
963
+ if (!value.ok)
964
+ throw new BrowserError(`browser: content fetch failed: ${value.reason ?? 'unknown'}`, 'BROWSER_CONTENT_FAILED');
965
+ return { content: value.content ?? '', truncated: value.truncated ?? false };
966
+ }
967
+ /** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
968
+ async click(session, target, signal) {
969
+ const s = this.session(session);
970
+ const { handle } = this.activeTab(s);
971
+ signal?.throwIfAborted();
972
+ await this.drainDialog(s, handle);
973
+ const point = await resolvePointerTarget(handle, target, signal);
974
+ await suppressAutoUserControl(handle, signal);
975
+ // Electron installs no native context menu, so a right-click reaches the
976
+ // page's own handler — which is exactly what an agent wants to drive.
977
+ const button = target.button ?? 'left';
978
+ const modifiers = modifierMask(target.modifiers);
979
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button, clickCount: 1, modifiers }, signal);
980
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button, clickCount: 1, modifiers }, signal);
981
+ 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);
982
+ return point;
983
+ }
984
+ /** Double-click a target (physical input; clickCount 2). */
985
+ async doubleClick(session, target, signal) {
986
+ const s = this.session(session);
987
+ const { handle } = this.activeTab(s);
988
+ signal?.throwIfAborted();
989
+ await this.drainDialog(s, handle);
990
+ const point = await resolvePointerTarget(handle, target, signal);
991
+ await suppressAutoUserControl(handle, signal);
992
+ const button = target.button ?? 'left';
993
+ const modifiers = modifierMask(target.modifiers);
994
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button, clickCount: 2, modifiers }, signal);
995
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button, clickCount: 2, modifiers }, signal);
996
+ 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);
997
+ return point;
998
+ }
999
+ /** Move the pointer over a target (no click). */
1000
+ async hover(session, target, signal) {
1001
+ const s = this.session(session);
1002
+ const { handle } = this.activeTab(s);
1003
+ signal?.throwIfAborted();
1004
+ await this.drainDialog(s, handle);
1005
+ const point = await resolvePointerTarget(handle, target, signal);
1006
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y, button: 'none', modifiers: modifierMask(target.modifiers) }, signal);
1007
+ this.record(s, 'hover', { x: point.x, y: point.y, ...point.target === undefined ? {} : { target: point.target } }, true);
1008
+ return point;
1009
+ }
1010
+ /**
1011
+ * Press on one target, move to another, release.
1012
+ *
1013
+ * A hand does not teleport: the intermediate moves are what pointer-based
1014
+ * sliders and sortable libraries listen for, so a press straight onto the
1015
+ * destination would be ignored. Note this drives *pointer* drags only —
1016
+ * HTML5 drag-and-drop needs dragstart/drop, which synthesized mouse moves do
1017
+ * not produce; use the page's own controls, or a click-based reorder, there.
1018
+ */
1019
+ async drag(session, request, signal) {
1020
+ const s = this.session(session);
1021
+ const { handle } = this.activeTab(s);
1022
+ signal?.throwIfAborted();
1023
+ await this.drainDialog(s, handle);
1024
+ const from = await resolvePointerTarget(handle, request.from, signal);
1025
+ const to = await resolvePointerTarget(handle, request.to, signal);
1026
+ await suppressAutoUserControl(handle, signal);
1027
+ const steps = Math.max(1, Math.min(Math.trunc(request.steps ?? 12) || 12, 60));
1028
+ // Hover the source first: some libraries only arm on an enter.
1029
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: from.x, y: from.y, button: 'none', buttons: 0 }, signal);
1030
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: from.x, y: from.y, button: 'left', clickCount: 1, buttons: 1 }, signal);
1031
+ for (let step = 1; step <= steps; step += 1) {
1032
+ const ratio = step / steps;
1033
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', {
1034
+ type: 'mouseMoved',
1035
+ x: from.x + (to.x - from.x) * ratio,
1036
+ y: from.y + (to.y - from.y) * ratio,
1037
+ button: 'left',
1038
+ buttons: 1,
1039
+ }, signal);
1040
+ // Distinct events, not one burst: a listener that reads positions per
1041
+ // frame needs the gesture to span more than a single tick.
1042
+ await new Promise(resolve => setTimeout(resolve, DRAG_STEP_DELAY_MS));
1043
+ }
1044
+ await this.dispatchInput(handle, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: to.x, y: to.y, button: 'left', clickCount: 1, buttons: 0 }, signal);
1045
+ this.record(s, 'drag', {
1046
+ from: { x: Math.round(from.x), y: Math.round(from.y), ...from.target === undefined ? {} : { target: from.target } },
1047
+ to: { x: Math.round(to.x), y: Math.round(to.y), ...to.target === undefined ? {} : { target: to.target } },
1048
+ steps,
1049
+ }, true);
1050
+ return { from, to };
1051
+ }
1052
+ /** Scroll the active page by CSS-pixel deltas and return the final position. */
1053
+ async scroll(session, request, signal) {
1054
+ const s = this.session(session);
1055
+ const tab = this.activeTab(s);
1056
+ signal?.throwIfAborted();
1057
+ const deltaX = request.deltaX ?? 0;
1058
+ const deltaY = request.deltaY ?? 0;
1059
+ const hasExplicitDelta = request.deltaX !== undefined || request.deltaY !== undefined;
1060
+ const script = `(() => {
1061
+ const deltaX = ${JSON.stringify(deltaX)}
1062
+ const deltaY = ${JSON.stringify(deltaY)}
1063
+ const hasExplicitDelta = ${JSON.stringify(hasExplicitDelta)}
1064
+ const effectiveDeltaY = hasExplicitDelta ? deltaY : Math.max(window.innerHeight * 0.8, 480)
1065
+ window.scrollBy(deltaX, effectiveDeltaY)
1066
+ const root = document.documentElement
1067
+ return {
1068
+ x: window.scrollX,
1069
+ y: window.scrollY,
1070
+ maxX: Math.max(0, root.scrollWidth - window.innerWidth),
1071
+ maxY: Math.max(0, root.scrollHeight - window.innerHeight),
1072
+ }
1073
+ })()`;
1074
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), 15_000, signal, 'browser: scroll timed out');
1075
+ if (!result.ok)
1076
+ throw new BrowserError(`browser: scroll failed: ${result.exception}`, 'BROWSER_SCROLL_FAILED');
1077
+ const value = result.value;
1078
+ if (typeof value.x !== 'number' || typeof value.y !== 'number' || typeof value.maxX !== 'number' || typeof value.maxY !== 'number') {
1079
+ throw new BrowserError('browser: scroll returned invalid coordinates', 'BROWSER_SCROLL_FAILED');
1080
+ }
1081
+ this.record(s, 'scroll', { deltaX, deltaY: hasExplicitDelta ? deltaY : 'viewport' }, true);
1082
+ return { x: value.x, y: value.y, maxX: value.maxX, maxY: value.maxY };
1083
+ }
1084
+ /**
1085
+ * Attach a local file to the first matching file input. Uses the CDP DOM
1086
+ * domain (nodeId path), which — unlike a synthetic change event — makes the
1087
+ * input's files list true (real file selection), so pages that read
1088
+ * input.files or upload on change behave exactly like a real pick.
1089
+ */
1090
+ async uploadFile(session, request, signal) {
1091
+ const s = this.session(session);
1092
+ const { handle } = this.activeTab(s);
1093
+ signal?.throwIfAborted();
1094
+ await this.drainDialog(s, handle);
1095
+ // Same input semantics as click/type: do not let the page hand control to
1096
+ // the human while an agent-driven file selection is in flight.
1097
+ await suppressAutoUserControl(handle, signal);
1098
+ const selector = request.selector ?? 'input[type="file"]';
1099
+ // A file handed to a page leaves the machine, so admission happens before
1100
+ // any DOM work: the path must exist and sit inside the configured roots.
1101
+ const filePath = resolveReadPath(request.filePath, this.readRoots);
1102
+ // Bound the whole DOM sequence: a wedged renderer must not hang the tool.
1103
+ const timeoutMs = 30_000;
1104
+ await withTimeout((async () => {
1105
+ const doc = await handle.sendCommand('DOM.getDocument', {});
1106
+ const root = doc.root;
1107
+ const rootId = root?.nodeId;
1108
+ if (rootId === undefined) {
1109
+ throw new BrowserError('browser: could not resolve the document node', 'BROWSER_UPLOAD_FAILED');
1110
+ }
1111
+ const query = await handle.sendCommand('DOM.querySelector', { nodeId: rootId, selector });
1112
+ const nodeId = query.nodeId;
1113
+ if (nodeId === undefined || nodeId === 0) {
1114
+ throw new BrowserError(`browser: no file input matches "${selector}"`, 'BROWSER_UPLOAD_NO_INPUT');
1115
+ }
1116
+ await handle.sendCommand('DOM.setFileInputFiles', { files: [filePath], nodeId });
1117
+ })(), timeoutMs, signal, `browser: upload timed out after ${timeoutMs}ms`);
1118
+ this.record(s, 'uploadFile', { filePath: request.filePath, selector }, true, { result: '1 file attached' });
1119
+ return { path: request.filePath };
1120
+ }
1121
+ /**
1122
+ * Poll until an element matching the selector exists (and is visible).
1123
+ * Bounds the total wait; a timeout surfaces as BROWSER_WAIT_TIMEOUT.
1124
+ */
1125
+ async waitForElement(session, request, signal) {
1126
+ const s = this.session(session);
1127
+ return this.waitForElementTab(s, this.activeTab(s), request, signal);
1128
+ }
1129
+ /** Poll one tab until the selector matches. */
1130
+ async waitForElementTab(s, tab, request, signal) {
1131
+ const { handle } = tab;
1132
+ signal?.throwIfAborted();
1133
+ const timeoutMs = request.timeoutMs ?? 15_000;
1134
+ const visible = request.visible !== false;
1135
+ const selector = request.selector;
1136
+ const script = `(() => {
1137
+ let el = null
1138
+ try { el = document.querySelector(${JSON.stringify(selector)}) } catch (e) { return { error: String(e) } }
1139
+ if (!el) return null
1140
+ if (${visible}) {
1141
+ const r = el.getBoundingClientRect()
1142
+ const cs = getComputedStyle(el)
1143
+ if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') return null
1144
+ }
1145
+ return {
1146
+ found: true,
1147
+ selector: ${JSON.stringify(selector)},
1148
+ tag: el.tagName.toLowerCase(),
1149
+ text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200),
1150
+ }
1151
+ })()`;
1152
+ const deadline = Date.now() + timeoutMs;
1153
+ let lastError;
1154
+ while (Date.now() <= deadline) {
1155
+ signal?.throwIfAborted();
1156
+ const result = await withTimeout(handleSendEvaluate(handle, script, signal), Math.max(deadline - Date.now(), 250), signal, 'browser: wait poll timed out').catch((error) => ({ ok: false, exception: String(error) }));
1157
+ if (!result.ok) {
1158
+ lastError = result.exception;
1159
+ }
1160
+ else {
1161
+ const value = result.value;
1162
+ if (value?.found === true && typeof value.tag === 'string') {
1163
+ this.record(s, 'waitForElement', { selector, timeoutMs, visible }, true, { result: value.tag });
1164
+ return { found: true, selector, tag: value.tag, text: value.text ?? '' };
1165
+ }
1166
+ if (value?.error !== undefined) {
1167
+ // A malformed selector can never start matching, so fail now instead of
1168
+ // polling to the deadline and reporting a misleading timeout.
1169
+ throw new BrowserError(`browser: invalid selector "${selector}": ${value.error}`, 'BROWSER_SELECTOR_INVALID');
1170
+ }
1171
+ }
1172
+ await new Promise(resolve => setTimeout(resolve, Math.min(250, Math.max(deadline - Date.now(), 1))));
1173
+ }
1174
+ throw new BrowserError(`browser: element "${selector}" did not appear within ${timeoutMs}ms${lastError !== undefined ? ` (${lastError})` : ''}`, 'BROWSER_WAIT_TIMEOUT');
1175
+ }
1176
+ /** Type into the focused element. */
1177
+ async type(session, request, signal) {
1178
+ const s = this.session(session);
1179
+ const { handle } = this.activeTab(s);
1180
+ signal?.throwIfAborted();
1181
+ await this.drainDialog(s, handle);
1182
+ await suppressAutoUserControl(handle, signal);
1183
+ await this.dispatchInput(handle, 'Input.insertText', { text: request.text }, signal);
1184
+ // Store the full text so replay re-issues the same input; the history
1185
+ // tool truncates long values when rendering.
1186
+ this.record(s, 'type', { text: request.text }, true);
1187
+ }
1188
+ /**
1189
+ * Drive the host's chrome frame view with a raw CDP command.
1190
+ *
1191
+ * The chrome can live in a view of its own so the page viewport can really
1192
+ * shrink; that view is not a tab, so this is the only way to click the toolbar
1193
+ * (the click tests use it, and so does anything that needs to exercise the
1194
+ * chrome the way a person does).
1195
+ */
1196
+ async chromeInput(method, params = {}) {
1197
+ const host = this.host;
1198
+ if (typeof host.chromeInput !== 'function')
1199
+ throw new Error('this browser host has no chrome frame view');
1200
+ await host.chromeInput(method, params);
1201
+ }
1202
+ /**
1203
+ * Read a value back out of the chrome frame's own document.
1204
+ *
1205
+ * The frame is not a tab, so a page-directed evaluate cannot see it; without
1206
+ * this the toolbar's animations could only be inferred from the page's copy.
1207
+ */
1208
+ async chromeEval(expression) {
1209
+ const host = this.host;
1210
+ if (typeof host.chromeEval !== 'function')
1211
+ throw new Error('this browser host has no chrome frame view');
1212
+ return await host.chromeEval(expression);
1213
+ }
1214
+ /** Press a key into the page (keyDown + keyUp), as a physical-input path
1215
+ * for shortcuts and keyboard-driven UI. */
1216
+ async pressKey(session, request, signal) {
1217
+ const s = this.session(session);
1218
+ const { handle } = this.activeTab(s);
1219
+ signal?.throwIfAborted();
1220
+ await this.drainDialog(s, handle);
1221
+ await suppressAutoUserControl(handle, signal);
1222
+ const { key, code, vk } = keyDescriptor(request.key);
1223
+ const modifiers = modifierMask(request.modifiers);
1224
+ const text = keyText(request.key);
1225
+ const down = { type: text === null ? 'rawKeyDown' : 'keyDown', key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, modifiers };
1226
+ if (text !== null) {
1227
+ down.text = text;
1228
+ down.unmodifiedText = text;
1229
+ }
1230
+ await this.dispatchInput(handle, CDP_INPUT_DISPATCH_KEY_EVENT, down, signal);
1231
+ await this.dispatchInput(handle, CDP_INPUT_DISPATCH_KEY_EVENT, { type: 'keyUp', key, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, modifiers }, signal);
1232
+ this.record(s, 'pressKey', { key: request.key, ...(request.modifiers !== undefined && request.modifiers.length > 0 ? { modifiers: request.modifiers } : {}) }, true);
1233
+ }
1234
+ /**
1235
+ * Fill a form's fields in one batch. Runs one page-context script that
1236
+ * resolves each field (selector, or name/label/placeholder among visible
1237
+ * controls), sets its value with the native prototype setter (React/Vue
1238
+ * controlled inputs included) plus input/change events, handles
1239
+ * select/checkbox/radio/contenteditable, and optionally submits the form.
1240
+ */
1241
+ async fillForm(session, request, signal) {
1242
+ const s = this.session(session);
1243
+ const tab = this.activeTab(s);
1244
+ signal?.throwIfAborted();
1245
+ await this.drainDialog(s, tab.handle);
1246
+ const specs = JSON.stringify(request.fields.map(f => ({
1247
+ selector: f.selector ?? null,
1248
+ name: f.name ?? null,
1249
+ label: f.label ?? null,
1250
+ placeholder: f.placeholder ?? null,
1251
+ kind: f.kind ?? 'text',
1252
+ value: f.value,
1253
+ })));
1254
+ const submitFlag = request.submit === true;
1255
+ const script = `(() => {
1256
+ const specs = ${specs}
1257
+ const out = []
1258
+ const setNative = (el, proto, value) => {
1259
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
1260
+ if (setter) setter.call(el, value)
1261
+ else el.value = value
1262
+ }
1263
+ const visible = (el) => {
1264
+ const r = el.getBoundingClientRect()
1265
+ const cs = getComputedStyle(el)
1266
+ return r.width >= 4 && r.height >= 4 && cs.visibility !== 'hidden' && cs.display !== 'none'
1267
+ }
1268
+ const describe = (spec) => spec.selector || spec.name || spec.label || spec.placeholder || '(unspecified)'
1269
+ const matches = (el, spec) => {
1270
+ if (spec.selector) { try { return el.matches(spec.selector) } catch { return false } }
1271
+ if (spec.name && el.name === spec.name) return true
1272
+ if (spec.placeholder && el.placeholder === spec.placeholder) return true
1273
+ if (spec.label) {
1274
+ if (el.getAttribute('aria-label') === spec.label) return true
1275
+ if (el.id) {
1276
+ const lbl = document.querySelector('label[for=' + JSON.stringify(el.id) + ']')
1277
+ if (lbl && (lbl.textContent || '').trim() === spec.label) return true
1278
+ }
1279
+ const wrap = el.closest('label')
1280
+ if (wrap && (wrap.textContent || '').trim() === spec.label) return true
1281
+ }
1282
+ return false
1283
+ }
1284
+ const candidates = (spec) => {
1285
+ const raw = spec.selector
1286
+ ? [...document.querySelectorAll(spec.selector)]
1287
+ : [...document.querySelectorAll('input, textarea, select, [contenteditable="true"]')].filter(el => matches(el, spec))
1288
+ const all = raw.filter(el => !el.closest('[data-dsh-browser-chrome]'))
1289
+ const vis = all.filter(visible)
1290
+ return vis.length > 0 ? vis : all
1291
+ }
1292
+ const filledEls = []
1293
+ for (const spec of specs) {
1294
+ let els
1295
+ try {
1296
+ els = candidates(spec)
1297
+ } catch (e) {
1298
+ // A malformed selector must not abort the whole batch; report the
1299
+ // field as failed and continue with the rest.
1300
+ out.push({ ok: false, error: String(e), target: describe(spec) })
1301
+ continue
1302
+ }
1303
+ if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
1304
+ const el = els[0]
1305
+ const tag = el.tagName
1306
+ const type = (el.type || '').toLowerCase()
1307
+ const before = out.length
1308
+ try {
1309
+ if (tag === 'SELECT') {
1310
+ const wanted = String(spec.value)
1311
+ if (el.multiple) {
1312
+ const wantedList = wanted.split(',').map(x => x.trim())
1313
+ let hit = false
1314
+ for (const o of [...el.options]) {
1315
+ o.selected = wantedList.includes(o.value) || wantedList.includes((o.textContent || '').trim())
1316
+ if (o.selected) hit = true
1317
+ }
1318
+ if (!hit) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
1319
+ } else {
1320
+ let opt = [...el.options].find(o => o.value === wanted)
1321
+ if (!opt) opt = [...el.options].find(o => (o.textContent || '').trim() === wanted)
1322
+ if (!opt) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
1323
+ setNative(el, HTMLSelectElement.prototype, opt.value)
1324
+ }
1325
+ el.dispatchEvent(new Event('input', { bubbles: true }))
1326
+ el.dispatchEvent(new Event('change', { bubbles: true }))
1327
+ out.push({ ok: true, method: 'select', target: describe(spec) })
1328
+ } else if (type === 'file') {
1329
+ out.push({ ok: false, error: 'file inputs cannot be set from script; use browser_download or ask the human', target: describe(spec) })
1330
+ } else if (type === 'checkbox') {
1331
+ const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
1332
+ if (el.checked !== want) el.click()
1333
+ if (el.checked !== want) { out.push({ ok: false, error: 'checkbox did not change (disabled, or a handler prevented it)', target: describe(spec) }); continue }
1334
+ out.push({ ok: true, method: 'checkbox', target: describe(spec) })
1335
+ } else if (type === 'radio') {
1336
+ const wanted = String(spec.value)
1337
+ const radio = [...document.querySelectorAll('input[type="radio"][name=' + JSON.stringify(el.name || '') + ']')]
1338
+ .find(r => r.value === wanted || (r === el && (spec.value === true || spec.value === 'true')))
1339
+ if (!radio) { out.push({ ok: false, error: 'radio option not found: ' + wanted, target: describe(spec) }); continue }
1340
+ if (!radio.checked) radio.click()
1341
+ if (!radio.checked) { out.push({ ok: false, error: 'radio did not change (disabled, or a handler prevented it)', target: describe(spec) }); continue }
1342
+ out.push({ ok: true, method: 'radio', target: describe(spec) })
1343
+ } else if (el.isContentEditable) {
1344
+ el.textContent = String(spec.value)
1345
+ el.dispatchEvent(new Event('input', { bubbles: true }))
1346
+ out.push({ ok: true, method: 'contenteditable', target: describe(spec) })
1347
+ } else if (tag === 'TEXTAREA') {
1348
+ const wanted = String(spec.value)
1349
+ setNative(el, HTMLTextAreaElement.prototype, wanted)
1350
+ el.dispatchEvent(new Event('input', { bubbles: true }))
1351
+ el.dispatchEvent(new Event('change', { bubbles: true }))
1352
+ if (wanted !== '' && el.value === '') { out.push({ ok: false, error: 'the field rejected the value', target: describe(spec) }); continue }
1353
+ out.push({ ok: true, method: 'textarea', target: describe(spec) })
1354
+ } else {
1355
+ const wanted = String(spec.value)
1356
+ setNative(el, HTMLInputElement.prototype, wanted)
1357
+ el.dispatchEvent(new Event('input', { bubbles: true }))
1358
+ el.dispatchEvent(new Event('change', { bubbles: true }))
1359
+ // A constrained input (number/date/email) silently blanks a value it
1360
+ // will not accept. Only the empty outcome is unambiguous: the DOM may
1361
+ // legitimately normalise a value it did accept.
1362
+ if (wanted !== '' && el.value === '') { out.push({ ok: false, error: 'the field rejected the value', target: describe(spec) }); continue }
1363
+ out.push({ ok: true, method: 'input', target: describe(spec) })
1364
+ }
1365
+ } catch (e) {
1366
+ out.push({ ok: false, error: String(e), target: describe(spec) })
1367
+ }
1368
+ // Remember what actually landed, so submit anchors on a form the caller
1369
+ // really filled rather than the first field the page happens to expose.
1370
+ if (out.length > before && out[out.length - 1].ok === true) filledEls.push(el)
1371
+ }
1372
+ let submitted = false
1373
+ let blockReason = ''
1374
+ if (${submitFlag}) {
1375
+ let anchor = null
1376
+ for (let index = filledEls.length - 1; index >= 0; index--) {
1377
+ const candidate = filledEls[index]
1378
+ if (candidate.form || candidate.closest('form')) { anchor = candidate; break }
1379
+ }
1380
+ const form = anchor === null ? null : (anchor.form || anchor.closest('form'))
1381
+ if (form === null) blockReason = 'no containing form'
1382
+ // requestSubmit() runs constraint validation: on an invalid form it
1383
+ // neither throws nor submits, so reporting submitted:true was a silent
1384
+ // false success.
1385
+ else if (typeof form.checkValidity === 'function' && form.checkValidity() !== true) blockReason = 'the form is invalid'
1386
+ else { form.requestSubmit(); submitted = true }
1387
+ }
1388
+ return { fields: out, submitted, blockReason }
1389
+ })()`;
1390
+ const timeoutMs = request.timeoutMs ?? 30_000;
1391
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: fillForm timed out after ${timeoutMs}ms`);
1392
+ if (!result.ok) {
1393
+ throw new BrowserError(`browser: fillForm evaluation failed: ${result.exception}`, 'BROWSER_FILL_FAILED');
1394
+ }
1395
+ const value = result.value;
1396
+ const okCount = value.fields.filter(f => f.ok).length;
1397
+ const submitNote = value.submitted
1398
+ ? ', form submitted'
1399
+ : value.blockReason !== undefined && value.blockReason !== ''
1400
+ ? `, submit blocked: ${value.blockReason}`
1401
+ : '';
1402
+ this.record(s, 'fill', { fields: request.fields.length, submit: submitFlag }, okCount === value.fields.length, {
1403
+ result: `${okCount}/${value.fields.length} fields filled${submitNote}`,
1404
+ });
1405
+ return { fields: value.fields, submitted: value.submitted === true };
1406
+ }
1407
+ /**
1408
+ * Download a URL to a local file, keeping the session's cookies/login.
1409
+ * Requires the self-hosted host (which implements view-level download); the
1410
+ * desktop shell's embedded views delegate downloads to the real browser UI.
1411
+ */
1412
+ async download(session, request, signal) {
1413
+ const s = this.session(session);
1414
+ const { handle } = this.activeTab(s);
1415
+ signal?.throwIfAborted();
1416
+ const downloadable = handle;
1417
+ if (typeof downloadable.download !== 'function') {
1418
+ throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED');
1419
+ }
1420
+ // A download reaches the network with the session's cookies, so it passes
1421
+ // the same URL admission as navigation, and may only write inside the
1422
+ // configured roots.
1423
+ this.admitUrl(request.url, 'download');
1424
+ const target = resolveWritePath(request.savePath, this.writeRoots);
1425
+ // The child fetches in-page with awaitPromise; a slow/hung network can
1426
+ // block it well past the tool budget, so bound it like every other call.
1427
+ const timeoutMs = 60_000;
1428
+ await withTimeout(downloadable.download(request.url, target), timeoutMs, signal, `browser: download timed out after ${timeoutMs}ms`);
1429
+ this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath });
1430
+ return { path: request.savePath };
1431
+ }
1432
+ /**
1433
+ * Export the session's cookies (login state) as serializable objects.
1434
+ * Self-hosted only; the desktop shell's embedded views use the real profile.
1435
+ */
1436
+ async flushAuth(session) {
1437
+ const s = this.session(session);
1438
+ const { handle } = this.activeTab(s);
1439
+ const host = handle;
1440
+ if (typeof host.flushAuth !== 'function') {
1441
+ throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
1442
+ }
1443
+ const timeoutMs = 30_000;
1444
+ const cookies = await withTimeout(host.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`);
1445
+ this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` });
1446
+ return cookies;
1447
+ }
1448
+ /**
1449
+ * Remove cookies for one site scope. Challenge cookies that rotate their names
1450
+ * (WAF challenges) otherwise pile up generation after generation, and two live
1451
+ * generations in one request can be rejected by the site. Self-hosted only.
1452
+ */
1453
+ async clearAuth(session, request) {
1454
+ const s = this.session(session);
1455
+ const { handle } = this.activeTab(s);
1456
+ const clearable = handle;
1457
+ if (typeof clearable.clearCookies !== 'function') {
1458
+ throw new BrowserError('browser: cookie clearing is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
1459
+ }
1460
+ const timeoutMs = 30_000;
1461
+ const result = await withTimeout(clearable.clearCookies(request), timeoutMs, undefined, 'browser: cookie clear timed out after ' + String(timeoutMs) + 'ms');
1462
+ this.record(s, 'clearAuth', {
1463
+ ...request.domain !== undefined ? { domain: request.domain } : {},
1464
+ ...request.name !== undefined ? { name: request.name } : {},
1465
+ ...request.all === true ? { all: true } : {},
1466
+ }, true, { result: String(result.removed) + ' cookies' });
1467
+ return { removed: result.removed, names: [...result.names] };
1468
+ }
1469
+ /**
1470
+ * Import cookies from a JSON export on disk.
1471
+ *
1472
+ * The path is read-guarded exactly like browser_upload_file: a prompt-injected
1473
+ * path must not turn this into a way to read a file the operator never allowed.
1474
+ * A browser cookie export cannot be produced automatically — Chrome and Edge
1475
+ * 127+ encrypt cookie values with App-Bound Encryption, so a copied profile
1476
+ * yields nothing — which is why this takes a file the user exported.
1477
+ */
1478
+ async importAuth(session, path) {
1479
+ const s = this.session(session);
1480
+ const target = resolveReadPath(path, this.readRoots);
1481
+ let parsed;
1482
+ try {
1483
+ parsed = JSON.parse(readFileSync(target, 'utf8'));
1484
+ }
1485
+ catch (error) {
1486
+ throw new BrowserError(`browser: cannot read the cookie file: ${error.message}`, 'BROWSER_AUTH_FILE_INVALID');
1487
+ }
1488
+ // Accept both a bare array and the {"cookies": [...]} shape editors emit.
1489
+ const list = Array.isArray(parsed)
1490
+ ? parsed
1491
+ : typeof parsed === 'object' && parsed !== null && Array.isArray(parsed.cookies)
1492
+ ? parsed.cookies
1493
+ : undefined;
1494
+ if (list === undefined) {
1495
+ throw new BrowserError('browser: the cookie file must be a JSON array or {"cookies": [...]}', 'BROWSER_AUTH_FILE_INVALID');
1496
+ }
1497
+ const usable = list.map(normalizeExportedCookie).filter((cookie) => cookie !== undefined);
1498
+ if (usable.length === 0) {
1499
+ throw new BrowserError(`browser: the cookie file has no usable entries (${list.length} read)`, 'BROWSER_AUTH_FILE_INVALID');
1500
+ }
1501
+ const restored = await this.restoreAuth(session, usable);
1502
+ this.record(s, 'importAuth', { count: usable.length }, true, { result: `${restored} cookies` });
1503
+ return { restored, failed: list.length - usable.length };
1504
+ }
1505
+ /** Import cookies into the session (restore login state). Self-hosted only. */
1506
+ async restoreAuth(session, cookies) {
1507
+ const s = this.session(session);
1508
+ const { handle } = this.activeTab(s);
1509
+ const host = handle;
1510
+ if (typeof host.restoreAuth !== 'function') {
1511
+ throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
1512
+ }
1513
+ const timeoutMs = 30_000;
1514
+ const restored = await withTimeout(host.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`);
1515
+ this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` });
1516
+ return restored;
1517
+ }
1518
+ /**
1519
+ * Start a background scrape batch.
1520
+ *
1521
+ * It runs detached on purpose: one tool call has a ~60s budget while a large
1522
+ * batch takes minutes. Progress is polled with scrapeStatus, and each row is
1523
+ * appended the moment it is produced, so a stopped or interrupted batch keeps
1524
+ * everything it managed. `outPath` is write-guarded like any other browser
1525
+ * write, and truncated up front so a re-run never mixes two batches.
1526
+ */
1527
+ async startScrape(session, request) {
1528
+ const s = this.session(session);
1529
+ const urls = request.urls.filter(url => typeof url === 'string' && url.trim() !== '');
1530
+ if (urls.length === 0) {
1531
+ throw new BrowserError('browser: scrape needs at least one URL', 'BROWSER_SCRAPE_EMPTY');
1532
+ }
1533
+ if (typeof request.script !== 'string' || request.script.trim() === '') {
1534
+ throw new BrowserError('browser: scrape needs an extraction script', 'BROWSER_SCRAPE_EMPTY');
1535
+ }
1536
+ const target = resolveWritePath(request.outPath, this.writeRoots);
1537
+ writeFileSync(target, '');
1538
+ // Created only after every guard has passed, so a rejected start leaves no
1539
+ // orphaned view behind.
1540
+ const workers = Math.max(1, Math.min(Math.trunc(request.concurrency ?? 1) || 1, MAX_SCRAPE_WORKERS));
1541
+ const tabs = [];
1542
+ for (let i = 0; i < workers; i += 1) {
1543
+ const handle = this.host.createView(s.taskKey, s.taskLabel === '' ? undefined : s.taskLabel);
1544
+ const tab = this.createTab(handle);
1545
+ s.tabs.push(tab);
1546
+ tabs.push(tab);
1547
+ }
1548
+ const job = {
1549
+ id: `scrape:${randomUUID()}`,
1550
+ session: s,
1551
+ tabs,
1552
+ path: target,
1553
+ state: 'running',
1554
+ total: urls.length,
1555
+ done: 0,
1556
+ failed: 0,
1557
+ };
1558
+ this.scrapes.set(job.id, job);
1559
+ void this.runScrape(job, urls, request).catch(error => {
1560
+ job.state = 'done';
1561
+ job.error = String(error?.message ?? error);
1562
+ });
1563
+ this.record(s, 'scrape', { total: urls.length }, true, { result: `${urls.length} urls` });
1564
+ return scrapeStatusOf(job);
1565
+ }
1566
+ /** Progress of one batch. */
1567
+ async scrapeStatus(id) {
1568
+ return scrapeStatusOf(this.scrapeJob(id));
1569
+ }
1570
+ /** Ask a running batch to stop; rows already written stay. */
1571
+ async stopScrape(id) {
1572
+ const job = this.scrapeJob(id);
1573
+ if (job.state === 'running')
1574
+ job.state = 'stopped';
1575
+ return scrapeStatusOf(job);
1576
+ }
1577
+ /** Every batch this process knows about, oldest first. */
1578
+ async listScrapes() {
1579
+ return [...this.scrapes.values()].map(scrapeStatusOf);
1580
+ }
1581
+ scrapeJob(id) {
1582
+ const job = this.scrapes.get(id);
1583
+ if (job === undefined) {
1584
+ throw new BrowserError(`browser: unknown scrape ${id}`, 'BROWSER_SCRAPE_UNKNOWN');
1585
+ }
1586
+ return job;
1587
+ }
1588
+ /**
1589
+ * Visit each URL once, appending one JSONL row per page.
1590
+ *
1591
+ * Workers pull from one shared index, so `concurrency` sets the throughput
1592
+ * without changing the work. Rows therefore land in completion order; each row
1593
+ * carries its URL's index as `seq` so the caller can restore the original.
1594
+ */
1595
+ async runScrape(job, urls, request) {
1596
+ const perUrl = request.timeoutMs ?? 30_000;
1597
+ let next = 0;
1598
+ const take = () => (next < urls.length ? next++ : undefined);
1599
+ const worker = async (tab) => {
1600
+ for (;;) {
1601
+ if (job.state !== 'running')
1602
+ return;
1603
+ const index = take();
1604
+ if (index === undefined)
1605
+ return;
1606
+ const url = urls[index] ?? '';
1607
+ let row;
1608
+ try {
1609
+ // show: false — the batch works in the background tab it owns.
1610
+ // settleMs: 0 — it reads the DOM, so it must not pay the paint delay.
1611
+ await this.navigateTab(job.session, tab, url, undefined, false, 0);
1612
+ if (request.waitFor !== undefined) {
1613
+ await this.waitForElementTab(job.session, tab, { selector: request.waitFor, timeoutMs: perUrl });
1614
+ }
1615
+ const result = await this.executeTab(job.session, tab, { script: request.script, timeoutMs: perUrl });
1616
+ if (result.ok) {
1617
+ row = { seq: index, url, ok: true, data: result.value };
1618
+ }
1619
+ else {
1620
+ job.failed += 1;
1621
+ row = { seq: index, url, ok: false, error: result.exception };
1622
+ }
1623
+ }
1624
+ catch (error) {
1625
+ // One bad page must not end the batch: record it and keep going.
1626
+ job.failed += 1;
1627
+ row = { seq: index, url, ok: false, error: String(error?.message ?? error) };
1628
+ }
1629
+ // appendFileSync blocks, so two workers can never interleave a row.
1630
+ appendFileSync(job.path, scrapeRow(row));
1631
+ job.done += 1;
1632
+ }
1633
+ };
1634
+ try {
1635
+ await Promise.all(job.tabs.map(tab => worker(tab)));
1636
+ job.state = 'done';
1637
+ }
1638
+ finally {
1639
+ // The tabs outlive the loop only until here; a stopped batch cleans up too.
1640
+ this.destroyScrapeTabs(job);
1641
+ }
1642
+ }
1643
+ /** Drop a batch's private tabs (and their views) once the batch is over. */
1644
+ destroyScrapeTabs(job) {
1645
+ const s = job.session;
1646
+ for (const tab of job.tabs) {
1647
+ const index = s.tabs.findIndex(candidate => candidate.id === tab.id);
1648
+ if (index < 0)
1649
+ continue;
1650
+ s.tabs.splice(index, 1);
1651
+ this.ignoreHostFailure(this.host.destroyView(tab.handle));
1652
+ // Same index bookkeeping as closeTab: a batch tab is normally not active,
1653
+ // but a tool call could have activated one mid-batch.
1654
+ if (s.tabs.length === 0) {
1655
+ this.newTab(s);
1656
+ }
1657
+ else if (index < s.activeIndex) {
1658
+ s.activeIndex -= 1;
1659
+ }
1660
+ else if (s.activeIndex >= s.tabs.length) {
1661
+ s.activeIndex = s.tabs.length - 1;
1662
+ }
1663
+ }
1664
+ this.showActive(s);
1665
+ }
1666
+ /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
1667
+ async screenshot(session, request, signal) {
1668
+ const s = this.session(session);
1669
+ const { handle } = this.activeTab(s);
1670
+ signal?.throwIfAborted();
1671
+ // Native capturePage path (self-hosted): CDP Page.captureScreenshot can
1672
+ // hang indefinitely on a view once another (hidden) WebContentsView exists
1673
+ // in the shared window; capturePage is fast for the visible task view and resolves
1674
+ // immediately (empty) for hidden ones.
1675
+ const capturable = handle;
1676
+ if (request?.fullPage !== true && typeof capturable.capture === 'function') {
1677
+ // Ensure the target view is the visible one before capturing.
1678
+ this.showActive(s);
1679
+ const timeoutMs = 30_000;
1680
+ const shot = await withTimeout(capturable.capture(), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
1681
+ if (shot.base64 === '') {
1682
+ throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED');
1683
+ }
1684
+ return this.saveScreenshot(shot.base64, request?.savePath);
1685
+ }
1686
+ // Fallback: a desktop-shell handle (no capture()) or a full-page capture
1687
+ // uses CDP; full-page needs `captureBeyondViewport` which capturePage lacks.
1688
+ const params = {};
1689
+ if (request?.fullPage === true) {
1690
+ // `captureBeyondViewport` captures the full scrollable content; without
1691
+ // a clip this yields the full-page image (CDP default is the viewport).
1692
+ params.captureBeyondViewport = true;
1693
+ }
1694
+ const timeoutMs = 30_000;
1695
+ const result = await withTimeout(handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params), timeoutMs, signal, `browser: screenshot timed out after ${timeoutMs}ms`);
1696
+ const data = result.data;
1697
+ if (typeof data !== 'string') {
1698
+ throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
1699
+ }
1700
+ return this.saveScreenshot(data, request?.savePath);
1701
+ }
1702
+ /** Build the data URL and optionally write the PNG to disk. */
1703
+ saveScreenshot(base64, savePath) {
1704
+ if (savePath !== undefined) {
1705
+ // Admit before writing so a denied path never touches disk.
1706
+ const target = resolveWritePath(savePath, this.writeRoots);
1707
+ try {
1708
+ writeFileSync(target, Buffer.from(base64, 'base64'));
1709
+ return { dataUrl: `data:image/png;base64,${base64}`, path: savePath };
1710
+ }
1711
+ catch (error) {
1712
+ // Report the write problem but keep the capture usable.
1713
+ throw new BrowserError(`browser: screenshot save to "${savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
1714
+ }
1715
+ }
1716
+ return { dataUrl: `data:image/png;base64,${base64}` };
1717
+ }
1718
+ /**
1719
+ * Pick up (and forget) any JS dialog the host auto-accepted, so the
1720
+ * operation trail shows the human/agent what the page asked. Best-effort.
1721
+ */
1722
+ async drainDialog(s, handle) {
1723
+ const drainable = handle;
1724
+ if (typeof drainable.clearDialog !== 'function')
1725
+ return;
1726
+ try {
1727
+ const dialog = await drainable.clearDialog();
1728
+ if (dialog !== null && dialog !== undefined) {
1729
+ // Keep it: browser_dialog inspect reports the last one, and drainDialog is the
1730
+ // only place the host hands it over.
1731
+ s.lastDialog = dialog;
1732
+ this.record(s, 'dialog', dialog, true);
1733
+ }
1734
+ }
1735
+ catch {
1736
+ // Dialog supervision is cosmetic; never fail a page operation for it.
1737
+ }
1738
+ }
1739
+ /**
1740
+ * Set how the host answers the next JS dialog, and report the resulting state.
1741
+ *
1742
+ * The policy lives in the host (it answers the CDP event there, where a
1743
+ * round-trip would already be too late), so this is a push, not a pull.
1744
+ */
1745
+ async setDialogPolicy(session, policy) {
1746
+ const s = this.session(session);
1747
+ const { handle } = this.activeTab(s);
1748
+ const normalized = policy.behavior === 'dismiss'
1749
+ ? (policy.promptText === undefined ? { behavior: 'dismiss' } : { behavior: 'dismiss', promptText: policy.promptText })
1750
+ : (policy.promptText === undefined ? { behavior: 'accept' } : { behavior: 'accept', promptText: policy.promptText });
1751
+ const pushable = handle;
1752
+ if (typeof pushable.setDialogPolicy === 'function') {
1753
+ await pushable.setDialogPolicy(normalized);
1754
+ }
1755
+ s.dialogPolicy = normalized;
1756
+ this.record(s, 'dialog-policy', { ...normalized }, true);
1757
+ return { dialog: s.lastDialog ?? null, policy: normalized };
1758
+ }
1759
+ /**
1760
+ * Console messages the host captured for the active tab.
1761
+ *
1762
+ * Reading does NOT clear by default: debugging is usually a look-again loop, so
1763
+ * `clear: true` is explicit. The host keeps a bounded ring, so old entries fall
1764
+ * off on their own.
1765
+ */
1766
+ async consoleMessages(session, options = {}) {
1767
+ const s = this.session(session);
1768
+ const { handle } = this.activeTab(s);
1769
+ const reader = handle;
1770
+ if (typeof reader.readConsole !== 'function')
1771
+ return { messages: [] };
1772
+ const raw = await reader.readConsole(options.clear === true);
1773
+ const list = Array.isArray(raw?.messages) ? raw.messages : [];
1774
+ const filtered = options.level === undefined ? list : list.filter(entry => entry.level === options.level);
1775
+ const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)));
1776
+ return { messages: filtered.slice(-limit) };
1777
+ }
1778
+ /** Network requests the host captured for the active tab (bounded ring). */
1779
+ async networkRequests(session, options = {}) {
1780
+ const s = this.session(session);
1781
+ const { handle } = this.activeTab(s);
1782
+ const reader = handle;
1783
+ if (typeof reader.readNetwork !== 'function')
1784
+ return { requests: [] };
1785
+ const raw = await reader.readNetwork(options.clear === true);
1786
+ const list = Array.isArray(raw?.requests) ? raw.requests : [];
1787
+ const needle = (options.urlContains ?? '').toLowerCase();
1788
+ const filtered = list.filter(entry => (options.failedOnly !== true || entry.failed !== undefined)
1789
+ && (needle === '' || entry.url.toLowerCase().includes(needle)));
1790
+ const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)));
1791
+ return { requests: filtered.slice(-limit) };
1792
+ }
1793
+ /**
1794
+ * Apply device/viewport/media emulation to the active tab.
1795
+ *
1796
+ * Plain CDP through the existing command path, so no host change was needed.
1797
+ * `clear` undoes all three: metrics, user agent, and emulated media.
1798
+ */
1799
+ async emulate(session, options = {}) {
1800
+ const s = this.session(session);
1801
+ const { handle } = this.activeTab(s);
1802
+ const applied = [];
1803
+ if (options.clear === true) {
1804
+ await handle.sendCommand('Emulation.clearDeviceMetricsOverride', {});
1805
+ await handle.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' });
1806
+ await handle.sendCommand('Emulation.setEmulatedMedia', { media: '', features: [] });
1807
+ this.record(s, 'emulate', { clear: true }, true);
1808
+ return { applied: ['cleared'] };
1809
+ }
1810
+ if (options.width !== undefined && options.height !== undefined) {
1811
+ const width = Math.max(1, Math.trunc(options.width));
1812
+ const height = Math.max(1, Math.trunc(options.height));
1813
+ await handle.sendCommand('Emulation.setDeviceMetricsOverride', {
1814
+ width,
1815
+ height,
1816
+ deviceScaleFactor: options.deviceScaleFactor ?? 0,
1817
+ mobile: options.mobile === true,
1818
+ });
1819
+ applied.push('viewport ' + String(width) + 'x' + String(height) + (options.mobile === true ? ' mobile' : ''));
1820
+ }
1821
+ if (options.userAgent !== undefined) {
1822
+ await handle.sendCommand('Emulation.setUserAgentOverride', { userAgent: options.userAgent });
1823
+ applied.push('user-agent');
1824
+ }
1825
+ if (options.colorScheme !== undefined) {
1826
+ await handle.sendCommand('Emulation.setEmulatedMedia', {
1827
+ media: '',
1828
+ features: [{ name: 'prefers-color-scheme', value: options.colorScheme }],
1829
+ });
1830
+ applied.push('color-scheme ' + options.colorScheme);
1831
+ }
1832
+ this.record(s, 'emulate', { ...applied.length === 0 ? { noop: true } : {} }, true);
1833
+ return { applied };
1834
+ }
1835
+ /**
1836
+ * Drain any dialog that opened since the last input call, then report the last
1837
+ * one and the current policy.
1838
+ *
1839
+ * `dialogState` alone is not enough for the tool: the host only hands a dialog
1840
+ * over when something drains it, so an inspect that does not drain misses exactly
1841
+ * the dialog the caller just triggered. (Found on the real machine.)
1842
+ */
1843
+ async inspectDialog(session) {
1844
+ const s = this.session(session);
1845
+ const { handle } = this.activeTab(s);
1846
+ await this.drainDialog(s, handle);
1847
+ return { dialog: s.lastDialog ?? null, policy: s.dialogPolicy ?? { behavior: 'accept' } };
1848
+ }
1849
+ /** The last JS dialog the host reported, plus the current policy. */
1850
+ dialogState(session) {
1851
+ const s = this.session(session);
1852
+ return { dialog: s.lastDialog ?? null, policy: s.dialogPolicy ?? { behavior: 'accept' } };
1853
+ }
1854
+ /** Name this browser task (space). */
1855
+ async setSpace(session, label) {
1856
+ const s = this.session(session);
1857
+ const { handle } = this.activeTab(s);
1858
+ const labelable = handle;
1859
+ if (typeof labelable.label === 'function') {
1860
+ await labelable.label(label);
1861
+ }
1862
+ else {
1863
+ throw new BrowserError('browser: space naming is only available on the self-hosted browser', 'BROWSER_SPACE_UNSUPPORTED');
1864
+ }
1865
+ s.taskLabel = label;
1866
+ this.record(s, 'setSpace', { label }, true);
1867
+ }
1868
+ /** List every browser task (space) with its label. */
1869
+ async listSpaces() {
1870
+ const host = this.host;
1871
+ if (typeof host.listWindows !== 'function')
1872
+ return [];
1873
+ return host.listWindows();
1874
+ }
1875
+ /** List browser tasks with live collaboration status. */
1876
+ async listTasks() {
1877
+ if (typeof this.host.listTasks === 'function') {
1878
+ const tasks = await this.host.listTasks();
1879
+ for (const task of tasks)
1880
+ this.rememberHostedTask(task);
1881
+ return tasks;
1882
+ }
1883
+ const tasks = new Map();
1884
+ for (const session of this.sessions.values()) {
1885
+ const current = tasks.get(session.taskKey);
1886
+ const next = this.localTaskInfo(session);
1887
+ tasks.set(session.taskKey, current === undefined
1888
+ ? next
1889
+ : { ...next, tabs: current.tabs + next.tabs, active: current.active || next.active });
1890
+ }
1891
+ return [...tasks.values()];
1892
+ }
1893
+ /** Read the collaboration state for one session's task. */
1894
+ async getTask(session) {
1895
+ const s = this.session(session);
1896
+ const hosted = typeof this.host.getTask === 'function' ? await this.host.getTask(s.taskKey) : undefined;
1897
+ if (hosted !== undefined) {
1898
+ this.rememberHostedTask(hosted);
1899
+ return hosted;
1900
+ }
1901
+ return this.localTaskInfo(s);
1902
+ }
1903
+ /** Apply one visible task state update and mirror it to a supporting host. */
1904
+ async updateTask(session, update) {
1905
+ const s = this.session(session);
1906
+ const previous = this.taskStates.get(s.taskKey) ?? { status: 'idle', control: 'agent', updatedAt: Date.now() };
1907
+ const next = {
1908
+ ...previous,
1909
+ ...update.status !== undefined ? { status: update.status } : {},
1910
+ ...update.control !== undefined ? { control: update.control } : {},
1911
+ ...update.latestAction !== undefined ? { latestAction: update.latestAction } : {},
1912
+ ...update.error !== undefined ? { error: update.error.slice(0, 180) } : {},
1913
+ updatedAt: Date.now(),
1914
+ };
1915
+ if (next.status !== 'failed' && update.error === undefined)
1916
+ delete next.error;
1917
+ this.taskStates.set(s.taskKey, next);
1918
+ // Only send fields this call intentionally changes. A page-side handoff
1919
+ // can update the host between two Agent operations; replaying a stale
1920
+ // cached control field here would overwrite the newer human choice.
1921
+ const hosted = typeof this.host.updateTask === 'function'
1922
+ ? await this.host.updateTask(s.taskKey, {
1923
+ ...update.status !== undefined ? { status: update.status } : {},
1924
+ ...update.control !== undefined ? { control: update.control } : {},
1925
+ ...update.latestAction !== undefined ? { latestAction: update.latestAction } : {},
1926
+ ...update.error !== undefined ? { error: update.error.slice(0, 180) } : {},
1927
+ })
1928
+ : undefined;
1929
+ if (hosted !== undefined) {
1930
+ this.rememberHostedTask(hosted);
1931
+ return hosted;
1932
+ }
1933
+ return this.localTaskInfo(s);
1934
+ }
1935
+ /** Hand control to the user or return it to Agent-driven actions. */
1936
+ async setHandoff(session, state) {
1937
+ return this.updateTask(session, state === 'waiting-user'
1938
+ ? { status: 'waiting-user', control: 'human', latestAction: 'waiting for user' }
1939
+ : { status: 'idle', control: 'agent', latestAction: 'agent resumed' });
1940
+ }
1941
+ /** Append one operation to the session's history. */
1942
+ record(s, action, params, ok, detail) {
1943
+ const entry = {
1944
+ seq: s.nextSeq++,
1945
+ action,
1946
+ params: clampHistoryParams(params),
1947
+ ok,
1948
+ ...detail?.result !== undefined ? { result: detail.result } : {},
1949
+ ...detail?.error !== undefined ? { error: detail.error } : {},
1950
+ at: Date.now(),
1951
+ };
1952
+ s.history.push(entry);
1953
+ // Bound memory: keep the last 500 operations.
1954
+ if (s.history.length > 500)
1955
+ s.history.splice(0, s.history.length - 500);
1956
+ // Mirror onto the human-facing trail in the shared window (best-effort).
1957
+ const tab = s.tabs[s.activeIndex];
1958
+ const state = this.taskStates.get(s.taskKey);
1959
+ if (state !== undefined) {
1960
+ state.latestAction = action;
1961
+ state.updatedAt = entry.at;
1962
+ }
1963
+ try {
1964
+ this.host.trace?.(tab?.handle.id ?? 'default', { action, params, ok, at: entry.at });
1965
+ const pending = this.host.updateTask?.(s.taskKey, { latestAction: action });
1966
+ void pending?.catch(() => undefined);
1967
+ }
1968
+ catch { /* trail is cosmetic */ }
1969
+ }
1970
+ /** Return the session's chronological operation log (newest last). */
1971
+ async history(session) {
1972
+ return this.session(session).history;
1973
+ }
1974
+ /**
1975
+ * Replay one recorded operation by sequence number. Navigate/click/type are
1976
+ * re-issued against the current page; execute re-runs its script. The
1977
+ * replayed step is appended to history as a new entry.
1978
+ * @param session - the session id.
1979
+ * @param seq - the recorded entry's sequence number to replay.
1980
+ */
1981
+ async replay(session, seq) {
1982
+ const s = this.session(session);
1983
+ const entry = s.history.find(e => e.seq === seq);
1984
+ if (entry === undefined) {
1985
+ throw new BrowserError(`browser: no history entry with seq ${seq}`, 'BROWSER_HISTORY_UNKNOWN');
1986
+ }
1987
+ switch (entry.action) {
1988
+ case 'navigate': {
1989
+ const url = entry.params.url;
1990
+ if (typeof url !== 'string')
1991
+ throw new BrowserError(`browser: history seq ${seq} navigate has no url`, 'BROWSER_HISTORY_INVALID');
1992
+ await this.navigate(session, { url });
1993
+ this.record(s, 'replay', { seq, of: entry.action, url }, true);
1994
+ return;
1995
+ }
1996
+ case 'click': {
1997
+ const x = entry.params.x;
1998
+ const y = entry.params.y;
1999
+ if (typeof x !== 'number' || typeof y !== 'number')
2000
+ throw new BrowserError(`browser: history seq ${seq} click has no coordinates`, 'BROWSER_HISTORY_INVALID');
2001
+ await this.click(session, { x, y });
2002
+ this.record(s, 'replay', { seq, of: entry.action, x, y }, true);
2003
+ return;
2004
+ }
2005
+ case 'type': {
2006
+ const text = entry.params.text;
2007
+ if (typeof text !== 'string')
2008
+ throw new BrowserError(`browser: history seq ${seq} type has no text`, 'BROWSER_HISTORY_INVALID');
2009
+ if (entry.params.textTruncated === true) {
2010
+ throw new BrowserError(`browser: history seq ${seq} text was too long to keep in full; replay is not possible`, 'BROWSER_HISTORY_TRUNCATED');
2011
+ }
2012
+ await this.type(session, { text });
2013
+ this.record(s, 'replay', { seq, of: entry.action, text }, true);
2014
+ return;
2015
+ }
2016
+ case 'execute': {
2017
+ const script = entry.params.script;
2018
+ if (typeof script !== 'string')
2019
+ throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID');
2020
+ if (entry.params.scriptTruncated === true) {
2021
+ throw new BrowserError(`browser: history seq ${seq} script was too long to keep in full; replay is not possible`, 'BROWSER_HISTORY_TRUNCATED');
2022
+ }
2023
+ const recordedArgs = entry.params.args;
2024
+ const args = Array.isArray(recordedArgs) ? recordedArgs.filter((a) => typeof a === 'string') : undefined;
2025
+ const result = await this.execute(session, { script, ...args !== undefined && args.length > 0 ? { args } : {} });
2026
+ 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 });
2027
+ return;
2028
+ }
2029
+ default:
2030
+ throw new BrowserError(`browser: history seq ${seq} action "${entry.action}" is not replayable`, 'BROWSER_HISTORY_NOT_REPLAYABLE');
2031
+ }
2032
+ }
2033
+ /** Close the session and destroy all its views. Idempotent. */
2034
+ close(session) {
2035
+ const existing = this.sessions.get(session);
2036
+ if (existing !== undefined) {
2037
+ this.sessions.delete(session);
2038
+ for (const tab of existing.tabs)
2039
+ this.ignoreHostFailure(this.host.destroyView(tab.handle));
2040
+ const replacement = [...this.sessions.values()].find(candidate => candidate.taskKey === existing.taskKey);
2041
+ if (this.sessionsByTask.get(existing.taskKey) === session) {
2042
+ if (replacement === undefined)
2043
+ this.sessionsByTask.delete(existing.taskKey);
2044
+ else
2045
+ this.sessionsByTask.set(existing.taskKey, replacement.id);
2046
+ }
2047
+ if (replacement === undefined) {
2048
+ this.taskStates.delete(existing.taskKey);
2049
+ }
2050
+ }
2051
+ return Promise.resolve();
2052
+ }
2053
+ /** Recover the live session associated with a stable task key. */
2054
+ sessionForTask(taskKey) {
2055
+ const indexed = this.sessionsByTask.get(taskKey);
2056
+ if (indexed !== undefined) {
2057
+ const session = this.sessions.get(indexed);
2058
+ if (session !== undefined)
2059
+ return session;
2060
+ this.sessionsByTask.delete(taskKey);
2061
+ }
2062
+ for (const session of this.sessions.values()) {
2063
+ if (session.taskKey === taskKey) {
2064
+ this.sessionsByTask.set(taskKey, session.id);
2065
+ return session;
2066
+ }
2067
+ }
2068
+ return undefined;
2069
+ }
2070
+ /** Look up a session or throw the unknown-session error. */
2071
+ session(session) {
2072
+ const existing = this.sessions.get(session);
2073
+ if (existing === undefined) {
2074
+ throw new BrowserError(`browser: session "${session}" is not open`, 'BROWSER_SESSION_UNKNOWN');
2075
+ }
2076
+ return existing;
2077
+ }
2078
+ /** The active tab of a session. */
2079
+ activeTab(s) {
2080
+ const tab = s.tabs[s.activeIndex];
2081
+ if (tab === undefined)
2082
+ throw new BrowserError('browser: session has no active tab', 'BROWSER_TAB_UNKNOWN');
2083
+ return tab;
2084
+ }
2085
+ /** Navigate through the browser history while preserving page readiness behavior. */
2086
+ async navigateHistory(session, direction, action, signal) {
2087
+ const s = this.session(session);
2088
+ const tab = this.activeTab(s);
2089
+ signal?.throwIfAborted();
2090
+ const history = await withTimeout(tab.handle.sendCommand(CDP_PAGE_GET_NAVIGATION_HISTORY, {}), 15_000, signal, 'browser: history lookup timed out');
2091
+ const currentIndex = typeof history.currentIndex === 'number' ? history.currentIndex : -1;
2092
+ const entries = Array.isArray(history.entries) ? history.entries : [];
2093
+ const target = entries[currentIndex + direction];
2094
+ if (target === undefined || typeof target.id !== 'number') {
2095
+ this.record(s, action, { navigated: false }, true);
2096
+ return false;
2097
+ }
2098
+ await withTimeout(tab.handle.sendCommand(CDP_PAGE_NAVIGATE_TO_HISTORY_ENTRY, { entryId: target.id }), 30_000, signal, `browser: ${action} timed out after 30000ms`);
2099
+ this.invalidateSnapshots(tab);
2100
+ this.record(s, action, { navigated: true }, true);
2101
+ this.showActive(s);
2102
+ await waitForDocumentReady(tab.handle, signal);
2103
+ void reinstallPageChrome(tab.handle);
2104
+ return true;
2105
+ }
2106
+ /** Drop every reference that was captured before a document transition. */
2107
+ invalidateSnapshots(tab) {
2108
+ tab.navigationEpoch += 1;
2109
+ tab.snapshots.clear();
2110
+ }
2111
+ /** Resolve one exact snapshot reference, rejecting any changed or missing target. */
2112
+ async resolveSnapshotTarget(tab, request, block, signal) {
2113
+ const record = tab.snapshots.get(request.snapshotId);
2114
+ if (record === undefined) {
2115
+ throw new BrowserError(`browser: snapshot "${request.snapshotId}" is not available in this tab`, 'BROWSER_SNAPSHOT_UNKNOWN');
2116
+ }
2117
+ if (record.tabId !== tab.id || record.epoch !== tab.navigationEpoch) {
2118
+ throw new BrowserError(`browser: snapshot "${request.snapshotId}" is stale`, 'BROWSER_SNAPSHOT_STALE');
2119
+ }
2120
+ const target = record.targets.get(request.ref);
2121
+ if (target === undefined) {
2122
+ throw new BrowserError(`browser: snapshot "${request.snapshotId}" has no element ref ${request.ref}`, 'BROWSER_REF_UNKNOWN');
2123
+ }
2124
+ const script = `(() => {
2125
+ if (location.href !== ${JSON.stringify(record.url)}) return { stale: 'url changed' }
2126
+ let el
2127
+ try { el = document.querySelector(${JSON.stringify(target.path)}) } catch { return { stale: 'selector invalid' } }
2128
+ if (!el || el.closest('[data-dsh-browser-chrome]')) return { stale: 'element missing' }
2129
+ const fingerprint = [
2130
+ el.tagName,
2131
+ el.getAttribute('type') || '',
2132
+ el.id || '',
2133
+ el.getAttribute('name') || '',
2134
+ el.getAttribute('aria-label') || '',
2135
+ (el.textContent || el.value || '').toString().replace(/\s+/g, ' ').trim().slice(0, 120),
2136
+ ].join('\u001f')
2137
+ if (fingerprint !== ${JSON.stringify(target.fingerprint)}) return { stale: 'element changed' }
2138
+ el.scrollIntoView({ block: ${JSON.stringify(block)}, inline: 'nearest', behavior: 'auto' })
2139
+ const rect = el.getBoundingClientRect()
2140
+ const style = getComputedStyle(el)
2141
+ if (rect.width < 4 || rect.height < 4 || style.visibility === 'hidden' || style.display === 'none') return { stale: 'element hidden' }
2142
+ const root = document.documentElement
2143
+ return {
2144
+ x: Math.round(rect.x + rect.width / 2),
2145
+ y: Math.round(rect.y + rect.height / 2),
2146
+ scrollX: window.scrollX,
2147
+ scrollY: window.scrollY,
2148
+ maxX: Math.max(0, root.scrollWidth - window.innerWidth),
2149
+ maxY: Math.max(0, root.scrollHeight - window.innerHeight),
2150
+ }
2151
+ })()`;
2152
+ const result = await withTimeout(handleSendEvaluate(tab.handle, script), 15_000, signal, 'browser: snapshot reference resolution timed out');
2153
+ if (!result.ok) {
2154
+ throw new BrowserError(`browser: snapshot reference resolution failed: ${result.exception}`, 'BROWSER_REF_RESOLVE_FAILED');
2155
+ }
2156
+ const value = result.value;
2157
+ if (typeof value.stale === 'string'
2158
+ || typeof value.x !== 'number'
2159
+ || typeof value.y !== 'number'
2160
+ || typeof value.scrollX !== 'number'
2161
+ || typeof value.scrollY !== 'number'
2162
+ || typeof value.maxX !== 'number'
2163
+ || typeof value.maxY !== 'number') {
2164
+ throw new BrowserError(`browser: snapshot "${request.snapshotId}" is stale${typeof value.stale === 'string' ? `: ${value.stale}` : ''}`, 'BROWSER_SNAPSHOT_STALE');
2165
+ }
2166
+ return { x: value.x, y: value.y, maxX: value.maxX, maxY: value.maxY };
2167
+ }
2168
+ /** Sync provider fallback cache from the host's authoritative workspace state. */
2169
+ rememberHostedTask(task) {
2170
+ this.taskStates.set(task.key, {
2171
+ status: task.status,
2172
+ control: task.control,
2173
+ ...task.latestAction !== undefined ? { latestAction: task.latestAction } : {},
2174
+ ...task.error !== undefined ? { error: task.error } : {},
2175
+ updatedAt: task.updatedAt,
2176
+ });
2177
+ }
2178
+ /** Build the provider-side task summary when a host has no richer workspace. */
2179
+ localTaskInfo(s) {
2180
+ const state = this.taskStates.get(s.taskKey) ?? { status: 'idle', control: 'agent', updatedAt: Date.now() };
2181
+ return {
2182
+ key: s.taskKey,
2183
+ label: s.taskLabel,
2184
+ active: this.sessions.size === 1,
2185
+ tabs: s.tabs.length,
2186
+ status: state.status,
2187
+ control: state.control,
2188
+ ...state.latestAction !== undefined ? { latestAction: state.latestAction } : {},
2189
+ updatedAt: state.updatedAt,
2190
+ ...state.error !== undefined ? { error: state.error } : {},
2191
+ };
2192
+ }
2193
+ /** Create a tab with its short-lived snapshot reference store. */
2194
+ createTab(handle) {
2195
+ return { id: `tab:${randomUUID()}`, handle, navigationEpoch: 0, snapshots: new Map() };
2196
+ }
2197
+ /** Append a fresh tab and make it active. */
2198
+ newTab(s) {
2199
+ const handle = this.host.createView(s.taskKey, s.taskLabel === '' ? undefined : s.taskLabel);
2200
+ s.tabs.push(this.createTab(handle));
2201
+ s.activeIndex = s.tabs.length - 1;
2202
+ this.showActive(s);
2203
+ }
2204
+ /** Notify the host of the active tab; it preserves the human-selected task view. */
2205
+ /**
2206
+ * Fire-and-forget host call: these run while the provider keeps going, so a rejection
2207
+ * must never escape. Electron's host answers `unknown view` for a handle it no longer
2208
+ * knows (a host restart leaves the provider holding stale ones), and an unhandled
2209
+ * rejection surfaces as *some other* tool call failing - Ctrl+W did exactly that.
2210
+ * The desired end state (view gone) holds either way, so swallowing is right here.
2211
+ */
2212
+ ignoreHostFailure(promise) {
2213
+ void Promise.resolve(promise).catch(() => undefined);
2214
+ }
2215
+ showActive(s) {
2216
+ // 让陈旧的一次显示安静地失败:紧接着的操作/切换会把它纠正回来,而一条没人接的
2217
+ // rejection 会以「别的工具调用失败了」的形式冒出来(实测 Ctrl+W 关最后一个标签就是这样)。
2218
+ void Promise.resolve(this.host.showView?.(this.activeTab(s).handle)).catch(() => undefined);
2219
+ }
2220
+ /** Read the current URL of a view through CDP. */
2221
+ async currentUrl(handle) {
2222
+ // Bound the read: a wedged renderer would otherwise hang listTabs.
2223
+ const timeoutMs = 10_000;
2224
+ const result = await withTimeout(handleSendEvaluate(handle, 'location.href'), timeoutMs, undefined, `browser: url read timed out after ${timeoutMs}ms`);
2225
+ return result.ok && typeof result.value === 'string' ? result.value : '';
2226
+ }
2227
+ }
2228
+ /**
2229
+ * Bound a promise so a wedged CDP call surfaces as an error instead of
2230
+ * hanging the tool call forever. The caller's signal, when provided, wins
2231
+ * over the timeout if it fires first.
2232
+ * @param promise - the operation to bound.
2233
+ * @param ms - the timeout budget.
2234
+ * @param signal - optional caller signal.
2235
+ * @param message - the timeout error message.
2236
+ * @returns the promise's value, or a rejected promise on timeout/abort.
2237
+ */
2238
+ function withTimeout(promise, ms, signal, message) {
2239
+ return new Promise((resolve, reject) => {
2240
+ let done = false;
2241
+ const timer = setTimeout(() => {
2242
+ if (done)
2243
+ return;
2244
+ done = true;
2245
+ // A fired timeout must also release the abort listener; { once: true }
2246
+ // only releases it on the next abort, which may never come.
2247
+ if (signal !== undefined)
2248
+ signal.removeEventListener('abort', onAbort);
2249
+ // A stable code lets callers branch on a timeout; the name is preserved for
2250
+ // the one call site that already matched on it.
2251
+ const error = new BrowserError(message, 'BROWSER_OPERATION_TIMEOUT');
2252
+ error.name = 'TimeoutError';
2253
+ reject(error);
2254
+ }, ms);
2255
+ const finish = (fn) => {
2256
+ if (done)
2257
+ return;
2258
+ done = true;
2259
+ clearTimeout(timer);
2260
+ if (signal !== undefined)
2261
+ signal.removeEventListener('abort', onAbort);
2262
+ fn();
2263
+ };
2264
+ const onAbort = () => {
2265
+ if (done)
2266
+ return;
2267
+ done = true;
2268
+ clearTimeout(timer);
2269
+ reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'));
2270
+ };
2271
+ if (signal !== undefined)
2272
+ signal.addEventListener('abort', onAbort, { once: true });
2273
+ promise.then(value => finish(() => resolve(value)), error => finish(() => reject(error)));
2274
+ });
2275
+ }
2276
+ /**
2277
+ * Run a `Runtime.evaluate` through a view handle and normalize the result.
2278
+ * Shared by execute, snapshot, content, and internal URL reads.
2279
+ * @param handle - the view handle to evaluate in.
2280
+ * @param expression - the JS expression.
2281
+ * @param signal - optional abort signal; a fired signal rejects the call.
2282
+ */
2283
+ /** Best-effort injection of the human chrome into the current document. */
2284
+ async function reinstallPageChrome(handle) {
2285
+ // Prefer the host's own injection: only it holds the per-view binding token, so
2286
+ // its copy can still authenticate actions the human triggers. The tokenless
2287
+ // script below is a fallback for hosts that do not own the chrome.
2288
+ if (typeof handle.reinstallChrome === 'function') {
2289
+ try {
2290
+ await handle.reinstallChrome();
2291
+ return;
2292
+ }
2293
+ catch {
2294
+ // Fall through rather than leaving the document without any chrome.
2295
+ }
2296
+ }
2297
+ try {
2298
+ await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
2299
+ expression: PAGE_CHROME_SCRIPT,
2300
+ returnByValue: true,
2301
+ });
2302
+ }
2303
+ catch {
2304
+ // Chrome is cosmetic; never fail navigation for it.
2305
+ }
2306
+ }
2307
+ /**
2308
+ * The in-page half of {@link resolvePointerTarget}: resolve the element, scroll
2309
+ * it into view, and return its centre.
2310
+ *
2311
+ * Exported so it can be exercised. The matching rule — the innermost visible
2312
+ * element whose label contains the text wins — is the part most likely to be
2313
+ * wrong, and Node has no DOM to check it against.
2314
+ */
2315
+ export function pointerTargetScript(selector, text) {
2316
+ return `(() => {
2317
+ const selector = ${JSON.stringify(selector ?? null)}
2318
+ const needle = ${JSON.stringify(text ?? null)}
2319
+ const visible = (el) => {
2320
+ const r = el.getBoundingClientRect()
2321
+ const cs = getComputedStyle(el)
2322
+ return r.width >= 4 && r.height >= 4 && cs.visibility !== 'hidden' && cs.display !== 'none'
2323
+ }
2324
+ const usable = (el) => !el.closest('[data-dsh-browser-chrome]') && visible(el)
2325
+ const labelOf = (el) => (el.getAttribute('aria-label') || el.textContent || el.value || '').toString().replace(/\\s+/g, ' ').trim()
2326
+ const describe = (el) => labelOf(el).slice(0, 80)
2327
+ let el = null
2328
+ if (selector !== null) {
2329
+ let matches
2330
+ try { matches = [...document.querySelectorAll(selector)] } catch (e) { return { error: 'invalid selector: ' + String(e) } }
2331
+ el = matches.find(usable) ?? null
2332
+ } else {
2333
+ const lower = needle.toLowerCase()
2334
+ // One bottom-up pass gives every element its own text. Matching a list of
2335
+ // tag names is not enough: plenty of text lives in tags nobody thinks to
2336
+ // list (p, h1, dd, figcaption, legend, option...), and a site that splits a
2337
+ // label into one span per character leaves every leaf holding a single
2338
+ // character while its container holds the whole phrase. Reading
2339
+ // textContent per element instead would re-walk each subtree.
2340
+ const texts = new Map()
2341
+ const walk = (node) => {
2342
+ let text = ''
2343
+ for (const child of node.childNodes) {
2344
+ if (child.nodeType === 3) text += child.nodeValue ?? ''
2345
+ else if (child.nodeType === 1) text += walk(child)
2346
+ }
2347
+ if (node.nodeType === 1) texts.set(node, text)
2348
+ return text
2349
+ }
2350
+ if (document.body !== null) walk(document.body)
2351
+ let best = null
2352
+ for (const [candidate, own] of texts) {
2353
+ // Cheapest rejection first: a big page has far more elements than matches.
2354
+ const label = (candidate.getAttribute('aria-label') || own || candidate.value || '').replace(/\\s+/g, ' ').trim()
2355
+ if (label === '' || !label.toLowerCase().includes(lower)) continue
2356
+ if (!usable(candidate)) continue
2357
+ let depth = 0
2358
+ for (let node = candidate; node !== null; node = node.parentElement) depth += 1
2359
+ // The shortest label is the innermost element still containing the text;
2360
+ // on a tie the deeper one wins, so a <button> beats its wrapper.
2361
+ if (best === null || label.length < best.label.length || (label.length === best.label.length && depth > best.depth)) {
2362
+ best = { el: candidate, label, depth }
2363
+ }
2364
+ }
2365
+ el = best?.el ?? null
2366
+ }
2367
+ if (el === null) return { missing: true }
2368
+ el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'auto' })
2369
+ const r = el.getBoundingClientRect()
2370
+ if (r.width < 4 || r.height < 4) return { missing: true }
2371
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, target: describe(el) }
2372
+ })()`;
2373
+ }
2374
+ /**
2375
+ * Resolve a pointer target to a viewport point.
2376
+ *
2377
+ * Coordinates pass straight through. A selector or text is resolved inside the
2378
+ * page and scrolled into view first, and the element is described in the result
2379
+ * so the caller can confirm what it actually hit — a bare coordinate click
2380
+ * cannot tell you that.
2381
+ */
2382
+ async function resolvePointerTarget(handle, target, signal) {
2383
+ if (target.selector === undefined && target.text === undefined) {
2384
+ if (typeof target.x !== 'number' || typeof target.y !== 'number') {
2385
+ throw new BrowserError('browser: a pointer action needs x and y, a selector, or text', 'BROWSER_TARGET_MISSING');
2386
+ }
2387
+ // Coordinates are NOT scrolled into view (only selector/text are), so a point
2388
+ // below the fold is dropped by the renderer and the call looks like it worked.
2389
+ // Measured on a real page: the same centre that hits at 100% is off-screen at
2390
+ // 110% because the page reflows taller. Refuse, and say what is actually there.
2391
+ const view = await withTimeout(handleSendEvaluate(handle, `(function () {
2392
+ var x = ${JSON.stringify(target.x)}, y = ${JSON.stringify(target.y)};
2393
+ var el = document.elementFromPoint(x, y);
2394
+ return { iw: window.innerWidth, ih: window.innerHeight,
2395
+ hit: el === null ? '' : (el.tagName + (el.id ? '#' + el.id : '') + (el.textContent ? ' ' + el.textContent.replace(/\\s+/g, ' ').trim().slice(0, 40) : '')) };
2396
+ })()`, signal), 5_000, signal, 'browser: coordinate probe timed out after 5000ms');
2397
+ const info = view.ok ? view.value : null;
2398
+ // A 0x0 viewport means "not laid out yet" (hidden view), not "the point is off
2399
+ // screen": clicks still land there (the smoke's input checks prove it), so only a
2400
+ // real, non-zero viewport may refuse.
2401
+ if (info !== null && typeof info?.iw === 'number' && typeof info?.ih === 'number' && info.iw > 0 && info.ih > 0) {
2402
+ if (target.x >= info.iw || target.y >= info.ih || target.x < 0 || target.y < 0) {
2403
+ throw new BrowserError(`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)`, 'BROWSER_TARGET_OFFSCREEN');
2404
+ }
2405
+ }
2406
+ return {
2407
+ x: target.x,
2408
+ y: target.y,
2409
+ ...info?.hit !== undefined && info.hit !== '' ? { target: info.hit } : {},
2410
+ };
2411
+ }
2412
+ const script = pointerTargetScript(target.selector, target.text);
2413
+ const result = await withTimeout(handleSendEvaluate(handle, script, signal), 10_000, signal, 'browser: target resolution timed out after 10000ms');
2414
+ if (!result.ok) {
2415
+ throw new BrowserError(`browser: could not resolve the target: ${result.exception}`, 'BROWSER_TARGET_FAILED');
2416
+ }
2417
+ const value = result.value;
2418
+ if (value?.error !== undefined) {
2419
+ throw new BrowserError(`browser: ${value.error}`, 'BROWSER_TARGET_FAILED');
2420
+ }
2421
+ if (value?.missing === true || typeof value?.x !== 'number' || typeof value?.y !== 'number') {
2422
+ const what = target.selector !== undefined ? `selector "${target.selector}"` : `text "${target.text ?? ''}"`;
2423
+ throw new BrowserError(`browser: no visible element matches ${what}`, 'BROWSER_TARGET_NOT_FOUND');
2424
+ }
2425
+ return { x: value.x, y: value.y, ...value.target === undefined ? {} : { target: value.target } };
2426
+ }
2427
+ /**
2428
+ * Wait until the main document reports complete, without turning an otherwise
2429
+ * successful navigation into a failure when a page is slow or never settles.
2430
+ */
2431
+ async function waitForDocumentReady(handle, signal, settleMs = 250) {
2432
+ const deadline = Date.now() + 12_000;
2433
+ try {
2434
+ await withTimeout((async () => {
2435
+ while (Date.now() <= deadline) {
2436
+ const result = await handleSendEvaluate(handle, 'document.readyState', signal).catch(() => undefined);
2437
+ if (result?.ok && result.value === 'complete') {
2438
+ // The settle delay exists so a screenshot or snapshot does not catch a
2439
+ // still-blank renderer. A scrape reads the DOM, not pixels, so it
2440
+ // passes 0 — otherwise a thousand-page batch would idle 250s.
2441
+ if (settleMs > 0)
2442
+ await new Promise(resolve => setTimeout(resolve, settleMs));
2443
+ return;
2444
+ }
2445
+ await new Promise(resolve => setTimeout(resolve, 150));
2446
+ }
2447
+ })(), 13_000, signal, 'readiness wait exceeded');
2448
+ }
2449
+ catch {
2450
+ // Readiness is an optimization: never fail a valid navigation for it.
2451
+ }
2452
+ }
2453
+ /** Mark a short window in which CDP input must not transfer control to the user. */
2454
+ async function suppressAutoUserControl(handle, signal) {
2455
+ const expression = '(() => { const host = document.getElementById(' + JSON.stringify(PAGE_CHROME_HOST_ID)
2456
+ + '); if (!host) return false; host.setAttribute("data-dsh-agent-input-until", String(Date.now() + ' + String(AGENT_INPUT_SUPPRESSION_MS) + ')); return true })()';
2457
+ await withTimeout(handleSendEvaluate(handle, expression, signal), 2_000, signal, 'browser: agent input suppression timed out').catch(() => undefined);
2458
+ }
2459
+ /**
2460
+ * Best-effort markdown rendering of a DOM subtree, used by
2461
+ * {@link ElectronBrowserProvider.content} for `format: 'markdown'`.
2462
+ *
2463
+ * Deliberately self-contained (no closures over module state, no imports):
2464
+ * the provider embeds this function's source in the page with
2465
+ * `Function.prototype.toString`, so the tests exercise the very code the page
2466
+ * runs.
2467
+ *
2468
+ * Block containers (div/p/section/article/li/headings/...) recurse into their
2469
+ * children and are joined with newlines, while adjacent inline runs are
2470
+ * concatenated — text split by <b>/<span> stays one paragraph, and a container
2471
+ * never emits its own `textContent` on top of its children (the old walker did,
2472
+ * which flattened real pages — everything is wrapped in divs — to plain text).
2473
+ * @param root - the subtree root (an element, or a text node).
2474
+ * @returns the markdown text.
2475
+ */
2476
+ export function renderMarkdown(root) {
2477
+ const BLOCK_TAGS = new Set([
2478
+ 'address', 'article', 'aside', 'blockquote', 'dd', 'details', 'dialog',
2479
+ 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form',
2480
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li',
2481
+ 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary', 'table',
2482
+ 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul',
2483
+ ]);
2484
+ const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template']);
2485
+ const collapse = (text) => text.replace(/\s+/g, ' ').trim();
2486
+ const childrenOf = (node) => {
2487
+ const list = node.childNodes;
2488
+ if (list === undefined || list === null)
2489
+ return [];
2490
+ const out = [];
2491
+ for (let index = 0; index < list.length; index++)
2492
+ out.push(list[index]);
2493
+ return out;
2494
+ };
2495
+ /** Inline rendering: concatenates descendants, keeping links/images inline. */
2496
+ const inline = (node) => {
2497
+ // Whitespace is collapsed but NOT trimmed: trimming here would eat the space
2498
+ // that separates two inline runs ('Hello ' + <b>world</b>).
2499
+ if (node.nodeType === 3)
2500
+ return (node.textContent ?? '').replace(/\s+/g, ' ');
2501
+ if (node.nodeType !== 1)
2502
+ return '';
2503
+ const tag = (node.tagName ?? '').toLowerCase();
2504
+ if (SKIP_TAGS.has(tag))
2505
+ return '';
2506
+ if (tag === 'br')
2507
+ return '\n';
2508
+ if (tag === 'img')
2509
+ return node.src ? '![' + collapse(node.alt ?? '') + '](' + node.src + ')' : '';
2510
+ if (tag === 'a') {
2511
+ const text = collapse(inlineChildren(node));
2512
+ return text === '' ? '' : '[' + text + '](' + (node.href ?? '') + ')';
2513
+ }
2514
+ return inlineChildren(node);
2515
+ };
2516
+ const inlineChildren = (node) => childrenOf(node).map(inline).join('');
2517
+ /** Render one child as a block piece (own line) or an inline piece (merged). */
2518
+ const renderBlock = (node) => {
2519
+ if (node.nodeType === 3)
2520
+ return { text: inline(node), block: false };
2521
+ if (node.nodeType !== 1)
2522
+ return { text: '', block: false };
2523
+ const tag = (node.tagName ?? '').toLowerCase();
2524
+ if (SKIP_TAGS.has(tag))
2525
+ return { text: '', block: false };
2526
+ if (tag === 'br')
2527
+ return { text: '\n', block: false };
2528
+ if (tag === 'hr')
2529
+ return { text: '---', block: true };
2530
+ const heading = /^h([1-6])$/.exec(tag);
2531
+ if (heading !== null) {
2532
+ const text = collapse(inline(node));
2533
+ return { text: text === '' ? '' : '#'.repeat(Number(heading[1])) + ' ' + text, block: true };
2534
+ }
2535
+ if (tag === 'li') {
2536
+ const text = collapse(inline(node));
2537
+ return { text: text === '' ? '' : '- ' + text, block: true };
2538
+ }
2539
+ if (BLOCK_TAGS.has(tag))
2540
+ return { text: renderChildren(node), block: true };
2541
+ return { text: inline(node), block: false };
2542
+ };
2543
+ /** Join a node's children: inline neighbours merge, block boundaries newline. */
2544
+ const renderChildren = (node) => {
2545
+ const out = [];
2546
+ for (const child of childrenOf(node)) {
2547
+ const piece = renderBlock(child);
2548
+ if (collapse(piece.text) === '')
2549
+ continue;
2550
+ const previous = out[out.length - 1];
2551
+ if (previous !== undefined && !previous.block && !piece.block)
2552
+ previous.text += piece.text;
2553
+ else
2554
+ out.push({ text: piece.text, block: piece.block });
2555
+ }
2556
+ // Inline runs are trimmed once, after merging, so their inner spacing stays.
2557
+ return out.map(piece => piece.block ? piece.text : collapse(piece.text)).join('\n');
2558
+ };
2559
+ if (root.nodeType === 3)
2560
+ return collapse(root.textContent ?? '');
2561
+ return renderChildren(root).replace(/\n{3,}/g, '\n\n').trim();
2562
+ }
2563
+ async function handleSendEvaluate(handle, expression, signal) {
2564
+ signal?.throwIfAborted();
2565
+ const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
2566
+ expression,
2567
+ returnByValue: true,
2568
+ awaitPromise: true,
2569
+ });
2570
+ if (result.exceptionDetails !== undefined) {
2571
+ const detail = result.exceptionDetails;
2572
+ return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' };
2573
+ }
2574
+ return { ok: true, value: result.result?.value ?? null };
2575
+ }