cdp-toolkit 1.2.0 → 1.6.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,228 @@ 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.6.0] - 2026-08-09
9
+
10
+ This release skips 1.5.0 (reserved for in-flight work on another branch).
11
+
12
+ ### Added
13
+
14
+ - **`start_screen_recording` and `stop_screen_recording`, a tab-to-video capture
15
+ pair.** `take_screenshot` answers "what did the page look like"; nothing in
16
+ the toolkit answered "what did it DO", so proving a flow worked meant
17
+ stitching stills together by hand or shelling out to a desktop recorder that
18
+ captures the whole screen instead of the tab it was told to drive. The pair
19
+ records one named target over `Page.startScreencast`, keyed by targetId like
20
+ network mocking, so several agents can record different tabs in the same
21
+ browser at once. `start_screen_recording` opens a persistent page connection,
22
+ probes ffmpeg, and returns `{target, format, spoolDir, encoder, codec,
23
+ startedAt, note}`; `stop_screen_recording` tears the stream down, encodes the
24
+ spooled frames with ffmpeg, and returns `{path, bytes, durationMs,
25
+ frameCount, encodedFrames, codec, encoder, width, height, droppedFrames,
26
+ target}`. Like `performance_start_trace`/`performance_stop_trace`, the pair
27
+ must run WITHIN THE SAME PROCESS: the frames are events on the connection
28
+ that started the screencast and cannot be re-attached from a fresh process,
29
+ so this needs the MCP server rather than the one-process-per-call CLI.
30
+
31
+ - **Chrome only, and absent rather than throwing on Firefox.** WebDriver BiDi
32
+ has no streamed-frame primitive at all (the spec offers only the one-shot
33
+ `browsingContext.captureScreenshot`), so both tools declare a new capability,
34
+ `capture.screencast`, that the BiDi driver never offers. Per the toolkit's
35
+ ADR-001 pattern, a capability a backend cannot run means the tool is missing
36
+ from `tools/list` under `--browser firefox`, not present and failing mid-call.
37
+
38
+ - **Frames are acked before the disk write, not after.** Chrome will not send
39
+ screencast frame N+1 until frame N is acknowledged with
40
+ `Page.screencastFrameAck`, so the ack fires from the event handler before
41
+ the frame's bytes are written to the spool; anything awaited before the ack
42
+ becomes the frame rate. An unacked frame stalls the whole stream.
43
+
44
+ - **Variable frame rate handled with a timestamped ledger, not a fixed
45
+ encoding rate.** Chrome emits a screencast frame on repaint, not on a clock:
46
+ a still page emits nothing and an animating one emits at whatever rate it
47
+ paints. Every captured frame is timestamped into an in-memory ledger, and on
48
+ stop that ledger is rendered as an ffconcat manifest with an explicit
49
+ per-frame duration, so a still page holds one long frame and the total video
50
+ duration tracks wallclock rather than racing or freezing.
51
+
52
+ - **Frames are coalesced onto the encoder's 40ms PTS grid, deliberately and
53
+ visibly.** ffmpeg's concat demuxer takes its stream time_base from the first
54
+ file's sub-demuxer, and image files arrive through `image2` at its default
55
+ 25fps, so every frame's presentation timestamp is silently snapped to a
56
+ 1/25s grid no matter what the manifest's `duration` lines say — `-vsync vfr`
57
+ then drops every frame that collides with an earlier one on that grid, with
58
+ no warning on stderr. A first pass caught this the hard way: a 373-frame
59
+ capture encoded to 102 frames with every check green. Three other fixes were
60
+ measured and rejected: `-r` before `-i` overrides the durations outright (a
61
+ 1.000s clip became 0.101s), `-framerate` is not an option the concat demuxer
62
+ accepts, and `settb`/`-video_track_timescale` change only the output
63
+ timebase. The toolkit now snaps the ledger onto the 40ms grid itself before
64
+ handing it to ffmpeg, keeping the first frame per slot, and reports both
65
+ numbers instead of letting them silently differ: `frameCount` is what was
66
+ captured off the wire, `encodedFrames` is what actually reached the video.
67
+
68
+ - **Encoder ladder, probed once per process from `ffmpeg -hide_banner
69
+ -encoders`, matched on the name column only:** `hevc_videotoolbox` →
70
+ `h264_videotoolbox` → `libx265` → `libx264`. Both HEVC rungs get `-tag:v
71
+ hvc1`, without which QuickTime refuses to play the MP4 it just wrote.
72
+ Matching only the encoder-name column (not the raw text) matters because
73
+ ffmpeg repeats a family name inside a sibling encoder's description — a
74
+ substring match on `libx264` also hits `libx264rgb`, an RGB-only encoder
75
+ that cannot write the `yuv420p` file the tool just promised.
76
+
77
+ - **ffmpeg is probed at START, with an actionable error, not at stop.** A
78
+ missing ffmpeg now fails before a recording is captured and thrown away,
79
+ rather than after. The error names the two install commands
80
+ (`brew install ffmpeg` / `apt install ffmpeg`) and `CDP_FFMPEG`, the env var
81
+ that points the toolkit at a binary not on `PATH`.
82
+
83
+ - **A failed encode keeps the spool and hands back the exact re-run
84
+ command.** If ffmpeg exits non-zero, the spooled frames and the generated
85
+ `frames.ffconcat` manifest are kept on disk (named in the error) instead of
86
+ being deleted with the failed attempt, along with the literal ffmpeg
87
+ command line to re-run by hand.
88
+
89
+ - **`start_screen_recording` takes the same capture knobs as the underlying
90
+ CDP call:** `format` (`jpeg` default, or `png`), `quality` (JPEG 0-100),
91
+ `maxWidth`/`maxHeight` (also pins frame size against a mid-recording
92
+ viewport change), and `everyNthFrame` to skip repaints on a high-framerate
93
+ page before they're coalesced away. `stop_screen_recording` takes
94
+ `savePath` to override the default `<artifact dir>/screen-recording-
95
+ <targetIdShort>-<stamp>.mp4` output path.
96
+
97
+ ### Notes
98
+ - Tool count is now 41 (29 parity + 12 superset); 33 under Firefox, which
99
+ lacks the eight capability-gated tools (`start_screen_recording` /
100
+ `stop_screen_recording` join the tracing, heap-snapshot, and Lighthouse
101
+ groups already absent there).
102
+
103
+ ## [1.4.0] - 2026-08-06
104
+
105
+ This is the first npm release since 1.2.0. Both `v1.2.1` and `v1.3.0` were
106
+ tagged and pushed to GitHub but never reached npm, because the release runs
107
+ through GitHub Actions with OIDC trusted publishing and Actions was down. Their
108
+ changes are folded in here rather than published separately, so 1.4.0 on npm
109
+ carries the lease-conflict fix (1.2.1), the tab-origin ledger (1.3.0), and the
110
+ cookie group plus the `evaluate_script` file sink below. See the [1.3.0] and
111
+ [1.2.1] sections for the folded-in details.
112
+
113
+ ### Added
114
+
115
+ - **`set_cookie` and `delete_cookies`, the WRITE half of the cookie group.**
116
+ `list_cookies` made the store readable; these make it writable, including the
117
+ httpOnly and secure cookies `document.cookie` can neither create nor remove,
118
+ so seeding an authenticated session or clearing one is a tool call rather
119
+ than a hand-rolled raw CDP detour. `set_cookie` takes `{name, value}` plus
120
+ the usual cookie attributes (`url`, `domain`, `path`, `expires` in Unix
121
+ seconds, `httpOnly`, `secure`, `sameSite`) and answers `{set:true, target}`;
122
+ `delete_cookies` takes `{name}` plus `url` or `domain` and an optional
123
+ `path`, and answers `{deleted:true, target}`. Chrome rides `Network.setCookie`
124
+ and `Network.deleteCookies`, Firefox rides `storage.setCookie` and
125
+ `storage.deleteCookies` partitioned by the resolved browsing context.
126
+ Three deliberate refusals, all of which exist because the alternative is a
127
+ call that looks like it worked. First, either `url` or `domain` is required
128
+ on both tools: a cookie has to be attributed to a site, and guessing the
129
+ current page's origin would write a real cookie somewhere the caller never
130
+ named, while a name-only delete would sweep the whole partition. Second,
131
+ Chrome answers `Network.setCookie` with `success:false` rather than an error
132
+ when it declines a cookie (a domain the url does not cover, a secure cookie
133
+ on an insecure origin, an oversized value), so that false is raised as an
134
+ error instead of being reported as `set:true`. Third, BiDi has no `url`
135
+ parameter and requires a `domain`, so the Firefox driver derives one from the
136
+ url's host and throws on a url with no host (`about:blank`, a `data:` URL)
137
+ rather than sending a call Firefox answers cleanly while writing nothing.
138
+ Two things are deliberately absent: the response never echoes the value back,
139
+ since the value is usually the credential the caller just supplied, and
140
+ `delete_cookies` reports no count, because neither protocol says how many
141
+ cookies it removed and a number here would be invented. Read `list_cookies`
142
+ before and after when a real count is needed. `path` is passed through
143
+ exactly as given and never defaulted to `/`, since Chrome derives the path
144
+ from `url` and an invented default would widen a deliberately narrow cookie.
145
+
146
+ - **`list_cookies`, a new tool that reads the target page's cookie store,
147
+ httpOnly cookies included.** There was no cookie tool at all before this, so
148
+ reading an httpOnly session cookie, exactly the set `document.cookie` cannot
149
+ see and `evaluate_script` therefore cannot reach, meant dropping to raw CDP
150
+ by hand: a fallback that fights the very contention this toolkit exists to
151
+ avoid, since a page target accepts a second websocket and then never answers
152
+ on it while another client is attached. Each cookie carries `name`, `value`,
153
+ `domain`, `path`, `expires` (Unix seconds, `-1` for a session cookie),
154
+ `size`, `httpOnly`, `secure`, `sameSite` and `session`. The read is
155
+ page-scoped, not browser-wide: Chrome uses `Network.getCookies` rather than
156
+ the whole-profile `Storage.getCookies`, and Firefox uses `storage.getCookies`
157
+ partitioned by that browsing context, so a call naming one tab never returns
158
+ every other site's credentials. Both backends are supported, with the same
159
+ shape: the BiDi differences (a bytes-value that may be base64, an absent
160
+ `expiry` meaning a session cookie, a lowercase `sameSite`) are normalized in
161
+ the driver. Optional `domain` and `name` filters narrow the result: `name` is
162
+ an exact match, `domain` ignores a leading dot on either side and also
163
+ matches subdomains, and that is the entire matching rule. Optional `savePath`
164
+ behaves exactly as `evaluate_script`'s does: the cookie array is written to
165
+ that file as JSON and the response carries only `{path, bytes, count,
166
+ target}`, with no cookie value in any form, no preview and no truncation,
167
+ which is how to capture a session cookie without putting the credential in
168
+ the calling agent's transcript. Tab leases are respected the same way every
169
+ sibling tool respects them, at target resolution.
170
+
171
+ - **`evaluate_script` can write its value to a file instead of returning it,
172
+ via a new optional `savePath`.** Reading a JWT, a session token, or any other
173
+ credential out of the page previously forced the secret through the tool
174
+ result, which is to say straight into the calling agent's transcript, with no
175
+ way to opt out. With `savePath` set, the evaluated value is serialized as
176
+ JSON to that file and the response carries only `{path, bytes, type, target}`:
177
+ no copy of the value, no preview, no truncated form, no leading characters.
178
+ `type` is the JS `typeof` of the value (`"null"` for null), which describes
179
+ the value without disclosing any of it. Path handling matches
180
+ `take_heapsnapshot`: an absolute path is used as-is, a relative one resolves
181
+ under the artifact dir (`/tmp/cdp-toolkit`, or `CDP_ARTIFACT_DIR`), and
182
+ missing parent directories are created. Omitting `savePath` leaves
183
+ `evaluate_script` behaving exactly as it did before, value returned inline,
184
+ so this is purely additive for every existing caller. Thrown page-side
185
+ exceptions still surface as errors and are never written into the file.
186
+
187
+ ## [1.3.0] - 2026-08-06
188
+
189
+ `v1.2.1` was tagged and pushed to GitHub but never published to npm: the
190
+ release runs through GitHub Actions with OIDC trusted publishing, and Actions
191
+ was down for a full day. Rather than publish that fix on its own, it ships
192
+ here alongside tab origin, so 1.3.0 is the first npm release carrying both
193
+ the lease feature (`claim_page`/`release_page`/`list_leases`) and tab origin.
194
+
195
+ ### Added
196
+
197
+ - **`list_pages` now reports where each tab came from.** Every tab cdp-toolkit
198
+ creates (`new_page`, and `claim_page` with no `targetId`) is written to a
199
+ creation ledger stored alongside the lease files, and each `list_pages` row
200
+ gains `origin`: `"agent"` when the toolkit created the tab, carrying the
201
+ creating `label` and `createdAt`, otherwise `"unknown"`. The record outlives
202
+ the lease on purpose. Once an agent releases, expires, or dies, its abandoned
203
+ tab was previously indistinguishable from one the human opened, which is
204
+ exactly when provenance is worth having. `origin` is never `"human"`: the
205
+ toolkit cannot prove a person opened a tab, so `"unknown"` is the honest word
206
+ for anything it did not create itself. A tab whose record exists but could
207
+ not be read reports `"unknown"` plus `originUnreadable`, so a broken record is
208
+ never mistaken for no record, matching how `list_leases` reports an unreadable
209
+ lease. The ledger is reaped when it is read, dropping records for targets the
210
+ browser no longer has, so nothing has to be scheduled to clean it up. Purely
211
+ additive: `id`, `url`, `title`, and `type` are unchanged, and a missing or
212
+ unreadable ledger degrades to every page reporting `"unknown"` rather than
213
+ failing the call.
214
+
215
+ ## [1.2.1] - 2026-08-05
216
+
217
+ ### Fixed
218
+
219
+ - **On Chrome, a refusal caused by another agent's lease is now reported as a
220
+ lease conflict.** It was reported with the code meaning the target did not
221
+ exist, and the holder details were dropped, so a caller could not tell "this
222
+ tab is spoken for, go get the token or retry" apart from "that tab is not
223
+ there, stop looking". The refusal message itself always read correctly, so
224
+ anyone reading the text saw the real reason; only the machine readable code
225
+ and the holder fields were wrong. A genuinely missing target is still
226
+ reported as `no-such-target`. Enforcement was never affected: a leased tab
227
+ was refused before this fix and is refused after it. Firefox was never
228
+ affected, and now reports an identical conflict to Chrome's.
229
+
8
230
  ## [1.2.0] - 2026-08-05
