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.
- package/CHANGELOG.md +403 -0
- package/CONTRACT.md +9 -1
- package/README.md +58 -17
- package/dist/cli.js +1302 -158
- package/dist/index.js +1144 -145
- package/dist/mcp.js +1620 -244
- package/package.json +6 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +259 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +487 -25
- package/src/cdp/workers.ts +177 -0
- package/src/cli.ts +3 -1
- package/src/client.ts +52 -2
- package/src/driver.ts +187 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +127 -6
- package/src/leases.ts +1 -1
- package/src/manifest.ts +279 -46
- package/src/mcp.ts +6 -1
- package/src/origins.ts +41 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +372 -30
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
- package/src/types.ts +20 -0
- package/src/workers.ts +159 -0
|
@@ -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
|
|