cdp-toolkit 1.6.0 → 1.8.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 +439 -0
- package/CONTRACT.md +11 -2
- package/README.md +89 -23
- package/dist/cli.js +1150 -111
- package/dist/index.js +1011 -99
- package/dist/mcp.js +1447 -167
- package/package.json +5 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +231 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +472 -25
- package/src/driver.ts +161 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +269 -27
- package/src/leases.ts +84 -4
- package/src/manifest.ts +295 -56
- package/src/mcp.ts +10 -2
- package/src/reap.ts +165 -0
- package/src/shared-tools.ts +303 -30
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,443 @@ 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.8.0] - 2026-08-09
|
|
9
|
+
|
|
10
|
+
Two tracks land together, merged onto one integration branch. Track S closes the
|
|
11
|
+
gap 1.7.0's takeover mode opened: an agent can now claim a human's open tab, but
|
|
12
|
+
nothing detected the human was still there, and reap could destroy an
|
|
13
|
+
agent-abandoned tab the moment it went idle. Track P is four groups of input
|
|
14
|
+
parity chrome-devtools-mcp already had and this toolkit didn't: a wheel/scroll
|
|
15
|
+
tool, a raw mouse primitive, real HTML5 drag-and-drop, and navigation
|
|
16
|
+
history/downloads/permissions. Tool count moves 41 → 45. Every new
|
|
17
|
+
input-dispatching path (Track P) was wired through Track S's dispatch log as
|
|
18
|
+
part of the merge, not left as a follow-up — see "Changed" below.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **An activity beacon: `claim_page` and `list_leases` now know when a human is
|
|
23
|
+
already using a tab (`src/activity.ts`).** A tiny in-page script records the
|
|
24
|
+
timestamp of the last `pointerdown`/`mousedown`/`keydown`/`wheel`/`touchstart`,
|
|
25
|
+
installed at claim time on both `claim_page` modes and
|
|
26
|
+
`new_page{claim:true}`. On Chrome, `Page.addScriptToEvaluateOnNewDocument`
|
|
27
|
+
alone does **not** survive navigation under this toolkit's per-call connection
|
|
28
|
+
lifetime — the registration dies with the connection that made it, which is
|
|
29
|
+
every tool call, a fact verified against a live browser before anything was
|
|
30
|
+
built on it (`addScriptToEvaluateOnNewDocument` → new connection → navigate →
|
|
31
|
+
`null`). So the CDP driver now holds one connection open per beaconed tab
|
|
32
|
+
purely to keep the registration alive across navigations, bounded at 32
|
|
33
|
+
tabs with oldest-first eviction; an evicted tab still answers for input on
|
|
34
|
+
its current document, only the next navigation's re-arm is lost. Firefox
|
|
35
|
+
needs none of this — its BiDi session lives for the whole process, so a
|
|
36
|
+
`script.addPreloadScript` registration survives navigation for free, and was
|
|
37
|
+
implemented and live-verified rather than left absent, since the spec's
|
|
38
|
+
Firefox carve-out only applied if the plumbing was intractable and it wasn't.
|
|
39
|
+
CDP-dispatched input is `isTrusted:true` too, so the beacon alone cannot tell
|
|
40
|
+
a human click from the toolkit's own: every input-dispatching call writes to
|
|
41
|
+
an in-process dispatch log, and a beacon timestamp counts as human only when
|
|
42
|
+
it postdates this server's last dispatch on that tab by more than 1500ms.
|
|
43
|
+
`claim_page`/`list_leases` surface it as `humanActiveMs` — milliseconds since
|
|
44
|
+
input this server did not dispatch. **`null`/absent means NO DATA, never "no
|
|
45
|
+
human"**: a fresh tab, a tab whose every input was this server's own, and a
|
|
46
|
+
backend that cannot answer at all are all indistinguishable from each other
|
|
47
|
+
and from silence. A `claim_page` takeover of a tab a human used within the
|
|
48
|
+
last 30 seconds additionally carries a `contention` warning string, and **the
|
|
49
|
+
claim is never refused for it** — taking over a person's tab is what
|
|
50
|
+
takeover mode is for, so the warning informs a caller who already holds the
|
|
51
|
+
lease, it does not gate anything. Known blind spots, stated rather than
|
|
52
|
+
designed away: input inside a cross-origin iframe is invisible to the
|
|
53
|
+
beacon, and a *second* MCP server process's dispatches read as human to this
|
|
54
|
+
one, since dispatch-log correlation is scoped to this process.
|
|
55
|
+
|
|
56
|
+
- **Split reap horizon: reclaimable and destroyed are different moments, now
|
|
57
|
+
separated by `CDP_REAP_GRACE_MS`.** Before this release, `list_pages` and
|
|
58
|
+
`list_leases` closed an agent's tab the instant its lease read `expired`,
|
|
59
|
+
which is also the instant another agent becomes *entitled* to take it —
|
|
60
|
+
fine for handing the lease to someone else, destructive when applied to the
|
|
61
|
+
tab itself. An agent 20 minutes into a build between tool calls does not
|
|
62
|
+
expect to come back to a destroyed tab just because it could, in principle,
|
|
63
|
+
have been reclaimed. `staleAgentTabs` (`src/reap.ts`) stays a pure function
|
|
64
|
+
but now takes `now`/`reapGraceMs`: an `expired` lease qualifies for the
|
|
65
|
+
destructive close only once `now - lastUsedAt > ttlMs + reapGraceMs`.
|
|
66
|
+
`CDP_REAP_GRACE_MS` defaults to `2700000` (45 minutes), for a 60-minute total
|
|
67
|
+
fuse after last use. Lease *reclaimability* — `list_leases`' `stale` field —
|
|
68
|
+
is byte-for-byte unchanged at `ttlMs`; only the destructive close moved.
|
|
69
|
+
`dead-pid` is unaffected by grace and reaps immediately, because that
|
|
70
|
+
process is never coming back and there is nothing to wait for.
|
|
71
|
+
|
|
72
|
+
- **Renderer ping and lease observability on `list_pages`/`list_leases`.**
|
|
73
|
+
`list_pages` gains `probe?: boolean` (default false, byte-identical shape
|
|
74
|
+
when omitted): one bounded (500ms) `Runtime.evaluate` per page-type target,
|
|
75
|
+
run concurrently so one wedged tab cannot stall the rest of the listing
|
|
76
|
+
beyond its own budget. Adds `responsive: boolean` (false on a timeout, a
|
|
77
|
+
page-side exception, or an unreachable target — never an error for the
|
|
78
|
+
whole call) and, where the same round trip finds human-attributed input,
|
|
79
|
+
`humanActiveMs`, reusing the beacon's own discrimination rule rather than a
|
|
80
|
+
second implementation of it. Separately and unconditional on `probe`,
|
|
81
|
+
`list_leases` rows gain computed `idleMs` (now−lastUsedAt) and `expiresAt`
|
|
82
|
+
(lastUsedAt+ttlMs, the same value `claim_page` already returns under that
|
|
83
|
+
name), and `list_pages` entries for a tab under an active, readable lease of
|
|
84
|
+
the driver's own backend gain `lease: {label,pid,idleMs,expiresAt,stale}`.
|
|
85
|
+
Both are computed fresh in the tool layer on every call, never stored.
|
|
86
|
+
|
|
87
|
+
- **`scroll`, a new wheel-dispatch tool, on both backends.** Anchor at
|
|
88
|
+
`uid`/`selector`/`x`+`y` (element anchors are scrolled into view first, same
|
|
89
|
+
as `click`), or the viewport center if all three are omitted; at least one
|
|
90
|
+
of `deltaX`/`deltaY` is required, positive `deltaY` scrolling down and
|
|
91
|
+
positive `deltaX` scrolling right (wheel convention). Chrome dispatches
|
|
92
|
+
`Input.dispatchMouseEvent{type:"mouseWheel"}`; Firefox dispatches WebDriver
|
|
93
|
+
BiDi's `wheel` input source — both live-verified against a real browser, not
|
|
94
|
+
left on "it typechecks." A real bug surfaced along the way: Chrome
|
|
95
|
+
smooth-scrolls a wheel dispatch, so the command's ack resolves once the
|
|
96
|
+
event is *queued*, not once the resulting scroll has *settled*, and a
|
|
97
|
+
caller reading `scrollTop` immediately after could see the pre-scroll
|
|
98
|
+
value. Fixed with a capture-phase `scroll` listener armed before dispatch
|
|
99
|
+
(window-level, since `scroll` doesn't bubble but a capture-phase listener on
|
|
100
|
+
`window` still sees it), debounced 60ms with a 500ms hard cap rather than a
|
|
101
|
+
blind sleep sized wrong for both large and small deltas.
|
|
102
|
+
|
|
103
|
+
- **`dispatch_mouse`, the raw move/down/up primitive, Chrome-only.** Dispatch
|
|
104
|
+
exactly one `move`/`down`/`up` mouse event at absolute viewport coordinates
|
|
105
|
+
(`x`/`y` required on every call — CDP has no notion of a current pointer
|
|
106
|
+
position to default from); compose your own move/down/move/up sequences for
|
|
107
|
+
anything a physical mouse can do that `click`/`drag`'s fixed sequences
|
|
108
|
+
can't — canvas drag-painting, marquee/rubber-band selection, a custom
|
|
109
|
+
hit-testing widget. Takes the same `button`/`clickCount`/`modifiers` as
|
|
110
|
+
`click`. Lives outside the Driver abstraction by design, alongside
|
|
111
|
+
`heap.ts`/`screencast.ts`'s other chrome-only-tool modules, but is
|
|
112
|
+
lease-gated through the exact same choke point every other interaction tool
|
|
113
|
+
uses. Chrome-only, capability `input.raw`: absent from `tools/list` under
|
|
114
|
+
`--browser firefox`, never present-and-throwing.
|
|
115
|
+
|
|
116
|
+
- **`click` gains `modifiers` and `clickCount:3`.** `modifiers` (Alt/Control/
|
|
117
|
+
Meta/Shift, CDP bits 1/2/4/8) is held for both press and release, e.g.
|
|
118
|
+
`["Shift"]` for a shift-click; `clickCount:3` triple-clicks, selecting a
|
|
119
|
+
paragraph/line in most editors. `modifiers` is a **Chrome-only parameter on
|
|
120
|
+
an otherwise-universal tool**: a non-empty array throws a clear error under
|
|
121
|
+
`--browser firefox` rather than being silently dropped or ignored — a
|
|
122
|
+
documented param-level gap under ADR-001, not a missing tool.
|
|
123
|
+
|
|
124
|
+
- **Real HTML5 drag-and-drop (`drag mode:"html5"`), plus `steps`, `to:{x,y}`,
|
|
125
|
+
and `by:{dx,dy}` on the existing mouse-mode drag.** The spec's premise —
|
|
126
|
+
that synthetic mouse events cannot drive HTML5 drag-and-drop at all — is
|
|
127
|
+
**false**, measured against Chrome 151: `mode:"mouse"` (the default) does
|
|
128
|
+
start a real drag and can fire `dragstart`/`dragover`/`drop`. The actual gap
|
|
129
|
+
is that *which* `dragover` events reach the page depends on where the
|
|
130
|
+
interpolated pointer path happens to land, because mouse mode's drag
|
|
131
|
+
events follow that path rather than being dispatched at fixed points. At
|
|
132
|
+
the default `steps:2`, a drop zone written the standard way (`preventDefault`
|
|
133
|
+
only inside `dragover`, the HTML spec's own pattern) sees **zero** `dragover`
|
|
134
|
+
events and refuses the drop outright — a coin flip decided by geometry, not
|
|
135
|
+
a reliable "it doesn't work." `mode:"html5"` closes that gap deterministically:
|
|
136
|
+
`Input.setInterceptDrags(true)` arms interception, the mouse press/move
|
|
137
|
+
sequence runs, `Input.dragIntercepted` hands back the page's own `dragstart`
|
|
138
|
+
DragData, and the toolkit replays it as `dragEnter`→`dragOver`→`drop` exactly
|
|
139
|
+
at the destination — path-independent, and interception is always disabled
|
|
140
|
+
in a `finally`. Missing the intercepted event within 5 seconds (e.g. the
|
|
141
|
+
source isn't `draggable="true"`) is an actionable error, never a silent
|
|
142
|
+
no-op. `mode:"html5"` is Chrome-only (capability `input.html5Drag`, absent
|
|
143
|
+
from `REQUIRED_CAPABILITIES` so `drag` itself stays available on both
|
|
144
|
+
backends) and is rejected with a clear error under Firefox rather than
|
|
145
|
+
silently downgrading to mouse mode, which would report a drop that never
|
|
146
|
+
happened. `steps` (default 2, matching the exact pre-1.8.0 dispatch) sets
|
|
147
|
+
the interpolated-move count for mouse mode; `to` now also accepts an
|
|
148
|
+
absolute `{x,y}` alongside `uid`/`selector`, and a new top-level `by:{dx,dy}`
|
|
149
|
+
offsets from the source point instead (sliders, map panning, resize
|
|
150
|
+
handles) — exactly one of `to`/`by` is required, refused rather than
|
|
151
|
+
resolved by precedence when both or neither are given.
|
|
152
|
+
|
|
153
|
+
- **`navigate_page` gains `history: "back"|"forward"`, on both backends.**
|
|
154
|
+
Traverses this tab's session history like the browser's own Back/Forward
|
|
155
|
+
buttons: Chrome via `Page.getNavigationHistory` + `Page.navigateToHistoryEntry`,
|
|
156
|
+
Firefox via BiDi's `browsingContext.traverseHistory{delta:±1}`. Mutually
|
|
157
|
+
exclusive with `url`/`reload` — passing two of the three is refused by name,
|
|
158
|
+
never resolved by precedence. Going back from the first entry, or forward
|
|
159
|
+
from the last, is an **error naming the direction**, never a silent
|
|
160
|
+
no-op that navigates nowhere; both backends were made to raise the same
|
|
161
|
+
sentence (Firefox's own error never names a direction, so the BiDi driver
|
|
162
|
+
rewraps it). Waits for the same load milestone as an ordinary navigation.
|
|
163
|
+
|
|
164
|
+
- **`wait_for_download`, capture a real file download as a file on disk,
|
|
165
|
+
Chrome-only and MCP-server-only.** Returns `{path,suggestedFilename,bytes,
|
|
166
|
+
url,target}`, renaming Chrome's internal guid to the page's own filename
|
|
167
|
+
(collision-suffixed: `report.csv`, `report-1.csv`, ...). Two premises in the
|
|
168
|
+
original design were probed against a real browser before anything was
|
|
169
|
+
built, and both were **false**: `Browser.setDownloadBehavior` does **not**
|
|
170
|
+
persist past the arming client's disconnect (measured: an armed-then-closed
|
|
171
|
+
connection followed by a click landed zero files, no events, anywhere — the
|
|
172
|
+
download is *denied*, not merely misdirected), and the toolkit's per-call
|
|
173
|
+
CDP lifetime means every tool call is exactly such a disconnect. The fix is
|
|
174
|
+
`src/tools/browser-session.ts`: one lazily-opened, module-scoped connection
|
|
175
|
+
to the browser endpoint, held open for the process's life, arming download
|
|
176
|
+
capture once and keeping a ring buffer of completions — opened only when
|
|
177
|
+
`wait_for_download` or `grant_permissions` is first called, so a session
|
|
178
|
+
that never downloads pays nothing. **Ordering rule, not optional:** call
|
|
179
|
+
`wait_for_download{arm:true}` *before* the click that starts the download
|
|
180
|
+
(it arms and returns immediately: `{armed:true,downloadPath,pending}`), then
|
|
181
|
+
click, then call `wait_for_download` again to collect the finished file —
|
|
182
|
+
arming late loses the download with no file anywhere to recover it from.
|
|
183
|
+
**Browser-global side effect:** while armed, *every* download in the
|
|
184
|
+
browser, all tabs and origins, is redirected into the toolkit's downloads
|
|
185
|
+
directory for as long as the server runs. **MCP-server only, not a
|
|
186
|
+
preference:** the arm lives on a connection this server process holds open;
|
|
187
|
+
under the one-shot CLI that connection dies with the process before a
|
|
188
|
+
download could ever complete, so the tool needs the long-lived server the
|
|
189
|
+
same way `performance_start_trace`/`start_screen_recording` already do.
|
|
190
|
+
Chrome-only, capability `browser.downloads`: WebDriver BiDi has no command
|
|
191
|
+
to redirect a download to a chosen directory.
|
|
192
|
+
|
|
193
|
+
- **`grant_permissions`, pre-answer permission prompts for an origin,
|
|
194
|
+
Chrome-only and MCP-server-only.** `permissions: string[]` takes CDP
|
|
195
|
+
PermissionType values (`geolocation`, `notifications`, `clipboardReadWrite`,
|
|
196
|
+
`camera`, `microphone`, `midi`, ...; an unknown name is refused by Chrome
|
|
197
|
+
with the bad value in the message), keyed by **origin**, not tab — every
|
|
198
|
+
tab on that origin is affected, including ones opened later. `reset:true`
|
|
199
|
+
clears this server's previous grants first, or instead when no permissions
|
|
200
|
+
are given; CDP's reset is not itself origin-scoped, so it clears every
|
|
201
|
+
origin at once. The same probe that falsified `wait_for_download`'s
|
|
202
|
+
persistence assumption was run against `Browser.grantPermissions` on
|
|
203
|
+
spec, since the same per-connection-state trap applies here too, and it
|
|
204
|
+
sprang: a grant also does **not** survive the granting client's disconnect.
|
|
205
|
+
Shares `browser-session.ts`'s standing connection with `wait_for_download`
|
|
206
|
+
for exactly that reason. Chrome-only, capability `browser.permissions`:
|
|
207
|
+
absent from `tools/list` under Firefox, never present-and-throwing.
|
|
208
|
+
|
|
209
|
+
### Changed (behavior, no flag)
|
|
210
|
+
|
|
211
|
+
- **Every input-dispatching path, old and new, now writes through one choke
|
|
212
|
+
point, closing a real bug the individual branches could not see.** Track P
|
|
213
|
+
was built against pre-1.8.0 main, before Track S's dispatch log existed, so
|
|
214
|
+
its `scroll` and html5-drag paths sent `Input.*` straight down the
|
|
215
|
+
connection. Merged as-is, this compiles, typechecks, and passes both
|
|
216
|
+
branches' full test suites while the activity beacon reports the toolkit's
|
|
217
|
+
own scroll as a human using the tab — an agent warned about contention with
|
|
218
|
+
itself, the exact failure the beacon exists to prevent, invisible to either
|
|
219
|
+
branch's own tests because neither branch could see the other's code.
|
|
220
|
+
`dispatch_mouse` lives outside the Driver abstraction and has no
|
|
221
|
+
`dispatchInput` to call, so the write moved to a new shared function,
|
|
222
|
+
`sendInput(conn, backend, targetId, method, params)` in `src/activity.ts`;
|
|
223
|
+
`CdpPageDriver.dispatchInput` now delegates to it and `dispatch-mouse.ts`
|
|
224
|
+
calls it directly. `Input.setInterceptDrags` (html5 drag's arm and its
|
|
225
|
+
`finally`) is sent but deliberately **not** recorded: it synthesizes no
|
|
226
|
+
page input, and logging it would suppress a real human input landing in the
|
|
227
|
+
same 1500ms window — classified by denylist, so an `Input.*` command nobody
|
|
228
|
+
thought to classify defaults to being logged, because over-warning is
|
|
229
|
+
recoverable and silent misattribution is the bug this feature exists to
|
|
230
|
+
avoid. Firefox needed no code change: Track P built its BiDi scroll and drag
|
|
231
|
+
on `performActions`, which already called `recordDispatch`, so it inherited
|
|
232
|
+
the log for free — the whole argument for having a choke point in the first
|
|
233
|
+
place. The invariant — nothing in `src/` sends `Input.*` except through
|
|
234
|
+
`sendInput` — is grep-verified post-merge and is now also a test
|
|
235
|
+
(`test/input-dispatch-wiring.test.ts`), mutation-checked by reverting
|
|
236
|
+
`scroll` to a raw send and confirming the test names the exact line and the
|
|
237
|
+
live smoke reproduces the bug (`humanActiveMs=202` on the toolkit's own
|
|
238
|
+
scroll, instead of `undefined`).
|
|
239
|
+
|
|
240
|
+
### Residuals
|
|
241
|
+
|
|
242
|
+
- **A stale claim in `drag`'s own manifest description was caught and fixed
|
|
243
|
+
during integration, not by a fresh reader later.** An earlier pass on the
|
|
244
|
+
top-level `drag` description already stated the measured mouse-vs-html5
|
|
245
|
+
finding, but the `mode` property's own enum description still read "does
|
|
246
|
+
nothing on HTML5 draggable elements" — the exact folklore the rest of this
|
|
247
|
+
release measured false. Corrected to match; no test pinned the stale
|
|
248
|
+
string.
|
|
249
|
+
- **Second-MCP-server blind spot, stated plainly.** Two MCP server processes
|
|
250
|
+
driving the same browser cannot see each other's dispatch logs, so one
|
|
251
|
+
server's tool calls read as human activity to the other. Accepted: the
|
|
252
|
+
discrimination rule is honest about what it can and cannot see, and the
|
|
253
|
+
alternative (a shared dispatch log across processes) is a much larger
|
|
254
|
+
feature for a scenario this toolkit does not otherwise support (see
|
|
255
|
+
"Parallel tabs" — one writer per flow).
|
|
256
|
+
- **Cross-origin iframe input is invisible to the beacon,** since the
|
|
257
|
+
listener is installed on the top document only. Under-warning here is the
|
|
258
|
+
same accepted direction as the denylist above: a missed human input reads
|
|
259
|
+
as agent-only, never the reverse.
|
|
260
|
+
- **`dispatch_mouse`'s `modifiers` and `click`'s `modifiers` are two separate
|
|
261
|
+
parameters that happen to share an enum,** not one modifier system: each
|
|
262
|
+
tool validates and documents its own Firefox behavior independently.
|
|
263
|
+
- **Firefox's BiDi `scroll` path shipped once and was verified twice, in two
|
|
264
|
+
different seats.** It typechecked and passed unit tests against the real
|
|
265
|
+
BiDi capability set when Track P landed, but no Firefox was available to
|
|
266
|
+
that seat to run it live; the integration seat had one, ran it, and it
|
|
267
|
+
passed unmodified — the implementation was correct the first time, only the
|
|
268
|
+
live evidence was missing. Recorded here so "typechecks" is never mistaken
|
|
269
|
+
for "verified" on the next capability that ships without a live check.
|
|
270
|
+
|
|
271
|
+
### Notes
|
|
272
|
+
|
|
273
|
+
- Tool count is now 45 (29 parity + 16 superset: `performance_trace`, the
|
|
274
|
+
cookie group, the network-mocking group, the lease group, the
|
|
275
|
+
screen-recording pair, `scroll`, `dispatch_mouse`, `wait_for_download`, and
|
|
276
|
+
`grant_permissions`); 34 under Firefox, which lacks the eleven
|
|
277
|
+
capability-gated tools — the pre-1.8.0 eight (tracing, heap-snapshot,
|
|
278
|
+
Lighthouse, screen-recording) plus `dispatch_mouse`, `wait_for_download`,
|
|
279
|
+
and `grant_permissions`. `scroll` and the extended `navigate_page`/`click`/
|
|
280
|
+
`drag` params ship on both backends; only `click`'s `modifiers` and `drag`'s
|
|
281
|
+
`mode:"html5"` stay Chrome-only, and only at the parameter level — the
|
|
282
|
+
tools themselves are universal.
|
|
283
|
+
- Two new live-smoke scripts, `bun run staleness:smoke` and `bun run
|
|
284
|
+
input:smoke`, are each a single process talking to a single browser:
|
|
285
|
+
`CDP_BASE` is a module-level constant captured at import time, so one
|
|
286
|
+
process cannot address two browsers on two ports. Each is therefore
|
|
287
|
+
env-driven — `staleness:smoke` honors `STALENESS_SMOKE_PORT` (default
|
|
288
|
+
9501), `input:smoke` honors `CDP_SMOKE_PORT` (default 9513) — so the two
|
|
289
|
+
can run against separate scratch Chromes without colliding.
|
|
290
|
+
|
|
291
|
+
## [1.7.0] - 2026-08-09
|
|
292
|
+
|
|
293
|
+
Four things land on top of 1.2's opt-in lease model, all under one new switch
|
|
294
|
+
plus one default that moves without it. `CDP_REQUIRE_LEASE` turns leasing from
|
|
295
|
+
optional into mandatory for a long-lived MCP server: every call auto-acquires
|
|
296
|
+
the tab it touches instead of driving it lease-free. `release_page` now closes
|
|
297
|
+
a tab this toolkit opened, the one behavior a caller sees without opting into
|
|
298
|
+
anything. Reap closes tabs an agent abandoned instead of leaking them forever.
|
|
299
|
+
And `claim_page` gained a `target` mode so an agent can take over a tab a human
|
|
300
|
+
already has open, not just claim one it created itself.
|
|
301
|
+
|
|
302
|
+
### Added
|
|
303
|
+
|
|
304
|
+
- **`CDP_REQUIRE_LEASE`, a switch that makes holding a lease mandatory instead
|
|
305
|
+
of optional, and the `auto` tier that makes it livable.** Off by default, so
|
|
306
|
+
a 1.6.0 consumer is unaffected. On, in a long-lived MCP server process only:
|
|
307
|
+
`assertLeaseOk` now *acquires* a lease on an unheld tab instead of waving the
|
|
308
|
+
call through, which is what makes "you cannot drive a tab you do not hold"
|
|
309
|
+
actually true rather than aspirational. That acquired lease is marked
|
|
310
|
+
`auto: true` on the `LeaseRecord` (absent, and therefore `false`, on every
|
|
311
|
+
record a pre-1.7.0 toolkit ever wrote, so an upgrade never downgrades a held
|
|
312
|
+
lease's protection), and an auto lease is owned by a *process*: any later
|
|
313
|
+
call from the same pid passes with no token, while a lease taken explicitly
|
|
314
|
+
via `claim_page` or `new_page{claim:true}` still demands its token even from
|
|
315
|
+
that same process. That two-tier split is what lets an ordinary tool call
|
|
316
|
+
keep working with zero protocol changes while still giving one subagent a
|
|
317
|
+
way to fence off a tab from its siblings, which share its pid. `new_page`
|
|
318
|
+
auto-claims the tab it just created under strict mode, even without
|
|
319
|
+
`claim:true`, because a tab nobody holds the instant it exists is a tab the
|
|
320
|
+
very next call would have to auto-acquire anyway; `claim:true` still yields
|
|
321
|
+
the stronger, explicit tier. **Strict mode is MCP-only, deliberately and
|
|
322
|
+
unconditionally**, not just by convention: `requireLease()` reads a
|
|
323
|
+
per-process flag that only `mcp.ts` sets at startup, so `CDP_REQUIRE_LEASE=1`
|
|
324
|
+
in a CLI invocation's environment is inert no matter what. A CLI call is one
|
|
325
|
+
process per invocation, so a lease it auto-acquired would be reclaimable by
|
|
326
|
+
the dead-pid rule the instant that process exited, and "a lease that is
|
|
327
|
+
reclaimable on arrival" is worse protection than none; the sharper danger is
|
|
328
|
+
reap, which would then read that instantly-dead-pid record as an abandoned
|
|
329
|
+
agent tab and could close it on the very next `list_pages` call.
|
|
330
|
+
|
|
331
|
+
- **`release_page{target}`, the counterpart an auto-acquired lease needs**,
|
|
332
|
+
because the gate mints those without ever handing the caller a token to give
|
|
333
|
+
back with `lease`. `target` takes the same selector grammar as everywhere
|
|
334
|
+
else and resolves through `resolvePage`, which is itself the authorization:
|
|
335
|
+
it runs `assertLeaseOk`, so a tab another live process holds is refused
|
|
336
|
+
there and, under strict mode, an unheld tab is acquired on the way in, which
|
|
337
|
+
means the caller genuinely holds it by the time it is released. `lease` and
|
|
338
|
+
`target` are mutually exclusive and exactly one is required.
|
|
339
|
+
|
|
340
|
+
- **Reap: `list_pages` and `list_leases` close tabs an agent abandoned,
|
|
341
|
+
instead of leaking them forever.** `release_page` only closes a tab an agent
|
|
342
|
+
gives back, and an agent that crashes, is killed, or just stops calling
|
|
343
|
+
never gives anything back; a timeout is a more likely end for an agent than
|
|
344
|
+
a clean shutdown. Rather than a background sweeper (a second lifetime to
|
|
345
|
+
reason about, and a process that has to be running), reap runs on read: the
|
|
346
|
+
two list tools already hold the browser's live target list and the lease
|
|
347
|
+
directory in hand at that moment, so checking costs one extra pass over data
|
|
348
|
+
already fetched. The selection itself, `staleAgentTabs` in the new
|
|
349
|
+
`src/reap.ts`, is a pure function so the four conditions that gate a close
|
|
350
|
+
can be tested exhaustively with no browser and no filesystem, which matters
|
|
351
|
+
because every mistake in this code closes a tab someone wanted: (1) the
|
|
352
|
+
lease row is for this backend and actually readable — an `unreadable` row is
|
|
353
|
+
never a candidate, because a guess in the destructive direction is the one
|
|
354
|
+
guess that cannot be undone; (2) it is stale for `dead-pid` or `expired`,
|
|
355
|
+
never `target-gone` (nothing to close) or a healthy lease; (3) the target is
|
|
356
|
+
still genuinely open, per the live page listing; and (4), the load-bearing
|
|
357
|
+
one, **the toolkit both opened the tab AND leased it** — an origin record by
|
|
358
|
+
itself is not enough. An agent-created tab that never went through
|
|
359
|
+
`claim_page`/`new_page{claim:true}` and was never auto-leased has no lease
|
|
360
|
+
row at all, and that is exactly what `new_page` produces for a caller who
|
|
361
|
+
never touches leases; reaping those would be a data-loss bug wearing a
|
|
362
|
+
cleanup's clothes, not a cleanup. Closed tabs are reported in an additive
|
|
363
|
+
`reaped` array (`{targetId, label, reason}`) present only when something was
|
|
364
|
+
actually closed, so the common-case response shape is byte-identical to
|
|
365
|
+
1.6.0; a close that fails is not reported and the lease is left in place, so
|
|
366
|
+
the next read tries again rather than lying about what is still open.
|
|
367
|
+
Strict-mode gated, for the same MCP-only reason as auto-acquire: a CLI user
|
|
368
|
+
running `list_pages` twice would otherwise watch the second run close the
|
|
369
|
+
tabs the first run's now-dead process just leased.
|
|
370
|
+
|
|
371
|
+
- **`claim_page{target}`, a takeover path for a tab that is already open.**
|
|
372
|
+
With neither `target` nor `targetId`, `claim_page` still opens a fresh tab,
|
|
373
|
+
which was its only mode before; `targetId` still claims an exact existing
|
|
374
|
+
target id, kept for back-compat. Neither of those covers "work in the tab I
|
|
375
|
+
already have open" when the caller does not have that tab's id to hand,
|
|
376
|
+
which is the ordinary shape of a human asking an agent to continue in a tab
|
|
377
|
+
that is sitting right in front of them. `target` takes the toolkit's whole
|
|
378
|
+
selector grammar (`active | index:N | url:<substr> | title:<substr> |
|
|
379
|
+
<targetId>`) and resolves it against the live page list only: an unmatched
|
|
380
|
+
selector is an error, never a silently substituted new tab, because "the tab
|
|
381
|
+
I asked for is not there" and "here is an empty one instead" are answers a
|
|
382
|
+
caller has to be able to tell apart. The result gained `opened: boolean`,
|
|
383
|
+
true only when this call actually created the tab, which is the same
|
|
384
|
+
distinction the creation ledger records and is what keeps close-on-release
|
|
385
|
+
safe through this new path: a tab taken over this way has no creation
|
|
386
|
+
record, so `release_page` releases it and leaves it open. **Resolution here
|
|
387
|
+
is deliberately gate-free** — it goes through `pickPage`, not `resolvePage`
|
|
388
|
+
— because under strict mode `resolvePage` would auto-acquire a lease on an
|
|
389
|
+
unheld tab on the way in, and the explicit claim right behind it would then
|
|
390
|
+
collide with the lease it had just minted for itself. The protection this
|
|
391
|
+
skips is not lost, only moved one layer down: `claimLease`'s own conflict
|
|
392
|
+
check still refuses a tab any live process holds, auto-acquired or explicit.
|
|
393
|
+
**There is deliberately no force or steal option.** A tab another live agent
|
|
394
|
+
holds is refused with the same conflict error as ever; the tab this feature
|
|
395
|
+
exists to take over is a human's, and a human's tab carries no lease at all.
|
|
396
|
+
|
|
397
|
+
### Changed (behavior, no flag)
|
|
398
|
+
|
|
399
|
+
- **`release_page` now closes a tab this toolkit opened, by default.** This is
|
|
400
|
+
the one thing a 1.6.0 caller sees without setting `CDP_REQUIRE_LEASE`:
|
|
401
|
+
previously `release_page` only ever gave the lease back and never touched
|
|
402
|
+
the tab. The close is scoped by the creation ledger from `origins.ts`, which
|
|
403
|
+
answers "did this toolkit open it" independently of the lease, and only
|
|
404
|
+
independently of the lease could it answer that at release time: a tab this
|
|
405
|
+
toolkit opened for an agent is closed, a tab a human already had open and an
|
|
406
|
+
agent merely claimed is released and left exactly alone, and `close: false`
|
|
407
|
+
opts out of a close per call while `close: true` forces one either way. A
|
|
408
|
+
release that did not actually happen — already released, reclaimed, or
|
|
409
|
+
expired — never closes anything, because by then the tab may belong to
|
|
410
|
+
another agent entirely; that is the one rule here that is a safety property
|
|
411
|
+
rather than a convenience, not a default that can be overridden.
|
|
412
|
+
|
|
413
|
+
### Residuals
|
|
414
|
+
|
|
415
|
+
Stated plainly rather than designed away, most inherited from strict mode's
|
|
416
|
+
design doc, one new to the takeover path:
|
|
417
|
+
|
|
418
|
+
- **Auto leases do not isolate subagents sharing one MCP server process.** All
|
|
419
|
+
of a session's subagents share one pid, so they pass each other's auto
|
|
420
|
+
leases freely. The escape hatch is an explicit `claim_page`, which demands
|
|
421
|
+
its token even same-process; that is the accepted cost of keeping the
|
|
422
|
+
ordinary path ergonomic.
|
|
423
|
+
- **Reap closes an idle-but-alive agent's tab after `ttlMs` elapses.**
|
|
424
|
+
`expired` means reclaimable, so another agent could already have taken the
|
|
425
|
+
tab by the time reap runs; closing it is consistent with that, but it is
|
|
426
|
+
still a close the original agent did not ask for. Tunable via
|
|
427
|
+
`CDP_LEASE_TTL_MS` (default 15 minutes).
|
|
428
|
+
- **A read can have a side effect.** `list_pages` and `list_leases` can close
|
|
429
|
+
tabs under strict mode. Mitigated only by reporting every close in `reaped`.
|
|
430
|
+
- **`release_page{target}` under strict mode, on an unleased agent tab,**
|
|
431
|
+
auto-acquires it and then closes it in the same call, closing a tab the
|
|
432
|
+
caller itself never drove a single command against. Accepted because under
|
|
433
|
+
strict mode an unleased agent tab means nobody is actually driving it.
|
|
434
|
+
- **Auto-acquire can mint a lease on a non-page target.** `pickPage`'s bare-id
|
|
435
|
+
branch searches the full `all:true` listing, so an exact id for a worker or
|
|
436
|
+
an iframe still resolves and gets leased. Harmless, since a lease is just a
|
|
437
|
+
file, but it will show up as a row in `list_leases`.
|
|
438
|
+
|
|
439
|
+
### Notes
|
|
440
|
+
|
|
441
|
+
- Tool count is unchanged at 41 (29 parity + 12 superset); 33 under Firefox.
|
|
442
|
+
`claim_page`, `release_page`, and `list_leases` gain arguments and result
|
|
443
|
+
fields, none of them removed or renamed.
|
|
444
|
+
|
|
8
445
|
## [1.6.0] - 2026-08-09
|
|
9
446
|
|
|
10
447
|
This release skips 1.5.0 (reserved for in-flight work on another branch).
|
|
@@ -315,5 +752,7 @@ First public release.
|
|
|
315
752
|
- 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`.
|
|
316
753
|
- Runtime: Bun ≥ 1.1 (recommended) or Node ≥ 22 (for the global `WebSocket`). Requires Chrome/Chromium started with `--remote-debugging-port=9222`.
|
|
317
754
|
|
|
755
|
+
[1.8.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.8.0
|
|
756
|
+
[1.7.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.7.0
|
|
318
757
|
[1.6.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.6.0
|
|
319
758
|
[1.0.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.0.0
|
package/CONTRACT.md
CHANGED
|
@@ -12,7 +12,13 @@ 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 `
|
|
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`, `select_page`, and `release_page`'s `target` argument). 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
|
+
|
|
17
|
+
**Under `CDP_REQUIRE_LEASE` the gate does not only check, it can ACQUIRE.** `assertLeaseOk` calls `claimLease` itself when the resolved tab is unheld and strict mode is on (see `requireLease()` and the `auto` tier in `src/leases.ts`). A fourth resolution path that skipped the choke point used to lose only enforcement; under strict mode it also loses acquisition, so a tab reached that way is silently never protected even after the flag is on. `lease:smoke`'s strict-mode phase (`CDP_REQUIRE_LEASE=1`, run as a second live process) is what actually exercises this; `bun test` cannot, since acquisition is keyed on the calling pid the same way enforcement is.
|
|
18
|
+
|
|
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
|
+
|
|
21
|
+
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.
|
|
16
22
|
|
|
17
23
|
## Core client API (from `src/client.ts`)
|
|
18
24
|
|
|
@@ -62,7 +68,10 @@ export async function click(args: ClickArgs): Promise<unknown>;
|
|
|
62
68
|
- 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
69
|
- Recorder buffers: `${ARTIFACT_DIR}/rec-<targetId>.jsonl`.
|
|
64
70
|
- 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`.
|
|
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.
|
|
71
|
+
- 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. Carries an `auto?: boolean` field (absent, and read as `false`, on any record older than 1.7.0): `true` means the gate itself acquired it under `CDP_REQUIRE_LEASE` and it is usable by any call from the owning pid with no token; `false` means it was claimed explicitly (`claim_page` or `new_page{claim:true}`) and demands its token even from that same pid.
|
|
72
|
+
- Origin files (`src/origins.ts`): `${ARTIFACT_DIR}/origin-<backend>-<targetId>.json`, one per tab this toolkit itself created (never written for a tab merely claimed). Same exclusive-create discipline as lease files, same directory, and it deliberately OUTLIVES the lease: `release_page`, expiry, and reclamation never touch it, only the tab closing does (reaped on the next `list_pages`/`list_origins` read). This is what `release_page` and reap both consult to decide whether closing a given tab is this toolkit's call to make; see `src/reap.ts`'s header for the four conditions that gate a reap close. As of 1.8.0 an `expired` lease qualifies for that close only past `ttlMs + CDP_REAP_GRACE_MS` (default 45 extra minutes) — `list_leases`' `stale`/reclaimable reading is unchanged at `ttlMs`; only the destructive close moved later. `dead-pid` is unaffected by grace.
|
|
73
|
+
- Beacon sessions (`src/activity.ts`, `src/cdp/driver.ts`): a `BeaconSessions` pool of held Chrome connections, one per tab with an installed activity beacon, bounded at 32 with oldest-first eviction — the same module-scope pattern `recorder.ts`/`network_mock.ts` already use for their own persistent per-target sessions. Exists only because `Page.addScriptToEvaluateOnNewDocument`'s registration dies with the connection that made it (verified against a live browser, not assumed), so surviving navigation under this toolkit's per-call lifetime requires holding a connection open; eviction degrades (the current document's listeners keep answering, only the next navigation's re-arm is lost) rather than breaking. Firefox needs no equivalent: its `LifetimeModel` is `"session"`, so a `script.addPreloadScript` registration on the one memoized BiDi connection per port already lives as long as that connection does.
|
|
74
|
+
- The browser-session connection (`src/tools/browser-session.ts`): one lazily-opened, module-scoped connection to the CDP browser endpoint (not a page endpoint), held open for the process's life, backing `wait_for_download` and `grant_permissions`. Opened only on first use of either tool, so a session that never touches downloads or permissions pays nothing. Exists because `Browser.setDownloadBehavior` and `Browser.grantPermissions` were measured — probed against a real browser before either tool was written — to NOT persist past the arming/granting client's disconnect, which this toolkit's per-call connection lifetime would otherwise hit on every single call. Both tools are therefore MCP-server-only capabilities, the same category as `performance_start_trace`/`stop_screen_recording`'s persistent-connection requirement, for the same underlying reason.
|
|
66
75
|
|
|
67
76
|
## The element-reference scheme (READ if you touch snapshot or input)
|
|
68
77
|
|