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/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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 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. **
|
|
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 (and know when a human is using it too), plus a built-in network-mocking fake backend. **45 tools.**
|
|
4
4
|
|
|
5
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
|
|
|
@@ -118,8 +118,9 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
|
|
|
118
118
|
|
|
119
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.
|
|
120
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`.
|
|
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.
|
|
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, real HTML5 drag-and-drop, raw scroll/mouse dispatch, download capture, permission grants, and tab-to-video screen recording. 45 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
|
|
122
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
|
+
- **Knows when a human is already using the tab you're driving.** An in-page activity beacon distinguishes a person's clicks/keys/scrolls from the toolkit's own dispatched input, so `claim_page` and `list_leases` can report `humanActiveMs` and warn on contention instead of silently fighting someone for the keyboard. See "Staleness: is a human already using this tab?" below.
|
|
123
124
|
- **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
125
|
- **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.
|
|
125
126
|
|
|
@@ -198,10 +199,12 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
|
|
|
198
199
|
| `CDP_ARTIFACT_DIR` | `/tmp/cdp-toolkit` | Screenshots, traces, heap snapshots, lighthouse reports, recorder buffers. |
|
|
199
200
|
| `CDP_STATE_DIR` | `/tmp/cdp-toolkit` | `select_page` selected-target file, in-flight trace state. |
|
|
200
201
|
| `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. |
|
|
202
|
+
| `CDP_REQUIRE_LEASE` | off | Strict mode, MCP server only (inert under the CLI regardless of value). Turns leasing from optional into mandatory: a call against an unheld tab acquires a lease instead of driving it lease-free, and `list_pages`/`list_leases` close tabs an abandoned agent left behind. See "Parallel tabs" below. |
|
|
203
|
+
| `CDP_REAP_GRACE_MS` | `2700000` | Extra grace (on top of `CDP_LEASE_TTL_MS`) before an `expired` lease's tab is actually destroyed by reap; `dead-pid` tabs are reaped immediately regardless. See "Reap" below. |
|
|
201
204
|
|
|
202
205
|
## Firefox (WebDriver BiDi)
|
|
203
206
|
|
|
204
|
-
cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same
|
|
207
|
+
cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 45-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
|
|
205
208
|
|
|
206
209
|
```bash
|
|
207
210
|
cdp --browser firefox take_snapshot # CLI: explicit flag
|
|
@@ -222,8 +225,10 @@ Backend selection precedence: `--browser chrome|firefox` flag, then `CDP_BROWSER
|
|
|
222
225
|
- `take_heapsnapshot` (needs `heap.snapshot`)
|
|
223
226
|
- `lighthouse_audit` (needs `audit.lighthouse`)
|
|
224
227
|
- `start_screen_recording`, `stop_screen_recording` (needs `capture.screencast`): BiDi has no streamed-frame primitive at all, only the one-shot `browsingContext.captureScreenshot`.
|
|
228
|
+
- `dispatch_mouse` (needs `input.raw`): the raw move/down/up primitive is a direct `Input.dispatchMouseEvent` wrapper with no BiDi analogue.
|
|
229
|
+
- `wait_for_download`, `grant_permissions` (need `browser.downloads` / `browser.permissions`): both drive Chrome's `Browser.*` domain; WebDriver BiDi has no command to redirect a download or pre-grant a permission.
|
|
225
230
|
|
|
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)
|
|
231
|
+
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), the `claim_page`/`release_page`/`list_leases` lease group, and the new `scroll` tool (Chrome dispatches `Input.dispatchMouseEvent{type:'mouseWheel'}`, Firefox uses BiDi's `wheel` input source — both live-verified), is available under both backends: 34 of the 45 tools.
|
|
227
232
|
|
|
228
233
|
**Honest capability gaps, not oversold parity:**
|
|
229
234
|
|
|
@@ -233,27 +238,31 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
|
|
|
233
238
|
- **No tracing, heap snapshots, or Lighthouse.** See the capability list above; there is no BiDi equivalent for any of the three.
|
|
234
239
|
- **`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`.
|
|
235
240
|
- **No `--browser firefox` attach mode.** By design, per the launch model above: every invocation gets its own throwaway Firefox process and profile.
|
|
241
|
+
- **No modifier-key clicks.** `click`'s `modifiers` (Alt/Control/Meta/Shift) is a Chrome-only *parameter* on an otherwise-universal tool: a non-empty `modifiers` array throws under `--browser firefox` rather than being silently dropped. A plain click still works on both backends.
|
|
242
|
+
- **No real HTML5 drag-and-drop mode.** `drag`'s `mode:"html5"` requires capability `input.html5Drag` and is rejected with a clear error under Firefox; `mode:"mouse"` (the default) works on both.
|
|
236
243
|
|
|
237
|
-
## The tools (29 parity +
|
|
244
|
+
## The tools (29 parity + 16 superset = 45)
|
|
238
245
|
|
|
239
|
-
The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the
|
|
246
|
+
The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 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, the `start_screen_recording`/`stop_screen_recording` screen-recording pair, `scroll`, `dispatch_mouse`, `wait_for_download`, and `grant_permissions`) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
|
|
240
247
|
|
|
241
248
|
| MCP name | CDP method(s) | Parity notes / gaps |
|
|
242
249
|
|---|---|---|
|
|
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. |
|
|
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). |
|
|
250
|
+
| `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. Each row additionally carries `origin` (`agent` or `unknown`, never `human`) plus `label`/`createdAt` for tabs this toolkit created, and, for a tab under an active lease of this backend, a `lease:{label,pid,idleMs,expiresAt,stale}` field (unconditional on `probe`). `probe:true` pings each page-type target's renderer (one bounded 500ms `Runtime.evaluate`, never more) and adds `responsive:boolean` plus `humanActiveMs` where that round trip found human-attributed input; a wedged/unreachable tab reports `responsive:false`, never an error for the whole call. Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs first (destructively, only once a lease is `CDP_REAP_GRACE_MS` past its TTL — see "Staleness" below), reporting closures in an additive `reaped` array. |
|
|
251
|
+
| `new_page` | `Target.createTarget` (+ lease file) | Returns `{targetId,url}`; does not await navigation (use `navigate_page`). `claim:true` also claims the new tab and returns a `lease` token (`label`/`ttlMs` optional). Under `CDP_REQUIRE_LEASE` the tab is claimed and a `lease` returned even without `claim:true`. |
|
|
245
252
|
| `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. |
|
|
246
253
|
| `select_page` | `Target.activateTarget` + selected-state file | Writes a flat-file selected target; `resolveTarget` does not read it, so `active` still means `index:0` unless a tool opts in. |
|
|
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). |
|
|
254
|
+
| `navigate_page` | `Page.navigate` / `Page.reload` + load events, or `history:'back'\|'forward'` (Chrome: `Page.getNavigationHistory` + `Page.navigateToHistoryEntry`; Firefox: `browsingContext.traverseHistory`) | Returns `{url,frameId,waitedFor}` (no auto-snapshot; a history move also returns `traversed:'back'\|'forward'`). `waitUntil` supports `load`/`domcontentloaded`. `reload:true` (+ `ignoreCache:true` for a hard reload). `url`/`reload`/`history` are mutually exclusive; going back from the first entry (or forward from the last) is an error naming the direction, never a silent no-op. Works on both backends. |
|
|
248
255
|
| `wait_for` | `Runtime.evaluate` (poll `innerText`) | Text-substring waiting only; throws on timeout rather than returning `{found:false}`. |
|
|
249
256
|
| `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. Toolkit addition: optional `savePath` writes the value to a JSON file and keeps it out of the response entirely. |
|
|
250
257
|
| `list_cookies` *(superset)* | `Network.getCookies` | Reads the target page's cookie store, httpOnly cookies included, which `document.cookie` and therefore `evaluate_script` cannot see. Page-scoped on purpose, not the browser-wide jar (`Storage.getCookies`), so it answers for the tab you named. Optional `domain`/`name` filters; optional `savePath` writes the array to a JSON file and returns `{path,bytes,count,target}` with no cookie value in the response. |
|
|
251
258
|
| `set_cookie` *(superset)* | `Network.setCookie` | Writes one cookie, httpOnly and secure ones included, which `document.cookie` cannot create. Either `url` or `domain` is required and the call is refused with an error when neither is given. Chrome's `success:false` refusal is raised as an error rather than reported as a write. Answers `{set:true,target}` and never echoes the value back. `path` is passed through as given, never defaulted. |
|
|
252
259
|
| `delete_cookies` *(superset)* | `Network.deleteCookies` | Removes the named cookie, httpOnly ones included. Requires `name` plus `url` or `domain`, so a name-only call cannot sweep the store; optional `path` narrows further. Answers `{deleted:true,target}` with no count, because neither protocol reports one; read `list_cookies` before and after for a real count. |
|
|
253
260
|
| `take_snapshot` | `Accessibility.getFullAXTree` | uid is the raw `backendDOMNodeId` (stateless, non-sequential). Full tree in one shot; frames flattened. `interactiveOnly` is a toolkit addition. |
|
|
254
|
-
| `click` | `Input.dispatchMouseEvent` | No implicit auto-wait/retry; resolves and acts once; re-snapshot between steps. |
|
|
261
|
+
| `click` | `Input.dispatchMouseEvent` | No implicit auto-wait/retry; resolves and acts once; re-snapshot between steps. `clickCount:3` triple-clicks (selects a paragraph/line in most editors). `modifiers` (Alt/Control/Meta/Shift) holds keys for the press and release, e.g. a shift-click — **Chrome-only param**: a non-empty array throws under `--browser firefox`. |
|
|
255
262
|
| `hover` | `Input.dispatchMouseEvent` (`mouseMoved`) | Same single-shot model as `click`. |
|
|
256
|
-
| `
|
|
263
|
+
| `scroll` *(superset)* | Chrome: `Input.dispatchMouseEvent{type:'mouseWheel'}`; Firefox: BiDi's `wheel` input source | Anchor at `uid`/`selector`/`x`+`y`, or the viewport center if all three are omitted; an element anchor is scrolled into view first. At least one of `deltaX`/`deltaY` is required (positive `deltaY` scrolls down, positive `deltaX` scrolls right — wheel convention). Returns `{x,y,deltaX,deltaY,target}`. Works on both backends. |
|
|
264
|
+
| `drag` | Chrome: `Input.dispatchMouseEvent` (press→move→release), or `mode:"html5"`: `Input.setInterceptDrags` + `Input.dispatchDragEvent` | `mode:"mouse"` (default) sends a synthetic mouse drag; Chrome does turn this into a real HTML5 drag too, but which `dragenter`/`dragover`/`drop` events reach the page depends on where the interpolated pointer path lands — at the default `steps:2`, a standard HTML5 drop zone (`preventDefault` inside `dragover`) sees zero `dragover` events and refuses the drop. `mode:"html5"` replays the page's own drag data as `dragEnter`/`dragOver`/`drop` exactly at the destination, so it works regardless of pointer path — **Chrome-only** (`input.html5Drag`), rejected with a clear error under Firefox. `steps` (default 2) sets the interpolated-move count for mouse mode. Destination is `to:{uid\|selector\|x,y}` or a new `by:{dx,dy}` offset (sliders, map panning); exactly one of `to`/`by` is required. |
|
|
265
|
+
| `dispatch_mouse` *(superset)* | `Input.dispatchMouseEvent` (one event per call) | The raw primitive: dispatch exactly one `move`/`down`/`up` at absolute viewport coordinates (`x`/`y` required on every call — CDP has no notion of a "current pointer position"). Compose your own move/down/move/up sequences for anything a physical mouse can do that `click`/`drag`'s fixed sequences can't: canvas drag-painting, marquee/rubber-band selection, a custom-hit-testing widget. Takes the same `button`/`clickCount`/`modifiers` as `click`. **Chrome-only** (`input.raw`): absent from `tools/list` under `--browser firefox`, never present-and-throwing. |
|
|
257
266
|
| `fill` | `Input.insertText` | Atomic paste-like commit, not per-character keystrokes. |
|
|
258
267
|
| `fill_form` | per field: `callFunctionOn` + `insertText` | Array of `{uid|selector,value}`; same insertText caveat. |
|
|
259
268
|
| `type_text` | `Input.insertText` | Appends (does not clear first); insertText, not per-key. |
|
|
@@ -278,9 +287,11 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 12 superset tools (`
|
|
|
278
287
|
| `mock_request` *(superset)* | `Fetch.*` (+ `Page.reload`) | **A fake backend.** Registers a rule on a target's persistent session: fulfill with a canned response, fail, or continue (with optional `delayMs`/`failRate`). Persists across reloads until `clear_mocks`. Request-stage only. Cached requests aren't intercepted (use `reload:true`). |
|
|
279
288
|
| `list_mocks` *(superset)* | `Runtime.evaluate` (liveness probe) | Lists active mock sessions with rules + hit counts; prunes sessions whose tab closed. |
|
|
280
289
|
| `clear_mocks` *(superset)* | `Fetch.disable` | Tears down the resolved target's mock session (or all with `all:true`). |
|
|
281
|
-
| `claim_page` *(superset)* | `Target.createTarget` + lease file | Opens
|
|
282
|
-
| `release_page` *(superset)* | lease file | Gives a lease back
|
|
283
|
-
| `list_leases` *(superset)* | lease file | Who holds what, with pid liveness and reclaimability. Needs no token; never returns the nonce. A lease file that could not be read or parsed is reported as an `unreadable` row instead of being skipped. |
|
|
290
|
+
| `claim_page` *(superset)* | `Target.createTarget` + lease file | Opens a fresh tab and claims it (no `target`/`targetId`), or takes over a tab that is already open (`target`, any selector, or the exact-id `targetId`) and returns an opaque lease token plus `opened` (true only when this call created the tab). Never steals a tab another live process holds. Also returns `humanActiveMs` (ms since input this server did not dispatch; `null` means no data, never "no human") and, on a takeover of a tab a human used within the last 30s, a `contention` warning — the claim is never refused for it. MCP only; the CLI refuses it. |
|
|
291
|
+
| `release_page` *(superset)* | lease file | Gives a lease back via `lease` (the token) or `target` (a selector, for a lease the gate auto-acquired and so never handed out a token for). Idempotent. Closes the tab by default when this toolkit's creation ledger says it opened it; leaves a merely-claimed tab open. Override either way with `close`. |
|
|
292
|
+
| `list_leases` *(superset)* | lease file | Who holds what, with pid liveness and reclaimability, plus computed `idleMs`/`expiresAt` and, where the tab's beacon answers, `humanActiveMs` (absent, not null, when there's no answer). Needs no token; never returns the nonce. A lease file that could not be read or parsed is reported as an `unreadable` row instead of being skipped (with `idleMs`/`expiresAt` omitted). Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs, reporting closures in an additive `reaped` array. |
|
|
293
|
+
| `wait_for_download` *(superset)* | `Browser.setDownloadBehavior` + `Browser.downloadWillBegin`/`downloadProgress` on a standing browser-endpoint connection | Waits for a download to finish and returns it as a real file on disk: `{path,suggestedFilename,bytes,url,target}`. **Ordering rule, not optional:** call `wait_for_download{arm:true}` *before* the click that starts the download, then click, then call `wait_for_download` again to collect it — arming this late is not a preference, it's because Chrome reverts the download-behavior override the instant the arming client disconnects, and denies an unarmed download outright. **Browser-global side effect:** while armed, *every* download in the browser is redirected into the toolkit's downloads directory. **MCP-server only:** the arm lives on a connection this server process holds open; the one-shot CLI's connection dies with the process, so nothing is captured there. Chrome-only (`browser.downloads`). |
|
|
294
|
+
| `grant_permissions` *(superset)* | `Browser.grantPermissions` / `Browser.resetPermissions` on the same standing connection | Grants permissions (`geolocation`, `notifications`, `clipboardReadWrite`, ...) for an origin up front, so a page doesn't show a prompt no agent can click. Keyed by **origin**, not tab — every tab on that origin is affected, including ones opened later. `reset:true` clears this server's previous grants first (or instead); note CDP's reset is not origin-scoped, so it clears every origin at once. Same MCP-server-only reasoning as `wait_for_download`: Chrome discards the grant the moment the granting connection closes. Chrome-only (`browser.permissions`). |
|
|
284
295
|
|
|
285
296
|
## Parallel tabs: many agents, one browser
|
|
286
297
|
|
|
@@ -296,17 +307,71 @@ Two agents pointed at the same Chrome both resolve `target: active` to whatever
|
|
|
296
307
|
{"lease": "chrome:1A2B...:0f3c...", "targetId": "1A2B...", "url": "https://example.com", "label": "checkout-agent", "ttlMs": 900000, "expiresAt": 1754400000000}
|
|
297
308
|
```
|
|
298
309
|
|
|
299
|
-
Pass that token as `lease` on every later call against the tab. `new_page` with `claim: true` does the open-and-claim in one call. `release_page` gives it back, `list_leases` shows who holds what.
|
|
310
|
+
Pass that token as `lease` on every later call against the tab. `new_page` with `claim: true` does the open-and-claim in one call. `release_page` gives it back and, by default, closes the tab if this toolkit opened it (see "Close on release" below). `list_leases` shows who holds what.
|
|
300
311
|
|
|
301
|
-
**Opt in, and only ever a refusal.** A tab nobody claimed behaves exactly as it did before. Omitting `lease` is identical to pre-1.2 behavior in every respect: no default changed, no return shape changed, no previously legal call now needs a new argument. This is why it
|
|
312
|
+
**Opt in, and only ever a refusal — this is the default, with `CDP_REQUIRE_LEASE` unset.** A tab nobody claimed behaves exactly as it did before. Omitting `lease` is identical to pre-1.2 behavior in every respect: no default changed, no return shape changed, no previously legal call now needs a new argument. This is why it was 1.2 and not 2.0.
|
|
313
|
+
|
|
314
|
+
### Strict mode: `CDP_REQUIRE_LEASE`
|
|
315
|
+
|
|
316
|
+
The default above is opt-in on purpose, but opt-in means an agent that never learns the protocol never gets the protection either. `CDP_REQUIRE_LEASE=1` (also `true`/`yes`/`on`, case-insensitively), read only by a long-lived MCP server process (never the CLI, see below), turns holding a lease from optional into mandatory: any call that resolves to a tab nobody holds now **acquires** a lease on it instead of driving it lease-free, so "you cannot drive a tab you do not hold" becomes literally true rather than a convention an agent can forget. `new_page` claims the tab it just created even without `claim:true`, for the same reason: a tab nobody holds the instant it exists is a tab the very next call would have to auto-acquire anyway.
|
|
317
|
+
|
|
318
|
+
That auto-acquired lease is a weaker tier than an explicit one, and the difference is what keeps strict mode ergonomic instead of a second protocol to learn:
|
|
319
|
+
|
|
320
|
+
- **Auto** (acquired by the gate, or by `new_page` without `claim:true`): owned by a *process*. Any later call from the same pid passes with no `lease` argument at all; only another process is refused.
|
|
321
|
+
- **Explicit** (`claim_page`, or `new_page{claim:true}`): still demands its token on every call, even from the process that claimed it. This is how one subagent fences a tab off from sibling subagents that share its pid, since a Claude Code session's subagents all run inside one MCP server process.
|
|
322
|
+
|
|
323
|
+
An unleased tab under strict mode is still refused to a different process exactly as under the default: the tab's whole selector surface (`active`, no target, `index:N`, `url:`, `title:`, a bare targetId) all resolve through the same gate, so there is no selector form that quietly bypasses acquisition. Turning `CDP_REQUIRE_LEASE` off never locks a process out of leases it already holds: the pid-only check on an auto lease is not itself gated on the flag, only the *acquiring* is.
|
|
324
|
+
|
|
325
|
+
**Strict mode is MCP-only, unconditionally, not by convention.** The CLI never reads `CDP_REQUIRE_LEASE` as true no matter how it is set, because a CLI invocation is one process per call: a lease it auto-acquired would be reclaimable by the dead-pid rule the instant that process exited, which is a worse position than no lease at all, and reap (below) would then read that instantly-dead-pid record as an abandoned agent tab and could close it the next time anything lists pages.
|
|
302
326
|
|
|
303
327
|
**What can newly fail.** Any call that resolves to a leased tab without presenting that tab's token is refused, naming the tab, its url, and the label of whoever holds it. That is every selector form, not one of them: `active` or no target at all, `index:N`, `url:<substring>`, `title:<substring>`, and a bare targetId all reach the same check. The case worth singling out is `active` (or no target) against a leased tab index 0, because there you did not name the tab and before 1.2 the call silently succeeded against whatever tab happened to be first. That refusal is the point of the feature, and it is called out here rather than left to be discovered.
|
|
304
328
|
|
|
305
|
-
**Reclamation.** A lease is reclaimable when its owning process is gone, or when `lastUsedAt` is older than `ttlMs` (default 15 minutes, `CDP_LEASE_TTL_MS`). Those two are the whole list. A lease whose tab is no longer open is *reported* by `list_leases` as `target-gone`, which is useful to see, but that report is not what frees it: such a record is freed by the same two rules as any other, whichever lands first. Every checked call refreshes `lastUsedAt`, so an agent that is working never expires and no heartbeat is needed. Reclaiming mints a fresh nonce, which invalidates the previous owner's token: a stalled agent that comes back cannot keep driving a tab someone else now owns.
|
|
329
|
+
**Reclamation.** A lease is reclaimable when its owning process is gone, or when `lastUsedAt` is older than `ttlMs` (default 15 minutes, `CDP_LEASE_TTL_MS`). Those two are the whole list. A lease whose tab is no longer open is *reported* by `list_leases` as `target-gone`, which is useful to see, but that report is not what frees it: such a record is freed by the same two rules as any other, whichever lands first. Every checked call refreshes `lastUsedAt`, so an agent that is working never expires and no heartbeat is needed. Reclaiming mints a fresh nonce, which invalidates the previous owner's token: a stalled agent that comes back cannot keep driving a tab someone else now owns. This applies equally to an auto lease and an explicit one; the tier only changes who may use the lease without a token, never how or when it goes stale.
|
|
330
|
+
|
|
331
|
+
### Taking over a tab that is already open
|
|
332
|
+
|
|
333
|
+
`claim_page` had one mode before: open a fresh tab (or claim an exact `targetId`) and return a token for it. Neither covers "work in the tab I already have open", the ordinary shape of a human handing an agent a task in a tab that is already sitting in front of them, since a human does not have a targetId to hand over. `target` is that path — the toolkit's whole selector grammar, resolved against the tab that is actually there:
|
|
334
|
+
|
|
335
|
+
```json
|
|
336
|
+
{"tool": "claim_page", "arguments": {"target": "active", "label": "continuing-agent"}}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
```json
|
|
340
|
+
{"lease": "chrome:9F1A...:7b2e...", "targetId": "9F1A...", "url": "https://app.example.com/inbox", "label": "continuing-agent", "ttlMs": 900000, "expiresAt": 1754400000000, "opened": false}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
`opened: false` says this call took over a tab rather than creating one; it is `true` only from the fresh-tab mode. That flag matters because `release_page` reads the same distinction: a tab `claim_page{target}` took over has no creation-ledger record (this toolkit did not open it), so releasing it always leaves it open, regardless of `close`. Two safety properties hold without exception: **a selector that matches nothing is an error, never a silently substituted new tab** (`target` given means the fresh-tab branch is unreachable), and **there is no force or steal** — a tab another live process holds, auto-acquired or explicit, is refused with the same conflict error `claim_page` has always thrown, naming the holder. The tab this exists to take over is a human's, and a human's tab carries no lease to begin with.
|
|
344
|
+
|
|
345
|
+
### Close on release
|
|
346
|
+
|
|
347
|
+
`release_page` used to only ever give the lease back. It now also closes the tab, **by default and without any flag**, when the creation ledger says this toolkit opened it — the one thing a caller sees change without setting `CDP_REQUIRE_LEASE` at all:
|
|
348
|
+
|
|
349
|
+
- A tab `new_page` or `claim_page` created is closed on release.
|
|
350
|
+
- A tab that was already open, and merely claimed (`claim_page{targetId}` or `claim_page{target}`), is released and left exactly alone.
|
|
351
|
+
- `close: false` opts a call out of closing; `close: true` forces a close either way.
|
|
352
|
+
- A release that did not actually happen — already released, reclaimed, or expired — never closes anything, because the tab may belong to a different agent by then. That is the one rule here that is a safety property, not a convenience, and it holds regardless of `close`.
|
|
353
|
+
|
|
354
|
+
Under `CDP_REQUIRE_LEASE`, `release_page{target}` is how you give back a lease the gate acquired for you automatically, since that path never hands out a token to pass to `lease`.
|
|
355
|
+
|
|
356
|
+
### Reap: abandoned agent tabs get closed on read
|
|
357
|
+
|
|
358
|
+
`release_page` closes a tab an agent gives back; it does nothing for a tab whose agent crashed, was killed, or simply stopped calling; a timeout ending an agent is more common than a clean shutdown. Under `CDP_REQUIRE_LEASE`, `list_pages` and `list_leases` reap first: a tab is closed only when **all four** hold — its lease is for the browser being listed and is actually readable (an `unreadable` row is never touched), the lease is stale for `dead-pid` or `expired` (never `target-gone`, already closed, or a healthy lease), the target is still genuinely open, and **the toolkit both opened the tab and leased it**. That last condition is the one that matters most: an agent-created tab with no lease at all is never reaped, because that is exactly what `new_page` produces for a caller who never touches leases, and closing those would be data loss dressed as cleanup. Closed tabs are reported in an additive `reaped` array (`{targetId, label, reason}`), present only when something actually closed, so the common-case response shape is unchanged; a close that fails is silently left for the next read to retry rather than reported as done. Reap is strict-mode only, for the same reason auto-acquire is: outside a long-lived process, a dead-pid record proves nothing about abandonment.
|
|
359
|
+
|
|
360
|
+
**Reclaimable and destroyed are two different moments, on purpose (`CDP_REAP_GRACE_MS`).** A lease reads `stale`/reclaimable in `list_leases` the instant its `ttlMs` elapses — cheap and reversible, since another agent may simply take it over. Reap's *close*, the destructive half, waits longer: an `expired` lease only qualifies for reap once `now - lastUsedAt > ttlMs + CDP_REAP_GRACE_MS` (default `2700000`, 45 minutes, so 60 minutes total after last use). An agent 20 minutes into a build between tool calls does not come back to find its tab destroyed just because another agent could, in principle, have reclaimed the lease. `dead-pid` is unaffected by grace and reaps immediately — that process is never coming back, so there is nothing to wait for.
|
|
306
361
|
|
|
307
362
|
**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.
|
|
308
363
|
|
|
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
|
|
364
|
+
**Verifying it against a real browser.** `bun run lease:smoke` (`test/lease-smoke.ts`) is the end-to-end harness for this feature, and `bun test` cannot reach any of it: a lease is keyed on the claiming pid, so only a second genuinely separate OS process can collide with the first, and reap only ever fires from a long-lived process. It drives a REAL browser on `CDP_BASE` (default `http://127.0.0.1:9222`) and spawns real child OS processes to play a tokenless stranger, a doomed child that claims a tab and exits, and a corpse that exists only to donate a genuinely dead pid. Under the default (flag off): it asserts the owner can act, a tokenless stranger is refused with the `LeaseConflictError` type, `targetId` and `holder` intact and NOT coded `no-such-target`, the refusal actually prevented the side effect (proved by reading a marker back, not by the throw), a genuinely missing target still reports `no-such-target`, the stranger is admitted after `release_page`, and an orphaned lease from a real dead child is reclaimable with a fresh nonce. It then turns `CDP_REQUIRE_LEASE` on for the rest of the run and asserts: a second strict process with no token is refused the same way; an ordinary ungated call auto-acquires a lease, marked `auto` and owned by that pid; `release_page{target}` closes a tab this toolkit opened and leaves alone one with no creation record, even when explicitly claimed; and reap closes a tab with a genuinely dead-pid lease, names it in `reaped` with `reason:"dead-pid"`, and leaves every other tab the run opened untouched. A final scenario takes over a stand-in for a human's tab (open, unleased, no creation record) via `claim_page{target}`, drives it with the returned token, releases it, and confirms it is still open, then confirms a target that matches nothing is refused and opens no tab. It is safe to point at a browser with real tabs open: every tab it touches is one it opened itself, addressed only by its own target id, and it never calls `Browser.close`. 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 and origin files go to a private temp dir that is removed on exit, and reap — the single most destructive path exercised here — only ever considers a tab with both a lease record and an origin record in that private directory, which no pre-existing tab has.
|
|
365
|
+
|
|
366
|
+
### Staleness: is a human already using this tab?
|
|
367
|
+
|
|
368
|
+
`lastUsedAt` measures TOOLKIT calls only, so a tab a person has been typing in for ten minutes looks perfectly idle by that field — exactly the case that matters once `claim_page{target}` lets an agent take over a tab a human already has open. 1.8.0 adds an in-page activity beacon and surfaces what it sees, without ever refusing anything because of it.
|
|
369
|
+
|
|
370
|
+
**The beacon.** A capture-phase, passive listener on `pointerdown`/`mousedown`/`keydown`/`wheel`/`touchstart` records `window.__cdpToolkitLastInput = Date.now()`. It's injected at claim time on both `claim_page` modes and `new_page{claim:true}`. On Chrome, `Page.addScriptToEvaluateOnNewDocument` alone does not survive navigation under this toolkit's per-call connection lifetime: the registration dies the instant the installing connection closes, which is every tool call. So the driver holds one connection open per beaconed tab purely to keep the registration alive across navigations (bounded at 32 tabs, oldest-first eviction; an evicted tab still answers for input on its current document, only the next navigation's re-arm is lost). Firefox needs none of this — its BiDi session lives for the whole process, so a `script.addPreloadScript` registration survives navigation for free.
|
|
371
|
+
|
|
372
|
+
**Telling a person from the toolkit itself.** CDP-dispatched input is `isTrusted:true` too, so the beacon alone can't distinguish a human click from the toolkit's own — every input-dispatching call (`click`, `drag`, `scroll`, `fill`, `fill_form`, `type_text`, `press_key`, `dispatch_mouse`) writes through one dispatch-log choke point, and a beacon timestamp counts as human only when it postdates the toolkit's own last dispatch on that tab by more than 1500ms. Known blind spots: input inside a cross-origin iframe is invisible to it, and a *second* MCP server process's dispatches read as human to this one.
|
|
373
|
+
|
|
374
|
+
**Surfacing.** `claim_page` and `list_leases` both return `humanActiveMs` — milliseconds since input this server did not dispatch. **`null` (or an absent field) means NO DATA — no beacon on that tab yet, or every input on it was this server's own — it never means "no human."** On a `claim_page` takeover of a tab a human used within the last 30 seconds, the result also carries a `contention` warning string. **The claim is never refused for this**: taking over a person's tab is what takeover mode is for, so by the time you'd read the warning you already hold the lease and the tab is yours to drive — it just tells you that driving it now means fighting a live person for the keyboard and mouse, so you can open your own tab instead or ask first. `list_pages{probe:true}` adds a third read on top: one bounded (500ms) renderer ping per page-type target, annotating `responsive:boolean` (false on a timeout or a wedged tab, never an error for the whole call) and, where that same round trip found one, `humanActiveMs`.
|
|
310
375
|
|
|
311
376
|
### Which tab did an agent open? (`origin`)
|
|
312
377
|
|
|
@@ -322,9 +387,9 @@ So every tab this toolkit creates is written to a creation ledger, and `list_pag
|
|
|
322
387
|
- `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
388
|
- `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
389
|
|
|
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.
|
|
390
|
+
**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. Under `CDP_REQUIRE_LEASE`, the record is also what authorizes a *close*: `list_pages`/`list_leases` reap an abandoned agent's tab only when it has both this origin record and a stale (`dead-pid`/`expired`) lease, never on the origin record alone, and `release_page` (or reap) consults it to decide whether closing this specific tab is this toolkit's call to make. See "Reap" above.
|
|
326
391
|
|
|
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.
|
|
392
|
+
**Claiming a tab you did not open records nothing.** `claim_page` with an explicit `targetId`, or with `target` (the takeover path), takes ownership of an existing tab without creating one; that tab keeps reporting `origin: "unknown"`, because provenance is not ownership. That is also why `release_page` leaves such a tab open no matter how it was claimed.
|
|
328
393
|
|
|
329
394
|
**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
395
|
|
|
@@ -335,7 +400,7 @@ So every tab this toolkit creates is written to a creation ledger, and `list_pag
|
|
|
335
400
|
- 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.
|
|
336
401
|
- 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.
|
|
337
402
|
- No protection from humans. The lease is enforced against cdp-toolkit's own tool calls. It cannot stop someone clicking into the tab.
|
|
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`.
|
|
403
|
+
- 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`. Strict mode (`CDP_REQUIRE_LEASE`) is likewise MCP-only and for the same underlying reason, doubled: an auto-acquired lease would be reclaimable on arrival, worse than no lease at all, and reap would then be free to read that instantly-dead-pid record as an abandoned tab and close it on the next listing.
|
|
339
404
|
|
|
340
405
|
## How it's built
|
|
341
406
|
|
|
@@ -343,13 +408,14 @@ So every tab this toolkit creates is written to a creation ledger, and `list_pag
|
|
|
343
408
|
src/
|
|
344
409
|
client.ts # CdpConnection, openPage/withPage/openBrowser, resolveTarget, timeouts
|
|
345
410
|
types.ts # Target, TargetSelector, Uid, CDP envelopes
|
|
346
|
-
index.ts # TOOLS registry (
|
|
411
|
+
index.ts # TOOLS registry (45) + re-exported client primitives
|
|
347
412
|
cli.ts # the Bun CLI
|
|
348
413
|
mcp.ts # stdio MCP server (exposes TOOLS via @modelcontextprotocol/sdk)
|
|
349
414
|
manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
|
|
350
|
-
leases.ts # lease records, staleness, reclamation, assertLeaseOk
|
|
415
|
+
leases.ts # lease records, staleness, reclamation, assertLeaseOk, CDP_REQUIRE_LEASE
|
|
351
416
|
leases-tools.ts # claim_page / release_page / list_leases
|
|
352
417
|
origins.ts # tab creation ledger: who OPENED a tab, outliving its lease
|
|
418
|
+
reap.ts # closes abandoned agent tabs on list_pages / list_leases (strict mode)
|
|
353
419
|
tools/
|
|
354
420
|
pages.ts navigation.ts evaluate.ts snapshot.ts input.ts
|
|
355
421
|
screenshot.ts emulation.ts dialogs.ts recorder.ts console.ts
|