cdp-toolkit 1.1.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +203 -9
- package/CONTRACT.md +7 -5
- package/README.md +113 -33
- package/dist/cli.js +764 -64
- package/dist/index.js +650 -42
- package/dist/mcp.js +1091 -77
- package/package.json +4 -3
- package/src/bidi/driver.ts +167 -4
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +145 -1
- package/src/cli.ts +47 -22
- package/src/client.ts +25 -7
- package/src/driver.ts +107 -1
- package/src/firefox-tools.ts +6 -3
- package/src/index.ts +14 -2
- package/src/leases-tools.ts +107 -0
- package/src/leases.ts +541 -0
- package/src/manifest.ts +339 -15
- package/src/mcp.ts +27 -19
- package/src/origins.ts +266 -0
- package/src/shared-tools.ts +365 -14
- package/src/tools/console.ts +3 -3
- package/src/tools/evaluate.ts +2 -2
- package/src/tools/heap.ts +3 -3
- package/src/tools/lighthouse.ts +4 -4
- package/src/tools/navigation.ts +1 -1
- package/src/tools/network.ts +7 -7
- package/src/tools/network_mock.ts +8 -8
- package/src/tools/performance.ts +11 -11
- package/src/tools/recorder.ts +5 -5
- package/src/tools/snapshot.ts +2 -2
package/src/shared-tools.ts
CHANGED
|
@@ -33,11 +33,13 @@
|
|
|
33
33
|
* behavior outranks the abstraction, these 7 stay two implementations; see the migration report.
|
|
34
34
|
*/
|
|
35
35
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
36
|
-
import { join } from "node:path";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
37
|
import type {
|
|
38
|
-
BrowserDriver, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
|
|
38
|
+
BrowserCookie, BrowserDriver, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
|
|
39
39
|
} from "./driver.ts";
|
|
40
40
|
import type { TargetSelector } from "./types.ts";
|
|
41
|
+
import { assertLeaseOk, claimLease, defaultLabel, leaseTtlMs, releaseLeaseFor, type LeaseBackend, type LeaseToken } from "./leases.ts";
|
|
42
|
+
import { newTrackedPage, originIndex, type PageOrigin } from "./origins.ts";
|
|
41
43
|
|
|
42
44
|
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
43
45
|
const STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
@@ -49,6 +51,12 @@ function target3(p: PageInfo): { id: string; url: string; title: string } {
|
|
|
49
51
|
return { id: p.id, url: p.url, title: p.title };
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/** Same discriminator leases-tools.ts uses: the driver's own uid scheme tag
|
|
55
|
+
* ("cdp" for Chrome, "bidi" for Firefox). */
|
|
56
|
+
function backendOf(driver: BrowserDriver): LeaseBackend {
|
|
57
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
/* -------------------------------- page acquisition -------------------------------- */
|
|
53
61
|
|
|
54
62
|
/** Acquire, run, always release: the Driver counterpart of client.ts's withPage. */
|
|
@@ -61,14 +69,31 @@ async function withPage<T>(driver: BrowserDriver, target: TargetSelector | undef
|
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Promise<PageInfo> {
|
|
73
|
+
const pages = await driver.listPages();
|
|
74
|
+
const hit = await pickPage(driver, pages, selector);
|
|
75
|
+
// THE third choke point. close_page and select_page resolve here and nowhere
|
|
76
|
+
// else, so the lease check has to be here too, not only in resolveTarget /
|
|
77
|
+
// resolveContext. Closing someone else's leased tab is the worst version of
|
|
78
|
+
// the collision this feature exists to stop.
|
|
79
|
+
//
|
|
80
|
+
// Deliberately NOT passing liveIds, matching resolveTarget and resolveContext.
|
|
81
|
+
// For every branch but one it would be inert (hit is an element of `pages`),
|
|
82
|
+
// and for the bare-id branch it would be actively wrong: that branch can
|
|
83
|
+
// return a non-page target off the `all:true` listing, whose id is absent
|
|
84
|
+
// from `pages`, so passing pages' ids would report a perfectly healthy lease
|
|
85
|
+
// as target-gone and wave the caller straight through it.
|
|
86
|
+
await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
|
|
87
|
+
return hit;
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
/**
|
|
65
91
|
* Same TargetSelector grammar as client.ts's resolveTarget. Matches its one asymmetry exactly:
|
|
66
92
|
* every branch resolves against page-type targets only EXCEPT a bare-id lookup, which (like
|
|
67
93
|
* legacy resolveTarget's `targets.find`) searches the FULL unfiltered listing, so passing an
|
|
68
94
|
* exact non-page targetId (a worker, an iframe) still resolves.
|
|
69
95
|
*/
|
|
70
|
-
async function
|
|
71
|
-
const pages = await driver.listPages();
|
|
96
|
+
async function pickPage(driver: BrowserDriver, pages: PageInfo[], selector?: TargetSelector): Promise<PageInfo> {
|
|
72
97
|
if (selector === undefined || selector === "active") {
|
|
73
98
|
if (!pages.length) throw new SharedToolError("no page targets open");
|
|
74
99
|
return pages[0]!;
|
|
@@ -109,21 +134,97 @@ function locatorOf(args: { uid?: DriverUid; selector?: string }): ElementLocator
|
|
|
109
134
|
|
|
110
135
|
/* -------------------------------- pages (4) -------------------------------- */
|
|
111
136
|
|
|
112
|
-
|
|
137
|
+
/**
|
|
138
|
+
* A page listing entry, PageInfo plus provenance. STRICTLY ADDITIVE: {id, url,
|
|
139
|
+
* title, type} are unchanged in name, type and value, because consumers read
|
|
140
|
+
* them today.
|
|
141
|
+
*/
|
|
142
|
+
export interface ListedPage extends PageInfo {
|
|
143
|
+
/** "agent" when the creation ledger has a record for this target. Otherwise
|
|
144
|
+
* "unknown", NEVER "human": the absence of a record is not proof that a
|
|
145
|
+
* person opened the tab. See src/origins.ts. */
|
|
146
|
+
origin: PageOrigin;
|
|
147
|
+
/** The creating agent's label. Present only when origin is "agent". */
|
|
148
|
+
label?: string;
|
|
149
|
+
/** When this toolkit created the tab (epoch ms). Only when origin is "agent". */
|
|
150
|
+
createdAt?: number;
|
|
151
|
+
/** Present only when this target HAS a ledger record that could not be read:
|
|
152
|
+
* the errno, or "unparseable". origin stays "unknown" because provenance is
|
|
153
|
+
* genuinely unknown, but this field is what keeps an unreadable record from
|
|
154
|
+
* being reported as a clean "nothing was ever recorded". */
|
|
155
|
+
originUnreadable?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The reap set for the origin ledger: every target the browser currently has,
|
|
160
|
+
* INCLUDING non-page targets, regardless of what this call is listing.
|
|
161
|
+
*
|
|
162
|
+
* Using the filtered page list would be a bug with a plausible disguise: a
|
|
163
|
+
* default list_pages call drops workers and iframes, so reaping against it
|
|
164
|
+
* would delete the ledger record of any target that is alive but filtered out.
|
|
165
|
+
* Returning undefined (which listOrigins reads as "reap nothing") when the
|
|
166
|
+
* unfiltered listing fails is the same principle one step further: a failed
|
|
167
|
+
* enumeration must never be able to empty the ledger.
|
|
168
|
+
*/
|
|
169
|
+
async function reapSet(driver: BrowserDriver, pages: PageInfo[], all?: boolean): Promise<readonly string[] | undefined> {
|
|
170
|
+
if (all === true) return pages.map((p) => p.id);
|
|
171
|
+
try {
|
|
172
|
+
return (await driver.listPages({ all: true })).map((p) => p.id);
|
|
173
|
+
} catch {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function listPages(driver: BrowserDriver, args: { all?: boolean } = {}): Promise<{ pages: ListedPage[]; count: number }> {
|
|
113
179
|
const pages = await driver.listPages({ all: args.all });
|
|
114
|
-
|
|
180
|
+
// Never let provenance break the listing: originIndex does not throw, and a
|
|
181
|
+
// missing or unreadable ledger yields an empty map, so every page falls back
|
|
182
|
+
// to origin "unknown" exactly as a pre-ledger consumer always saw.
|
|
183
|
+
const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
|
|
184
|
+
const annotated: ListedPage[] = pages.map((p) => {
|
|
185
|
+
const rec = ledger.get(p.id);
|
|
186
|
+
if (!rec) return { ...p, origin: "unknown" };
|
|
187
|
+
if (rec.unreadable !== undefined) return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
|
|
188
|
+
return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
|
|
189
|
+
});
|
|
190
|
+
return { pages: annotated, count: annotated.length };
|
|
115
191
|
}
|
|
116
192
|
|
|
117
|
-
export async function newPage(
|
|
118
|
-
|
|
119
|
-
|
|
193
|
+
export async function newPage(
|
|
194
|
+
driver: BrowserDriver,
|
|
195
|
+
args: { url?: string; claim?: boolean; label?: string; ttlMs?: number } = {},
|
|
196
|
+
): Promise<{ targetId: string; url: string; lease?: LeaseToken; label?: string; expiresAt?: number }> {
|
|
197
|
+
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
198
|
+
// Creates the tab AND writes its creation record. The record outlives any
|
|
199
|
+
// lease taken below, which is the point: a released tab is still an agent's.
|
|
200
|
+
const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
|
|
201
|
+
if (args.claim !== true) return { targetId: p.id, url: p.url };
|
|
202
|
+
// Atomic in the sense that matters: the tab is claimed before this call
|
|
203
|
+
// returns, so no other agent can see it unclaimed and take it first.
|
|
204
|
+
const { record, token } = await claimLease(backendOf(driver), p.id, {
|
|
205
|
+
label,
|
|
206
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
207
|
+
});
|
|
208
|
+
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
120
209
|
}
|
|
121
210
|
|
|
122
|
-
export async function closePage(
|
|
211
|
+
export async function closePage(
|
|
212
|
+
driver: BrowserDriver,
|
|
213
|
+
args: { target?: TargetSelector },
|
|
214
|
+
): Promise<{ closed: string; success: boolean; leaseReleased?: boolean }> {
|
|
123
215
|
if (args.target === undefined || args.target === "") throw new SharedToolError("close_page requires an explicit target; refusing to guess which page to close");
|
|
124
216
|
const p = await resolvePage(driver, args.target);
|
|
125
217
|
const res = await driver.closePage(p.id);
|
|
126
|
-
|
|
218
|
+
// A failed close (target refused to close, or was already gone) must leave
|
|
219
|
+
// the lease intact: the caller still owns a tab that never actually closed,
|
|
220
|
+
// and releasing it here would let another agent claim a tab this one still
|
|
221
|
+
// thinks it holds.
|
|
222
|
+
if (!res.success) return { closed: p.id, success: false, leaseReleased: false };
|
|
223
|
+
// Closing a leased tab must not leave its lease file behind waiting on the
|
|
224
|
+
// TTL. resolvePage above already refused this call unless the caller holds
|
|
225
|
+
// the lease, so no token is needed here.
|
|
226
|
+
const released = await releaseLeaseFor(backendOf(driver), p.id);
|
|
227
|
+
return { closed: p.id, success: true, ...(released.released ? { leaseReleased: true } : {}) };
|
|
127
228
|
}
|
|
128
229
|
|
|
129
230
|
async function writeSelectedFile(id: string): Promise<void> {
|
|
@@ -186,14 +287,261 @@ export const waitFor = waitForText;
|
|
|
186
287
|
|
|
187
288
|
/* ------------------------------- evaluate (1) ------------------------------- */
|
|
188
289
|
|
|
290
|
+
/**
|
|
291
|
+
* The `savePath` result shape. Deliberately carries NO form of the evaluated
|
|
292
|
+
* value: not the value, not a preview, not a truncation, not a byte prefix.
|
|
293
|
+
* The whole reason `savePath` exists is that the value may be a credential
|
|
294
|
+
* (a JWT or session token read out of localStorage), and anything this object
|
|
295
|
+
* carried would land in the caller's transcript, which is the exact leak the
|
|
296
|
+
* file sink is here to prevent. `type` is the JS typeof of the value ("null"
|
|
297
|
+
* for null), which describes the value without disclosing any of it.
|
|
298
|
+
*/
|
|
299
|
+
export interface EvaluateScriptSaveResult {
|
|
300
|
+
path: string;
|
|
301
|
+
bytes: number;
|
|
302
|
+
type: string;
|
|
303
|
+
target: { id: string; url: string; title: string };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Serialize an evaluated value for the file sink. `undefined` (an expression
|
|
308
|
+
* with no result) is written as JSON null rather than left unwritten, so the
|
|
309
|
+
* file always exists and always parses.
|
|
310
|
+
*/
|
|
311
|
+
function serializeForSink(value: unknown): string {
|
|
312
|
+
const json = JSON.stringify(value === undefined ? null : value, null, 2);
|
|
313
|
+
return json ?? "null";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* THE file sink, shared by every tool that takes a `savePath`: serialize the
|
|
318
|
+
* value as JSON, write it, and report only where it went and how big it was.
|
|
319
|
+
* It returns no form of the value on purpose, so a caller of this helper
|
|
320
|
+
* cannot accidentally leak one back into a response.
|
|
321
|
+
*
|
|
322
|
+
* Path resolution matches take_heapsnapshot: an absolute path is used as-is, a
|
|
323
|
+
* relative one resolves under the artifact dir, and missing parent directories
|
|
324
|
+
* are created.
|
|
325
|
+
*/
|
|
326
|
+
async function writeJsonSink(savePath: string, value: unknown): Promise<{ path: string; bytes: number }> {
|
|
327
|
+
const path = savePath.startsWith("/") ? savePath : join(ARTIFACT_DIR, savePath);
|
|
328
|
+
await mkdir(dirname(path), { recursive: true });
|
|
329
|
+
const json = serializeForSink(value);
|
|
330
|
+
await writeFile(path, json, "utf8");
|
|
331
|
+
return { path, bytes: Buffer.byteLength(json, "utf8") };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** True when a savePath argument actually asks for the sink. An empty string is
|
|
335
|
+
* treated as absent, so the caller gets the value inline. */
|
|
336
|
+
function sinkRequested(savePath?: string): savePath is string {
|
|
337
|
+
return savePath !== undefined && savePath !== "";
|
|
338
|
+
}
|
|
339
|
+
|
|
189
340
|
export async function evaluateScript(
|
|
190
341
|
driver: BrowserDriver,
|
|
191
|
-
args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[] },
|
|
342
|
+
args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[]; savePath?: string },
|
|
192
343
|
): Promise<unknown> {
|
|
193
344
|
if (typeof args.expression !== "string" || args.expression.length === 0) {
|
|
194
345
|
throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
|
|
195
346
|
}
|
|
196
|
-
return withPage(driver, args.target, (page) =>
|
|
347
|
+
return withPage(driver, args.target, async (page) => {
|
|
348
|
+
const value = await page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true });
|
|
349
|
+
// No savePath: byte-identical to the pre-sink behavior, the value itself.
|
|
350
|
+
// A thrown page-side exception never reaches here, so it still surfaces as
|
|
351
|
+
// an error rather than being written into the file.
|
|
352
|
+
if (!sinkRequested(args.savePath)) return value;
|
|
353
|
+
|
|
354
|
+
const { path, bytes } = await writeJsonSink(args.savePath, value);
|
|
355
|
+
const result: EvaluateScriptSaveResult = {
|
|
356
|
+
path,
|
|
357
|
+
bytes,
|
|
358
|
+
type: value === null ? "null" : typeof value,
|
|
359
|
+
target: target3(page.info),
|
|
360
|
+
};
|
|
361
|
+
return result;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* -------------------------------- cookies (1) -------------------------------- */
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The `savePath` result shape for list_cookies. Same rule as
|
|
369
|
+
* EvaluateScriptSaveResult: NO cookie value in any form, not even a prefix. A
|
|
370
|
+
* session cookie IS the credential, so a preview of one is a leaked one.
|
|
371
|
+
* `count` is how many cookies were written, which tells a caller the read
|
|
372
|
+
* worked without disclosing anything about what it read.
|
|
373
|
+
*/
|
|
374
|
+
export interface ListCookiesSaveResult {
|
|
375
|
+
path: string;
|
|
376
|
+
bytes: number;
|
|
377
|
+
count: number;
|
|
378
|
+
target: { id: string; url: string; title: string };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface ListCookiesResult {
|
|
382
|
+
cookies: BrowserCookie[];
|
|
383
|
+
count: number;
|
|
384
|
+
target: { id: string; url: string; title: string };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Cookie domain matching for the `domain` filter, deliberately small.
|
|
389
|
+
*
|
|
390
|
+
* A cookie's own domain carries a leading dot when it was set for subdomains
|
|
391
|
+
* (".example.test") and none when it was host-only ("example.test"), and a
|
|
392
|
+
* caller who types "example.test" means both. So the comparison strips a
|
|
393
|
+
* leading dot from each side and then accepts an exact match or a subdomain of
|
|
394
|
+
* the filter ("app.example.test" matches "example.test"). That is the whole
|
|
395
|
+
* rule: no wildcards, no patterns, no query language.
|
|
396
|
+
*/
|
|
397
|
+
function cookieDomainMatches(cookieDomain: string, filter: string): boolean {
|
|
398
|
+
const bare = (d: string) => (d.startsWith(".") ? d.slice(1) : d).toLowerCase();
|
|
399
|
+
const c = bare(cookieDomain);
|
|
400
|
+
const f = bare(filter);
|
|
401
|
+
return c === f || c.endsWith(`.${f}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Apply the optional name/domain filters. Exported for the unit tests, which
|
|
405
|
+
* can then check the matching rule without standing up a browser. */
|
|
406
|
+
export function filterCookies(cookies: readonly BrowserCookie[], filter: { domain?: string; name?: string }): BrowserCookie[] {
|
|
407
|
+
return cookies.filter((c) => {
|
|
408
|
+
if (filter.name !== undefined && filter.name !== "" && c.name !== filter.name) return false;
|
|
409
|
+
if (filter.domain !== undefined && filter.domain !== "" && !cookieDomainMatches(c.domain, filter.domain)) return false;
|
|
410
|
+
return true;
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Read the cookies of the target page, httpOnly ones included.
|
|
416
|
+
*
|
|
417
|
+
* This is the one read that a page script cannot do: document.cookie omits
|
|
418
|
+
* every httpOnly cookie by design, which is precisely the set a session
|
|
419
|
+
* credential lives in. Both drivers answer over the protocol's own cookie
|
|
420
|
+
* store, so the flags come from the browser rather than being inferred.
|
|
421
|
+
*
|
|
422
|
+
* With `savePath` set the cookie array is written to that file as JSON and the
|
|
423
|
+
* response carries only {path, bytes, count, target}: no value, no preview, no
|
|
424
|
+
* truncation. Without it, the cookies are returned inline.
|
|
425
|
+
*/
|
|
426
|
+
export async function listCookies(
|
|
427
|
+
driver: BrowserDriver,
|
|
428
|
+
args: { target?: TargetSelector; domain?: string; name?: string; savePath?: string } = {},
|
|
429
|
+
): Promise<ListCookiesResult | ListCookiesSaveResult> {
|
|
430
|
+
return withPage(driver, args.target, async (page) => {
|
|
431
|
+
const all = await page.getCookies();
|
|
432
|
+
const cookies = filterCookies(all, { domain: args.domain, name: args.name });
|
|
433
|
+
if (!sinkRequested(args.savePath)) {
|
|
434
|
+
return { cookies, count: cookies.length, target: target3(page.info) };
|
|
435
|
+
}
|
|
436
|
+
const { path, bytes } = await writeJsonSink(args.savePath, cookies);
|
|
437
|
+
return { path, bytes, count: cookies.length, target: target3(page.info) };
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export interface SetCookieResult {
|
|
442
|
+
set: true;
|
|
443
|
+
target: { id: string; url: string; title: string };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export interface DeleteCookiesResult {
|
|
447
|
+
deleted: true;
|
|
448
|
+
target: { id: string; url: string; title: string };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* THE site constraint both write tools share: a cookie has to be attributed to
|
|
453
|
+
* a site, and `url` or `domain` is how a caller says which.
|
|
454
|
+
*
|
|
455
|
+
* Checked here, above the drivers, so the refusal is ONE message on both
|
|
456
|
+
* backends rather than a CDP protocol error on one and a Firefox no-op on the
|
|
457
|
+
* other. It throws rather than defaulting to the current page's origin: a
|
|
458
|
+
* guessed domain writes a real cookie somewhere the caller did not name, and a
|
|
459
|
+
* cookie written to the wrong site is worse than a call that failed loudly.
|
|
460
|
+
*
|
|
461
|
+
* Exported for the unit tests, which pin the message without a browser.
|
|
462
|
+
*/
|
|
463
|
+
export function assertCookieSite(
|
|
464
|
+
args: { url?: string; domain?: string },
|
|
465
|
+
toolName: string,
|
|
466
|
+
): void {
|
|
467
|
+
const hasUrl = args.url !== undefined && args.url !== "";
|
|
468
|
+
const hasDomain = args.domain !== undefined && args.domain !== "";
|
|
469
|
+
if (!hasUrl && !hasDomain) {
|
|
470
|
+
throw new Error(`${toolName} requires either 'url' or 'domain': a cookie has to be attributed to a site, and neither was given.`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The shared 'name' check. A write tool with no name has nothing to write and
|
|
475
|
+
* a delete tool with no name would match every cookie, so an empty one is
|
|
476
|
+
* refused rather than treated as absent. */
|
|
477
|
+
function assertCookieName(name: string | undefined, toolName: string): asserts name is string {
|
|
478
|
+
if (name === undefined || name === "") {
|
|
479
|
+
throw new Error(`${toolName} requires a non-empty 'name'.`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Write one cookie into the target page's cookie store.
|
|
485
|
+
*
|
|
486
|
+
* The response is deliberately tiny: `{set: true, target}`. It does NOT echo
|
|
487
|
+
* the value back, because the value is frequently the credential and a caller
|
|
488
|
+
* who just supplied it does not need it repeated into their transcript. The
|
|
489
|
+
* `set: true` is earned rather than assumed: the CDP driver throws on Chrome's
|
|
490
|
+
* `{success:false}` refusal and the BiDi driver throws when it cannot derive
|
|
491
|
+
* the domain BiDi requires, so reaching this line means a backend confirmed
|
|
492
|
+
* the write.
|
|
493
|
+
*/
|
|
494
|
+
export async function setCookie(
|
|
495
|
+
driver: BrowserDriver,
|
|
496
|
+
args: {
|
|
497
|
+
target?: TargetSelector; name: string; value: string; url?: string; domain?: string;
|
|
498
|
+
path?: string; expires?: number; httpOnly?: boolean; secure?: boolean;
|
|
499
|
+
sameSite?: "strict" | "lax" | "none" | "default";
|
|
500
|
+
},
|
|
501
|
+
): Promise<SetCookieResult> {
|
|
502
|
+
assertCookieName(args.name, "set_cookie");
|
|
503
|
+
if (typeof args.value !== "string") throw new Error("set_cookie requires a string 'value'.");
|
|
504
|
+
assertCookieSite(args, "set_cookie");
|
|
505
|
+
return withPage(driver, args.target, async (page) => {
|
|
506
|
+
await page.setCookie({
|
|
507
|
+
name: args.name,
|
|
508
|
+
value: args.value,
|
|
509
|
+
...(args.url !== undefined ? { url: args.url } : {}),
|
|
510
|
+
...(args.domain !== undefined ? { domain: args.domain } : {}),
|
|
511
|
+
...(args.path !== undefined ? { path: args.path } : {}),
|
|
512
|
+
...(args.expires !== undefined ? { expires: args.expires } : {}),
|
|
513
|
+
...(args.httpOnly !== undefined ? { httpOnly: args.httpOnly } : {}),
|
|
514
|
+
...(args.secure !== undefined ? { secure: args.secure } : {}),
|
|
515
|
+
...(args.sameSite !== undefined ? { sameSite: args.sameSite } : {}),
|
|
516
|
+
});
|
|
517
|
+
return { set: true as const, target: target3(page.info) };
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Remove the named cookie from the target page's cookie store.
|
|
523
|
+
*
|
|
524
|
+
* `{deleted: true, target}` and NO count, on purpose. Neither protocol reports
|
|
525
|
+
* how many cookies it removed, so any number here would be invented; a caller
|
|
526
|
+
* who needs one reads list_cookies before and after. `deleted: true` means the
|
|
527
|
+
* backend accepted and performed the deletion, not that a matching cookie
|
|
528
|
+
* existed: deleting an absent cookie is a success on both backends.
|
|
529
|
+
*/
|
|
530
|
+
export async function deleteCookies(
|
|
531
|
+
driver: BrowserDriver,
|
|
532
|
+
args: { target?: TargetSelector; name: string; url?: string; domain?: string; path?: string },
|
|
533
|
+
): Promise<DeleteCookiesResult> {
|
|
534
|
+
assertCookieName(args.name, "delete_cookies");
|
|
535
|
+
assertCookieSite(args, "delete_cookies");
|
|
536
|
+
return withPage(driver, args.target, async (page) => {
|
|
537
|
+
await page.deleteCookies({
|
|
538
|
+
name: args.name,
|
|
539
|
+
...(args.url !== undefined ? { url: args.url } : {}),
|
|
540
|
+
...(args.domain !== undefined ? { domain: args.domain } : {}),
|
|
541
|
+
...(args.path !== undefined ? { path: args.path } : {}),
|
|
542
|
+
});
|
|
543
|
+
return { deleted: true as const, target: target3(page.info) };
|
|
544
|
+
});
|
|
197
545
|
}
|
|
198
546
|
|
|
199
547
|
/* -------------------------------- snapshot (1) -------------------------------- */
|
|
@@ -394,7 +742,7 @@ export async function handleDialog(
|
|
|
394
742
|
|
|
395
743
|
/* ------------------------------------- registry ------------------------------------- */
|
|
396
744
|
|
|
397
|
-
/** The
|
|
745
|
+
/** The 23 tools this file unifies, importable by both src/index.ts (Chrome) and
|
|
398
746
|
* src/firefox-tools.ts (Firefox). Each function is (driver, args) => Promise<result>. */
|
|
399
747
|
export const SHARED_TOOLS = {
|
|
400
748
|
list_pages: listPages,
|
|
@@ -404,6 +752,9 @@ export const SHARED_TOOLS = {
|
|
|
404
752
|
navigate_page: navigatePage,
|
|
405
753
|
wait_for: waitForText,
|
|
406
754
|
evaluate_script: evaluateScript,
|
|
755
|
+
list_cookies: listCookies,
|
|
756
|
+
set_cookie: setCookie,
|
|
757
|
+
delete_cookies: deleteCookies,
|
|
407
758
|
take_snapshot: takeSnapshot,
|
|
408
759
|
click,
|
|
409
760
|
hover,
|
package/src/tools/console.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* console.ts
|
|
2
|
+
* console.ts: read console output captured by the recorder.
|
|
3
3
|
*
|
|
4
4
|
* list_console_messages -> listConsoleMessages
|
|
5
5
|
* get_console_message -> getConsoleMessage
|
|
@@ -135,7 +135,7 @@ async function readConsoleEntries(file: string): Promise<ConsoleEntry[]> {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
|
-
* list_console_messages
|
|
138
|
+
* list_console_messages: return console output for the target. With
|
|
139
139
|
* `reload:true`, records a fresh capture window by reloading the page.
|
|
140
140
|
*/
|
|
141
141
|
export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): Promise<{
|
|
@@ -166,7 +166,7 @@ export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): P
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
|
-
* get_console_message
|
|
169
|
+
* get_console_message: return a single console entry by index from the
|
|
170
170
|
* existing buffer for the target. Throws if the index is out of range.
|
|
171
171
|
*/
|
|
172
172
|
export async function getConsoleMessage(args: GetConsoleMessageArgs = {}): Promise<ConsoleEntry> {
|
package/src/tools/evaluate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* evaluate_script
|
|
2
|
+
* evaluate_script: run arbitrary JavaScript in a page's main context over raw
|
|
3
3
|
* CDP and return the evaluated value.
|
|
4
4
|
*
|
|
5
5
|
* Two modes, selected by whether `args` is supplied:
|
|
@@ -148,7 +148,7 @@ export async function evaluateScript(args: EvaluateScriptArgs): Promise<unknown>
|
|
|
148
148
|
*
|
|
149
149
|
* Parity gaps vs chrome-devtools-mcp evaluate_script:
|
|
150
150
|
* - MCP injects a typed `page`/element handle into the evaluated function; here `args` are plain
|
|
151
|
-
* JSON-serializable values passed positionally
|
|
151
|
+
* JSON-serializable values passed positionally; no live element/page handle is bound.
|
|
152
152
|
* - Non-serializable return values (DOM nodes, functions, circular objects) come back as their CDP
|
|
153
153
|
* `description` string rather than a structured handle, since returnByValue is used.
|
|
154
154
|
* - No isolated-world/contextId selection: evaluation runs in the page's default main-world context.
|
package/src/tools/heap.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* heap.ts
|
|
2
|
+
* heap.ts: `take_heapsnapshot` over raw CDP.
|
|
3
3
|
*
|
|
4
4
|
* Captures a V8 heap snapshot of a page target and writes it as a
|
|
5
5
|
* `.heapsnapshot` file (the JSON format DevTools' Memory panel loads).
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* CDP semantics this module relies on:
|
|
8
8
|
* - `HeapProfiler.takeHeapSnapshot` streams the snapshot back as a sequence
|
|
9
9
|
* of `HeapProfiler.addHeapSnapshotChunk` events. The command's own result
|
|
10
|
-
* does NOT carry the data
|
|
10
|
+
* does NOT carry the data; it resolves only after the final chunk has been
|
|
11
11
|
* emitted. So we subscribe to the chunk event first, accumulate every
|
|
12
12
|
* chunk's string, then `await` the command; once it resolves we have the
|
|
13
13
|
* complete snapshot and join the chunks into one JSON document.
|
|
@@ -103,7 +103,7 @@ export async function takeHeapsnapshot(
|
|
|
103
103
|
*
|
|
104
104
|
* Parity gaps vs chrome-devtools-mcp `take_heapsnapshot`:
|
|
105
105
|
* - reportProgress is forced false; no incremental progress callbacks surfaced.
|
|
106
|
-
* - Returns {path,bytes,chunks,target} only
|
|
106
|
+
* - Returns {path,bytes,chunks,target} only; does not parse/summarize the
|
|
107
107
|
* snapshot (node counts, retained sizes); the .heapsnapshot file is for the
|
|
108
108
|
* DevTools Memory panel to load, matching the MCP tool's artifact behavior.
|
|
109
109
|
* --------------------------------------------------------------------------*/
|
package/src/tools/lighthouse.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lighthouse_audit
|
|
2
|
+
* lighthouse_audit: THE ONLY non-CDP tool in this toolkit.
|
|
3
3
|
*
|
|
4
4
|
* Unlike every other module, this one does NOT speak the Chrome DevTools
|
|
5
5
|
* Protocol over a WebSocket. Instead it shells out to the external Lighthouse
|
|
@@ -105,7 +105,7 @@ interface LighthouseReport {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export interface LighthouseAuditArgs {
|
|
108
|
-
/** The URL to audit. Required
|
|
108
|
+
/** The URL to audit. Required, and never points at a user tab implicitly. */
|
|
109
109
|
url: string;
|
|
110
110
|
/**
|
|
111
111
|
* Lighthouse categories to run. Defaults to the full set.
|
|
@@ -206,7 +206,7 @@ export async function lighthouseAudit(
|
|
|
206
206
|
const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(
|
|
207
207
|
res.stderr,
|
|
208
208
|
)
|
|
209
|
-
? " The Lighthouse CLI could not be fetched/installed via npx
|
|
209
|
+
? " The Lighthouse CLI could not be fetched/installed via npx; ensure network access and that 'lighthouse' is installable."
|
|
210
210
|
: "";
|
|
211
211
|
throw new CdpError(
|
|
212
212
|
`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`,
|
|
@@ -264,7 +264,7 @@ function truncate(s: string, max = 600): string {
|
|
|
264
264
|
* ---------------------------------------------------------------------------
|
|
265
265
|
* NON-CDP MODULE. This is the toolkit's sole tool that does not speak raw CDP.
|
|
266
266
|
*
|
|
267
|
-
* External dependency (subprocess, fetched on demand by npx
|
|
267
|
+
* External dependency (subprocess, fetched on demand by npx, not an npm dep):
|
|
268
268
|
* - `npx --yes lighthouse <url> --port=<cdpPort> --output=json
|
|
269
269
|
* --output-path=<artifact> --quiet --preset=desktop
|
|
270
270
|
* [--only-categories=...] --chrome-flags=--headless=new`
|
package/src/tools/navigation.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface NavigatePageResult {
|
|
|
48
48
|
* finish loading.
|
|
49
49
|
*
|
|
50
50
|
* `Page.navigate`/`Page.reload` resolve once the action is *committed*. We
|
|
51
|
-
* separately race the load milestone event
|
|
51
|
+
* separately race the load milestone event, subscribed BEFORE issuing the
|
|
52
52
|
* command so a fast load can't fire before we're listening. Pass
|
|
53
53
|
* `reload:true, ignoreCache:true` for a hard reload that refetches every
|
|
54
54
|
* subresource (the only way to pick up a freshly-deployed bundle the HTTP
|
package/src/tools/network.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* network.ts
|
|
2
|
+
* network.ts: read network activity captured by the recorder.
|
|
3
3
|
*
|
|
4
4
|
* list_network_requests -> listNetworkRequests
|
|
5
5
|
* get_network_request -> getNetworkRequest
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
* - default ({ reload: false }): read the target's shared "latest" buffer
|
|
9
9
|
* (rec-<targetId>.jsonl), correlate the four Network.* events by requestId,
|
|
10
10
|
* and return one row per request.
|
|
11
|
-
* - { reload: true }: run a captureWindow (both domains)
|
|
11
|
+
* - { reload: true }: run a captureWindow (both domains): Page.reload, capture
|
|
12
12
|
* for `durationMs` (default 2500ms), stop, then read+return the requests.
|
|
13
13
|
*
|
|
14
14
|
* Response bodies: CDP only serves a body from the LIVE renderer session, so
|
|
15
15
|
* get_network_request with { includeBody: true } drives a fresh reload capture
|
|
16
16
|
* and calls Network.getResponseBody before closing. Because a reload re-mints
|
|
17
17
|
* requestIds, body fetch is matched by `url` (stable), not by a carried-over
|
|
18
|
-
* requestId
|
|
18
|
+
* requestId; see getNetworkRequest for the exact contract.
|
|
19
19
|
*/
|
|
20
20
|
import { readFile } from "node:fs/promises";
|
|
21
21
|
import { CdpError, resolveTarget } from "../client.ts";
|
|
@@ -59,7 +59,7 @@ export interface ListNetworkRequestsArgs {
|
|
|
59
59
|
|
|
60
60
|
export interface GetNetworkRequestArgs {
|
|
61
61
|
target?: TargetSelector;
|
|
62
|
-
/** Match by exact requestId (metadata only
|
|
62
|
+
/** Match by exact requestId (metadata only, see includeBody). */
|
|
63
63
|
requestId?: string;
|
|
64
64
|
/** Match by URL substring (first match). Required for body fetch (includeBody). */
|
|
65
65
|
url?: string;
|
|
@@ -153,7 +153,7 @@ async function readNetworkRequests(file: string): Promise<NetworkRequest[]> {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
|
-
* list_network_requests
|
|
156
|
+
* list_network_requests: correlated request rows for the target. With
|
|
157
157
|
* `reload:true`, records a fresh capture window (both domains) by reloading.
|
|
158
158
|
*/
|
|
159
159
|
export async function listNetworkRequests(args: ListNetworkRequestsArgs = {}): Promise<{
|
|
@@ -187,13 +187,13 @@ export async function listNetworkRequests(args: ListNetworkRequestsArgs = {}): P
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
|
-
* get_network_request
|
|
190
|
+
* get_network_request: return one request (matched by requestId, else by URL
|
|
191
191
|
* substring) including response status/headers.
|
|
192
192
|
*
|
|
193
193
|
* Body retrieval: CDP only serves a response body from the LIVE renderer
|
|
194
194
|
* session, so `includeBody:true` must drive a fresh reload capture and fetch the
|
|
195
195
|
* body before the connection closes. A reload re-mints requestIds, so a body
|
|
196
|
-
* fetch can only be matched by `url` (stable across reload)
|
|
196
|
+
* fetch can only be matched by `url` (stable across reload), NOT by a
|
|
197
197
|
* `requestId` carried over from a prior `list_network_requests`. When a
|
|
198
198
|
* requestId is supplied with includeBody we therefore return metadata from the
|
|
199
199
|
* existing buffer and explain why the body is unavailable, rather than reloading
|