Shadow DOM & iframes โ what Hyperbolic Gateway can see and click
Two different problems that get confused because both "hide" content:
- Shadow DOM is a same-document boundary. Open roots are readable and clickable; closed roots are not, by design.
- Iframes are separate documents (often cross-origin). The top frame cannot read them with
executeJavaScript; frames need their own execution context.
| surface | open shadow DOM | closed shadow DOM | iframe (same-site and cross-site) |
|---|---|---|---|
observe(markdown) |
โ composed DOM | โ | โ main frame only |
Set-of-Marks / find |
โ marks inside roots | โ
find(sealed:true) (coordinates, no mark id) |
โ
find(sealed:true) + frames |
act(mark_id / selector) |
โ resolves the inner control | โ (sealed results carry coordinates) | โ
100+ frame mark ids (find(include_frames:true)) |
act(target_text) |
โ (composed text) | โ | โ |
act(x, y) |
โ | โ (pixels are pixels) | โ |
frames โ text |
โ controls in frames | โ
via find(sealed:true) |
โ frame as content |
frames โ screenshots |
โ | โ (pixels are pixels) | โ |
focus_walk (Tab) |
โ | โ trusted entry + AX confirmation | โ |
| component signature | โ find/observe (host surface) | โ sealed results | โ frame results |
evaluate |
โ | โ | โ |
evaluate {frames:true} |
โ per frame | โ when the demonstrated effect is inside the root | โ |
A control sealed inside a closed root or an iframe is still addressable by pixels. frames
computes iframe pixels; find(sealed:true) computes closed-root pixels through the accessibility
tree. Everything else is main-document only, on purpose.
Shadow DOM
Discovery
The marks scanner does not use document.querySelectorAll on its own. It collects roots first:
const collectRoots = (root) => {
roots.push(root);
for (const el of root.querySelectorAll('*')) {
if (el.shadowRoot) collectRoots(el.shadowRoot); // open roots only
}
};
Every interactive selector then runs in each root, so a <button> inside a web component is a
first-class candidate with its own mark id (src/main/marks/marks-overlay.ts).
Resolution
kqDeep(selector) searches the document and every open root. When a selector matches a
custom-element host, targetBoxScript resolves it to the control a mouse would actually hit โ
the inner <input>, not the wrapper.
This matters for typing: focusing a host either moves focus off the inner input or does nothing, and
the keystrokes then go nowhere. That failure is documented in
FIELD_NOTES_2026-10-20_SIGNUP.md; act now focuses the
resolved control and reports what actually received focus.
Hit-testing
document.elementFromPoint() returns the shadow host, never the inner element. A naive occlusion
check therefore marks every shadow control as "covered" and drops it. kqAtPoint() walks down through
open roots to the deepest hit, and containment checks use kqContainsComposed() โ because
el.contains() does not cross a shadow boundary.
Reading
observe(as:"markdown") renders the composed DOM: open roots, live form values, passwords as a
length only. The envelope reports shadow_roots so you can tell how much of the page came from
components.
POST /dom/facts {deep: true} (and the frames tool) use the same root walk, so a web component's
inner control appears in control facts and not just in marks. Deep mode also fixes the two things
that break inside a root: hit-testing descends via shadowRoot.elementFromPoint, and containment
uses a composed walk instead of el.contains().
Closed roots (1.4.91)
find {sealed:true} adds a sealed array to the normal result. It contains closed-root controls
with a role, name, rect and actions โ no mark id and no input value:
// POST /find {"sealed": true, "contains": "Pay"}
{
"marks": [ /* open/light DOM refs as before */ ],
"sealed": [{
"role": "button",
"name": "Sealed Pay",
"rect": { "x": 18, "y": 8, "width": 89, "height": 21 },
"actions": ["click"],
"source": "ax",
"frameId": "โฆ",
"frameUrl": "https://pay.example/checkout",
"inViewport": true
}],
"sealedTotal": 1,
"sealedCached": false
}
How it stays honest:
- Discovery is not page JavaScript.
Accessibility.getFullAXTreenames the controls;DOM.getDocument({pierce:true})(andDOM.describeNodeon each frame owner) proves they live under ashadowRootType:"closed"root;DOM.getBoxModelmeasures them. The page's ownshadowRootprobe still returnsnull. - Per frame. Same-process children use
getFullAXTree({frameId}); cross-site children use their own target session, and the target root's top-level rect composes the coordinates. - Hidden semantics come from AX for free.
display:none,visibility:hiddenandaria-hiddencontrols are absent. Zero-size controls are dropped; offscreen ones are returned withinViewport:falseand a short-livedtoken(st_โฆ) accepted byact(sealed_token), which scrolls the real node into view and re-measures it before clicking. - Quiet by default. Nothing scans on navigation, observe, plain
find, screenshot or idle. The first call costs one AX tree + one pierce per frame set; identities are cached by document revision for 5 seconds, and a repeat call only re-measures the matches. Six full scans per minute per tab is the ceiling (429 SEALED_RATE_LIMITEDwithretry_after). The AX domain is enabled for the scan and disabled again immediately. - Values never leave. AX exposes field values (
value:"hunter2"was observed in the probe); the result deliberately omits them. - Acting is coordinate-based.
act(x, y)clicks,act(x, y, action:"type")focuses and types, andclear_firstuses trusted Ctrl+A + Delete โ which works inside a closed root where page JS cannot clear the field. The typing verdict reportsunobservablerather than falsely claiming the value did not change.
Entering a closed root (focus_walk)
focus_walk presses real Tab/Shift+Tab keys from a light-DOM host and reads the accessibility
tree after each step to see which inner node actually has focus:
// POST /focus_walk {"selector": "#checkout-host", "max_steps": 6}
{
"started": true,
"landed": true,
"reason": "left-host",
"steps": [
{ "index": 1, "role": "button", "name": "Pay now", "rect": { "x": 18, "y": 8, "width": 89, "height": 21 }, "gapMs": 213 },
{ "index": 2, "role": "textbox", "name": "Card number", "rect": { โฆ }, "gapMs": 198 }
],
"medianStepGapMs": 205,
"restored": false
}
document.activeElementretargets to the host for a closed root, so AX is the only honest focus oracle.RootWebAreaalso reportsfocused:true; it is filtered out.- Starting focus uses
DOM.focuswith a page-sidefocus()fallback (needed by somedelegatesFocushosts). - A
delegatesFocushost is already on its inner control when focused; that appears asindex: 0. - Stops on
left-host,max-steps,no-focus, ornot-focusable. - Human cadence: one key event per step, a 90-140 ms dwell, median reported as
medianStepGapMs. - On failure, focus returns to the host (
restored:true). - To activate the landed control, call
act(action:"press", text:"Enter").
Component signatures (1.4.94)
A mark from find/observe(as:"components") carries the nearest custom-element host's exported
surface. The same surface is attached to sealed controls (read from the light-DOM host through CDP)
and to frames/frame-mark controls (read in the frame):
{
"tag": "pay-widget",
"defined": true,
"part": ["card"],
"exportparts": ["button:pay-button", "label:pay-label"],
"cssVars": ["--brand", "--radius"],
"signature": "pay-widget:3640e0c6"
}
part, exportparts and the custom-property names are sorted and normalised before the hash, so
attribute order and re-renders do not change the signature. A renamed part or a different exported
surface does.
Recipes store the signature on a step. Replay verifies it before acting:
- unchanged surface โ the step proceeds
- changed surface โ
409 SIGNATURE_MISMATCH, and the recipe is retired immediately instead of burning three attempts componentSurfaceoff in Settings โ409 SIGNATURE_UNAVAILABLE(not a silent click)
Limits: signatures are read from the host, so they cover page-visible hosts. Sealed and iframe
controls carry the same surface. partHighlight and cdpStyleMatch are described below.
Limits
- Closed roots are invisible to page JavaScript.
element.shadowRootisnullforattachShadow({mode:'closed'}). Discovery is CDP-only, andobserve/evaluatestill cannot read the root's text or values. - Shadow content is not flattened into
observe(as:"text")regions the way it is for markdown. find(sealed:true)returns coordinates, not mark ids: an id would imply a DOM handle that does not exist. Re-runfindafter a layout change; offscreen controls get a short-lived token thatact(sealed_token)can scroll and re-measure.- CSS
selectorfiltering is not available for sealed controls (there is no page-side selector engine that can see them); usename,role,tagorcontains.
Iframes
The boundary
webContents.executeJavaScript runs in the top frame only. An iframe is a separate document with
its own globals; a cross-origin one cannot be reached from the parent at all.
evaluateInFrames
evaluateInFrames(wc, expression, { maxFrames?, timeoutMs?, includeBlank? })
-> Array<{ frameId, url, name, ok, value?, error? }>
Walks the CDP frame tree and evaluates the expression in each frame's own isolated world
(src/main/browser/frame-eval.ts). Bounds: 8 frames by default (max 32), 1500 ms per command,
isolated-world contexts cached and invalidated on did-start-loading, blank frames skipped, failures
reported per frame instead of thrown.
Measured: ~20 ms of frame traversal for a page with one child.
The frame as content
Knowing that an iframe exists is not the same as being able to do anything with it. POST /frames
(MCP frames) answers the three questions a blank box cannot:
- Where is it? โ content-box rect in top-level viewport pixels.
- What is inside it? โ its controls, described by the same control-facts expression as marks, with their rects already offset into the page.
- What does it look like? โ optionally, a cropped screenshot per visible frame.
// POST /frames {"include_controls": true, "screenshots": true}
{
"viewport": { "width": 1280, "height": 601 },
"frames": [
{ "depth": 0, "url": "https://shop.example/checkout", "rect": { "x": 0, "y": 0, "width": 1280, "height": 601 } },
{
"depth": 1,
"url": "https://pay.example/frame",
"crossOrigin": true,
"rect": { "x": 323, "y": 142, "width": 400, "height": 200 },
"controls": [
{ "tag": "button", "name": "Pay now", "actions": ["click", "hover"],
"rect": { "x": 331, "y": 222, "width": 89, "height": 21 }, "visible": true, "occluded": false }
]
}
],
"images": [{ "url": "https://pay.example/frame", "width": 600, "height": 300, "mimeType": "image/jpeg" }]
}
The control's rect is a page coordinate, not a frame coordinate: act(x: 376, y: 233) clicks it,
and that is verified in the live test โ the child document records the click.
How the geometry is derived. CDP gives the frame tree and each frame's owner element
(DOM.getFrameOwner). The owner is resolved in its parent's document and measured with
getBoundingClientRect(); the parent's own origin is added. That is why it works cross-origin: the
iframe is unreachable, but the <iframe> element belongs to the parent, which is not. Because
parents come before children in the tree, nested frames compose instead of falling back to
"in-frame activation".
Cross-site frames (1.4.90). Chromium omits an out-of-process iframe (a different site, not
merely a different origin) from the parent's Page.getFrameTree; it appears only as an iframe
target. src/main/browser/frame-tree.ts attaches those targets
(Target.attachToTarget({flatten:true})), reads each target's own frame tree, keeps only targets
whose parent is a frame in this tab, and detaches the sessions in finally. FrameInfo.oopif
marks the frame and targetId names the target; evaluation creates its isolated world on that
session. Geometry is still measured through the parent's owner element, so the top-level rect is
identical to a same-site frame. The live suite serves the child from 127.0.0.2 while the page
stays on 127.0.0.1, keeping the OOPIF path covered.
How the screenshots are made. One viewport capture, then nativeImage.crop() per visible frame
(clamped to the viewport). The device-pixel ratio is derived from the captured image rather than
assumed, so a 1.5ร display yields a 600ร300 crop of a 400ร200 frame. One capture for N frames is
cheaper than N CDP clips, and it avoids the page-vs-viewport coordinate ambiguity in
Page.captureScreenshot.
Cost. Opt-in, four CDP commands per frame plus one evaluate. screenshots adds one capture and
one crop per visible frame. Both are bounded: 6 frames by default, 16 max.
What uses it
Consent is the first consumer, in stages:
- top-document scan (one evaluate โ the common case)
- only if that finds nothing and the page embeds an
<iframe>, the same scan runs in each frame - the match is clicked at top-level coordinates (iframe rect + local centre), so it remains a trusted CDP click
- if the offset cannot be derived (nested frame, dynamic
src), the control is activated in-frame and the result saysdelivery: "in-frame"rather than pretending
The isolated-world gotcha
An isolated world shares the DOM but not page globals. window.__anything set by the page is
invisible inside it; attributes, text and computed styles are readable. Tests and features must
assert on DOM state, not on page variables โ this cost a debugging round during Phase 3.
Frame control marks (1.4.97)
find {include_frames:true} stamps and returns child-frame controls with 100+ mark ids
(100 + child_index * 100, 99 ids per frame). act(mark_id) and type_mark resolve them live:
the element is found by data-mark-id inside its frame, scrolled into view, measured, and the
frame's top-level rect is added before the humanized click.
- Same-site and cross-site (OOPIF) frames both use
evaluateInFrames. framesexposes the sameidon each control.observe(as:"som"|"components", include_frames:true)merges the same marks/components; the decision loop can now see iframe affordances alongside page ones.extract(include_frames:true)runs the same extraction per child frame. The main result is unchanged;frames[]holds per-frame{frameId,url,ok,value|error}, andtable/tablesaddframeRecords[]tagged withframeId/frameUrl.- Main-frame marks remain 1โ99; the blocks never collide.
findwithoutinclude_framesis unchanged (main-frame only).
Visual / style provenance (1.4.101)
partHighlight(VISIBLE, off) collectspart/exportpartsfrom open shadow hosts, injectstag::part(name){outline:โฆ}only for the duration of one screenshot, then removes the style. Closed roots are not enumerable by page JS; force-open or sealed discovery is the separate path.cdpStyleMatch(COST, off) capturesCSS.styleSheetAddedheaders and runsCSS.getMatchedStylesForNodefor sealed controls. Each result may carrystyle: {sourceUrl, constructed}; bundled apps often report no URL, so the fields are optional.
Limits
observe/extractare main-frame only.findis main-frame by default; passinclude_frames:truefor 100+ mark ids, andact(mark_id)resolves those frame ids live.- An iframe inside a closed shadow root has no reachable owner element, so it gets no geometry and no separate crop. It still appears in a full-page screenshot as pixels.
- Geometry is origin + offset. A CSS transform on the iframe (scale, rotate, 3D) invalidates that assumption; the reported rect is the axis-aligned box, and clicks land on the un-transformed offset.
clear_firston a coordinate type cannot see inside the frame (the active element is read from the top document), so it is a no-op there. Select-all + delete withpressis the workaround.
What to do today
| need | approach |
|---|---|
| read text inside an iframe | POST /evaluate {frames:true} |
| see what an iframe looks like | frames {screenshots:true} โ one image per frame |
| list and click a control inside an iframe | frames โ act(x, y) at the reported control centre |
| type into an iframe control | act(x, y, action:"type", text:โฆ) โ the click focuses the frame's field, then keys go there |
| answer a consent wall in an iframe | dismiss_consent โ it does the offset math |
| automate a whole flow inside an iframe | frames + coordinate acts; marks are main-frame only |
What remains
Remaining work is the Clone Lab proposal (TODO.md item 18), parked by decision โ
the read/observe/extract/target paths now cover main documents, open/closed roots, same-site frames
and OOPIFs.
Force-open is the one detectably risky path: off by default, opt-in per run, confirmed in Settings,
and called out by a red chip. See SHADOW_WALK.md.
Coordinates stay the contract for sealed content: an id would imply a DOM handle that does not exist.
The delivery plan, the settings levers, and the risk tiers live in
SHADOW_WALK.md.
Tests
npm run test:tabwalk is the keyboard fixture:
- a closed root with two buttons and an input; trusted Tab enters it and AX confirms each inner control
- stops when focus leaves the host (
left-host) or atmax_steps - Enter activates the landed button
- Shift+Tab goes backward; a failed walk restores focus to the host
- a
delegatesFocushost reports the inner control as the initial step - median step gap โฅ 80ms (a keyboard user, not a burst)
npm run test:shadow (live) and npm run test:shadow:unit cover the settings gate: defaults,
clamping, corrupt-file fallback, the KUANG_SHADOW_WALK=off kill switch, every structured refusal,
the master switch, output suppression and cap enforcement.
npm run test:dom covers the general control/frame surface:
- a clickable
divwith an inline handler and no role - occlusion (covered button), disabled control, hidden input
act(target_text)on a semantics-free element, and the ambiguity refusal- cross-site iframe: consent answered with
frame: "child",delivery: "cdp", and the child's DOM confirms the click landed - frame evaluation reads a cross-site (OOPIF) child, reports per-frame errors, skips blank frames
- frame geometry:
framesreports the child's top-level rect, describes its button with offset coordinates, and clicking that centre is confirmed by the child document - frame screenshot: a 400ร200 frame becomes a 600ร300 crop at 1.5ร (aspect 2.0), not a whole-page image
npm run test:sealed is the closed-root fixture:
- closed root with a button, text input, and a nested closed root
display:noneandaria-hiddencontrols are absent from AX- page JS still reports
shadowRoot === null - clicking by the returned coordinate actuates the component; typing with
clear_firstreplaces the value; the result never contains it - a same-site closed frame and a cross-site (OOPIF) closed frame are both found and clicked
- quiet-by-default assertion (
cdp.ax === 0until asked), identity-cache hit, and a real429after the per-tab scan budget
Settings gate
The Shadow Walk levers are enforced before work starts, not after:
| surface | disabled result |
|---|---|
find {sealed:true} |
400 SEALED_DISABLED (or SHADOW_WALK_DISABLED when the master is off) |
focus_walk |
400 TAB_WALK_DISABLED |
/frames, frame geometry |
400 FRAMES_DISABLED |
/dom/facts {deep:true} |
400 DEEP_OPEN_ROOTS_DISABLED |
marks / find / observe |
deepOpenRoots:false keeps them in the light DOM (no error) |
The panel lives in Settings โ SHADOW WALK; writes go over IPC to
data/shadow-walk.json. /health returns a shadowWalk block with the active policy, so an agent
can check before asking. Turning frame controls or screenshots off returns frames without those
fields and a notes line, rather than silently ignoring the request.
See also
- CONTROL_LAYER.md โ targeting, consent policy, takeover, pacing
- SHADOW_WALK.md โ the plan for closed roots and the Shadow Walk settings levers
src/main/input/README.mdโ human takeoversrc/main/browser/README.mdโ frame evaluation API and bounds