cdp-toolkit 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,133 @@ 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.4.0] - 2026-08-06
9
+
10
+ This is the first npm release since 1.2.0. Both `v1.2.1` and `v1.3.0` were
11
+ tagged and pushed to GitHub but never reached npm, because the release runs
12
+ through GitHub Actions with OIDC trusted publishing and Actions was down. Their
13
+ changes are folded in here rather than published separately, so 1.4.0 on npm
14
+ carries the lease-conflict fix (1.2.1), the tab-origin ledger (1.3.0), and the
15
+ cookie group plus the `evaluate_script` file sink below. See the [1.3.0] and
16
+ [1.2.1] sections for the folded-in details.
17
+
18
+ ### Added
19
+
20
+ - **`set_cookie` and `delete_cookies`, the WRITE half of the cookie group.**
21
+ `list_cookies` made the store readable; these make it writable, including the
22
+ httpOnly and secure cookies `document.cookie` can neither create nor remove,
23
+ so seeding an authenticated session or clearing one is a tool call rather
24
+ than a hand-rolled raw CDP detour. `set_cookie` takes `{name, value}` plus
25
+ the usual cookie attributes (`url`, `domain`, `path`, `expires` in Unix
26
+ seconds, `httpOnly`, `secure`, `sameSite`) and answers `{set:true, target}`;
27
+ `delete_cookies` takes `{name}` plus `url` or `domain` and an optional
28
+ `path`, and answers `{deleted:true, target}`. Chrome rides `Network.setCookie`
29
+ and `Network.deleteCookies`, Firefox rides `storage.setCookie` and
30
+ `storage.deleteCookies` partitioned by the resolved browsing context.
31
+ Three deliberate refusals, all of which exist because the alternative is a
32
+ call that looks like it worked. First, either `url` or `domain` is required
33
+ on both tools: a cookie has to be attributed to a site, and guessing the
34
+ current page's origin would write a real cookie somewhere the caller never
35
+ named, while a name-only delete would sweep the whole partition. Second,
36
+ Chrome answers `Network.setCookie` with `success:false` rather than an error
37
+ when it declines a cookie (a domain the url does not cover, a secure cookie
38
+ on an insecure origin, an oversized value), so that false is raised as an
39
+ error instead of being reported as `set:true`. Third, BiDi has no `url`
40
+ parameter and requires a `domain`, so the Firefox driver derives one from the
41
+ url's host and throws on a url with no host (`about:blank`, a `data:` URL)
42
+ rather than sending a call Firefox answers cleanly while writing nothing.
43
+ Two things are deliberately absent: the response never echoes the value back,
44
+ since the value is usually the credential the caller just supplied, and
45
+ `delete_cookies` reports no count, because neither protocol says how many
46
+ cookies it removed and a number here would be invented. Read `list_cookies`
47
+ before and after when a real count is needed. `path` is passed through
48
+ exactly as given and never defaulted to `/`, since Chrome derives the path
49
+ from `url` and an invented default would widen a deliberately narrow cookie.
50
+
51
+ - **`list_cookies`, a new tool that reads the target page's cookie store,
52
+ httpOnly cookies included.** There was no cookie tool at all before this, so
53
+ reading an httpOnly session cookie, exactly the set `document.cookie` cannot
54
+ see and `evaluate_script` therefore cannot reach, meant dropping to raw CDP
55
+ by hand: a fallback that fights the very contention this toolkit exists to
56
+ avoid, since a page target accepts a second websocket and then never answers
57
+ on it while another client is attached. Each cookie carries `name`, `value`,
58
+ `domain`, `path`, `expires` (Unix seconds, `-1` for a session cookie),
59
+ `size`, `httpOnly`, `secure`, `sameSite` and `session`. The read is
60
+ page-scoped, not browser-wide: Chrome uses `Network.getCookies` rather than
61
+ the whole-profile `Storage.getCookies`, and Firefox uses `storage.getCookies`
62
+ partitioned by that browsing context, so a call naming one tab never returns
63
+ every other site's credentials. Both backends are supported, with the same
64
+ shape: the BiDi differences (a bytes-value that may be base64, an absent
65
+ `expiry` meaning a session cookie, a lowercase `sameSite`) are normalized in
66
+ the driver. Optional `domain` and `name` filters narrow the result: `name` is
67
+ an exact match, `domain` ignores a leading dot on either side and also
68
+ matches subdomains, and that is the entire matching rule. Optional `savePath`
69
+ behaves exactly as `evaluate_script`'s does: the cookie array is written to
70
+ that file as JSON and the response carries only `{path, bytes, count,
71
+ target}`, with no cookie value in any form, no preview and no truncation,
72
+ which is how to capture a session cookie without putting the credential in
73
+ the calling agent's transcript. Tab leases are respected the same way every
74
+ sibling tool respects them, at target resolution.
75
+
76
+ - **`evaluate_script` can write its value to a file instead of returning it,
77
+ via a new optional `savePath`.** Reading a JWT, a session token, or any other
78
+ credential out of the page previously forced the secret through the tool
79
+ result, which is to say straight into the calling agent's transcript, with no
80
+ way to opt out. With `savePath` set, the evaluated value is serialized as
81
+ JSON to that file and the response carries only `{path, bytes, type, target}`:
82
+ no copy of the value, no preview, no truncated form, no leading characters.
83
+ `type` is the JS `typeof` of the value (`"null"` for null), which describes
84
+ the value without disclosing any of it. Path handling matches
85
+ `take_heapsnapshot`: an absolute path is used as-is, a relative one resolves
86
+ under the artifact dir (`/tmp/cdp-toolkit`, or `CDP_ARTIFACT_DIR`), and
87
+ missing parent directories are created. Omitting `savePath` leaves
88
+ `evaluate_script` behaving exactly as it did before, value returned inline,
89
+ so this is purely additive for every existing caller. Thrown page-side
90
+ exceptions still surface as errors and are never written into the file.
91
+
92
+ ## [1.3.0] - 2026-08-06
93
+
94
+ `v1.2.1` was tagged and pushed to GitHub but never published to npm: the
95
+ release runs through GitHub Actions with OIDC trusted publishing, and Actions
96
+ was down for a full day. Rather than publish that fix on its own, it ships
97
+ here alongside tab origin, so 1.3.0 is the first npm release carrying both
98
+ the lease feature (`claim_page`/`release_page`/`list_leases`) and tab origin.
99
+
100
+ ### Added
101
+
102
+ - **`list_pages` now reports where each tab came from.** Every tab cdp-toolkit
103
+ creates (`new_page`, and `claim_page` with no `targetId`) is written to a
104
+ creation ledger stored alongside the lease files, and each `list_pages` row
105
+ gains `origin`: `"agent"` when the toolkit created the tab, carrying the
106
+ creating `label` and `createdAt`, otherwise `"unknown"`. The record outlives
107
+ the lease on purpose. Once an agent releases, expires, or dies, its abandoned
108
+ tab was previously indistinguishable from one the human opened, which is
109
+ exactly when provenance is worth having. `origin` is never `"human"`: the
110
+ toolkit cannot prove a person opened a tab, so `"unknown"` is the honest word
111
+ for anything it did not create itself. A tab whose record exists but could
112
+ not be read reports `"unknown"` plus `originUnreadable`, so a broken record is
113
+ never mistaken for no record, matching how `list_leases` reports an unreadable
114
+ lease. The ledger is reaped when it is read, dropping records for targets the
115
+ browser no longer has, so nothing has to be scheduled to clean it up. Purely
116
+ additive: `id`, `url`, `title`, and `type` are unchanged, and a missing or
117
+ unreadable ledger degrades to every page reporting `"unknown"` rather than
118
+ failing the call.
119
+
120
+ ## [1.2.1] - 2026-08-05
121
+
122
+ ### Fixed
123
+
124
+ - **On Chrome, a refusal caused by another agent's lease is now reported as a
125
+ lease conflict.** It was reported with the code meaning the target did not
126
+ exist, and the holder details were dropped, so a caller could not tell "this
127
+ tab is spoken for, go get the token or retry" apart from "that tab is not
128
+ there, stop looking". The refusal message itself always read correctly, so
129
+ anyone reading the text saw the real reason; only the machine readable code
130
+ and the holder fields were wrong. A genuinely missing target is still
131
+ reported as `no-such-target`. Enforcement was never affected: a leased tab
132
+ was refused before this fix and is refused after it. Firefox was never
133
+ affected, and now reports an identical conflict to Chrome's.
134
+
8
135
  ## [1.2.0] - 2026-08-05
