cdp-toolkit 1.8.0 → 1.9.1

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,241 @@ 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
+ ## [1.9.1] - 2026-08-10
9
+
10
+ `list_network_requests`, `get_network_request`, and `list_console_messages` now
11
+ observe MV3 background service workers, not just pages: the outbound fetch a
12
+ worker makes to a real backend is a first-class capture target, not something
13
+ you have to fake with an `evaluate_script` monkeypatch. Tool count stays 45 —
14
+ the three tools grow a `worker:` arm, no new tool. One seat (N1) landed it,
15
+ every protocol claim below probed against a live Chrome 151 before being built
16
+ on.
17
+
18
+ ### Added
19
+
20
+ - **The three console/network reader tools accept `target: "worker:<substring>"`
21
+ or a bare worker target id**, alongside the page selectors they already took.
22
+ Chrome only (capability `worker.targets`) — Firefox refuses the arm from its
23
+ real capability set before launching a browser that could never have served
24
+ the call. The allowlist that grants the arm, `WORKER_CAPABLE_TOOLS` in
25
+ `src/workers.ts`, now names four tools (`evaluate_script` plus these three);
26
+ it is the single source of truth for both the refusal messages and
27
+ `test/manifest-grammar-drift.test.ts`'s exclusion property, so a fifth tool
28
+ cannot silently advertise `worker:` without a deliberate edit here.
29
+
30
+ - **A worker capture LISTENS instead of reloading.** Measured on Chrome
31
+ 151.0.7922.109: `Network.enable` and `Runtime.consoleAPICalled` work
32
+ identically on a direct worker session to how they work on a page — all four
33
+ events the recorder persists (`requestWillBeSent`, `responseReceived`,
34
+ `loadingFinished`, `loadingFailed`) arrive, and `Network.getResponseBody`
35
+ serves the body — but `Page.enable`/`Page.reload` **do not exist** on a
36
+ worker session (`-32601 'Page.enable' wasn't found`). A page capture is
37
+ reload-driven; a worker capture cannot be, so it opens the worker's own CDP
38
+ session and listens for `durationMs` instead. `captureWindow()`'s branch is
39
+ keyed off the resolved target's type, not the selector's shape, so a bare
40
+ worker id takes the listen path too.
41
+
42
+ - **Recording a worker keeps it alive for the length of the capture — a
43
+ deliberate side effect, not a bug.** A held CDP session suppresses MV3 idle
44
+ eviction; measured in both directions with the same polling loop so
45
+ "stayed alive" isn't an artifact of how the poll works: with the recorder's
46
+ session held, the worker is still present in `/json/list` and still emits a
47
+ fresh `Network.requestWillBeSent` for a triggered fetch at t+60s; once the
48
+ session detaches, the same worker idle-evicts within ~30s. Documented in the
49
+ three tools' manifest descriptions and asserted in both directions by the
50
+ live smoke, not left implicit.
51
+
52
+ - **`wake` (default `true` on a capture) is REFUSED on a read-only call and on
53
+ a bare target id, rather than accepted and ignored.** Measured: a worker
54
+ that idle-evicts and restarts comes back under a brand-new target id (probed
55
+ restart: `C43F0A18...` → `1F7D3C59...` for the same extension). The recorder
56
+ keys its buffer file by target id, so waking a worker on a *read* would hand
57
+ back a live worker with an empty buffer — "0 requests" for a capture that
58
+ really did record some, a zero shaped like an answer. A wake therefore only
59
+ makes sense when a capture is being started, and only from a
60
+ `worker:<substring>` selector, since an evicted worker's old id no longer
61
+ exists to name. The refusal messages (`workerWakeMisuseMessage`,
62
+ `workerBufferReadMissMessage` in `src/workers.ts`) teach the id-re-mint fact
63
+ rather than just rejecting the argument.
64
+
65
+ - **Known limitation, stated rather than silently absent: a worker woken BY a
66
+ capture has already run its top-level code by the time the recorder
67
+ attaches**, so a fetch the worker makes at startup can be missed. Trigger
68
+ the request you want to observe (a message, an alarm) after the capture
69
+ starts rather than relying on the worker's own boot-time fetch. Fixing this
70
+ needs `Target.setAutoAttach{waitForDebuggerOnStart}` on a held browser
71
+ session, a different feature; the limitation is documented in
72
+ `recorder.ts`'s header and in the three manifest descriptions instead.
73
+
74
+ - **`get_console_message` reads the same worker-keyed buffer (so its miss
75
+ message matches what it reads) but is deliberately NOT in
76
+ `WORKER_CAPABLE_TOOLS`** — it already resolved worker targets through the
77
+ 1.9.0 arm, routing it through the read-path resolver only sharpens its miss
78
+ message; advertising it as worker-capable was out of scope, and the drift
79
+ test pins the advertised list at four.
80
+
81
+ ### Measured, not assumed — the protocol facts this release ships on
82
+
83
+ - **`Page.enable`/`Page.reload` are `-32601` on a worker session** — the
84
+ single measurement that shapes the implementation: a worker window cannot
85
+ be reload-driven, so it listens.
86
+ - **A held session suppresses MV3 idle eviction; detach restores it** —
87
+ confirmed in both directions with the same polling loop, including a
88
+ post-hold evaluate and a fresh `Network.requestWillBeSent` at t+60s, so
89
+ "alive" means genuinely live, not a stale listing entry.
90
+ - **A worker's target id is re-minted on every restart, never stable** — this
91
+ is why `wake` is refused on reads and on bare ids rather than silently doing
92
+ the wrong thing.
93
+ - **Two things a caller might reach for instead don't work, for ordinary
94
+ JavaScript reasons, not a service-worker-specific one.** A value bound at
95
+ module top level is not visible from `evaluate_script`'s global scope
96
+ (plain lexical scoping, identical in a page); assigning `self.fetch`
97
+ afterward cannot rebind a `fetch` reference the module already captured
98
+ (closure semantics, also identical in a page). `Network`-domain recording
99
+ on the worker's own session observes what the code ACTUALLY SENT and needs
100
+ neither. (An earlier field report attributing this to service-worker eval
101
+ semantics was a misdiagnosis of an outdated loaded extension build, not a
102
+ real capability gap — see the stale-build tip below.)
103
+
104
+ ### Notes
105
+
106
+ - Tool count is unchanged at **45** — this release grew the `target` grammar
107
+ on three existing tools, not the tool list.
108
+ - `docs/social-preview.html` and `docs/demo.tape` carry no version string and
109
+ needed no re-render for this release.
110
+ - Before deep-debugging an extension that "the toolkit can't see": verify the
111
+ loaded build is current with
112
+ `evaluate_script {target:"worker:<id>", expression:"chrome.runtime.getManifest().version"}`.
113
+ An outdated unpacked build left over from a prior load mimics every symptom
114
+ in this release's "measured facts" section above.
115
+ - New tests: 20 in `test/worker-capture.test.ts`, 3 more in
116
+ `test/manifest-grammar-drift.test.ts` (allowlist rescoped from an
117
+ evaluate_script-only exclusion to the four-tool `WORKER_CAPABLE_TOOLS`
118
+ allowlist), and new live scenarios in `test/extension-smoke.ts` covering the
119
+ worker fetch/console capture, the eviction pair in both directions, and the
120
+ capture-path wake (proven by the fixture's own start counter, `starts: 2 ->
121
+ 3`, never by `ServiceWorker.startWorker`'s result, which reports empty
122
+ success even for a scope that doesn't exist) — 31/31 assertions, up from the
123
+ 1.9.0 baseline of 20.
124
+
125
+ ## [1.9.0] - 2026-08-09
126
+
127
+ Four complaints from a field agent driving an MV3 extension, closed without adding
128
+ a tool: the surface grows inside `evaluate_script`, the `target` selector
129
+ grammar, and error DX. Tool count stays 45. Two seats landed sequentially —
130
+ W2 (`label:` + key-echo) first, W1 (worker targets + idle-wake) on top of it —
131
+ and every protocol assertion below was probed against a live Chrome 151 before
132
+ being built on, because five 1.8.0 premises were folklore that measured false.
133
+
134
+ ### Added
135
+
136
+ - **`label:<name>` in the target-selector grammar, universal across every
137
+ page-taking tool.** Resolves an exact label match against BOTH the origin
138
+ ledger (tabs this toolkit created) and live lease records (a tab taken over
139
+ and claimed with a label, which has no origin record) — so a tab you named
140
+ is findable by that name regardless of which path put you in it. A miss
141
+ enumerates the labels that DO exist, deduped across both sources; an
142
+ ambiguity (the same label on two live targets) names both ids rather than
143
+ silently picking the first. **The grammar turned out to have three
144
+ independent copies** — `pickPage` (`src/shared-tools.ts`), `pickTarget`
145
+ (`src/client.ts`, the one `evaluate_script`/`click`/`hover`/… actually call),
146
+ and `pickContext` (`src/bidi/driver.ts`, Firefox) — a fact the live smoke
147
+ caught immediately when `label:` worked in `claim_page` but not in
148
+ `evaluate_script`. The lookup core is now one function, `resolveLiveLabel`
149
+ in `src/origins.ts`, wired into all three call sites, each keeping its own
150
+ error class. A new structural test, `test/manifest-grammar-drift.test.ts`,
151
+ asserts every manifest description that names `title:` also names `label:`,
152
+ so a fourth resolver copy (or a stale description) can't slip past review
153
+ silently again.
154
+
155
+ - **Key-echo validation on `evaluate_script`.** When `expression` is missing
156
+ or empty and the call instead carries `function`, `code`, `js`, `script`,
157
+ `fn`, or `body`, the error names the wrong key by value and says the right
158
+ one is `expression` — and, when the value looks like a function literal,
159
+ that function literals go in `expression` with `args`. One line, saving the
160
+ round trip the field complaint measured.
161
+
162
+ - **`evaluate_script` can now evaluate inside an MV3 extension's background
163
+ SERVICE WORKER, not just a page — no raw-CDP escape hatch needed.** Pass
164
+ `target: "worker:<extension-id>"` (or any substring of the worker's url,
165
+ e.g. `worker:background.js`) and read `chrome.storage.local`,
166
+ `chrome.runtime`, and the worker's own globals directly. Chrome-only
167
+ (capability `worker.targets`) and accepted by `evaluate_script` alone —
168
+ `worker:` is deliberately absent from `pickPage`'s page-only grammar
169
+ (closing/selecting/releasing a worker is meaningless) and refused there by
170
+ name, and a new drift-test arm asserts no OTHER tool's manifest description
171
+ ever names `worker:<`, so a later sweep can't advertise a Chrome-only,
172
+ one-tool selector as universal grammar. Firefox refuses the arm with a
173
+ capability-gap error naming `worker.targets`, per ADR-001 — WebDriver BiDi
174
+ has no extension-service-worker target concept to map onto.
175
+ **`wake` (default `true`)** asks Chrome to start the worker first when the
176
+ selector matches nothing or a known-but-stopped worker, then re-resolves;
177
+ `wake:false` fails fast with an error that teaches the MV3 eviction fact
178
+ instead of a bare "not found". Worker targets bypass the lease gate
179
+ entirely (`assertLeaseOk` is skipped for a worker-typed resolution in
180
+ `client.ts`): reap's "close the stale tab" is meaningless for a target
181
+ Chrome starts and stops on its own, and strict mode auto-acquiring a lease
182
+ per evaluate would mint a file keyed on an id that vanishes at the next
183
+ idle — garbage the page-only reap set can never collect. The page-only id
184
+ set reap and leases already keep (`ReapInput.livePageIds`) was left
185
+ unmerged on purpose.
186
+
187
+ ### Measured, not assumed — the protocol facts this release ships on
188
+
189
+ Every one of these was a hypothesis in the spec and a measurement against a
190
+ live Chrome 151.0.7922.109 before any line of feature code was written:
191
+
192
+ - **`evaluate_script` against a worker never needs a flattened browser
193
+ session.** `/json/list` exposes a running service worker with its own
194
+ `webSocketDebuggerUrl`, and a direct WebSocket connect + `Runtime.evaluate`
195
+ with `awaitPromise` works there exactly as it does against a page — proven
196
+ by `self.constructor.name === "ServiceWorkerGlobalScope"` in the same
197
+ round trip. The feature rides the existing per-target connect path with no
198
+ new connection model.
199
+ - **The "awaited value is lost over a flattened session" folklore is false on
200
+ Chrome 151, measured anyway even though it turned out not to matter.**
201
+ `Target.attachToTarget{flatten:true}` against the same worker returns the
202
+ real awaited value too. **No warning was shipped for a loss that cannot be
203
+ triggered.**
204
+ - **A stopped MV3 worker is invisible to every target listing — `/json/list`
205
+ and `Target.getTargets` under any filter — so "no such worker" almost
206
+ always means "idle-evicted," not "wrong id."** The only place a stopped
207
+ worker is visible at all is the registration inventory
208
+ `ServiceWorker.enable` pushes as events, which is why the miss/wake error
209
+ text says so explicitly.
210
+ - **`ServiceWorker.enable`/`startWorker` do not exist on the browser
211
+ endpoint** (`-32601 wasn't found`) — **they work on a flattened session over
212
+ any page**, including a plain `about:blank` tab with no relationship to the
213
+ extension. That's the wake mechanism: attach to whichever page target
214
+ happens to be open, enable the domain there, start the matched scope.
215
+ - **`startWorker` reports empty success for a scope that does not exist at
216
+ all** — its return value is worthless as proof a worker woke. A wake is
217
+ therefore verified only by re-reading the target list until the worker
218
+ reappears (bounded, 30 × 300ms), never by the command's own result.
219
+ - **`--load-extension` is inert on Chrome 151**, even with the documented
220
+ `--disable-features=DisableLoadExtensionCommandLineSwitch` unblock flag —
221
+ confirmed by checking the ServiceWorker registration inventory, which would
222
+ show a stopped-but-installed extension even if nothing were running, and
223
+ showed nothing at all. `Extensions.loadUnpacked` over CDP is the install
224
+ path that actually works, and is what the new extension smoke and any
225
+ future extension testing with this toolkit should use. `--headless=new` is
226
+ fine for driving an extension; no headed instance is required.
227
+
228
+ ### Notes
229
+
230
+ - Tool count is unchanged at **45** — this release grew `evaluate_script` and
231
+ the target-selector grammar, not the tool list.
232
+ - `docs/social-preview.html` and `docs/demo.tape` carry no version string and
233
+ needed no re-render for this release.
234
+ - New tests: `test/label-selector.test.ts`, `test/worker-selector.test.ts`,
235
+ two new arms in `test/manifest-grammar-drift.test.ts` (inclusion for
236
+ `label:`, exclusion for `worker:`), and a new live smoke,
237
+ `test/extension-smoke.ts`, against a fixture MV3 extension in
238
+ `test/fixtures/mv3-extension/` — 20/20 assertions, including a genuine idle
239
+ eviction (worker absence from `/json/list` asserted before waking) and a
240
+ strict-mode check that a worker evaluate mints zero lease files while a
241
+ page evaluate in the same run still mints one.
242
+
8
243
  ## [1.8.0] - 2026-08-09
9
244
 
10
245
  Two tracks land together, merged onto one integration branch. Track S closes the
@@ -752,6 +987,8 @@ First public release.
752
987
  - Zero runtime dependencies in the CDP/CLI layer (Node's global `WebSocket` + `fetch`). The MCP server adds only `@modelcontextprotocol/sdk`; `lighthouse_audit` is the sole non-CDP tool and shells out to `npx lighthouse`.
753
988
  - Runtime: Bun ≥ 1.1 (recommended) or Node ≥ 22 (for the global `WebSocket`). Requires Chrome/Chromium started with `--remote-debugging-port=9222`.
754
989
 
990
+ [1.9.1]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.9.1
991
+ [1.9.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.9.0
755
992
  [1.8.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.8.0
756
993
  [1.7.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.7.0
757
994
  [1.6.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.6.0
package/CONTRACT.md CHANGED
@@ -18,6 +18,10 @@ writing a module.
18
18
 
19
19
  **`claim_page{target}` is a deliberate, reviewed exception to this rule, not a violation of it.** Its target resolution goes through `pickPage` (`src/shared-tools.ts`), which is exported *specifically* for this one caller and does **not** call `assertLeaseOk`. Routing a claim through `resolvePage` instead would, under strict mode, auto-acquire a lease on the way in and then collide with the explicit claim right behind it, one call claiming the same tab against itself twice. The protection is not dropped, only relocated one layer down: `claimLease`'s own conflict check still refuses a tab any live process holds, auto-acquired or explicit, so a stranger's tab is still refused, only later and by a different function. `pickPage` must never gain a second caller without re-deriving this argument for that caller too; if it does not need gate-free resolution, it needs `resolvePage`, not `pickPage`.
20
20
 
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
+
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.
24
+
21
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.
22
26
 
23
27
  ## Core client API (from `src/client.ts`)
@@ -113,7 +117,7 @@ A `Uid` **is** a CDP `backendDOMNodeId` (a number). This makes refs stateless:
113
117
  | `src/tools/screenshot.ts` | `take_screenshot`→`takeScreenshot` | `Page.captureScreenshot`. Support `{ format:'png'|'jpeg', quality, fullPage, uid?/selector? (element clip via getBoxModel), savePath? }`. Write to ARTIFACT_DIR, return `{ path, bytes, format }` and optionally base64 when `{ returnBase64:true }`. |
114
118
  | `src/tools/emulation.ts` | `emulate`→`emulate`, `resize_page`→`resizePage` | `Emulation.setDeviceMetricsOverride` (width,height,deviceScaleFactor,mobile), `Emulation.setUserAgentOverride`, `Emulation.setCPUThrottlingRate`, `Emulation.setEmulatedMedia`, `Network`-based throttling via `Network.emulateNetworkConditions`. `resize_page` = device metrics width/height. |
115
119
  | `src/tools/dialogs.ts` | `handle_dialog`→`handleDialog` | `Page.enable`, subscribe `Page.javascriptDialogOpening`, respond with `Page.handleJavaScriptDialog({ accept, promptText })`. Support a "wait for next dialog then handle" mode and an "auto-handle for N ms" mode. |
116
- | `src/tools/recorder.ts` + `src/tools/console.ts` + `src/tools/network.ts` | console: `list_console_messages`→`listConsoleMessages`, `get_console_message`→`getConsoleMessage`; network: `list_network_requests`→`listNetworkRequests`, `get_network_request`→`getNetworkRequest` | **Owned by one agent.** `recorder.ts` exports `startRecorder(target, {network,console}): Promise<{stop()}>` that opens a persistent `openPage` conn, enables `Network`+`Runtime`(+`Log`), and appends events to `rec-<targetId>.jsonl`. console/network `list_*` read that file (and support a one-shot `{ reload:true, durationMs }` mode that records fresh by reloading the page and capturing for a window, then returns). `get_*` filter by id/url. Document the recorder model at the top of `recorder.ts`. |
120
+ | `src/tools/recorder.ts` + `src/tools/console.ts` + `src/tools/network.ts` | console: `list_console_messages`→`listConsoleMessages`, `get_console_message`→`getConsoleMessage`; network: `list_network_requests`→`listNetworkRequests`, `get_network_request`→`getNetworkRequest` | **Owned by one agent.** `recorder.ts` exports `startRecorder(target, {network,console}): Promise<{stop()}>` that opens a persistent `openPage` conn, enables `Network`+`Runtime`(+`Log`), and appends events to `rec-<targetId>.jsonl`. console/network `list_*` read that file (and support a one-shot `{ reload:true, durationMs }` mode that records fresh by reloading the page and capturing for a window, then returns). `get_*` filter by id/url. Document the recorder model at the top of `recorder.ts`. **As of 1.9.1, the same three tools also accept a `worker:<substring>` target** (Chrome only, capability `worker.targets`; MV3 service/shared workers), resolved via `resolveRecorderTarget`/`resolveWorkerSelector` in `src/workers.ts` rather than `resolveTarget`. Because `Page.enable`/`Page.reload` do not exist on a worker session (measured: `-32601`), `captureWindow()` skips the reload for a worker-typed resolution and LISTENS on the held connection for `durationMs` instead — keyed off the resolved target's type, not the selector's shape, so a bare worker id takes the same branch as a `worker:` selector. Holding that session is a deliberate side effect, the same pattern as the beacon sessions below: it SUPPRESSES the worker's MV3 idle eviction for as long as the capture runs (measured: alive and still emitting events at 60s held, evicted ~30s after detach). `wake` (default `true` on a capture) restarts an evicted worker first, but is refused on a read-only call and on a bare target id, because a restart mints a NEW target id and the buffer is keyed by the old one — see `workerWakeMisuseMessage` in `src/workers.ts`. |
117
121
  | `src/tools/performance.ts` | `performance_start_trace`→`performanceStartTrace`, `performance_stop_trace`→`performanceStopTrace`, `performance_analyze_insight`→`performanceAnalyzeInsight` | `Tracing.start`(categories incl. `devtools.timeline`,`blink.user_timing`,`loading`,`-*`), collect `Tracing.dataCollected`, `Tracing.end`→`Tracing.tracingComplete`, write trace JSON to ARTIFACT_DIR. start/stop need a persistent conn, so use a state file to hold the in-flight trace path/connection target. `performance_analyze_insight` parses a trace file for LCP, FCP/navigation timing, long tasks (>50ms), layout shifts (CLS), total blocking time; return structured metrics. **Mark in a top comment that this is a CDP-native approximation of the MCP's insight analyzer.** |
118
122
  | `src/tools/screencast.ts` | `start_screen_recording`→`startScreenRecording`, `stop_screen_recording`→`stopScreenRecording` | **Toolkit addition (not MCP parity): a tab captured to an H.265 MP4.** Persistent `openPage` conn parked in a module-level `Map` keyed by targetId (recorder.ts's statefulness, performance.ts's registry), `Page.startScreencast` → `Page.screencastFrame` → **ack from the handler, before the disk write** (an unacked frame stalls the stream) → spool file + ledger row `{file,timestamp,width,height}`. Frames arrive on repaint only, so playback timing MUST come from per-frame ffconcat `duration`s off the ledger, never a fixed rate. Encoder ladder probed once from `ffmpeg -encoders`: `hevc_videotoolbox`→`h264_videotoolbox`→`libx265`→`libx264`, `-tag:v hvc1` on HEVC, always `+faststart`/`yuv420p`/even dims. ffmpeg is probed at START (fail before capturing, not after). Pure parts (`frameDurationsMs`/`buildConcatManifest`/`selectEncoder`/`buildFfmpegArgs`/`imageSize`) are unit-tested; live capture via `test/screencast-smoke.ts`. |
119
123
  | `src/tools/heap.ts` | `take_heapsnapshot`→`takeHeapsnapshot` | `HeapProfiler.enable` → `HeapProfiler.takeHeapSnapshot`, assemble `HeapProfiler.addHeapSnapshotChunk` events into a `.heapsnapshot` file under ARTIFACT_DIR; return `{ path, bytes }`. |
package/README.md CHANGED
@@ -128,7 +128,7 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
128
128
 
129
129
  Each point below leads with the symptom you've probably hit, then the cause, then the fix.
130
130
 
131
- - **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> | <targetId>`) and attaches a single WebSocket to just that page, so a busy tab you did not name is never touched.
131
+ - **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.
132
132
  - **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`).
133
133
  - **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.
134
134
  - **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.
@@ -253,7 +253,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
253
253
  | `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. |
254
254
  | `navigate_page` | `Page.navigate` / `Page.reload` + load events, or `history:'back'\|'forward'` (Chrome: `Page.getNavigationHistory` + `Page.navigateToHistoryEntry`; Firefox: `browsingContext.traverseHistory`) | Returns `{url,frameId,waitedFor}` (no auto-snapshot; a history move also returns `traversed:'back'\|'forward'`). `waitUntil` supports `load`/`domcontentloaded`. `reload:true` (+ `ignoreCache:true` for a hard reload). `url`/`reload`/`history` are mutually exclusive; going back from the first entry (or forward from the last) is an error naming the direction, never a silent no-op. Works on both backends. |
255
255
  | `wait_for` | `Runtime.evaluate` (poll `innerText`) | Text-substring waiting only; throws on timeout rather than returning `{found:false}`. |
256
- | `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. Toolkit addition: optional `savePath` writes the value to a JSON file and keeps it out of the response entirely. |
256
+ | `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. Toolkit addition: optional `savePath` writes the value to a JSON file and keeps it out of the response entirely. If `expression` is missing/empty and the call instead carries `function`/`code`/`js`/`script`/`fn`/`body`, the error names the wrong key and points at `expression`. One of four tools that also accept `target: "worker:<substring>"` (Chrome only, capability `worker.targets`) to reach an MV3 extension's background service worker — see "Driving MV3 extensions" below. |
257
257
  | `list_cookies` *(superset)* | `Network.getCookies` | Reads the target page's cookie store, httpOnly cookies included, which `document.cookie` and therefore `evaluate_script` cannot see. Page-scoped on purpose, not the browser-wide jar (`Storage.getCookies`), so it answers for the tab you named. Optional `domain`/`name` filters; optional `savePath` writes the array to a JSON file and returns `{path,bytes,count,target}` with no cookie value in the response. |
258
258
  | `set_cookie` *(superset)* | `Network.setCookie` | Writes one cookie, httpOnly and secure ones included, which `document.cookie` cannot create. Either `url` or `domain` is required and the call is refused with an error when neither is given. Chrome's `success:false` refusal is raised as an error rather than reported as a write. Answers `{set:true,target}` and never echoes the value back. `path` is passed through as given, never defaulted. |
259
259
  | `delete_cookies` *(superset)* | `Network.deleteCookies` | Removes the named cookie, httpOnly ones included. Requires `name` plus `url` or `domain`, so a name-only call cannot sweep the store; optional `path` narrows further. Answers `{deleted:true,target}` with no count, because neither protocol reports one; read `list_cookies` before and after for a real count. |
@@ -274,10 +274,10 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
274
274
  | `emulate` | `Emulation.*` / `Network.emulateNetworkConditions` | Stateless: UA/CPU/media/network overrides reset when the per-call connection closes. No named device presets. |
275
275
  | `resize_page` | `Emulation.setDeviceMetricsOverride` | Verifies via `window.innerWidth/innerHeight`. Override persists on the target. |
276
276
  | `handle_dialog` | `Page.javascriptDialogOpening` / `handleJavaScriptDialog` | Caller arms first and triggers out-of-band (or `handleDialogForExpression` to trigger-and-handle atomically). Supports wait-for-next and auto-handle-for-N-ms. |
277
- | `list_console_messages` | `Runtime`/`Log` events (+ `Page.reload`) | `reload:true` records console+network into a unique per-capture file; default read returns the latest. Args flattened best-effort. |
278
- | `get_console_message` | reads the shared "latest" buffer | Index into the latest capture; throws if out of range. |
279
- | `list_network_requests` | `Network.*` events (+ `Page.reload`) | Correlated rows from the per-capture buffer; redirect chains collapse to the first row. No timing breakdown / POST data. |
280
- | `get_network_request` | above + `Network.getResponseBody` | Bodies only via a fresh reload capture (CDP serves bodies from the live session); `includeBody` matches by **url**. |
277
+ | `list_console_messages` | `Runtime`/`Log` events (+ `Page.reload`, or LISTEN for `durationMs` on a worker target) | `reload:true` records console+network into a unique per-capture file; default read returns the latest. Args flattened best-effort. Accepts `target: "worker:<substring>"` (Chrome only) to capture an MV3 service worker's `console.log` — see "Driving MV3 extensions" below. |
278
+ | `get_console_message` | reads the shared "latest" buffer | Index into the latest capture; throws if out of range. Resolves a worker-keyed buffer too, but is deliberately not advertised as worker-capable (see CHANGELOG 1.9.1). |
279
+ | `list_network_requests` | `Network.*` events (+ `Page.reload`, or LISTEN for `durationMs` on a worker target) | Correlated rows from the per-capture buffer; redirect chains collapse to the first row. No timing breakdown / POST data. Accepts `target: "worker:<substring>"` (Chrome only) to capture the requests an MV3 service worker itself makes — see "Driving MV3 extensions" below. |
280
+ | `get_network_request` | above + `Network.getResponseBody` | Bodies only via a fresh capture (CDP serves bodies from the live session); `includeBody` matches by **url**. Worker-capable, same as `list_network_requests`. |
281
281
  | `performance_start_trace` | `Tracing.start` / `dataCollected` | Works ONLY within one process; a live trace buffer is bound to its connection. Use `performance_trace` for robustness. |
282
282
  | `performance_stop_trace` | `Tracing.end` / `tracingComplete` | Must run in the SAME process as `performance_start_trace`; throws a clear error otherwise. |
283
283
  | `performance_analyze_insight` | parses a trace JSON file | A **CDP-native approximation** of the MCP insight analyzer (FCP/LCP/CLS/TBT/long-tasks); close but not byte-identical. Requires an explicit `tracePath`. |
@@ -293,6 +293,39 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
293
293
  | `wait_for_download` *(superset)* | `Browser.setDownloadBehavior` + `Browser.downloadWillBegin`/`downloadProgress` on a standing browser-endpoint connection | Waits for a download to finish and returns it as a real file on disk: `{path,suggestedFilename,bytes,url,target}`. **Ordering rule, not optional:** call `wait_for_download{arm:true}` *before* the click that starts the download, then click, then call `wait_for_download` again to collect it — arming this late is not a preference, it's because Chrome reverts the download-behavior override the instant the arming client disconnects, and denies an unarmed download outright. **Browser-global side effect:** while armed, *every* download in the browser is redirected into the toolkit's downloads directory. **MCP-server only:** the arm lives on a connection this server process holds open; the one-shot CLI's connection dies with the process, so nothing is captured there. Chrome-only (`browser.downloads`). |
294
294
  | `grant_permissions` *(superset)* | `Browser.grantPermissions` / `Browser.resetPermissions` on the same standing connection | Grants permissions (`geolocation`, `notifications`, `clipboardReadWrite`, ...) for an origin up front, so a page doesn't show a prompt no agent can click. Keyed by **origin**, not tab — every tab on that origin is affected, including ones opened later. `reset:true` clears this server's previous grants first (or instead); note CDP's reset is not origin-scoped, so it clears every origin at once. Same MCP-server-only reasoning as `wait_for_download`: Chrome discards the grant the moment the granting connection closes. Chrome-only (`browser.permissions`). |
295
295
 
296
+ ## Driving MV3 extensions
297
+
298
+ An MV3 extension's logic lives in a background **service worker**, not a page, so `chrome.storage.local`, `chrome.runtime`, and everything else the extension owns is unreachable through a page-typed selector. Four tools accept `target: "worker:<substring>"` (Chrome only, capability `worker.targets`) — `evaluate_script`, and, as of 1.9.1, `list_network_requests`, `get_network_request`, and `list_console_messages` — any substring of the worker's url, so `worker:<extension-id>` and `worker:background.js` both work:
299
+
300
+ ```json
301
+ {"tool": "evaluate_script", "arguments": {"target": "worker:ekgaohljhieodkfggjkfgmmamfpngdhn", "expression": "chrome.storage.local.get()", "args": []}}
302
+ ```
303
+
304
+ A few facts worth knowing before you reach for it, all measured against a real browser rather than assumed:
305
+
306
+ - **An idle MV3 worker is evicted within seconds of its last activity, and once evicted it is invisible to every target listing** — not "stopped and still visible," genuinely absent from `/json/list` and `Target.getTargets` under any filter. So a `worker:` miss almost always means "asleep," not "wrong id," and the error says so.
307
+ - **`wake` defaults to `true`:** a selector that matches nothing, or a known-but-stopped worker, is started for you first, then re-resolved. Pass `wake:false` to fail fast instead — the error then teaches the eviction fact rather than reading like a typo.
308
+ - **A wake is verified by re-reading the target list, never by the start command's own result** — Chrome's start command reports success even for a scope that doesn't exist, so nothing here trusts it.
309
+ - **Worker targets bypass the lease gate entirely.** `claim_page`/strict mode never mint a lease for a worker-targeted call, on any of the four tools; a worker id is Chrome's to create and destroy, not this toolkit's to fence.
310
+ - **If you're loading your own unpacked extension for testing, `--load-extension` does nothing on recent Chrome**, even with the documented unblock flag. Use `Extensions.loadUnpacked` over CDP instead (what this toolkit's own extension smoke does).
311
+ - **The extension id is a hash of the unpacked extension's absolute path**, so it's deterministic across loads from the same directory but differs from the id a packed/store install would get — don't hardcode an id copied from a different machine's path.
312
+ - **Before deep-debugging something that looks like "the toolkit can't see it," check you're driving the build you think you are:** `evaluate_script {target:"worker:<id>", expression:"chrome.runtime.getManifest().version"}`. Chrome keeps serving a stale unpacked build's worker until you explicitly reload the extension, and an outdated loaded build mimics every symptom below.
313
+
314
+ ### Watching what a worker sends and logs (1.9.1)
315
+
316
+ `list_network_requests`, `get_network_request`, and `list_console_messages` record a worker the same way they record a page — over the worker's own CDP session, the same `Network`/`Runtime` domains, the same `get_*` follow-up for bodies — with one difference: a worker capture can't be driven by reload. Measured against Chrome 151.0.7922.109: `Page.enable`/`Page.reload` don't exist on a worker session (`-32601 'Page.enable' wasn't found`), so a worker capture LISTENS for `durationMs` instead of reloading; `Network.requestWillBeSent`/`responseReceived`/`loadingFinished` and `Runtime.consoleAPICalled` all arrive on that same session, and `Network.getResponseBody` serves the body, exactly like a page capture.
317
+
318
+ ```json
319
+ {"tool": "list_network_requests", "arguments": {"target": "worker:ekgaohljhieodkfggjkfgmmamfpngdhn", "reload": true, "durationMs": 5000}}
320
+ ```
321
+
322
+ - **Recording a worker keeps it alive for the length of the capture — a documented side effect, not a bug.** A held CDP session suppresses MV3 idle eviction (measured: alive and still emitting events at 60s held; evicted ~30s after the recorder detaches). Watch a worker and Chrome won't put it to sleep out from under you until you stop.
323
+ - **`wake` (default `true` on a capture) is refused on a read-only call and on a bare target id**, rather than accepted and quietly doing nothing useful. A worker that idle-evicts and restarts comes back under a brand-new target id (measured: re-minted on every restart), and the buffer is keyed by id, so waking on a *read* would hand back a live worker with an empty buffer — "0 requests" for a capture that really did record some, a zero shaped like an answer. Re-run with `reload:true` to record a fresh capture instead.
324
+ - **A worker woken by the capture has already run its top-level code by the time the recorder attaches**, so a fetch it makes at startup can be missed. Trigger the request you want to observe (a message, an alarm) after the capture starts rather than relying on the worker's own boot-time fetch.
325
+ - **A `self.fetch` monkeypatch via `evaluate_script` won't reliably intercept a worker's requests, and reading a module-scoped value from `evaluate_script` won't reliably work either — both for ordinary JavaScript reasons, not a service-worker-specific one.** A value bound at module top level is not visible from `evaluate_script`'s global scope (plain lexical scoping, identical in a page); assigning `self.fetch` cannot rebind a `fetch` reference the module already captured (closure semantics, also identical in a page). `list_network_requests`/`get_network_request` observe the `Network` domain on the worker's own session directly and need neither.
326
+
327
+ `worker:` is deliberately absent everywhere else — `close_page`/`select_page`/`release_page` and `claim_page`'s `target` stay page-only, refusing the arm by name rather than silently missing, and Firefox refuses it with a capability error (`worker.targets`), since WebDriver BiDi has no extension-service-worker target to map onto.
328
+
296
329
  ## Parallel tabs: many agents, one browser
297
330
 
298
331
  Two agents pointed at the same Chrome both resolve `target: active` to whatever tab the human last focused. Nothing errors. Each one gets plausible, well-formed output from the wrong page: a snapshot of someone else's form, a fill that lands in someone else's field. A crash gets caught; a silent wrong-tab success does not.
@@ -320,11 +353,11 @@ That auto-acquired lease is a weaker tier than an explicit one, and the differen
320
353
  - **Auto** (acquired by the gate, or by `new_page` without `claim:true`): owned by a *process*. Any later call from the same pid passes with no `lease` argument at all; only another process is refused.
321
354
  - **Explicit** (`claim_page`, or `new_page{claim:true}`): still demands its token on every call, even from the process that claimed it. This is how one subagent fences a tab off from sibling subagents that share its pid, since a Claude Code session's subagents all run inside one MCP server process.
322
355
 
323
- An unleased tab under strict mode is still refused to a different process exactly as under the default: the tab's whole selector surface (`active`, no target, `index:N`, `url:`, `title:`, a bare targetId) all resolve through the same gate, so there is no selector form that quietly bypasses acquisition. Turning `CDP_REQUIRE_LEASE` off never locks a process out of leases it already holds: the pid-only check on an auto lease is not itself gated on the flag, only the *acquiring* is.
356
+ An unleased tab under strict mode is still refused to a different process exactly as under the default: the tab's whole selector surface (`active`, no target, `index:N`, `url:`, `title:`, `label:`, a bare targetId) all resolve through the same gate, so there is no selector form that quietly bypasses acquisition. Turning `CDP_REQUIRE_LEASE` off never locks a process out of leases it already holds: the pid-only check on an auto lease is not itself gated on the flag, only the *acquiring* is.
324
357
 
325
358
  **Strict mode is MCP-only, unconditionally, not by convention.** The CLI never reads `CDP_REQUIRE_LEASE` as true no matter how it is set, because a CLI invocation is one process per call: a lease it auto-acquired would be reclaimable by the dead-pid rule the instant that process exited, which is a worse position than no lease at all, and reap (below) would then read that instantly-dead-pid record as an abandoned agent tab and could close it the next time anything lists pages.
326
359
 
327
- **What can newly fail.** Any call that resolves to a leased tab without presenting that tab's token is refused, naming the tab, its url, and the label of whoever holds it. That is every selector form, not one of them: `active` or no target at all, `index:N`, `url:<substring>`, `title:<substring>`, and a bare targetId all reach the same check. The case worth singling out is `active` (or no target) against a leased tab index 0, because there you did not name the tab and before 1.2 the call silently succeeded against whatever tab happened to be first. That refusal is the point of the feature, and it is called out here rather than left to be discovered.
360
+ **What can newly fail.** Any call that resolves to a leased tab without presenting that tab's token is refused, naming the tab, its url, and the label of whoever holds it. That is every selector form, not one of them: `active` or no target at all, `index:N`, `url:<substring>`, `title:<substring>`, `label:<name>`, and a bare targetId all reach the same check. The case worth singling out is `active` (or no target) against a leased tab index 0, because there you did not name the tab and before 1.2 the call silently succeeded against whatever tab happened to be first. That refusal is the point of the feature, and it is called out here rather than left to be discovered.
328
361
 
329
362
  **Reclamation.** A lease is reclaimable when its owning process is gone, or when `lastUsedAt` is older than `ttlMs` (default 15 minutes, `CDP_LEASE_TTL_MS`). Those two are the whole list. A lease whose tab is no longer open is *reported* by `list_leases` as `target-gone`, which is useful to see, but that report is not what frees it: such a record is freed by the same two rules as any other, whichever lands first. Every checked call refreshes `lastUsedAt`, so an agent that is working never expires and no heartbeat is needed. Reclaiming mints a fresh nonce, which invalidates the previous owner's token: a stalled agent that comes back cannot keep driving a tab someone else now owns. This applies equally to an auto lease and an explicit one; the tier only changes who may use the lease without a token, never how or when it goes stale.
330
363