cdp-toolkit 1.7.0 → 1.8.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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * dispatch-mouse.ts: `dispatch_mouse` over raw CDP, the toolkit's raw mouse-event primitive.
3
+ *
4
+ * WHY THIS IS CHROME-ONLY (capability "input.raw", declared in ../driver.ts /
5
+ * ../cdp/driver.ts), NOT a PageDriver method
6
+ * ============================================================================
7
+ * Every other interaction (click/hover/drag/scroll) is a Driver-neutral, multi-event SEQUENCE:
8
+ * "resolve an element, then press-and-release". dispatch_mouse is the opposite by design — ONE
9
+ * raw event per call, composed by the CALLER into whatever sequence a real mouse could produce
10
+ * (move/down/move/up reaches canvas drag-painting, marquee selection, custom widgets that
11
+ * click/drag's fixed sequences cannot). CDP's Input.dispatchMouseEvent is a natural fit: it is
12
+ * genuinely stateless, so "one event, one call" costs nothing extra.
13
+ *
14
+ * WebDriver BiDi's input.performActions is a worse fit for this SPECIFIC primitive: it models a
15
+ * virtual input DEVICE with session-lifetime state (a pointerDown from one call leaves the button
16
+ * "held" for a later call), not a raw event dispatch, and per the 1.8.0 spec (Track P1 §2) that
17
+ * mismatch — plus this driver not having been verified against real Firefox for the raw primitive
18
+ * this pass — earns the explicit "Chrome-only capability... unless bidi is trivial" fallback,
19
+ * exactly like start_screen_recording. tools/list omits dispatch_mouse under --browser firefox
20
+ * (ADR-001: absent, never present-and-throwing); nothing in this file runs under that backend.
21
+ *
22
+ * Lease gate: `withPage` (client.ts) resolves through `resolveTarget`, which calls
23
+ * `assertLeaseOk("chrome", ...)` — the same choke point click/hover/drag/scroll go through via
24
+ * resolvePage (shared-tools.ts). No tool here bypasses it.
25
+ */
26
+ import { sendInput } from "../activity.ts";
27
+ import { withPage } from "../client.ts";
28
+ import type { TargetSelector } from "../types.ts";
29
+
30
+ const CLICK_MODIFIER_BITS: Record<string, number> = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
31
+ const BUTTONS = new Set(["left", "right", "middle"]);
32
+ const CDP_EVENT_TYPE: Record<"move" | "down" | "up", string> = {
33
+ move: "mouseMoved",
34
+ down: "mousePressed",
35
+ up: "mouseReleased",
36
+ };
37
+
38
+ export interface DispatchMouseArgs {
39
+ target?: TargetSelector;
40
+ /** "move" (mouseMoved), "down" (mousePressed), or "up" (mouseReleased). */
41
+ action: "move" | "down" | "up";
42
+ /** Viewport-space coordinates. Required on every event: CDP has no notion of "current pointer
43
+ * position" to default from. */
44
+ x: number;
45
+ y: number;
46
+ /** Mouse button: 'left' (default), 'right', or 'middle'. */
47
+ button?: "left" | "right" | "middle";
48
+ /** Click-run length for a down/up pair (2 = the second half of a double-click). Ignored for 'move'. */
49
+ clickCount?: number;
50
+ /** "Alt" | "Control" | "Meta" | "Shift", held for this one event. Same bit values as click's
51
+ * modifiers (cdp/driver.ts's inputModifierBits): Alt=1, Control=2, Meta=4, Shift=8. */
52
+ modifiers?: string[];
53
+ }
54
+
55
+ export interface DispatchMouseResult {
56
+ dispatched: "move" | "down" | "up";
57
+ x: number;
58
+ y: number;
59
+ }
60
+
61
+ /** CDP modifier-bit math for the closed Alt/Control/Meta/Shift enum. Exported for the unit tests;
62
+ * intentionally duplicated from (not imported from) cdp/driver.ts's identically-named constant,
63
+ * so this chrome-only tool module has no dependency on the Driver-abstraction layer it sits
64
+ * beside, matching heap.ts/screencast.ts's existing raw-CDP module boundary. */
65
+ export function modifierBits(mods?: readonly string[]): number {
66
+ let bits = 0;
67
+ for (const m of mods ?? []) {
68
+ const bit = CLICK_MODIFIER_BITS[m];
69
+ if (bit === undefined) throw new Error(`dispatch_mouse: unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
70
+ bits |= bit;
71
+ }
72
+ return bits;
73
+ }
74
+
75
+ /** Argument validation, split out and exported so the unit tests can pin every refusal without a
76
+ * browser. Throws on the first violation; returns nothing (the caller re-reads `args`). */
77
+ export function validateDispatchMouseArgs(args: DispatchMouseArgs): void {
78
+ if (args.action !== "move" && args.action !== "down" && args.action !== "up") {
79
+ throw new Error("dispatch_mouse requires { action } of 'move', 'down', or 'up'");
80
+ }
81
+ if (typeof args.x !== "number" || typeof args.y !== "number" || !Number.isFinite(args.x) || !Number.isFinite(args.y)) {
82
+ throw new Error("dispatch_mouse requires numeric { x } and { y } on every call");
83
+ }
84
+ const button = args.button ?? "left";
85
+ if (!BUTTONS.has(button)) throw new Error(`dispatch_mouse: unknown button '${button}' (use left, right, or middle)`);
86
+ }
87
+
88
+ /**
89
+ * Dispatch exactly one raw mouse event. Composability is the point: a caller reaches anything a
90
+ * physical mouse can by sequencing move/down/move/up calls itself (canvas drag-painting, marquee
91
+ * selection, a custom widget that click/drag's fixed sequences cannot drive).
92
+ */
93
+ export async function dispatchMouse(args: DispatchMouseArgs): Promise<DispatchMouseResult> {
94
+ validateDispatchMouseArgs(args);
95
+ const button = args.button ?? "left";
96
+ const modifiers = modifierBits(args.modifiers);
97
+ return withPage(args.target, async (conn, target) => {
98
+ const params: Record<string, unknown> = { type: CDP_EVENT_TYPE[args.action], x: args.x, y: args.y, button, modifiers };
99
+ if (args.action !== "move") params.clickCount = args.clickCount ?? 1;
100
+ // Through activity.ts's sendInput, NOT conn.send: this event has to land in the dispatch log or
101
+ // the activity beacon reads the toolkit's own mouse as a human's and warns about contention with
102
+ // itself. sendInput is the shared writer CdpPageDriver.dispatchInput also delegates to — the one
103
+ // import this module takes from outside its own raw-CDP boundary, and the reason the file header's
104
+ // "not a PageDriver method" argument costs nothing in bookkeeping.
105
+ await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", params);
106
+ return { dispatched: args.action, x: args.x, y: args.y };
107
+ });
108
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * downloads.ts: `wait_for_download` — block until a file download finishes, then hand back a real
3
+ * path on disk with the page's own filename (1.8.0 Track P3).
4
+ *
5
+ * WHY THIS IS CHROME-ONLY (capability "browser.downloads"), NOT a PageDriver method
6
+ * ================================================================================
7
+ * A download is browser-scoped, not page-scoped: Browser.setDownloadBehavior, Browser.downloadWillBegin
8
+ * and Browser.downloadProgress all live on the CDP browser endpoint, and the download a caller
9
+ * cares about may be started by a click in one tab and finish while another is focused. There is no
10
+ * page handle to hang it off, so it stays out of the Driver interface entirely, exactly like
11
+ * dispatch-mouse.ts and screencast.ts. WebDriver BiDi has browsingContext.downloadWillBegin and
12
+ * downloadEnd events but NO command to redirect a download to a directory of our choosing, so
13
+ * Firefox cannot deliver the "here is the file" half of this tool at all: per ADR-001 it is absent
14
+ * from tools/list under --browser firefox rather than present and throwing.
15
+ *
16
+ * THE ORDERING RULE, and why it is a rule rather than a wart
17
+ * ---------------------------------------------------------
18
+ * Download capture must be ARMED BEFORE the click that starts the download. That is not a design
19
+ * preference; it is Chrome's behavior, measured (see browser-session.ts's header for the three
20
+ * controls): the download-behavior override is per-client state, reverted the moment the arming
21
+ * connection disconnects, and unarmed headless Chrome denies the download outright. So:
22
+ *
23
+ * wait_for_download { arm: true } <- arms, returns immediately, connection stays open
24
+ * click { selector: "#export" } <- the download starts and COMPLETES, captured meanwhile
25
+ * wait_for_download {} <- returns the file that finished during the click call
26
+ *
27
+ * The last step works because the standing connection buffered the completion while no tool call
28
+ * was running. A caller who does not want two round trips can also do click-then-wait in the other
29
+ * order (`wait_for_download` arms on every call), but only if something armed earlier in the same
30
+ * server process — the FIRST download of a process must be preceded by an arm.
31
+ *
32
+ * Lease gate: this tool resolves `target` through client.ts's resolveTarget, which calls
33
+ * assertLeaseOk — the same choke point every other Chrome tool passes through. It resolves a target
34
+ * even though the CDP work is browser-scoped precisely so that gate is not skipped, and so the
35
+ * result names the tab the caller believed it was working in.
36
+ */
37
+ import { readdir, rename, stat } from "node:fs/promises";
38
+ import { basename, extname, join } from "node:path";
39
+ import { resolveTarget } from "../client.ts";
40
+ import type { TargetSelector } from "../types.ts";
41
+ import { DOWNLOADS_DIR, armDownloads, nextCompletedDownload, pendingDownloads } from "./browser-session.ts";
42
+
43
+ export interface WaitForDownloadArgs {
44
+ target?: TargetSelector;
45
+ /** Arm download capture and return immediately, without waiting for a download. */
46
+ arm?: boolean;
47
+ /** How long to wait for a download to complete. Default 30000. */
48
+ timeoutMs?: number;
49
+ }
50
+
51
+ export interface WaitForDownloadResult {
52
+ path: string;
53
+ suggestedFilename: string;
54
+ bytes: number;
55
+ url?: string;
56
+ target: { id: string; url: string; title: string };
57
+ }
58
+
59
+ export interface ArmDownloadResult {
60
+ armed: true;
61
+ downloadPath: string;
62
+ /** Completed downloads already buffered and not yet collected, so an arm call is also a peek. */
63
+ pending: number;
64
+ target: { id: string; url: string; title: string };
65
+ }
66
+
67
+ /**
68
+ * A page-supplied filename reduced to something safe to join onto a directory.
69
+ *
70
+ * `suggestedFilename` comes from the page (an `<a download>` attribute or a Content-Disposition
71
+ * header), so it is ATTACKER-CONTROLLED in the ordinary case of driving an untrusted site. A name
72
+ * like "../../../../etc/cron.d/x" or "/etc/passwd" would otherwise be joined straight onto the
73
+ * downloads directory and escape it. Everything up to the last separator is discarded, then the
74
+ * traversal and hidden-file edge cases ("", ".", "..") fall back to a fixed name. Pure, and
75
+ * exported so every one of those cases is pinned by a unit test.
76
+ */
77
+ export function safeFilename(suggested: string | undefined): string {
78
+ const raw = (suggested ?? "").replace(/[\u0000-\u001f]/g, "");
79
+ // basename() on the platform separator, plus an explicit backslash split, so a Windows-style
80
+ // path in a header cannot survive on a POSIX host where basename() would treat it as one name.
81
+ const name = basename(raw.split("\\").pop() ?? "").trim();
82
+ if (!name || name === "." || name === "..") return "download";
83
+ return name;
84
+ }
85
+
86
+ /**
87
+ * `name`, or the first "name-N.ext" that is not already taken. Suffixed before the extension so a
88
+ * collided "report.csv" stays a .csv file, which is the difference between the caller's next tool
89
+ * opening it and not.
90
+ */
91
+ export function nextFreeName(name: string, taken: ReadonlySet<string>): string {
92
+ if (!taken.has(name)) return name;
93
+ const ext = extname(name);
94
+ const stem = ext ? name.slice(0, -ext.length) : name;
95
+ for (let i = 1; ; i++) {
96
+ const candidate = `${stem}-${i}${ext}`;
97
+ if (!taken.has(candidate)) return candidate;
98
+ }
99
+ }
100
+
101
+ /** Validation split out so the refusals are unit-testable without a browser. */
102
+ export function validateWaitForDownloadArgs(args: WaitForDownloadArgs): void {
103
+ if (args.timeoutMs !== undefined) {
104
+ if (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
105
+ throw new Error(`wait_for_download: 'timeoutMs' must be a positive number (got ${JSON.stringify(args.timeoutMs)})`);
106
+ }
107
+ }
108
+ if (args.arm !== undefined && typeof args.arm !== "boolean") {
109
+ throw new Error(`wait_for_download: 'arm' must be a boolean (got ${JSON.stringify(args.arm)})`);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Wait for the next completed download and return it as a named file on disk.
115
+ *
116
+ * With `arm:true` this only arms capture and returns — no waiting, no file. See the file header for
117
+ * why arming has to happen before the triggering click.
118
+ */
119
+ export async function waitForDownload(args: WaitForDownloadArgs = {}): Promise<WaitForDownloadResult | ArmDownloadResult> {
120
+ validateWaitForDownloadArgs(args);
121
+ // The lease gate, before any browser state is touched.
122
+ const t = await resolveTarget(args.target);
123
+ const target = { id: t.id, url: t.url, title: t.title };
124
+ const { downloadPath } = await armDownloads();
125
+ if (args.arm === true) return { armed: true, downloadPath, pending: pendingDownloads().length, target };
126
+
127
+ const rec = await nextCompletedDownload(args.timeoutMs ?? 30_000);
128
+ if (!rec.filePath) {
129
+ throw new Error(`wait_for_download: download ${rec.guid} completed without a file path (nothing to return)`);
130
+ }
131
+ const suggestedFilename = safeFilename(rec.suggestedFilename);
132
+ // Read the directory at rename time, not earlier: another download may have landed while this
133
+ // call was waiting, and the collision set has to reflect the directory as it is now.
134
+ const taken = new Set(await readdir(DOWNLOADS_DIR).catch(() => [] as string[]));
135
+ taken.delete(basename(rec.filePath)); // our own guid file is not a collision with itself
136
+ const finalName = nextFreeName(suggestedFilename, taken);
137
+ const path = join(DOWNLOADS_DIR, finalName);
138
+ await rename(rec.filePath, path);
139
+ // Size from the file itself, not from the event's receivedBytes: the file on disk is what the
140
+ // caller is about to read, and it is the only number that cannot disagree with it.
141
+ const bytes = (await stat(path)).size;
142
+ return { path, suggestedFilename, bytes, ...(rec.url ? { url: rec.url } : {}), target };
143
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * permissions.ts: `grant_permissions` — answer the browser's permission prompts up front, so a page
3
+ * that asks for geolocation / notifications / clipboard gets a decision instead of a modal an
4
+ * agent cannot click (1.8.0 Track P3).
5
+ *
6
+ * WHY THIS IS CHROME-ONLY (capability "browser.permissions"), NOT a PageDriver method
7
+ * ==================================================================================
8
+ * Browser.grantPermissions and Browser.resetPermissions are browser-endpoint commands keyed by
9
+ * ORIGIN, not by tab: a grant made "for" one tab applies to every tab on that origin, including
10
+ * ones opened later. There is no page-scoped concept to put on the Driver interface, so this stays
11
+ * a raw-CDP module like dispatch-mouse.ts. WebDriver BiDi's permission handling lives in an
12
+ * optional module this driver does not negotiate, so under Firefox the tool is absent from
13
+ * tools/list per ADR-001 rather than present and throwing.
14
+ *
15
+ * THE STANDING-CONNECTION REQUIREMENT (measured, not assumed)
16
+ * ----------------------------------------------------------
17
+ * A grant is bound to the CDP client that issued it and is REVERTED when that client disconnects.
18
+ * Measured on isolated headless Chrome 151: grant geolocation for an origin, close the granting
19
+ * connection, then query `navigator.permissions.query({name:"geolocation"}).state` over a fresh
20
+ * connection — the answer is "prompt", not "granted". Identical result granting from the page
21
+ * endpoint instead of the browser endpoint. A per-call implementation of this tool would therefore
22
+ * report `{granted:["geolocation"]}` and leave the browser in exactly the state it started in,
23
+ * which is the worst possible outcome: a caller acting on a grant that does not exist.
24
+ *
25
+ * So every command here goes through browser-session.ts's standing connection, and the grant lasts
26
+ * as long as that connection does — the life of the MCP server process. Under the stateless CLI
27
+ * (one process per command) the grant dies with the process, which makes this an MCP-server
28
+ * capability like network_mock.ts's fake backends. That limit is stated in the manifest description
29
+ * rather than papered over.
30
+ *
31
+ * Lease gate: `target` is resolved through client.ts's resolveTarget (which calls assertLeaseOk)
32
+ * before anything is granted — both to keep the gate on the path and because the resolved tab's URL
33
+ * is what `origin` defaults to.
34
+ */
35
+ import { resolveTarget } from "../client.ts";
36
+ import type { TargetSelector } from "../types.ts";
37
+ import { browserSend } from "./browser-session.ts";
38
+
39
+ export interface GrantPermissionsArgs {
40
+ target?: TargetSelector;
41
+ /** CDP PermissionType values, e.g. "geolocation", "notifications", "clipboardReadWrite". */
42
+ permissions?: string[];
43
+ /** Origin the grant applies to, e.g. "https://example.com". Defaults to the target tab's origin. */
44
+ origin?: string;
45
+ /** Reset every permission this session granted before granting (or, with no `permissions`, instead). */
46
+ reset?: boolean;
47
+ }
48
+
49
+ export interface GrantPermissionsResult {
50
+ granted?: string[];
51
+ origin?: string;
52
+ reset?: true;
53
+ target: { id: string; url: string; title: string };
54
+ }
55
+
56
+ /**
57
+ * The origin a permission grant should default to for a tab at `url`, or undefined when the tab has
58
+ * no grantable origin.
59
+ *
60
+ * `data:`, `blob:`, `about:blank` and friends all serialize to the opaque origin "null", which
61
+ * Browser.grantPermissions cannot key on. Returning undefined (rather than the string "null") is
62
+ * what lets the caller be told to pass `origin` explicitly, instead of getting a protocol error
63
+ * about a value they never supplied. Pure, and exported for the unit tests.
64
+ */
65
+ export function originOf(url: string): string | undefined {
66
+ try {
67
+ const o = new URL(url).origin;
68
+ return o && o !== "null" ? o : undefined;
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ /** Validation split out so every refusal is pinned by a unit test without a browser. */
75
+ export function validateGrantPermissionsArgs(args: GrantPermissionsArgs): void {
76
+ const reset = args.reset === true;
77
+ if (args.reset !== undefined && typeof args.reset !== "boolean") {
78
+ throw new Error(`grant_permissions: 'reset' must be a boolean (got ${JSON.stringify(args.reset)})`);
79
+ }
80
+ if (args.permissions === undefined) {
81
+ // reset-only is the one legal way to call this with nothing to grant.
82
+ if (!reset) throw new Error("grant_permissions: 'permissions' is required (or pass reset:true to clear previous grants)");
83
+ return;
84
+ }
85
+ if (!Array.isArray(args.permissions)) {
86
+ throw new Error(`grant_permissions: 'permissions' must be an array of CDP PermissionType strings (got ${JSON.stringify(args.permissions)})`);
87
+ }
88
+ // An empty array is refused rather than treated as "grant nothing": it is a caller who built the
89
+ // list dynamically and got zero entries, and answering {granted:[]} would report success for a
90
+ // call that changed nothing.
91
+ if (args.permissions.length === 0 && !reset) {
92
+ throw new Error("grant_permissions: 'permissions' must not be empty (pass reset:true if the intent is to clear grants)");
93
+ }
94
+ for (const p of args.permissions) {
95
+ if (typeof p !== "string" || p.length === 0) {
96
+ throw new Error(`grant_permissions: every entry of 'permissions' must be a non-empty string (got ${JSON.stringify(p)})`);
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Grant browser permissions for an origin, and/or reset previously granted ones.
103
+ *
104
+ * `reset:true` with `permissions` resets FIRST and then grants, so the result is exactly the listed
105
+ * permissions rather than the listed ones unioned with whatever was granted earlier. Note that
106
+ * Browser.resetPermissions takes no origin — CDP resets this client's grants for every origin at
107
+ * once — so a reset is not scoped to `origin` even though a grant is.
108
+ */
109
+ export async function grantPermissions(args: GrantPermissionsArgs = {}): Promise<GrantPermissionsResult> {
110
+ validateGrantPermissionsArgs(args);
111
+ const t = await resolveTarget(args.target);
112
+ const target = { id: t.id, url: t.url, title: t.title };
113
+ const reset = args.reset === true;
114
+ const permissions = args.permissions ?? [];
115
+
116
+ if (reset) await browserSend("Browser.resetPermissions", {});
117
+ if (permissions.length === 0) return { reset: true, target };
118
+
119
+ const origin = args.origin ?? originOf(t.url);
120
+ if (!origin) {
121
+ throw new Error(
122
+ `grant_permissions: cannot derive an origin from the target tab's url (${JSON.stringify(t.url)}); pass 'origin' explicitly, e.g. origin:"https://example.com"`,
123
+ );
124
+ }
125
+ // Chrome rejects an unknown PermissionType by name ("Unknown permission type: ..."), so a typo
126
+ // surfaces as a clear protocol error rather than a silently-ignored entry. No local allowlist is
127
+ // kept here: it would go stale against Chrome's own enum and start refusing valid permissions.
128
+ await browserSend("Browser.grantPermissions", { origin, permissions });
129
+ return { granted: [...permissions], origin, ...(reset ? { reset: true as const } : {}), target };
130
+ }