9
136
 
10
137
  Two things landed since 1.0: a second backend, Firefox over WebDriver BiDi,
package/CONTRACT.md CHANGED
@@ -12,7 +12,7 @@ writing a module.
12
12
  3. **TypeScript strict.** `tsc --noEmit` must pass with `strict`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`. Import types with `import type`. Use `.ts` extensions in imports (e.g. `import { withPage } from "../client.ts"`).
13
13
  4. **One module per assigned bundle.** Write only the files you are assigned. Never edit `client.ts`, `types.ts`, or another agent's file.
14
14
  5. **Tool fn naming = camelCase of the MCP tool name.** `take_snapshot` → `export async function takeSnapshot(...)`. `list_network_requests` → `listNetworkRequests`. The integration step maps mechanically, so the names must be exact.
15
- 6. **Never re-resolve a target outside a choke point.** There are exactly three functions that turn a `TargetSelector` into a concrete tab, and all three call `assertLeaseOk`: `resolveTarget` (`src/client.ts`, Chrome), `resolveContext` (`src/bidi/driver.ts`, Firefox), and `resolvePage` (`src/shared-tools.ts`, used by `close_page` and `select_page`). A new tool must go through one of them. If you add a fourth resolution path, it must call `assertLeaseOk` too, or every tool on that path silently loses lease protection. Do NOT add a lease check to an individual tool: that puts the burden on every future contributor to remember it, and one forgotten tool defeats the feature for that tool. A lease conflict throws `LeaseConflictError` rather than being modelled as a capability gap under ADR-001. That departure is deliberate and reviewed: a runtime ownership collision between two agents is not a missing backend capability, so it cannot be discovered at `tools/list` time and must surface at call time.
15
+ 6. **Never re-resolve a target outside a choke point.** There are exactly three functions that turn a `TargetSelector` into a concrete tab, and all three call `assertLeaseOk`: `resolveTarget` (`src/client.ts`, Chrome), `resolveContext` (`src/bidi/driver.ts`, Firefox), and `resolvePage` (`src/shared-tools.ts`, used by `close_page` and `select_page`). A new tool must go through one of them. If you add a fourth resolution path, it must call `assertLeaseOk` too, or every tool on that path silently loses lease protection. Do NOT add a lease check to an individual tool: that puts the burden on every future contributor to remember it, and one forgotten tool defeats the feature for that tool. A lease conflict throws `LeaseConflictError` rather than being modelled as a capability gap under ADR-001. That departure is deliberate and reviewed: a runtime ownership collision between two agents is not a missing backend capability, so it cannot be discovered at `tools/list` time and must surface at call time. If you touch any of those three choke points, or the error handling above them, re-run `bun run lease:smoke` (`test/lease-smoke.ts`): `bun test` cannot reach this, because a lease is keyed on the claiming pid and only a second live OS process can collide with the first. That harness spawns one, drives a real browser, and asserts the refusal keeps its type, its `targetId` and its `holder` and actually blocks the side effect. It is safe against a browser with real tabs open: one throwaway tab, addressed and closed by its own id, with a runtime-captured baseline diffed at the end.
16
16
 
17
17
  ## Core client API (from `src/client.ts`)
18
18
 
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 *one* Chrome tab you point it at over the raw DevTools Protocol, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, one tab, no fan-out, plus a built-in network-mocking fake backend. **36 tools.**
3
+ **A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the *one* Chrome tab you point it at over the raw DevTools Protocol, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, one tab, no fan-out, plus a built-in network-mocking fake backend. **39 tools.**
4
4
 
5
5
  > For AI-agent developers and Claude Code / Cursor users who need **one known tab driven reliably**, not a Puppeteer-managed browser.
6
6
 
@@ -45,7 +45,8 @@ If [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp
45
45
  **Yes, if you:**
46
46
  - drive one known tab from Claude Code / Cursor / any MCP host and want it to never wedge;
47
47
  - need to **mock a backend** to build or test a UI before the real API exists;
48
- - have been bitten by the eager-`Network.enable` hang on a busy renderer.
48
+ - have been bitten by the eager-`Network.enable` hang on a busy renderer;
49
+ - run **multiple agents against one Chrome** and need them to stop stealing each other's tabs.
49
50
 
50
51
  **Probably not, if you:**
51
52
  - need cross-browser (Firefox / WebKit), use [`playwright-mcp`](https://github.com/microsoft/playwright-mcp);
@@ -117,7 +118,9 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
117
118
 
118
119
  - **Single-target reliability.** One WebSocket to one resolved target, a bounded timeout on every CDP command, lazy domain enabling, and stateless element refs. There is no broadcast step that can stall on a wedged tab.
119
120
  - **Network mocking: build the UI before the backend exists.** `mock_request` arms a persistent per-target fake backend: return canned responses, force errors, or inject latency/fault rates. Mocks survive reloads and navigations until `clear_mocks`.
120
- - **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, and heap snapshots. 36 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
121
+ - **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, heap snapshots, and a cookie group that reads, writes, and deletes httpOnly cookies. 39 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
122
+ - **Many agents, one browser, no stolen tabs.** `claim_page` hands out an opaque lease token for one tab; every other tool checks it at target resolution, so an unqualified call against a leased tab is refused by name rather than silently retargeted to whatever tab a different agent is driving.
123
+ - **Provenance that outlives the lease.** `list_pages` reports `origin: "agent"` (with the creating `label`) for every tab this toolkit opened, and `"unknown"` otherwise. It never claims `"human"`: the absence of a creation record cannot prove a person opened the tab, so `"unknown"` is the honest word once an agent releases, expires, or dies and its tab is left behind.
121
124
 
122
125
  ## Why raw CDP beats the MCP for known targets
123
126
 
@@ -159,6 +162,17 @@ cdp navigate_page --target index:0 --url https://example.com
159
162
  cdp take_snapshot --target url:example --interactiveOnly true
160
163
  cdp click --target index:0 --uid 42
161
164
  cdp evaluate_script --json '{"expression":"document.title"}'
165
+ # Read a value WITHOUT it landing in the response (or an agent transcript):
166
+ # savePath writes the value to a JSON file and returns {path,bytes,type,target} only.
167
+ cdp evaluate_script --json '{"expression":"localStorage.getItem(\"auth\")","savePath":"auth.json"}'
168
+ # Cookies, httpOnly ones included (document.cookie cannot see those):
169
+ cdp list_cookies --target index:0 --json '{"domain":"example.com"}'
170
+ # Same read, with the values kept out of the response: {path,bytes,count,target} only.
171
+ cdp list_cookies --json '{"name":"session","savePath":"cookies.json"}'
172
+ # Write one, httpOnly included (document.cookie cannot create those either):
173
+ cdp set_cookie --json '{"name":"session","value":"abc","url":"https://example.com/","httpOnly":true}'
174
+ # Remove it again. Either url or domain is required on both write tools.
175
+ cdp delete_cookies --json '{"name":"session","url":"https://example.com/"}'
162
176
  cdp take_screenshot --target url:example --fullPage true
163
177
  cdp lighthouse_audit --url https://example.com --json '{"categories":["performance"]}'
164
178
  ```