9
231
 
10
232
  Two things landed since 1.0: a second backend, Firefox over WebDriver BiDi,
@@ -93,4 +315,5 @@ First public release.
93
315
  - 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`.
94
316
  - Runtime: Bun ≥ 1.1 (recommended) or Node ≥ 22 (for the global `WebSocket`). Requires Chrome/Chromium started with `--remote-debugging-port=9222`.
95
317
 
318
+ [1.6.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.6.0
96
319
  [1.0.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.0.0
package/CONTRACT.md CHANGED
@@ -12,7 +12,7 @@ writing a module.
12
12
  3. **TypeScript strict.** `tsc --noEmit` must pass with `strict`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`. Import types with `import type`. Use `.ts` extensions in imports (e.g. `import { withPage } from "../client.ts"`).
13
13
  4. **One module per assigned bundle.** Write only the files you are assigned. Never edit `client.ts`, `types.ts`, or another agent's file.
14
14
  5. **Tool fn naming = camelCase of the MCP tool name.** `take_snapshot` → `export async function takeSnapshot(...)`. `list_network_requests` → `listNetworkRequests`. The integration step maps mechanically, so the names must be exact.
15
- 6. **Never re-resolve a target outside a choke point.** There are exactly three functions that turn a `TargetSelector` into a concrete tab, and all three call `assertLeaseOk`: `resolveTarget` (`src/client.ts`, Chrome), `resolveContext` (`src/bidi/driver.ts`, Firefox), and `resolvePage` (`src/shared-tools.ts`, used by `close_page` and `select_page`). A new tool must go through one of them. If you add a fourth resolution path, it must call `assertLeaseOk` too, or every tool on that path silently loses lease protection. Do NOT add a lease check to an individual tool: that puts the burden on every future contributor to remember it, and one forgotten tool defeats the feature for that tool. A lease conflict throws `LeaseConflictError` rather than being modelled as a capability gap under ADR-001. That departure is deliberate and reviewed: a runtime ownership collision between two agents is not a missing backend capability, so it cannot be discovered at `tools/list` time and must surface at call time.
15
+ 6. **Never re-resolve a target outside a choke point.** There are exactly three functions that turn a `TargetSelector` into a concrete tab, and all three call `assertLeaseOk`: `resolveTarget` (`src/client.ts`, Chrome), `resolveContext` (`src/bidi/driver.ts`, Firefox), and `resolvePage` (`src/shared-tools.ts`, used by `close_page` and `select_page`). A new tool must go through one of them. If you add a fourth resolution path, it must call `assertLeaseOk` too, or every tool on that path silently loses lease protection. Do NOT add a lease check to an individual tool: that puts the burden on every future contributor to remember it, and one forgotten tool defeats the feature for that tool. A lease conflict throws `LeaseConflictError` rather than being modelled as a capability gap under ADR-001. That departure is deliberate and reviewed: a runtime ownership collision between two agents is not a missing backend capability, so it cannot be discovered at `tools/list` time and must surface at call time. If you touch any of those three choke points, or the error handling above them, re-run `bun run lease:smoke` (`test/lease-smoke.ts`): `bun test` cannot reach this, because a lease is keyed on the claiming pid and only a second live OS process can collide with the first. That harness spawns one, drives a real browser, and asserts the refusal keeps its type, its `targetId` and its `holder` and actually blocks the side effect. It is safe against a browser with real tabs open: one throwaway tab, addressed and closed by its own id, with a runtime-captured baseline diffed at the end.
16
16
 
