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/src/driver.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ADR-001: one browser-neutral Driver interface behind the 33 tools.
2
+ * ADR-001: one browser-neutral Driver interface behind the 36 tools.
3
3
  *
4
4
  * Context. Every tool in src/tools/ is a stateless one-shot on withPage():
5
5
  * open a socket, act, always close. That is only correct because a CDP `Uid`
@@ -281,6 +281,80 @@ export interface HandledDialogInfo extends DialogInfo {
281
281
  handled: true;
282
282
  }
283
283
 
284
+ /**
285
+ * One cookie, neutral over CDP's `Network.Cookie` and BiDi's `network.Cookie`.
286
+ *
287
+ * Every flag a caller needs to reason about a session credential is present:
288
+ * an httpOnly cookie is exactly the one `document.cookie` cannot read, so a
289
+ * shape that dropped `httpOnly` would make the reason this exists invisible.
290
+ *
291
+ * Two normalizations, applied by both drivers so the shape does not change
292
+ * with the backend:
293
+ * - `expires` is a Unix timestamp in SECONDS, or -1 for a session cookie.
294
+ * CDP reports exactly that; BiDi reports an optional `expiry` and omits it
295
+ * for a session cookie, which the BiDi driver maps to -1.
296
+ * - `sameSite` is lowercased to BiDi's vocabulary ("strict" | "lax" | "none"
297
+ * | "default"). CDP capitalizes it and omits it entirely when unset, which
298
+ * the CDP driver maps to "default".
299
+ */
300
+ export interface BrowserCookie {
301
+ name: string;
302
+ value: string;
303
+ domain: string;
304
+ path: string;
305
+ /** Unix seconds, or -1 for a session cookie. */
306
+ expires: number;
307
+ /** Byte length of name plus value, as the browser reports it. */
308
+ size: number;
309
+ httpOnly: boolean;
310
+ secure: boolean;
311
+ sameSite: "strict" | "lax" | "none" | "default";
312
+ /** True when the cookie expires with the browsing session (no expiry set). */
313
+ session: boolean;
314
+ }
315
+
316
+ /**
317
+ * One cookie to WRITE, neutral over CDP's `Network.setCookie` and BiDi's
318
+ * `storage.setCookie`.
319
+ *
320
+ * `url` and `domain` are both optional here but not both omittable: the cookie
321
+ * has to be attributed to a site somehow, and every backend refuses without
322
+ * one. The tool layer enforces that before either driver is reached, so the
323
+ * refusal is one message rather than two different protocol errors.
324
+ *
325
+ * `expires` follows the read shape: Unix seconds. Omit it for a session cookie,
326
+ * which is what both backends do when no expiry is supplied.
327
+ */
328
+ export interface SetCookieParams {
329
+ name: string;
330
+ value: string;
331
+ /** The URL the cookie is being set for. CDP derives domain, path and secure
332
+ * from it; BiDi has no url parameter, so its driver derives the domain. */
333
+ url?: string;
334
+ domain?: string;
335
+ path?: string;
336
+ /** Unix seconds. Omitted means a session cookie. */
337
+ expires?: number;
338
+ httpOnly?: boolean;
339
+ secure?: boolean;
340
+ sameSite?: "strict" | "lax" | "none" | "default";
341
+ }
342
+
343
+ /**
344
+ * Which cookies to DELETE, neutral over CDP's `Network.deleteCookies` and
345
+ * BiDi's `storage.deleteCookies` filter.
346
+ *
347
+ * `name` is required and there is no wildcard: this deletes a named cookie, not
348
+ * a swathe of them. As with SetCookieParams, one of `url` or `domain` must be
349
+ * present, and the tool layer enforces it.
350
+ */
351
+ export interface DeleteCookiesFilter {
352
+ name: string;
353
+ url?: string;
354
+ domain?: string;
355
+ path?: string;
356
+ }
357
+
284
358
  export interface InterceptRule {
285
359
  /** Glob: `*` any run, `?` one char. Both drivers normalize to this. */
286
360
  urlPattern: string;
@@ -403,6 +477,38 @@ export interface PageDriver {
403
477
  opts?: { timeoutMs?: number; autoMs?: number },
404
478
  ): Promise<HandledDialogInfo | { handled: HandledDialogInfo[]; count: number }>;
405
479
 
480
+ /* cookies */
481
+ /**
482
+ * Every cookie visible to THIS page, httpOnly ones included.
483
+ *
484
+ * Deliberately page-scoped, not browser-scoped. Both protocols offer a
485
+ * browser-wide jar (CDP's `Storage.getCookies` with no browserContextId,
486
+ * BiDi's storageKey partition), and both would hand back every credential
487
+ * for every site the browser has ever visited on a call that named one tab.
488
+ * A tool scoped to a target returns that target's cookies; a caller who
489
+ * wants another site's cookies points at a page on that site.
490
+ */
491
+ getCookies(): Promise<BrowserCookie[]>;
492
+
493
+ /**
494
+ * Write one cookie into the store this page reads from.
495
+ *
496
+ * Resolves only when the backend confirms the write. A backend that reports
497
+ * a refusal (CDP's `Network.setCookie` answers `{success:false}` for a cookie
498
+ * it will not accept) must throw rather than resolve, because a resolved
499
+ * promise here reads as "the cookie is set" and a caller will believe it.
500
+ */
501
+ setCookie(params: SetCookieParams): Promise<void>;
502
+
503
+ /**
504
+ * Remove the cookies matching the filter from the store this page reads from.
505
+ *
506
+ * Neither backend reports how many cookies it removed, so this returns
507
+ * nothing rather than an invented count. Deleting a cookie that was never
508
+ * there is not an error on either backend.
509
+ */
510
+ deleteCookies(filter: DeleteCookiesFilter): Promise<void>;
511
+
406
512
  /* network */
407
513
  /** Begin retaining response bodies. REQUIRED before getResponseBody unless
408
514
  * the driver declares "network.responseBodyPostHoc". Returns a stop fn. */
@@ -1,14 +1,17 @@
1
1
  /**
2
- * The complete Firefox tool registry: the 20 tools shared with Chrome (src/shared-tools.ts) plus
3
- * the 7 Firefox-only console/network/mock tools (src/bidi-tools.ts). This is what src/neutral.ts
4
- * used to be; it now composes two files instead of reimplementing everything in one.
2
+ * The complete Firefox tool registry: the 23 tools shared with Chrome (src/shared-tools.ts), the
3
+ * 7 Firefox-only console/network/mock tools (src/bidi-tools.ts), and the 3 lease tools
4
+ * (src/leases-tools.ts), which are backend-neutral by construction. This is what src/neutral.ts
5
+ * used to be; it now composes three files instead of reimplementing everything in one.
5
6
  */
6
7
  import type { BrowserDriver } from "./driver.ts";
7
8
  import type { ToolName } from "./index.ts";
8
9
  import { SHARED_TOOLS } from "./shared-tools.ts";
9
10
  import { BIDI_ONLY_TOOLS } from "./bidi-tools.ts";
11
+ import { LEASE_TOOLS } from "./leases-tools.ts";
10
12
 
11
13
  export const FIREFOX_TOOLS: Partial<Record<ToolName, (driver: BrowserDriver, args: never) => Promise<unknown>>> = {
12
14
  ...SHARED_TOOLS,
13
15
  ...BIDI_ONLY_TOOLS,
16
+ ...LEASE_TOOLS,
14
17
  };
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  // the byte-identical-Chrome-behavior contract this bind honors, and for the 7 tools that stay
20
20
  // two implementations (console/network/mock, immediately below, unchanged from before).
21
21
  import { createCdpDriver } from "./cdp/driver.ts";
22
+ import { LEASE_TOOLS } from "./leases-tools.ts";
22
23
  import { SHARED_TOOLS } from "./shared-tools.ts";
23
24
  // --- console + network (read the recorder buffer) ---
24
25
  import { listConsoleMessages, getConsoleMessage } from "./tools/console.ts";
@@ -55,8 +56,11 @@ function onCdp<A>(fn: (driver: ReturnType<typeof createCdpDriver>, args: A) => P
55
56
  * their canonical MCP (snake_case) name, plus one convenience superset tool
56
57
  * (`performance_trace`, a robust single-call trace : the start/stop pair cannot
57
58
  * span two stateless CLI processes), plus a 3-tool network-mocking group
58
- * (mock_request/list_mocks/clear_mocks : a persistent per-target fake backend).
59
- * 33 entries total. Listed explicitly so the mapping is auditable at a glance and
59
+ * (mock_request/list_mocks/clear_mocks : a persistent per-target fake backend),
60
+ * plus a 3-tool cookie group (list_cookies/set_cookie/delete_cookies : the
61
+ * cookie store including httpOnly cookies, which no page script can read or
62
+ * write).
63
+ * 39 entries total. Listed explicitly so the mapping is auditable at a glance and
60
64
  * the CLI can `--list` it.
61
65
  *
62
66
  * 20 of the 29 MCP-parity tools (pages/navigation/evaluate/snapshot/interaction/
@@ -75,6 +79,10 @@ export const TOOLS = {
75
79
  wait_for: onCdp(SHARED_TOOLS.wait_for),
76
80
  // evaluate (1)
77
81
  evaluate_script: onCdp(SHARED_TOOLS.evaluate_script),
82
+ // cookies (3) : toolkit addition, reads and writes httpOnly cookies that document.cookie cannot touch
83
+ list_cookies: onCdp(SHARED_TOOLS.list_cookies),
84
+ set_cookie: onCdp(SHARED_TOOLS.set_cookie),
85
+ delete_cookies: onCdp(SHARED_TOOLS.delete_cookies),
78
86
  // snapshot (1)
79
87
  take_snapshot: onCdp(SHARED_TOOLS.take_snapshot),
80
88
  // interaction (8)
@@ -110,6 +118,10 @@ export const TOOLS = {
110
118
  mock_request: mockRequest,
111
119
  list_mocks: listMocks,
112
120
  clear_mocks: clearMocks,
121
+ // tab leases (3) : opt-in ownership so many agents can share one browser
122
+ claim_page: onCdp(LEASE_TOOLS.claim_page),
123
+ release_page: onCdp(LEASE_TOOLS.release_page),
124
+ list_leases: onCdp(LEASE_TOOLS.list_leases),
113
125
  } satisfies Record<string, (args: never) => Promise<unknown>>;
114
126
 
115
127
  /** Canonical MCP tool name accepted by the registry. */
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The three lease tools, shaped like src/shared-tools.ts entries
3
+ * ((driver, args) => Promise<result>) so ONE implementation serves Chrome and
4
+ * Firefox, exactly as the other 20 unified tools do.
5
+ */
6
+ import type { BrowserDriver } from "./driver.ts";
7
+ import { newTrackedPage } from "./origins.ts";
8
+ import {
9
+ claimLease,
10
+ defaultLabel,
11
+ leaseTtlMs,
12
+ listLeases,
13
+ releaseLease,
14
+ type LeaseBackend,
15
+ type LeaseSummary,
16
+ type LeaseToken,
17
+ } from "./leases.ts";
18
+
19
+ class LeaseToolError extends Error {}
20
+
21
+ /** The driver's uid scheme is the one stable per-backend discriminator on the
22
+ * BrowserDriver surface ("cdp" for Chrome, "bidi" for Firefox). */
23
+ function backendOf(driver: BrowserDriver): LeaseBackend {
24
+ return driver.scheme === "bidi" ? "firefox" : "chrome";
25
+ }
26
+
27
+ export interface ClaimPageResult {
28
+ lease: LeaseToken;
29
+ targetId: string;
30
+ url: string;
31
+ label: string;
32
+ ttlMs: number;
33
+ expiresAt: number;
34
+ }
35
+
36
+ /**
37
+ * Claim a tab. With no targetId, opens a fresh tab first (optionally at `url`)
38
+ * and claims that, so "give me my own tab" is one call. With a targetId, claims
39
+ * an already-open tab.
40
+ */
41
+ export async function claimPage(
42
+ driver: BrowserDriver,
43
+ args: { targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
44
+ ): Promise<ClaimPageResult> {
45
+ const backend = backendOf(driver);
46
+ const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
47
+ const pages = await driver.listPages();
48
+ let targetId = args.targetId;
49
+ let url = args.url ?? "about:blank";
50
+ if (targetId === undefined || targetId === "") {
51
+ // Creation ledger, not the lease: this record survives release_page and
52
+ // expiry, so the tab stays attributable to `label` after the lease is gone.
53
+ // Claiming an EXISTING tab (the else branch) records nothing, because this
54
+ // toolkit did not create it and provenance is not ownership.
55
+ const page = await newTrackedPage(driver, backend, { url: args.url, label });
56
+ targetId = page.id;
57
+ url = page.url;
58
+ } else {
59
+ const existing = pages.find((p) => p.id === targetId);
60
+ if (!existing) throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
61
+ url = existing.url;
62
+ }
63
+ const liveIds = [...pages.map((p) => p.id), targetId];
64
+ const { record, token } = await claimLease(backend, targetId, {
65
+ label,
66
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
67
+ liveIds,
68
+ });
69
+ return {
70
+ lease: token,
71
+ targetId,
72
+ url,
73
+ label: record.label,
74
+ ttlMs: record.ttlMs,
75
+ expiresAt: record.lastUsedAt + record.ttlMs,
76
+ };
77
+ }
78
+
79
+ /** Release a held lease. Idempotent: an already-released or reclaimed lease
80
+ * reports released:false rather than throwing. */
81
+ export async function releasePage(
82
+ _driver: BrowserDriver,
83
+ args: { lease?: LeaseToken },
84
+ ): Promise<{ released: boolean }> {
85
+ if (typeof args.lease !== "string" || !args.lease.length) {
86
+ throw new LeaseToolError("release_page requires the 'lease' token returned by claim_page");
87
+ }
88
+ return releaseLease(args.lease);
89
+ }
90
+
91
+ /** Enumerate active leases for diagnosis. Requires no token by design. */
92
+ export async function listLeasesTool(
93
+ driver: BrowserDriver,
94
+ _args: Record<string, never> = {} as Record<string, never>,
95
+ ): Promise<{ leases: LeaseSummary[]; count: number }> {
96
+ const liveIds = (await driver.listPages()).map((p) => p.id);
97
+ // liveBackend scopes the target-gone test to the browser these ids came from,
98
+ // so leases held on the OTHER backend are not mislabeled as reclaimable.
99
+ const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
100
+ return { leases, count: leases.length };
101
+ }
102
+
103
+ export const LEASE_TOOLS = {
104
+ claim_page: claimPage,
105
+ release_page: releasePage,
106
+ list_leases: listLeasesTool,
107
+ } satisfies Record<string, (driver: BrowserDriver, args: never) => Promise<unknown>>;