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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 45 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -80,7 +80,8 @@
|
|
|
80
80
|
"firefox:smoke": "bun run ./test/firefox-smoke.ts",
|
|
81
81
|
"lease:smoke": "bun run ./test/lease-smoke.ts",
|
|
82
82
|
"staleness:smoke": "bun run ./test/staleness-smoke.ts",
|
|
83
|
-
"input:smoke": "bun run ./test/input-smoke.ts"
|
|
83
|
+
"input:smoke": "bun run ./test/input-smoke.ts",
|
|
84
|
+
"extension:smoke": "bun run ./test/extension-smoke.ts"
|
|
84
85
|
},
|
|
85
86
|
"devDependencies": {
|
|
86
87
|
"@types/node": "22.10.10",
|
package/src/bidi/driver.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
type ScreenshotOptions, type ScrollOptions, type SnapshotNode, type UidStability,
|
|
34
34
|
} from "../driver.ts";
|
|
35
35
|
import { assertLeaseOk } from "../leases.ts";
|
|
36
|
+
import { resolveLiveLabel } from "../origins.ts";
|
|
37
|
+
import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
|
|
36
38
|
import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
|
|
37
39
|
import { BidiConnection, BidiError, connectBidiSession } from "./client.ts";
|
|
38
40
|
import { takeStampedSnapshot } from "./snapshot.ts";
|
|
@@ -384,6 +386,32 @@ async function pickContext(
|
|
|
384
386
|
for (const c of contexts) if ((await safeTitle(conn, c.context)).includes(needle)) return c;
|
|
385
387
|
throw driverError("no-such-target", `no context with title containing '${needle}'`);
|
|
386
388
|
}
|
|
389
|
+
if (selector.startsWith("label:")) {
|
|
390
|
+
const label = selector.slice(6);
|
|
391
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("firefox", label, contexts.map((c) => c.context));
|
|
392
|
+
if (matchIds.length > 1) {
|
|
393
|
+
throw driverError("no-such-target", `label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
394
|
+
}
|
|
395
|
+
if (matchIds.length === 1) {
|
|
396
|
+
const hit = contexts.find((c) => c.context === matchIds[0]);
|
|
397
|
+
if (hit) return hit;
|
|
398
|
+
}
|
|
399
|
+
throw driverError(
|
|
400
|
+
"no-such-target",
|
|
401
|
+
knownLabels.length
|
|
402
|
+
? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})`
|
|
403
|
+
: `no live target with label '${label}' (no labels are currently assigned to any open target)`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (isWorkerSelector(selector)) {
|
|
407
|
+
// Capability-gated, not unimplemented: "unsupported" is the DriverErrorCode
|
|
408
|
+
// that tells a caller to check the capability rather than retry. The tool
|
|
409
|
+
// itself stays available on Firefox — only this arm of the grammar is
|
|
410
|
+
// refused, exactly as drag's mode:'html5' is. Refusing loudly beats the
|
|
411
|
+
// alternative the bare-id fall-through would produce, which is a confusing
|
|
412
|
+
// "no context matching 'worker:abc'" that reads like a typo.
|
|
413
|
+
throw driverError("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
414
|
+
}
|
|
387
415
|
const exact = contexts.find((c) => c.context === selector);
|
|
388
416
|
if (exact) return exact;
|
|
389
417
|
throw driverError("no-such-target", `no context matching '${selector}'`);
|
package/src/cdp/driver.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { Target, TargetSelector } from "../types.ts";
|
|
|
10
10
|
import { LeaseConflictError } from "../leases.ts";
|
|
11
11
|
import { BEACON_READ_EXPRESSION, BEACON_SOURCE, BeaconSessions, RENDERER_PROBE_EXPRESSION, RENDERER_PROBE_TIMEOUT_MS, sendInput } from "../activity.ts";
|
|
12
12
|
import { resolveUid } from "../tools/snapshot.ts";
|
|
13
|
+
import { resolveWorkerSelector } from "./workers.ts";
|
|
13
14
|
import { buildFulfillParams, effectiveAction, selectRule } from "../tools/network_mock.ts";
|
|
14
15
|
import {
|
|
15
16
|
LEGACY_NUMERIC_UID, interpolatePoints, type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
|
|
@@ -348,7 +349,7 @@ const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
|
|
348
349
|
"screenshot.fullPage", "screenshot.element", "network.intercept",
|
|
349
350
|
"snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
|
|
350
351
|
"capture.screencast", "input.raw", "input.html5Drag",
|
|
351
|
-
"browser.downloads", "browser.permissions",
|
|
352
|
+
"browser.downloads", "browser.permissions", "worker.targets",
|
|
352
353
|
]);
|
|
353
354
|
/* ---------------------------------- PageDriver ---------------------------------- */
|
|
354
355
|
class CdpPageDriver implements PageDriver {
|
|
@@ -1106,6 +1107,20 @@ class CdpBrowserDriver implements BrowserDriver {
|
|
|
1106
1107
|
await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
|
|
1107
1108
|
return { id: target.id, url: target.url, title: target.title };
|
|
1108
1109
|
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Capability "worker.targets". Resolution and the wake live in ./workers.ts;
|
|
1112
|
+
* this is the driver-shaped door onto them.
|
|
1113
|
+
*
|
|
1114
|
+
* The catch is noSuchTarget for the same reason page() uses it: a genuine
|
|
1115
|
+
* "nothing matched" is a CdpError (numeric code) and must surface as a
|
|
1116
|
+
* `no-such-target` DriverError, while a typed error from underneath — a lease
|
|
1117
|
+
* conflict, an fs errno — passes through with its class intact. The teaching
|
|
1118
|
+
* text in the miss message is the CdpError's message and survives relabeling.
|
|
1119
|
+
*/
|
|
1120
|
+
async resolveWorkerTarget(selector: string, opts: { wake: boolean }): Promise<PageInfo> {
|
|
1121
|
+
const target = await resolveWorkerSelector(selector, opts).catch(noSuchTarget);
|
|
1122
|
+
return { id: target.id, url: target.url, title: target.title, type: target.type };
|
|
1123
|
+
}
|
|
1109
1124
|
async page(selector: TargetSelector): Promise<PageDriver> {
|
|
1110
1125
|
const { conn, target } = await openPage(selector).catch(noSuchTarget);
|
|
1111
1126
|
// Deliberately NOT enabling ANY domain here (previously eagerly enabled Page+Runtime on every
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome-only: resolving a "worker:<substring>" selector, including WAKING an
|
|
3
|
+
* idle-evicted MV3 service worker so there is something to resolve.
|
|
4
|
+
*
|
|
5
|
+
* EVERY PROTOCOL CHOICE BELOW WAS MEASURED against Chrome 151.0.7922.109 for
|
|
6
|
+
* 1.9.0, because the obvious ones are wrong:
|
|
7
|
+
*
|
|
8
|
+
* 1. `ServiceWorker.enable` and `ServiceWorker.startWorker` DO NOT EXIST on the
|
|
9
|
+
* browser endpoint — both answer `-32601 wasn't found` there. The domain is
|
|
10
|
+
* reachable only over a session attached to a PAGE target. Any page works;
|
|
11
|
+
* a plain `about:blank` tab hosted the wake in the probe, so no page on the
|
|
12
|
+
* extension's own origin is needed. That is why this opens a browser
|
|
13
|
+
* connection and then attaches a flattened session to some page, rather
|
|
14
|
+
* than talking to the browser endpoint directly.
|
|
15
|
+
*
|
|
16
|
+
* 2. A STOPPED worker is not a target. It is absent from `/json/list` and from
|
|
17
|
+
* `Target.getTargets` under every filter tried (including an explicit
|
|
18
|
+
* all-types filter, and after `Target.setDiscoverTargets`). The ONLY place a
|
|
19
|
+
* stopped worker is visible is the registration inventory that
|
|
20
|
+
* `ServiceWorker.enable` pushes as `workerRegistrationUpdated` /
|
|
21
|
+
* `workerVersionUpdated`. So matching a substring against a sleeping worker
|
|
22
|
+
* means matching against those events, not against a target list.
|
|
23
|
+
*
|
|
24
|
+
* 3. `ServiceWorker.startWorker` RETURNS EMPTY SUCCESS FOR A SCOPE THAT DOES
|
|
25
|
+
* NOT EXIST — a completely invented `chrome-extension://aaaa.../` scope
|
|
26
|
+
* answers `{}` exactly like a real one. Its result is therefore worthless as
|
|
27
|
+
* evidence, and nothing here reports a wake on the strength of it: the
|
|
28
|
+
* caller re-reads the target list and only a worker that actually shows up
|
|
29
|
+
* counts. This is the one dishonesty the feature could most easily ship.
|
|
30
|
+
*
|
|
31
|
+
* WHY THE CONNECTION IS ALWAYS CLOSED IN A `finally`. Two reasons, and the
|
|
32
|
+
* second is not obvious: a leaked socket keeps the process alive (the standing
|
|
33
|
+
* harness rule), AND an attached CDP session KEEPS A SERVICE WORKER FROM BEING
|
|
34
|
+
* EVICTED. A connection left open here would suppress the very eviction this
|
|
35
|
+
* module exists to recover from, so the leak would hide itself.
|
|
36
|
+
*/
|
|
37
|
+
import { CdpError, listTargets, openBrowser } from "../client.ts";
|
|
38
|
+
import type { Target } from "../types.ts";
|
|
39
|
+
import {
|
|
40
|
+
resolveWorkerTargets,
|
|
41
|
+
workerAmbiguityMessage,
|
|
42
|
+
workerMissMessage,
|
|
43
|
+
workerNeedle,
|
|
44
|
+
WORKER_EMPTY_NEEDLE_MESSAGE,
|
|
45
|
+
} from "../workers.ts";
|
|
46
|
+
|
|
47
|
+
/** Bounded wait for the first inventory event after ServiceWorker.enable. A
|
|
48
|
+
* browser with no registrations at all never sends one, so this must time out
|
|
49
|
+
* rather than hang; the settle that follows collects the rest of the batch. */
|
|
50
|
+
const INVENTORY_FIRST_EVENT_MS = 1_500;
|
|
51
|
+
const INVENTORY_SETTLE_MS = 250;
|
|
52
|
+
/** Wake poll budget: 30 x 300ms = 9s. The probe saw a worker reappear in well
|
|
53
|
+
* under 2s; the headroom is for a cold worker whose top-level code does work. */
|
|
54
|
+
const WAKE_POLL_MS = 300;
|
|
55
|
+
const WAKE_POLL_ATTEMPTS = 30;
|
|
56
|
+
|
|
57
|
+
const NO_HOST_PAGE_MESSAGE =
|
|
58
|
+
"cannot start a service worker: the ServiceWorker CDP domain is only reachable over a page session, " +
|
|
59
|
+
"and this browser has no page target open. Open any tab (new_page) and retry — the tab does not have " +
|
|
60
|
+
"to belong to the extension.";
|
|
61
|
+
|
|
62
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
63
|
+
|
|
64
|
+
interface RegistrationEvent {
|
|
65
|
+
registrationId?: unknown;
|
|
66
|
+
scopeURL?: unknown;
|
|
67
|
+
isDeleted?: unknown;
|
|
68
|
+
}
|
|
69
|
+
interface VersionEvent {
|
|
70
|
+
registrationId?: unknown;
|
|
71
|
+
scriptURL?: unknown;
|
|
72
|
+
runningStatus?: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface WorkerStartAttempt {
|
|
76
|
+
/** True only when a matching registration was found AND a start was issued.
|
|
77
|
+
* Says nothing about whether the worker actually came up: see fact 3. */
|
|
78
|
+
startRequested: boolean;
|
|
79
|
+
/** Every registered scope this browser reported, for the miss message. */
|
|
80
|
+
scopes: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read the service-worker registration inventory and, when exactly one
|
|
85
|
+
* registration matches `needle`, ask Chrome to start it.
|
|
86
|
+
*
|
|
87
|
+
* Matching is against the version's scriptURL OR the registration's scopeURL,
|
|
88
|
+
* so both `worker:<extension-id>` (which appears in both) and
|
|
89
|
+
* `worker:background.js` (which appears only in the scriptURL) resolve.
|
|
90
|
+
*/
|
|
91
|
+
export async function requestWorkerStart(needle: string): Promise<WorkerStartAttempt> {
|
|
92
|
+
const conn = await openBrowser();
|
|
93
|
+
try {
|
|
94
|
+
const host = (await listTargets()).find((t) => t.type === "page");
|
|
95
|
+
if (!host) throw new CdpError(NO_HOST_PAGE_MESSAGE);
|
|
96
|
+
const { sessionId } = await conn.send<{ sessionId?: string }>("Target.attachToTarget", {
|
|
97
|
+
targetId: host.id,
|
|
98
|
+
flatten: true,
|
|
99
|
+
});
|
|
100
|
+
if (!sessionId) throw new CdpError("could not attach a session to host the ServiceWorker domain");
|
|
101
|
+
|
|
102
|
+
const scopeById = new Map<string, string>();
|
|
103
|
+
const versions: Array<{ registrationId: string; scriptURL: string }> = [];
|
|
104
|
+
conn.on("ServiceWorker.workerRegistrationUpdated", (params) => {
|
|
105
|
+
for (const r of (params.registrations as RegistrationEvent[] | undefined) ?? []) {
|
|
106
|
+
if (r.isDeleted === true) continue;
|
|
107
|
+
scopeById.set(String(r.registrationId), String(r.scopeURL ?? ""));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
conn.on("ServiceWorker.workerVersionUpdated", (params) => {
|
|
111
|
+
for (const v of (params.versions as VersionEvent[] | undefined) ?? []) {
|
|
112
|
+
versions.push({ registrationId: String(v.registrationId), scriptURL: String(v.scriptURL ?? "") });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await conn.send("ServiceWorker.enable", {}, { sessionId });
|
|
117
|
+
// The inventory arrives as events AFTER the enable response, so waiting on
|
|
118
|
+
// the response alone would read an empty map every time.
|
|
119
|
+
await conn.waitFor("ServiceWorker.workerVersionUpdated", undefined, INVENTORY_FIRST_EVENT_MS).catch(() => undefined);
|
|
120
|
+
await sleep(INVENTORY_SETTLE_MS);
|
|
121
|
+
|
|
122
|
+
const scopes = [...new Set(scopeById.values())].filter((s) => s.length > 0).sort();
|
|
123
|
+
const matched = new Set<string>();
|
|
124
|
+
for (const v of versions) {
|
|
125
|
+
const scope = scopeById.get(v.registrationId);
|
|
126
|
+
if (scope === undefined) continue;
|
|
127
|
+
if (v.scriptURL.includes(needle) || scope.includes(needle)) matched.add(scope);
|
|
128
|
+
}
|
|
129
|
+
if (matched.size === 0) return { startRequested: false, scopes };
|
|
130
|
+
if (matched.size > 1) {
|
|
131
|
+
throw new CdpError(
|
|
132
|
+
`'worker:${needle}' matches more than one registered service-worker scope: ${[...matched].sort().join(", ")}. ` +
|
|
133
|
+
"Narrow the substring.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
await conn.send("ServiceWorker.startWorker", { scopeURL: [...matched][0]! }, { sessionId });
|
|
137
|
+
return { startRequested: true, scopes };
|
|
138
|
+
} finally {
|
|
139
|
+
// See the header: a held session also suppresses eviction.
|
|
140
|
+
conn.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve a `worker:<substring>` selector to a live worker target, waking an
|
|
146
|
+
* evicted worker first when `wake` is true (the default at the tool surface).
|
|
147
|
+
*
|
|
148
|
+
* The wake path is: match running targets -> nothing -> ask for a start ->
|
|
149
|
+
* RE-READ THE TARGET LIST until the worker appears or the budget runs out.
|
|
150
|
+
* The re-read is the only evidence accepted that a wake happened.
|
|
151
|
+
*/
|
|
152
|
+
export async function resolveWorkerSelector(selector: string, opts: { wake: boolean }): Promise<Target> {
|
|
153
|
+
const needle = workerNeedle(selector);
|
|
154
|
+
if (needle === "") throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
155
|
+
|
|
156
|
+
const first = resolveWorkerTargets(await listTargets(), needle);
|
|
157
|
+
if (first.matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, first.matches));
|
|
158
|
+
if (first.matches.length === 1) return first.matches[0]!;
|
|
159
|
+
|
|
160
|
+
if (!opts.wake) {
|
|
161
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers: first.liveWorkers }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { startRequested, scopes } = await requestWorkerStart(needle);
|
|
165
|
+
if (startRequested) {
|
|
166
|
+
for (let i = 0; i < WAKE_POLL_ATTEMPTS; i++) {
|
|
167
|
+
await sleep(WAKE_POLL_MS);
|
|
168
|
+
const again = resolveWorkerTargets(await listTargets(), needle);
|
|
169
|
+
if (again.matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, again.matches));
|
|
170
|
+
if (again.matches.length === 1) return again.matches[0]!;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const final = resolveWorkerTargets(await listTargets(), needle);
|
|
174
|
+
throw new CdpError(
|
|
175
|
+
workerMissMessage(needle, { wakeAttempted: true, liveWorkers: final.liveWorkers, registeredScopes: scopes }),
|
|
176
|
+
);
|
|
177
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -48,7 +48,9 @@ Backend selection: --browser chrome|firefox, else the CDP_BROWSER env var, else
|
|
|
48
48
|
zero behavior change for existing users). Firefox is LAUNCHED per invocation (it cannot be attached
|
|
49
49
|
to), used for exactly one tool call, then disposed before the process exits.
|
|
50
50
|
|
|
51
|
-
Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
|
|
51
|
+
Target selector grammar: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>
|
|
52
|
+
plus worker:<substr> for evaluate_script ONLY (Chrome only): an MV3 extension's background service
|
|
53
|
+
worker, e.g. worker:<extension-id>. An idle-evicted worker is started first unless wake:false.
|
|
52
54
|
Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
|
|
53
55
|
Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
|
|
54
56
|
Run with --list to print every tool name available under the selected backend.
|
package/src/client.ts
CHANGED
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { CdpResponse, Target, TargetSelector } from "./types.ts";
|
|
7
7
|
import { assertLeaseOk } from "./leases.ts";
|
|
8
|
+
import { resolveLiveLabel } from "./origins.ts";
|
|
9
|
+
import {
|
|
10
|
+
isWorkerSelector,
|
|
11
|
+
isWorkerTargetType,
|
|
12
|
+
resolveWorkerTargets,
|
|
13
|
+
workerAmbiguityMessage,
|
|
14
|
+
workerMissMessage,
|
|
15
|
+
workerNeedle,
|
|
16
|
+
WORKER_EMPTY_NEEDLE_MESSAGE,
|
|
17
|
+
} from "./workers.ts";
|
|
8
18
|
|
|
9
19
|
/** Base HTTP origin of the DevTools endpoint. Override with CDP_BASE. */
|
|
10
20
|
export const BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
@@ -197,7 +207,19 @@ export async function browserWsUrl(): Promise<string> {
|
|
|
197
207
|
* the ambient lease scope instead (see leases.ts). */
|
|
198
208
|
export async function resolveTarget(selector: TargetSelector, opts: { lease?: string } = {}): Promise<Target> {
|
|
199
209
|
const targets = await listTargets();
|
|
200
|
-
const hit = pickTarget(targets, selector);
|
|
210
|
+
const hit = await pickTarget(targets, selector);
|
|
211
|
+
// WORKER TARGETS BYPASS THE LEASE GATE ENTIRELY, and that is a decision, not
|
|
212
|
+
// an omission. A lease answers "who is driving this TAB", and every
|
|
213
|
+
// consequence of holding one is a tab consequence: reap CLOSES an abandoned
|
|
214
|
+
// leased tab, and "close the stale tab" is meaningless for a service worker
|
|
215
|
+
// that Chrome starts and stops on its own schedule (see ReapInput.livePageIds,
|
|
216
|
+
// which is deliberately fed the page-only listing for exactly this reason).
|
|
217
|
+
// Letting strict mode auto-acquire here would mint a lease file per worker
|
|
218
|
+
// evaluate, keyed on an id that vanishes the moment the worker idles out —
|
|
219
|
+
// garbage that the page-only reap set can never collect. It also cannot be
|
|
220
|
+
// used to sneak past a lease: a worker is not a tab, so there is no tab
|
|
221
|
+
// someone else holds that this could reach.
|
|
222
|
+
if (isWorkerTargetType(hit.type)) return hit;
|
|
201
223
|
// THE Chrome choke point. Every tool reaches a page through here, so a tool
|
|
202
224
|
// added after this shipped is protected with no action from its author.
|
|
203
225
|
//
|
|
@@ -211,7 +233,7 @@ export async function resolveTarget(selector: TargetSelector, opts: { lease?: st
|
|
|
211
233
|
return hit;
|
|
212
234
|
}
|
|
213
235
|
|
|
214
|
-
function pickTarget(targets: Target[], selector: TargetSelector): Target {
|
|
236
|
+
async function pickTarget(targets: Target[], selector: TargetSelector): Promise<Target> {
|
|
215
237
|
const pages = targets.filter((t) => t.type === "page");
|
|
216
238
|
if (!selector || selector === "active") {
|
|
217
239
|
if (!pages.length) throw new CdpError("no page targets open");
|
|
@@ -234,6 +256,34 @@ function pickTarget(targets: Target[], selector: TargetSelector): Target {
|
|
|
234
256
|
if (!hit) throw new CdpError(`no page title containing '${needle}'`);
|
|
235
257
|
return hit;
|
|
236
258
|
}
|
|
259
|
+
if (selector.startsWith("label:")) {
|
|
260
|
+
const label = selector.slice(6);
|
|
261
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("chrome", label, pages.map((t) => t.id));
|
|
262
|
+
if (matchIds.length > 1) {
|
|
263
|
+
throw new CdpError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
264
|
+
}
|
|
265
|
+
if (matchIds.length === 1) {
|
|
266
|
+
const hit = pages.find((t) => t.id === matchIds[0]);
|
|
267
|
+
if (hit) return hit;
|
|
268
|
+
}
|
|
269
|
+
throw new CdpError(
|
|
270
|
+
knownLabels.length
|
|
271
|
+
? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})`
|
|
272
|
+
: `no live target with label '${label}' (no labels are currently assigned to any open target)`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (isWorkerSelector(selector)) {
|
|
276
|
+
// Resolved against the UNFILTERED listing, never `pages`: a worker is not a
|
|
277
|
+
// page target and would not be in it. Waking an evicted worker is NOT done
|
|
278
|
+
// here — this stays a pure pick over what the browser already reports, and
|
|
279
|
+
// the wake path lives in cdp/workers.ts, reached only from evaluate_script.
|
|
280
|
+
const needle = workerNeedle(selector);
|
|
281
|
+
if (needle === "") throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
282
|
+
const { matches, liveWorkers } = resolveWorkerTargets(targets, needle);
|
|
283
|
+
if (matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, matches));
|
|
284
|
+
if (matches.length === 1) return matches[0]!;
|
|
285
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
|
|
286
|
+
}
|
|
237
287
|
// bare id
|
|
238
288
|
const byId = targets.find((t) => t.id === selector);
|
|
239
289
|
if (!byId) throw new CdpError(`no target with id '${selector}'`);
|
package/src/driver.ts
CHANGED
|
@@ -142,7 +142,8 @@ export type Capability =
|
|
|
142
142
|
| "input.raw" // raw single mouse-event dispatch (dispatch_mouse's move/down/up primitive); Chrome only
|
|
143
143
|
| "input.html5Drag" // real HTML5 drag events (drag's mode:"html5"); Chrome only, see PageDriver.drag
|
|
144
144
|
| "browser.downloads" // capture a file download to a known path (wait_for_download); Chrome only
|
|
145
|
-
| "browser.permissions"
|
|
145
|
+
| "browser.permissions" // grant/reset browser permissions for an origin (grant_permissions); Chrome only
|
|
146
|
+
| "worker.targets"; // address a service/shared worker as a target ("worker:<substr>"), incl. waking an evicted MV3 worker; Chrome only
|
|
146
147
|
|
|
147
148
|
/**
|
|
148
149
|
* Tools whose availability depends on a capability. A tool absent from this
|
|
@@ -563,6 +564,31 @@ export interface BrowserDriver {
|
|
|
563
564
|
*/
|
|
564
565
|
probeRenderer?(targetId: string, timeoutMs?: number): Promise<{ responsive: boolean; beaconTs: number | null }>;
|
|
565
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Resolve a "worker:<substring>" selector to a concrete service/shared-worker
|
|
569
|
+
* target, STARTING an idle-evicted MV3 service worker first when `wake` is
|
|
570
|
+
* true. The returned PageInfo's id is a plain target id, so the caller then
|
|
571
|
+
* acquires it through the ordinary page() path.
|
|
572
|
+
*
|
|
573
|
+
* OPTIONAL **and** gated by Capability "worker.targets", unlike
|
|
574
|
+
* installActivityBeacon / readActivityBeacon / probeRenderer, which are
|
|
575
|
+
* optional but deliberately NOT capabilities. The difference is what a caller
|
|
576
|
+
* can observe: those three produce an absent FIELD on a result that is
|
|
577
|
+
* otherwise complete, so silence is the honest answer. This one decides
|
|
578
|
+
* whether an argument the caller explicitly wrote is honoured, and silently
|
|
579
|
+
* resolving `worker:...` to something else — or to nothing — would be a wrong
|
|
580
|
+
* answer rather than a missing annotation. So it refuses out loud, exactly as
|
|
581
|
+
* drag's mode:'html5' does behind "input.html5Drag" (see resolveDragMode).
|
|
582
|
+
* No tool is gated out of tools/list: evaluate_script is universal, only this
|
|
583
|
+
* one arm of its `target` grammar is Chrome-only.
|
|
584
|
+
*
|
|
585
|
+
* MUST NOT report a wake it cannot see: an implementation is required to
|
|
586
|
+
* confirm the worker is back in the target listing rather than trusting the
|
|
587
|
+
* start command's own result. See cdp/workers.ts for why that is not
|
|
588
|
+
* paranoia.
|
|
589
|
+
*/
|
|
590
|
+
resolveWorkerTarget?(selector: string, opts: { wake: boolean }): Promise<PageInfo>;
|
|
591
|
+
|
|
566
592
|
/** Tear down the transport: process exit (CLI) or shutdown (MCP server).
|
|
567
593
|
* Idempotent. MUST NOT be called by a tool. */
|
|
568
594
|
dispose(): Promise<void>;
|
package/src/leases-tools.ts
CHANGED
|
@@ -73,7 +73,7 @@ export interface ClaimPageResult {
|
|
|
73
73
|
* path for "work in the tab I have open", which previously had none, because
|
|
74
74
|
* `targetId` demands an exact id a human never has to hand and no id at all
|
|
75
75
|
* meant a brand new tab. `target` takes the toolkit's whole selector grammar
|
|
76
|
-
* (active | index:N | url:<substr> | title:<substr> | <targetId>).
|
|
76
|
+
* (active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>).
|
|
77
77
|
*
|
|
78
78
|
* `targetId` is unchanged and still accepted; `target` is the same thing with a
|
|
79
79
|
* grammar, so exactly one of the two may be given.
|
|
@@ -123,7 +123,7 @@ export async function claimPage(
|
|
|
123
123
|
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
124
124
|
if (hasTarget && hasTargetId) {
|
|
125
125
|
throw new LeaseToolError(
|
|
126
|
-
"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.",
|
|
126
|
+
"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.",
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
const backend = backendOf(driver);
|