cdp-toolkit 1.1.0 → 1.4.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 +203 -9
- package/CONTRACT.md +7 -5
- package/README.md +113 -33
- package/dist/cli.js +764 -64
- package/dist/index.js +650 -42
- package/dist/mcp.js +1091 -77
- package/package.json +4 -3
- package/src/bidi/driver.ts +167 -4
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +145 -1
- package/src/cli.ts +47 -22
- package/src/client.ts +25 -7
- package/src/driver.ts +107 -1
- package/src/firefox-tools.ts +6 -3
- package/src/index.ts +14 -2
- package/src/leases-tools.ts +107 -0
- package/src/leases.ts +541 -0
- package/src/manifest.ts +339 -15
- package/src/mcp.ts +27 -19
- package/src/origins.ts +266 -0
- package/src/shared-tools.ts +365 -14
- 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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "A lightweight chrome-devtools-mcp alternative: a single-target MCP server + CLI that drives one tab over a 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.
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "A lightweight chrome-devtools-mcp alternative: a single-target MCP server + CLI that drives one tab over a 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. 39 tools including built-in network mocking and a cookie group. No Puppeteer, no all-target fan-out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": {
|
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
"smoke": "bun run ./test/smoke.ts",
|
|
77
77
|
"mock:smoke": "bun run ./test/mock-smoke.ts",
|
|
78
78
|
"mcp:smoke": "bun run ./test/mcp-smoke.ts",
|
|
79
|
-
"firefox:smoke": "bun run ./test/firefox-smoke.ts"
|
|
79
|
+
"firefox:smoke": "bun run ./test/firefox-smoke.ts",
|
|
80
|
+
"lease:smoke": "bun run ./test/lease-smoke.ts"
|
|
80
81
|
},
|
|
81
82
|
"devDependencies": {
|
|
82
83
|
"@types/node": "22.10.10",
|
package/src/bidi/driver.ts
CHANGED
|
@@ -25,11 +25,13 @@
|
|
|
25
25
|
import type { TargetSelector } from "../types.ts";
|
|
26
26
|
import {
|
|
27
27
|
UID_STAMP_ATTR, isDriverError,
|
|
28
|
-
type BrowserDriver, type Capability, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
|
|
28
|
+
type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
|
|
29
|
+
type SetCookieParams,
|
|
29
30
|
type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel,
|
|
30
31
|
type MouseButtonOptions, type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo,
|
|
31
32
|
type ScreenshotOptions, type SnapshotNode, type UidStability,
|
|
32
33
|
} from "../driver.ts";
|
|
34
|
+
import { assertLeaseOk } from "../leases.ts";
|
|
33
35
|
import { BidiConnection, BidiError, connectBidiSession } from "./client.ts";
|
|
34
36
|
import { takeStampedSnapshot } from "./snapshot.ts";
|
|
35
37
|
import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
|
|
@@ -37,8 +39,60 @@ import type {
|
|
|
37
39
|
BrowsingContextId, BrowsingContextInfo, BrowsingContextLocator, BrowsingContextReadinessState,
|
|
38
40
|
BrowsingContextCaptureScreenshotParameters, ScriptSharedReference, ScriptRemoteValue, ScriptLocalValue,
|
|
39
41
|
ScriptNodeRemoteValue, ScriptExceptionDetails, InputSourceActions, InputPointerSourceAction, InputKeySourceAction,
|
|
42
|
+
NetworkCookie,
|
|
40
43
|
} from "./protocol.ts";
|
|
41
44
|
|
|
45
|
+
/* ---------------------------------- cookies ---------------------------------- */
|
|
46
|
+
/**
|
|
47
|
+
* BiDi's cookie shape to the neutral one. Three differences to absorb:
|
|
48
|
+
* - the value is a network.BytesValue, either a plain string or base64, so a
|
|
49
|
+
* base64 one is decoded rather than handed back as opaque base64;
|
|
50
|
+
* - `expiry` is optional and simply absent for a session cookie, which becomes
|
|
51
|
+
* the neutral -1 that CDP reports directly;
|
|
52
|
+
* - `session` has no BiDi field at all, so it is derived from that same
|
|
53
|
+
* absence, which is exactly what a session cookie means.
|
|
54
|
+
* `sameSite` already uses the lowercase vocabulary the neutral shape adopted.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeBidiCookie(c: NetworkCookie): BrowserCookie {
|
|
57
|
+
const value = c.value.type === "base64" ? Buffer.from(c.value.value, "base64").toString("utf8") : c.value.value;
|
|
58
|
+
const hasExpiry = typeof c.expiry === "number";
|
|
59
|
+
return {
|
|
60
|
+
name: c.name, value, domain: c.domain, path: c.path,
|
|
61
|
+
expires: hasExpiry ? (c.expiry as number) : -1,
|
|
62
|
+
size: typeof c.size === "number" ? c.size : Buffer.byteLength(`${c.name}${value}`, "utf8"),
|
|
63
|
+
httpOnly: c.httpOnly === true, secure: c.secure === true,
|
|
64
|
+
sameSite: c.sameSite ?? "default", session: !hasExpiry,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The host of a url, for the BiDi cookie calls that require a `domain` and have
|
|
70
|
+
* no `url` parameter of their own.
|
|
71
|
+
*
|
|
72
|
+
* Exported so a test can pin the failure cases without a browser. Every branch
|
|
73
|
+
* that cannot produce a host THROWS: an absent url, an unparseable one, and a
|
|
74
|
+
* parseable one with an empty host (about:blank, data:, javascript:) all raise
|
|
75
|
+
* a message naming the tool and what it needs. The alternative, sending the
|
|
76
|
+
* call without a domain, is a request Firefox answers cleanly while writing or
|
|
77
|
+
* deleting nothing recognizable, which is the exact wrong-but-plausible ack
|
|
78
|
+
* this driver refuses to emit.
|
|
79
|
+
*/
|
|
80
|
+
export function hostFromUrl(url: string | undefined, toolName: string): string {
|
|
81
|
+
if (url === undefined || url === "") {
|
|
82
|
+
throw driverError("unsupported", `${toolName} on Firefox needs a 'domain': BiDi's storage cookie calls have no 'url' parameter, and no url was given to derive one from.`);
|
|
83
|
+
}
|
|
84
|
+
let host = "";
|
|
85
|
+
try {
|
|
86
|
+
host = new URL(url).hostname;
|
|
87
|
+
} catch {
|
|
88
|
+
throw driverError("unsupported", `${toolName} could not parse the url '${url}' to derive the 'domain' BiDi requires. Pass 'domain' explicitly.`);
|
|
89
|
+
}
|
|
90
|
+
if (host === "") {
|
|
91
|
+
throw driverError("unsupported", `${toolName} cannot derive a 'domain' from '${url}': that url has no host. Pass 'domain' explicitly.`);
|
|
92
|
+
}
|
|
93
|
+
return host;
|
|
94
|
+
}
|
|
95
|
+
|
|
42
96
|
/* ------------------------------ error + uid codec ------------------------------ */
|
|
43
97
|
function driverError(code: DriverErrorCode, message: string, data?: unknown): DriverError {
|
|
44
98
|
return Object.assign(new Error(message), { code, ...(data !== undefined ? { data } : {}) }) as DriverError;
|
|
@@ -256,10 +310,33 @@ async function pageInfoOf(conn: BidiConnection, ctx: BrowsingContextInfo): Promi
|
|
|
256
310
|
return { id: ctx.context, url: ctx.url, title: await safeTitle(conn, ctx.context) };
|
|
257
311
|
}
|
|
258
312
|
/** Same TargetSelector grammar as client.ts's resolveTarget, but resolved entirely over BiDi
|
|
259
|
-
* (browsingContext.getTree): there is no HTTP discovery endpoint here, per client.ts's own footer.
|
|
260
|
-
|
|
313
|
+
* (browsingContext.getTree): there is no HTTP discovery endpoint here, per client.ts's own footer.
|
|
314
|
+
*
|
|
315
|
+
* THE Firefox choke point for tab leases. Keyed "firefox", never a bare id: a CDP targetId and a
|
|
316
|
+
* BiDi context id are not disjoint by construction, so one bare key space could produce a false
|
|
317
|
+
* collision between an unrelated Chrome tab and a Firefox context. Note this backend needs the
|
|
318
|
+
* lease far less than Chrome does (backend.ts: every MCP server process launches its OWN Firefox,
|
|
319
|
+
* so two sessions cannot race over one instance the way they do over Chrome on port 9222). It is
|
|
320
|
+
* here for consistency and for a future shared-instance setup, not to close an observed gap. */
|
|
321
|
+
async function resolveContext(
|
|
322
|
+
conn: BidiConnection,
|
|
323
|
+
selector: TargetSelector,
|
|
324
|
+
opts: { lease?: string } = {},
|
|
325
|
+
): Promise<BrowsingContextInfo> {
|
|
261
326
|
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => { throw mapBidiError(e); });
|
|
262
327
|
if (!contexts.length) throw driverError("no-such-target", "no browsing contexts available");
|
|
328
|
+
const hit = await pickContext(conn, contexts, selector);
|
|
329
|
+
// liveIds deliberately not passed: hit is by construction a member of contexts, so target-gone
|
|
330
|
+
// could never fire for the context just resolved (see leases.ts's assertLeaseOk callers).
|
|
331
|
+
await assertLeaseOk("firefox", hit.context, { lease: opts.lease, url: hit.url });
|
|
332
|
+
return hit;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function pickContext(
|
|
336
|
+
conn: BidiConnection,
|
|
337
|
+
contexts: BrowsingContextInfo[],
|
|
338
|
+
selector: TargetSelector,
|
|
339
|
+
): Promise<BrowsingContextInfo> {
|
|
263
340
|
if (selector === undefined || selector === "active") return contexts[0]!;
|
|
264
341
|
if (selector.startsWith("index:")) {
|
|
265
342
|
const t = contexts[Number(selector.slice(6))];
|
|
@@ -619,6 +696,88 @@ class BidiPageDriver implements PageDriver {
|
|
|
619
696
|
return this.handleOneDialog(accept, promptText, opts?.timeoutMs ?? 15_000);
|
|
620
697
|
}
|
|
621
698
|
|
|
699
|
+
/**
|
|
700
|
+
* storage.getCookies, partitioned by THIS browsing context.
|
|
701
|
+
*
|
|
702
|
+
* The context partition is the BiDi counterpart of the CDP driver's choice of
|
|
703
|
+
* the page-scoped Network.getCookies over the browser-wide jar: it asks for
|
|
704
|
+
* the cookies of the tab that was resolved, not for every cookie the profile
|
|
705
|
+
* holds. httpOnly cookies are included, since this reads the cookie store
|
|
706
|
+
* over the protocol rather than evaluating document.cookie in the page.
|
|
707
|
+
*/
|
|
708
|
+
async getCookies(): Promise<BrowserCookie[]> {
|
|
709
|
+
try {
|
|
710
|
+
const { cookies } = await this.conn.send("storage.getCookies", { partition: { type: "context", context: this.contextId } });
|
|
711
|
+
return (cookies ?? []).map(normalizeBidiCookie);
|
|
712
|
+
} catch (e) {
|
|
713
|
+
throw mapBidiError(e);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* storage.setCookie, partitioned by THIS browsing context.
|
|
719
|
+
*
|
|
720
|
+
* One real difference from CDP to absorb, and it is absorbed EXPLICITLY
|
|
721
|
+
* rather than papered over: BiDi has no `url` parameter and requires
|
|
722
|
+
* `domain`, while CDP accepts either and derives the rest from the url. So a
|
|
723
|
+
* caller who supplied only a url gets its host used as the domain, and a url
|
|
724
|
+
* with no host (about:blank, a data: URL, a malformed string) throws instead
|
|
725
|
+
* of being dropped, because a set that silently did nothing is the failure
|
|
726
|
+
* this tool must never produce.
|
|
727
|
+
*
|
|
728
|
+
* Note the derivation is narrower than CDP's: CDP also takes `path` and
|
|
729
|
+
* `secure` from the url, and this does not, so those stay exactly as the
|
|
730
|
+
* caller gave them on both backends.
|
|
731
|
+
*/
|
|
732
|
+
async setCookie(params: SetCookieParams): Promise<void> {
|
|
733
|
+
const domain = params.domain ?? hostFromUrl(params.url, "set_cookie");
|
|
734
|
+
try {
|
|
735
|
+
await this.conn.send("storage.setCookie", {
|
|
736
|
+
cookie: {
|
|
737
|
+
name: params.name,
|
|
738
|
+
value: { type: "string", value: params.value },
|
|
739
|
+
domain,
|
|
740
|
+
...(params.path !== undefined ? { path: params.path } : {}),
|
|
741
|
+
...(params.expires !== undefined ? { expiry: params.expires } : {}),
|
|
742
|
+
...(params.httpOnly !== undefined ? { httpOnly: params.httpOnly } : {}),
|
|
743
|
+
...(params.secure !== undefined ? { secure: params.secure } : {}),
|
|
744
|
+
...(params.sameSite !== undefined ? { sameSite: params.sameSite } : {}),
|
|
745
|
+
},
|
|
746
|
+
partition: { type: "context", context: this.contextId },
|
|
747
|
+
});
|
|
748
|
+
} catch (e) {
|
|
749
|
+
throw mapBidiError(e);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* storage.deleteCookies with a filter, partitioned by THIS browsing context.
|
|
755
|
+
*
|
|
756
|
+
* Same url-to-domain derivation as setCookie and for the same reason: the
|
|
757
|
+
* BiDi filter has `domain` and no `url`, and a filter that quietly dropped
|
|
758
|
+
* the only site constraint would delete by name across the partition, which
|
|
759
|
+
* is a bigger deletion than the caller asked for.
|
|
760
|
+
*
|
|
761
|
+
* BiDi reports a partitionKey and no count, exactly as CDP reports nothing,
|
|
762
|
+
* so this returns nothing on both backends rather than one of them inventing
|
|
763
|
+
* a number the other cannot produce.
|
|
764
|
+
*/
|
|
765
|
+
async deleteCookies(filter: DeleteCookiesFilter): Promise<void> {
|
|
766
|
+
const domain = filter.domain ?? hostFromUrl(filter.url, "delete_cookies");
|
|
767
|
+
try {
|
|
768
|
+
await this.conn.send("storage.deleteCookies", {
|
|
769
|
+
filter: {
|
|
770
|
+
name: filter.name,
|
|
771
|
+
domain,
|
|
772
|
+
...(filter.path !== undefined ? { path: filter.path } : {}),
|
|
773
|
+
},
|
|
774
|
+
partition: { type: "context", context: this.contextId },
|
|
775
|
+
});
|
|
776
|
+
} catch (e) {
|
|
777
|
+
throw mapBidiError(e);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
622
781
|
// Response bodies need network.addDataCollector armed BEFORE the request, per the established
|
|
623
782
|
// fact this driver was handed: no way to read a body for a request that happened before
|
|
624
783
|
// capture was armed. That is the mirror of the CDP driver's own honest omission of
|
|
@@ -771,7 +930,11 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
771
930
|
export function createFirefoxDriver(port: number, opts?: { timeoutMs?: number }): BrowserDriver {
|
|
772
931
|
return new BidiBrowserDriver(port, opts?.timeoutMs);
|
|
773
932
|
}
|
|
774
|
-
|
|
933
|
+
// resolveContext is exported for ONE reason: it is the Firefox lease choke
|
|
934
|
+
// point, and a choke point nothing can call directly is a choke point nothing
|
|
935
|
+
// can test. Its gate was deletable with a green suite until test/leases.test.ts
|
|
936
|
+
// could drive it against a stub connection. Not part of the public surface.
|
|
937
|
+
export { BidiBrowserDriver, BidiPageDriver, resolveContext };
|
|
775
938
|
|
|
776
939
|
/* ------------------------------------------------------------------------------
|
|
777
940
|
* BiDi modules/commands used: session (new, subscribe/unsubscribe, via client.ts); browsingContext
|
package/src/capabilities.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-backend tool availability: filters the
|
|
2
|
+
* Per-backend tool availability: filters the 36-tool registry down to what a
|
|
3
3
|
* given DriverKind can actually run, per ADR-001's rule that tools/list
|
|
4
4
|
* discovers gaps, not runtime "unsupported" throws. Pure and I/O-free: driver
|
|
5
5
|
* construction reads a static readonly `capabilities` set, it does not dial a
|
package/src/cdp/driver.ts
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type CdpConnection, CdpError, listTargets, openBrowser, openPage, resolveTarget } from "../client.ts";
|
|
9
9
|
import type { Target, TargetSelector } from "../types.ts";
|
|
10
|
+
import { LeaseConflictError } from "../leases.ts";
|
|
10
11
|
import { resolveUid } from "../tools/snapshot.ts";
|
|
11
12
|
import { buildFulfillParams, effectiveAction, selectRule } from "../tools/network_mock.ts";
|
|
12
13
|
import {
|
|
13
|
-
LEGACY_NUMERIC_UID, type BrowserDriver, type Capability, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
|
|
14
|
+
LEGACY_NUMERIC_UID, type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
|
|
15
|
+
type SetCookieParams,
|
|
14
16
|
type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel, type MouseButtonOptions,
|
|
15
17
|
type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo, type ScreenshotOptions, type SnapshotNode, type UidStability,
|
|
16
18
|
} from "../driver.ts";
|
|
@@ -29,6 +31,49 @@ function decodeUid(uid: DriverUid): number {
|
|
|
29
31
|
if (!Number.isSafeInteger(n) || n <= 0) throw driverError("foreign-uid", `malformed cdp uid payload: ${uid}`);
|
|
30
32
|
return n;
|
|
31
33
|
}
|
|
34
|
+
/* ---------------------------------- cookies ---------------------------------- */
|
|
35
|
+
/** The subset of CDP's `Network.Cookie` this driver reads. */
|
|
36
|
+
export interface CdpCookie {
|
|
37
|
+
name: string; value: string; domain: string; path: string; expires: number; size: number;
|
|
38
|
+
httpOnly: boolean; secure: boolean; session: boolean; sameSite?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* CDP's cookie shape to the neutral one. Two mappings carry the difference:
|
|
43
|
+
* `sameSite` is capitalized by CDP and absent when the cookie never set the
|
|
44
|
+
* attribute, so it lowercases and falls back to "default" (BiDi's word for the
|
|
45
|
+
* same state); `expires` is already CDP's Unix seconds with -1 for a session
|
|
46
|
+
* cookie, so it passes through, with `session` kept as the browser's own flag
|
|
47
|
+
* rather than re-derived from the timestamp.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeCdpCookie(c: CdpCookie): BrowserCookie {
|
|
50
|
+
const same = (c.sameSite ?? "").toLowerCase();
|
|
51
|
+
const sameSite: BrowserCookie["sameSite"] =
|
|
52
|
+
same === "strict" || same === "lax" || same === "none" ? same : "default";
|
|
53
|
+
return {
|
|
54
|
+
name: c.name, value: c.value, domain: c.domain, path: c.path,
|
|
55
|
+
expires: typeof c.expires === "number" ? c.expires : -1,
|
|
56
|
+
size: typeof c.size === "number" ? c.size : Buffer.byteLength(`${c.name}${c.value}`, "utf8"),
|
|
57
|
+
httpOnly: c.httpOnly === true, secure: c.secure === true, sameSite, session: c.session === true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The neutral lowercase `sameSite` back to CDP's own vocabulary.
|
|
63
|
+
*
|
|
64
|
+
* The inverse of normalizeCdpCookie's mapping, and it has to be the inverse:
|
|
65
|
+
* CDP capitalizes the three real values and has NO word for "default", which
|
|
66
|
+
* is BiDi's name for a cookie that never set the attribute. So "default"
|
|
67
|
+
* returns undefined and the caller omits the field, which is exactly the state
|
|
68
|
+
* it describes.
|
|
69
|
+
*/
|
|
70
|
+
export function cdpSameSite(sameSite?: "strict" | "lax" | "none" | "default"): "Strict" | "Lax" | "None" | undefined {
|
|
71
|
+
if (sameSite === "strict") return "Strict";
|
|
72
|
+
if (sameSite === "lax") return "Lax";
|
|
73
|
+
if (sameSite === "none") return "None";
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
32
77
|
/* ---------------------- copied helpers, see attributions below ---------------------- */
|
|
33
78
|
// Copied verbatim from src/tools/input.ts `centerOf`: scroll into view, read viewport-space center.
|
|
34
79
|
async function centerOf(conn: CdpConnection, objectId: string): Promise<{ x: number; y: number }> {
|
|
@@ -535,6 +580,79 @@ class CdpPageDriver implements PageDriver {
|
|
|
535
580
|
});
|
|
536
581
|
});
|
|
537
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* `Network.getCookies` over `Storage.getCookies`, deliberately.
|
|
585
|
+
*
|
|
586
|
+
* The two differ in SCOPE, not in detail: `Storage.getCookies` returns the
|
|
587
|
+
* whole browser jar (every cookie for every site the profile has), while
|
|
588
|
+
* `Network.getCookies`, called with no `urls`, returns the cookies of the
|
|
589
|
+
* page this connection is attached to and its subframes. This driver is
|
|
590
|
+
* always attached to one resolved target, so the page-scoped call is the one
|
|
591
|
+
* that answers the question the caller asked. It also keeps unrelated sites'
|
|
592
|
+
* session credentials out of a result that only named one tab.
|
|
593
|
+
*
|
|
594
|
+
* The Network domain is enabled with the same lazy ensureDomain the rest of
|
|
595
|
+
* the class uses. httpOnly cookies come back either way: this is a protocol
|
|
596
|
+
* read of the cookie store, not a `document.cookie` evaluation, which is the
|
|
597
|
+
* entire reason a tool is better here than a script.
|
|
598
|
+
*/
|
|
599
|
+
async getCookies(): Promise<BrowserCookie[]> {
|
|
600
|
+
await this.ensureDomain("Network");
|
|
601
|
+
const { cookies } = await this.conn.send<{ cookies: CdpCookie[] }>("Network.getCookies");
|
|
602
|
+
return (cookies ?? []).map(normalizeCdpCookie);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* `Network.setCookie`, which answers `{success: boolean}` rather than an
|
|
606
|
+
* error when it declines a cookie.
|
|
607
|
+
*
|
|
608
|
+
* That return is the whole reason this method is more than one line. Chrome
|
|
609
|
+
* refuses a cookie for plenty of ordinary reasons (a domain the url does not
|
|
610
|
+
* belong to, `secure` on an insecure origin, an oversized value) and reports
|
|
611
|
+
* the refusal as `success:false` with a perfectly successful protocol reply.
|
|
612
|
+
* Resolving on that would hand the caller a "cookie set" that is not set, so
|
|
613
|
+
* a false is turned into a thrown error naming the cookie.
|
|
614
|
+
*
|
|
615
|
+
* `path` is passed through as given, not defaulted: with a `url` CDP derives
|
|
616
|
+
* the path from it, and inventing "/" here would silently widen the scope of
|
|
617
|
+
* a cookie the caller asked to scope narrowly.
|
|
618
|
+
*/
|
|
619
|
+
async setCookie(params: SetCookieParams): Promise<void> {
|
|
620
|
+
await this.ensureDomain("Network");
|
|
621
|
+
const sameSite = cdpSameSite(params.sameSite);
|
|
622
|
+
const { success } = await this.conn.send<{ success: boolean }>("Network.setCookie", {
|
|
623
|
+
name: params.name,
|
|
624
|
+
value: params.value,
|
|
625
|
+
...(params.url !== undefined ? { url: params.url } : {}),
|
|
626
|
+
...(params.domain !== undefined ? { domain: params.domain } : {}),
|
|
627
|
+
...(params.path !== undefined ? { path: params.path } : {}),
|
|
628
|
+
...(params.expires !== undefined ? { expires: params.expires } : {}),
|
|
629
|
+
...(params.httpOnly !== undefined ? { httpOnly: params.httpOnly } : {}),
|
|
630
|
+
...(params.secure !== undefined ? { secure: params.secure } : {}),
|
|
631
|
+
...(sameSite !== undefined ? { sameSite } : {}),
|
|
632
|
+
});
|
|
633
|
+
if (success !== true) {
|
|
634
|
+
// "page-error" is this file's code for a browser-side refusal of an
|
|
635
|
+
// operation that reached the browser intact, which is precisely this.
|
|
636
|
+
throw driverError(
|
|
637
|
+
"page-error",
|
|
638
|
+
`Network.setCookie declined the cookie '${params.name}'. Chrome rejects a cookie whose domain does not match the url, a secure cookie on an insecure origin, and an oversized name or value.`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* `Network.deleteCookies`. Unlike setCookie it reports nothing at all, not
|
|
644
|
+
* even a count, so this resolves on the protocol acknowledgement and the tool
|
|
645
|
+
* above it returns a boolean ack instead of a fabricated number.
|
|
646
|
+
*/
|
|
647
|
+
async deleteCookies(filter: DeleteCookiesFilter): Promise<void> {
|
|
648
|
+
await this.ensureDomain("Network");
|
|
649
|
+
await this.conn.send("Network.deleteCookies", {
|
|
650
|
+
name: filter.name,
|
|
651
|
+
...(filter.url !== undefined ? { url: filter.url } : {}),
|
|
652
|
+
...(filter.domain !== undefined ? { domain: filter.domain } : {}),
|
|
653
|
+
...(filter.path !== undefined ? { path: filter.path } : {}),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
538
656
|
async startBodyCapture(): Promise<() => Promise<void>> {
|
|
539
657
|
await this.ensureDomain("Network");
|
|
540
658
|
return async () => {
|
|
@@ -587,7 +705,33 @@ async function withBrowserConn<T>(fn: (conn: CdpConnection) => Promise<T>): Prom
|
|
|
587
705
|
conn.close();
|
|
588
706
|
}
|
|
589
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Relabel a target-resolution failure as `no-such-target`, but ONLY when the
|
|
710
|
+
* failure carries no type of its own.
|
|
711
|
+
*
|
|
712
|
+
* This used to relabel everything, and that was wrong in a way no unit test
|
|
713
|
+
* caught, because the message text survived and the MCP surface reads message
|
|
714
|
+
* text. resolveTarget is where the Chrome lease gate lives, so a
|
|
715
|
+
* LeaseConflictError raised by assertLeaseOk came through here and lost its
|
|
716
|
+
* class, its targetId and its holder, and came out coded `no-such-target`,
|
|
717
|
+
* which tells a caller the selector matched nothing and it should stop. A lease
|
|
718
|
+
* conflict is the one case where retrying, or going to fetch the token, is the
|
|
719
|
+
* right move. readLease's deliberate rethrow on an UNREADABLE lease file was
|
|
720
|
+
* mislabeled the same way, which is precisely the confusion that throw exists
|
|
721
|
+
* to prevent. BiDi never had a blanket catch, so the two backends disagreed.
|
|
722
|
+
*
|
|
723
|
+
* The pass-through is by TYPE, not by an enumerated list of errors, so a typed
|
|
724
|
+
* error added anywhere below this frame is protected without anyone having to
|
|
725
|
+
* remember to come back here:
|
|
726
|
+
* - LeaseConflictError, named because it is the one the caller acts on;
|
|
727
|
+
* - anything carrying a STRING `code`, which covers every DriverError and
|
|
728
|
+
* every fs errno (an EACCES out of readLease reaches this frame).
|
|
729
|
+
* CdpError's `code` is a NUMBER (a CDP protocol error code), so a genuine
|
|
730
|
+
* "that target does not exist" from pickTarget still relabels, as it must.
|
|
731
|
+
*/
|
|
590
732
|
function noSuchTarget(e: unknown): never {
|
|
733
|
+
if (e instanceof LeaseConflictError) throw e;
|
|
734
|
+
if (typeof (e as { code?: unknown } | null | undefined)?.code === "string") throw e;
|
|
591
735
|
throw driverError("no-such-target", e instanceof CdpError ? e.message : String(e));
|
|
592
736
|
}
|
|
593
737
|
class CdpBrowserDriver implements BrowserDriver {
|
package/src/cli.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { TOOLS, TOOL_NAMES, type ToolName } from "./index.ts";
|
|
|
26
26
|
import { resolveBrowserKind, stripBrowserFlag, startFirefoxSession } from "./backend.ts";
|
|
27
27
|
import { toolAvailability } from "./capabilities.ts";
|
|
28
28
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
29
|
+
import { leaseFromArgs, withLeaseScope } from "./leases.ts";
|
|
29
30
|
|
|
30
31
|
const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
|
|
31
32
|
|
|
@@ -48,6 +49,8 @@ zero behavior change for existing users). Firefox is LAUNCHED per invocation (it
|
|
|
48
49
|
to), used for exactly one tool call, then disposed before the process exits.
|
|
49
50
|
|
|
50
51
|
Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
|
|
52
|
+
Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
|
|
53
|
+
Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
|
|
51
54
|
Run with --list to print every tool name available under the selected backend.
|
|
52
55
|
Run with --capabilities to see, per tool, whether it is available and (if not) which capability
|
|
53
56
|
the selected backend is missing.`;
|
|
@@ -168,30 +171,52 @@ async function main(): Promise<number> {
|
|
|
168
171
|
return 1;
|
|
169
172
|
}
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
process.stderr.write(`${JSON.stringify({ error: `tool '${parsed.tool}' has no Firefox implementation wired` })}\n`);
|
|
174
|
+
// Decision: the CLI cannot claim a lease. Per backend.ts, a CLI invocation is
|
|
175
|
+
// one process per call, so the claiming process is already dead by the next
|
|
176
|
+
// call and the dead-pid rule would make the lease reclaimable at once,
|
|
177
|
+
// regardless of ttlMs. A lease that is reclaimable on arrival is worse than
|
|
178
|
+
// no lease, because it reads as protection that is not there. Every OTHER
|
|
179
|
+
// CLI call still goes through assertLeaseOk and may present --lease.
|
|
180
|
+
if (parsed.tool === "claim_page") {
|
|
181
|
+
process.stderr.write(
|
|
182
|
+
`${JSON.stringify({
|
|
183
|
+
error:
|
|
184
|
+
"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>.",
|
|
185
|
+
})}\n`,
|
|
186
|
+
);
|
|
185
187
|
return 1;
|
|
186
188
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
189
|
+
|
|
190
|
+
// ONE lease scope per invocation, wrapping BOTH backend branches, exactly as
|
|
191
|
+
// mcp.ts does. `--lease <token>` lands in parsed.args like any other flag, so
|
|
192
|
+
// no tool signature changes and a tool added later is covered for free.
|
|
193
|
+
const tool = parsed.tool;
|
|
194
|
+
return withLeaseScope(leaseFromArgs(parsed.args), async () => {
|
|
195
|
+
if (browser === "chrome") {
|
|
196
|
+
const fn = TOOLS[tool];
|
|
197
|
+
// The registry's value type is the union of all tool signatures; each tool
|
|
198
|
+
// validates its own args at runtime, so we hand the parsed object through.
|
|
199
|
+
const result = await (fn as (args: unknown) => Promise<unknown>)(parsed.args);
|
|
200
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Firefox: one process per invocation. Launch, run exactly one tool call, always dispose,
|
|
205
|
+
// even if the tool call itself throws, so the spawned Firefox process never leaks.
|
|
206
|
+
const neutralFn = FIREFOX_TOOLS[tool];
|
|
207
|
+
if (!neutralFn) {
|
|
208
|
+
process.stderr.write(`${JSON.stringify({ error: `tool '${tool}' has no Firefox implementation wired` })}\n`);
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
const session = await startFirefoxSession();
|
|
212
|
+
try {
|
|
213
|
+
const result = await neutralFn(session.driver, parsed.args as never);
|
|
214
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
215
|
+
return 0;
|
|
216
|
+
} finally {
|
|
217
|
+
await session.dispose();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
195
220
|
}
|
|
196
221
|
|
|
197
222
|
main()
|
package/src/client.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Core raw-CDP client. Zero runtime dependencies
|
|
2
|
+
* Core raw-CDP client. Zero runtime dependencies: uses Node's global
|
|
3
3
|
* WebSocket (Node >= 22; verified on Node 25.9). Every tool module is built on
|
|
4
4
|
* the primitives exported here. See CONTRACT.md.
|
|
5
5
|
*/
|
|
6
6
|
import type { CdpResponse, Target, TargetSelector } from "./types.ts";
|
|
7
|
+
import { assertLeaseOk } from "./leases.ts";
|
|
7
8
|
|
|
8
9
|
/** Base HTTP origin of the DevTools endpoint. Override with CDP_BASE. */
|
|
9
10
|
export const BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
@@ -185,15 +186,32 @@ export function listTargets(): Promise<Target[]> {
|
|
|
185
186
|
return httpJson<Target[]>("/json/list");
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
/** Browser-level WebSocket URL (GET /json/version)
|
|
189
|
+
/** Browser-level WebSocket URL (GET /json/version), for Target.* / Browser.*. */
|
|
189
190
|
export async function browserWsUrl(): Promise<string> {
|
|
190
191
|
const v = await httpJson<{ webSocketDebuggerUrl: string }>("/json/version");
|
|
191
192
|
return v.webSocketDebuggerUrl;
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
/** Resolve a TargetSelector to a concrete page target. See types.ts for grammar.
|
|
195
|
-
|
|
195
|
+
/** Resolve a TargetSelector to a concrete page target. See types.ts for grammar.
|
|
196
|
+
* opts.lease is for library callers; the MCP/CLI path supplies the token through
|
|
197
|
+
* the ambient lease scope instead (see leases.ts). */
|
|
198
|
+
export async function resolveTarget(selector: TargetSelector, opts: { lease?: string } = {}): Promise<Target> {
|
|
196
199
|
const targets = await listTargets();
|
|
200
|
+
const hit = pickTarget(targets, selector);
|
|
201
|
+
// THE Chrome choke point. Every tool reaches a page through here, so a tool
|
|
202
|
+
// added after this shipped is protected with no action from its author.
|
|
203
|
+
//
|
|
204
|
+
// Deliberately NOT passing liveIds. It would be inert here: pickTarget
|
|
205
|
+
// returns an element of `targets`, so hit.id is always a member of that list
|
|
206
|
+
// and the "target-gone" staleness condition could never fire for the very
|
|
207
|
+
// target we just resolved. Passing it would only suggest a check is happening
|
|
208
|
+
// that is not. target-gone earns its keep on claim, and on a list_leases
|
|
209
|
+
// reap, where a record's id may genuinely be absent from the browser.
|
|
210
|
+
await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
|
|
211
|
+
return hit;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pickTarget(targets: Target[], selector: TargetSelector): Target {
|
|
197
215
|
const pages = targets.filter((t) => t.type === "page");
|
|
198
216
|
if (!selector || selector === "active") {
|
|
199
217
|
if (!pages.length) throw new CdpError("no page targets open");
|
|
@@ -230,9 +248,9 @@ export async function openBrowser(opts: { timeoutMs?: number } = {}): Promise<Cd
|
|
|
230
248
|
/** Open a connection to a page target. Returns the connection and the target. */
|
|
231
249
|
export async function openPage(
|
|
232
250
|
selector: TargetSelector,
|
|
233
|
-
opts: { timeoutMs?: number } = {},
|
|
251
|
+
opts: { timeoutMs?: number; lease?: string } = {},
|
|
234
252
|
): Promise<{ conn: CdpConnection; target: Target }> {
|
|
235
|
-
const target = await resolveTarget(selector);
|
|
253
|
+
const target = await resolveTarget(selector, { lease: opts.lease });
|
|
236
254
|
const conn = await new CdpConnection(target.webSocketDebuggerUrl, opts).connect();
|
|
237
255
|
return { conn, target };
|
|
238
256
|
}
|
|
@@ -244,7 +262,7 @@ export async function openPage(
|
|
|
244
262
|
export async function withPage<T>(
|
|
245
263
|
selector: TargetSelector,
|
|
246
264
|
fn: (conn: CdpConnection, target: Target) => Promise<T>,
|
|
247
|
-
opts: { timeoutMs?: number } = {},
|
|
265
|
+
opts: { timeoutMs?: number; lease?: string } = {},
|
|
248
266
|
): Promise<T> {
|
|
249
267
|
const { conn, target } = await openPage(selector, opts);
|
|
250
268
|
try {
|