cdp-toolkit 1.1.0 → 1.2.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/dist/cli.js CHANGED
@@ -1,5 +1,257 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/leases.ts
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { randomBytes } from "node:crypto";
6
+ import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ var DEFAULT_LEASE_TTL_MS = 900000;
9
+ function leaseDir() {
10
+ return process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
11
+ }
12
+ function leaseTtlMs() {
13
+ const raw = Number(process.env.CDP_LEASE_TTL_MS);
14
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
15
+ }
16
+ function safeId(id) {
17
+ return id.replace(/[^A-Za-z0-9._-]/g, "_");
18
+ }
19
+ function leaseFile(backend, targetId) {
20
+ return join(leaseDir(), `lease-${backend}-${safeId(targetId)}.json`);
21
+ }
22
+ function mintToken(backend, targetId, nonce) {
23
+ return `${backend}:${targetId}:${nonce}`;
24
+ }
25
+ function tokenParts(token) {
26
+ if (typeof token !== "string")
27
+ return;
28
+ const first = token.indexOf(":");
29
+ const last = token.lastIndexOf(":");
30
+ if (first <= 0 || last <= first)
31
+ return;
32
+ const backend = token.slice(0, first);
33
+ if (backend !== "chrome" && backend !== "firefox")
34
+ return;
35
+ const targetId = token.slice(first + 1, last);
36
+ const nonce = token.slice(last + 1);
37
+ if (!targetId.length || !/^[0-9a-f]{24}$/.test(nonce))
38
+ return;
39
+ return { backend, targetId, nonce };
40
+ }
41
+
42
+ class LeaseConflictError extends Error {
43
+ targetId;
44
+ holder;
45
+ constructor(message, targetId, holder) {
46
+ super(message);
47
+ this.targetId = targetId;
48
+ this.holder = holder;
49
+ this.name = "LeaseConflictError";
50
+ }
51
+ }
52
+ async function readLease(backend, targetId) {
53
+ let raw;
54
+ try {
55
+ raw = await readFile(leaseFile(backend, targetId), "utf8");
56
+ } catch (err) {
57
+ if (err.code === "ENOENT")
58
+ return;
59
+ throw err;
60
+ }
61
+ try {
62
+ const rec = JSON.parse(raw);
63
+ if (typeof rec?.nonce === "string" && typeof rec?.pid === "number")
64
+ return rec;
65
+ } catch {}
66
+ return;
67
+ }
68
+ function isPidAlive(pid) {
69
+ if (!Number.isInteger(pid) || pid <= 0)
70
+ return false;
71
+ try {
72
+ process.kill(pid, 0);
73
+ return true;
74
+ } catch (err) {
75
+ return err.code === "EPERM";
76
+ }
77
+ }
78
+ function staleReason(rec, opts) {
79
+ if (!isPidAlive(rec.pid))
80
+ return "dead-pid";
81
+ if (opts.now - rec.lastUsedAt > rec.ttlMs)
82
+ return "expired";
83
+ if (opts.liveIds && !opts.liveIds.includes(rec.targetId))
84
+ return "target-gone";
85
+ return false;
86
+ }
87
+ async function claimLease(backend, targetId, opts) {
88
+ const now = opts.now ?? Date.now();
89
+ await mkdir(leaseDir(), { recursive: true });
90
+ for (let attempt = 0;attempt < 3; attempt++) {
91
+ const record = {
92
+ backend,
93
+ targetId,
94
+ nonce: randomBytes(12).toString("hex"),
95
+ pid: process.pid,
96
+ label: opts.label,
97
+ createdAt: now,
98
+ lastUsedAt: now,
99
+ ttlMs: opts.ttlMs ?? leaseTtlMs()
100
+ };
101
+ try {
102
+ await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
103
+ return { token: mintToken(backend, targetId, record.nonce), record };
104
+ } catch (err) {
105
+ if (err.code !== "EEXIST")
106
+ throw err;
107
+ }
108
+ const existing = await readLease(backend, targetId);
109
+ if (!existing)
110
+ continue;
111
+ const stale = staleReason(existing, { now, liveIds: opts.liveIds });
112
+ if (!stale) {
113
+ throw new LeaseConflictError(`target '${targetId}' is already leased by '${existing.label}' (pid ${existing.pid}); call release_page with that lease's token, or list_leases to inspect it`, targetId, existing.label);
114
+ }
115
+ await unlink(leaseFile(backend, targetId)).catch(() => {
116
+ return;
117
+ });
118
+ }
119
+ throw new LeaseConflictError(`target '${targetId}' could not be claimed after 3 attempts: another process is claiming and releasing it concurrently`, targetId);
120
+ }
121
+ async function releaseLease(token) {
122
+ const parts = tokenParts(token);
123
+ if (!parts)
124
+ return { released: false };
125
+ const rec = await readLease(parts.backend, parts.targetId);
126
+ if (!rec || rec.nonce !== parts.nonce)
127
+ return { released: false };
128
+ await unlink(leaseFile(parts.backend, parts.targetId)).catch(() => {
129
+ return;
130
+ });
131
+ return { released: true };
132
+ }
133
+ async function releaseLeaseFor(backend, targetId) {
134
+ if (!await readLease(backend, targetId))
135
+ return { released: false };
136
+ await unlink(leaseFile(backend, targetId)).catch(() => {
137
+ return;
138
+ });
139
+ return { released: true };
140
+ }
141
+ var leaseScope = new AsyncLocalStorage;
142
+ function withLeaseScope(lease, fn) {
143
+ return leaseScope.run(lease, fn);
144
+ }
145
+ function currentLease() {
146
+ return leaseScope.getStore();
147
+ }
148
+ function leaseFromArgs(args) {
149
+ if (!args || typeof args !== "object")
150
+ return;
151
+ const lease = args.lease;
152
+ return typeof lease === "string" && lease.length > 0 ? lease : undefined;
153
+ }
154
+ async function touchLease(rec, now = Date.now()) {
155
+ const current = await readLease(rec.backend, rec.targetId).catch(() => {
156
+ return;
157
+ });
158
+ if (!current || current.nonce !== rec.nonce)
159
+ return;
160
+ await writeFile(leaseFile(current.backend, current.targetId), JSON.stringify({ ...current, lastUsedAt: now }), "utf8").catch(() => {
161
+ return;
162
+ });
163
+ }
164
+ function describeTarget(targetId, ctx) {
165
+ const url = ctx.url ? ` (url ${ctx.url})` : ctx.title ? ` (title ${JSON.stringify(ctx.title)})` : "";
166
+ return `target '${targetId}'${url}`;
167
+ }
168
+ async function assertLeaseOk(backend, targetId, ctx = {}) {
169
+ const token = ctx.lease ?? currentLease();
170
+ const now = ctx.now ?? Date.now();
171
+ const rec = await readLease(backend, targetId);
172
+ const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
173
+ const where = describeTarget(targetId, ctx);
174
+ if (token === undefined) {
175
+ if (!held)
176
+ return;
177
+ throw new LeaseConflictError(`${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.`, targetId, held.label);
178
+ }
179
+ const parts = tokenParts(token);
180
+ if (!parts) {
181
+ throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
182
+ }
183
+ if (parts.backend !== backend || parts.targetId !== targetId) {
184
+ if (!held)
185
+ return;
186
+ throw new LeaseConflictError(`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.`, targetId, held.label);
187
+ }
188
+ if (!held) {
189
+ throw new LeaseConflictError(`${where} is not leased any more: the token you passed belongs to a lease that was released or reclaimed. Call claim_page again to take the tab.`, targetId);
190
+ }
191
+ if (parts.nonce !== held.nonce) {
192
+ throw new LeaseConflictError(`${where} was reclaimed and is now leased by '${held.label}' (pid ${held.pid}). Your token was invalidated by that reclamation. Call claim_page again if you still need this tab.`, targetId, held.label);
193
+ }
194
+ await touchLease(held, now);
195
+ }
196
+ var LEASE_FILE_RE = /^lease-(chrome|firefox)-(.+)\.json$/;
197
+ async function listLeases(opts = {}) {
198
+ const now = opts.now ?? Date.now();
199
+ let names = [];
200
+ try {
201
+ names = await readdir(leaseDir());
202
+ } catch (err) {
203
+ if (err.code === "ENOENT")
204
+ return [];
205
+ throw err;
206
+ }
207
+ const out = [];
208
+ for (const name of names) {
209
+ const parts = LEASE_FILE_RE.exec(name);
210
+ if (!parts)
211
+ continue;
212
+ const fromName = {
213
+ backend: parts[1],
214
+ targetId: parts[2],
215
+ label: "",
216
+ pid: 0,
217
+ createdAt: 0,
218
+ lastUsedAt: 0,
219
+ ttlMs: 0,
220
+ pidAlive: false,
221
+ stale: false
222
+ };
223
+ let rec;
224
+ try {
225
+ rec = JSON.parse(await readFile(join(leaseDir(), name), "utf8"));
226
+ } catch (err) {
227
+ const code = err.code;
228
+ if (code === "ENOENT")
229
+ continue;
230
+ out.push({ ...fromName, unreadable: code ?? "unparseable" });
231
+ continue;
232
+ }
233
+ if (typeof rec?.nonce !== "string" || typeof rec?.pid !== "number") {
234
+ out.push({ ...fromName, unreadable: "unparseable" });
235
+ continue;
236
+ }
237
+ out.push({
238
+ backend: rec.backend,
239
+ targetId: rec.targetId,
240
+ label: rec.label,
241
+ pid: rec.pid,
242
+ createdAt: rec.createdAt,
243
+ lastUsedAt: rec.lastUsedAt,
244
+ ttlMs: rec.ttlMs,
245
+ pidAlive: isPidAlive(rec.pid),
246
+ stale: staleReason(rec, {
247
+ now,
248
+ liveIds: opts.liveBackend === undefined || opts.liveBackend === rec.backend ? opts.liveIds : undefined
249
+ })
250
+ });
251
+ }
252
+ return out;
253
+ }
254
+
3
255
  // src/client.ts
