cdp-toolkit 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +155 -0
- package/CONTRACT.md +7 -2
- package/README.md +57 -13
- package/dist/cli.js +184 -62
- package/dist/index.js +184 -62
- package/dist/mcp.js +245 -112
- package/package.json +1 -1
- package/src/leases-tools.ts +146 -25
- package/src/leases.ts +83 -3
- package/src/manifest.ts +59 -49
- package/src/mcp.ts +5 -2
- package/src/reap.ts +114 -0
- package/src/shared-tools.ts +39 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,160 @@ 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.7.0] - 2026-08-09
|
|
9
|
+
|
|
10
|
+
Four things land on top of 1.2's opt-in lease model, all under one new switch
|
|
11
|
+
plus one default that moves without it. `CDP_REQUIRE_LEASE` turns leasing from
|
|
12
|
+
optional into mandatory for a long-lived MCP server: every call auto-acquires
|
|
13
|
+
the tab it touches instead of driving it lease-free. `release_page` now closes
|
|
14
|
+
a tab this toolkit opened, the one behavior a caller sees without opting into
|
|
15
|
+
anything. Reap closes tabs an agent abandoned instead of leaking them forever.
|
|
16
|
+
And `claim_page` gained a `target` mode so an agent can take over a tab a human
|
|
17
|
+
already has open, not just claim one it created itself.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`CDP_REQUIRE_LEASE`, a switch that makes holding a lease mandatory instead
|
|
22
|
+
of optional, and the `auto` tier that makes it livable.** Off by default, so
|
|
23
|
+
a 1.6.0 consumer is unaffected. On, in a long-lived MCP server process only:
|
|
24
|
+
`assertLeaseOk` now *acquires* a lease on an unheld tab instead of waving the
|
|
25
|
+
call through, which is what makes "you cannot drive a tab you do not hold"
|
|
26
|
+
actually true rather than aspirational. That acquired lease is marked
|
|
27
|
+
`auto: true` on the `LeaseRecord` (absent, and therefore `false`, on every
|
|
28
|
+
record a pre-1.7.0 toolkit ever wrote, so an upgrade never downgrades a held
|
|
29
|
+
lease's protection), and an auto lease is owned by a *process*: any later
|
|
30
|
+
call from the same pid passes with no token, while a lease taken explicitly
|
|
31
|
+
via `claim_page` or `new_page{claim:true}` still demands its token even from
|
|
32
|
+
that same process. That two-tier split is what lets an ordinary tool call
|
|
33
|
+
keep working with zero protocol changes while still giving one subagent a
|
|
34
|
+
way to fence off a tab from its siblings, which share its pid. `new_page`
|
|
35
|
+
auto-claims the tab it just created under strict mode, even without
|
|
36
|
+
`claim:true`, because a tab nobody holds the instant it exists is a tab the
|
|
37
|
+
very next call would have to auto-acquire anyway; `claim:true` still yields
|
|
38
|
+
the stronger, explicit tier. **Strict mode is MCP-only, deliberately and
|
|
39
|
+
unconditionally**, not just by convention: `requireLease()` reads a
|
|
40
|
+
per-process flag that only `mcp.ts` sets at startup, so `CDP_REQUIRE_LEASE=1`
|
|
41
|
+
in a CLI invocation's environment is inert no matter what. A CLI call is one
|
|
42
|
+
process per invocation, so a lease it auto-acquired would be reclaimable by
|
|
43
|
+
the dead-pid rule the instant that process exited, and "a lease that is
|
|
44
|
+
reclaimable on arrival" is worse protection than none; the sharper danger is
|
|
45
|
+
reap, which would then read that instantly-dead-pid record as an abandoned
|
|
46
|
+
agent tab and could close it on the very next `list_pages` call.
|
|
47
|
+
|
|
48
|
+
- **`release_page{target}`, the counterpart an auto-acquired lease needs**,
|
|
49
|
+
because the gate mints those without ever handing the caller a token to give
|
|
50
|
+
back with `lease`. `target` takes the same selector grammar as everywhere
|
|
51
|
+
else and resolves through `resolvePage`, which is itself the authorization:
|
|
52
|
+
it runs `assertLeaseOk`, so a tab another live process holds is refused
|
|
53
|
+
there and, under strict mode, an unheld tab is acquired on the way in, which
|
|
54
|
+
means the caller genuinely holds it by the time it is released. `lease` and
|
|
55
|
+
`target` are mutually exclusive and exactly one is required.
|
|
56
|
+
|
|
57
|
+
- **Reap: `list_pages` and `list_leases` close tabs an agent abandoned,
|
|
58
|
+
instead of leaking them forever.** `release_page` only closes a tab an agent
|
|
59
|
+
gives back, and an agent that crashes, is killed, or just stops calling
|
|
60
|
+
never gives anything back; a timeout is a more likely end for an agent than
|
|
61
|
+
a clean shutdown. Rather than a background sweeper (a second lifetime to
|
|
62
|
+
reason about, and a process that has to be running), reap runs on read: the
|
|
63
|
+
two list tools already hold the browser's live target list and the lease
|
|
64
|
+
directory in hand at that moment, so checking costs one extra pass over data
|
|
65
|
+
already fetched. The selection itself, `staleAgentTabs` in the new
|
|
66
|
+
`src/reap.ts`, is a pure function so the four conditions that gate a close
|
|
67
|
+
can be tested exhaustively with no browser and no filesystem, which matters
|
|
68
|
+
because every mistake in this code closes a tab someone wanted: (1) the
|
|
69
|
+
lease row is for this backend and actually readable — an `unreadable` row is
|
|
70
|
+
never a candidate, because a guess in the destructive direction is the one
|
|
71
|
+
guess that cannot be undone; (2) it is stale for `dead-pid` or `expired`,
|
|
72
|
+
never `target-gone` (nothing to close) or a healthy lease; (3) the target is
|
|
73
|
+
still genuinely open, per the live page listing; and (4), the load-bearing
|
|
74
|
+
one, **the toolkit both opened the tab AND leased it** — an origin record by
|
|
75
|
+
itself is not enough. An agent-created tab that never went through
|
|
76
|
+
`claim_page`/`new_page{claim:true}` and was never auto-leased has no lease
|
|
77
|
+
row at all, and that is exactly what `new_page` produces for a caller who
|
|
78
|
+
never touches leases; reaping those would be a data-loss bug wearing a
|
|
79
|
+
cleanup's clothes, not a cleanup. Closed tabs are reported in an additive
|
|
80
|
+
`reaped` array (`{targetId, label, reason}`) present only when something was
|
|
81
|
+
actually closed, so the common-case response shape is byte-identical to
|
|
82
|
+
1.6.0; a close that fails is not reported and the lease is left in place, so
|
|
83
|
+
the next read tries again rather than lying about what is still open.
|
|
84
|
+
Strict-mode gated, for the same MCP-only reason as auto-acquire: a CLI user
|
|
85
|
+
running `list_pages` twice would otherwise watch the second run close the
|
|
86
|
+
tabs the first run's now-dead process just leased.
|
|
87
|
+
|
|
88
|
+
- **`claim_page{target}`, a takeover path for a tab that is already open.**
|
|
89
|
+
With neither `target` nor `targetId`, `claim_page` still opens a fresh tab,
|
|
90
|
+
which was its only mode before; `targetId` still claims an exact existing
|
|
91
|
+
target id, kept for back-compat. Neither of those covers "work in the tab I
|
|
92
|
+
already have open" when the caller does not have that tab's id to hand,
|
|
93
|
+
which is the ordinary shape of a human asking an agent to continue in a tab
|
|
94
|
+
that is sitting right in front of them. `target` takes the toolkit's whole
|
|
95
|
+
selector grammar (`active | index:N | url:<substr> | title:<substr> |
|
|
96
|
+
<targetId>`) and resolves it against the live page list only: an unmatched
|
|
97
|
+
selector is an error, never a silently substituted new tab, because "the tab
|
|
98
|
+
I asked for is not there" and "here is an empty one instead" are answers a
|
|
99
|
+
caller has to be able to tell apart. The result gained `opened: boolean`,
|
|
100
|
+
true only when this call actually created the tab, which is the same
|
|
101
|
+
distinction the creation ledger records and is what keeps close-on-release
|
|
102
|
+
safe through this new path: a tab taken over this way has no creation
|
|
103
|
+
record, so `release_page` releases it and leaves it open. **Resolution here
|
|
104
|
+
is deliberately gate-free** — it goes through `pickPage`, not `resolvePage`
|
|
105
|
+
— because under strict mode `resolvePage` would auto-acquire a lease on an
|
|
106
|
+
unheld tab on the way in, and the explicit claim right behind it would then
|
|
107
|
+
collide with the lease it had just minted for itself. The protection this
|
|
108
|
+
skips is not lost, only moved one layer down: `claimLease`'s own conflict
|
|
109
|
+
check still refuses a tab any live process holds, auto-acquired or explicit.
|
|
110
|
+
**There is deliberately no force or steal option.** A tab another live agent
|
|
111
|
+
holds is refused with the same conflict error as ever; the tab this feature
|
|
112
|
+
exists to take over is a human's, and a human's tab carries no lease at all.
|
|
113
|
+
|
|
114
|
+
### Changed (behavior, no flag)
|
|
115
|
+
|
|
116
|
+
- **`release_page` now closes a tab this toolkit opened, by default.** This is
|
|
117
|
+
the one thing a 1.6.0 caller sees without setting `CDP_REQUIRE_LEASE`:
|
|
118
|
+
previously `release_page` only ever gave the lease back and never touched
|
|
119
|
+
the tab. The close is scoped by the creation ledger from `origins.ts`, which
|
|
120
|
+
answers "did this toolkit open it" independently of the lease, and only
|
|
121
|
+
independently of the lease could it answer that at release time: a tab this
|
|
122
|
+
toolkit opened for an agent is closed, a tab a human already had open and an
|
|
123
|
+
agent merely claimed is released and left exactly alone, and `close: false`
|
|
124
|
+
opts out of a close per call while `close: true` forces one either way. A
|
|
125
|
+
release that did not actually happen — already released, reclaimed, or
|
|
126
|
+
expired — never closes anything, because by then the tab may belong to
|
|
127
|
+
another agent entirely; that is the one rule here that is a safety property
|
|
128
|
+
rather than a convenience, not a default that can be overridden.
|
|
129
|
+
|
|
130
|
+
### Residuals
|
|
131
|
+
|
|
132
|
+
Stated plainly rather than designed away, most inherited from strict mode's
|
|
133
|
+
design doc, one new to the takeover path:
|
|
134
|
+
|
|
135
|
+
- **Auto leases do not isolate subagents sharing one MCP server process.** All
|
|
136
|
+
of a session's subagents share one pid, so they pass each other's auto
|
|
137
|
+
leases freely. The escape hatch is an explicit `claim_page`, which demands
|
|
138
|
+
its token even same-process; that is the accepted cost of keeping the
|
|
139
|
+
ordinary path ergonomic.
|
|
140
|
+
- **Reap closes an idle-but-alive agent's tab after `ttlMs` elapses.**
|
|
141
|
+
`expired` means reclaimable, so another agent could already have taken the
|
|
142
|
+
tab by the time reap runs; closing it is consistent with that, but it is
|
|
143
|
+
still a close the original agent did not ask for. Tunable via
|
|
144
|
+
`CDP_LEASE_TTL_MS` (default 15 minutes).
|
|
145
|
+
- **A read can have a side effect.** `list_pages` and `list_leases` can close
|
|
146
|
+
tabs under strict mode. Mitigated only by reporting every close in `reaped`.
|
|
147
|
+
- **`release_page{target}` under strict mode, on an unleased agent tab,**
|
|
148
|
+
auto-acquires it and then closes it in the same call, closing a tab the
|
|
149
|
+
caller itself never drove a single command against. Accepted because under
|
|
150
|
+
strict mode an unleased agent tab means nobody is actually driving it.
|
|
151
|
+
- **Auto-acquire can mint a lease on a non-page target.** `pickPage`'s bare-id
|
|
152
|
+
branch searches the full `all:true` listing, so an exact id for a worker or
|
|
153
|
+
an iframe still resolves and gets leased. Harmless, since a lease is just a
|
|
154
|
+
file, but it will show up as a row in `list_leases`.
|
|
155
|
+
|
|
156
|
+
### Notes
|
|
157
|
+
|
|
158
|
+
- Tool count is unchanged at 41 (29 parity + 12 superset); 33 under Firefox.
|
|
159
|
+
`claim_page`, `release_page`, and `list_leases` gain arguments and result
|
|
160
|
+
fields, none of them removed or renamed.
|
|
161
|
+
|
|
8
162
|
## [1.6.0] - 2026-08-09
|
|
9
163
|
|
|
10
164
|
This release skips 1.5.0 (reserved for in-flight work on another branch).
|
|
@@ -315,5 +469,6 @@ First public release.
|
|
|
315
469
|
- 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
470
|
- Runtime: Bun ≥ 1.1 (recommended) or Node ≥ 22 (for the global `WebSocket`). Requires Chrome/Chromium started with `--remote-debugging-port=9222`.
|
|
317
471
|
|
|
472
|
+
[1.7.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.7.0
|
|
318
473
|
[1.6.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.6.0
|
|
319
474
|
[1.0.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.0.0
|
package/CONTRACT.md
CHANGED
|
@@ -12,7 +12,11 @@ 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`.
|
|
16
20
|
|
|
17
21
|
## Core client API (from `src/client.ts`)
|
|
18
22
|
|
|
@@ -62,7 +66,8 @@ export async function click(args: ClickArgs): Promise<unknown>;
|
|
|
62
66
|
- 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
67
|
- Recorder buffers: `${ARTIFACT_DIR}/rec-<targetId>.jsonl`.
|
|
64
68
|
- 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.
|
|
69
|
+
- 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.
|
|
70
|
+
- 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.
|
|
66
71
|
|
|
67
72
|
## The element-reference scheme (READ if you touch snapshot or input)
|
|
68
73
|
|
package/README.md
CHANGED
|
@@ -198,6 +198,7 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
|
|
|
198
198
|
| `CDP_ARTIFACT_DIR` | `/tmp/cdp-toolkit` | Screenshots, traces, heap snapshots, lighthouse reports, recorder buffers. |
|
|
199
199
|
| `CDP_STATE_DIR` | `/tmp/cdp-toolkit` | `select_page` selected-target file, in-flight trace state. |
|
|
200
200
|
| `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. |
|
|
201
|
+
| `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. |
|
|
201
202
|
|
|
202
203
|
## Firefox (WebDriver BiDi)
|
|
203
204
|
|
|
@@ -240,8 +241,8 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 12 superset tools (`
|
|
|
240
241
|
|
|
241
242
|
| MCP name | CDP method(s) | Parity notes / gaps |
|
|
242
243
|
|---|---|---|
|
|
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). |
|
|
244
|
+
| `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. Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs first, reporting closures in an additive `reaped` array. |
|
|
245
|
+
| `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
246
|
| `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
247
|
| `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
248
|
| `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). |
|
|
@@ -278,9 +279,9 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 12 superset tools (`
|
|
|
278
279
|
| `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
280
|
| `list_mocks` *(superset)* | `Runtime.evaluate` (liveness probe) | Lists active mock sessions with rules + hit counts; prunes sessions whose tab closed. |
|
|
280
281
|
| `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. |
|
|
282
|
+
| `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. MCP only; the CLI refuses it. |
|
|
283
|
+
| `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`. |
|
|
284
|
+
| `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. Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs, reporting closures in an additive `reaped` array. |
|
|
284
285
|
|
|
285
286
|
## Parallel tabs: many agents, one browser
|
|
286
287
|
|
|
@@ -296,17 +297,59 @@ Two agents pointed at the same Chrome both resolve `target: active` to whatever
|
|
|
296
297
|
{"lease": "chrome:1A2B...:0f3c...", "targetId": "1A2B...", "url": "https://example.com", "label": "checkout-agent", "ttlMs": 900000, "expiresAt": 1754400000000}
|
|
297
298
|
```
|
|
298
299
|
|
|
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.
|
|
300
|
+
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
301
|
|
|
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
|
|
302
|
+
**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.
|
|
303
|
+
|
|
304
|
+
### Strict mode: `CDP_REQUIRE_LEASE`
|
|
305
|
+
|
|
306
|
+
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.
|
|
307
|
+
|
|
308
|
+
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:
|
|
309
|
+
|
|
310
|
+
- **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.
|
|
311
|
+
- **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.
|
|
312
|
+
|
|
313
|
+
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.
|
|
314
|
+
|
|
315
|
+
**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
316
|
|
|
303
317
|
**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
318
|
|
|
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.
|
|
319
|
+
**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.
|
|
320
|
+
|
|
321
|
+
### Taking over a tab that is already open
|
|
322
|
+
|
|
323
|
+
`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:
|
|
324
|
+
|
|
325
|
+
```json
|
|
326
|
+
{"tool": "claim_page", "arguments": {"target": "active", "label": "continuing-agent"}}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
```json
|
|
330
|
+
{"lease": "chrome:9F1A...:7b2e...", "targetId": "9F1A...", "url": "https://app.example.com/inbox", "label": "continuing-agent", "ttlMs": 900000, "expiresAt": 1754400000000, "opened": false}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`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.
|
|
334
|
+
|
|
335
|
+
### Close on release
|
|
336
|
+
|
|
337
|
+
`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:
|
|
338
|
+
|
|
339
|
+
- A tab `new_page` or `claim_page` created is closed on release.
|
|
340
|
+
- A tab that was already open, and merely claimed (`claim_page{targetId}` or `claim_page{target}`), is released and left exactly alone.
|
|
341
|
+
- `close: false` opts a call out of closing; `close: true` forces a close either way.
|
|
342
|
+
- 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`.
|
|
343
|
+
|
|
344
|
+
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`.
|
|
345
|
+
|
|
346
|
+
### Reap: abandoned agent tabs get closed on read
|
|
347
|
+
|
|
348
|
+
`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.
|
|
306
349
|
|
|
307
350
|
**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
351
|
|
|
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
|
|
352
|
+
**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.
|
|
310
353
|
|
|
311
354
|
### Which tab did an agent open? (`origin`)
|
|
312
355
|
|
|
@@ -322,9 +365,9 @@ So every tab this toolkit creates is written to a creation ledger, and `list_pag
|
|
|
322
365
|
- `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
366
|
- `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
367
|
|
|
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.
|
|
368
|
+
**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
369
|
|
|
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.
|
|
370
|
+
**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
371
|
|
|
329
372
|
**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
373
|
|
|
@@ -335,7 +378,7 @@ So every tab this toolkit creates is written to a creation ledger, and `list_pag
|
|
|
335
378
|
- 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
379
|
- 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
380
|
- 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`.
|
|
381
|
+
- 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
382
|
|
|
340
383
|
## How it's built
|
|
341
384
|
|
|
@@ -347,9 +390,10 @@ src/
|
|
|
347
390
|
cli.ts # the Bun CLI
|
|
348
391
|
mcp.ts # stdio MCP server (exposes TOOLS via @modelcontextprotocol/sdk)
|
|
349
392
|
manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
|
|
350
|
-
leases.ts # lease records, staleness, reclamation, assertLeaseOk
|
|
393
|
+
leases.ts # lease records, staleness, reclamation, assertLeaseOk, CDP_REQUIRE_LEASE
|
|
351
394
|
leases-tools.ts # claim_page / release_page / list_leases
|
|
352
395
|
origins.ts # tab creation ledger: who OPENED a tab, outliving its lease
|
|
396
|
+
reap.ts # closes abandoned agent tabs on list_pages / list_leases (strict mode)
|
|
353
397
|
tools/
|
|
354
398
|
pages.ts navigation.ts evaluate.ts snapshot.ts input.ts
|
|
355
399
|
screenshot.ts emulation.ts dialogs.ts recorder.ts console.ts
|