cdp-toolkit 1.7.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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * browser-session.ts: ONE standing connection to the CDP **browser** endpoint, held open across
3
+ * tool calls, owning the browser-client-scoped state behind `wait_for_download` and
4
+ * `grant_permissions` (1.8.0 Track P3).
5
+ *
6
+ * WHY A STANDING CONNECTION IS NOT OPTIONAL HERE
7
+ * ==============================================
8
+ * The 1.8.0 spec described wait_for_download as "the server subscribes to
9
+ * Browser.downloadWillBegin/downloadProgress and keeps a ring buffer", which silently assumes a
10
+ * connection that outlives a tool call. This toolkit's CDP lifetime is per-call (ADR-001,
11
+ * driver.ts: "per-call": acquiring opens a transport and release() closes it), so that assumption
12
+ * had to be checked rather than inherited — the same spec had already shipped one false
13
+ * protocol assertion that Track S1 caught the same way.
14
+ *
15
+ * MEASURED, against an isolated headless Chrome 151 on macOS (three controls, one variable — the
16
+ * arming client's liveness — with the click, the page, and the download path held identical):
17
+ *
18
+ * A. armer connection OPEN across the click .............. BOTH downloads landed in downloadPath;
19
+ * Browser.downloadWillBegin + downloadProgress{state:"completed"} arrived on the armer.
20
+ * B. armer connection CLOSED before the click ............ ZERO files. No events anywhere.
21
+ * C. armed from the PAGE endpoint, then closed .......... ZERO files.
22
+ *
23
+ * So `Browser.setDownloadBehavior{behavior:"allowAndName", downloadPath, eventsEnabled:true}` does
24
+ * NOT persist browser-side after the arming client disconnects: it is per-client state that Chrome
25
+ * reverts on disconnect, and headless Chrome's default is to deny downloads, so the file does not
26
+ * merely go somewhere else — it never happens at all.
27
+ *
28
+ * The same experiment run for permissions gives the same verdict: `Browser.grantPermissions` on
29
+ * either endpoint, followed by that connection closing, leaves
30
+ * `navigator.permissions.query({name:"geolocation"}).state` at "prompt", never "granted". Both
31
+ * features therefore need a connection someone keeps open. That is this module.
32
+ *
33
+ * WHAT A DIRECTORY WATCHER WOULD AND WOULD NOT BUY. Watching downloadPath for a new file (and its
34
+ * .crdownload disappearing) was the obvious per-call fallback, and it does not work on its own:
35
+ * with nothing armed, the download is denied and the directory stays empty forever, so the watcher
36
+ * would poll a directory that can never fill. Once something IS armed, the standing connection
37
+ * already receives `Browser.downloadProgress{state:"completed", filePath}` — which carries the
38
+ * finished path directly, so there is nothing left for a watcher to discover. It is not implemented
39
+ * because it would be strictly redundant, not because it was overlooked.
40
+ *
41
+ * LIFETIME AND ITS HONEST LIMIT. The connection is opened LAZILY, on the first call to one of the
42
+ * two tools, so a session that never downloads and never grants permissions opens nothing and pays
43
+ * nothing. It then lives until the process exits. Under the MCP server (a long-lived process) that
44
+ * spans many tool calls, which is what makes "arm, click in a later call, wait in a later call
45
+ * still" work. Under the CLI — one process per command — it dies at process exit, so both tools are
46
+ * MCP-server capabilities, exactly like network_mock.ts's persistent fake backends and recorder.ts's
47
+ * background mode, and for exactly the same reason.
48
+ *
49
+ * If the connection dies (browser exited, socket dropped), the arm and every grant made through it
50
+ * are gone with it; the session is marked dead and the next call opens a fresh one and RE-ARMS the
51
+ * download behavior it remembers. Permission grants cannot be replayed that way — this module never
52
+ * pretends a grant survived a reconnect, because a caller told "granted" for a permission the
53
+ * browser has since forgotten would act on it.
54
+ */
55
+ import { mkdir } from "node:fs/promises";
56
+ import { join } from "node:path";
57
+ import { openBrowser, type CdpConnection } from "../client.ts";
58
+
59
+ /** Artifact root, captured at import exactly like every other module here (screencast, heap, ...). */
60
+ const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
61
+
62
+ /** Where armed downloads are written, before wait_for_download renames each guid to its filename. */
63
+ export const DOWNLOADS_DIR = join(ARTIFACT_DIR, "downloads");
64
+
65
+ /** How many finished downloads the ring buffer keeps. Same intent as recorder.ts's buffers: enough
66
+ * that a caller who triggered several downloads before waiting gets them all, bounded so a
67
+ * long-lived server cannot grow this without limit. */
68
+ export const DOWNLOAD_BUFFER_LIMIT = 50;
69
+
70
+ /** One download, as the standing connection observed it. */
71
+ export interface DownloadRecord {
72
+ guid: string;
73
+ /** Source URL, from Browser.downloadWillBegin. */
74
+ url?: string;
75
+ /** The page's own name for the file (the `download` attribute / Content-Disposition). UNTRUSTED:
76
+ * it is page-controlled, so downloads.ts sanitizes it before it ever reaches a filesystem path. */
77
+ suggestedFilename?: string;
78
+ /** Bytes received, from the terminal Browser.downloadProgress. */
79
+ bytes?: number;
80
+ /** Absolute path Chrome wrote, straight off the completed event — never reconstructed by us. */
81
+ filePath?: string;
82
+ state: "inProgress" | "completed" | "canceled";
83
+ /** True once a wait_for_download call has returned this record, so two waits never answer with
84
+ * the same download. */
85
+ consumed: boolean;
86
+ }
87
+
88
+ interface BrowserSession {
89
+ conn: CdpConnection;
90
+ /** The path passed to setDownloadBehavior on this connection; undefined until armed. */
91
+ armedPath?: string;
92
+ downloads: DownloadRecord[];
93
+ /** Resolvers waiting for the next completed download, in FIFO order. */
94
+ waiters: Array<(r: DownloadRecord) => void>;
95
+ dead: boolean;
96
+ }
97
+
98
+ let session: BrowserSession | undefined;
99
+
100
+ /** True for the errors that mean "this socket is gone", not "the command was rejected". */
101
+ function isDisconnect(e: unknown): boolean {
102
+ const m = e instanceof Error ? e.message : String(e);
103
+ return /connection (not open|closed)|closed by caller|websocket|ECONNREFUSED|socket/i.test(m);
104
+ }
105
+
106
+ function record(s: BrowserSession, guid: string): DownloadRecord {
107
+ let r = s.downloads.find((d) => d.guid === guid);
108
+ if (!r) {
109
+ r = { guid, state: "inProgress", consumed: false };
110
+ s.downloads.push(r);
111
+ // Drop the OLDEST already-consumed record first, and only fall back to dropping an unconsumed
112
+ // one when the buffer is full of them: evicting a download nobody has collected yet is how a
113
+ // caller silently loses a file they are about to ask for.
114
+ while (s.downloads.length > DOWNLOAD_BUFFER_LIMIT) {
115
+ const i = s.downloads.findIndex((d) => d.consumed);
116
+ s.downloads.splice(i >= 0 ? i : 0, 1);
117
+ }
118
+ }
119
+ return r;
120
+ }
121
+
122
+ /** Wire the download event handlers onto a freshly opened connection. */
123
+ function subscribe(s: BrowserSession): void {
124
+ s.conn.on("Browser.downloadWillBegin", (p) => {
125
+ const r = record(s, String(p.guid));
126
+ if (typeof p.url === "string") r.url = p.url;
127
+ if (typeof p.suggestedFilename === "string") r.suggestedFilename = p.suggestedFilename;
128
+ });
129
+ s.conn.on("Browser.downloadProgress", (p) => {
130
+ const r = record(s, String(p.guid));
131
+ if (typeof p.receivedBytes === "number") r.bytes = p.receivedBytes;
132
+ if (typeof p.filePath === "string") r.filePath = p.filePath;
133
+ const state = String(p.state);
134
+ if (state === "completed" || state === "canceled") {
135
+ r.state = state;
136
+ // Hand a completed download straight to the longest-waiting caller. Marking it consumed HERE
137
+ // (not in the waiter) closes the race where a second waiter, scheduled between this callback
138
+ // and the first waiter resuming, scans the buffer and claims the same record.
139
+ if (state === "completed") {
140
+ const waiter = s.waiters.shift();
141
+ if (waiter) {
142
+ r.consumed = true;
143
+ waiter(r);
144
+ }
145
+ }
146
+ }
147
+ });
148
+ }
149
+
150
+ /**
151
+ * The standing connection, opened on first use and reused forever after. Re-arms the download
152
+ * behavior when a dead connection is replaced, so a browser restart does not silently leave the
153
+ * caller unarmed while every call still reports success.
154
+ */
155
+ export async function getBrowserSession(): Promise<BrowserSession> {
156
+ if (session && !session.dead) return session;
157
+ const previousPath = session?.armedPath;
158
+ const conn = await openBrowser();
159
+ const s: BrowserSession = { conn, downloads: [], waiters: [], dead: false };
160
+ subscribe(s);
161
+ session = s;
162
+ if (previousPath) await armDownloadsAt(previousPath);
163
+ return s;
164
+ }
165
+
166
+ /** Send on the standing connection, marking the session dead if the socket turned out to be gone. */
167
+ export async function browserSend<T = Record<string, unknown>>(method: string, params: Record<string, unknown> = {}): Promise<T> {
168
+ const s = await getBrowserSession();
169
+ try {
170
+ return await s.conn.send<T>(method, params);
171
+ } catch (e) {
172
+ if (isDisconnect(e)) {
173
+ s.dead = true;
174
+ // One retry on a fresh connection: a socket that died between calls is the expected case
175
+ // (the browser was restarted), not an error the caller can act on.
176
+ const fresh = await getBrowserSession();
177
+ return await fresh.conn.send<T>(method, params);
178
+ }
179
+ throw e;
180
+ }
181
+ }
182
+
183
+ /** Point Chrome's downloads at `path` on the standing connection, creating the directory first. */
184
+ async function armDownloadsAt(path: string): Promise<void> {
185
+ await mkdir(path, { recursive: true });
186
+ const s = await getBrowserSession();
187
+ await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
188
+ s.armedPath = path;
189
+ }
190
+
191
+ /**
192
+ * Ensure downloads are captured to DOWNLOADS_DIR. Idempotent and cheap to repeat: re-issuing
193
+ * setDownloadBehavior with identical parameters is accepted by Chrome (verified), so every
194
+ * wait_for_download call can arm without tracking whether some earlier call already did.
195
+ */
196
+ export async function armDownloads(): Promise<{ downloadPath: string }> {
197
+ const s = await getBrowserSession();
198
+ if (s.armedPath !== DOWNLOADS_DIR) await armDownloadsAt(DOWNLOADS_DIR);
199
+ return { downloadPath: DOWNLOADS_DIR };
200
+ }
201
+
202
+ /** Completed-but-uncollected downloads, oldest first. */
203
+ export function pendingDownloads(): DownloadRecord[] {
204
+ return (session?.downloads ?? []).filter((d) => d.state === "completed" && !d.consumed);
205
+ }
206
+
207
+ /**
208
+ * The next completed download nobody has collected: one already buffered, else the next one to
209
+ * finish, else a timeout. Buffered-first is what makes "click in one tool call, wait in a later
210
+ * one" work — the standing connection recorded the completion while no tool call was running.
211
+ */
212
+ export async function nextCompletedDownload(timeoutMs: number): Promise<DownloadRecord> {
213
+ const s = await getBrowserSession();
214
+ const buffered = s.downloads.find((d) => d.state === "completed" && !d.consumed);
215
+ if (buffered) {
216
+ buffered.consumed = true;
217
+ return buffered;
218
+ }
219
+ return new Promise<DownloadRecord>((resolve, reject) => {
220
+ const waiter = (r: DownloadRecord): void => {
221
+ clearTimeout(timer);
222
+ resolve(r);
223
+ };
224
+ const timer = setTimeout(() => {
225
+ const i = s.waiters.indexOf(waiter);
226
+ if (i >= 0) s.waiters.splice(i, 1);
227
+ reject(new Error(
228
+ `wait_for_download: no download completed within ${timeoutMs}ms. Downloads are only captured while this server holds the arming connection, so the triggering click must come AFTER a wait_for_download call (use arm:true to arm without waiting).`,
229
+ ));
230
+ }, timeoutMs);
231
+ s.waiters.push(waiter);
232
+ });
233
+ }
234
+
235
+ /** Close the standing connection. For process shutdown and tests; safe to call when none is open. */
236
+ export async function disposeBrowserSession(): Promise<void> {
237
+ const s = session;
238
+ session = undefined;
239
+ if (!s) return;
240
+ s.dead = true;
241
+ try {
242
+ s.conn.close();
243
+ } catch {
244
+ /* already gone */
245
+ }
246
+ }
@@ -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
+ }
package/src/types.ts CHANGED
@@ -41,6 +41,26 @@ export interface CdpEvent<P = Record<string, unknown>> {
41
41
  * - "index:N" -> Nth page-type target (0-based)
42
42
  * - "url:<substring>" -> first page whose url contains substring
43
43
  * - "title:<substring>" -> first page whose title contains substring
44
+ * - "label:<name>" -> the page-type target whose label EXACTLY
45
+ * matches `name`, checked against both the origin ledger (agent-created
46
+ * tabs, see src/origins.ts) and live lease records (covers a taken-over
47
+ * tab claimed with a label, which has no origin record — see
48
+ * src/leases-tools.ts's claimPage). The resolved id must be present in
49
+ * the listing this call is resolving against; a label whose tab is gone
50
+ * falls through to the normal no-match error. A miss enumerates the
51
+ * labels that DO exist; two live targets sharing a label is an ambiguity
52
+ * error naming both ids, never a silent first-match. See pickPage in
53
+ * src/shared-tools.ts.
54
+ * - "worker:<substring>" -> a service_worker/shared_worker target whose
55
+ * URL contains the substring (an MV3 extension's background worker is
56
+ * `chrome-extension://<id>/<script>`, so both the extension id and the
57
+ * script name match). NOT UNIVERSAL, unlike every arm above it: resolved
58
+ * only by Chrome's pickTarget (this file's resolveTarget), accepted only by
59
+ * evaluate_script, refused with a capability error on Firefox and by the
60
+ * page-only resolvers. It reads the UNFILTERED target listing, because a
61
+ * worker is not a page target — and a worker that is idle-evicted is in no
62
+ * listing at all, which is what evaluate_script's `wake` exists for. See
63
+ * src/workers.ts and src/cdp/workers.ts.
44
64
  */
45
65
  export type TargetSelector = string | undefined;
46
66