Improve browser iframe DOM actions
Add an Agent Zero owned browser DOM helper that captures shadow DOM and iframe content with frame-chain/node references.\n\nInstall the DOM helper before page-content capture for both local and host-browser runtimes, and send DOM helper payloads to A0 CLI host browser sessions when needed.\n\nCover iframe content refs and host-browser payload delivery in focused regression tests.
Alessandro committed
May 26, 2026 at 17:36 UTC
ee0acc72f910433b6dd6d8dbc1f6db1a8db06e77
5 files changed
+1502
-4
plugins/_browser/assets/browser-dom-helper.js
new
+1343
@@ -0,0 +1,1343 @@
1
+(() => {
2
+ const DOM_HELPER_CHANNEL = "a0.browser.dom_helper";
3
+ const DOM_HELPER_KEY = "__spaceBrowserDomHelper__";
4
+ const VERSION = "1";
5
+ const REQUEST_TIMEOUT_MS = 800;
6
+
7
+ if (globalThis[DOM_HELPER_KEY]?.version === VERSION) {
8
+ return;
9
+ }
10
+
11
+ const INTERACTIVE_ROLES = new Set([
12
+ "button",
13
+ "checkbox",
14
+ "combobox",
15
+ "link",
16
+ "menuitem",
17
+ "menuitemcheckbox",
18
+ "menuitemradio",
19
+ "option",
20
+ "radio",
21
+ "searchbox",
22
+ "slider",
23
+ "spinbutton",
24
+ "switch",
25
+ "tab",
26
+ "textbox"
27
+ ]);
28
+ const STRUCTURAL_ROLES = new Set([
29
+ "alertdialog",
30
+ "article",
31
+ "banner",
32
+ "complementary",
33
+ "contentinfo",
34
+ "dialog",
35
+ "document",
36
+ "form",
37
+ "group",
38
+ "main",
39
+ "navigation",
40
+ "none",
41
+ "presentation",
42
+ "region"
43
+ ]);
44
+ const INTERACTIVE_EVENT_NAMES = new Set([
45
+ "auxclick",
46
+ "change",
47
+ "click",
48
+ "contextmenu",
49
+ "dblclick",
50
+ "input",
51
+ "keydown",
52
+ "keypress",
53
+ "keyup",
54
+ "mousedown",
55
+ "mouseup",
56
+ "pointerdown",
57
+ "pointerup",
58
+ "submit",
59
+ "touchend",
60
+ "touchstart"
61
+ ]);
62
+ const INTERACTIVE_EVENT_PROPERTIES = [...INTERACTIVE_EVENT_NAMES]
63
+ .map((eventName) => `on${eventName}`);
64
+ const SKIP_TAGS = new Set([
65
+ "HEAD",
66
+ "LINK",
67
+ "META",
68
+ "NOSCRIPT",
69
+ "SCRIPT",
70
+ "STYLE",
71
+ "TEMPLATE"
72
+ ]);
73
+ const VOID_TAGS = new Set([
74
+ "area",
75
+ "base",
76
+ "br",
77
+ "col",
78
+ "embed",
79
+ "hr",
80
+ "img",
81
+ "input",
82
+ "link",
83
+ "meta",
84
+ "param",
85
+ "source",
86
+ "track",
87
+ "wbr"
88
+ ]);
89
+
90
+ const childFramesById = new Map();
91
+ const elementsByNodeId = new Map();
92
+ const nodeIdsByElement = new WeakMap();
93
+ const pendingRequests = new Map();
94
+ const helperFrameId = typeof globalThis.crypto?.randomUUID === "function"
95
+ ? globalThis.crypto.randomUUID()
96
+ : `a0-browser-frame-${Date.now()}-${Math.random().toString(16).slice(2)}`;
97
+ let nextNodeId = 1;
98
+ let nextRequestId = 1;
99
+
100
+ function patchOpenShadowDom() {
101
+ const original = globalThis.Element?.prototype?.attachShadow;
102
+ if (!original || original.__a0BrowserDomHelperOpenShadowPatch) {
103
+ return;
104
+ }
105
+ const patched = function attachShadow(options) {
106
+ return original.call(this, { ...(options || {}), mode: "open" });
107
+ };
108
+ patched.__a0BrowserDomHelperOpenShadowPatch = true;
109
+ globalThis.Element.prototype.attachShadow = patched;
110
+ }
111
+
112
+ patchOpenShadowDom();
113
+
114
+ function createNamedError(name, message, details = {}) {
115
+ const error = new Error(message);
116
+ error.name = name;
117
+ Object.assign(error, details);
118
+ return error;
119
+ }
120
+
121
+ function normalizeText(value) {
122
+ return String(value ?? "").replace(/\s+/gu, " ").trim();
123
+ }
124
+
125
+ function normalizeAttributeText(value) {
126
+ return normalizeText(value).slice(0, 160);
127
+ }
128
+
129
+ function truncateText(value, maxLength = 120) {
130
+ const normalizedValue = normalizeText(value);
131
+ if (normalizedValue.length <= maxLength) {
132
+ return normalizedValue;
133
+ }
134
+ return `${normalizedValue.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`;
135
+ }
136
+
137
+ function escapeHtmlText(value) {
138
+ return String(value ?? "")
139
+ .replace(/&/gu, "&")
140
+ .replace(/</gu, "<")
141
+ .replace(/>/gu, ">");
142
+ }
143
+
144
+ function escapeHtmlAttribute(value) {
145
+ return escapeHtmlText(value)
146
+ .replace(/"/gu, """)
147
+ .replace(/'/gu, "'");
148
+ }
149
+
150
+ function isElementNode(value) {
151
+ return Boolean(value && value.nodeType === 1);
152
+ }
153
+
154
+ function isTextNode(value) {
155
+ return Boolean(value && value.nodeType === 3);
156
+ }
157
+
158
+ function getTagName(element) {
159
+ return String(element?.tagName || "").toUpperCase();
160
+ }
161
+
162
+ function getAttributeNamesSafe(element) {
163
+ try {
164
+ if (typeof element?.getAttributeNames === "function") {
165
+ return element.getAttributeNames();
166
+ }
167
+ return [...(element?.attributes || [])]
168
+ .map((attribute) => String(attribute?.name || "").trim())
169
+ .filter(Boolean);
170
+ } catch {
171
+ return [];
172
+ }
173
+ }
174
+
175
+ function getComputedStyleSafe(element) {
176
+ try {
177
+ return globalThis.getComputedStyle?.(element) || null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ function isStyleDeclarationHidden(styleValue) {
184
+ const normalizedStyleValue = String(styleValue || "")
185
+ .toLowerCase()
186
+ .replace(/\s+/gu, "");
187
+ if (!normalizedStyleValue) {
188
+ return false;
189
+ }
190
+ return /(?:^|;)display:none(?:;|$)/u.test(normalizedStyleValue)
191
+ || /(?:^|;)visibility:hidden(?:;|$)/u.test(normalizedStyleValue)
192
+ || /(?:^|;)visibility:collapse(?:;|$)/u.test(normalizedStyleValue)
193
+ || /(?:^|;)content-visibility:hidden(?:;|$)/u.test(normalizedStyleValue)
194
+ || /(?:^|;)opacity:0(?:\.0+)?(?:;|$)/u.test(normalizedStyleValue);
195
+ }
196
+
197
+ function isComputedStyleHidden(computedStyle) {
198
+ if (!computedStyle) {
199
+ return false;
200
+ }
201
+ const display = normalizeText(computedStyle.display).toLowerCase();
202
+ const visibility = normalizeText(computedStyle.visibility).toLowerCase();
203
+ const contentVisibility = normalizeText(computedStyle.contentVisibility).toLowerCase();
204
+ const opacity = Number(computedStyle.opacity || 1);
205
+ return display === "none"
206
+ || visibility === "hidden"
207
+ || visibility === "collapse"
208
+ || contentVisibility === "hidden"
209
+ || opacity <= 0;
210
+ }
211
+
212
+ function isEffectivelyHiddenByAncestor(element) {
213
+ let current = element;
214
+ while (isElementNode(current)) {
215
+ if (current.hidden || current.getAttribute?.("aria-hidden") === "true") {
216
+ return true;
217
+ }
218
+ if (isStyleDeclarationHidden(current.getAttribute?.("style"))) {
219
+ return true;
220
+ }
221
+ if (isComputedStyleHidden(getComputedStyleSafe(current))) {
222
+ return true;
223
+ }
224
+ current = current.parentElement;
225
+ }
226
+ return false;
227
+ }
228
+
229
+ function isHiddenElement(element) {
230
+ if (!isElementNode(element)) {
231
+ return true;
232
+ }
233
+ const tagName = getTagName(element);
234
+ if (SKIP_TAGS.has(tagName)) {
235
+ return true;
236
+ }
237
+ if (element.hidden || element.getAttribute?.("aria-hidden") === "true") {
238
+ return true;
239
+ }
240
+ if (tagName === "INPUT" && String(element.getAttribute?.("type") || "").toLowerCase() === "hidden") {
241
+ return true;
242
+ }
243
+ if (isStyleDeclarationHidden(element.getAttribute?.("style"))) {
244
+ return true;
245
+ }
246
+ if (isComputedStyleHidden(getComputedStyleSafe(element))) {
247
+ return true;
248
+ }
249
+ return isEffectivelyHiddenByAncestor(element.parentElement);
250
+ }
251
+
252
+ function normalizeInteractiveEventName(value) {
253
+ return String(value || "")
254
+ .trim()
255
+ .toLowerCase()
256
+ .split(/[.:]/u, 1)[0];
257
+ }
258
+
259
+ function isGlobalOrDelegatedEventBinding(value) {
260
+ const parts = String(value || "")
261
+ .trim()
262
+ .toLowerCase()
263
+ .split(/[.:]/u)
264
+ .map((part) => part.trim())
265
+ .filter(Boolean);
266
+ return parts.includes("window")
267
+ || parts.includes("document")
268
+ || parts.includes("outside")
269
+ || parts.includes("away");
270
+ }
271
+
272
+ function isInteractiveEventName(value) {
273
+ return INTERACTIVE_EVENT_NAMES.has(normalizeInteractiveEventName(value));
274
+ }
275
+
276
+ function isInteractiveEventAttributeName(attributeName) {
277
+ const normalizedName = String(attributeName || "").trim().toLowerCase();
278
+ if (!normalizedName) {
279
+ return false;
280
+ }
281
+ if (normalizedName.startsWith("@")) {
282
+ return !isGlobalOrDelegatedEventBinding(normalizedName.slice(1))
283
+ && isInteractiveEventName(normalizedName.slice(1));
284
+ }
285
+ if (normalizedName.startsWith("x-on:") || normalizedName.startsWith("v-on:")) {
286
+ return !isGlobalOrDelegatedEventBinding(normalizedName.slice(5))
287
+ && isInteractiveEventName(normalizedName.slice(5));
288
+ }
289
+ if (normalizedName.startsWith("ng-")) {
290
+ return isInteractiveEventName(normalizedName.slice(3));
291
+ }
292
+ if (normalizedName.startsWith("on") && normalizedName.length > 2) {
293
+ return isInteractiveEventName(normalizedName.slice(2));
294
+ }
295
+ return false;
296
+ }
297
+
298
+ function hasInteractiveEventHandlerAttribute(element) {
299
+ return getAttributeNamesSafe(element).some((attributeName) => {
300
+ return isInteractiveEventAttributeName(attributeName);
301
+ });
302
+ }
303
+
304
+ function hasInteractiveEventHandlerProperty(element) {
305
+ return INTERACTIVE_EVENT_PROPERTIES.some((propertyName) => {
306
+ return typeof element?.[propertyName] === "function";
307
+ });
308
+ }
309
+
310
+ function hasInteractiveEventHandler(element) {
311
+ return hasInteractiveEventHandlerAttribute(element) || hasInteractiveEventHandlerProperty(element);
312
+ }
313
+
314
+ function isActionableElement(element) {
315
+ if (!isElementNode(element) || isHiddenElement(element)) {
316
+ return false;
317
+ }
318
+ const tagName = getTagName(element);
319
+ if (tagName === "A" && element.hasAttribute?.("href")) {
320
+ return true;
321
+ }
322
+ if (tagName === "IMG") {
323
+ return true;
324
+ }
325
+ if (["BUTTON", "INPUT", "SELECT", "TEXTAREA", "SUMMARY"].includes(tagName)) {
326
+ return true;
327
+ }
328
+ if (String(element.getAttribute?.("contenteditable") || "").toLowerCase() === "true") {
329
+ return true;
330
+ }
331
+ const role = String(element.getAttribute?.("role") || "").trim().toLowerCase();
332
+ if (INTERACTIVE_ROLES.has(role)) {
333
+ return true;
334
+ }
335
+ if (STRUCTURAL_ROLES.has(role)) {
336
+ return false;
337
+ }
338
+ if (hasInteractiveEventHandlerAttribute(element)) {
339
+ return true;
340
+ }
341
+ return hasInteractiveEventHandlerProperty(element) && Boolean(normalizeText(element.textContent || ""));
342
+ }
343
+
344
+ function normalizeFrameChain(frameChain) {
345
+ const rawFrameChain = Array.isArray(frameChain)
346
+ ? frameChain
347
+ : typeof frameChain === "string"
348
+ ? frameChain.split(">")
349
+ : [];
350
+ return rawFrameChain
351
+ .map((entry) => String(entry || "").trim())
352
+ .filter(Boolean);
353
+ }
354
+
355
+ function encodeFrameChain(frameChain) {
356
+ return normalizeFrameChain(frameChain).join(">");
357
+ }
358
+
359
+ function ensureNodeId(element) {
360
+ if (nodeIdsByElement.has(element)) {
361
+ return nodeIdsByElement.get(element);
362
+ }
363
+ const nodeId = String(nextNodeId++);
364
+ nodeIdsByElement.set(element, nodeId);
365
+ elementsByNodeId.set(nodeId, element);
366
+ return nodeId;
367
+ }
368
+
369
+ function normalizeSnapshotMode(value) {
370
+ return String(value || "").trim().toLowerCase() || "dom";
371
+ }
372
+
373
+ function isContentSnapshotMode(payload = {}) {
374
+ return normalizeSnapshotMode(payload?.snapshotMode) === "content";
375
+ }
376
+
377
+ function normalizeSelectorList(payload = {}) {
378
+ const rawSelectors = typeof payload === "string"
379
+ ? [payload]
380
+ : Array.isArray(payload?.selectors)
381
+ ? payload.selectors
382
+ : typeof payload?.selectors === "string"
383
+ ? [payload.selectors]
384
+ : Array.isArray(payload?.selector)
385
+ ? payload.selector
386
+ : typeof payload?.selector === "string"
387
+ ? [payload.selector]
388
+ : [];
389
+ return rawSelectors.map((selector) => String(selector || "").trim()).filter(Boolean);
390
+ }
391
+
392
+ function getReferenceValueMetadata(element) {
393
+ const tagName = getTagName(element);
394
+ if (tagName === "INPUT") {
395
+ const inputType = String(element.getAttribute?.("type") || element.type || "text").toLowerCase();
396
+ if (inputType === "password") {
397
+ return "";
398
+ }
399
+ if (["checkbox", "radio"].includes(inputType)) {
400
+ return element.checked ? "checked" : "unchecked";
401
+ }
402
+ return truncateText(element.value || element.getAttribute?.("value") || "", 96);
403
+ }
404
+ if (tagName === "TEXTAREA") {
405
+ return truncateText(element.value || "", 96);
406
+ }
407
+ if (tagName === "SELECT") {
408
+ return [...(element.selectedOptions || [])]
409
+ .map((option) => truncateText(option.textContent || option.label || option.value || "", 48))
410
+ .filter(Boolean)
411
+ .join(" | ");
412
+ }
413
+ if (String(element.getAttribute?.("contenteditable") || "").toLowerCase() === "true") {
414
+ return truncateText(element.textContent || "", 96);
415
+ }
416
+ return "";
417
+ }
418
+
419
+ function collectElementStateMetadata(element) {
420
+ if (!isElementNode(element)) {
421
+ return {
422
+ descriptorTags: [],
423
+ semanticTags: [],
424
+ stateTags: []
425
+ };
426
+ }
427
+ const tagName = getTagName(element);
428
+ const computedStyle = getComputedStyleSafe(element);
429
+ const ariaDisabled = String(element.getAttribute?.("aria-disabled") || "").trim().toLowerCase() === "true";
430
+ const ariaChecked = String(element.getAttribute?.("aria-checked") || "").trim().toLowerCase() === "true";
431
+ const ariaExpanded = String(element.getAttribute?.("aria-expanded") || "").trim().toLowerCase() === "true";
432
+ const ariaInvalid = String(element.getAttribute?.("aria-invalid") || "").trim().toLowerCase() === "true";
433
+ const ariaPressed = String(element.getAttribute?.("aria-pressed") || "").trim().toLowerCase() === "true";
434
+ const ariaReadonly = String(element.getAttribute?.("aria-readonly") || "").trim().toLowerCase() === "true";
435
+ const ariaRequired = String(element.getAttribute?.("aria-required") || "").trim().toLowerCase() === "true";
436
+ const ariaSelected = String(element.getAttribute?.("aria-selected") || "").trim().toLowerCase() === "true";
437
+ const disabled = Boolean(element.disabled || ariaDisabled || element.closest?.("[inert]"));
438
+ const checked = Boolean(element.checked || ariaChecked);
439
+ const selected = tagName === "OPTION" ? Boolean(element.selected) : ariaSelected;
440
+ const invalid = Boolean(ariaInvalid || element.matches?.(":invalid"));
441
+ const readonly = Boolean(element.readOnly || ariaReadonly);
442
+ const required = Boolean(element.required || ariaRequired);
443
+ const blocked = !disabled && normalizeText(computedStyle?.pointerEvents || "").toLowerCase() === "none";
444
+ const stateTags = [
445
+ disabled ? "disabled" : "",
446
+ blocked ? "blocked" : "",
447
+ checked ? "checked" : "",
448
+ selected && tagName !== "SELECT" ? "selected" : "",
449
+ invalid ? "invalid" : "",
450
+ ariaExpanded ? "expanded" : "",
451
+ ariaPressed ? "pressed" : ""
452
+ ].filter(Boolean);
453
+ return {
454
+ blocked,
455
+ checked,
456
+ descriptorTags: stateTags.slice(),
457
+ disabled,
458
+ expanded: ariaExpanded,
459
+ invalid,
460
+ pointerEventsNone: blocked,
461
+ pressed: ariaPressed,
462
+ readonly,
463
+ required,
464
+ selected,
465
+ semanticTags: [],
466
+ stateTags,
467
+ visible: !isHiddenElement(element)
468
+ };
469
+ }
470
+
471
+ function serializeAttributes(element, frameChain) {
472
+ const serializedAttributes = [];
473
+ const helperManagedAttributes = new Set([
474
+ "data-space-browser-node-id",
475
+ "data-space-browser-frame-id",
476
+ "data-space-browser-frame-chain",
477
+ "data-space-browser-state-tags",
478
+ "data-space-browser-semantic-tags",
479
+ "data-space-browser-descriptor-tags",
480
+ "data-space-browser-live-value",
481
+ "data-space-browser-selected-text"
482
+ ]);
483
+ try {
484
+ [...(element?.attributes || [])].forEach((attribute) => {
485
+ const name = String(attribute?.name || "").trim();
486
+ if (!name || helperManagedAttributes.has(name)) {
487
+ return;
488
+ }
489
+ serializedAttributes.push(` ${name}="${escapeHtmlAttribute(attribute?.value || "")}"`);
490
+ });
491
+ } catch {
492
+ // Attribute reads are best effort across unusual DOM nodes.
493
+ }
494
+
495
+ if (isActionableElement(element)) {
496
+ const stateMetadata = collectElementStateMetadata(element);
497
+ const liveValue = getReferenceValueMetadata(element);
498
+ serializedAttributes.push(` data-space-browser-node-id="${escapeHtmlAttribute(ensureNodeId(element))}"`);
499
+ serializedAttributes.push(` data-space-browser-frame-id="${escapeHtmlAttribute(helperFrameId)}"`);
500
+ serializedAttributes.push(` data-space-browser-frame-chain="${escapeHtmlAttribute(encodeFrameChain(frameChain))}"`);
501
+ if (stateMetadata.stateTags.length) {
502
+ serializedAttributes.push(` data-space-browser-state-tags="${escapeHtmlAttribute(stateMetadata.stateTags.join(" "))}"`);
503
+ }
504
+ if (stateMetadata.semanticTags.length) {
505
+ serializedAttributes.push(` data-space-browser-semantic-tags="${escapeHtmlAttribute(stateMetadata.semanticTags.join(" "))}"`);
506
+ }
507
+ if (stateMetadata.descriptorTags.length) {
508
+ serializedAttributes.push(` data-space-browser-descriptor-tags="${escapeHtmlAttribute(stateMetadata.descriptorTags.join(" "))}"`);
509
+ }
510
+ if (liveValue) {
511
+ serializedAttributes.push(` data-space-browser-live-value="${escapeHtmlAttribute(liveValue)}"`);
512
+ if (getTagName(element) === "SELECT") {
513
+ serializedAttributes.push(` data-space-browser-selected-text="${escapeHtmlAttribute(liveValue)}"`);
514
+ }
515
+ }
516
+ }
517
+ return serializedAttributes.join("");
518
+ }
519
+
520
+ function isFrameLikeElement(element) {
521
+ return ["IFRAME", "FRAME", "OBJECT", "EMBED"].includes(getTagName(element));
522
+ }
523
+
524
+ function createRequestId() {
525
+ nextRequestId += 1;
526
+ return `a0-browser-dom-${Date.now()}-${nextRequestId}-${Math.random().toString(16).slice(2)}`;
527
+ }
528
+
529
+ function resolveElementWindow(element) {
530
+ try {
531
+ return element?.contentWindow || null;
532
+ } catch {
533
+ return null;
534
+ }
535
+ }
536
+
537
+ function requestChildFrameOperation(targetWindow, type, payload = {}, frameElement = null) {
538
+ if (!targetWindow || typeof targetWindow.postMessage !== "function") {
539
+ throw createNamedError(
540
+ "BrowserDomHelperFrameUnavailableError",
541
+ "Embedded frame window is unavailable.",
542
+ {
543
+ code: "browser_dom_helper_frame_window_unavailable",
544
+ details: { frameElementTag: getTagName(frameElement).toLowerCase() }
545
+ }
546
+ );
547
+ }
548
+ return new Promise((resolve, reject) => {
549
+ const requestId = createRequestId();
550
+ const timer = globalThis.setTimeout(() => {
551
+ pendingRequests.delete(requestId);
552
+ reject(createNamedError(
553
+ "BrowserDomHelperFrameTimeoutError",
554
+ `Embedded frame request "${type}" timed out.`,
555
+ { code: "browser_dom_helper_frame_timeout", details: { type } }
556
+ ));
557
+ }, REQUEST_TIMEOUT_MS);
558
+ pendingRequests.set(requestId, { reject, resolve, timer, type });
559
+ try {
560
+ targetWindow.postMessage({
561
+ channel: DOM_HELPER_CHANNEL,
562
+ payload,
563
+ requestId,
564
+ type
565
+ }, "*");
566
+ } catch (error) {
567
+ globalThis.clearTimeout(timer);
568
+ pendingRequests.delete(requestId);
569
+ reject(createNamedError(
570
+ "BrowserDomHelperFrameRequestError",
571
+ `Embedded frame request "${type}" could not be posted.`,
572
+ { cause: error, code: "browser_dom_helper_frame_postmessage_failed", details: { type } }
573
+ ));
574
+ }
575
+ });
576
+ }
577
+
578
+ function registerChildFrame(frameId, targetWindow) {
579
+ const normalizedFrameId = String(frameId || "").trim();
580
+ if (normalizedFrameId && targetWindow && typeof targetWindow.postMessage === "function") {
581
+ childFramesById.set(normalizedFrameId, targetWindow);
582
+ }
583
+ }
584
+
585
+ function extractDocumentBodyHtml(html) {
586
+ const normalizedHtml = String(html || "").trim();
587
+ if (!normalizedHtml) {
588
+ return "";
589
+ }
590
+ try {
591
+ if (typeof DOMParser === "function") {
592
+ const parsedDocument = new DOMParser().parseFromString(normalizedHtml, "text/html");
593
+ return String(parsedDocument.body?.innerHTML || normalizedHtml).trim();
594
+ }
595
+ } catch {
596
+ // Fall through to the raw snapshot.
597
+ }
598
+ return normalizedHtml.replace(/<!doctype[\s\S]*?>/iu, "").trim();
599
+ }
600
+
601
+ async function captureFrameElement(frameElement, frameChain, payload = {}) {
602
+ const childWindow = resolveElementWindow(frameElement);
603
+ const childPayload = {
604
+ snapshotMode: normalizeSnapshotMode(payload?.snapshotMode),
605
+ parentFrameChain: frameChain
606
+ };
607
+ if (!childWindow) {
608
+ return {
609
+ frameChain: [],
610
+ frameId: "",
611
+ html: escapeHtmlText("Embedded frame snapshot unavailable."),
612
+ message: "Embedded frame snapshot unavailable.",
613
+ ok: false,
614
+ status: "window_unavailable",
615
+ title: "",
616
+ url: String(frameElement?.getAttribute?.("src") || "").trim()
617
+ };
618
+ }
619
+
620
+ try {
621
+ const snapshot = await requestChildFrameOperation(childWindow, "capture_document", childPayload, frameElement);
622
+ registerChildFrame(snapshot?.frameId, childWindow);
623
+ return snapshot;
624
+ } catch (error) {
625
+ try {
626
+ const frameDocument = frameElement?.contentDocument;
627
+ if (frameDocument) {
628
+ const currentFrameChain = normalizeFrameChain(frameChain);
629
+ return {
630
+ frameChain: currentFrameChain,
631
+ frameId: helperFrameId,
632
+ html: await serializeDocumentNode(frameDocument, currentFrameChain, childPayload),
633
+ ok: true,
634
+ title: String(frameDocument.title || "").trim(),
635
+ url: String(childWindow.location?.href || frameElement?.src || "").trim()
636
+ };
637
+ }
638
+ } catch {
639
+ // Cross-origin frames stay best effort and report the postMessage failure below.
640
+ }
641
+ return {
642
+ frameChain: normalizeFrameChain(frameChain),
643
+ frameId: "",
644
+ html: escapeHtmlText(String(error?.message || "Embedded frame snapshot unavailable.")),
645
+ message: String(error?.message || "Embedded frame snapshot unavailable."),
646
+ ok: false,
647
+ status: String(error?.code || "capture_failed"),
648
+ title: String(frameElement?.getAttribute?.("title") || "").trim(),
649
+ url: String(frameElement?.getAttribute?.("src") || frameElement?.src || "").trim()
650
+ };
651
+ }
652
+ }
653
+
654
+ function renderFrameDocument(snapshot, frameElement, payload = {}) {
655
+ const normalizedSnapshot = snapshot && typeof snapshot === "object" ? snapshot : {};
656
+ const content = String(
657
+ normalizedSnapshot.html
658
+ || escapeHtmlText(normalizedSnapshot.message || "Embedded frame snapshot unavailable.")
659
+ );
660
+ if (isContentSnapshotMode(payload)) {
661
+ return normalizedSnapshot.ok === false ? "" : extractDocumentBodyHtml(content);
662
+ }
663
+ return `<space-browser-frame-document`
664
+ + ` data-space-browser-frame-id="${escapeHtmlAttribute(normalizedSnapshot.frameId || "")}"`
665
+ + ` data-space-browser-frame-chain="${escapeHtmlAttribute(encodeFrameChain(normalizedSnapshot.frameChain))}"`
666
+ + ` data-space-browser-status="${escapeHtmlAttribute(normalizedSnapshot.ok === false ? normalizedSnapshot.status || "error" : "ok")}"`
667
+ + ` data-space-browser-frame-url="${escapeHtmlAttribute(normalizedSnapshot.url || frameElement?.src || "")}"`
668
+ + ` data-space-browser-frame-title="${escapeHtmlAttribute(normalizedSnapshot.title || frameElement?.getAttribute?.("title") || "")}">`
669
+ + content
670
+ + `</space-browser-frame-document>`;
671
+ }
672
+
673
+ async function serializeChildNodes(parentNode, frameChain, payload = {}) {
674
+ const parts = [];
675
+ for (const childNode of Array.from(parentNode?.childNodes || [])) {
676
+ parts.push(await serializeNode(childNode, frameChain, payload));
677
+ }
678
+ return parts.join("");
679
+ }
680
+
681
+ async function serializeElementNode(element, frameChain, payload = {}) {
682
+ const tagName = String(element?.tagName || "").toLowerCase();
683
+ if (!tagName) {
684
+ return "";
685
+ }
686
+ if (isContentSnapshotMode(payload) && isHiddenElement(element)) {
687
+ return "";
688
+ }
689
+ const openTag = `<${tagName}${serializeAttributes(element, frameChain)}>`;
690
+ const lightDom = await serializeChildNodes(element, frameChain, payload);
691
+ let shadowDom = "";
692
+ try {
693
+ const shadowRoot = element?.shadowRoot;
694
+ if (shadowRoot) {
695
+ const shadowInnerHtml = await serializeChildNodes(shadowRoot, frameChain, payload);
696
+ shadowDom = isContentSnapshotMode(payload)
697
+ ? shadowInnerHtml
698
+ : `<space-browser-shadow-root>${shadowInnerHtml}</space-browser-shadow-root>`;
699
+ }
700
+ } catch {
701
+ shadowDom = "";
702
+ }
703
+ let frameDom = "";
704
+ if (isFrameLikeElement(element)) {
705
+ frameDom = renderFrameDocument(
706
+ await captureFrameElement(element, frameChain, payload),
707
+ element,
708
+ payload
709
+ );
710
+ if (isContentSnapshotMode(payload)) {
711
+ return frameDom;
712
+ }
713
+ }
714
+ if (VOID_TAGS.has(tagName)) {
715
+ return `${openTag}${shadowDom}${frameDom}`;
716
+ }
717
+ return `${openTag}${lightDom}${shadowDom}${frameDom}</${tagName}>`;
718
+ }
719
+
720
+ async function serializeNode(node, frameChain, payload = {}) {
721
+ if (!node || typeof node.nodeType !== "number") {
722
+ return "";
723
+ }
724
+ if (node.nodeType === 9) {
725
+ return serializeDocumentNode(node, frameChain, payload);
726
+ }
727
+ if (node.nodeType === 11) {
728
+ return serializeChildNodes(node, frameChain, payload);
729
+ }
730
+ if (node.nodeType === 1) {
731
+ return serializeElementNode(node, frameChain, payload);
732
+ }
733
+ if (node.nodeType === 3) {
734
+ return escapeHtmlText(node.textContent || "");
735
+ }
736
+ if (node.nodeType === 8 && !isContentSnapshotMode(payload)) {
737
+ return `<!--${escapeHtmlText(node.data || "")}-->`;
738
+ }
739
+ return "";
740
+ }
741
+
742
+ async function serializeDocumentNode(doc, frameChain, payload = {}) {
743
+ return serializeChildNodes(doc, frameChain, payload);
744
+ }
745
+
746
+ async function serializeSelectorTargets(doc, selectors, frameChain, payload = {}) {
747
+ const targets = {};
748
+ for (const selector of selectors) {
749
+ let elements = [];
750
+ try {
751
+ elements = [...(doc?.querySelectorAll?.(selector) || [])];
752
+ } catch (error) {
753
+ throw createNamedError(
754
+ "BrowserDomHelperSelectorError",
755
+ `Browser DOM helper could not resolve selector "${selector}".`,
756
+ { cause: error, code: "browser_dom_helper_selector_error", details: { selector } }
757
+ );
758
+ }
759
+ const parts = [];
760
+ for (const element of elements) {
761
+ parts.push(await serializeNode(element, frameChain, payload));
762
+ }
763
+ targets[selector] = parts.join("");
764
+ }
765
+ return targets;
766
+ }
767
+
768
+ async function captureDocument(payload = {}) {
769
+ childFramesById.clear();
770
+ const currentFrameChain = normalizeFrameChain(payload?.parentFrameChain).concat(helperFrameId);
771
+ const selectors = normalizeSelectorList(payload);
772
+ const documentSnapshot = {
773
+ frameChain: currentFrameChain,
774
+ frameId: helperFrameId,
775
+ ok: true,
776
+ title: String(globalThis.document?.title || "").trim(),
777
+ url: String(globalThis.location?.href || "").trim()
778
+ };
779
+ if (selectors.length) {
780
+ return {
781
+ ...documentSnapshot,
782
+ targets: await serializeSelectorTargets(globalThis.document, selectors, currentFrameChain, payload)
783
+ };
784
+ }
785
+ return {
786
+ ...documentSnapshot,
787
+ html: await serializeDocumentNode(globalThis.document, currentFrameChain, payload)
788
+ };
789
+ }
790
+
791
+ function getElementByNodeId(nodeId, actionLabel) {
792
+ const normalizedNodeId = String(nodeId || "").trim();
793
+ if (!normalizedNodeId) {
794
+ throw createNamedError(
795
+ "BrowserDomHelperReferenceError",
796
+ `Browser DOM helper ${actionLabel} requires a node id.`,
797
+ { code: "browser_dom_helper_node_required", details: { action: actionLabel } }
798
+ );
799
+ }
800
+ const element = elementsByNodeId.get(normalizedNodeId);
801
+ if (!element) {
802
+ throw createNamedError(
803
+ "BrowserDomHelperReferenceError",
804
+ `Browser DOM helper could not find node "${normalizedNodeId}".`,
805
+ { code: "browser_dom_helper_node_not_found", details: { action: actionLabel, nodeId: normalizedNodeId } }
806
+ );
807
+ }
808
+ if (element.isConnected === false) {
809
+ throw createNamedError(
810
+ "BrowserDomHelperReferenceError",
811
+ `Browser DOM helper node "${normalizedNodeId}" is no longer connected.`,
812
+ { code: "browser_dom_helper_node_disconnected", details: { action: actionLabel, nodeId: normalizedNodeId } }
813
+ );
814
+ }
815
+ return element;
816
+ }
817
+
818
+ function serializeElementSnapshot(element) {
819
+ if (!isElementNode(element)) {
820
+ return "";
821
+ }
822
+ try {
823
+ if (typeof element.outerHTML === "string" && element.outerHTML) {
824
+ return element.outerHTML;
825
+ }
826
+ } catch {
827
+ // Fall through.
828
+ }
829
+ return "";
830
+ }
831
+
832
+ function scrollElementIntoView(element) {
833
+ try {
834
+ element.scrollIntoView?.({ behavior: "auto", block: "center", inline: "center" });
835
+ return true;
836
+ } catch {
837
+ return false;
838
+ }
839
+ }
840
+
841
+ function focusElement(element) {
842
+ try {
843
+ element.focus?.({ preventScroll: true });
844
+ return true;
845
+ } catch {
846
+ try {
847
+ element.focus?.();
848
+ return true;
849
+ } catch {
850
+ return false;
851
+ }
852
+ }
853
+ }
854
+
855
+ function dispatchDomEvent(target, eventName, EventType = "Event", options = {}) {
856
+ const EventConstructor = typeof globalThis[EventType] === "function"
857
+ ? globalThis[EventType]
858
+ : globalThis.Event;
859
+ const event = new EventConstructor(eventName, {
860
+ bubbles: true,
861
+ cancelable: true,
862
+ composed: true,
863
+ ...options
864
+ });
865
+ target.dispatchEvent(event);
866
+ return event;
867
+ }
868
+
869
+ function dispatchKeyboardEvent(target, eventName, options = {}) {
870
+ const KeyboardEventConstructor = typeof globalThis.KeyboardEvent === "function"
871
+ ? globalThis.KeyboardEvent
872
+ : globalThis.Event;
873
+ const event = new KeyboardEventConstructor(eventName, {
874
+ bubbles: true,
875
+ cancelable: true,
876
+ composed: true,
877
+ code: "Enter",
878
+ key: "Enter",
879
+ keyCode: 13,
880
+ which: 13,
881
+ ...options
882
+ });
883
+ target.dispatchEvent(event);
884
+ return event;
885
+ }
886
+
887
+ function setNativeValue(element, nextValue) {
888
+ const tagName = getTagName(element);
889
+ const normalizedValue = String(nextValue ?? "");
890
+ if (tagName === "INPUT") {
891
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis.HTMLInputElement?.prototype || {}, "value");
892
+ if (typeof descriptor?.set === "function") {
893
+ descriptor.set.call(element, normalizedValue);
894
+ } else {
895
+ element.value = normalizedValue;
896
+ }
897
+ return normalizedValue;
898
+ }
899
+ if (tagName === "TEXTAREA") {
900
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis.HTMLTextAreaElement?.prototype || {}, "value");
901
+ if (typeof descriptor?.set === "function") {
902
+ descriptor.set.call(element, normalizedValue);
903
+ } else {
904
+ element.value = normalizedValue;
905
+ }
906
+ return normalizedValue;
907
+ }
908
+ if (tagName === "SELECT") {
909
+ element.value = normalizedValue;
910
+ return element.value;
911
+ }
912
+ if (String(element.getAttribute?.("contenteditable") || "").toLowerCase() === "true") {
913
+ element.textContent = normalizedValue;
914
+ return normalizedValue;
915
+ }
916
+ throw createNamedError(
917
+ "BrowserDomHelperActionError",
918
+ `Browser DOM helper cannot type into <${getTagName(element).toLowerCase()}>.`,
919
+ { code: "browser_dom_helper_type_unsupported" }
920
+ );
921
+ }
922
+
923
+ function delayMs(timeoutMs) {
924
+ return new Promise((resolve) => {
925
+ globalThis.setTimeout(resolve, Math.max(0, Number(timeoutMs) || 0));
926
+ });
927
+ }
928
+
929
+ function describeActiveElement(element) {
930
+ if (!isElementNode(element)) {
931
+ return "";
932
+ }
933
+ return [
934
+ getTagName(element).toLowerCase(),
935
+ normalizeAttributeText(element.getAttribute?.("id")) ? `#${normalizeAttributeText(element.getAttribute?.("id"))}` : "",
936
+ normalizeAttributeText(element.getAttribute?.("name")) ? `name=${normalizeAttributeText(element.getAttribute?.("name"))}` : ""
937
+ ].filter(Boolean).join(" ");
938
+ }
939
+
940
+ function getActionObservationRoot(element) {
941
+ return element?.closest?.("form, fieldset, dialog, [role='dialog'], [role='alert'], [role='status'], [aria-live], article, section, main, li, tr, td, th")
942
+ || element?.parentElement
943
+ || element
944
+ || globalThis.document?.body
945
+ || globalThis.document?.documentElement
946
+ || null;
947
+ }
948
+
949
+ function captureActionEffectSnapshot(element) {
950
+ const observationRoot = getActionObservationRoot(element);
951
+ return {
952
+ activeElement: describeActiveElement(globalThis.document?.activeElement),
953
+ observationRoot,
954
+ observationText: truncateText(normalizeText(observationRoot?.textContent || ""), 2000),
955
+ targetDom: truncateText(serializeElementSnapshot(element), 2000),
956
+ targetState: collectElementStateMetadata(element),
957
+ value: getReferenceValueMetadata(element)
958
+ };
959
+ }
960
+
961
+ async function withObservedActionWindow(observationRoot, action, { quietMs = 40, timeoutMs = 180 } = {}) {
962
+ const target = observationRoot?.ownerDocument?.body
963
+ || observationRoot?.ownerDocument?.documentElement
964
+ || globalThis.document?.body
965
+ || globalThis.document?.documentElement;
966
+ if (!target || typeof globalThis.MutationObserver !== "function") {
967
+ const result = await action();
968
+ await delayMs(timeoutMs);
969
+ return { observedMutations: { attributeNames: [], mutationCount: 0 }, result };
970
+ }
971
+ const attributeNames = new Set();
972
+ let lastMutationAt = 0;
973
+ let mutationCount = 0;
974
+ const observer = new globalThis.MutationObserver((mutations) => {
975
+ mutationCount += mutations.length;
976
+ lastMutationAt = Date.now();
977
+ mutations.forEach((mutation) => {
978
+ if (mutation.type === "attributes" && mutation.attributeName) {
979
+ attributeNames.add(String(mutation.attributeName));
980
+ }
981
+ });
982
+ });
983
+ try {
984
+ observer.observe(target, {
985
+ attributes: true,
986
+ characterData: true,
987
+ childList: true,
988
+ subtree: true
989
+ });
990
+ const result = await action();
991
+ const startedAt = Date.now();
992
+ while (Date.now() - startedAt < timeoutMs) {
993
+ await delayMs(20);
994
+ if (mutationCount > 0 && Date.now() - lastMutationAt >= quietMs) {
995
+ break;
996
+ }
997
+ }
998
+ return { observedMutations: { attributeNames: [...attributeNames], mutationCount }, result };
999
+ } finally {
1000
+ observer.disconnect();
1001
+ }
1002
+ }
1003
+
1004
+ function buildActionEffectResult(beforeSnapshot, afterSnapshot, observedMutations, extra = {}) {
1005
+ const focusChanged = beforeSnapshot.activeElement !== afterSnapshot.activeElement;
1006
+ const nearbyTextChanged = beforeSnapshot.observationText !== afterSnapshot.observationText;
1007
+ const valueChanged = beforeSnapshot.value !== afterSnapshot.value;
1008
+ const checkedChanged = beforeSnapshot.targetState.checked !== afterSnapshot.targetState.checked;
1009
+ const selectedChanged = beforeSnapshot.targetState.selected !== afterSnapshot.targetState.selected;
1010
+ const expandedChanged = beforeSnapshot.targetState.expanded !== afterSnapshot.targetState.expanded;
1011
+ const pressedChanged = beforeSnapshot.targetState.pressed !== afterSnapshot.targetState.pressed;
1012
+ const targetDomChanged = beforeSnapshot.targetDom !== afterSnapshot.targetDom;
1013
+ const domChanged = Boolean(observedMutations.mutationCount) || targetDomChanged || nearbyTextChanged;
1014
+ const status = {
1015
+ checkedChanged,
1016
+ domChanged,
1017
+ expandedChanged,
1018
+ focusChanged,
1019
+ nearbyTextChanged,
1020
+ pressedChanged,
1021
+ reacted: false,
1022
+ selectedChanged,
1023
+ targetChanged: targetDomChanged || valueChanged || checkedChanged || selectedChanged || expandedChanged || pressedChanged,
1024
+ targetDomChanged,
1025
+ valueChanged
1026
+ };
1027
+ status.reacted = Object.entries(status).some(([key, value]) => key !== "reacted" && value === true);
1028
+ status.noObservedEffect = !status.reacted;
1029
+ return {
1030
+ ...extra,
1031
+ effect: {
1032
+ mutationAttributes: observedMutations.attributeNames.slice(0, 8),
1033
+ mutationCount: observedMutations.mutationCount
1034
+ },
1035
+ status
1036
+ };
1037
+ }
1038
+
1039
+ function collectActionResult(element) {
1040
+ const state = collectElementStateMetadata(element);
1041
+ return {
1042
+ connected: element.isConnected !== false,
1043
+ descriptorTags: state.descriptorTags.slice(),
1044
+ dom: serializeElementSnapshot(element),
1045
+ frameId: helperFrameId,
1046
+ nodeId: ensureNodeId(element),
1047
+ semanticTags: state.semanticTags.slice(),
1048
+ state,
1049
+ tagName: getTagName(element)
1050
+ };
1051
+ }
1052
+
1053
+ function detailLocalNode(payload = {}) {
1054
+ return collectActionResult(getElementByNodeId(payload?.nodeId, "detail"));
1055
+ }
1056
+
1057
+ async function clickLocalNode(payload = {}) {
1058
+ const element = getElementByNodeId(payload?.nodeId, "click");
1059
+ const beforeSnapshot = captureActionEffectSnapshot(element);
1060
+ scrollElementIntoView(element);
1061
+ focusElement(element);
1062
+ if (beforeSnapshot.targetState.disabled) {
1063
+ throw createNamedError(
1064
+ "BrowserDomHelperActionError",
1065
+ `Browser DOM helper node "${payload?.nodeId}" is disabled.`,
1066
+ { code: "browser_dom_helper_click_disabled" }
1067
+ );
1068
+ }
1069
+ const { observedMutations } = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
1070
+ if (typeof element.click === "function") {
1071
+ element.click();
1072
+ } else {
1073
+ dispatchDomEvent(element, "click", "MouseEvent", { button: 0 });
1074
+ }
1075
+ });
1076
+ return {
1077
+ ...collectActionResult(element),
1078
+ ...buildActionEffectResult(beforeSnapshot, captureActionEffectSnapshot(element), observedMutations)
1079
+ };
1080
+ }
1081
+
1082
+ async function typeLocalNode(payload = {}) {
1083
+ const element = getElementByNodeId(payload?.nodeId, "type");
1084
+ const beforeSnapshot = captureActionEffectSnapshot(element);
1085
+ const { observedMutations, result } = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
1086
+ scrollElementIntoView(element);
1087
+ focusElement(element);
1088
+ const appliedValue = setNativeValue(element, payload?.value ?? "");
1089
+ dispatchDomEvent(element, "beforeinput", "InputEvent", {
1090
+ data: String(payload?.value ?? ""),
1091
+ inputType: "insertText"
1092
+ });
1093
+ dispatchDomEvent(element, "input", "InputEvent", {
1094
+ data: String(payload?.value ?? ""),
1095
+ inputType: "insertText"
1096
+ });
1097
+ dispatchDomEvent(element, "change");
1098
+ return appliedValue;
1099
+ });
1100
+ return {
1101
+ ...collectActionResult(element),
1102
+ ...buildActionEffectResult(beforeSnapshot, captureActionEffectSnapshot(element), observedMutations),
1103
+ value: result
1104
+ };
1105
+ }
1106
+
1107
+ async function submitLocalNode(payload = {}) {
1108
+ const element = getElementByNodeId(payload?.nodeId, "submit");
1109
+ const tagName = getTagName(element);
1110
+ const beforeSnapshot = captureActionEffectSnapshot(element);
1111
+ const { observedMutations } = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
1112
+ scrollElementIntoView(element);
1113
+ focusElement(element);
1114
+ if (tagName === "FORM" && typeof element.requestSubmit === "function") {
1115
+ element.requestSubmit();
1116
+ } else if (element.form && typeof element.form.requestSubmit === "function") {
1117
+ element.form.requestSubmit(["BUTTON", "INPUT"].includes(tagName) ? element : undefined);
1118
+ } else if (element.form) {
1119
+ const submitEvent = dispatchDomEvent(element.form, "submit");
1120
+ if (!submitEvent.defaultPrevented) {
1121
+ element.form.submit?.();
1122
+ }
1123
+ } else if (typeof element.click === "function") {
1124
+ element.click();
1125
+ } else {
1126
+ throw createNamedError(
1127
+ "BrowserDomHelperActionError",
1128
+ `Browser DOM helper cannot submit node "${payload?.nodeId}".`,
1129
+ { code: "browser_dom_helper_submit_unsupported" }
1130
+ );
1131
+ }
1132
+ });
1133
+ return {
1134
+ ...collectActionResult(element),
1135
+ ...buildActionEffectResult(beforeSnapshot, captureActionEffectSnapshot(element), observedMutations)
1136
+ };
1137
+ }
1138
+
1139
+ async function pressEnterLocalNode(payload = {}) {
1140
+ const element = getElementByNodeId(payload?.nodeId, "type_submit");
1141
+ const beforeSnapshot = captureActionEffectSnapshot(element);
1142
+ const { observedMutations } = await withObservedActionWindow(beforeSnapshot.observationRoot, async () => {
1143
+ scrollElementIntoView(element);
1144
+ focusElement(element);
1145
+ dispatchKeyboardEvent(element, "keydown");
1146
+ dispatchKeyboardEvent(element, "keypress");
1147
+ dispatchKeyboardEvent(element, "keyup");
1148
+ if (getTagName(element) === "INPUT" && element.form && typeof element.form.requestSubmit === "function") {
1149
+ element.form.requestSubmit();
1150
+ }
1151
+ });
1152
+ return {
1153
+ ...collectActionResult(element),
1154
+ ...buildActionEffectResult(beforeSnapshot, captureActionEffectSnapshot(element), observedMutations)
1155
+ };
1156
+ }
1157
+
1158
+ async function typeSubmitLocalNode(payload = {}) {
1159
+ const typed = await typeLocalNode(payload);
1160
+ const submitted = await pressEnterLocalNode(payload);
1161
+ return {
1162
+ ...submitted,
1163
+ effect: {
1164
+ mutationAttributes: [...new Set([...(typed?.effect?.mutationAttributes || []), ...(submitted?.effect?.mutationAttributes || [])])],
1165
+ mutationCount: Number(typed?.effect?.mutationCount || 0) + Number(submitted?.effect?.mutationCount || 0)
1166
+ },
1167
+ status: {
1168
+ ...(typed?.status || {}),
1169
+ ...(submitted?.status || {}),
1170
+ reacted: Boolean(typed?.status?.reacted || submitted?.status?.reacted),
1171
+ noObservedEffect: !Boolean(typed?.status?.reacted || submitted?.status?.reacted)
1172
+ },
1173
+ value: typed.value
1174
+ };
1175
+ }
1176
+
1177
+ function scrollLocalNode(payload = {}) {
1178
+ const element = getElementByNodeId(payload?.nodeId, "scroll");
1179
+ const beforeSnapshot = captureActionEffectSnapshot(element);
1180
+ scrollElementIntoView(element);
1181
+ focusElement(element);
1182
+ const scrollEffect = buildActionEffectResult(beforeSnapshot, captureActionEffectSnapshot(element), {
1183
+ attributeNames: [],
1184
+ mutationCount: 0
1185
+ });
1186
+ return {
1187
+ ...collectActionResult(element),
1188
+ ...scrollEffect,
1189
+ status: {
1190
+ ...scrollEffect.status,
1191
+ reacted: true,
1192
+ noObservedEffect: false
1193
+ }
1194
+ };
1195
+ }
1196
+
1197
+ async function invokeLocalOperation(type, payload = {}) {
1198
+ if (type === "capture_document") {
1199
+ return captureDocument(payload);
1200
+ }
1201
+ if (type === "detail_node") {
1202
+ return detailLocalNode(payload);
1203
+ }
1204
+ if (type === "click_node") {
1205
+ return clickLocalNode(payload);
1206
+ }
1207
+ if (type === "type_node") {
1208
+ return typeLocalNode(payload);
1209
+ }
1210
+ if (type === "submit_node") {
1211
+ return submitLocalNode(payload);
1212
+ }
1213
+ if (type === "type_submit_node") {
1214
+ return typeSubmitLocalNode(payload);
1215
+ }
1216
+ if (type === "scroll_node") {
1217
+ return scrollLocalNode(payload);
1218
+ }
1219
+ throw createNamedError(
1220
+ "BrowserDomHelperActionError",
1221
+ `Browser DOM helper does not support "${type}".`,
1222
+ { code: "browser_dom_helper_action_unsupported", details: { type } }
1223
+ );
1224
+ }
1225
+
1226
+ async function routeOperation(type, payload = {}) {
1227
+ const frameChain = normalizeFrameChain(payload?.frameChain);
1228
+ if (!frameChain.length) {
1229
+ return invokeLocalOperation(type, payload);
1230
+ }
1231
+ if (frameChain[0] !== helperFrameId) {
1232
+ throw createNamedError(
1233
+ "BrowserDomHelperFrameRouteError",
1234
+ `Browser DOM helper cannot route frame chain "${encodeFrameChain(frameChain)}" from "${helperFrameId}".`,
1235
+ { code: "browser_dom_helper_frame_chain_mismatch", details: { frameChain } }
1236
+ );
1237
+ }
1238
+ if (frameChain.length === 1) {
1239
+ return invokeLocalOperation(type, payload);
1240
+ }
1241
+ const nextFrameId = frameChain[1];
1242
+ const childWindow = childFramesById.get(nextFrameId);
1243
+ if (!childWindow) {
1244
+ throw createNamedError(
1245
+ "BrowserDomHelperFrameRouteError",
1246
+ `Browser DOM helper does not know child frame "${nextFrameId}".`,
1247
+ { code: "browser_dom_helper_child_frame_missing", details: { frameChain } }
1248
+ );
1249
+ }
1250
+ return requestChildFrameOperation(childWindow, type, {
1251
+ ...payload,
1252
+ frameChain: frameChain.slice(1)
1253
+ });
1254
+ }
1255
+
1256
+ globalThis.addEventListener("message", (event) => {
1257
+ const rawMessage = event?.data;
1258
+ if (!rawMessage || rawMessage.channel !== DOM_HELPER_CHANNEL || typeof rawMessage.type !== "string") {
1259
+ return;
1260
+ }
1261
+ const requestId = typeof rawMessage.requestId === "string" ? rawMessage.requestId : "";
1262
+ if (!requestId) {
1263
+ return;
1264
+ }
1265
+ if (rawMessage.type.endsWith("_result")) {
1266
+ const pendingRequest = pendingRequests.get(requestId);
1267
+ if (!pendingRequest) {
1268
+ return;
1269
+ }
1270
+ pendingRequests.delete(requestId);
1271
+ if (pendingRequest.timer != null) {
1272
+ globalThis.clearTimeout(pendingRequest.timer);
1273
+ }
1274
+ if (rawMessage.ok === false) {
1275
+ pendingRequest.reject(createNamedError(
1276
+ "BrowserDomHelperRemoteError",
1277
+ String(rawMessage?.payload?.message || `Embedded frame request "${pendingRequest.type}" failed.`),
1278
+ {
1279
+ code: rawMessage?.payload?.code ?? "browser_dom_helper_remote_error",
1280
+ details: rawMessage?.payload?.details || {},
1281
+ payload: rawMessage.payload
1282
+ }
1283
+ ));
1284
+ return;
1285
+ }
1286
+ pendingRequest.resolve(rawMessage.payload);
1287
+ return;
1288
+ }
1289
+ if (typeof event?.source?.postMessage !== "function") {
1290
+ return;
1291
+ }
1292
+ Promise.resolve(routeOperation(rawMessage.type, rawMessage.payload || {}))
1293
+ .then((payload) => {
1294
+ event.source.postMessage({
1295
+ channel: DOM_HELPER_CHANNEL,
1296
+ ok: true,
1297
+ payload,
1298
+ requestId,
1299
+ type: `${rawMessage.type}_result`
1300
+ }, "*");
1301
+ })
1302
+ .catch((error) => {
1303
+ console.error(`[a0-browser/dom-helper] Request "${rawMessage.type}" failed.`, error);
1304
+ event.source.postMessage({
1305
+ channel: DOM_HELPER_CHANNEL,
1306
+ ok: false,
1307
+ payload: {
1308
+ code: error?.code ?? "browser_dom_helper_error",
1309
+ details: error?.details || {},
1310
+ message: String(error?.message || `Embedded frame request "${rawMessage.type}" failed.`)
1311
+ },
1312
+ requestId,
1313
+ type: `${rawMessage.type}_result`
1314
+ }, "*");
1315
+ });
1316
+ });
1317
+
1318
+ globalThis[DOM_HELPER_KEY] = {
1319
+ captureDocument(payload) {
1320
+ return captureDocument(payload);
1321
+ },
1322
+ clickNode(frameChain, nodeId) {
1323
+ return routeOperation("click_node", { frameChain, nodeId });
1324
+ },
1325
+ detailNode(frameChain, nodeId) {
1326
+ return routeOperation("detail_node", { frameChain, nodeId });
1327
+ },
1328
+ frameId: helperFrameId,
1329
+ scrollNode(frameChain, nodeId) {
1330
+ return routeOperation("scroll_node", { frameChain, nodeId });
1331
+ },
1332
+ submitNode(frameChain, nodeId) {
1333
+ return routeOperation("submit_node", { frameChain, nodeId });
1334
+ },
1335
+ typeNode(frameChain, nodeId, value) {
1336
+ return routeOperation("type_node", { frameChain, nodeId, value });
1337
+ },
1338
+ typeSubmitNode(frameChain, nodeId, value) {
1339
+ return routeOperation("type_submit_node", { frameChain, nodeId, value });
1340
+ },
1341
+ version: VERSION
1342
+ };
1343
+})();
plugins/_browser/helpers/connector_runtime.py
+45
-4
@@ -39,6 +39,7 @@ from plugins._browser.helpers.url import normalize_url
39
40
BROWSER_OP_EVENT = "connector_browser_op"
41
BROWSER_OP_TIMEOUT = 120.0
42
+DOM_HELPER_PATH = Path(__file__).resolve().parents[1] / "assets" / "browser-dom-helper.js"
43
CONTENT_HELPER_PATH = Path(__file__).resolve().parents[1] / "assets" / "browser-page-content.js"
44
MAX_ARTIFACT_SIZE_BYTES = 25 * 1024 * 1024
45
HOST_BROWSER_PRIVACY_POLICY_KEY = getattr(
@@ -287,7 +288,7 @@ class ConnectorBrowserRuntime:
288
)
289
sid = self._select_sid() or sid
290
290
- return await self._send_browser_op(sid, self._with_content_helper(sid, payload))
291
+ return await self._send_browser_op(sid, self._with_browser_helpers(sid, payload))
292
293
def _host_browser_profile_mode(self) -> str:
294
config = get_browser_config(self.agent)
@@ -295,11 +296,25 @@ class ConnectorBrowserRuntime:
296
return "agent" if mode == "agent" else "existing"
297
298
def _with_content_helper(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
299
+ return self._with_browser_helpers(sid, payload)
300
+
301
+ def _with_browser_helpers(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
302
metadata = host_browser_metadata_for_sid(sid) or {}
299
- if str(metadata.get("content_helper_sha256") or "").strip().lower() == _content_helper_sha256():
303
+ content_helper_current = (
304
+ str(metadata.get("content_helper_sha256") or "").strip().lower()
305
+ == _content_helper_sha256()
306
+ )
307
+ dom_helper_current = (
308
+ str(metadata.get("dom_helper_sha256") or "").strip().lower()
309
+ == _dom_helper_sha256()
310
+ )
311
+ if content_helper_current and dom_helper_current:
312
return payload
313
payload = dict(payload)
302
- payload["content_helper"] = _content_helper_payload()
314
+ if not dom_helper_current:
315
+ payload["dom_helper"] = _dom_helper_payload()
316
+ if not content_helper_current:
317
+ payload["content_helper"] = _content_helper_payload()
318
return payload
319
320
async def _send_browser_op(self, sid: str, payload: dict[str, Any]) -> Any:
@@ -521,6 +536,33 @@ def _content_helper_sha256() -> str:
536
return str(_content_helper_payload()["sha256"])
537
538
539
+@lru_cache(maxsize=1)
540
+def _dom_helper_payload() -> dict[str, Any]:
541
+ try:
542
+ source = DOM_HELPER_PATH.read_text(encoding="utf-8")
543
+ except OSError as exc:
544
+ raise RuntimeError(
545
+ f"Host-browser DOM helper could not be read from {DOM_HELPER_PATH}: {exc}"
546
+ ) from exc
547
+ return {
548
+ "required_apis": [
549
+ "captureDocument",
550
+ "clickNode",
551
+ "detailNode",
552
+ "scrollNode",
553
+ "submitNode",
554
+ "typeNode",
555
+ "typeSubmitNode",
556
+ ],
557
+ "source": source,
558
+ "sha256": hashlib.sha256(source.encode("utf-8")).hexdigest(),
559
+ }
560
+
561
+
562
+def _dom_helper_sha256() -> str:
563
+ return str(_dom_helper_payload()["sha256"])
564
+
565
+
566
def _content_helper_required_apis(source: str) -> list[str]:
567
match = _REQUIRED_API_NAMES_RE.search(source)
568
if not match:
@@ -561,4 +603,3 @@ def _api_base_is_local(api_base: str) -> bool:
603
parsed = urlparse(api_base if "://" in api_base else f"http://{api_base}")
604
hostname = (parsed.hostname or "").strip().lower()
605
return hostname in _LOCAL_HOSTS
564
-
plugins/_browser/helpers/runtime.py
+28
@@ -32,6 +32,7 @@ from plugins._browser.helpers.url import normalize_url
32
33
34
PLUGIN_DIR = Path(__file__).resolve().parents[1]
35
+DOM_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-dom-helper.js"
36
CONTENT_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-page-content.js"
37
RUNTIME_DATA_KEY = "_browser_runtime"
38
DEFAULT_VIEWPORT = {"width": 1024, "height": 768}
@@ -558,6 +559,7 @@ class _BrowserRuntimeCore:
559
self.screencasts: dict[str, _BrowserScreencast] = {}
560
self.next_browser_id = 1
561
self.last_interacted_browser_id: int | None = None
562
+ self._dom_helper_source: str | None = None
563
self._content_helper_source: str | None = None
564
self._start_lock: asyncio.Lock | None = None
565
self._registry_lock: asyncio.Lock | None = None
@@ -811,6 +813,7 @@ class _BrowserRuntimeCore:
813
self.context.set_default_navigation_timeout(30000)
814
self.context.on("close", self._on_context_closed)
815
self.context.on("page", self._on_new_page_sync)
816
+ await self.context.add_init_script(path=str(DOM_HELPER_PATH))
817
await self.context.add_init_script(path=str(CONTENT_HELPER_PATH))
818
819
for page in list(self.context.pages):
@@ -2325,6 +2328,7 @@ class _BrowserRuntimeCore:
2328
await self.stop_screencast(stream_id)
2329
2330
async def _ensure_content_helper(self, page: Any) -> None:
2331
+ await self._ensure_dom_helper(page)
2332
has_helper = await page.evaluate(
2333
"() => Boolean(globalThis.__spaceBrowserPageContent__?.ready?.())"
2334
)
@@ -2334,6 +2338,30 @@ class _BrowserRuntimeCore:
2338
self._content_helper_source = CONTENT_HELPER_PATH.read_text(encoding="utf-8")
2339
await page.evaluate(self._content_helper_source)
2340
2341
+ async def _ensure_dom_helper(self, page: Any) -> None:
2342
+ if self._dom_helper_source is None:
2343
+ self._dom_helper_source = DOM_HELPER_PATH.read_text(encoding="utf-8")
2344
+ await self._ensure_helper_source(
2345
+ page,
2346
+ self._dom_helper_source,
2347
+ "() => Boolean(globalThis.__spaceBrowserDomHelper__?.captureDocument)",
2348
+ )
2349
+
2350
+ async def _ensure_helper_source(self, page: Any, source: str, ready_script: str) -> None:
2351
+ targets = [page]
2352
+ frames = getattr(page, "frames", None)
2353
+ if isinstance(frames, list) and frames:
2354
+ targets = frames
2355
+ for target in targets:
2356
+ try:
2357
+ has_helper = await target.evaluate(ready_script)
2358
+ except Exception:
2359
+ continue
2360
+ if has_helper:
2361
+ continue
2362
+ with contextlib.suppress(Exception):
2363
+ await target.evaluate(source)
2364
+
2365
_runtimes: dict[str, BrowserRuntime] = {}
2366
_runtime_lock = threading.RLock()
2367
tests/test_browser_agent_regressions.py
+83
@@ -1,5 +1,6 @@
1
import asyncio
2
import json
3
+import re
4
import sys
5
import threading
6
from pathlib import Path
@@ -1226,7 +1227,13 @@ def test_browser_runtime_and_content_helper_expose_annotation_target():
1227
helper = (
1228
PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-page-content.js"
1229
).read_text(encoding="utf-8")
1230
+ dom_helper = (
1231
+ PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-dom-helper.js"
1232
+ ).read_text(encoding="utf-8")
1233
1234
+ assert "DOM_HELPER_PATH" in runtime
1235
+ assert "browser-dom-helper.js" in runtime
1236
+ assert "globalThis.__spaceBrowserDomHelper__?.captureDocument" in runtime
1237
assert "async def annotation_target" in runtime
1238
assert "globalThis.__spaceBrowserPageContent__.annotate(payload || null)" in runtime
1239
assert "function annotate(payload = null)" in helper
@@ -1238,6 +1245,11 @@ def test_browser_runtime_and_content_helper_expose_annotation_target():
1245
assert "fileInputFor," in helper
1246
assert "sanitizeAnnotationDom" in helper
1247
assert "password" in helper
1248
+ assert "__spaceBrowserDomHelper__" in dom_helper
1249
+ assert "captureDocument(payload)" in dom_helper
1250
+ assert "clickNode(frameChain, nodeId)" in dom_helper
1251
+ assert "requestChildFrameOperation" in dom_helper
1252
+ assert "data-space-browser-frame-chain" in dom_helper
1253
1254
1255
def test_browser_content_helper_keeps_label_wrapped_controls_referenceable():
@@ -1279,6 +1291,77 @@ def test_browser_runtime_requires_current_content_helper_for_modifier_clicks():
1291
assert "__spaceBrowserPageContent__?.ready?.()" in runtime
1292
1293
1294
+@pytest.mark.anyio
1295
+async def test_browser_dom_helper_clicks_content_ref_inside_iframe():
1296
+ pytest.importorskip("playwright.async_api")
1297
+ from playwright.async_api import async_playwright
1298
+
1299
+ browser_binary = get_playwright_binary()
1300
+ if not browser_binary:
1301
+ pytest.skip("Playwright Chromium binary is not installed")
1302
+
1303
+ dom_helper = (
1304
+ PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-dom-helper.js"
1305
+ ).read_text(encoding="utf-8")
1306
+ content_helper = (
1307
+ PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-page-content.js"
1308
+ ).read_text(encoding="utf-8")
1309
+
1310
+ async with async_playwright() as playwright:
1311
+ try:
1312
+ browser = await playwright.chromium.launch(
1313
+ executable_path=str(browser_binary),
1314
+ headless=True,
1315
+ args=["--no-sandbox"],
1316
+ )
1317
+ except Exception as exc:
1318
+ pytest.skip(f"Playwright Chromium could not launch: {exc}")
1319
+
1320
+ try:
1321
+ context = await browser.new_context()
1322
+ await context.add_init_script(dom_helper)
1323
+ await context.add_init_script(content_helper)
1324
+ page = await context.new_page()
1325
+ await page.set_content(
1326
+ """
1327
+ <html>
1328
+ <body>
1329
+ <iframe srcdoc="
1330
+ <html>
1331
+ <body>
1332
+ <button id='inside' onclick="document.body.dataset.clicked = 'yes'; this.textContent = 'Clicked inside frame'">
1333
+ Frame Launch
1334
+ </button>
1335
+ </body>
1336
+ </html>
1337
+ "></iframe>
1338
+ </body>
1339
+ </html>
1340
+ """
1341
+ )
1342
+ await page.wait_for_function(
1343
+ "() => Boolean(document.querySelector('iframe')?.contentWindow?.__spaceBrowserDomHelper__)"
1344
+ )
1345
+
1346
+ captured = await page.evaluate(
1347
+ "(payload) => globalThis.__spaceBrowserPageContent__.capture(payload || null)",
1348
+ None,
1349
+ )
1350
+ document_content = str(captured.get("document") or "")
1351
+ match = re.search(r"\[button (\d+)\]\s*Frame Launch", document_content)
1352
+ assert match, document_content
1353
+
1354
+ action = await page.evaluate(
1355
+ "(ref) => globalThis.__spaceBrowserPageContent__.click(ref)",
1356
+ match.group(1),
1357
+ )
1358
+ frame = next(frame for frame in page.frames if frame != page.main_frame)
1359
+ assert await frame.evaluate("() => document.body.dataset.clicked") == "yes"
1360
+ assert action["status"]["reacted"] is True
1361
+ finally:
1362
+ await browser.close()
1363
+
1364
+
1365
@pytest.mark.anyio
1366
async def test_browser_screencast_acknowledges_and_drops_stale_frames():
1367
first_image = SMALL_JPEG_10X10
tests/test_host_browser_connector.py
+3
@@ -448,6 +448,9 @@ def test_connector_runtime_ensures_preparable_host_browser_before_action(monkeyp
448
assert result == {"id": 1, "state": {"runtime": "host"}}
449
assert [payload["action"] for payload in emitted] == ["ensure", "open"]
450
assert [payload["profile_mode"] for payload in emitted] == ["existing", "existing"]
451
+ assert "__spaceBrowserDomHelper__" in emitted[0]["dom_helper"]["source"]
452
+ assert "captureDocument" in emitted[0]["dom_helper"]["required_apis"]
453
+ assert emitted[0]["dom_helper"]["sha256"]
454
assert "__spaceBrowserPageContent__" in emitted[0]["content_helper"]["source"]
455
assert "capture" in emitted[0]["content_helper"]["required_apis"]
456
assert emitted[0]["content_helper"]["sha256"]