cdp-toolkit 1.8.0 → 1.9.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 +119 -0
- package/CONTRACT.md +4 -0
- package/README.md +23 -4
- package/dist/cli.js +338 -111
- package/dist/index.js +319 -110
- package/dist/mcp.js +380 -151
- package/package.json +3 -2
- package/src/bidi/driver.ts +28 -0
- package/src/cdp/driver.ts +16 -1
- package/src/cdp/workers.ts +177 -0
- package/src/cli.ts +3 -1
- package/src/client.ts +52 -2
- package/src/driver.ts +27 -1
- package/src/leases-tools.ts +2 -2
- package/src/manifest.ts +44 -40
- package/src/mcp.ts +1 -1
- package/src/origins.ts +41 -1
- package/src/shared-tools.ts +104 -3
- package/src/types.ts +20 -0
- package/src/workers.ts +159 -0
package/dist/cli.js
CHANGED
|
@@ -278,6 +278,169 @@ async function listLeases(opts = {}) {
|
|
|
278
278
|
return out;
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// src/origins.ts
|
|
282
|
+
import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
|
|
283
|
+
import { join as join2 } from "node:path";
|
|
284
|
+
function safeId2(id) {
|
|
285
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
286
|
+
}
|
|
287
|
+
function originFile(backend, targetId) {
|
|
288
|
+
return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
|
|
289
|
+
}
|
|
290
|
+
var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
|
|
291
|
+
async function recordOrigin(backend, targetId, opts = {}) {
|
|
292
|
+
const record = {
|
|
293
|
+
backend,
|
|
294
|
+
targetId,
|
|
295
|
+
label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
|
|
296
|
+
pid: process.pid,
|
|
297
|
+
createdAt: opts.now ?? Date.now()
|
|
298
|
+
};
|
|
299
|
+
const file = originFile(backend, targetId);
|
|
300
|
+
try {
|
|
301
|
+
await mkdir2(leaseDir(), { recursive: true });
|
|
302
|
+
try {
|
|
303
|
+
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
304
|
+
return;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (err.code !== "EEXIST")
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
await unlink2(file).catch(() => {
|
|
310
|
+
return;
|
|
311
|
+
});
|
|
312
|
+
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
313
|
+
} catch {}
|
|
314
|
+
}
|
|
315
|
+
async function readOrigin(backend, targetId) {
|
|
316
|
+
let raw;
|
|
317
|
+
try {
|
|
318
|
+
raw = await readFile2(originFile(backend, targetId), "utf8");
|
|
319
|
+
} catch {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const rec = JSON.parse(raw);
|
|
324
|
+
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
325
|
+
return rec;
|
|
326
|
+
} catch {}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
async function listOrigins(opts = {}) {
|
|
330
|
+
let names = [];
|
|
331
|
+
try {
|
|
332
|
+
names = await readdir2(leaseDir());
|
|
333
|
+
} catch {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
|
|
337
|
+
const out = [];
|
|
338
|
+
for (const name of names) {
|
|
339
|
+
const parts = ORIGIN_FILE_RE.exec(name);
|
|
340
|
+
if (!parts)
|
|
341
|
+
continue;
|
|
342
|
+
const backend = parts[1];
|
|
343
|
+
const nameId = parts[2];
|
|
344
|
+
const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
|
|
345
|
+
if (scoped && !live.has(nameId)) {
|
|
346
|
+
await unlink2(join2(leaseDir(), name)).catch(() => {
|
|
347
|
+
return;
|
|
348
|
+
});
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
|
|
352
|
+
let rec;
|
|
353
|
+
try {
|
|
354
|
+
rec = JSON.parse(await readFile2(join2(leaseDir(), name), "utf8"));
|
|
355
|
+
} catch (err) {
|
|
356
|
+
const code = err.code;
|
|
357
|
+
if (code === "ENOENT")
|
|
358
|
+
continue;
|
|
359
|
+
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
|
|
363
|
+
out.push({ ...fromName, unreadable: "unparseable" });
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
async function originIndex(backend, liveIds) {
|
|
371
|
+
const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
|
|
372
|
+
const index = new Map;
|
|
373
|
+
for (const row of rows) {
|
|
374
|
+
if (row.backend !== backend)
|
|
375
|
+
continue;
|
|
376
|
+
index.set(row.targetId, row);
|
|
377
|
+
}
|
|
378
|
+
return index;
|
|
379
|
+
}
|
|
380
|
+
async function newTrackedPage(driver, backend, opts = {}) {
|
|
381
|
+
const page = await driver.newPage(opts.url);
|
|
382
|
+
await recordOrigin(backend, page.id, { label: opts.label });
|
|
383
|
+
return page;
|
|
384
|
+
}
|
|
385
|
+
async function resolveLiveLabel(backend, label, liveIds) {
|
|
386
|
+
const live = new Set(liveIds);
|
|
387
|
+
const [origins, leases] = await Promise.all([originIndex(backend), listLeases({ liveBackend: backend })]);
|
|
388
|
+
const knownLabels = new Set;
|
|
389
|
+
const matchIds = new Set;
|
|
390
|
+
for (const rec of origins.values()) {
|
|
391
|
+
if (rec.unreadable !== undefined || !live.has(rec.targetId))
|
|
392
|
+
continue;
|
|
393
|
+
knownLabels.add(rec.label);
|
|
394
|
+
if (rec.label === label)
|
|
395
|
+
matchIds.add(rec.targetId);
|
|
396
|
+
}
|
|
397
|
+
for (const rec of leases) {
|
|
398
|
+
if (rec.backend !== backend || rec.unreadable !== undefined || !live.has(rec.targetId))
|
|
399
|
+
continue;
|
|
400
|
+
knownLabels.add(rec.label);
|
|
401
|
+
if (rec.label === label)
|
|
402
|
+
matchIds.add(rec.targetId);
|
|
403
|
+
}
|
|
404
|
+
return { matchIds: [...matchIds], knownLabels: [...knownLabels].sort() };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/workers.ts
|
|
408
|
+
var WORKER_SELECTOR_PREFIX = "worker:";
|
|
409
|
+
var WORKER_TARGET_TYPES = new Set(["service_worker", "shared_worker"]);
|
|
410
|
+
function isWorkerSelector(selector) {
|
|
411
|
+
return typeof selector === "string" && selector.startsWith(WORKER_SELECTOR_PREFIX);
|
|
412
|
+
}
|
|
413
|
+
function workerNeedle(selector) {
|
|
414
|
+
return selector.slice(WORKER_SELECTOR_PREFIX.length);
|
|
415
|
+
}
|
|
416
|
+
function isWorkerTargetType(type) {
|
|
417
|
+
return type !== undefined && WORKER_TARGET_TYPES.has(type);
|
|
418
|
+
}
|
|
419
|
+
function resolveWorkerTargets(targets, needle) {
|
|
420
|
+
const liveWorkers = targets.filter((t) => isWorkerTargetType(t.type));
|
|
421
|
+
return {
|
|
422
|
+
matches: needle === "" ? [] : liveWorkers.filter((t) => t.url.includes(needle)),
|
|
423
|
+
liveWorkers
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
var WORKER_EMPTY_NEEDLE_MESSAGE = "'worker:' needs a substring of the worker's url, e.g. worker:<extension-id> or worker:background.js";
|
|
427
|
+
var WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in an extension's background worker.";
|
|
428
|
+
var WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). evaluate_script is the one tool that " + "accepts a worker: target.";
|
|
429
|
+
function urlList(workers) {
|
|
430
|
+
return workers.map((w) => w.url).join(", ");
|
|
431
|
+
}
|
|
432
|
+
function workerAmbiguityMessage(needle, matches) {
|
|
433
|
+
return `'worker:${needle}' matches more than one running worker: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the worker's target id directly.";
|
|
434
|
+
}
|
|
435
|
+
function workerMissMessage(needle, opts) {
|
|
436
|
+
const running = opts.liveWorkers?.length ? ` (running workers: ${urlList(opts.liveWorkers)})` : " (no workers are running)";
|
|
437
|
+
if (!opts.wakeAttempted) {
|
|
438
|
+
return `no running worker whose url contains '${needle}'${running}. ` + "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " + "listing, so this is usually a sleeping worker rather than a wrong substring. You passed wake:false; " + "wake:true (the default) asks Chrome to start it first.";
|
|
439
|
+
}
|
|
440
|
+
const scopes = opts.registeredScopes?.length ? ` Registered service-worker scopes in this browser: ${opts.registeredScopes.join(", ")}.` : " No service-worker registrations were reported by this browser at all.";
|
|
441
|
+
return `no worker whose url contains '${needle}' could be reached${running}. ` + `A start was requested (wake:true) and no matching worker appeared.${scopes} ` + "If the extension is installed but its worker never starts, check the worker script for a top-level throw.";
|
|
442
|
+
}
|
|
443
|
+
|
|
281
444
|
// src/client.ts
|
|
282
445
|
var BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
283
446
|
var DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
|
|
@@ -433,11 +596,13 @@ async function browserWsUrl() {
|
|
|
433
596
|
}
|
|
434
597
|
async function resolveTarget(selector, opts = {}) {
|
|
435
598
|
const targets = await listTargets();
|
|
436
|
-
const hit = pickTarget(targets, selector);
|
|
599
|
+
const hit = await pickTarget(targets, selector);
|
|
600
|
+
if (isWorkerTargetType(hit.type))
|
|
601
|
+
return hit;
|
|
437
602
|
await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
|
|
438
603
|
return hit;
|
|
439
604
|
}
|
|
440
|
-
function pickTarget(targets, selector) {
|
|
605
|
+
async function pickTarget(targets, selector) {
|
|
441
606
|
const pages = targets.filter((t) => t.type === "page");
|
|
442
607
|
if (!selector || selector === "active") {
|
|
443
608
|
if (!pages.length)
|
|
@@ -464,6 +629,30 @@ function pickTarget(targets, selector) {
|
|
|
464
629
|
throw new CdpError(`no page title containing '${needle}'`);
|
|
465
630
|
return hit;
|
|
466
631
|
}
|
|
632
|
+
if (selector.startsWith("label:")) {
|
|
633
|
+
const label = selector.slice(6);
|
|
634
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("chrome", label, pages.map((t) => t.id));
|
|
635
|
+
if (matchIds.length > 1) {
|
|
636
|
+
throw new CdpError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
637
|
+
}
|
|
638
|
+
if (matchIds.length === 1) {
|
|
639
|
+
const hit = pages.find((t) => t.id === matchIds[0]);
|
|
640
|
+
if (hit)
|
|
641
|
+
return hit;
|
|
642
|
+
}
|
|
643
|
+
throw new CdpError(knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
|
|
644
|
+
}
|
|
645
|
+
if (isWorkerSelector(selector)) {
|
|
646
|
+
const needle = workerNeedle(selector);
|
|
647
|
+
if (needle === "")
|
|
648
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
649
|
+
const { matches, liveWorkers } = resolveWorkerTargets(targets, needle);
|
|
650
|
+
if (matches.length > 1)
|
|
651
|
+
throw new CdpError(workerAmbiguityMessage(needle, matches));
|
|
652
|
+
if (matches.length === 1)
|
|
653
|
+
return matches[0];
|
|
654
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
|
|
655
|
+
}
|
|
467
656
|
const byId = targets.find((t) => t.id === selector);
|
|
468
657
|
if (!byId)
|
|
469
658
|
throw new CdpError(`no target with id '${selector}'`);
|
|
@@ -653,6 +842,91 @@ async function resolveUid(conn, uid) {
|
|
|
653
842
|
return { objectId: object.objectId };
|
|
654
843
|
}
|
|
655
844
|
|
|
845
|
+
// src/cdp/workers.ts
|
|
846
|
+
var INVENTORY_FIRST_EVENT_MS = 1500;
|
|
847
|
+
var INVENTORY_SETTLE_MS = 250;
|
|
848
|
+
var WAKE_POLL_MS = 300;
|
|
849
|
+
var WAKE_POLL_ATTEMPTS = 30;
|
|
850
|
+
var NO_HOST_PAGE_MESSAGE = "cannot start a service worker: the ServiceWorker CDP domain is only reachable over a page session, " + "and this browser has no page target open. Open any tab (new_page) and retry — the tab does not have " + "to belong to the extension.";
|
|
851
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
852
|
+
async function requestWorkerStart(needle) {
|
|
853
|
+
const conn = await openBrowser();
|
|
854
|
+
try {
|
|
855
|
+
const host = (await listTargets()).find((t) => t.type === "page");
|
|
856
|
+
if (!host)
|
|
857
|
+
throw new CdpError(NO_HOST_PAGE_MESSAGE);
|
|
858
|
+
const { sessionId } = await conn.send("Target.attachToTarget", {
|
|
859
|
+
targetId: host.id,
|
|
860
|
+
flatten: true
|
|
861
|
+
});
|
|
862
|
+
if (!sessionId)
|
|
863
|
+
throw new CdpError("could not attach a session to host the ServiceWorker domain");
|
|
864
|
+
const scopeById = new Map;
|
|
865
|
+
const versions = [];
|
|
866
|
+
conn.on("ServiceWorker.workerRegistrationUpdated", (params) => {
|
|
867
|
+
for (const r of params.registrations ?? []) {
|
|
868
|
+
if (r.isDeleted === true)
|
|
869
|
+
continue;
|
|
870
|
+
scopeById.set(String(r.registrationId), String(r.scopeURL ?? ""));
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
conn.on("ServiceWorker.workerVersionUpdated", (params) => {
|
|
874
|
+
for (const v of params.versions ?? []) {
|
|
875
|
+
versions.push({ registrationId: String(v.registrationId), scriptURL: String(v.scriptURL ?? "") });
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
await conn.send("ServiceWorker.enable", {}, { sessionId });
|
|
879
|
+
await conn.waitFor("ServiceWorker.workerVersionUpdated", undefined, INVENTORY_FIRST_EVENT_MS).catch(() => {
|
|
880
|
+
return;
|
|
881
|
+
});
|
|
882
|
+
await sleep(INVENTORY_SETTLE_MS);
|
|
883
|
+
const scopes = [...new Set(scopeById.values())].filter((s) => s.length > 0).sort();
|
|
884
|
+
const matched = new Set;
|
|
885
|
+
for (const v of versions) {
|
|
886
|
+
const scope = scopeById.get(v.registrationId);
|
|
887
|
+
if (scope === undefined)
|
|
888
|
+
continue;
|
|
889
|
+
if (v.scriptURL.includes(needle) || scope.includes(needle))
|
|
890
|
+
matched.add(scope);
|
|
891
|
+
}
|
|
892
|
+
if (matched.size === 0)
|
|
893
|
+
return { startRequested: false, scopes };
|
|
894
|
+
if (matched.size > 1) {
|
|
895
|
+
throw new CdpError(`'worker:${needle}' matches more than one registered service-worker scope: ${[...matched].sort().join(", ")}. ` + "Narrow the substring.");
|
|
896
|
+
}
|
|
897
|
+
await conn.send("ServiceWorker.startWorker", { scopeURL: [...matched][0] }, { sessionId });
|
|
898
|
+
return { startRequested: true, scopes };
|
|
899
|
+
} finally {
|
|
900
|
+
conn.close();
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
async function resolveWorkerSelector(selector, opts) {
|
|
904
|
+
const needle = workerNeedle(selector);
|
|
905
|
+
if (needle === "")
|
|
906
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
907
|
+
const first = resolveWorkerTargets(await listTargets(), needle);
|
|
908
|
+
if (first.matches.length > 1)
|
|
909
|
+
throw new CdpError(workerAmbiguityMessage(needle, first.matches));
|
|
910
|
+
if (first.matches.length === 1)
|
|
911
|
+
return first.matches[0];
|
|
912
|
+
if (!opts.wake) {
|
|
913
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers: first.liveWorkers }));
|
|
914
|
+
}
|
|
915
|
+
const { startRequested, scopes } = await requestWorkerStart(needle);
|
|
916
|
+
if (startRequested) {
|
|
917
|
+
for (let i = 0;i < WAKE_POLL_ATTEMPTS; i++) {
|
|
918
|
+
await sleep(WAKE_POLL_MS);
|
|
919
|
+
const again = resolveWorkerTargets(await listTargets(), needle);
|
|
920
|
+
if (again.matches.length > 1)
|
|
921
|
+
throw new CdpError(workerAmbiguityMessage(needle, again.matches));
|
|
922
|
+
if (again.matches.length === 1)
|
|
923
|
+
return again.matches[0];
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const final = resolveWorkerTargets(await listTargets(), needle);
|
|
927
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: true, liveWorkers: final.liveWorkers, registeredScopes: scopes }));
|
|
928
|
+
}
|
|
929
|
+
|
|
656
930
|
// src/tools/network_mock.ts
|
|
657
931
|
function escapeRegExpChar(ch) {
|
|
658
932
|
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
|
|
@@ -1134,7 +1408,8 @@ var CDP_CAPABILITIES = new Set([
|
|
|
1134
1408
|
"input.raw",
|
|
1135
1409
|
"input.html5Drag",
|
|
1136
1410
|
"browser.downloads",
|
|
1137
|
-
"browser.permissions"
|
|
1411
|
+
"browser.permissions",
|
|
1412
|
+
"worker.targets"
|
|
1138
1413
|
]);
|
|
1139
1414
|
|
|
1140
1415
|
class CdpPageDriver {
|
|
@@ -1702,6 +1977,10 @@ class CdpBrowserDriver {
|
|
|
1702
1977
|
await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
|
|
1703
1978
|
return { id: target.id, url: target.url, title: target.title };
|
|
1704
1979
|
}
|
|
1980
|
+
async resolveWorkerTarget(selector, opts) {
|
|
1981
|
+
const target = await resolveWorkerSelector(selector, opts).catch(noSuchTarget);
|
|
1982
|
+
return { id: target.id, url: target.url, title: target.title, type: target.type };
|
|
1983
|
+
}
|
|
1705
1984
|
async page(selector) {
|
|
1706
1985
|
const { conn, target } = await openPage(selector).catch(noSuchTarget);
|
|
1707
1986
|
return new CdpPageDriver(conn, target, this);
|
|
@@ -1788,111 +2067,6 @@ function createCdpDriver() {
|
|
|
1788
2067
|
return new CdpBrowserDriver;
|
|
1789
2068
|
}
|
|
1790
2069
|
|
|
1791
|
-
// src/origins.ts
|
|
1792
|
-
import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1793
|
-
import { join as join2 } from "node:path";
|
|
1794
|
-
function safeId2(id) {
|
|
1795
|
-
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1796
|
-
}
|
|
1797
|
-
function originFile(backend, targetId) {
|
|
1798
|
-
return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
|
|
1799
|
-
}
|
|
1800
|
-
var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
|
|
1801
|
-
async function recordOrigin(backend, targetId, opts = {}) {
|
|
1802
|
-
const record = {
|
|
1803
|
-
backend,
|
|
1804
|
-
targetId,
|
|
1805
|
-
label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
|
|
1806
|
-
pid: process.pid,
|
|
1807
|
-
createdAt: opts.now ?? Date.now()
|
|
1808
|
-
};
|
|
1809
|
-
const file = originFile(backend, targetId);
|
|
1810
|
-
try {
|
|
1811
|
-
await mkdir2(leaseDir(), { recursive: true });
|
|
1812
|
-
try {
|
|
1813
|
-
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1814
|
-
return;
|
|
1815
|
-
} catch (err) {
|
|
1816
|
-
if (err.code !== "EEXIST")
|
|
1817
|
-
throw err;
|
|
1818
|
-
}
|
|
1819
|
-
await unlink2(file).catch(() => {
|
|
1820
|
-
return;
|
|
1821
|
-
});
|
|
1822
|
-
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1823
|
-
} catch {}
|
|
1824
|
-
}
|
|
1825
|
-
async function readOrigin(backend, targetId) {
|
|
1826
|
-
let raw;
|
|
1827
|
-
try {
|
|
1828
|
-
raw = await readFile2(originFile(backend, targetId), "utf8");
|
|
1829
|
-
} catch {
|
|
1830
|
-
return;
|
|
1831
|
-
}
|
|
1832
|
-
try {
|
|
1833
|
-
const rec = JSON.parse(raw);
|
|
1834
|
-
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
1835
|
-
return rec;
|
|
1836
|
-
} catch {}
|
|
1837
|
-
return;
|
|
1838
|
-
}
|
|
1839
|
-
async function listOrigins(opts = {}) {
|
|
1840
|
-
let names = [];
|
|
1841
|
-
try {
|
|
1842
|
-
names = await readdir2(leaseDir());
|
|
1843
|
-
} catch {
|
|
1844
|
-
return [];
|
|
1845
|
-
}
|
|
1846
|
-
const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
|
|
1847
|
-
const out = [];
|
|
1848
|
-
for (const name of names) {
|
|
1849
|
-
const parts = ORIGIN_FILE_RE.exec(name);
|
|
1850
|
-
if (!parts)
|
|
1851
|
-
continue;
|
|
1852
|
-
const backend = parts[1];
|
|
1853
|
-
const nameId = parts[2];
|
|
1854
|
-
const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
|
|
1855
|
-
if (scoped && !live.has(nameId)) {
|
|
1856
|
-
await unlink2(join2(leaseDir(), name)).catch(() => {
|
|
1857
|
-
return;
|
|
1858
|
-
});
|
|
1859
|
-
continue;
|
|
1860
|
-
}
|
|
1861
|
-
const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
|
|
1862
|
-
let rec;
|
|
1863
|
-
try {
|
|
1864
|
-
rec = JSON.parse(await readFile2(join2(leaseDir(), name), "utf8"));
|
|
1865
|
-
} catch (err) {
|
|
1866
|
-
const code = err.code;
|
|
1867
|
-
if (code === "ENOENT")
|
|
1868
|
-
continue;
|
|
1869
|
-
out.push({ ...fromName, unreadable: code ?? "unparseable" });
|
|
1870
|
-
continue;
|
|
1871
|
-
}
|
|
1872
|
-
if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
|
|
1873
|
-
out.push({ ...fromName, unreadable: "unparseable" });
|
|
1874
|
-
continue;
|
|
1875
|
-
}
|
|
1876
|
-
out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
|
|
1877
|
-
}
|
|
1878
|
-
return out;
|
|
1879
|
-
}
|
|
1880
|
-
async function originIndex(backend, liveIds) {
|
|
1881
|
-
const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
|
|
1882
|
-
const index = new Map;
|
|
1883
|
-
for (const row of rows) {
|
|
1884
|
-
if (row.backend !== backend)
|
|
1885
|
-
continue;
|
|
1886
|
-
index.set(row.targetId, row);
|
|
1887
|
-
}
|
|
1888
|
-
return index;
|
|
1889
|
-
}
|
|
1890
|
-
async function newTrackedPage(driver, backend, opts = {}) {
|
|
1891
|
-
const page = await driver.newPage(opts.url);
|
|
1892
|
-
await recordOrigin(backend, page.id, { label: opts.label });
|
|
1893
|
-
return page;
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
2070
|
// src/reap.ts
|
|
1897
2071
|
function staleAgentTabs(input) {
|
|
1898
2072
|
const live = new Set(input.livePageIds);
|
|
@@ -1993,6 +2167,11 @@ async function pickPage(driver, pages, selector) {
|
|
|
1993
2167
|
throw new SharedToolError(`no page title containing '${needle}'`);
|
|
1994
2168
|
return p;
|
|
1995
2169
|
}
|
|
2170
|
+
if (selector.startsWith("label:"))
|
|
2171
|
+
return pickByLabel(driver, pages, selector.slice(6));
|
|
2172
|
+
if (isWorkerSelector(selector)) {
|
|
2173
|
+
throw new SharedToolError(backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE);
|
|
2174
|
+
}
|
|
1996
2175
|
const exactPage = pages.find((x) => x.id === selector);
|
|
1997
2176
|
if (exactPage)
|
|
1998
2177
|
return exactPage;
|
|
@@ -2002,6 +2181,18 @@ async function pickPage(driver, pages, selector) {
|
|
|
2002
2181
|
return exact;
|
|
2003
2182
|
throw new SharedToolError(`no target with id '${selector}'`);
|
|
2004
2183
|
}
|
|
2184
|
+
async function pickByLabel(driver, pages, label) {
|
|
2185
|
+
const { matchIds, knownLabels } = await resolveLiveLabel(backendOf(driver), label, pages.map((x) => x.id));
|
|
2186
|
+
if (matchIds.length > 1) {
|
|
2187
|
+
throw new SharedToolError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
2188
|
+
}
|
|
2189
|
+
if (matchIds.length === 1) {
|
|
2190
|
+
const p = pages.find((x) => x.id === matchIds[0]);
|
|
2191
|
+
if (p)
|
|
2192
|
+
return p;
|
|
2193
|
+
}
|
|
2194
|
+
throw new SharedToolError(knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
|
|
2195
|
+
}
|
|
2005
2196
|
function locatorOf(args) {
|
|
2006
2197
|
const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
|
|
2007
2198
|
const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
|
|
@@ -2143,11 +2334,29 @@ async function writeJsonSink(savePath, value) {
|
|
|
2143
2334
|
function sinkRequested(savePath) {
|
|
2144
2335
|
return savePath !== undefined && savePath !== "";
|
|
2145
2336
|
}
|
|
2337
|
+
var EXPRESSION_ALIAS_KEYS = ["function", "code", "js", "script", "fn", "body"];
|
|
2338
|
+
async function resolveWorkerSelectorFor(driver, selector, wake) {
|
|
2339
|
+
if (!driver.capabilities.has("worker.targets") || typeof driver.resolveWorkerTarget !== "function") {
|
|
2340
|
+
throw new SharedToolError(`evaluateScript: ${WORKER_SELECTOR_UNSUPPORTED_MESSAGE}`);
|
|
2341
|
+
}
|
|
2342
|
+
return driver.resolveWorkerTarget(selector, { wake });
|
|
2343
|
+
}
|
|
2146
2344
|
async function evaluateScript(driver, args) {
|
|
2147
2345
|
if (typeof args.expression !== "string" || args.expression.length === 0) {
|
|
2346
|
+
const wrongKey = EXPRESSION_ALIAS_KEYS.find((k) => args[k] !== undefined);
|
|
2347
|
+
if (wrongKey !== undefined) {
|
|
2348
|
+
throw new SharedToolError(`evaluateScript: you passed '${wrongKey}'; the key is 'expression' — function literals go in 'expression', invoked with 'args'.`);
|
|
2349
|
+
}
|
|
2148
2350
|
throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
|
|
2149
2351
|
}
|
|
2150
|
-
|
|
2352
|
+
if (args.wake !== undefined && typeof args.wake !== "boolean") {
|
|
2353
|
+
throw new SharedToolError("evaluateScript: 'wake' must be a boolean");
|
|
2354
|
+
}
|
|
2355
|
+
if (args.wake !== undefined && !isWorkerSelector(args.target)) {
|
|
2356
|
+
throw new SharedToolError("evaluateScript: 'wake' only applies to a target of the form 'worker:<substring>'; a page target is never asleep.");
|
|
2357
|
+
}
|
|
2358
|
+
const target = isWorkerSelector(args.target) ? (await resolveWorkerSelectorFor(driver, args.target, args.wake ?? true)).id : args.target;
|
|
2359
|
+
return withPage2(driver, target, async (page) => {
|
|
2151
2360
|
const value = await page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true });
|
|
2152
2361
|
if (!sinkRequested(args.savePath))
|
|
2153
2362
|
return value;
|
|
@@ -2511,7 +2720,7 @@ async function claimPage(driver, args = {}) {
|
|
|
2511
2720
|
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2512
2721
|
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
2513
2722
|
if (hasTarget && hasTargetId) {
|
|
2514
|
-
throw new LeaseToolError("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.");
|
|
2723
|
+
throw new LeaseToolError("claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | label:<name> | <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.");
|
|
2515
2724
|
}
|
|
2516
2725
|
const backend = backendOf2(driver);
|
|
2517
2726
|
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
@@ -4906,6 +5115,22 @@ async function pickContext(conn, contexts, selector) {
|
|
|
4906
5115
|
return c;
|
|
4907
5116
|
throw driverError2("no-such-target", `no context with title containing '${needle}'`);
|
|
4908
5117
|
}
|
|
5118
|
+
if (selector.startsWith("label:")) {
|
|
5119
|
+
const label = selector.slice(6);
|
|
5120
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("firefox", label, contexts.map((c) => c.context));
|
|
5121
|
+
if (matchIds.length > 1) {
|
|
5122
|
+
throw driverError2("no-such-target", `label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
5123
|
+
}
|
|
5124
|
+
if (matchIds.length === 1) {
|
|
5125
|
+
const hit = contexts.find((c) => c.context === matchIds[0]);
|
|
5126
|
+
if (hit)
|
|
5127
|
+
return hit;
|
|
5128
|
+
}
|
|
5129
|
+
throw driverError2("no-such-target", knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
|
|
5130
|
+
}
|
|
5131
|
+
if (isWorkerSelector(selector)) {
|
|
5132
|
+
throw driverError2("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
5133
|
+
}
|
|
4909
5134
|
const exact = contexts.find((c) => c.context === selector);
|
|
4910
5135
|
if (exact)
|
|
4911
5136
|
return exact;
|
|
@@ -5935,7 +6160,9 @@ Backend selection: --browser chrome|firefox, else the CDP_BROWSER env var, else
|
|
|
5935
6160
|
zero behavior change for existing users). Firefox is LAUNCHED per invocation (it cannot be attached
|
|
5936
6161
|
to), used for exactly one tool call, then disposed before the process exits.
|
|
5937
6162
|
|
|
5938
|
-
Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
|
|
6163
|
+
Target selector grammar: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>
|
|
6164
|
+
plus worker:<substr> for evaluate_script ONLY (Chrome only): an MV3 extension's background service
|
|
6165
|
+
worker, e.g. worker:<extension-id>. An idle-evicted worker is started first unless wake:false.
|
|
5939
6166
|
Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
|
|
5940
6167
|
Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
|
|
5941
6168
|
Run with --list to print every tool name available under the selected backend.
|