cdp-toolkit 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to cdp-toolkit are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.7.0] - 2026-09-15
9
+
10
+ **Cross-origin iframes are now discoverable and addressable.** A visible out-of-process iframe used to be a silent hole: `take_snapshot` on the parent page stopped at a bare `Iframe` line with no hint that an entire interactable document lived behind it, `list_pages` hid the iframe's target by default, and the only way to drive it was to know that a raw target id from `list_pages {all:true}` happened to work. Now the snapshot marks the boundary, the docs say the target exists, and a `frame:` selector addresses it by URL. Verified live against Chrome 153 with a real cross-origin iframe: marker present in both snapshot modes, `evaluate_script`/`take_snapshot` drive the frame through both its target id and the new selector, and two iframes with the same URL produce an ambiguity error naming both ids instead of a silent first-match.
11
+
12
+ ### Added
13
+
14
+ - **OOPIF markers in `take_snapshot`.** When an emitted `<iframe>` node owns an out-of-process (cross-origin) frame, its snapshot line now carries the frame's identity: `[4821] Iframe "Checkout" [frame="F405BF…" frameUrl="https://example.com/"]`. `frame` is the frame id, which IS the iframe's CDP target id — feed it straight back as `target` to any interaction tool. The marker appears in `interactiveOnly` mode too (an iframe at a process boundary is itself the interactable thing), while same-origin iframes are unchanged: their AX subtrees were already merged into the parent tree. Detection drives off the browser's target list correlated through `DOM.getFrameOwner` — deliberately not `Page.getFrameTree`, which omits out-of-process child frames on a page-level attachment (observed Chrome 153) — and is failure-soft end to end: any error in detection degrades to the pre-marker snapshot, never a failed call. (`src/cdp/driver.ts`, `test/iframe-markers.test.ts`)
15
+ - **`frame:<url-substring>` target selector (Chrome).** Resolves against the browser's out-of-process iframe targets by URL substring, so an iframe is addressable without first correlating raw target ids out of `list_pages {all:true}`. Follows the `worker:` arm's rules: an empty needle matches nothing, more than one match is an ambiguity error naming every id+url (never a silent first-match), and a miss lists the live iframes and teaches that only out-of-process iframes appear as targets. Accepted by every tool whose `target` flows through the shared resolver (evaluate_script, take_snapshot, click, screenshot, …); `close_page`/`select_page`/`release_page`/`claim_page` refuse it with a page-only message, and Firefox refuses it with `unsupported` naming the `frame.targets` capability, same as `worker:`. A bare iframe target id still resolves everywhere it did before. (`src/frames.ts`, `src/client.ts`, `src/shared-tools.ts`, `src/bidi/driver.ts`, `test/frame-selector.test.ts`)
16
+
17
+ ### Documented
18
+
19
+ - `list_pages {all:true}` now says it lists out-of-process iframes (not just workers/background pages) and that a returned non-page targetId is accepted as a bare id by any tool's `target` param — in the tool's deep doc, the manifest, the MCP server instructions, the README tools table and target-grammar bullet, the CLI help grammar, and the CONTRACT grammar rule. The snapshot doc names the cross-origin boundary and the marker. (`src/toolDocs.ts`, `src/mcp.ts`, `src/manifest.ts`, `README.md`, `CONTRACT.md`, `skills/using-cdp-toolkit/SKILL.md`)
20
+
21
+ ### Deferred
22
+
23
+ - Recursing child-frame accessibility nodes into the parent snapshot is NOT in this release. `backendDOMNodeId` uids are per-renderer-process, so child-frame uids would collide with parent uids under every interaction tool's `DOM.resolveNode` — recursion requires session-aware uid namespacing across all nine interaction tools, plus a BiDi-side answer where no AX domain exists. The marker + `frame:` pair ships the discoverability without that surgery.
24
+
8
25
  ## [2.6.0] - 2026-09-15
9
26
 
10
27
  **Bun is now a first-class, CI-tested runtime, and the MCP server can serve streamable HTTP.** The package has always declared `engines.bun`, but nothing proved the server actually booted under Bun — or under Node, for that matter. This release pins both: the server and CLI run under Bun ≥ 1.1 or Node ≥ 22, importing `dist/mcp.js` / `dist/cli.js` as libraries is now silent (no server start, no CLI usage dump), and under Bun the same `mcp.js` entry can serve the full MCP surface over loopback HTTP with `--transport streamable-http` for hosts that launch it as an HTTP service instead of a stdio child.
package/CONTRACT.md CHANGED
@@ -20,7 +20,7 @@ writing a module.
20
20
 
21
21
  **A worker-typed hit inside `resolveTarget` is the other deliberate exception, added in 1.9.0.** A `worker:<substring>` selector (Chrome only, capability `worker.targets`; one tool in 1.9.0, `evaluate_script` — four as of 1.9.1, see `WORKER_CAPABLE_TOOLS` in `src/workers.ts`) resolves to a service/shared worker target, and `resolveTarget` returns it before reaching `assertLeaseOk` — see the comment at the top of that branch in `src/client.ts`. A lease answers "who is driving this tab"; a worker is not a tab, has no reap-meaningful close, and strict-mode auto-acquire on it would mint a lease file keyed on an id that vanishes the moment Chrome idles the worker out, garbage the page-only reap set (`ReapInput.livePageIds`) can never collect. This also cannot be used to bypass a lease on an actual tab: there is no tab a worker resolution could stand in for. **The three console/network reader tools take a parallel path, not this one** — `resolveRecorderTarget`/`resolveWorkerSelector` in `src/tools/recorder.ts`/`src/cdp/workers.ts` — but the same reasoning holds: neither touches `assertLeaseOk`, tested by asserting zero lease files on a worker read with a page control in the same strict-mode window.
22
22
 
23
- **The `TargetSelector` grammar itself also has three independent copies, a separate fact from the lease choke points above** — `pickTarget` (`src/client.ts`, what `resolveTarget` calls, and what most tools reach through), `pickPage` (`src/shared-tools.ts`), and `pickContext` (`src/bidi/driver.ts`, Firefox). A prefix arm added to one does not exist for callers routed through another; `label:` (1.9.0) was first wired into only `pickPage` and passed every unit test while `evaluate_script` still rejected it, because `evaluate_script` calls `pickTarget`, not `pickPage`, and only the live smoke caught the gap. The shared lookup core for an arm that needs identical semantics everywhere (as `label:` does) belongs in its own module — see `resolveLiveLabel` in `src/origins.ts` — wired into all three call sites, each keeping its own error class (`CdpError` / `SharedToolError` / `DriverError`). `test/manifest-grammar-drift.test.ts` structurally asserts a completeness property (every `title:` mention also mentions `label:`) and, separately, an exclusion property for `worker:` (only a tool in the `WORKER_CAPABLE_TOOLS` allowlist — four as of 1.9.1 — may name it) — grep `startsWith("title:")` across `src/` before adding a new arm, and extend or scope that test for it.
23
+ **The `TargetSelector` grammar itself also has three independent copies, a separate fact from the lease choke points above** — `pickTarget` (`src/client.ts`, what `resolveTarget` calls, and what most tools reach through), `pickPage` (`src/shared-tools.ts`), and `pickContext` (`src/bidi/driver.ts`, Firefox). A prefix arm added to one does not exist for callers routed through another; `label:` (1.9.0) was first wired into only `pickPage` and passed every unit test while `evaluate_script` still rejected it, because `evaluate_script` calls `pickTarget`, not `pickPage`, and only the live smoke caught the gap. The shared lookup core for an arm that needs identical semantics everywhere (as `label:` does) belongs in its own module — see `resolveLiveLabel` in `src/origins.ts` — wired into all three call sites, each keeping its own error class (`CdpError` / `SharedToolError` / `DriverError`). `test/manifest-grammar-drift.test.ts` structurally asserts a completeness property (every `title:` mention also mentions `label:`) and, separately, an exclusion property for `worker:` (only a tool in the `WORKER_CAPABLE_TOOLS` allowlist — four as of 1.9.1 — may name it) — grep `startsWith("title:")` across `src/` before adding a new arm, and extend or scope that test for it. `frame:<substr>` (OOPIF iframes) is Chrome-only like `worker:` but resolves through all three parsers with one deliberate asymmetry: an iframe targetId from an unfiltered `list_pages{all:true}` resolves as a bare 32-hex id everywhere, while `worker:` never matches an iframe.
24
24
 
25
25
  7. **Never dispatch `Input.*` outside `sendInput`.** As of 1.8.0 there is exactly one function that writes a synthesized input command to a Chrome connection: `sendInput(conn, backend, targetId, method, params)` in `src/activity.ts`. `CdpPageDriver.dispatchInput` (the per-driver choke point every interaction method already had to go through) delegates to it, and any tool built outside the Driver abstraction (`dispatch_mouse`, in `src/tools/dispatch-mouse.ts`, is the precedent) must call it directly rather than opening its own `conn.send("Input....", ...)`. This is what keeps the activity beacon's human/agent discrimination (`src/activity.ts`'s dispatch log) correct: a new input path that bypasses `sendInput` will typecheck, pass its own unit tests, and pass `bun test` in full, while silently making the toolkit's own input read as a human using the tab — exactly the 1.8.0 merge bug `test/input-dispatch-wiring.test.ts` now exists to catch (it walks every `.ts` under `src/`, strips comment-only lines, and fails on any line that both sends and names `Input.`). On the Firefox side, `BidiPageDriver.performActions` is the twin choke point and already calls `recordDispatch`; a new BiDi input path goes through it the same way `CdpPageDriver.dispatchInput` is used on Chrome. Mode-toggle commands that dispatch no page input themselves (`Input.setInterceptDrags`'s arm/disarm around HTML5 drag) are still sent through `sendInput` but are on a denylist that skips recording them — classify a new such command explicitly; the default for anything unclassified is to be logged, since over-warning a human is recoverable and silently misattributing their input to "agent" is not.
26
26
 
package/README.md CHANGED
@@ -140,7 +140,7 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
140
140
 
141
141
  Each point below leads with the symptom you've probably hit, then the cause, then the fix.
142
142
 
143
- - **Your agent stalls on a call when a busy background tab is open** → that's the **all-target fan-out**: every operation broadcasts to all attached targets. every cdp-toolkit call resolves *one* target (`active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>`) and attaches a single WebSocket to just that page, so a busy tab you did not name is never touched.
143
+ - **Your agent stalls on a call when a busy background tab is open** → that's the **all-target fan-out**: every operation broadcasts to all attached targets. every cdp-toolkit call resolves *one* target (`active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>`, plus `frame:<substr>` for an out-of-process iframe, Chrome-only) and attaches a single WebSocket to just that page, so a busy tab you did not name is never touched.
144
144
  - **A tool hangs forever and never returns** → the MCP's eager `Network.enable` on a busy or hung renderer is a known wedge. cdp-toolkit enables domains **lazily**, only where a tool needs them (the recorder enables `Network`/`Runtime`/`Log`; most tools touch only `Page`/`Runtime`/`DOM`).