17
17
  ## Core client API (from `src/client.ts`)
18
18
 
@@ -61,6 +61,7 @@ export async function click(args: ClickArgs): Promise<unknown>;
61
61
  - Artifacts (PNG, trace, heapsnapshot, lighthouse report): write under `ARTIFACT_DIR` = `process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit"`. `mkdir -p` it. Filenames: `<tool>-<targetIdShort>-<isoish>.<ext>` where you derive a stamp from `Date.now()` at call time (allowed in normal runtime; only the Workflow *script* sandbox forbids it, modules run normally).
62
62
  - Selected-target state file (for `select_page`): `process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit"` + `/selected`. Contains a bare targetId. `resolveTarget` does NOT read it; tools may read it as a fallback default if you choose, but the simplest correct behavior is fine.
63
63
  - Recorder buffers: `${ARTIFACT_DIR}/rec-<targetId>.jsonl`.
64
+ - Screencast spool: `${ARTIFACT_DIR}/screencast-<targetIdShort>-<isoish>/` holding `frame-NNNNNN.jpg|png` plus the generated `frames.ffconcat`. Deleted on a successful encode; **kept, and named in the error, when ffmpeg fails**, so the frames can be re-encoded by hand instead of lost. Output video: `${ARTIFACT_DIR}/screen-recording-<targetIdShort>-<isoish>.mp4`.
64
65
  - Lease files: `${ARTIFACT_DIR}/lease-<backend>-<targetId>.json`, one per leased tab, backend is `chrome` or `firefox`. Written with the `wx` exclusive-create flag so two simultaneous claims cannot both win. Keyed by backend plus id because a CDP targetId and a BiDi context id are not disjoint by construction.
