[Bug]: `browser frames` misses out-of-process iframes: enumeration and target resolution both use paths that cannot see them
- Dominant language
- JavaScript
- Stars
- 29.3k
- Forks
- 2.9k
- Avg merge
- 15h 36m
- Merged PRs (30d)
- 70
Description
### Description
The execution-context path added in #1084 is present, and the current direct-target path can execute Runtime/Accessibility commands once it is given an exact OOPIF target id. Two discovery steps currently prevent reaching it:
- `browser frames` enumerates through the parent page target's `Page.getFrameTree`, which is LocalFrame-only
- `resolveFrameTargetId` queries the CDP `Target.getTargets` command, which is forbidden for extension clients, and turns the denial into `Candidates: none`
So for the case the direct-target routing was built for, it is unreachable. I think this also explains the target-discoverability problem recorded in #1446, and that problem looks solvable. Details below.
### Steps to Reproduce
Save as `oopif.html` and serve it over HTTP. (`file://` is not a useful OpenCLI repro because Browser Bridge does not treat file tabs as debuggable.)
```html
OOPIF repro
```
```
opencli browser probe open "http://localhost:8000/oopif.html"
opencli browser probe frames
# => []
opencli browser probe eval "location.href" --frame 0
# => Frame index 0 out of range (0 cross-origin frames available)
```
On desktop Chrome with Site Isolation enabled (the default), a successfully loaded cross-site document is normally placed in an OOPIF. I confirmed this exact page on Chrome 150 **without** `--site-per-process`: the parent's `Page.getFrameTree` has only the root, while the DOM owner reports `frameId=EA1D…`, `chrome.debugger.getTargets()` returns the same id with `type:"other"`, and `chrome.debugger.attach({targetId})` + `Runtime.evaluate` reads `https://example.com/`.
An ordinary cross-origin but same-site iframe (`a.test` → `b.a.test`) normally does *not* reproduce this under default site isolation — which matters for §2. (Opaque-origin sandboxes and explicit origin-isolation flags can make same-site frames out-of-process too.)
For a CI regression test I'd avoid depending on `example.com` and instead map two different registrable domains (e.g. `a.opencli.test` and `b.opencli-other.test`) to localhost via `--host-resolver-rules`.
### Expected Behavior
`browser frames` lists the out-of-process iframe, and `eval --frame 0` evaluates inside it. Concretely, for the page above I would expect one entry whose `url` is `https://example.com/`, and `eval "location.href" --frame 0` to print that URL.
### OpenCLI Version
1.8.6 (extension 1.0.22)
### Node.js Version
22.x
### Operating System
macOS
### Logs / Screenshots
```shell
$ opencli browser probe frames
[]
$ opencli browser probe eval "location.href" --frame 0
✖ Frame index 0 out of range (0 cross-origin frames available)
```
---
## Analysis
## 1. Root cause 1 — the parent's `Page.getFrameTree` is LocalFrame-only
`InspectorPageAgent` builds the tree from `LocalFrame*` and skips children it cannot `DynamicTo`. A genuine OOPIF never appears in the **parent page target's** `childFrames`; from that renderer it is a `RemoteFrame`. (Addressed through its own target it is, of course, that target's local root frame.) Still the case on `main` today.
Measured on controlled pages served with `--site-per-process`:
| frame | in parent's `Page.getFrameTree` | in DOM (`pierce:true`) |
|---|---|---|
| same-site, different origin | yes | yes |
| cross-site (OOPIF) | **no** | yes |
| `sandbox` opaque origin | **no** | yes |
| sandboxed `srcdoc` | **no** | yes |
So the accurate statement is not "cross-origin frames are missing" but "RemoteFrames are missing". SOP-blocked cross-origin frames that remain same-process *do* appear, which is what makes this easy to mis-test.
## 2. Why the #1446 smoke test didn't catch it
The smoke test uses `a.opencli.test` and `b.opencli.test` as its cross-origin pair. Under Site Isolation a *site* is scheme + registrable domain — subdomains and ports are ignored — so both are `http://opencli.test` and normally share a renderer. No OOPIF is created.
The test also asserts the cross-origin frame *is* present in `Page.getFrameTree`, which is itself evidence the frame was same-process. The checked-in smoke test underlying #1446 therefore exercised no OOPIF, so it could not establish whether an actual OOPIF target was discoverable. I don't have the May 2026 CI logs and can't rule out additional unpublished probes.
I'm not raising this to relitigate the issue — only because it's the reason the limitation looks broader than it is.
## 3. Root cause 2 — the resolver uses a forbidden command
For a normal extension client the `TargetHandler` runs in `kAutoAttachOnly`. `Target.getTargets`, `Target.setDiscoverTargets` and `Target.attachToTarget` are all rejected with `{"code":-32000,"message":"Not allowed"}`, regardless of any prior `setAutoAttach`. `resolveFrameTargetId` still calls `Target.getTargets` and maps the rejection to `null`, which surfaces as "no candidates" rather than an error.
The extension API works where the CDP command does not:
- `chrome.debugger.getTargets()` **does** list OOPIFs, as `type: "other"`. `SerializeTarget` maps anything that isn't page/background/worker to `other`, so a filter on `type === 'iframe'` discards all of them — that enum value doesn't exist. (Inner frames of the OOPIF PDF viewer are deliberately hidden; that exception is real.)
- In current Chromium, for ordinary RenderFrame OOPIF targets, the DOM owner's `frameId` equals that target's id — both derive from the same DevTools frame token. This is an implementation property, not a general one-target-per-frame protocol guarantee.
- `chrome.debugger.attach({targetId})` + `Runtime.evaluate` then reads the frame. `Accessibility.enable` / `getFullAXTree` also succeed on that direct target, and on flat auto-attach child sessions, on Chrome 150 for the ordinary HTML OOPIFs tested here. I can't say what the 2026-05 CI binary did; only that it works now.
**Resolution should match `candidate.id === frameId` exactly, and should not fall back by URL.** `chrome.debugger.getTargets()` is profile-global, and multiple tabs or frames can legitimately expose the same URL — a URL fallback can hand back a frame in a different tab, or a worker.
So the acceptance scope for a fix is three things, not one: the public enumeration source, exact-id resolution via `chrome.debugger.getTargets()`, and direct attach.
One correction to a plausible-looking approach: CDP's `contentDocument` is **not** a usable cross-origin test. `InspectorDOMAgent` serializes the local frame owner's `HTMLFrameOwnerElement::contentDocument()` without applying the page's same-origin access check, so CDP may report a `contentDocument` while page JS sees `iframe.contentDocument === null`; attempts to read restricted properties such as `iframe.contentWindow.location.href` or `iframe.contentWindow.document` throw `SecurityError`. Origin comparison is needed, and `about:blank` / `srcdoc` origin inheritance and opaque sandbox origins each need handling.
## 4. A separate DOM-order enumeration does not preserve snapshot index parity
The documented contract is clear — `frames` is "in snapshot order", `eval --frame` takes "index from `browser frames`", and a test is named after "the same order exposed by snapshot `[F#]` markers". #1084's first commit flattened all child frames; `fix(browser): align cross-origin iframe routing contract` deliberately moved it to the same-origin-inline / cross-origin-slot namespace, and `fix(browser): unify iframe frame-index routing` put `frames` and `eval` on one enumerator. The intent is settled: `--frame N` addresses what the snapshot labelled.
Enumerating independently from the DOM silently leaves that namespace. The snapshot walk also skips ad elements, stops once it has expanded `MAX_IFRAMES` accessible same-origin iframes, and prunes subtrees whose ancestor is outside the expanded viewport. Two divergences I measured:
- an OOPIF with `id="ad_banner"` is cross-origin DOM-order index 0, but the snapshot excludes it and labels the *next* OOPIF `[F0]`
- with five accessible same-origin iframes expanded ahead of it, an OOPIF is cross-origin DOM-order index 0 while the snapshot prints no `[F#]` label at all
Today `--frame` is only wired to `eval`, so the consequence is that the expression runs in the wrong frame — and JS mutations performed through `eval` can affect the wrong document. If `--frame` is ever extended to `click`/`fill`, the same mismatch becomes a wrong-target write.
The snapshot index also can't be treated as a stable frame property or safely recomputed later without binding to the original snapshot state. With the same page and the same `viewportExpand: 2000`, an iframe whose ancestor sits at document top 4500px gets no `[F#]` at `scrollY=0` and becomes `[F0]` at `scrollY=4000`. So `[F#]` is a function of `(parent frame, snapshot generation, layout, scroll, viewport, snapshot options)`. Layout changes such as lazy loading can therefore produce the same mismatch without any explicit scrolling.
That points at binding the index to the snapshot that produced it: have the snapshot tag the iframes it labels (`data-opencli-frame-ref=N`, mirroring `data-opencli-ref`), return the tagged element as a Runtime remote object, and call `DOM.describeNode({objectId})` to get the owner's `frameId`. `describeNode` explicitly does not require a prior `DOM.getDocument` or a DOM-domain enable. Note that the current `evaluate` uses `returnByValue: true` and so cannot return an objectId as-is — this needs a dedicated internal command or a raw `sendDebuggerCommand`.
Three things that design has to get right:
- **The generation must be part of the returned handle**, or otherwise supplied back by the caller. Keeping only "the latest snapshot mapping" is racy: another public or internal snapshot in the same session can replace it before `--frame` is used, and command handlers aren't serialized per session. `annotatedScreenshot()` internally runs `snapshot({source:'dom', viewportExpand: 0})`, so an internal snapshot can overwrite a mapping the user never saw.
- **Invalidate on navigation, frame-owner replacement, and target detach/process swap** — and do not silently reidentify a stale frame by URL. Even when the frameId survives, the document state the caller read is stale.
- **A nested OOPIF's `[F#]` is a namespace local to its parent**, so a flat integer cannot express `0/0`.
Separately: restoring parity means frames dropped by the ad/viewport/`MAX_IFRAMES` filters have no numeric address at all — they become unreachable through the public `--frame` addressing path. Whether to expose a second namespace for them (`frames --all` plus an exact `--frame-id` / handle selector) is a product call I have no standing to make.
## 4a. Why this went unnoticed
The tests that assert frame-index ordering mock `Page.getFrameTree` and never execute `generateSnapshotJs` on the same fixture. The DOM snapshot unit test checks the generated source and options but has no `[F#]` output assertion. So the ad, viewport, `MAX_IFRAMES`, and DOM-mutation cases aren't covered by a contract test.
## 5. One more thing to avoid
`DOM.getDocument` invalidates every previously issued frontend `nodeId` in that target/session — `InspectorDOMAgent` calls `DiscardFrontendBindings()` at the start of each call. Enumerating via `getDocument` on every `frames` / `eval --frame` will break any concurrent consumer holding node ids in that session. The `describeNode` route above avoids it.
`depth:-1, pierce:true` also serializes a much larger structure than `Page.getFrameTree` and introduces a clear performance risk on large pages; I have not benchmarked the delta.
Happy to send a PRonce the public addressing contract is agreed. Discovering an owner `frameId` and attaching by exact target id are mechanical; preserving snapshot-bound indices, stale detection, nested namespaces, and access to filtered frames is the design-sensitive part.
Contributor guide
Research direction
Start by tracing the browser frames enumerator, resolveFrameTargetId, and the eval --frame path, then inspect the mocked frame-index tests and the generateSnapshotJs DOM snapshot test. Reproduce the OOPIF case with the host-resolver setup described in the issue. Done requires agreed snapshot-bound indexing, exact target resolution and direct attachment, with tests covering ordering, filtering, stale handles, and nested OOPIFs.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- cli, devtools
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100