cdp-toolkit 1.4.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "1.4.0",
4
- "description": "A lightweight chrome-devtools-mcp alternative: a single-target MCP server + CLI that drives one tab over a direct WebSocket with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 39 tools including built-in network mocking and a cookie group. No Puppeteer, no all-target fan-out.",
3
+ "version": "1.7.0",
4
+ "description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 41 tools including built-in network mocking, a cookie group, tab leases, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": {
@@ -75,6 +75,7 @@
75
75
  "test": "bun test",
76
76
  "smoke": "bun run ./test/smoke.ts",
77
77
  "mock:smoke": "bun run ./test/mock-smoke.ts",
78
+ "screencast:smoke": "bun run ./test/screencast-smoke.ts",
78
79
  "mcp:smoke": "bun run ./test/mcp-smoke.ts",
79
80
  "firefox:smoke": "bun run ./test/firefox-smoke.ts",
80
81
  "lease:smoke": "bun run ./test/lease-smoke.ts"
@@ -269,6 +269,10 @@ const BUTTON_CODES: Record<"left" | "right" | "middle", number> = { left: 0, mid
269
269
  * supported yet" on real Firefox 153.0.3, contradicting the initial assumption that both text and
270
270
  * xpath locators were live; ElementLocator's `text` branch still resolves and correctly surfaces
271
271
  * "unsupported" rather than silently misbehaving, it is simply not offered as a capability.
272
+ * capture.screencast is absent because WebDriver BiDi has no streamed-frame primitive at all:
273
+ * the spec offers only the one-shot browsingContext.captureScreenshot, with no screencast
274
+ * start/stop command and no per-repaint frame event to subscribe to, so the screen-recording
275
+ * pair is honestly missing under Firefox rather than present and throwing.
272
276
  * network.intercept rides network.addIntercept + network.beforeRequestSent, verified working
273
277
  * (see intercept()'s comment on why urlPatterns is never populated). screenshot.fullPage uses
274
278
  * captureScreenshot's origin:"document", screenshot.element uses its clip:{type:"element"}, both verified.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Per-backend tool availability: filters the 36-tool registry down to what a
2
+ * Per-backend tool availability: filters the 41-tool registry down to what a
3
3
  * given DriverKind can actually run, per ADR-001's rule that tools/list
4
4
  * discovers gaps, not runtime "unsupported" throws. Pure and I/O-free: driver
5
5
  * construction reads a static readonly `capabilities` set, it does not dial a
package/src/cdp/driver.ts CHANGED
@@ -259,12 +259,15 @@ async function elementClip(conn: CdpConnection, loc: ElementLocator): Promise<{
259
259
  * not "not armed in advance", so an honest capability set omits it rather than reading true by
260
260
  * construction. locate.text/locate.xpath both ride DOM.performSearch (see searchLocate above),
261
261
  * which is a real Chrome capability, not a line-budget artifact, so both ARE declared.
262
+ * capture.screencast rides Page.startScreencast / Page.screencastFrame / Page.screencastFrameAck,
263
+ * the streamed-repaint primitive behind start_screen_recording and stop_screen_recording.
262
264
  */
263
265
  const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
264
266
  "trace.performance", "heap.snapshot", "audit.lighthouse", "emulate.cpuThrottling",
265
267
  "emulate.mediaFeatures", "emulate.deviceMetrics", "emulate.networkConditions",
266
268
  "screenshot.fullPage", "screenshot.element", "network.intercept",
267
269
  "snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
270
+ "capture.screencast",
268
271
  ]);
269
272
  /* ---------------------------------- PageDriver ---------------------------------- */
270
273
  class CdpPageDriver implements PageDriver {
package/src/driver.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ADR-001: one browser-neutral Driver interface behind the 36 tools.
2
+ * ADR-001: one browser-neutral Driver interface behind the 41 tools.
3
3
  *
4
4
  * Context. Every tool in src/tools/ is a stateless one-shot on withPage():
5
5
  * open a socket, act, always close. That is only correct because a CDP `Uid`
@@ -126,6 +126,7 @@ export type Capability =
126
126
  | "trace.performance" // Chrome tracing, behind every performance_* tool
127
127
  | "heap.snapshot" // V8 heap snapshots
128
128
  | "audit.lighthouse" // shelling out to lighthouse against a debug port
129
+ | "capture.screencast" // streamed repaint frames (CDP Page.startScreencast); BiDi has no equivalent
129
130
  | "emulate.cpuThrottling" // CPU multiplier; absent from BiDi's emulation module
130
131
  | "emulate.mediaFeatures" // media type + features, e.g. prefers-color-scheme
131
132
  | "emulate.deviceMetrics" // past width/height: scale factor, mobile flag
@@ -151,6 +152,8 @@ export const REQUIRED_CAPABILITIES: Partial<Record<ToolName, readonly Capability
151
152
  performance_trace: ["trace.performance"],
152
153
  take_heapsnapshot: ["heap.snapshot"],
153
154
  lighthouse_audit: ["audit.lighthouse"],
155
+ start_screen_recording: ["capture.screencast"],
156
+ stop_screen_recording: ["capture.screencast"],
154
157
  mock_request: ["network.intercept"],
155
158
  list_mocks: ["network.intercept"],
156
159
  clear_mocks: ["network.intercept"],
package/src/index.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Two exports:
5
5
  * 1. `TOOLS` : the registry mapping every chrome-devtools-mcp tool name
6
- * (snake_case string, all 29) to its raw-CDP implementation function. The
6
+ * (snake_case string, all 29 of them) plus the toolkit's own superset
7
+ * tools, 41 entries in all, to its raw-CDP implementation function. The
7
8
  * CLI and any embedder dispatch through this single table.
8
9
  * 2. The client primitives, re-exported so consumers can build their own
9
10
  * flows on the same connection/timeout machinery the tools use.
@@ -12,8 +13,9 @@
12
13
  * value (or throws). See CONTRACT.md for the design rules each module follows.
13
14
  */
14
15
 
15
- // --- the 20 tools unified with Firefox onto one Driver-based implementation ---
16
+ // --- the 23 tools unified with Firefox onto one Driver-based implementation ---
16
17
  // (list_pages/new_page/close_page/select_page, navigate_page/wait_for, evaluate_script,
18
+ // list_cookies/set_cookie/delete_cookies,
17
19
  // take_snapshot, click/hover/drag/fill/fill_form/type_text/press_key/upload_file,
18
20
  // take_screenshot/emulate/resize_page, handle_dialog). See shared-tools.ts's file header for
19
21
  // the byte-identical-Chrome-behavior contract this bind honors, and for the 7 tools that stay
@@ -31,6 +33,8 @@ import {
31
33
  performanceAnalyzeInsight,
32
34
  performanceTrace,
33
35
  } from "./tools/performance.ts";
36
+ // --- screen recording (Page.startScreencast + ffmpeg) ---
37
+ import { startScreenRecording, stopScreenRecording } from "./tools/screencast.ts";
34
38
  // --- heap ---
35
39
  import { takeHeapsnapshot } from "./tools/heap.ts";
36
40
  // --- lighthouse (the sole non-CDP tool) ---
@@ -59,8 +63,12 @@ function onCdp<A>(fn: (driver: ReturnType<typeof createCdpDriver>, args: A) => P
59
63
  * (mock_request/list_mocks/clear_mocks : a persistent per-target fake backend),
60
64
  * plus a 3-tool cookie group (list_cookies/set_cookie/delete_cookies : the
61
65
  * cookie store including httpOnly cookies, which no page script can read or
62
- * write).
63
- * 39 entries total. Listed explicitly so the mapping is auditable at a glance and
66
+ * write), plus a 3-tool lease group (claim_page/release_page/list_leases :
67
+ * opt-in tab ownership so many agents can share one browser), plus a 2-tool
68
+ * screen-recording pair (start_screen_recording/stop_screen_recording : a tab
69
+ * captured to an H.265 MP4 with per-frame durations, so a variable-rate
70
+ * repaint stream plays back at wallclock speed).
71
+ * 29 + 1 + 3 + 3 + 3 + 2 = 41 entries total. Listed explicitly so the mapping is auditable at a glance and
64
72
  * the CLI can `--list` it.
65
73
  *
66
74
  * 20 of the 29 MCP-parity tools (pages/navigation/evaluate/snapshot/interaction/
@@ -110,6 +118,9 @@ export const TOOLS = {
110
118
  performance_stop_trace: performanceStopTrace,
111
119
  performance_analyze_insight: performanceAnalyzeInsight,
112
120
  performance_trace: performanceTrace,
121
+ // screen recording (2) : toolkit addition, a tab-to-H.265-MP4 pair over Page.startScreencast
122
+ start_screen_recording: startScreenRecording,
123
+ stop_screen_recording: stopScreenRecording,
113
124
  // heap (1)
114
125
  take_heapsnapshot: takeHeapsnapshot,
115
126
  // lighthouse (1) : the only non-CDP tool
@@ -1,16 +1,22 @@
1
1
  /**
2
2
  * The three lease tools, shaped like src/shared-tools.ts entries
3
3
  * ((driver, args) => Promise<result>) so ONE implementation serves Chrome and
4
- * Firefox, exactly as the other 20 unified tools do.
4
+ * Firefox, exactly as the 23 unified tools in that file do.
5
5
  */
6
6
  import type { BrowserDriver } from "./driver.ts";
7
- import { newTrackedPage } from "./origins.ts";
7
+ import { newTrackedPage, readOrigin } from "./origins.ts";
8
+ import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
9
+ import { pickPage, resolvePage } from "./shared-tools.ts";
10
+ import type { TargetSelector } from "./types.ts";
8
11
  import {
9
12
  claimLease,
10
13
  defaultLabel,
11
14
  leaseTtlMs,
12
15
  listLeases,
13
16
  releaseLease,
17
+ releaseLeaseFor,
18
+ requireLease,
19
+ tokenParts,
14
20
  type LeaseBackend,
15
21
  type LeaseSummary,
16
22
  type LeaseToken,
@@ -31,34 +37,87 @@ export interface ClaimPageResult {
31
37
  label: string;
32
38
  ttlMs: number;
33
39
  expiresAt: number;
40
+ /** true when this call CREATED the tab, false when it claimed one that was
41
+ * already open. The same distinction the creation ledger records, surfaced
42
+ * in the answer so a caller knows without a second lookup whether
43
+ * release_page will close the tab. */
44
+ opened: boolean;
34
45
  }
35
46
 
36
47
  /**
37
- * Claim a tab. With no targetId, opens a fresh tab first (optionally at `url`)
38
- * and claims that, so "give me my own tab" is one call. With a targetId, claims
39
- * an already-open tab.
48
+ * Claim a tab, in one of two modes.
49
+ *
50
+ * OPEN: with neither `target` nor `targetId`, opens a fresh tab (optionally at
51
+ * `url`) and claims that, so "give me my own tab" is one call.
52
+ *
53
+ * TAKE OVER: with `target`, claims a tab that is ALREADY OPEN — the first-class
54
+ * path for "work in the tab I have open", which previously had none, because
55
+ * `targetId` demands an exact id a human never has to hand and no id at all
56
+ * meant a brand new tab. `target` takes the toolkit's whole selector grammar
57
+ * (active | index:N | url:<substr> | title:<substr> | <targetId>).
58
+ *
59
+ * `targetId` is unchanged and still accepted; `target` is the same thing with a
60
+ * grammar, so exactly one of the two may be given.
61
+ *
62
+ * WHY RESOLUTION IS GATE-FREE HERE. `target` resolves through pickPage, NOT
63
+ * resolvePage. resolvePage runs assertLeaseOk, which under CDP_REQUIRE_LEASE
64
+ * auto-acquires a lease on an unleased tab: routing a claim through it would
65
+ * claim the tab twice and collide with itself. The protection is not lost, only
66
+ * moved — claimLease below still refuses a tab a live process holds.
67
+ *
68
+ * TAKEOVER IS OF UNLEASED TABS ONLY. There is deliberately no force/steal
69
+ * option: a tab another live agent holds stays refused with the same conflict
70
+ * error as before. The tab this feature exists to take over is a human's, and a
71
+ * human's tab carries no lease.
72
+ *
73
+ * RESOLUTION NEVER CREATES A TAB. With `target` given, the open branch is
74
+ * unreachable: an unmatched selector is an error, never a silently substituted
75
+ * new tab, because "the tab I asked for is not there" and "here is an empty one
76
+ * instead" are answers a caller must be able to tell apart.
40
77
  */
41
78
  export async function claimPage(
42
79
  driver: BrowserDriver,
43
- args: { targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
80
+ args: { target?: TargetSelector; targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
44
81
  ): Promise<ClaimPageResult> {
82
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
83
+ const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
84
+ if (hasTarget && hasTargetId) {
85
+ throw new LeaseToolError(
86
+ "claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.",
87
+ );
88
+ }
45
89
  const backend = backendOf(driver);
46
90
  const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
47
91
  const pages = await driver.listPages();
48
- let targetId = args.targetId;
49
- let url = args.url ?? "about:blank";
50
- if (targetId === undefined || targetId === "") {
92
+ let targetId: string;
93
+ let url: string;
94
+ let opened: boolean;
95
+ if (hasTarget) {
96
+ // Gate-free on purpose: see the header. No tab is created on this path,
97
+ // including when nothing matches.
98
+ const hit = await pickPage(driver, pages, args.target).catch((err: unknown) => {
99
+ throw new LeaseToolError(
100
+ `claim_page: no open tab matches target '${args.target}' (${(err as Error).message}). No tab was opened: claim_page never creates one when 'target' is given.`,
101
+ );
102
+ });
103
+ targetId = hit.id;
104
+ url = hit.url;
105
+ opened = false;
106
+ } else if (hasTargetId) {
107
+ const existing = pages.find((p) => p.id === args.targetId);
108
+ if (!existing) throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
109
+ targetId = existing.id;
110
+ url = existing.url;
111
+ opened = false;
112
+ } else {
51
113
  // Creation ledger, not the lease: this record survives release_page and
52
114
  // expiry, so the tab stays attributable to `label` after the lease is gone.
53
- // Claiming an EXISTING tab (the else branch) records nothing, because this
54
- // toolkit did not create it and provenance is not ownership.
115
+ // Claiming an EXISTING tab (the branches above) records nothing, because
116
+ // this toolkit did not create it and provenance is not ownership.
55
117
  const page = await newTrackedPage(driver, backend, { url: args.url, label });
56
118
  targetId = page.id;
57
119
  url = page.url;
58
- } else {
59
- const existing = pages.find((p) => p.id === targetId);
60
- if (!existing) throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
61
- url = existing.url;
120
+ opened = true;
62
121
  }
63
122
  const liveIds = [...pages.map((p) => p.id), targetId];
64
123
  const { record, token } = await claimLease(backend, targetId, {
@@ -73,31 +132,93 @@ export async function claimPage(
73
132
  label: record.label,
74
133
  ttlMs: record.ttlMs,
75
134
  expiresAt: record.lastUsedAt + record.ttlMs,
135
+ opened,
76
136
  };
77
137
  }
78
138
 
79
- /** Release a held lease. Idempotent: an already-released or reclaimed lease
80
- * reports released:false rather than throwing. */
139
+ /**
140
+ * Give a lease back, and close the tab if this toolkit opened it.
141
+ *
142
+ * TWO WAYS IN, because an auto-acquired lease never handed the caller a token:
143
+ * `lease` is the 1.4.0 path, `target` resolves through shared-tools' resolvePage
144
+ * and is authorized by the same gate as any other call. Exactly one is required;
145
+ * accepting both would leave it ambiguous which one authorizes the close.
146
+ *
147
+ * WHY PROVENANCE DECIDES THE CLOSE. A lease says who is driving a tab, never who
148
+ * opened it. Closing on release is right for a tab this toolkit opened for an
149
+ * agent and wrong for a tab the human already had open and an agent merely
150
+ * claimed. origins.ts records exactly that distinction and its records outlive
151
+ * the lease, so the answer is still there at release time.
152
+ *
153
+ * A RELEASE THAT DID NOT HAPPEN NEVER CLOSES. An already-released or reclaimed
154
+ * lease reports released:false, and the tab may well belong to another agent by
155
+ * then; closing it would destroy a tab someone else is driving. This is the one
156
+ * rule in this function that is a safety property rather than a convenience.
157
+ */
81
158
  export async function releasePage(
82
- _driver: BrowserDriver,
83
- args: { lease?: LeaseToken },
84
- ): Promise<{ released: boolean }> {
85
- if (typeof args.lease !== "string" || !args.lease.length) {
86
- throw new LeaseToolError("release_page requires the 'lease' token returned by claim_page");
159
+ driver: BrowserDriver,
160
+ args: { lease?: LeaseToken; target?: TargetSelector; close?: boolean } = {},
161
+ ): Promise<{ released: boolean; closed: boolean; targetId?: string }> {
162
+ const hasLease = typeof args.lease === "string" && args.lease.length > 0;
163
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
164
+ if (hasLease === hasTarget) {
165
+ throw new LeaseToolError(
166
+ "release_page takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds)",
167
+ );
168
+ }
169
+
170
+ const backend = backendOf(driver);
171
+ let targetId: string;
172
+ let released: boolean;
173
+
174
+ if (hasLease) {
175
+ const parts = tokenParts(args.lease as LeaseToken);
176
+ // A malformed token names no tab, so there is nothing to release and
177
+ // nothing to close. Idempotent rather than throwing, as in 1.4.0.
178
+ if (!parts) return { released: false, closed: false };
179
+ targetId = parts.targetId;
180
+ released = (await releaseLease(args.lease as LeaseToken)).released;
181
+ } else {
182
+ // resolvePage runs assertLeaseOk, which IS the authorization: a tab held by
183
+ // another process throws here, and under strict mode an unleased tab is
184
+ // acquired first, so the caller holds it by the time we release it.
185
+ const page = await resolvePage(driver, args.target);
186
+ targetId = page.id;
187
+ released = (await releaseLeaseFor(backend, targetId)).released;
87
188
  }
88
- return releaseLease(args.lease);
189
+
190
+ if (!released) return { released: false, closed: false, ...(hasTarget ? { targetId } : {}) };
191
+
192
+ const shouldClose =
193
+ args.close === false ? false
194
+ : args.close === true ? true
195
+ : (await readOrigin(backend, targetId)) !== undefined;
196
+ if (!shouldClose) return { released: true, closed: false, targetId };
197
+
198
+ // The release already succeeded and is not undone by a failed close: the
199
+ // caller has genuinely given the lease back either way, and throwing here
200
+ // would report a release that did happen as an error.
201
+ const res = await driver.closePage(targetId).catch(() => ({ success: false }));
202
+ return { released: true, closed: res.success === true, targetId };
89
203
  }
90
204
 
91
205
  /** Enumerate active leases for diagnosis. Requires no token by design. */
92
206
  export async function listLeasesTool(
93
207
  driver: BrowserDriver,
94
208
  _args: Record<string, never> = {} as Record<string, never>,
95
- ): Promise<{ leases: LeaseSummary[]; count: number }> {
209
+ ): Promise<{ leases: LeaseSummary[]; count: number; reaped?: ReapedTab[] }> {
210
+ const backend = backendOf(driver);
211
+ // Same reap as list_pages, and for the same reason: this is the other tool an
212
+ // operator runs when tabs look wrong, so it must not report a lease it is
213
+ // about to delete. Strict mode only.
214
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
215
+ const reapedIds = new Set(reaped.map((r) => r.targetId));
96
216
  const liveIds = (await driver.listPages()).map((p) => p.id);
97
217
  // liveBackend scopes the target-gone test to the browser these ids came from,
98
218
  // so leases held on the OTHER backend are not mislabeled as reclaimable.
99
- const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
100
- return { leases, count: leases.length };
219
+ const all = await listLeases({ liveIds, liveBackend: backend });
220
+ const leases = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
221
+ return { leases, count: leases.length, ...(reaped.length ? { reaped } : {}) };
101
222
  }
102
223
 
103
224
  export const LEASE_TOOLS = {
package/src/leases.ts CHANGED
@@ -49,6 +49,15 @@ export interface LeaseRecord {
49
49
  createdAt: number;
50
50
  lastUsedAt: number;
51
51
  ttlMs: number;
52
+ /**
53
+ * How this lease was taken, and therefore who may use it without a token.
54
+ * true - the gate acquired it implicitly; passes for any call from `pid`.
55
+ * false - claim_page or new_page{claim:true}; the token is required, even
56
+ * from the owning process.
57
+ * Absent on records written by <=1.4.0 and read as false, so an upgrade never
58
+ * downgrades a held lease from token-required to pid-only.
59
+ */
60
+ auto?: boolean;
52
61
  }
53
62
 
54
63
  /** 15 minutes. Refresh-on-use means an active agent never expires, so this only
@@ -65,6 +74,43 @@ export function leaseTtlMs(): number {
65
74
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
66
75
  }
67
76
 
77
+ /**
78
+ * Whether THIS process is the long-lived MCP server rather than a one-shot CLI
79
+ * invocation. Module state, which this file's header otherwise forbids: that
80
+ * prohibition is about state describing OTHER processes' ownership, which must
81
+ * live on disk because two MCP servers drive one browser. This describes this
82
+ * process's own role, which no other process can observe or contend for, so a
83
+ * module flag is the correct scope rather than a violation of the rule.
84
+ */
85
+ let longLived = false;
86
+
87
+ /** Called once by mcp.ts at startup. cli.ts never calls it. The parameter
88
+ * exists so a test can put the module back into CLI state. */
89
+ export function markLongLivedProcess(value = true): void {
90
+ longLived = value;
91
+ }
92
+
93
+ /**
94
+ * Strict mode: every tool call acquires a lease for the tab it resolves, and
95
+ * stale agent tabs are reaped on read. Off by default, so 1.4.0 consumers are
96
+ * unaffected.
97
+ *
98
+ * ALWAYS FALSE UNDER THE CLI, regardless of the env var, and that is not a
99
+ * convenience. cli.ts already refuses claim_page because a CLI invocation is
100
+ * one process per call, so its lease is reclaimable by the dead-pid rule the
101
+ * moment the process exits, and "a lease that is reclaimable on arrival is
102
+ * worse than no lease". Auto-acquire would mint exactly such a lease on every
103
+ * call. The sharper danger is reap: those dead-pid records would read as
104
+ * orphaned agent tabs, so a CLI user running list_pages twice could watch the
105
+ * second run close the tabs the first run leased. Read per call, like
106
+ * leaseDir(), so a test can flip it.
107
+ */
108
+ export function requireLease(): boolean {
109
+ if (!longLived) return false;
110
+ const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
111
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
112
+ }
113
+
68
114
  /** The label recorded when a caller supplies none. ONE definition, because it
69
115
  * is now written by three call sites (claim_page, new_page, and the origin
70
116
  * ledger) and a second rule would make the same tab describe itself two
@@ -158,6 +204,9 @@ export interface ClaimOptions {
158
204
  now?: number;
159
205
  /** Live target ids from the browser, enabling the "target-gone" reclamation. */
160
206
  liveIds?: readonly string[];
207
+ /** Marks the lease gate-acquired. See LeaseRecord.auto. Defaults to false:
208
+ * an unmarked claim is an explicit one, which is the stricter reading. */
209
+ auto?: boolean;
161
210
  }
162
211
 
163
212
  /** Why an existing lease may be taken over. All three are sufficient alone. */
@@ -214,6 +263,7 @@ export async function claimLease(
214
263
  createdAt: now,
215
264
  lastUsedAt: now,
216
265
  ttlMs: opts.ttlMs ?? leaseTtlMs(),
266
+ auto: opts.auto === true,
217
267
  };
218
268
  try {
219
269
  await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
@@ -265,7 +315,7 @@ export async function releaseLeaseFor(backend: LeaseBackend, targetId: string):
265
315
  * This exists so enforcement can stay at ONE point per resolution path while
266
316
  * the token still reaches it. The token arrives as a key on a tool's args
267
317
  * object, many frames above resolveTarget, and threading a parameter through
268
- * all 36 tools would put the burden on every future contributor to remember
318
+ * all 41 tools would put the burden on every future contributor to remember
269
319
  * the check exists: one missed tool would silently defeat the feature for that
270
320
  * tool. mcp.ts and cli.ts open this scope once per dispatch instead, so a tool
271
321
  * added tomorrow is covered with no action from whoever writes it.
@@ -370,7 +420,29 @@ export async function assertLeaseOk(
370
420
  const where = describeTarget(targetId, ctx);
371
421
 
372
422
  if (token === undefined) {
373
- if (!held) return; // unleased, or leased-but-reclaimable: today's behavior
423
+ if (!held) {
424
+ // STRICT MODE: acquire rather than wave through. This is the change that
425
+ // makes "you cannot drive a tab you do not hold" true, and it is done
426
+ // here rather than by refusing because refusing would force every caller
427
+ // to learn a claim/release protocol to do what used to just work.
428
+ // Marked auto:true, which is what lets a later call from this same
429
+ // process pass with no token. A LeaseConflictError from claimLease (two
430
+ // processes racing to acquire the same tab, resolved by the "wx"
431
+ // exclusive create) propagates: the loser is genuinely refused.
432
+ if (requireLease()) {
433
+ await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
434
+ }
435
+ return; // unleased, or leased-but-reclaimable: today's behavior
436
+ }
437
+ // An auto lease is owned by a PROCESS, not by a token, so any call from the
438
+ // owning pid passes. NOT gated on requireLease(): an auto:true record can
439
+ // only exist because strict mode created it, and gating this too would mean
440
+ // turning the flag off locks this process out of leases it still holds. It
441
+ // cannot regress 1.4.0, where no auto:true record can exist at all.
442
+ if (held.auto === true && held.pid === process.pid) {
443
+ await touchLease(held, now);
444
+ return;
445
+ }
374
446
  throw new LeaseConflictError(
375
447
  `${where} is leased by '${held.label}' (pid ${held.pid}). Pass that lease's token as the 'lease' argument, or call release_page to free it. list_leases shows every active lease.`,
376
448
  targetId,
@@ -396,8 +468,16 @@ export async function assertLeaseOk(
396
468
  // call arrives here, select_page via activatePage included.
397
469
  if (parts.backend !== backend || parts.targetId !== targetId) {
398
470
  // The token is about a different tab, so it says nothing about this one.
399
- // An unleased tab is open to everyone, token in hand or not.
400
- if (!held) return;
471
+ // Under strict mode this tab still has to be acquired: an agent holding
472
+ // tab A that reaches into unleased tab C is exactly the case the feature
473
+ // exists to cover, and it is the COMMON case under the ambient dispatch
474
+ // scope, not an edge one.
475
+ if (!held) {
476
+ if (requireLease()) {
477
+ await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
478
+ }
479
+ return;
480
+ }
401
481
  throw new LeaseConflictError(
402
482
  `the lease token you passed is for ${parts.backend} target '${parts.targetId}', not ${where}, which is leased by '${held.label}'. Resolve the tab you actually hold, or claim this one.`,
403
483
  targetId,