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/CHANGELOG.md +76 -9
- package/CONTRACT.md +7 -5
- package/README.md +67 -30
- package/dist/cli.js +410 -61
- package/dist/index.js +370 -39
- package/dist/mcp.js +609 -72
- package/package.json +2 -2
- package/src/bidi/driver.ts +31 -3
- package/src/capabilities.ts +1 -1
- package/src/cli.ts +47 -22
- package/src/client.ts +25 -7
- package/src/driver.ts +1 -1
- package/src/firefox-tools.ts +6 -3
- package/src/index.ts +6 -1
- package/src/leases-tools.ts +101 -0
- package/src/leases.ts +533 -0
- package/src/manifest.ts +211 -13
- package/src/mcp.ts +27 -19
- package/src/shared-tools.ts +52 -6
- package/src/tools/console.ts +3 -3
- package/src/tools/evaluate.ts +2 -2
- package/src/tools/heap.ts +3 -3
- package/src/tools/lighthouse.ts +4 -4
- package/src/tools/navigation.ts +1 -1
- package/src/tools/network.ts +7 -7
- package/src/tools/network_mock.ts +8 -8
- package/src/tools/performance.ts +11 -11
- package/src/tools/recorder.ts +5 -5
- package/src/tools/snapshot.ts +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
|
1153
|
-
await
|
|
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
|
|
1636
|
+
await mkdir2(ARTIFACT_DIR, { recursive: true });
|
|
1309
1637
|
const ext = outFormat === "jpeg" ? "jpg" : "png";
|
|
1310
|
-
const path = args.savePath ??
|
|
1311
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1750
|
-
import { join as
|
|
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
|
|
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
|
|
1791
|
-
const path =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1982
|
-
import { join as
|
|
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
|
|
2008
|
-
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath :
|
|
2009
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 (
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
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
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
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
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
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);
|