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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
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",
@@ -4,13 +4,19 @@
4
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" });
@@ -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,