jackwener / jackwener/OpenCLI

[Bug]: `browser frames` misses out-of-process iframes: enumeration and target resolution both use paths that cannot see them

Open
#2,223 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.