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.
- package/CHANGELOG.md +284 -0
- package/CONTRACT.md +5 -1
- package/README.md +35 -13
- package/dist/cli.js +973 -56
- package/dist/index.js +834 -44
- package/dist/mcp.js +1213 -66
- package/package.json +5 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +231 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +472 -25
- package/src/driver.ts +161 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +125 -4
- package/src/leases.ts +1 -1
- package/src/manifest.ts +239 -10
- package/src/mcp.ts +6 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +268 -27
- 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
|
@@ -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
|
+
}
|