@@ -186,7 +200,7 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
186
200
 
187
201
  ## Firefox (WebDriver BiDi)
188
202
 
189
- cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 36-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
203
+ cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 39-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
190
204
 
191
205
  ```bash
192
206
  cdp --browser firefox take_snapshot # CLI: explicit flag
@@ -207,7 +221,7 @@ Backend selection precedence: `--browser chrome|firefox` flag, then `CDP_BROWSER
207
221
  - `take_heapsnapshot` (needs `heap.snapshot`)
208
222
  - `lighthouse_audit` (needs `audit.lighthouse`)
209
223
 
210
- Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain) and the `claim_page`/`release_page`/`list_leases` lease group, is available under both backends: 30 of the 36 tools.
224
+ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain) and the `claim_page`/`release_page`/`list_leases` lease group, is available under both backends: 33 of the 39 tools.
211
225
 
212
226
  **Honest capability gaps, not oversold parity:**
213
227
 
@@ -218,19 +232,22 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
218
232
  - **`locate.text` is not available** (Firefox 153's `browsingContext.locateNodes` rejects the `innerText` locator type as unsupported), unlike Chrome, which has it via `DOM.performSearch`.
219
233
  - **No `--browser firefox` attach mode.** By design, per the launch model above: every invocation gets its own throwaway Firefox process and profile.
220
234
 
221
- ## The tools (29 parity + 7 superset = 36)
235
+ ## The tools (29 parity + 10 superset = 39)
222
236
 
223
- The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 7 superset tools (`performance_trace`, the `mock_request`/`list_mocks`/`clear_mocks` group, and the `claim_page`/`release_page`/`list_leases` lease group) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
237
+ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 10 superset tools (`performance_trace`, the `list_cookies`/`set_cookie`/`delete_cookies` cookie group, the `mock_request`/`list_mocks`/`clear_mocks` group, and the `claim_page`/`release_page`/`list_leases` lease group) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
224
238
 
225
239
  | MCP name | CDP method(s) | Parity notes / gaps |
226
240
  |---|---|---|
227
- | `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. |
241
+ | `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. Each row additionally carries `origin` (`agent` or `unknown`, never `human`) plus `label`/`createdAt` for tabs this toolkit created. |
228
242
  | `new_page` | `Target.createTarget` (+ lease file) | Returns `{targetId,url}`; does not await navigation (use `navigate_page`). `claim:true` also claims the new tab and returns a `lease` token (`label`/`ttlMs` optional). |
229
243
  | `close_page` | `Target.closeTarget` (+ lease file) | Reports `success:true` on the empty result newer Chromium returns. A successful close also releases that tab's lease; a failed close leaves it in place. |
230
244
  | `select_page` | `Target.activateTarget` + selected-state file | Writes a flat-file selected target; `resolveTarget` does not read it, so `active` still means `index:0` unless a tool opts in. |
231
245
  | `navigate_page` | `Page.navigate` / `Page.reload` + load events | Returns `{url,frameId,waitedFor}` (no auto-snapshot). `waitUntil` supports `load`/`domcontentloaded`. `reload:true` (+ `ignoreCache:true` for a hard reload). |
232
246
  | `wait_for` | `Runtime.evaluate` (poll `innerText`) | Text-substring waiting only; throws on timeout rather than returning `{found:false}`. |
233
- | `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. |
247
+ | `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. |
248
+ | `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. |
249
+ | `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. |
250
+ | `delete_cookies` *(superset)* | `Network.deleteCookies` | Removes the named cookie, httpOnly ones included. Requires `name` plus `url` or `domain`, so a name-only call cannot sweep the store; optional `path` narrows further. Answers `{deleted:true,target}` with no count, because neither protocol reports one; read `list_cookies` before and after for a real count. |
234
251
  | `take_snapshot` | `Accessibility.getFullAXTree` | uid is the raw `backendDOMNodeId` (stateless, non-sequential). Full tree in one shot; frames flattened. `interactiveOnly` is a toolkit addition. |
235
252
  | `click` | `Input.dispatchMouseEvent` | No implicit auto-wait/retry; resolves and acts once; re-snapshot between steps. |
236
253
  | `hover` | `Input.dispatchMouseEvent` (`mouseMoved`) | Same single-shot model as `click`. |
@@ -285,6 +302,30 @@ Pass that token as `lease` on every later call against the tab. `new_page` with
285
302
 
286
303
  **Unreadable lease files.** A row in `list_leases` can carry `unreadable`, holding the errno (for example `EACCES`) or `"unparseable"`, when the lease file exists but could not be read or parsed. On such a row `label`, `pid`, `createdAt`, `lastUsedAt`, and `ttlMs` are zero placeholders, not real values, since they live inside the file that could not be read. `stale` is always `false` on that row, deliberately: `stale` is what a reader treats as free to take, and an unreadable lease must never read that way.
287
304
 
305
+ **Verifying it against a real browser.** `bun run lease:smoke` (`test/lease-smoke.ts`) is the end-to-end harness for this feature. It drives a REAL browser on `CDP_BASE` (default `http://127.0.0.1:9222`) and spawns a genuine second OS process for the stranger role, because a lease records the claiming pid and a sequence of CLI calls can never collide: the first process is dead before the second starts. It asserts that the owner can act, that a tokenless stranger is refused with the `LeaseConflictError` type, `targetId` and `holder` intact and NOT coded `no-such-target`, that the refusal actually prevented the side effect (proved by reading the marker back, not by the throw), that a genuinely missing target still reports `no-such-target`, that the stranger is admitted after `release_page`, and that a lease orphaned by a real child process which exited is reclaimable with a fresh nonce. It is safe to point at a browser with real tabs open: it opens exactly one throwaway `about:blank` tab, addresses it only by its own target id, closes exactly that tab, and never closes the browser. It captures its own baseline tab listing at runtime and fails only if a tab that existed before the run went missing; tabs you open mid-run are ignored. Lease files go to a private temp dir that is removed on exit.
306
+
307
+ ### Which tab did an agent open? (`origin`)
308
+
309
+ A lease answers "who is *driving* this tab right now". It cannot answer "who *opened* it", and that second question outlives the first: the moment an agent releases, expires, or dies, its abandoned tab is indistinguishable from one you opened yourself. Diffing two `list_pages` snapshots does not settle it either, since a tab you opened a minute ago looks exactly like a stray agent tab.
310
+
311
+ So every tab this toolkit creates is written to a creation ledger, and `list_pages` reports it:
312
+
313
+ ```json
314
+ {"id": "1A2B...", "url": "https://example.com", "title": "Example", "type": "page",
315
+ "origin": "agent", "label": "checkout-agent", "createdAt": 1754400000000}
316
+ ```
317
+
318
+ - `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`.
319
+ - `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.
320
+
321
+ **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.
322
+
323
+ **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.
324
+
325
+ **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`.
326
+
327
+ **Backward compatible.** `id`, `url`, `title`, and `type` are unchanged. The provenance fields are purely additive.
328
+
288
329
  **What this does not do.**
289
330
 
290
331
  - No fan-out. The shape is many agents with one tab each. One agent driving several tabs at once is a different feature and is not addressed.
@@ -298,12 +339,13 @@ Pass that token as `lease` on every later call against the tab. `new_page` with
298
339
  src/
299
340
  client.ts # CdpConnection, openPage/withPage/openBrowser, resolveTarget, timeouts
300
341
  types.ts # Target, TargetSelector, Uid, CDP envelopes
301
- index.ts # TOOLS registry (36) + re-exported client primitives
342
+ index.ts # TOOLS registry (39) + re-exported client primitives
302
343
  cli.ts # the Bun CLI
303
344
  mcp.ts # stdio MCP server (exposes TOOLS via @modelcontextprotocol/sdk)
304
345
  manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
305
346
  leases.ts # lease records, staleness, reclamation, assertLeaseOk
306
347
  leases-tools.ts # claim_page / release_page / list_leases
348
+ origins.ts # tab creation ledger: who OPENED a tab, outliving its lease
307
349
  tools/
308
350
  pages.ts navigation.ts evaluate.ts snapshot.ts input.ts
309
351
  screenshot.ts emulation.ts dialogs.ts recorder.ts console.ts
@@ -313,6 +355,7 @@ test/
313
355
  mock-smoke.ts # network-mocking end-to-end smoke (bun run mock:smoke)
314
356
  network_mock.test.ts # pure-logic unit tests (bun test)
315
357
  mcp-smoke.ts # MCP handshake + live tools/call round-trip (bun run mcp:smoke)
358
+ lease-smoke.ts # two-process live lease harness (bun run lease:smoke)
316
359
  ```
317
360
 
318
361
  Each module ends with a footer comment listing the exact CDP methods it uses and its parity gaps. The full design contract lives in [`CONTRACT.md`](./CONTRACT.md).