65
66
 
66
67
  ## The element-reference scheme (READ if you touch snapshot or input)
@@ -105,6 +106,7 @@ A `Uid` **is** a CDP `backendDOMNodeId` (a number). This makes refs stateless:
105
106
  | `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. |
106
107
  | `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`. |
107
108
  | `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.** |
109
+ | `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`. |
108
110
  | `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 }`. |
109
111
  | `src/tools/lighthouse.ts` | `lighthouse_audit`→`lighthouseAudit` | **Not pure CDP.** Spawn `npx --yes lighthouse <url> --port=9222 --output=json --output-path=<artifact> --chrome-flags=...` via `node:child_process`. If lighthouse/npx unavailable, throw a clear `CdpError` explaining the dependency. Return `{ path, categories: {performance, accessibility, ...scores} }` parsed from the JSON. Top comment must state this is the one non-CDP tool. |
110
112
  | `src/tools/network_mock.ts` | `mock_request`→`mockRequest`, `list_mocks`→`listMocks`, `clear_mocks`→`clearMocks` | **Toolkit addition (not MCP parity): a persistent fake backend.** Holds a per-target session (module-level `Map`, like `recorder.ts`) with a persistent `openPage` conn and `Fetch.enable` (patterns). `Fetch.requestPaused` → `selectRule` → `Fetch.fulfillRequest`/`failRequest`/`continueRequest` (default-continue unmatched, or page hangs). Pure logic (`urlMatches`/`selectRule`/`buildFulfillParams`/`effectiveAction`) is unit-tested; I/O via `test/mock-smoke.ts`. Persistence is MCP-server-process-scoped (CLI is one-shot). Request-stage only. |
@@ -115,4 +117,4 @@ End every module with a brief block comment listing the exact CDP methods/domain
115
117
 
116
118
  ## Integration (built after all modules exist)
117
119
 
118
- `src/index.ts` exports a `TOOLS` registry: `Record<mcpToolName, (args) => Promise<unknown>>` importing every fn. `src/cli.ts`: `bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<k> <v> ...]` → parse args, call `TOOLS[tool]`, print JSON result or `{ error }`, exit non-zero on throw. `README.md`: the full 29-tool mapping table + usage + the "why direct CDP beats MCP for known targets" rationale.
120
+ `src/index.ts` exports a `TOOLS` registry: `Record<mcpToolName, (args) => Promise<unknown>>` importing every fn. `src/cli.ts`: `bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<k> <v> ...]` → parse args, call `TOOLS[tool]`, print JSON result or `{ error }`, exit non-zero on throw. `README.md`: the full tool mapping table + usage + the "why direct CDP beats MCP for known targets" rationale.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # cdp-toolkit
2
2
 