4
256
  var BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
5
257
  var DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
@@ -153,8 +405,13 @@ async function browserWsUrl() {
153
405
  const v = await httpJson("/json/version");
154
406
  return v.webSocketDebuggerUrl;
155
407
  }
156
- async function resolveTarget(selector) {
408
+ async function resolveTarget(selector, opts = {}) {
157
409
  const targets = await listTargets();
410
+ const hit = pickTarget(targets, selector);
411
+ await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
412
+ return hit;
413
+ }
414
+ function pickTarget(targets, selector) {
158
415
  const pages = targets.filter((t) => t.type === "page");
159
416
  if (!selector || selector === "active") {
160
417
  if (!pages.length)
@@ -190,7 +447,7 @@ async function openBrowser(opts = {}) {
190
447
  return new CdpConnection(await browserWsUrl(), opts).connect();
191
448
  }
192
449
  async function openPage(selector, opts = {}) {
193
- const target = await resolveTarget(selector);
450
+ const target = await resolveTarget(selector, { lease: opts.lease });
194
451
  const conn = await new CdpConnection(target.webSocketDebuggerUrl, opts).connect();
195
452
  return { conn, target };
196
453
  }
@@ -1071,9 +1328,63 @@ function createCdpDriver() {
1071
1328
  return new CdpBrowserDriver;
1072
1329
  }
1073
1330
 
1331
+ // src/leases-tools.ts
1332
+ class LeaseToolError extends Error {
1333
+ }
1334
+ function backendOf(driver) {
1335
+ return driver.scheme === "bidi" ? "firefox" : "chrome";
1336
+ }
1337
+ async function claimPage(driver, args = {}) {
1338
+ const backend = backendOf(driver);
1339
+ const label = typeof args.label === "string" && args.label.length ? args.label : `pid-${process.pid}`;
1340
+ const pages = await driver.listPages();
1341
+ let targetId = args.targetId;
1342
+ let url = args.url ?? "about:blank";
1343
+ if (targetId === undefined || targetId === "") {
1344
+ const page = await driver.newPage(args.url);
1345
+ targetId = page.id;
1346
+ url = page.url;
1347
+ } else {
1348
+ const existing = pages.find((p) => p.id === targetId);
1349
+ if (!existing)
1350
+ throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
1351
+ url = existing.url;
1352
+ }
1353
+ const liveIds = [...pages.map((p) => p.id), targetId];
1354
+ const { record, token } = await claimLease(backend, targetId, {
1355
+ label,
1356
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
1357
+ liveIds
1358
+ });
1359
+ return {
1360
+ lease: token,
1361
+ targetId,
1362
+ url,
1363
+ label: record.label,
1364
+ ttlMs: record.ttlMs,
1365
+ expiresAt: record.lastUsedAt + record.ttlMs
1366
+ };
1367
+ }
1368
+ async function releasePage(_driver, args) {
1369
+ if (typeof args.lease !== "string" || !args.lease.length) {
1370
+ throw new LeaseToolError("release_page requires the 'lease' token returned by claim_page");
1371
+ }
1372
+ return releaseLease(args.lease);
1373
+ }
1374
+ async function listLeasesTool(driver, _args = {}) {
1375
+ const liveIds = (await driver.listPages()).map((p) => p.id);
1376
+ const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
1377
+ return { leases, count: leases.length };
1378
+ }
1379
+ var LEASE_TOOLS = {
1380
+ claim_page: claimPage,
1381
+ release_page: releasePage,
1382
+ list_leases: listLeasesTool
1383
+ };
1384
+
1074
1385
  // src/shared-tools.ts
1075
- import { mkdir, writeFile } from "node:fs/promises";
1076
- import { join } from "node:path";
1386
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
1387
+ import { join as join2 } from "node:path";
1077
1388
  var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1078
1389
  var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
1079
1390
 
@@ -1082,6 +1393,9 @@ class SharedToolError extends Error {
1082
1393
  function target3(p) {
1083
1394
  return { id: p.id, url: p.url, title: p.title };
1084
1395
  }
1396
+ function backendOf2(driver) {
1397
+ return driver.scheme === "bidi" ? "firefox" : "chrome";
1398
+ }
1085
1399
  async function withPage2(driver, target, fn) {
1086
1400
  const page = await driver.page(target);
1087
1401
  try {
@@ -1092,6 +1406,11 @@ async function withPage2(driver, target, fn) {
1092
1406
  }
1093
1407
  async function resolvePage(driver, selector) {
1094
1408
  const pages = await driver.listPages();
1409
+ const hit = await pickPage(driver, pages, selector);
1410
+ await assertLeaseOk(backendOf2(driver), hit.id, { url: hit.url, title: hit.title });
1411
+ return hit;
1412
+ }
1413
+ async function pickPage(driver, pages, selector) {
1095
1414
  if (selector === undefined || selector === "active") {
1096
1415
  if (!pages.length)
1097
1416
  throw new SharedToolError("no page targets open");
@@ -1139,18 +1458,27 @@ async function listPages(driver, args = {}) {
1139
1458
  }
1140
1459
  async function newPage(driver, args = {}) {
1141
1460
  const p = await driver.newPage(args.url);
1142
- return { targetId: p.id, url: p.url };
1461
+ if (args.claim !== true)
1462
+ return { targetId: p.id, url: p.url };
1463
+ const { record, token } = await claimLease(backendOf2(driver), p.id, {
1464
+ label: typeof args.label === "string" && args.label.length ? args.label : `pid-${process.pid}`,
1465
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
1466
+ });
1467
+ return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
1143
1468
  }
1144
1469
  async function closePage(driver, args) {
1145
1470
  if (args.target === undefined || args.target === "")
1146
1471
  throw new SharedToolError("close_page requires an explicit target; refusing to guess which page to close");
1147
1472
  const p = await resolvePage(driver, args.target);
1148
1473
  const res = await driver.closePage(p.id);
1149
- return { closed: p.id, success: res.success };
1474
+ if (!res.success)
1475
+ return { closed: p.id, success: false, leaseReleased: false };
1476
+ const released = await releaseLeaseFor(backendOf2(driver), p.id);
1477
+ return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
1150
1478
  }
1151
1479
  async function writeSelectedFile(id) {
1152
- await mkdir(STATE_DIR, { recursive: true });
1153
- await writeFile(join(STATE_DIR, "selected"), id, "utf8");
1480
+ await mkdir2(STATE_DIR, { recursive: true });
1481
+ await writeFile2(join2(STATE_DIR, "selected"), id, "utf8");
1154
1482
  }
1155
1483
  async function selectPage(driver, args) {
1156
1484
  if (args.target === undefined || args.target === "")
@@ -1305,10 +1633,10 @@ async function takeScreenshot(driver, args = {}) {
1305
1633
  const { data, format: outFormat } = await page.screenshot({ format, quality: args.quality, fullPage: args.fullPage, ...clip ? { clip } : {} });
1306
1634
  if (data.byteLength === 0)
1307
1635
  throw new SharedToolError("captureScreenshot returned empty data");
1308
- await mkdir(ARTIFACT_DIR, { recursive: true });
1636
+ await mkdir2(ARTIFACT_DIR, { recursive: true });
1309
1637
  const ext = outFormat === "jpeg" ? "jpg" : "png";
1310
- const path = args.savePath ?? join(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
1311
- await writeFile(path, Buffer.from(data));
1638
+ const path = args.savePath ?? join2(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
1639
+ await writeFile2(path, Buffer.from(data));
1312
1640
  const result = {
1313
1641
  path,
1314
1642
  bytes: data.byteLength,
@@ -1371,10 +1699,10 @@ var SHARED_TOOLS = {
1371
1699
  };
1372
1700
 
1373
1701
  // src/tools/console.ts
1374
- import { readFile } from "node:fs/promises";
1702
+ import { readFile as readFile2 } from "node:fs/promises";
1375
1703
 
1376
1704
  // src/tools/recorder.ts
1377
- import { mkdir as mkdir2, appendFile, writeFile as writeFile2, copyFile } from "node:fs/promises";
1705
+ import { mkdir as mkdir3, appendFile, writeFile as writeFile3, copyFile } from "node:fs/promises";
1378
1706
  var ARTIFACT_DIR2 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1379
1707
  function recFile(targetId) {
1380
1708
  return `${ARTIFACT_DIR2}/rec-${targetId}.jsonl`;
@@ -1395,11 +1723,11 @@ var CONSOLE_EVENTS = ["Runtime.consoleAPICalled", "Runtime.exceptionThrown", "Lo
1395
1723
  async function startRecorder(target, opts = {}) {
1396
1724
  const network = opts.network ?? true;
1397
1725
  const consoleCap = opts.console ?? true;
1398
- await mkdir2(ARTIFACT_DIR2, { recursive: true });
1726
+ await mkdir3(ARTIFACT_DIR2, { recursive: true });
1399
1727
  const { conn, target: resolved } = await openPage(target, { timeoutMs: opts.timeoutMs });
1400
1728
  const file = opts.file ?? recFile(resolved.id);
1401
1729
  if (opts.truncate) {
1402
- await writeFile2(file, "");
1730
+ await writeFile3(file, "");
1403
1731
  }
1404
1732
  let chain = Promise.resolve();
1405
1733
  let dropped = 0;
@@ -1541,7 +1869,7 @@ function lineToEntry(line, index) {
1541
1869
  async function readConsoleEntries(file) {
1542
1870
  let raw;
1543
1871
  try {
1544
- raw = await readFile(file, "utf8");
1872
+ raw = await readFile2(file, "utf8");
1545
1873
  } catch {
1546
1874
  return [];
1547
1875
  }
@@ -1597,7 +1925,7 @@ async function getConsoleMessage(args = {}) {
1597
1925
  }
1598
1926
 
1599
1927
  // src/tools/network.ts
1600
- import { readFile as readFile2 } from "node:fs/promises";
1928
+ import { readFile as readFile3 } from "node:fs/promises";
1601
1929
  var DEFAULT_CAPTURE_MS2 = 2500;
1602
1930
  function asHeaders(v) {
1603
1931
  if (!v || typeof v !== "object")
@@ -1664,7 +1992,7 @@ function correlate(lines) {
1664
1992
  async function readNetworkRequests(file) {
1665
1993
  let raw;
1666
1994
  try {
1667
- raw = await readFile2(file, "utf8");
1995
+ raw = await readFile3(file, "utf8");
1668
1996
  } catch {
1669
1997
  return [];
1670
1998
  }
@@ -1746,8 +2074,8 @@ async function getNetworkRequest(args = {}) {
1746
2074
  }
1747
2075
 
1748
2076
  // src/tools/performance.ts
1749
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3, stat, unlink } from "node:fs/promises";
1750
- import { join as join2 } from "node:path";
2077
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4, stat, unlink as unlink2 } from "node:fs/promises";
2078
+ import { join as join3 } from "node:path";
1751
2079
  var ARTIFACT_DIR3 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1752
2080
  var STATE_DIR2 = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
1753
2081
  var DEFAULT_CATEGORIES = [
@@ -1765,7 +2093,7 @@ function shortId(id) {
1765
2093
  return id.slice(0, 8);
1766
2094
  }
1767
2095
  function stateFilePath() {
1768
- return join2(STATE_DIR2, "trace-state.json");
2096
+ return join3(STATE_DIR2, "trace-state.json");
1769
2097
  }
1770
2098
  function bufferTraceData(conn, sink) {
1771
2099
  return conn.on("Tracing.dataCollected", (params) => {
@@ -1787,10 +2115,10 @@ async function endTrace(conn, sink, timeoutMs = 30000) {
1787
2115
  return sink;
1788
2116
  }
1789
2117
  async function writeTrace(events, targetId) {
1790
- await mkdir3(ARTIFACT_DIR3, { recursive: true });
1791
- const path = join2(ARTIFACT_DIR3, `trace-${shortId(targetId)}-${stamp2()}.json`);
2118
+ await mkdir4(ARTIFACT_DIR3, { recursive: true });
2119
+ const path = join3(ARTIFACT_DIR3, `trace-${shortId(targetId)}-${stamp2()}.json`);
1792
2120
  const body = JSON.stringify({ traceEvents: events, metadata: { source: "cdp-toolkit" } });
1793
- await writeFile3(path, body, "utf8");
2121
+ await writeFile4(path, body, "utf8");
1794
2122
  const { size } = await stat(path);
1795
2123
  return { path, bytes: size };
1796
2124
  }
@@ -1810,7 +2138,7 @@ async function performanceStartTrace(args = {}) {
1810
2138
  throw err;
1811
2139
  }
1812
2140
  liveTraces.set(target.id, { conn, target, events, startedAt: Date.now() });
1813
- await mkdir3(STATE_DIR2, { recursive: true });
2141
+ await mkdir4(STATE_DIR2, { recursive: true });
1814
2142
  const state = {
1815
2143
  targetId: target.id,
1816
2144
  url: target.url,
@@ -1818,7 +2146,7 @@ async function performanceStartTrace(args = {}) {
1818
2146
  pid: process.pid,
1819
2147
  categories
1820
2148
  };
1821
- await writeFile3(stateFilePath(), JSON.stringify(state), "utf8");
2149
+ await writeFile4(stateFilePath(), JSON.stringify(state), "utf8");
1822
2150
  return {
1823
2151
  target: { id: target.id, url: target.url, title: target.title },
1824
2152
  categories,
@@ -1831,7 +2159,7 @@ async function performanceStopTrace(args = {}) {
1831
2159
  if (!entry) {
1832
2160
  let hint = "";
1833
2161
  try {
1834
- const raw = await readFile3(stateFilePath(), "utf8");
2162
+ const raw = await readFile4(stateFilePath(), "utf8");
1835
2163
  const state = JSON.parse(raw);
1836
2164
  hint = state.pid === process.pid ? ` (state file lists target ${shortId(state.targetId)} but its live connection is gone)` : ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
1837
2165
  } catch {}
@@ -1843,7 +2171,7 @@ async function performanceStopTrace(args = {}) {
1843
2171
  } finally {
1844
2172
  conn.close();
1845
2173
  liveTraces.delete(target.id);
1846
- await unlink(stateFilePath()).catch(() => {});
2174
+ await unlink2(stateFilePath()).catch(() => {});
1847
2175
  }
1848
2176
  const written = await writeTrace(events, target.id);
1849
2177
  const metrics = analyzeEvents(events);
@@ -1961,7 +2289,7 @@ async function performanceAnalyzeInsight(args = {}) {
1961
2289
  }
1962
2290
  let raw;
1963
2291
  try {
1964
- raw = await readFile3(args.tracePath, "utf8");
2292
+ raw = await readFile4(args.tracePath, "utf8");
1965
2293
  } catch (err) {
1966
2294
  throw new CdpError(`cannot read trace file '${args.tracePath}': ${err.message}`);
1967
2295
  }
@@ -1978,8 +2306,8 @@ async function performanceAnalyzeInsight(args = {}) {
1978
2306
  }
1979
2307
 
1980
2308
  // src/tools/heap.ts
1981
- import { mkdir as mkdir4, writeFile as writeFile4 } from "node:fs/promises";
1982
- import { join as join3 } from "node:path";
2309
+ import { mkdir as mkdir5, writeFile as writeFile5 } from "node:fs/promises";
2310
+ import { join as join4 } from "node:path";
1983
2311
  var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1984
2312
  function stamp3(targetId) {
1985
2313
  const short = targetId.slice(0, 8);
@@ -2004,9 +2332,9 @@ async function takeHeapsnapshot(args = {}) {
2004
2332
  if (snapshot.length === 0) {
2005
2333
  throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
2006
2334
  }
2007
- await mkdir4(ARTIFACT_DIR4, { recursive: true });
2008
- const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join3(ARTIFACT_DIR4, args.savePath) : join3(ARTIFACT_DIR4, `take_heapsnapshot-${stamp3(target.id)}.heapsnapshot`);
2009
- await writeFile4(path, snapshot, "utf8");
2335
+ await mkdir5(ARTIFACT_DIR4, { recursive: true });
2336
+ const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join4(ARTIFACT_DIR4, args.savePath) : join4(ARTIFACT_DIR4, `take_heapsnapshot-${stamp3(target.id)}.heapsnapshot`);
2337
+ await writeFile5(path, snapshot, "utf8");
2010
2338
  const bytes = Buffer.byteLength(snapshot, "utf8");
2011
2339
  return {
2012
2340
  path,
@@ -2019,7 +2347,7 @@ async function takeHeapsnapshot(args = {}) {
2019
2347
 
2020
2348
  // src/tools/lighthouse.ts
2021
2349
  import { spawn } from "node:child_process";
2022
- import { mkdir as mkdir5, readFile as readFile4, stat as stat2 } from "node:fs/promises";
2350
+ import { mkdir as mkdir6, readFile as readFile5, stat as stat2 } from "node:fs/promises";
2023
2351
  var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
2024
2352
  function cdpPort() {
2025
2353
  const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
@@ -2080,7 +2408,7 @@ async function lighthouseAudit(args) {
2080
2408
  const url = args.url.trim();
2081
2409
  const timeoutMs = args.timeoutMs ?? 120000;
2082
2410
  const port = cdpPort();
2083
- await mkdir5(ARTIFACT_DIR5, { recursive: true });
2411
+ await mkdir6(ARTIFACT_DIR5, { recursive: true });
2084
2412
  const outPath = args.savePath ?? `${ARTIFACT_DIR5}/lighthouse-${shortHost(url)}-${stamp4()}.json`;
2085
2413
  const formFactor = args.formFactor ?? "desktop";
2086
2414
  const lhArgs = [
@@ -2109,7 +2437,7 @@ async function lighthouseAudit(args) {
2109
2437
  }
2110
2438
  let raw;
2111
2439
  try {
2112
- raw = await readFile4(outPath, "utf8");
2440
+ raw = await readFile5(outPath, "utf8");
2113
2441
  } catch {
2114
2442
  raw = undefined;
2115
2443
  }
@@ -2117,7 +2445,7 @@ async function lighthouseAudit(args) {
2117
2445
  if (res.signal === "SIGKILL") {
2118
2446
  throw new CdpError(`lighthouse_audit timed out after ${timeoutMs}ms (no report written). stderr: ${truncate(res.stderr)}`);
2119
2447
  }
2120
- const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx — ensure network access and that 'lighthouse' is installable." : "";
2448
+ const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx; ensure network access and that 'lighthouse' is installable." : "";
2121
2449
  throw new CdpError(`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`);
2122
2450
  }
2123
2451
  let report;
@@ -2191,7 +2519,10 @@ var TOOLS = {
2191
2519
  lighthouse_audit: lighthouseAudit,
2192
2520
  mock_request: mockRequest,
2193
2521
  list_mocks: listMocks,
2194
- clear_mocks: clearMocks
2522
+ clear_mocks: clearMocks,
2523
+ claim_page: onCdp(LEASE_TOOLS.claim_page),
2524
+ release_page: onCdp(LEASE_TOOLS.release_page),
2525
+ list_leases: onCdp(LEASE_TOOLS.list_leases)
2195
2526
  };
2196
2527
  var TOOL_NAMES = Object.keys(TOOLS);
2197
2528
 
@@ -2950,12 +3281,17 @@ async function safeTitle(conn, contextId) {
2950
3281
  async function pageInfoOf(conn, ctx) {
2951
3282
  return { id: ctx.context, url: ctx.url, title: await safeTitle(conn, ctx.context) };
2952
3283
  }
2953
- async function resolveContext(conn, selector) {
3284
+ async function resolveContext(conn, selector, opts = {}) {
2954
3285
  const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => {
2955
3286
  throw mapBidiError(e);
2956
3287
  });
2957
3288
  if (!contexts.length)
2958
3289
  throw driverError2("no-such-target", "no browsing contexts available");
3290
+ const hit = await pickContext(conn, contexts, selector);
3291
+ await assertLeaseOk("firefox", hit.context, { lease: opts.lease, url: hit.url });
3292
+ return hit;
3293
+ }
3294
+ async function pickContext(conn, contexts, selector) {
2959
3295
  if (selector === undefined || selector === "active")
2960
3296
  return contexts[0];
2961
3297
  if (selector.startsWith("index:")) {
@@ -3476,7 +3812,7 @@ import { spawn as spawn2 } from "node:child_process";
3476
3812
  import { mkdtempSync, rmSync } from "node:fs";
3477
3813
  import { createConnection, createServer } from "node:net";
3478
3814
  import { tmpdir } from "node:os";
3479
- import { join as join4 } from "node:path";
3815
+ import { join as join5 } from "node:path";
3480
3816
  function resolveBinary() {
3481
3817
  const candidates = [
3482
3818
  process.env.FIREFOX_BIN,
@@ -3550,7 +3886,7 @@ async function launchFirefox(opts = {}) {
3550
3886
  const binary = resolveBinary();
3551
3887
  const port = await pickPort(opts.port);
3552
3888
  const isThrowaway = opts.profilePath === undefined;
3553
- const profilePath = opts.profilePath ?? mkdtempSync(join4(tmpdir(), "cdp-toolkit-ff-profile-"));
3889
+ const profilePath = opts.profilePath ?? mkdtempSync(join5(tmpdir(), "cdp-toolkit-ff-profile-"));
3554
3890
  const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
3555
3891
  if (opts.headless)
3556
3892
  args.push("--headless");
@@ -3815,7 +4151,8 @@ var BIDI_ONLY_TOOLS = {
3815
4151
  // src/firefox-tools.ts
3816
4152
  var FIREFOX_TOOLS = {
3817
4153
  ...SHARED_TOOLS,
3818
- ...BIDI_ONLY_TOOLS
4154
+ ...BIDI_ONLY_TOOLS,
4155
+ ...LEASE_TOOLS
3819
4156
  };
3820
4157
 
3821
4158
  // src/cli.ts
@@ -3840,6 +4177,8 @@ zero behavior change for existing users). Firefox is LAUNCHED per invocation (it
3840
4177
  to), used for exactly one tool call, then disposed before the process exits.
3841
4178
 
3842
4179
  Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
4180
+ Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
4181
+ Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
3843
4182
  Run with --list to print every tool name available under the selected backend.
3844
4183
  Run with --capabilities to see, per tool, whether it is available and (if not) which capability
3845
4184
  the selected backend is missing.`;
@@ -3943,28 +4282,38 @@ async function main() {
3943
4282
  `);
3944
4283
  return 1;
3945
4284
  }
3946
- if (browser === "chrome") {
3947
- const fn = TOOLS[parsed.tool];
3948
- const result = await fn(parsed.args);
3949
- process.stdout.write(`${JSON.stringify(result, null, 2)}
3950
- `);
3951
- return 0;
3952
- }
3953
- const neutralFn = FIREFOX_TOOLS[parsed.tool];
3954
- if (!neutralFn) {
3955
- process.stderr.write(`${JSON.stringify({ error: `tool '${parsed.tool}' has no Firefox implementation wired` })}
4285
+ if (parsed.tool === "claim_page") {
4286
+ process.stderr.write(`${JSON.stringify({
4287
+ error: "claim_page is not available from the CLI: a CLI invocation is one process per call, so the lease it claimed would be reclaimable immediately by the dead-pid rule. Claim from the cdp-toolkit MCP server, which is long lived, then pass the token to CLI calls with --lease <token>."
4288
+ })}
3956
4289
  `);
3957
4290
  return 1;
3958
4291
  }
3959
- const session = await startFirefoxSession();
3960
- try {
3961
- const result = await neutralFn(session.driver, parsed.args);
3962
- process.stdout.write(`${JSON.stringify(result, null, 2)}
4292
+ const tool = parsed.tool;
4293
+ return withLeaseScope(leaseFromArgs(parsed.args), async () => {
4294
+ if (browser === "chrome") {
4295
+ const fn = TOOLS[tool];
4296
+ const result = await fn(parsed.args);
4297
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
3963
4298
  `);
3964
- return 0;
3965
- } finally {
3966
- await session.dispose();
3967
- }
4299
+ return 0;
4300
+ }
4301
+ const neutralFn = FIREFOX_TOOLS[tool];
4302
+ if (!neutralFn) {
4303
+ process.stderr.write(`${JSON.stringify({ error: `tool '${tool}' has no Firefox implementation wired` })}
4304
+ `);
4305
+ return 1;
4306
+ }
4307
+ const session = await startFirefoxSession();
4308
+ try {
4309
+ const result = await neutralFn(session.driver, parsed.args);
4310
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4311
+ `);
4312
+ return 0;
4313
+ } finally {
4314
+ await session.dispose();
4315
+ }
4316
+ });
3968
4317
  }
3969
4318
  main().then((code) => process.exit(code)).catch((err) => {
3970
4319
  const message = err instanceof Error ? err.message : String(err);