145
145
  - **No way to bound a slow call** → `CdpConnection.send()` enforces `CDP_TIMEOUT_MS` (15s default) on *every* command and **rejects rather than hangs**, so a stuck renderer can never block a caller indefinitely.
146
146
  - **Element handles drift across calls** → a `uid` **is** a CDP `backendDOMNodeId`, resolved on demand via `DOM.resolveNode`. There is no server-side handle table to drift or expire.
@@ -434,7 +434,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 19 superset tools (`
434
434
 
435
435
  | MCP name | CDP method(s) | Parity notes / gaps |
436
436
  |---|---|---|
437
- | `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. Each row additionally carries `origin` (`agent` or `unknown`, never `human`) plus `label`/`createdAt` for tabs this toolkit created, and, for a tab under an active lease of this backend, a `lease:{label,pid,idleMs,expiresAt,stale}` field (unconditional on `probe`). `probe:true` pings each page-type target's renderer (one bounded 500ms `Runtime.evaluate`, never more) and adds `responsive:boolean` plus `humanActiveMs` where that round trip found human-attributed input; a wedged/unreachable tab reports `responsive:false`, never an error for the whole call. Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs first (destructively, only once a lease is `CDP_REAP_GRACE_MS` past its TTL — see "Staleness" below), reporting closures in an additive `reaped` array. |
437
+ | `list_pages` | `GET /json/list` | `all` flag also exposes worker/background and out-of-process (OOPIF) iframe targets; MCP lists only page tabs. A non-page targetId from this listing is accepted by any tool's `target` param as a bare id, and `take_snapshot` marks a cross-origin iframe node with `frame:"<targetId>"` (input inside such an iframe is invisible to the activity beacon — see "Staleness" below — but the frame itself is fully driveable as its own target). Each row additionally carries `origin` (`agent` or `unknown`, never `human`) plus `label`/`createdAt` for tabs this toolkit created, and, for a tab under an active lease of this backend, a `lease:{label,pid,idleMs,expiresAt,stale}` field (unconditional on `probe`). `probe:true` pings each page-type target's renderer (one bounded 500ms `Runtime.evaluate`, never more) and adds `responsive:boolean` plus `humanActiveMs` where that round trip found human-attributed input; a wedged/unreachable tab reports `responsive:false`, never an error for the whole call. Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs first (destructively, only once a lease is `CDP_REAP_GRACE_MS` past its TTL — see "Staleness" below), reporting closures in an additive `reaped` array. |
438
438
  | `new_page` | `Target.createTarget` (+ lease file) | Returns `{targetId,url}`; does not await navigation (use `navigate_page`). `claim:true` also claims the new tab and returns a `lease` token (`label`/`ttlMs` optional). Under `CDP_REQUIRE_LEASE` the tab is claimed and a `lease` returned even without `claim:true`. |
439
439
  | `close_page` | `Target.closeTarget` (+ lease file) | Reports `success:true` on the empty result newer Chromium returns. A successful close also releases that tab's lease; a failed close leaves it in place. |
440
440
  | `select_page` | `Target.activateTarget` + selected-state file | Writes a flat-file selected target; `resolveTarget` does not read it, so `active` still means `index:0` unless a tool opts in. |
@@ -689,6 +689,37 @@ var init_workers = __esm(() => {
689
689
  WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
690
690
  });
691
691
 
692
+ // src/frames.ts
693
+ function isFrameSelector(selector) {
694
+ return typeof selector === "string" && selector.startsWith(FRAME_SELECTOR_PREFIX);
695
+ }
696
+ function frameNeedle(selector) {
697
+ return selector.slice(FRAME_SELECTOR_PREFIX.length);
698
+ }
699
+ function isFrameTargetType(type) {
700
+ return type === FRAME_TARGET_TYPE;
701
+ }
702
+ function resolveFrameTargets(targets, needle) {
703
+ const liveFrames = targets.filter((t) => isFrameTargetType(t.type));
704
+ return {
705
+ matches: needle === "" ? [] : liveFrames.filter((t) => t.url.includes(needle)),
706
+ liveFrames
707
+ };
708
+ }
709
+ function frameAmbiguityMessage(needle, matches) {
710
+ return `'frame:${needle}' matches more than one out-of-process iframe: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the iframe's target id directly.";
711
+ }
712
+ function frameMissMessage(needle, liveFrames) {
713
+ const live = liveFrames.length ? ` (out-of-process iframes: ${liveFrames.map((f) => f.url).join(", ")})` : " (no out-of-process iframes are live)";
714
+ return `no out-of-process iframe whose url contains '${needle}'${live}. ` + "Only a CROSS-ORIGIN iframe gets its own process and therefore its own target; a same-origin iframe " + "runs inside its parent page and is NOT a target at all, so no substring can ever select it — reach a " + "same-origin iframe through its parent page instead (evaluate_script on the page, then the iframe " + "element's contentDocument). An out-of-process iframe whose document navigated away is likewise gone " + "from the listing: re-list with list_pages{all:true} and pass the new target id.";
715
+ }
716
+ var FRAME_SELECTOR_PREFIX = "frame:", FRAME_PAGE_ONLY_TOOLS, FRAME_TARGET_TYPE = "iframe", FRAME_EMPTY_NEEDLE_MESSAGE = "'frame:' needs a substring of the iframe's url, e.g. frame:ads.example.com or frame:player.html", FRAME_SELECTOR_UNSUPPORTED_MESSAGE, FRAME_SELECTOR_PAGE_ONLY_MESSAGE;
717
+ var init_frames = __esm(() => {
718
+ FRAME_PAGE_ONLY_TOOLS = ["close_page", "select_page", "release_page", "claim_page"];
719
+ FRAME_SELECTOR_UNSUPPORTED_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, which this backend cannot address " + "(WebDriver BiDi has no iframe target — an iframe is a realm inside its tab's browsing context, not a " + "selectable target; Capability 'frame.targets' is Chrome-only). Use --browser chrome to act on a cross-origin iframe.";
720
+ FRAME_SELECTOR_PAGE_ONLY_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, and this tool is page-only " + "(closing, selecting, releasing or claiming an iframe is meaningless; these act on the embedder tab). " + `The page-only tools are ${FRAME_PAGE_ONLY_TOOLS.join(", ")}; every other tool that takes a 'target' ` + "accepts a frame: selector.";
721
+ });
722
+
692
723
  // src/activity.ts
693
724
  function dispatchKey(backend, targetId) {
694
725
  return `${backend}:${targetId}`;
@@ -2127,6 +2158,17 @@ async function pickTarget(targets, selector) {
2127
2158
  return matches[0];
2128
2159
  throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
2129
2160
  }
2161
+ if (isFrameSelector(selector)) {
2162
+ const needle = frameNeedle(selector);
2163
+ if (needle === "")
2164
+ throw new CdpError(FRAME_EMPTY_NEEDLE_MESSAGE);
2165
+ const { matches, liveFrames } = resolveFrameTargets(targets, needle);
2166
+ if (matches.length > 1)
2167
+ throw new CdpError(frameAmbiguityMessage(needle, matches));
2168
+ if (matches.length === 1)
2169
+ return matches[0];
2170
+ throw new CdpError(frameMissMessage(needle, liveFrames));
2171
+ }
2130
2172
  const byId = targets.find((t) => t.id === selector);
2131
2173
  if (!byId)
2132
2174
  throw new CdpError(`no target with id '${selector}'`);
@@ -2153,6 +2195,7 @@ var init_client2 = __esm(() => {
2153
2195
  init_leases();
2154
2196
  init_origins();
2155
2197
  init_workers();
2198
+ init_frames();
2156
2199
  BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
2157
2200
  DEFAULT_TIMEOUT_MS3 = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
2158
2201
  CdpError = class CdpError extends Error {
@@ -2843,6 +2886,9 @@ async function pickContext(conn, contexts, selector) {
2843
2886
  if (isWorkerSelector(selector)) {
2844
2887
  throw driverError("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
2845
2888
  }
2889
+ if (isFrameSelector(selector)) {
2890
+ throw driverError("unsupported", FRAME_SELECTOR_UNSUPPORTED_MESSAGE);
2891
+ }
2846
2892
  const exact = contexts.find((c) => c.context === selector);
2847
2893
  if (exact)
2848
2894
  return exact;
@@ -3054,6 +3100,7 @@ var init_driver2 = __esm(() => {
3054
3100
  init_leases();
3055
3101
  init_origins();
3056
3102
  init_workers();
3103
+ init_frames();
3057
3104
  init_activity();
3058
3105
  init_client();
3059
3106
  init_session_lease();
package/dist/cli.js CHANGED
@@ -473,6 +473,37 @@ var init_workers = __esm(() => {
473
473
  WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
474
474
  });
475
475
 
476
+ // src/frames.ts
477
+ function isFrameSelector(selector) {
478
+ return typeof selector === "string" && selector.startsWith(FRAME_SELECTOR_PREFIX);
479
+ }
480
+ function frameNeedle(selector) {
481
+ return selector.slice(FRAME_SELECTOR_PREFIX.length);
482
+ }
483
+ function isFrameTargetType(type) {
484
+ return type === FRAME_TARGET_TYPE;
485
+ }
486
+ function resolveFrameTargets(targets, needle) {
487
+ const liveFrames = targets.filter((t) => isFrameTargetType(t.type));
488
+ return {
489
+ matches: needle === "" ? [] : liveFrames.filter((t) => t.url.includes(needle)),
490
+ liveFrames
491
+ };
492
+ }
493
+ function frameAmbiguityMessage(needle, matches) {
494
+ return `'frame:${needle}' matches more than one out-of-process iframe: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the iframe's target id directly.";
495
+ }
496
+ function frameMissMessage(needle, liveFrames) {
497
+ const live = liveFrames.length ? ` (out-of-process iframes: ${liveFrames.map((f) => f.url).join(", ")})` : " (no out-of-process iframes are live)";
498
+ return `no out-of-process iframe whose url contains '${needle}'${live}. ` + "Only a CROSS-ORIGIN iframe gets its own process and therefore its own target; a same-origin iframe " + "runs inside its parent page and is NOT a target at all, so no substring can ever select it — reach a " + "same-origin iframe through its parent page instead (evaluate_script on the page, then the iframe " + "element's contentDocument). An out-of-process iframe whose document navigated away is likewise gone " + "from the listing: re-list with list_pages{all:true} and pass the new target id.";
499
+ }
500
+ var FRAME_SELECTOR_PREFIX = "frame:", FRAME_PAGE_ONLY_TOOLS, FRAME_TARGET_TYPE = "iframe", FRAME_EMPTY_NEEDLE_MESSAGE = "'frame:' needs a substring of the iframe's url, e.g. frame:ads.example.com or frame:player.html", FRAME_SELECTOR_UNSUPPORTED_MESSAGE, FRAME_SELECTOR_PAGE_ONLY_MESSAGE;
501
+ var init_frames = __esm(() => {
502
+ FRAME_PAGE_ONLY_TOOLS = ["close_page", "select_page", "release_page", "claim_page"];
503
+ FRAME_SELECTOR_UNSUPPORTED_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, which this backend cannot address " + "(WebDriver BiDi has no iframe target — an iframe is a realm inside its tab's browsing context, not a " + "selectable target; Capability 'frame.targets' is Chrome-only). Use --browser chrome to act on a cross-origin iframe.";
504
+ FRAME_SELECTOR_PAGE_ONLY_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, and this tool is page-only " + "(closing, selecting, releasing or claiming an iframe is meaningless; these act on the embedder tab). " + `The page-only tools are ${FRAME_PAGE_ONLY_TOOLS.join(", ")}; every other tool that takes a 'target' ` + "accepts a frame: selector.";
505
+ });
506
+
476
507
  // src/client.ts
477
508
  class CdpConnection {
478
509
  wsUrl;
@@ -671,6 +702,17 @@ async function pickTarget(targets, selector) {
671
702
  return matches[0];
672
703
  throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
673
704
  }
705
+ if (isFrameSelector(selector)) {
706
+ const needle = frameNeedle(selector);
707
+ if (needle === "")
708
+ throw new CdpError(FRAME_EMPTY_NEEDLE_MESSAGE);
709
+ const { matches, liveFrames } = resolveFrameTargets(targets, needle);
710
+ if (matches.length > 1)
711
+ throw new CdpError(frameAmbiguityMessage(needle, matches));
712
+ if (matches.length === 1)
713
+ return matches[0];
714
+ throw new CdpError(frameMissMessage(needle, liveFrames));
715
+ }
674
716
  const byId = targets.find((t) => t.id === selector);
675
717
  if (!byId)
676
718
  throw new CdpError(`no target with id '${selector}'`);
@@ -697,6 +739,7 @@ var init_client = __esm(() => {
697
739
  init_leases();
698
740
  init_origins();
699
741
  init_workers();
742
+ init_frames();
700
743
  BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
701
744
  DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
702
745
  CdpError = class CdpError extends Error {
@@ -2844,6 +2887,9 @@ async function pickContext(conn, contexts, selector) {
2844
2887
  if (isWorkerSelector(selector)) {
2845
2888
  throw driverError2("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
2846
2889
  }
2890
+ if (isFrameSelector(selector)) {
2891
+ throw driverError2("unsupported", FRAME_SELECTOR_UNSUPPORTED_MESSAGE);
2892
+ }
2847
2893
  const exact = contexts.find((c) => c.context === selector);
2848
2894
  if (exact)
2849
2895
  return exact;
@@ -3055,6 +3101,7 @@ var init_driver2 = __esm(() => {
3055
3101
  init_leases();
3056
3102
  init_origins();
3057
3103
  init_workers();
3104
+ init_frames();
3058
3105
  init_activity();
3059
3106
  init_client2();
3060
3107
  init_session_lease();
@@ -4162,7 +4209,8 @@ var CDP_CAPABILITIES = new Set([
4162
4209
  "emulate.focus",
4163
4210
  "browser.downloads",
4164
4211
  "browser.permissions",
4165
- "worker.targets"
4212
+ "worker.targets",
4213
+ "frame.targets"
4166
4214
  ]);
4167
4215
 
4168
4216
  class CdpPageDriver {
@@ -4295,6 +4343,7 @@ class CdpPageDriver {
4295
4343
  const interactiveOnly = opts?.interactiveOnly ?? false;
4296
4344
  await this.ensureDomain("Accessibility");
4297
4345
  const { nodes } = await this.conn.send("Accessibility.getFullAXTree");
4346
+ const oopifFrames = await this.detectOopifFrames();
4298
4347
  const byId = new Map;
4299
4348
  for (const n of nodes)
4300
4349
  byId.set(n.nodeId, n);
@@ -4303,11 +4352,17 @@ class CdpPageDriver {
4303
4352
  const walk = (node, depth) => {
4304
4353
  const role = axString(node.role);
4305
4354
  const name = axString(node.name);
4306
- const backendNodeId = node.backendDOMNodeId;
4307
- const emitted = !node.ignored && backendNodeId !== undefined && (interactiveOnly ? isInteractive(role) : !!(role || name));
4355
+ const backendDOMNodeId = node.backendDOMNodeId;
4356
+ const marker = backendDOMNodeId !== undefined ? oopifFrames.get(backendDOMNodeId) : undefined;
4357
+ const emitted = !node.ignored && backendDOMNodeId !== undefined && (interactiveOnly ? isInteractive(role) || marker !== undefined : !!(role || name));
4308
4358
  if (emitted) {
4309
4359
  const extras = axExtras(node);
4310
- out.push({ uid: encodeUid(backendNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
4360
+ if (marker) {
4361
+ extras.frame = marker.frameId;
4362
+ if (marker.frameUrl)
4363
+ extras.frameUrl = marker.frameUrl;
4364
+ }
4365
+ out.push({ uid: encodeUid(backendDOMNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
4311
4366
  }
4312
4367
  const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
4313
4368
  for (const childId of node.childIds ?? []) {
@@ -4320,6 +4375,20 @@ class CdpPageDriver {
4320
4375
  walk(root, 0);
4321
4376
  return out;
4322
4377
  }
4378
+ async detectOopifFrames() {
4379
+ const markers = new Map;
4380
+ try {
4381
+ await this.ensureDomainSoft("DOM");
4382
+ const iframeTargets = (await listTargets()).filter((t) => t.type === "iframe");
4383
+ for (const target of iframeTargets) {
4384
+ const owner = await this.conn.send("DOM.getFrameOwner", { frameId: target.id }).catch(() => ({ backendNodeId: undefined }));
4385
+ if (typeof owner.backendNodeId === "number") {
4386
+ markers.set(owner.backendNodeId, { frameId: target.id, ...target.url ? { frameUrl: target.url } : {} });
4387
+ }
4388
+ }
4389
+ } catch {}
4390
+ return markers;
4391
+ }
4323
4392
  async locate(loc) {
4324
4393
  return encodeUid(await backendNodeIdOf(this.conn, loc));
4325
4394
  }
@@ -5497,6 +5566,7 @@ init_leases();
5497
5566
  init_activity();
5498
5567
  init_origins();
5499
5568
  init_workers();
5569
+ init_frames();
5500
5570
  var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
5501
5571
  var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
5502
5572
 
@@ -5553,6 +5623,9 @@ async function pickPage(driver, pages, selector) {
5553
5623
  if (isWorkerSelector(selector)) {
5554
5624
  throw new SharedToolError(backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE);
5555
5625
  }
5626
+ if (isFrameSelector(selector)) {
5627
+ throw new SharedToolError(backendOf(driver) === "firefox" ? FRAME_SELECTOR_UNSUPPORTED_MESSAGE : FRAME_SELECTOR_PAGE_ONLY_MESSAGE);
5628
+ }
5556
5629
  const exactPage = pages.find((x) => x.id === selector);
5557
5630
  if (exactPage)
5558
5631
  return exactPage;
@@ -8863,7 +8936,7 @@ var MANIFEST = [
8863
8936
  },
8864
8937
  all: {
8865
8938
  type: "boolean",
8866
- description: "Include non-page targets."
8939
+ description: "Include non-page targets (workers, iframes)."
8867
8940
  },
8868
8941
  probe: {
8869
8942
  type: "boolean",
package/dist/index.js CHANGED
@@ -472,6 +472,37 @@ var init_workers = __esm(() => {
472
472
  WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
473
473
  });
474
474
 
475
+ // src/frames.ts
476
+ function isFrameSelector(selector) {
477
+ return typeof selector === "string" && selector.startsWith(FRAME_SELECTOR_PREFIX);
478
+ }
479
+ function frameNeedle(selector) {
480
+ return selector.slice(FRAME_SELECTOR_PREFIX.length);
481
+ }
482
+ function isFrameTargetType(type) {
483
+ return type === FRAME_TARGET_TYPE;
484
+ }
485
+ function resolveFrameTargets(targets, needle) {
486
+ const liveFrames = targets.filter((t) => isFrameTargetType(t.type));
487
+ return {
488
+ matches: needle === "" ? [] : liveFrames.filter((t) => t.url.includes(needle)),
489
+ liveFrames
490
+ };
491
+ }
492
+ function frameAmbiguityMessage(needle, matches) {
493
+ return `'frame:${needle}' matches more than one out-of-process iframe: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the iframe's target id directly.";
494
+ }
495
+ function frameMissMessage(needle, liveFrames) {
496
+ const live = liveFrames.length ? ` (out-of-process iframes: ${liveFrames.map((f) => f.url).join(", ")})` : " (no out-of-process iframes are live)";
497
+ return `no out-of-process iframe whose url contains '${needle}'${live}. ` + "Only a CROSS-ORIGIN iframe gets its own process and therefore its own target; a same-origin iframe " + "runs inside its parent page and is NOT a target at all, so no substring can ever select it — reach a " + "same-origin iframe through its parent page instead (evaluate_script on the page, then the iframe " + "element's contentDocument). An out-of-process iframe whose document navigated away is likewise gone " + "from the listing: re-list with list_pages{all:true} and pass the new target id.";
498
+ }
499
+ var FRAME_SELECTOR_PREFIX = "frame:", FRAME_PAGE_ONLY_TOOLS, FRAME_TARGET_TYPE = "iframe", FRAME_EMPTY_NEEDLE_MESSAGE = "'frame:' needs a substring of the iframe's url, e.g. frame:ads.example.com or frame:player.html", FRAME_SELECTOR_UNSUPPORTED_MESSAGE, FRAME_SELECTOR_PAGE_ONLY_MESSAGE;
500
+ var init_frames = __esm(() => {
501
+ FRAME_PAGE_ONLY_TOOLS = ["close_page", "select_page", "release_page", "claim_page"];
502
+ FRAME_SELECTOR_UNSUPPORTED_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, which this backend cannot address " + "(WebDriver BiDi has no iframe target — an iframe is a realm inside its tab's browsing context, not a " + "selectable target; Capability 'frame.targets' is Chrome-only). Use --browser chrome to act on a cross-origin iframe.";
503
+ FRAME_SELECTOR_PAGE_ONLY_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, and this tool is page-only " + "(closing, selecting, releasing or claiming an iframe is meaningless; these act on the embedder tab). " + `The page-only tools are ${FRAME_PAGE_ONLY_TOOLS.join(", ")}; every other tool that takes a 'target' ` + "accepts a frame: selector.";
504
+ });
505
+
475
506
  // src/client.ts
476
507
  class CdpConnection {
477
508
  wsUrl;
@@ -670,6 +701,17 @@ async function pickTarget(targets, selector) {
670
701
  return matches[0];
671
702
  throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
672
703
  }
704
+ if (isFrameSelector(selector)) {
705
+ const needle = frameNeedle(selector);
706
+ if (needle === "")
707
+ throw new CdpError(FRAME_EMPTY_NEEDLE_MESSAGE);
708
+ const { matches, liveFrames } = resolveFrameTargets(targets, needle);
709
+ if (matches.length > 1)
710
+ throw new CdpError(frameAmbiguityMessage(needle, matches));
711
+ if (matches.length === 1)
712
+ return matches[0];
713
+ throw new CdpError(frameMissMessage(needle, liveFrames));
714
+ }
673
715
  const byId = targets.find((t) => t.id === selector);
674
716
  if (!byId)
675
717
  throw new CdpError(`no target with id '${selector}'`);
@@ -696,6 +738,7 @@ var init_client = __esm(() => {
696
738
  init_leases();
697
739
  init_origins();
698
740
  init_workers();
741
+ init_frames();
699
742
  BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
700
743
  DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
701
744
  CdpError = class CdpError extends Error {
@@ -1564,7 +1607,8 @@ var CDP_CAPABILITIES = new Set([
1564
1607
  "emulate.focus",
1565
1608
  "browser.downloads",
1566
1609
  "browser.permissions",
1567
- "worker.targets"
1610
+ "worker.targets",
1611
+ "frame.targets"
1568
1612
  ]);
1569
1613
 
1570
1614
  class CdpPageDriver {
@@ -1697,6 +1741,7 @@ class CdpPageDriver {
1697
1741
  const interactiveOnly = opts?.interactiveOnly ?? false;
1698
1742
  await this.ensureDomain("Accessibility");
1699
1743
  const { nodes } = await this.conn.send("Accessibility.getFullAXTree");
1744
+ const oopifFrames = await this.detectOopifFrames();
1700
1745
  const byId = new Map;
1701
1746
  for (const n of nodes)
1702
1747
  byId.set(n.nodeId, n);
@@ -1705,11 +1750,17 @@ class CdpPageDriver {
1705
1750
  const walk = (node, depth) => {
1706
1751
  const role = axString(node.role);
1707
1752
  const name = axString(node.name);
1708
- const backendNodeId = node.backendDOMNodeId;
1709
- const emitted = !node.ignored && backendNodeId !== undefined && (interactiveOnly ? isInteractive(role) : !!(role || name));
1753
+ const backendDOMNodeId = node.backendDOMNodeId;
1754
+ const marker = backendDOMNodeId !== undefined ? oopifFrames.get(backendDOMNodeId) : undefined;
1755
+ const emitted = !node.ignored && backendDOMNodeId !== undefined && (interactiveOnly ? isInteractive(role) || marker !== undefined : !!(role || name));
1710
1756
  if (emitted) {
1711
1757
  const extras = axExtras(node);
1712
- out.push({ uid: encodeUid(backendNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
1758
+ if (marker) {
1759
+ extras.frame = marker.frameId;
1760
+ if (marker.frameUrl)
1761
+ extras.frameUrl = marker.frameUrl;
1762
+ }
1763
+ out.push({ uid: encodeUid(backendDOMNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
1713
1764
  }
1714
1765
  const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
1715
1766
  for (const childId of node.childIds ?? []) {
@@ -1722,6 +1773,20 @@ class CdpPageDriver {
1722
1773
  walk(root, 0);
1723
1774
  return out;
1724
1775
  }
1776
+ async detectOopifFrames() {
1777
+ const markers = new Map;
1778
+ try {
1779
+ await this.ensureDomainSoft("DOM");
1780
+ const iframeTargets = (await listTargets()).filter((t) => t.type === "iframe");
1781
+ for (const target of iframeTargets) {
1782
+ const owner = await this.conn.send("DOM.getFrameOwner", { frameId: target.id }).catch(() => ({ backendNodeId: undefined }));
1783
+ if (typeof owner.backendNodeId === "number") {
1784
+ markers.set(owner.backendNodeId, { frameId: target.id, ...target.url ? { frameUrl: target.url } : {} });
1785
+ }
1786
+ }
1787
+ } catch {}
1788
+ return markers;
1789
+ }
1725
1790
  async locate(loc) {
1726
1791
  return encodeUid(await backendNodeIdOf(this.conn, loc));
1727
1792
  }
@@ -2899,6 +2964,7 @@ init_leases();
2899
2964
  init_activity();
2900
2965
  init_origins();
2901
2966
  init_workers();
2967
+ init_frames();
2902
2968
  var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
2903
2969
  var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
2904
2970
 
@@ -2955,6 +3021,9 @@ async function pickPage(driver, pages, selector) {
2955
3021
  if (isWorkerSelector(selector)) {
2956
3022
  throw new SharedToolError(backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE);
2957
3023
  }
3024
+ if (isFrameSelector(selector)) {
3025
+ throw new SharedToolError(backendOf(driver) === "firefox" ? FRAME_SELECTOR_UNSUPPORTED_MESSAGE : FRAME_SELECTOR_PAGE_ONLY_MESSAGE);
3026
+ }
2958
3027
  const exactPage = pages.find((x) => x.id === selector);
2959
3028
  if (exactPage)
2960
3029
  return exactPage;
package/dist/mcp.js CHANGED
@@ -473,6 +473,37 @@ var init_workers = __esm(() => {
473
473
  WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
474
474
  });
475
475
 
476
+ // src/frames.ts
477
+ function isFrameSelector(selector) {
478
+ return typeof selector === "string" && selector.startsWith(FRAME_SELECTOR_PREFIX);
479
+ }
480
+ function frameNeedle(selector) {
481
+ return selector.slice(FRAME_SELECTOR_PREFIX.length);
482
+ }
483
+ function isFrameTargetType(type) {
484
+ return type === FRAME_TARGET_TYPE;
485
+ }
486
+ function resolveFrameTargets(targets, needle) {
487
+ const liveFrames = targets.filter((t) => isFrameTargetType(t.type));
488
+ return {
489
+ matches: needle === "" ? [] : liveFrames.filter((t) => t.url.includes(needle)),
490
+ liveFrames
491
+ };
492
+ }
493
+ function frameAmbiguityMessage(needle, matches) {
494
+ return `'frame:${needle}' matches more than one out-of-process iframe: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the iframe's target id directly.";
495
+ }
496
+ function frameMissMessage(needle, liveFrames) {
497
+ const live = liveFrames.length ? ` (out-of-process iframes: ${liveFrames.map((f) => f.url).join(", ")})` : " (no out-of-process iframes are live)";
498
+ return `no out-of-process iframe whose url contains '${needle}'${live}. ` + "Only a CROSS-ORIGIN iframe gets its own process and therefore its own target; a same-origin iframe " + "runs inside its parent page and is NOT a target at all, so no substring can ever select it — reach a " + "same-origin iframe through its parent page instead (evaluate_script on the page, then the iframe " + "element's contentDocument). An out-of-process iframe whose document navigated away is likewise gone " + "from the listing: re-list with list_pages{all:true} and pass the new target id.";
499
+ }
500
+ var FRAME_SELECTOR_PREFIX = "frame:", FRAME_PAGE_ONLY_TOOLS, FRAME_TARGET_TYPE = "iframe", FRAME_EMPTY_NEEDLE_MESSAGE = "'frame:' needs a substring of the iframe's url, e.g. frame:ads.example.com or frame:player.html", FRAME_SELECTOR_UNSUPPORTED_MESSAGE, FRAME_SELECTOR_PAGE_ONLY_MESSAGE;
501
+ var init_frames = __esm(() => {
502
+ FRAME_PAGE_ONLY_TOOLS = ["close_page", "select_page", "release_page", "claim_page"];
503
+ FRAME_SELECTOR_UNSUPPORTED_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, which this backend cannot address " + "(WebDriver BiDi has no iframe target — an iframe is a realm inside its tab's browsing context, not a " + "selectable target; Capability 'frame.targets' is Chrome-only). Use --browser chrome to act on a cross-origin iframe.";
504
+ FRAME_SELECTOR_PAGE_ONLY_MESSAGE = "target 'frame:<substring>' selects an out-of-process iframe, and this tool is page-only " + "(closing, selecting, releasing or claiming an iframe is meaningless; these act on the embedder tab). " + `The page-only tools are ${FRAME_PAGE_ONLY_TOOLS.join(", ")}; every other tool that takes a 'target' ` + "accepts a frame: selector.";
505
+ });
506
+
476
507
  // src/client.ts
477
508
  class CdpConnection {
478
509
  wsUrl;
@@ -671,6 +702,17 @@ async function pickTarget(targets, selector) {
671
702
  return matches[0];
672
703
  throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
673
704
  }
705
+ if (isFrameSelector(selector)) {
706
+ const needle = frameNeedle(selector);
707
+ if (needle === "")
708
+ throw new CdpError(FRAME_EMPTY_NEEDLE_MESSAGE);
709
+ const { matches, liveFrames } = resolveFrameTargets(targets, needle);
710
+ if (matches.length > 1)
711
+ throw new CdpError(frameAmbiguityMessage(needle, matches));
712
+ if (matches.length === 1)
713
+ return matches[0];
714
+ throw new CdpError(frameMissMessage(needle, liveFrames));
715
+ }
674
716
  const byId = targets.find((t) => t.id === selector);
675
717
  if (!byId)
676
718
  throw new CdpError(`no target with id '${selector}'`);
@@ -697,6 +739,7 @@ var init_client = __esm(() => {
697
739
  init_leases();
698
740
  init_origins();
699
741
  init_workers();
742
+ init_frames();
700
743
  BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
701
744
  DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
702
745
  CdpError = class CdpError extends Error {
@@ -2844,6 +2887,9 @@ async function pickContext(conn, contexts, selector) {
2844
2887
  if (isWorkerSelector(selector)) {
2845
2888
  throw driverError2("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
2846
2889
  }
2890
+ if (isFrameSelector(selector)) {
2891
+ throw driverError2("unsupported", FRAME_SELECTOR_UNSUPPORTED_MESSAGE);
2892
+ }
2847
2893
  const exact = contexts.find((c) => c.context === selector);
2848
2894
  if (exact)
2849
2895
  return exact;
@@ -3055,6 +3101,7 @@ var init_driver2 = __esm(() => {
3055
3101
  init_leases();
3056
3102
  init_origins();
3057
3103
  init_workers();
3104
+ init_frames();
3058
3105
  init_activity();
3059
3106
  init_client2();
3060
3107
  init_session_lease();
@@ -4175,7 +4222,8 @@ var CDP_CAPABILITIES = new Set([
4175
4222
  "emulate.focus",
4176
4223
  "browser.downloads",
4177
4224
  "browser.permissions",
4178
- "worker.targets"
4225
+ "worker.targets",
4226
+ "frame.targets"
4179
4227
  ]);
4180
4228
 
4181
4229
  class CdpPageDriver {
@@ -4308,6 +4356,7 @@ class CdpPageDriver {
4308
4356
  const interactiveOnly = opts?.interactiveOnly ?? false;
4309
4357
  await this.ensureDomain("Accessibility");
4310
4358
  const { nodes } = await this.conn.send("Accessibility.getFullAXTree");
4359
+ const oopifFrames = await this.detectOopifFrames();
4311
4360
  const byId = new Map;
4312
4361
  for (const n of nodes)
4313
4362
  byId.set(n.nodeId, n);
@@ -4316,11 +4365,17 @@ class CdpPageDriver {
4316
4365
  const walk = (node, depth) => {
4317
4366
  const role = axString(node.role);
4318
4367
  const name = axString(node.name);
4319
- const backendNodeId = node.backendDOMNodeId;
4320
- const emitted = !node.ignored && backendNodeId !== undefined && (interactiveOnly ? isInteractive(role) : !!(role || name));
4368
+ const backendDOMNodeId = node.backendDOMNodeId;
4369
+ const marker = backendDOMNodeId !== undefined ? oopifFrames.get(backendDOMNodeId) : undefined;
4370
+ const emitted = !node.ignored && backendDOMNodeId !== undefined && (interactiveOnly ? isInteractive(role) || marker !== undefined : !!(role || name));
4321
4371
  if (emitted) {
4322
4372
  const extras = axExtras(node);
4323
- out.push({ uid: encodeUid(backendNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
4373
+ if (marker) {
4374
+ extras.frame = marker.frameId;
4375
+ if (marker.frameUrl)
4376
+ extras.frameUrl = marker.frameUrl;
4377
+ }
4378
+ out.push({ uid: encodeUid(backendDOMNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
4324
4379
  }
4325
4380
  const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
4326
4381
  for (const childId of node.childIds ?? []) {
@@ -4333,6 +4388,20 @@ class CdpPageDriver {
4333
4388
  walk(root, 0);
4334
4389
  return out;
4335
4390
  }
4391
+ async detectOopifFrames() {
4392
+ const markers = new Map;
4393
+ try {
4394
+ await this.ensureDomainSoft("DOM");
4395
+ const iframeTargets = (await listTargets()).filter((t) => t.type === "iframe");
4396
+ for (const target of iframeTargets) {
4397
+ const owner = await this.conn.send("DOM.getFrameOwner", { frameId: target.id }).catch(() => ({ backendNodeId: undefined }));
4398
+ if (typeof owner.backendNodeId === "number") {
4399
+ markers.set(owner.backendNodeId, { frameId: target.id, ...target.url ? { frameUrl: target.url } : {} });
4400
+ }
4401
+ }
4402
+ } catch {}
4403
+ return markers;
4404
+ }
4336
4405
  async locate(loc) {
4337
4406
  return encodeUid(await backendNodeIdOf(this.conn, loc));
4338
4407
  }
@@ -5510,6 +5579,7 @@ init_leases();
5510
5579
  init_activity();
5511
5580
  init_origins();
5512
5581
  init_workers();
5582
+ init_frames();
5513
5583
  var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
5514
5584
  var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
5515
5585
 
@@ -5566,6 +5636,9 @@ async function pickPage(driver, pages, selector) {
5566
5636
  if (isWorkerSelector(selector)) {
5567
5637
  throw new SharedToolError(backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE);
5568
5638
  }
5639
+ if (isFrameSelector(selector)) {
5640
+ throw new SharedToolError(backendOf(driver) === "firefox" ? FRAME_SELECTOR_UNSUPPORTED_MESSAGE : FRAME_SELECTOR_PAGE_ONLY_MESSAGE);
5641
+ }
5569
5642
  const exactPage = pages.find((x) => x.id === selector);
5570
5643
  if (exactPage)
5571
5644
  return exactPage;
@@ -8405,7 +8478,7 @@ var MANIFEST = [
8405
8478
  },
8406
8479
  all: {
8407
8480
  type: "boolean",
8408
- description: "Include non-page targets."
8481
+ description: "Include non-page targets (workers, iframes)."
8409
8482
  },
8410
8483
  probe: {
8411
8484
  type: "boolean",
@@ -10778,10 +10851,10 @@ function resolveProfile(spec) {
10778
10851
  // src/toolDocs.ts
10779
10852
  var TOOL_DOCS = {
10780
10853
  list_pages: {
10781
- description: "Enumerate browser page targets (GET /json/list); page-type tabs only unless all=true. Each entry carries its targetId plus an origin field: 'agent' means this toolkit created it (also carries the creating label/createdAt, and stays 'agent' after the lease is released or the creator dies, making stray agent tabs findable), 'unknown' means no creation record - it never says 'human', the honest word for any tab it did not create; a record that exists but won't read adds originUnreadable. An entry under an active, readable lease also carries a 'lease' object {label,pid,idleMs,expiresAt,stale}, with idleMs and expiresAt computed fresh every call (never off disk), independent of probe. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs into an additive reaped array - but a lease reads reclaimable the moment its TTL elapses, while the tab is CLOSED only after a further grace (CDP_REAP_GRACE_MS, default 2700000ms/45min, so 60min total after last use); a dead-pid lease is reaped at once, a tab with no lease never. With probe, each page-type entry also gets 'responsive' (and humanActiveMs where human input is seen); both are absent, never false/null, without probe.",
10854
+ description: "Enumerate browser page targets (GET /json/list); page-type tabs only unless all=true, which also lists workers and out-of-process (OOPIF) iframes. Each entry carries its targetId plus an origin field: 'agent' means this toolkit created it (also carries the creating label/createdAt, and stays 'agent' after the lease is released or the creator dies, making stray agent tabs findable), 'unknown' means no creation record - it never says 'human', the honest word for any tab it did not create; a record that exists but won't read adds originUnreadable. An entry under an active, readable lease also carries a 'lease' object {label,pid,idleMs,expiresAt,stale}, with idleMs and expiresAt computed fresh every call (never off disk), independent of probe. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs into an additive reaped array - but a lease reads reclaimable the moment its TTL elapses, while the tab is CLOSED only after a further grace (CDP_REAP_GRACE_MS, default 2700000ms/45min, so 60min total after last use); a dead-pid lease is reaped at once, a tab with no lease never. With probe, each page-type entry also gets 'responsive' (and humanActiveMs where human input is seen); both are absent, never false/null, without probe.",
10782
10855
  params: {
10783
10856
  lease: "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
10784
- all: "Include non-page targets (service/shared workers, background pages); default false lists page-type tabs only.",
10857
+ all: "Include non-page targets (service/shared workers, background pages, out-of-process (OOPIF) iframes); default false lists page-type tabs only. A returned non-page targetId is accepted as a bare id by any tool's `target` param.",
10785
10858
  probe: "Ping each page-type renderer before returning, one bounded 500ms check per target and never more, so a wedged tab can't stall the listing. Adds 'responsive' (false on timeout/exception/unreachable, never an error for the whole call) and, where the same round trip finds human input, humanActiveMs. Default false. On a backend that cannot answer the ping, responsive is absent too (not false)."
10786
10859
  }
10787
10860
  },
@@ -10882,7 +10955,7 @@ var TOOL_DOCS = {
10882
10955
  }
10883
10956
  },
10884
10957
  take_snapshot: {
10885
- description: "Captures the page's accessibility tree as an indented text tree, each line prefixed [uid] (a CDP backendDOMNodeId). Every interaction tool (click/hover/fill/etc.) resolves that uid via DOM.resolveNode — run this first to discover uids to act on.",
10958
+ description: `Captures the page's accessibility tree as an indented text tree, each line prefixed [uid] (a CDP backendDOMNodeId). Every interaction tool (click/hover/fill/etc.) resolves that uid via DOM.resolveNode — run this first to discover uids to act on. Cross-origin iframe subtrees are not walked (they live in a separate renderer process); the iframe node itself is emitted with a frame="<targetId>" marker (also in interactiveOnly mode), so the frame is discoverable and driveable via that id or frame:<url-substring>.`,
10886
10959
  params: {
10887
10960
  lease: "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
10888
10961
  target: "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'. Grammar: see server instructions.",
@@ -11280,7 +11353,7 @@ var TOOL_DOCS = {
11280
11353
  };
11281
11354
 
11282
11355
  // src/version.ts
11283
- var VERSION = "2.6.0";
11356
+ var VERSION = "2.7.0";
11284
11357
 
11285
11358
  // src/mcp.ts
11286
11359
  function isDirectRun() {
@@ -11340,8 +11413,8 @@ function auditCoverage() {
11340
11413
  var INSTRUCTIONS = [
11341
11414
  "cdp-toolkit drives a real browser over Chrome DevTools Protocol (or Firefox WebDriver-BiDi): open and drive pages, click/fill/type, screenshot and accessibility-snapshot, read console + network, set cookies, emulate devices, run Lighthouse and performance traces, record screencasts. Conventions shared across its tools:",
11342
11415
  "TOOL AVAILABILITY: tools/list is complete and fixed for this process's life — cache it. Its one-liners are not the full docs: call describe_tool {name} on an unfamiliar tool, or describe_tool {} for the grouped catalog. CDP_TOOL_PROFILE=core (or a group list) lists only those groups; unlisted tools stay callable by name.",
11343
- "TARGET SELECTOR (the `target` param, unless a tool says otherwise): 'active' (default = first page) | 'index:N' (0-based) | 'url:<substr>' | 'title:<substr>' | 'label:<name>' (exact, from the origin ledger or a live lease) | a 32-hex '<targetId>'. Four Chrome-only tools (evaluate_script, list_network_requests, get_network_request, list_console_messages) also accept 'worker:<substr>' to reach a service/shared worker — e.g. an MV3 extension's background worker (worker:<extension-id> or worker:background.js); an idle-evicted worker is started first (see each tool's `wake`).",
11344
- "LEASES (the `lease` param): claim_page, and new_page{claim:true}, mint an opaque token. Omit it for a tab THIS process already holds. It is required for a tab held by ANOTHER process, or one claimed explicitly. Under CDP_REQUIRE_LEASE the gate auto-acquires a lease for any tab this process drives — no token is surfaced, so pass `target`, not `lease` — while an explicit claim:true still demands its token on every later call. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all.",
11416
+ "TARGET SELECTOR (the `target` param, unless a tool says otherwise): 'active' (default = first page) | 'index:N' (0-based) | 'url:<substr>' | 'title:<substr>' | 'label:<name>' (exact, ledger or live lease) | a 32-hex '<targetId>' of any target type — an iframe id from list_pages{all:true} drives like a tab. Chrome tools also accept 'frame:<substr>' for OOPIF iframe targets. Four Chrome-only tools (evaluate_script, list_network_requests, get_network_request, list_console_messages) also accept 'worker:<substr>' to reach a service/shared worker (e.g. an MV3 background worker); an idle-evicted worker is started first (see `wake`).",
11417
+ "LEASES (the `lease` param): claim_page, and new_page{claim:true}, mint an opaque token. Omit it for a tab THIS process already holds. It is required for a tab held by ANOTHER process, or one claimed explicitly. Under CDP_REQUIRE_LEASE the gate auto-acquires a lease for any tab this process drives — no token is surfaced, so pass `target`, not `lease` — while an explicit claim:true still demands its token on every later call.",
11345
11418
  "ORIGIN: list_pages and list_leases tag each tab's `origin` as 'agent' (this toolkit created it) or 'unknown' — never 'human', because the toolkit cannot prove a person opened a tab. An 'agent' tab stays findable after its creator releases the lease or dies."
11346
11419
  ].join(`
11347
11420
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 48 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,6 +46,7 @@ import {
46
46
  import { assertLeaseOk, defaultLabel, isPidAlive } from "../leases.ts";
47
47
  import { resolveLiveLabel } from "../origins.ts";
48
48
  import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
49
+ import { FRAME_SELECTOR_UNSUPPORTED_MESSAGE, isFrameSelector } from "../frames.ts";
49
50
  import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
50
51
  import { BidiConnection, BidiError, connectBidiSessionUrl } from "./client.ts";
51
52
  import {
@@ -845,6 +846,13 @@ async function pickContext(
845
846
  // "no context matching 'worker:abc'" that reads like a typo.
846
847
  throw driverError("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
847
848
  }
849
+ if (isFrameSelector(selector)) {
850
+ // Capability-gated for the same reason as the worker: arm above. WebDriver
851
+ // BiDi has no iframe TARGET to resolve to — an iframe is a realm inside its
852
+ // tab's browsing context — so the honest error names the capability
853
+ // ('frame.targets', Chrome-only) rather than retrying or reading as a typo.
854
+ throw driverError("unsupported", FRAME_SELECTOR_UNSUPPORTED_MESSAGE);
855
+ }
848
856
  const exact = contexts.find((c) => c.context === selector);
849
857
  if (exact) return exact;
850
858
  throw driverError("no-such-target", `no context matching '${selector}'`);
package/src/cdp/driver.ts CHANGED
@@ -652,7 +652,7 @@ const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
652
652
  "screenshot.fullPage", "screenshot.element", "screenshot.scale", "screenshot.renderSize", "screenshot.tile", "network.intercept",
653
653
  "snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
654
654
  "capture.screencast", "input.raw", "input.html5Drag", "emulate.focus",
655
- "browser.downloads", "browser.permissions", "worker.targets",
655
+ "browser.downloads", "browser.permissions", "worker.targets", "frame.targets",
656
656
  ]);
657
657
  /* ---------------------------------- PageDriver ---------------------------------- */
658
658
  class CdpPageDriver implements PageDriver {
@@ -820,10 +820,36 @@ class CdpPageDriver implements PageDriver {
820
820
  const { objectId } = await resolveElementLocator(this.conn, loc);
821
821
  return this.callFn(objectId, functionDeclaration, args, true);
822
822
  }
823
+ /**
824
+ * OOPIF MARKERS (issue #7 fix 1): which emitted snapshot nodes own an out-of-process iframe.
825
+ *
826
+ * Chrome's Accessibility.getFullAXTree includes the <iframe> ELEMENT's AX node (role "Iframe",
827
+ * with a backendDOMNodeId) but not the subtree of an out-of-process iframe (OOPIF): that subtree
828
+ * lives in a separate renderer/AX tree, so its contents are simply absent and a caller reading
829
+ * the snapshot has no signal that an entire interactable document ends at that line. Same-origin
830
+ * iframes ARE merged into the one response, so they need no marker — only process-isolated
831
+ * frames do, and process isolation is decided by Chrome, not by any origin heuristic here.
832
+ *
833
+ * Truth source: the browser's target list (`/json/list`) names every OOPIF as its own target of
834
+ * type "iframe", and an OOPIF's target id IS its frame id. `Page.getFrameTree` is deliberately
835
+ * NOT used here: on a page-level websocket attachment it omits out-of-process child frames
836
+ * (observed Chrome 153: same DOM with the <iframe>, frame tree with no childFrames), which is
837
+ * exactly the set this marker exists for. DOM.getFrameOwner maps each candidate frame id to the
838
+ * backendNodeId of the owning <iframe> element — the node snapshot() is about to emit; it throws
839
+ * for frame ids this page doesn't own, which is the filter.
840
+ *
841
+ * The marker rides the node's existing `extras` map ("frame" = frame/target id, "frameUrl" when
842
+ * the frame tree reported one) because SnapshotNode is owned by ../driver.ts and shared with the
843
+ * BiDi driver; renderSnapshotLine renders unknown extras keys quoted, which is the desired shape
844
+ * (`[frame="A1B2..."]`). Everything here is failure-soft by design: a snapshot must never fail
845
+ * because marker detection did, so any error returns an empty map and the snapshot degrades to
846
+ * exactly its pre-marker output.
847
+ */
823
848
  async snapshot(opts?: { interactiveOnly?: boolean }): Promise<SnapshotNode[]> {
824
849
  const interactiveOnly = opts?.interactiveOnly ?? false;
825
850
  await this.ensureDomain("Accessibility");
826
851
  const { nodes } = await this.conn.send<{ nodes: AxNode[] }>("Accessibility.getFullAXTree");
852
+ const oopifFrames = await this.detectOopifFrames();
827
853
  const byId = new Map<string, AxNode>();
828
854
  for (const n of nodes) byId.set(n.nodeId, n);
829
855
  const roots = nodes.filter((n) => !n.parentId || !byId.has(n.parentId));
@@ -837,11 +863,20 @@ class CdpPageDriver implements PageDriver {
837
863
  const walk = (node: AxNode, depth: number): void => {
838
864
  const role = axString(node.role);
839
865
  const name = axString(node.name);
840
- const backendNodeId = node.backendDOMNodeId;
841
- const emitted = !node.ignored && backendNodeId !== undefined && (interactiveOnly ? isInteractive(role) : !!(role || name));
866
+ const backendDOMNodeId = node.backendDOMNodeId;
867
+ const marker = backendDOMNodeId !== undefined ? oopifFrames.get(backendDOMNodeId) : undefined;
868
+ // interactiveOnly drops "Iframe" (not in INTERACTIVE_ROLES), but an iframe carrying an
869
+ // OOPIF marker is itself the accessibility boundary of an entire process, so it is
870
+ // emitted anyway: the default view must surface that the boundary exists. Everything
871
+ // else about the emit rule is unchanged, and the depth rule below is untouched.
872
+ const emitted = !node.ignored && backendDOMNodeId !== undefined && (interactiveOnly ? isInteractive(role) || marker !== undefined : !!(role || name));
842
873
  if (emitted) {
843
874
  const extras = axExtras(node);
844
- out.push({ uid: encodeUid(backendNodeId as number), role: role ?? "generic", depth, ...(name ? { name } : {}), ...(Object.keys(extras).length ? { extras } : {}) });
875
+ if (marker) {
876
+ extras.frame = marker.frameId;
877
+ if (marker.frameUrl) extras.frameUrl = marker.frameUrl;
878
+ }
879
+ out.push({ uid: encodeUid(backendDOMNodeId as number), role: role ?? "generic", depth, ...(name ? { name } : {}), ...(Object.keys(extras).length ? { extras } : {}) });
845
880
  }
846
881
  const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
847
882
  for (const childId of node.childIds ?? []) {
@@ -852,6 +887,36 @@ class CdpPageDriver implements PageDriver {
852
887
  for (const root of roots) walk(root, 0);
853
888
  return out;
854
889
  }
890
+ /**
891
+ * The marker map behind snapshot()'s OOPIF annotations (see snapshot()'s OOPIF MARKERS block):
892
+ * backendNodeId of the owning <iframe> element -> { frameId (= CDP target id), frameUrl }.
893
+ * Failure-soft end to end: any error anywhere in the round-trip yields an EMPTY map, never a
894
+ * failed snapshot. DOM.getFrameOwner throws for the main frame, which is why only non-main
895
+ * frames are visited; ensureDomainSoft covers the DOM.enable it requires without making a
896
+ * failed enable break anything else (getFrameOwner would simply throw into the same catch).
897
+ */
898
+ private async detectOopifFrames(): Promise<Map<number, { frameId: string; frameUrl?: string }>> {
899
+ const markers = new Map<number, { frameId: string; frameUrl?: string }>();
900
+ try {
901
+ await this.ensureDomainSoft("DOM");
902
+ // Process-isolation truth, not an origin heuristic: every OOPIF has its own iframe-type
903
+ // CDP target whose id IS its frame id (listTargets is client.ts's /json/list). This page
904
+ // owns only some of them — DOM.getFrameOwner throws for any frame id the page doesn't own
905
+ // (notably iframe targets inside OTHER tabs), and that throw is the ownership filter.
906
+ const iframeTargets = (await listTargets()).filter((t) => t.type === "iframe");
907
+ for (const target of iframeTargets) {
908
+ const owner = await this.conn
909
+ .send<{ backendNodeId?: number }>("DOM.getFrameOwner", { frameId: target.id })
910
+ .catch(() => ({ backendNodeId: undefined }));
911
+ if (typeof owner.backendNodeId === "number") {
912
+ markers.set(owner.backendNodeId, { frameId: target.id, ...(target.url ? { frameUrl: target.url } : {}) });
913
+ }
914
+ }
915
+ } catch {
916
+ // Deliberately empty: see the doc comment. Markers are an annotation, not a guarantee.
917
+ }
918
+ return markers;
919
+ }
855
920
  async locate(loc: ElementLocator): Promise<DriverUid> {
856
921
  return encodeUid(await backendNodeIdOf(this.conn, loc));
857
922
  }
package/src/client.ts CHANGED
@@ -15,6 +15,14 @@ import {
15
15
  workerNeedle,
16
16
  WORKER_EMPTY_NEEDLE_MESSAGE,
17
17
  } from "./workers.ts";
18
+ import {
19
+ frameAmbiguityMessage,
20
+ frameMissMessage,
21
+ frameNeedle,
22
+ isFrameSelector,
23
+ resolveFrameTargets,
24
+ FRAME_EMPTY_NEEDLE_MESSAGE,
25
+ } from "./frames.ts";
18
26
 
19
27
  /** Base HTTP origin of the DevTools endpoint. Override with CDP_BASE. */
20
28
  export const BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
@@ -284,6 +292,20 @@ async function pickTarget(targets: Target[], selector: TargetSelector): Promise<
284
292
  if (matches.length === 1) return matches[0]!;
285
293
  throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
286
294
  }
295
+ if (isFrameSelector(selector)) {
296
+ // Also resolved against the UNFILTERED listing: an out-of-process iframe is
297
+ // its own target (site isolation), but not a PAGE target, so `pages` never
298
+ // contains it. Unlike the worker arm, the returned hit deliberately still
299
+ // flows through resolveTarget's lease gate below — a bare iframe id does
300
+ // too today, and this arm is a way of NAMING that same target, not a new
301
+ // permission to bypass who holds the embedder tab's lease.
302
+ const needle = frameNeedle(selector);
303
+ if (needle === "") throw new CdpError(FRAME_EMPTY_NEEDLE_MESSAGE);
304
+ const { matches, liveFrames } = resolveFrameTargets(targets, needle);
305
+ if (matches.length > 1) throw new CdpError(frameAmbiguityMessage(needle, matches));
306
+ if (matches.length === 1) return matches[0]!;
307
+ throw new CdpError(frameMissMessage(needle, liveFrames));
308
+ }
287
309
  // bare id
288
310
  const byId = targets.find((t) => t.id === selector);
289
311
  if (!byId) throw new CdpError(`no target with id '${selector}'`);
package/src/driver.ts CHANGED
@@ -153,7 +153,8 @@ export type Capability =
153
153
  | "emulate.focus" // fake page focus without moving the OS window (focus_emulation / click_focus_gated); Chrome only — BiDi's emulation module has no focus primitive
154
154
  | "browser.downloads" // capture a file download to a known path (wait_for_download); Chrome only
155
155
  | "browser.permissions" // grant/reset browser permissions for an origin (grant_permissions); Chrome only
156
- | "worker.targets"; // address a service/shared worker as a target ("worker:<substr>"), incl. waking an evicted MV3 worker; Chrome only
156
+ | "worker.targets" // address a service/shared worker as a target ("worker:<substr>"), incl. waking an evicted MV3 worker; Chrome only
157
+ | "frame.targets"; // address an out-of-process (cross-origin) iframe as a target ("frame:<substr>"); Chrome only — same-site iframes are not targets at all
157
158
 
158
159
  /**
159
160
  * Tools whose availability depends on a capability. A tool absent from this
package/src/frames.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The "frame:<url-substring>" arm of the TargetSelector grammar (issue #7).
3
+ *
4
+ * WHAT IT IS FOR. Chrome site isolation gives a CROSS-ORIGIN iframe its own
5
+ * process, and with it its own CDP target that shows up in /json/list with
6
+ * type "iframe" and its own webSocketDebuggerUrl. Until now the only way to
7
+ * name one was its 32-hex targetId, discovered by reading list_pages{all:true}
8
+ * output by eye. This arm names it by a substring of its url — `frame:ads.example.com`,
9
+ * `frame:player.html` — exactly the way `url:` names a tab.
10
+ *
11
+ * THE OOPIF FACT, which is what the miss message below exists to teach: only
12
+ * OUT-OF-PROCESS iframes appear as targets at all. A SAME-ORIGIN iframe runs
13
+ * inside its parent page's process and has no target, no id, no listing entry —
14
+ * no substring can ever select it, and a caller who does not know that goes
15
+ * hunting for a typo that is not there. This is the frame arm's counterpart of
16
+ * the MV3-eviction fact the worker messages teach (see workers.ts).
17
+ *
18
+ * WHY DATA, NEVER THROWS (the resolveLiveLabel precedent in origins.ts, and
19
+ * workers.ts's copy of this rule). The grammar has three independent resolver
20
+ * copies — `pickTarget` in client.ts, `pickPage` in shared-tools.ts,
21
+ * `pickContext` in bidi/driver.ts — each with its own error class (CdpError /
22
+ * SharedToolError / DriverError). A shared resolver that threw would force one
23
+ * class on all three, so this returns the facts and each caller raises its own
24
+ * error. What IS shared is the message TEXT below, because the teaching in it
25
+ * is the point of the feature and three hand-written variants of it would drift
26
+ * apart within one release.
27
+ *
28
+ * WHY THE ALL-TARGETS LISTING. An iframe is not a page target, so the page-only
29
+ * listing the other url:/title: arms resolve against never contains one. This
30
+ * arm reads the unfiltered listing, same as the worker: arm. That does NOT
31
+ * merge the id sets that reap and leases keep apart: see ReapInput.livePageIds's
32
+ * header. Unlike a worker, an iframe hit is NOT exempted from the lease gate in
33
+ * client.ts's resolveTarget — a bare iframe id goes through that gate today, and
34
+ * `frame:` is a way of NAMING the same target, not a new permission for it.
35
+ */
36
+
37
+ /** Selector prefix owned by this arm. */
38
+ export const FRAME_SELECTOR_PREFIX = "frame:";
39
+
40
+ /**
41
+ * The page-only tools that REFUSE this arm, and the single source of truth for
42
+ * it: the page-only refusal message below is built from this list.
43
+ *
44
+ * WHY A REFUSAL LIST AND NOT workers.ts's WORKER_CAPABLE_TOOLS allowlist: the
45
+ * worker arm is resolved per-tool (evaluate_script and the three recorders route
46
+ * it through resolveWorkerSelectorFor), so which tools accept it is a finite,
47
+ * deliberate list. The frame arm instead lives in client.ts's pickTarget, the
48
+ * ONE resolver every Chrome tool's `target` argument already flows through, so
49
+ * every target-taking tool accepts it with no per-tool work — the finite,
50
+ * deliberate list is the four that must NOT (closing, selecting, releasing or
51
+ * claiming an iframe is meaningless; those act on the embedder TAB).
52
+ */
53
+ export const FRAME_PAGE_ONLY_TOOLS = ["close_page", "select_page", "release_page", "claim_page"] as const;
54
+
55
+ /**
56
+ * The CDP target type this arm resolves: "iframe". /json/list reports an
57
+ * out-of-process iframe under exactly this type (a same-origin one is absent
58
+ * entirely, which is the OOPIF fact the miss message teaches).
59
+ */
60
+ export const FRAME_TARGET_TYPE = "iframe";
61
+
62
+ /** The two fields every listing shape (CDP Target, PageInfo) already carries. */
63
+ export interface FrameTargetLike {
64
+ id: string;
65
+ url: string;
66
+ type?: string;
67
+ }
68
+
69
+ export function isFrameSelector(selector: string | undefined): selector is string {
70
+ return typeof selector === "string" && selector.startsWith(FRAME_SELECTOR_PREFIX);
71
+ }
72
+
73
+ /** The substring half of "frame:<substring>". May be empty; callers reject that. */
74
+ export function frameNeedle(selector: string): string {
75
+ return selector.slice(FRAME_SELECTOR_PREFIX.length);
76
+ }
77
+
78
+ export function isFrameTargetType(type: string | undefined): boolean {
79
+ return type === FRAME_TARGET_TYPE;
80
+ }
81
+
82
+ export interface FrameResolution<T> {
83
+ /** Out-of-process iframe targets whose url contains the needle. */
84
+ matches: T[];
85
+ /** Every live iframe target, for a miss message that lists the alternatives. */
86
+ liveFrames: T[];
87
+ }
88
+
89
+ /**
90
+ * Pure: split a target listing into "iframes matching the needle" and "all live
91
+ * iframes". An EMPTY needle matches nothing here rather than matching
92
+ * everything, because `"anything".includes("")` is true and a bare `frame:`
93
+ * would otherwise silently mean "whichever iframe happens to be first".
94
+ * Callers reject the empty needle outright with FRAME_EMPTY_NEEDLE_MESSAGE.
95
+ */
96
+ export function resolveFrameTargets<T extends FrameTargetLike>(targets: readonly T[], needle: string): FrameResolution<T> {
97
+ const liveFrames = targets.filter((t) => isFrameTargetType(t.type));
98
+ return {
99
+ matches: needle === "" ? [] : liveFrames.filter((t) => t.url.includes(needle)),
100
+ liveFrames,
101
+ };
102
+ }
103
+
104
+ /* -------------------------------- shared message text -------------------------------- */
105
+
106
+ export const FRAME_EMPTY_NEEDLE_MESSAGE =
107
+ "'frame:' needs a substring of the iframe's url, e.g. frame:ads.example.com or frame:player.html";
108
+
109
+ /**
110
+ * The Firefox refusal. A PARAM-level gap, exactly like drag's mode:'html5' and
111
+ * the worker arm's WORKER_SELECTOR_UNSUPPORTED_MESSAGE: the target-taking tools
112
+ * stay in tools/list on Firefox because every tab selector still works there,
113
+ * and only this one arm is refused. Naming the reason matters — WebDriver BiDi
114
+ * has no iframe TARGET: iframes are realms inside a browsing context, and a
115
+ * context IS a tab, so there is nothing for a target selector to resolve to.
116
+ * This is a protocol-model gap, not an unimplemented to-do.
117
+ */
118
+ export const FRAME_SELECTOR_UNSUPPORTED_MESSAGE =
119
+ "target 'frame:<substring>' selects an out-of-process iframe, which this backend cannot address " +
120
+ "(WebDriver BiDi has no iframe target — an iframe is a realm inside its tab's browsing context, not a " +
121
+ "selectable target; Capability 'frame.targets' is Chrome-only). Use --browser chrome to act on a cross-origin iframe.";
122
+
123
+ /**
124
+ * Refusal from the page-only resolvers (close_page / select_page /
125
+ * release_page / claim_page): those act on the embedder TAB, and an iframe has
126
+ * no independent existence to close, select, release or claim. Because the arm
127
+ * is accepted by every OTHER target-taking tool (it resolves inside the shared
128
+ * pickTarget), the message names the refusing minority rather than an allowlist
129
+ * of the accepting majority.
130
+ */
131
+ export const FRAME_SELECTOR_PAGE_ONLY_MESSAGE =
132
+ "target 'frame:<substring>' selects an out-of-process iframe, and this tool is page-only " +
133
+ "(closing, selecting, releasing or claiming an iframe is meaningless; these act on the embedder tab). " +
134
+ `The page-only tools are ${FRAME_PAGE_ONLY_TOOLS.join(", ")}; every other tool that takes a 'target' ` +
135
+ "accepts a frame: selector.";
136
+
137
+ /** Ambiguity never resolves to a silent first match: name every candidate. */
138
+ export function frameAmbiguityMessage(needle: string, matches: readonly FrameTargetLike[]): string {
139
+ return (
140
+ `'frame:${needle}' matches more than one out-of-process iframe: ` +
141
+ matches.map((m) => `${m.url} (${m.id})`).join(", ") +
142
+ ". Narrow the substring, or pass the iframe's target id directly."
143
+ );
144
+ }
145
+
146
+ /**
147
+ * The miss message, and the whole reason this module owns its text. A miss
148
+ * here is usually NOT a typo, for one of two reasons the caller can act on:
149
+ * - the iframe is SAME-ORIGIN: it runs inside its parent page's process, has
150
+ * no target at all, and can never be selected by any substring. The fix is
151
+ * to go through the parent page (evaluate_script on the page, then
152
+ * contentDocument through the iframe element).
153
+ * - the frame navigated away or was removed: an OOPIF target is as
154
+ * short-lived as the document inside it, so the id seen a moment ago is
155
+ * simply gone. The fix is to re-list.
156
+ */
157
+ export function frameMissMessage(needle: string, liveFrames: readonly FrameTargetLike[]): string {
158
+ const live = liveFrames.length
159
+ ? ` (out-of-process iframes: ${liveFrames.map((f) => f.url).join(", ")})`
160
+ : " (no out-of-process iframes are live)";
161
+ return (
162
+ `no out-of-process iframe whose url contains '${needle}'${live}. ` +
163
+ "Only a CROSS-ORIGIN iframe gets its own process and therefore its own target; a same-origin iframe " +
164
+ "runs inside its parent page and is NOT a target at all, so no substring can ever select it — reach a " +
165
+ "same-origin iframe through its parent page instead (evaluate_script on the page, then the iframe " +
166
+ "element's contentDocument). An out-of-process iframe whose document navigated away is likewise gone " +
167
+ "from the listing: re-list with list_pages{all:true} and pass the new target id."
168
+ );
169
+ }
package/src/manifest.ts CHANGED
@@ -35,7 +35,7 @@ export const MANIFEST: ToolSpec[] = [
35
35
  },
36
36
  "all": {
37
37
  "type": "boolean",
38
- "description": "Include non-page targets."
38
+ "description": "Include non-page targets (workers, iframes)."
39
39
  },
40
40
  "probe": {
41
41
  "type": "boolean",
package/src/mcp.ts CHANGED
@@ -158,8 +158,8 @@ function auditCoverage(): void {
158
158
  const INSTRUCTIONS = [
159
159
  "cdp-toolkit drives a real browser over Chrome DevTools Protocol (or Firefox WebDriver-BiDi): open and drive pages, click/fill/type, screenshot and accessibility-snapshot, read console + network, set cookies, emulate devices, run Lighthouse and performance traces, record screencasts. Conventions shared across its tools:",
160
160
  "TOOL AVAILABILITY: tools/list is complete and fixed for this process's life — cache it. Its one-liners are not the full docs: call describe_tool {name} on an unfamiliar tool, or describe_tool {} for the grouped catalog. CDP_TOOL_PROFILE=core (or a group list) lists only those groups; unlisted tools stay callable by name.",
161
- "TARGET SELECTOR (the `target` param, unless a tool says otherwise): 'active' (default = first page) | 'index:N' (0-based) | 'url:<substr>' | 'title:<substr>' | 'label:<name>' (exact, from the origin ledger or a live lease) | a 32-hex '<targetId>'. Four Chrome-only tools (evaluate_script, list_network_requests, get_network_request, list_console_messages) also accept 'worker:<substr>' to reach a service/shared worker — e.g. an MV3 extension's background worker (worker:<extension-id> or worker:background.js); an idle-evicted worker is started first (see each tool's `wake`).",
162
- "LEASES (the `lease` param): claim_page, and new_page{claim:true}, mint an opaque token. Omit it for a tab THIS process already holds. It is required for a tab held by ANOTHER process, or one claimed explicitly. Under CDP_REQUIRE_LEASE the gate auto-acquires a lease for any tab this process drives — no token is surfaced, so pass `target`, not `lease` — while an explicit claim:true still demands its token on every later call. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all.",
161
+ "TARGET SELECTOR (the `target` param, unless a tool says otherwise): 'active' (default = first page) | 'index:N' (0-based) | 'url:<substr>' | 'title:<substr>' | 'label:<name>' (exact, ledger or live lease) | a 32-hex '<targetId>' of any target type — an iframe id from list_pages{all:true} drives like a tab. Chrome tools also accept 'frame:<substr>' for OOPIF iframe targets. Four Chrome-only tools (evaluate_script, list_network_requests, get_network_request, list_console_messages) also accept 'worker:<substr>' to reach a service/shared worker (e.g. an MV3 background worker); an idle-evicted worker is started first (see `wake`).",
162
+ "LEASES (the `lease` param): claim_page, and new_page{claim:true}, mint an opaque token. Omit it for a tab THIS process already holds. It is required for a tab held by ANOTHER process, or one claimed explicitly. Under CDP_REQUIRE_LEASE the gate auto-acquires a lease for any tab this process drives — no token is surfaced, so pass `target`, not `lease` — while an explicit claim:true still demands its token on every later call.",
163
163
  "ORIGIN: list_pages and list_leases tag each tab's `origin` as 'agent' (this toolkit created it) or 'unknown' — never 'human', because the toolkit cannot prove a person opened a tab. An 'agent' tab stays findable after its creator releases the lease or dies.",
164
164
  ].join("\n\n");
165
165
 
@@ -48,6 +48,7 @@ import {
48
48
  import { installBeacon, probeRendererActivity, rendererProbeSupported } from "./activity.ts";
49
49
  import { newTrackedPage, originIndex, resolveLiveLabel, type PageOrigin } from "./origins.ts";
50
50
  import { isWorkerSelector, WORKER_SELECTOR_PAGE_ONLY_MESSAGE, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "./workers.ts";
51
+ import { FRAME_SELECTOR_PAGE_ONLY_MESSAGE, FRAME_SELECTOR_UNSUPPORTED_MESSAGE, isFrameSelector } from "./frames.ts";
51
52
  import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
52
53
 
53
54
  const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
@@ -150,6 +151,18 @@ export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selecto
150
151
  backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE,
151
152
  );
152
153
  }
154
+ if (isFrameSelector(selector)) {
155
+ // Same contract as the worker: arm above, same reason. Every caller of
156
+ // pickPage (close_page / select_page / release_page via resolvePage, and
157
+ // claim_page's takeover) acts on a TAB; an out-of-process iframe has no
158
+ // independent existence to close, select, release or claim. Refusing with
159
+ // the reason beats the bare-id fall-through's "no target with id
160
+ // 'frame:foo'", which reads as a typo. The arm itself lives in client.ts's
161
+ // pickTarget and every other target-taking tool accepts it.
162
+ throw new SharedToolError(
163
+ backendOf(driver) === "firefox" ? FRAME_SELECTOR_UNSUPPORTED_MESSAGE : FRAME_SELECTOR_PAGE_ONLY_MESSAGE,
164
+ );
165
+ }
153
166
  const exactPage = pages.find((x) => x.id === selector);
154
167
  if (exactPage) return exactPage;
155
168
  const all = await driver.listPages({ all: true });
package/src/toolDocs.ts CHANGED
@@ -3,10 +3,10 @@ export type ToolDoc = { readonly description: string; readonly params: Record<st
3
3
 
4
4
  export const TOOL_DOCS: Record<string, ToolDoc> = {
5
5
  "list_pages": {
6
- "description": "Enumerate browser page targets (GET /json/list); page-type tabs only unless all=true. Each entry carries its targetId plus an origin field: 'agent' means this toolkit created it (also carries the creating label/createdAt, and stays 'agent' after the lease is released or the creator dies, making stray agent tabs findable), 'unknown' means no creation record - it never says 'human', the honest word for any tab it did not create; a record that exists but won't read adds originUnreadable. An entry under an active, readable lease also carries a 'lease' object {label,pid,idleMs,expiresAt,stale}, with idleMs and expiresAt computed fresh every call (never off disk), independent of probe. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs into an additive reaped array - but a lease reads reclaimable the moment its TTL elapses, while the tab is CLOSED only after a further grace (CDP_REAP_GRACE_MS, default 2700000ms/45min, so 60min total after last use); a dead-pid lease is reaped at once, a tab with no lease never. With probe, each page-type entry also gets 'responsive' (and humanActiveMs where human input is seen); both are absent, never false/null, without probe.",
6
+ "description": "Enumerate browser page targets (GET /json/list); page-type tabs only unless all=true, which also lists workers and out-of-process (OOPIF) iframes. Each entry carries its targetId plus an origin field: 'agent' means this toolkit created it (also carries the creating label/createdAt, and stays 'agent' after the lease is released or the creator dies, making stray agent tabs findable), 'unknown' means no creation record - it never says 'human', the honest word for any tab it did not create; a record that exists but won't read adds originUnreadable. An entry under an active, readable lease also carries a 'lease' object {label,pid,idleMs,expiresAt,stale}, with idleMs and expiresAt computed fresh every call (never off disk), independent of probe. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs into an additive reaped array - but a lease reads reclaimable the moment its TTL elapses, while the tab is CLOSED only after a further grace (CDP_REAP_GRACE_MS, default 2700000ms/45min, so 60min total after last use); a dead-pid lease is reaped at once, a tab with no lease never. With probe, each page-type entry also gets 'responsive' (and humanActiveMs where human input is seen); both are absent, never false/null, without probe.",
7
7
  "params": {
8
8
  "lease": "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
9
- "all": "Include non-page targets (service/shared workers, background pages); default false lists page-type tabs only.",
9
+ "all": "Include non-page targets (service/shared workers, background pages, out-of-process (OOPIF) iframes); default false lists page-type tabs only. A returned non-page targetId is accepted as a bare id by any tool's `target` param.",
10
10
  "probe": "Ping each page-type renderer before returning, one bounded 500ms check per target and never more, so a wedged tab can't stall the listing. Adds 'responsive' (false on timeout/exception/unreachable, never an error for the whole call) and, where the same round trip finds human input, humanActiveMs. Default false. On a backend that cannot answer the ping, responsive is absent too (not false)."
11
11
  }
12
12
  },
@@ -107,7 +107,7 @@ export const TOOL_DOCS: Record<string, ToolDoc> = {
107
107
  }
108
108
  },
109
109
  "take_snapshot": {
110
- "description": "Captures the page's accessibility tree as an indented text tree, each line prefixed [uid] (a CDP backendDOMNodeId). Every interaction tool (click/hover/fill/etc.) resolves that uid via DOM.resolveNode — run this first to discover uids to act on.",
110
+ "description": "Captures the page's accessibility tree as an indented text tree, each line prefixed [uid] (a CDP backendDOMNodeId). Every interaction tool (click/hover/fill/etc.) resolves that uid via DOM.resolveNode — run this first to discover uids to act on. Cross-origin iframe subtrees are not walked (they live in a separate renderer process); the iframe node itself is emitted with a frame=\"<targetId>\" marker (also in interactiveOnly mode), so the frame is discoverable and driveable via that id or frame:<url-substring>.",
111
111
  "params": {
112
112
  "lease": "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process or one claimed explicitly. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
113
113
  "target": "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'. Grammar: see server instructions.",
package/src/types.ts CHANGED
@@ -63,6 +63,19 @@ export interface CdpEvent<P = Record<string, unknown>> {
63
63
  * listing, because a worker is not a page target — and a worker that is
64
64
  * idle-evicted is in no listing at all, which is what `wake` exists for.
65
65
  * See src/workers.ts and src/cdp/workers.ts.
66
+ * - "frame:<substring>" -> the out-of-process (cross-origin) IFRAME target
67
+ * whose url contains the substring (site isolation gives an OOPIF its own
68
+ * process and its own target, type "iframe"). Chrome-only, like "worker:",
69
+ * but accepted by EVERY target-taking tool except the four page-only ones
70
+ * (close/select/release/claim act on the embedder tab): the arm lives in
71
+ * pickTarget itself, the one resolver every Chrome tool's target flows
72
+ * through, so no per-tool wiring was needed or added. Reads the UNFILTERED
73
+ * listing; a SAME-ORIGIN iframe is not a target at all (it runs inside its
74
+ * parent page) and can only be reached through the parent. Refused with a
75
+ * capability error on Firefox (no iframe targets exist in BiDi's context
76
+ * model) and by the page-only resolvers. See src/frames.ts. A bare iframe
77
+ * targetId from list_pages{all:true} also still resolves via the bare-id
78
+ * arm below.
66
79
  */
67
80
  export type TargetSelector = string | undefined;
68
81
 
package/src/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * ready line; keeping it in its own module means a release bump touches one
7
7
  * line here plus package.json, not a const buried in the server.
8
8
  */
9
- export const VERSION = "2.6.0";
9
+ export const VERSION = "2.7.0";