3
- **A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the *one* Chrome tab you point it at over the raw DevTools Protocol, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, one tab, no fan-out, plus a built-in network-mocking fake backend. **36 tools.**
3
+ **A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the Chrome tabs you point it at over the raw DevTools Protocol: any number of tabs, one explicitly named target per call over one direct socket, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, no all-target fan-out, plus tab leases so several agents can work one browser, plus a built-in network-mocking fake backend. **41 tools.**
4
4
 
5
- > For AI-agent developers and Claude Code / Cursor users who need **one known tab driven reliably**, not a Puppeteer-managed browser.
5
+ > For AI-agent developers and Claude Code / Cursor users who need **the tabs they name driven reliably**, not a Puppeteer-managed browser.
6
6
 
7
7
  [![CI](https://github.com/sblattj/cdp-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/sblattj/cdp-toolkit/actions/workflows/ci.yml)
8
8
  [![npm](https://img.shields.io/npm/v/cdp-toolkit?color=cb3837&logo=npm)](https://www.npmjs.com/package/cdp-toolkit)
@@ -16,7 +16,7 @@
16
16
 
17
17
  You let an AI agent (Claude Code, Cursor, any MCP host) control a Chrome tab: click, type, read the page, take screenshots, even fake API responses to test a UI before its backend exists. [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) does this too, but it runs a whole Puppeteer browser and talks to *all* your open tabs at once, which is why a single busy tab can freeze it and leave you typing `/mcp` to restart the server mid-task.
18
18
 
19
- `cdp-toolkit` keeps it simple: **one connection to the one tab you point it at, and a time limit on every action.** When something stalls, you get an error back, not a frozen agent. Same things you could do before, minus the wedging and the restarts.
19
+ `cdp-toolkit` keeps it simple: **one connection to the one tab each call names, and a time limit on every action.** Drive as many tabs as you like, one named target at a time, and point several agents at the same Chrome without them stealing each other's tabs. When something stalls, you get an error back, not a frozen agent. Same things you could do before, minus the wedging and the restarts.
20
20
 
21
21
  That's the whole pitch. The technical *why* (fan-out, lazy domain enabling, the `Network.enable` hang) is below.
22
22
 
@@ -24,13 +24,13 @@ That's the whole pitch. The technical *why* (fan-out, lazy domain enabling, the
24
24
 
25
25
  If [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) ever **wedged your agent on a busy tab**, you've met its design: it manages a Puppeteer browser, fans every operation out across *all* attached targets, and enables the `Network` domain on connect so it can passively buffer everything. That generality is exactly what makes it fragile once you already know which tab you want to drive.
26
26
 
27
- `cdp-toolkit` makes the opposite bet. Every tool attaches **one** direct WebSocket to **one** resolved target, enables only the CDP domains it needs, and enforces a **per-command timeout** so a stuck renderer can never hang the caller. For driving a tab you already have in hand, the common automation and evidence-gathering case, it's materially more robust.
27
+ `cdp-toolkit` makes the opposite bet. Every call attaches **one** direct WebSocket to the **one** target it resolved, enables only the CDP domains it needs, and enforces a **per-command timeout** so a stuck renderer can never hang the caller. The connection lives for that call and closes after it, so nothing bleeds between tabs. For driving tabs you already have in hand, the common automation and evidence-gathering case, it's materially more robust.
28
28
 
29
29
  ## cdp-toolkit vs chrome-devtools-mcp
30
30
 
31
31
  | | **cdp-toolkit** | **chrome-devtools-mcp** |
32
32
  |---|---|---|
33
- | **Target scope** | one resolved tab (`active` / `index:N` / `url:` / `title:`) | all attached targets; fan-out can stall on an unrelated tab |
33
+ | **Target scope** | one resolved tab per call (`active` / `index:N` / `url:` / `title:`), any number of tabs across calls | all attached targets; fan-out can stall on an unrelated tab |
34
34
  | **`Network.enable`** | lazy, only when a tool needs it | eager on connect, a known hang on busy renderers |
35
35
  | **Per-command timeout** | ✅ bounded (`CDP_TIMEOUT_MS`, 15s default), rejects, never hangs | ❌ none; a stuck renderer blocks indefinitely |
36
36
  | **Element refs** | stateless `backendDOMNodeId` (resolved on demand) | server-side handle table (can drift / expire) |
@@ -38,14 +38,15 @@ If [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp
38
38
  | **Runtime deps** | CDP/CLI layer: native `WebSocket` + `fetch` (the MCP server adds only the MCP SDK) | Puppeteer stack |
39
39
  | **Auto-wait / retry** | ❌ single-shot; re-snapshot between steps | ✅ Puppeteer's auto-wait envelope |
40
40
 
41
- **Use `chrome-devtools-mcp`** if you need multi-target autonomy or Puppeteer's auto-wait/retry on an *unknown* page. **Use `cdp-toolkit`** when you have one tab in hand and need it not to hang. They coexist: `cdp-toolkit`'s tools are namespaced `mcp__cdp-toolkit__*`, distinct from `mcp__chrome-devtools__*`.
41
+ **Use `chrome-devtools-mcp`** if you need multi-target autonomy or Puppeteer's auto-wait/retry on an *unknown* page. **Use `cdp-toolkit`** when you know which tabs you want driven, however many that is, and need them not to hang. They coexist: `cdp-toolkit`'s tools are namespaced `mcp__cdp-toolkit__*`, distinct from `mcp__chrome-devtools__*`.
42
42
 
43
43
  ## Is this for you?
44
44
 
45
45
  **Yes, if you:**
46
- - drive one known tab from Claude Code / Cursor / any MCP host and want it to never wedge;
46
+ - drive tabs you name from Claude Code / Cursor / any MCP host and want them to never wedge;
47
47
  - need to **mock a backend** to build or test a UI before the real API exists;
48
- - have been bitten by the eager-`Network.enable` hang on a busy renderer.
48
+ - have been bitten by the eager-`Network.enable` hang on a busy renderer;
49
+ - run **multiple agents against one Chrome** and need them to stop stealing each other's tabs.
49
50
 
50
51
  **Probably not, if you:**
51
52
  - need cross-browser (Firefox / WebKit), use [`playwright-mcp`](https://github.com/microsoft/playwright-mcp);
@@ -115,20 +116,23 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
115
116
 
116
117
  ## Key capabilities
117
118
 
118
- - **Single-target reliability.** One WebSocket to one resolved target, a bounded timeout on every CDP command, lazy domain enabling, and stateless element refs. There is no broadcast step that can stall on a wedged tab.
119
+ - **One target per call, never a broadcast.** Each call opens one WebSocket to the one target it named, with a bounded timeout on every CDP command, lazy domain enabling, and stateless element refs. Drive as many tabs as you like across calls; there is still no broadcast step that can stall on a wedged tab.
119
120
  - **Network mocking: build the UI before the backend exists.** `mock_request` arms a persistent per-target fake backend: return canned responses, force errors, or inject latency/fault rates. Mocks survive reloads and navigations until `clear_mocks`.
120
- - **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, and heap snapshots. 36 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
121
+ - **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, heap snapshots, a cookie group that reads, writes, and deletes httpOnly cookies, and tab-to-video screen recording. 41 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
122
+ - **Many agents, one browser, no stolen tabs.** `claim_page` hands out an opaque lease token for one tab; every other tool checks it at target resolution, so an unqualified call against a leased tab is refused by name rather than silently retargeted to whatever tab a different agent is driving.
123
+ - **Out-of-model secret handling.** A read that would return a credential, a JWT in `localStorage` via `evaluate_script` or an httpOnly session cookie via `list_cookies`, takes a `savePath` that writes the value to a file and keeps it out of the tool response entirely, so the secret never lands in the agent transcript. That same per-call, in-process design makes cdp-toolkit a clean substrate for credential-injection tools: a vaulted password can be typed straight into the DOM while only a status crosses back to the model, never the secret.
124
+ - **Provenance that outlives the lease.** `list_pages` reports `origin: "agent"` (with the creating `label`) for every tab this toolkit opened, and `"unknown"` otherwise. It never claims `"human"`: the absence of a creation record cannot prove a person opened the tab, so `"unknown"` is the honest word once an agent releases, expires, or dies and its tab is left behind.
121
125
 
122
126
  ## Why raw CDP beats the MCP for known targets
123
127
 
124
128
  Each point below leads with the symptom you've probably hit, then the cause, then the fix.
125
129
 
126
- - **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. cdp-toolkit resolves *one* target (`active | index:N | url:<substr> | title:<substr> | <targetId>`) and attaches a single WebSocket to just that page.
130
+ - **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.
127
131
  - **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`).
128
132
  - **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.
129
133
  - **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.
130
134
 
131
- The trade-off is generality: this toolkit targets one known page at a time and does **not** replicate Puppeteer's auto-wait/retry envelope. Re-`take_snapshot` between steps rather than expecting an implicit wait.
135
+ The trade-off is generality: this toolkit acts on one named page per call and does **not** replicate Puppeteer's auto-wait/retry envelope. Re-`take_snapshot` between steps rather than expecting an implicit wait.
132
136
 
133
137
  ## Network mocking: a fake backend for building/testing UIs
134
138
 
@@ -159,6 +163,17 @@ cdp navigate_page --target index:0 --url https://example.com
159
163
  cdp take_snapshot --target url:example --interactiveOnly true
160
164
  cdp click --target index:0 --uid 42
161
165
  cdp evaluate_script --json '{"expression":"document.title"}'
166
+ # Read a value WITHOUT it landing in the response (or an agent transcript):
167
+ # savePath writes the value to a JSON file and returns {path,bytes,type,target} only.
168
+ cdp evaluate_script --json '{"expression":"localStorage.getItem(\"auth\")","savePath":"auth.json"}'
169
+ # Cookies, httpOnly ones included (document.cookie cannot see those):
170
+ cdp list_cookies --target index:0 --json '{"domain":"example.com"}'
171
+ # Same read, with the values kept out of the response: {path,bytes,count,target} only.
172
+ cdp list_cookies --json '{"name":"session","savePath":"cookies.json"}'
173
+ # Write one, httpOnly included (document.cookie cannot create those either):
174
+ cdp set_cookie --json '{"name":"session","value":"abc","url":"https://example.com/","httpOnly":true}'
175
+ # Remove it again. Either url or domain is required on both write tools.
176
+ cdp delete_cookies --json '{"name":"session","url":"https://example.com/"}'
162
177
  cdp take_screenshot --target url:example --fullPage true
163
178
  cdp lighthouse_audit --url https://example.com --json '{"categories":["performance"]}'
164
179
  ```
@@ -186,7 +201,7 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
186
201
 
187
202
  ## Firefox (WebDriver BiDi)
188
203
 
189
- cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 36-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
204
+ cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 41-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
190
205
 
191
206
  ```bash
192
207
  cdp --browser firefox take_snapshot # CLI: explicit flag
@@ -206,8 +221,9 @@ Backend selection precedence: `--browser chrome|firefox` flag, then `CDP_BROWSER
206
221
  - `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `performance_trace` (needs `trace.performance`)
207
222
  - `take_heapsnapshot` (needs `heap.snapshot`)
208
223
  - `lighthouse_audit` (needs `audit.lighthouse`)
224
+ - `start_screen_recording`, `stop_screen_recording` (needs `capture.screencast`): BiDi has no streamed-frame primitive at all, only the one-shot `browsingContext.captureScreenshot`.
209
225
 
210
- Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain) and the `claim_page`/`release_page`/`list_leases` lease group, is available under both backends: 30 of the 36 tools.
226
+ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain) and the `claim_page`/`release_page`/`list_leases` lease group, is available under both backends: 33 of the 41 tools.
211
227
 
212
228
  **Honest capability gaps, not oversold parity:**
213
229
 
@@ -218,19 +234,22 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
218
234
  - **`locate.text` is not available** (Firefox 153's `browsingContext.locateNodes` rejects the `innerText` locator type as unsupported), unlike Chrome, which has it via `DOM.performSearch`.
219
235
  - **No `--browser firefox` attach mode.** By design, per the launch model above: every invocation gets its own throwaway Firefox process and profile.
220
236
 
221
- ## The tools (29 parity + 7 superset = 36)
237
+ ## The tools (29 parity + 12 superset = 41)
222
238
 
223
- The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 7 superset tools (`performance_trace`, the `mock_request`/`list_mocks`/`clear_mocks` group, and the `claim_page`/`release_page`/`list_leases` lease group) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
239
+ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 12 superset tools (`performance_trace`, the `list_cookies`/`set_cookie`/`delete_cookies` cookie group, the `mock_request`/`list_mocks`/`clear_mocks` group, the `claim_page`/`release_page`/`list_leases` lease group, and the `start_screen_recording`/`stop_screen_recording` screen-recording pair) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
224
240
 
225
241
  | MCP name | CDP method(s) | Parity notes / gaps |
226
242
  |---|---|---|
227
- | `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. |
243
+ | `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. |
228
244
  | `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). |
229
245
  | `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. |
230
246
  | `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. |
231
247
  | `navigate_page` | `Page.navigate` / `Page.reload` + load events | Returns `{url,frameId,waitedFor}` (no auto-snapshot). `waitUntil` supports `load`/`domcontentloaded`. `reload:true` (+ `ignoreCache:true` for a hard reload). |
232
248
  | `wait_for` | `Runtime.evaluate` (poll `innerText`) | Text-substring waiting only; throws on timeout rather than returning `{found:false}`. |
233
- | `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. |
249
+ | `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. |
250
+ | `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. |
251
+ | `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. |
252
+ | `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. |
234
253
  | `take_snapshot` | `Accessibility.getFullAXTree` | uid is the raw `backendDOMNodeId` (stateless, non-sequential). Full tree in one shot; frames flattened. `interactiveOnly` is a toolkit addition. |
235
254
  | `click` | `Input.dispatchMouseEvent` | No implicit auto-wait/retry; resolves and acts once; re-snapshot between steps. |
236
255
  | `hover` | `Input.dispatchMouseEvent` (`mouseMoved`) | Same single-shot model as `click`. |
@@ -241,6 +260,8 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 7 superset tools (`p
241
260
  | `press_key` | `Input.dispatchKeyEvent` | Curated named-key table + single chars; not the full Puppeteer KeyInput enum. |
242
261
  | `upload_file` | `DOM.setFileInputFiles` | Requires a resolvable `<input type=file>` (uid or selector). |
243
262
  | `take_screenshot` | `Page.captureScreenshot` (+ layout metrics) | Clip scale fixed at 1. Full-page uses `captureBeyondViewport` + layout-metrics clip. |
263
+ | `start_screen_recording` *(superset)* | `Page.startScreencast` / `Page.screencastFrame` / `Page.screencastFrameAck` | **Toolkit addition; chrome-devtools-mcp has no screen-recording tool.** Opens a persistent per-target connection and spools frames to a ledger on disk; pairs with `stop_screen_recording` in the SAME process, for the same cross-process reason as `performance_start_trace`. ffmpeg is probed here so a missing encoder fails before a recording is captured. Chrome only: absent from `tools/list` under `--browser firefox` (needs `capture.screencast`, which BiDi has no primitive for). |
264
+ | `stop_screen_recording` *(superset)* | `Page.stopScreencast` (+ ffmpeg encode) | Assembles the spooled frames into an H.265 (`hevc_videotoolbox`, falling back to `h264_videotoolbox` → `libx265` → `libx264`) MP4 using per-frame durations from the capture ledger, coalesced onto ffmpeg's 40ms concat-demuxer grid. Returns `{path,bytes,durationMs,frameCount,encodedFrames,codec,encoder,width,height,droppedFrames,target}` (`frameCount` captured vs. `encodedFrames` in the video). On an ffmpeg failure the spool is kept and the exact re-run command is named in the error. |
244
265
  | `emulate` | `Emulation.*` / `Network.emulateNetworkConditions` | Stateless: UA/CPU/media/network overrides reset when the per-call connection closes. No named device presets. |
245
266
  | `resize_page` | `Emulation.setDeviceMetricsOverride` | Verifies via `window.innerWidth/innerHeight`. Override persists on the target. |
246
267
  | `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. |
@@ -285,9 +306,33 @@ Pass that token as `lease` on every later call against the tab. `new_page` with
285
306
 
286
307
  **Unreadable lease files.** A row in `list_leases` can carry `unreadable`, holding the errno (for example `EACCES`) or `"unparseable"`, when the lease file exists but could not be read or parsed. On such a row `label`, `pid`, `createdAt`, `lastUsedAt`, and `ttlMs` are zero placeholders, not real values, since they live inside the file that could not be read. `stale` is always `false` on that row, deliberately: `stale` is what a reader treats as free to take, and an unreadable lease must never read that way.
287
308
 
309
+ **Verifying it against a real browser.** `bun run lease:smoke` (`test/lease-smoke.ts`) is the end-to-end harness for this feature. It drives a REAL browser on `CDP_BASE` (default `http://127.0.0.1:9222`) and spawns a genuine second OS process for the stranger role, because a lease records the claiming pid and a sequence of CLI calls can never collide: the first process is dead before the second starts. It asserts that the owner can act, that a tokenless stranger is refused with the `LeaseConflictError` type, `targetId` and `holder` intact and NOT coded `no-such-target`, that the refusal actually prevented the side effect (proved by reading the marker back, not by the throw), that a genuinely missing target still reports `no-such-target`, that the stranger is admitted after `release_page`, and that a lease orphaned by a real child process which exited is reclaimable with a fresh nonce. It is safe to point at a browser with real tabs open: it opens exactly one throwaway `about:blank` tab, addresses it only by its own target id, closes exactly that tab, and never closes the browser. It captures its own baseline tab listing at runtime and fails only if a tab that existed before the run went missing; tabs you open mid-run are ignored. Lease files go to a private temp dir that is removed on exit.
310
+
311
+ ### Which tab did an agent open? (`origin`)
312
+
313
+ A lease answers "who is *driving* this tab right now". It cannot answer "who *opened* it", and that second question outlives the first: the moment an agent releases, expires, or dies, its abandoned tab is indistinguishable from one you opened yourself. Diffing two `list_pages` snapshots does not settle it either, since a tab you opened a minute ago looks exactly like a stray agent tab.
314
+
315
+ So every tab this toolkit creates is written to a creation ledger, and `list_pages` reports it:
316
+
317
+ ```json
318
+ {"id": "1A2B...", "url": "https://example.com", "title": "Example", "type": "page",
319
+ "origin": "agent", "label": "checkout-agent", "createdAt": 1754400000000}
320
+ ```
321
+
322
+ - `origin: "agent"` means cdp-toolkit created the tab, via `new_page` or via `claim_page` with no `targetId`. The row then also carries the creating `label` (the same value a lease would show, defaulting to `pid-<pid>`) and `createdAt`.
323
+ - `origin: "unknown"` means there is no creation record. **It never says `"human"`.** The toolkit cannot prove a person opened a tab: no record is equally consistent with a tab opened before the server started, a tab restored from a previous session, another tool driving the same Chrome, or a record that could not be written. `unknown` is the honest word, and reading it as "safe, a human opened this" is the one mistake this field exists to prevent.
324
+
325
+ **It outlives the lease, deliberately.** `release_page`, expiry, and reclamation all leave the record alone. Only the tab going away removes it: the ledger is reaped when it is read, dropping records for targets the browser no longer has, so it stays self-cleaning with nothing scheduled.
326
+
327
+ **Claiming a tab you did not open records nothing.** `claim_page` with an explicit `targetId` takes ownership of an existing tab; that tab keeps reporting `origin: "unknown"`, because provenance is not ownership.
328
+
329
+ **Unreadable records.** If a target has a creation record that could not be read or parsed, its row reports `origin: "unknown"` plus `originUnreadable` (the errno, for example `EACCES`, or `"unparseable"`). Same principle as `list_leases`' `unreadable` rows: a broken record must never be indistinguishable from no record at all. A missing or unreadable ledger never fails `list_pages`; every page simply reports `unknown`.
330
+
331
+ **Backward compatible.** `id`, `url`, `title`, and `type` are unchanged. The provenance fields are purely additive.
332
+
288
333
  **What this does not do.**
289
334
 
290
- - No fan-out. The shape is many agents with one tab each. One agent driving several tabs at once is a different feature and is not addressed.
335
+ - No fan-out. Every call still names exactly one tab. An agent that wants several drives them a call at a time and holds a separate lease per tab; a single call acting on many tabs is a different feature and is not addressed.
291
336
  - No isolation. Two leased tabs on one Chrome still share cookies, `localStorage`, and every other origin-scoped store. A lease is ownership of a tab, not isolation of the browser under it.
292
337
  - No protection from humans. The lease is enforced against cdp-toolkit's own tool calls. It cannot stop someone clicking into the tab.
293
338
  - Not for the CLI. A CLI invocation is one process per call, so a lease it claimed would be reclaimable immediately by the dead-pid rule. `claim_page` is refused there. CLI calls can still present a token minted by the MCP server with `--lease`.
@@ -298,12 +343,13 @@ Pass that token as `lease` on every later call against the tab. `new_page` with
298
343
  src/
299
344
  client.ts # CdpConnection, openPage/withPage/openBrowser, resolveTarget, timeouts
300
345
  types.ts # Target, TargetSelector, Uid, CDP envelopes
301
- index.ts # TOOLS registry (36) + re-exported client primitives
346
+ index.ts # TOOLS registry (41) + re-exported client primitives
302
347
  cli.ts # the Bun CLI
303
348
  mcp.ts # stdio MCP server (exposes TOOLS via @modelcontextprotocol/sdk)
304
349
  manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
305
350
  leases.ts # lease records, staleness, reclamation, assertLeaseOk
306
351
  leases-tools.ts # claim_page / release_page / list_leases
352
+ origins.ts # tab creation ledger: who OPENED a tab, outliving its lease
307
353
  tools/
308
354
  pages.ts navigation.ts evaluate.ts snapshot.ts input.ts
309
355
  screenshot.ts emulation.ts dialogs.ts recorder.ts console.ts
@@ -313,6 +359,7 @@ test/
313
359
  mock-smoke.ts # network-mocking end-to-end smoke (bun run mock:smoke)
314
360
  network_mock.test.ts # pure-logic unit tests (bun test)
315
361
  mcp-smoke.ts # MCP handshake + live tools/call round-trip (bun run mcp:smoke)
362
+ lease-smoke.ts # two-process live lease harness (bun run lease:smoke)
316
363
  ```
317
364
 
318
365
  Each module ends with a footer comment listing the exact CDP methods it uses and its parity gaps. The full design contract lives in [`CONTRACT.md`](./CONTRACT.md).