dsh-browser-plus 0.0.0-stage → 0.5.1

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