cdp-toolkit 1.9.0 → 1.9.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "1.9.0",
3
+ "version": "1.9.4",
4
4
  "description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 45 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -78,6 +78,7 @@
78
78
  "screencast:smoke": "bun run ./test/screencast-smoke.ts",
79
79
  "mcp:smoke": "bun run ./test/mcp-smoke.ts",
80
80
  "firefox:smoke": "bun run ./test/firefox-smoke.ts",
81
+ "firefox:attach:smoke": "bun run ./test/firefox-attach-smoke.ts",
81
82
  "lease:smoke": "bun run ./test/lease-smoke.ts",
82
83
  "staleness:smoke": "bun run ./test/staleness-smoke.ts",
83
84
  "input:smoke": "bun run ./test/input-smoke.ts",
package/src/backend.ts CHANGED
@@ -6,27 +6,42 @@
6
6
  * ships to npm and today's users must see zero behavior change unless they
7
7
  * opt in.
8
8
  *
9
- * Firefox cannot be attached to (see bidi/launch.ts's header) and its driver
10
- * is `lifetime: "session"` (ADR-001), so ownership differs by entry point:
11
- * - CLI: one process per invocation. startFirefoxSession() launches, the
12
- * caller runs exactly one tool call, then disposeFirefoxSession() tears
13
- * the session AND the spawned process down before the process exits.
14
- * - MCP: long-lived. getOrCreateFirefoxSession() launches at most once and
15
- * memoizes the session at module scope; disposeFirefoxSession() runs on
16
- * shutdown (SIGINT/SIGTERM/stdin close), never per tool call.
17
- * Both paths funnel through the same FirefoxSession so neither can leak a
18
- * Firefox process: dispose() always closes the BiDi session THEN kills the
19
- * spawned binary, in that order, and is idempotent.
9
+ * Firefox runs in one of TWO modes, and the difference between them is process
10
+ * ownership:
11
+ * - LAUNCH (default): this module spawns a throwaway-profile Firefox and OWNS
12
+ * it. dispose() closes the BiDi session THEN kills the spawned binary, in
13
+ * that order, so no invocation can leak a process.
14
+ * - ATTACH (`--connect` / CDP_FIREFOX_ENDPOINT): the user already started
15
+ * Firefox with --remote-debugging-port, so it carries their real logged-in
16
+ * profile and we merely connect to its BiDi endpoint. NOTHING on this path
17
+ * owns a process: dispose() ends the BiDi session (freeing Firefox's single
18
+ * session slot) and closes the socket, and MUST NEVER kill the browser.
19
+ * What is impossible is RELAUNCHING the binary to open a debug port on an
20
+ * already-running instance (bidi/launch.ts's header), not attaching to a
21
+ * port that is already open.
22
+ * The Firefox driver is `lifetime: "session"` (ADR-001), so session ownership
23
+ * also differs by entry point:
24
+ * - CLI: one process per invocation. startFirefoxSession() launches or
25
+ * attaches, the caller runs exactly one tool call, then
26
+ * disposeFirefoxSession() tears the session (and, in launch mode only, the
27
+ * spawned process) down before the process exits.
28
+ * - MCP: long-lived. getOrCreateFirefoxSession() launches/attaches at most
29
+ * once and memoizes the session at module scope; disposeFirefoxSession()
30
+ * runs on shutdown (SIGINT/SIGTERM/stdin close), never per tool call.
31
+ * Both paths funnel through the same FirefoxSession, whose dispose() is
32
+ * idempotent in either mode.
20
33
  */
21
34
  import type { BrowserDriver, DriverKind } from "./driver.ts";
22
- import { createFirefoxDriver } from "./bidi/driver.ts";
35
+ import { createFirefoxDriver, createFirefoxDriverForEndpoint } from "./bidi/driver.ts";
23
36
  import { launchFirefox } from "./bidi/launch.ts";
24
37
 
25
- /** Resolve the selected backend from argv (already flag-scanned) + env, defaulting to chrome. */
26
- export function resolveBrowserKind(argv: readonly string[]): DriverKind {
38
+ /** Resolve the selected backend from argv (already flag-scanned) + env, defaulting to chrome.
39
+ * `env` is injectable ONLY so a test (and resolveBackend, which must read one consistent env)
40
+ * can pass a fixed object; every caller that omits it gets exactly today's process.env read. */
41
+ export function resolveBrowserKind(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): DriverKind {
27
42
  const flagIdx = argv.indexOf("--browser");
28
43
  const flagVal = flagIdx !== -1 ? argv[flagIdx + 1] : undefined;
29
- const raw = flagVal ?? process.env.CDP_BROWSER ?? "chrome";
44
+ const raw = flagVal ?? env.CDP_BROWSER ?? "chrome";
30
45
  if (raw !== "chrome" && raw !== "firefox") {
31
46
  throw new Error(`unknown --browser '${raw}': expected 'chrome' or 'firefox' (or set CDP_BROWSER)`);
32
47
  }
@@ -43,13 +58,130 @@ export function stripBrowserFlag(argv: readonly string[]): string[] {
43
58
  return out;
44
59
  }
45
60
 
61
+ /* ------------------------------- Firefox attach endpoint ------------------------------- */
62
+
63
+ /**
64
+ * Normalize the three accepted spellings of a Firefox attach endpoint into the one thing the
65
+ * transport takes, a ws URL:
66
+ * `9223` -> ws://127.0.0.1:9223/session
67
+ * `127.0.0.1:9223` -> ws://127.0.0.1:9223/session
68
+ * `ws://host:9223/session` -> verbatim (also wss://, also a non-default path)
69
+ * The bare-port and host:port spellings exist because they are what the user typed into Firefox's
70
+ * own `--remote-debugging-port`; the ws URL exists because BiDi's endpoint is not discoverable over
71
+ * HTTP the way CDP's is (bidi/client.ts's footer), so a non-loopback or proxied setup has no other
72
+ * way to say where the socket is. Throws with the accepted spellings on anything else, rather than
73
+ * handing the transport a URL that will fail later as an opaque connect timeout.
74
+ */
75
+ export function normalizeBidiEndpoint(raw: string): string {
76
+ const value = raw.trim();
77
+ if (/^wss?:\/\//i.test(value)) {
78
+ let url: URL;
79
+ try {
80
+ url = new URL(value);
81
+ } catch {
82
+ throw new Error(`invalid Firefox endpoint '${raw}': not a parseable ws:// URL`);
83
+ }
84
+ // Firefox serves BiDi at /session; a user who pasted just the origin means that path, and
85
+ // silently connecting to "/" would fail as a bare handshake error with no hint why.
86
+ if (url.pathname === "" || url.pathname === "/") url.pathname = "/session";
87
+ return url.toString();
88
+ }
89
+ if (/^\d+$/.test(value)) return `ws://127.0.0.1:${assertPort(value, raw)}/session`;
90
+ const hostPort = /^([^:/]+):(\d+)$/.exec(value);
91
+ if (hostPort) return `ws://${hostPort[1]}:${assertPort(hostPort[2]!, raw)}/session`;
92
+ throw new Error(`invalid Firefox endpoint '${raw}': expected a port (9223), host:port, or a ws:// URL`);
93
+ }
94
+
95
+ /** Port validation shared by the bare-port and host:port spellings. Rejects 0 as well as >65535:
96
+ * 0 means "pick any free port" to a listener and nothing at all to a client dialing out. */
97
+ function assertPort(digits: string, raw: string): number {
98
+ const port = Number(digits);
99
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
100
+ throw new Error(`invalid Firefox endpoint '${raw}': port must be 1-65535`);
101
+ }
102
+ return port;
103
+ }
104
+
105
+ /**
106
+ * Resolve the attach endpoint, or undefined for launch mode. Flag beats env, mirroring
107
+ * resolveBrowserKind's precedence: `--connect <port|host:port|ws-url>` then CDP_FIREFOX_ENDPOINT.
108
+ * An empty value counts as unset so an exported-but-blank env var does not force attach mode.
109
+ */
110
+ export function resolveFirefoxEndpoint(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): string | undefined {
111
+ const flagIdx = argv.indexOf("--connect");
112
+ const raw = (flagIdx !== -1 ? argv[flagIdx + 1] : undefined) ?? env.CDP_FIREFOX_ENDPOINT;
113
+ if (raw === undefined || raw === "") return undefined;
114
+ return normalizeBidiEndpoint(raw);
115
+ }
116
+
117
+ /** Strip a `--connect <value>` pair out of argv so it never reaches tool-arg parsing, exactly as
118
+ * stripBrowserFlag does for --browser. */
119
+ export function stripConnectFlag(argv: readonly string[]): string[] {
120
+ const out: string[] = [];
121
+ for (let i = 0; i < argv.length; i++) {
122
+ if (argv[i] === "--connect") { i++; continue; }
123
+ out.push(argv[i]!);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** What one entry point needs to know about the backend: which driver, and (Firefox only) whether
129
+ * to attach to an existing endpoint instead of launching. */
130
+ export interface BackendSelection {
131
+ kind: DriverKind;
132
+ endpoint?: string;
133
+ }
134
+
135
+ /**
136
+ * The single backend-selection entry point for cli.ts and mcp.ts: kind + optional attach endpoint.
137
+ *
138
+ * An endpoint IMPLIES Firefox, because there is nothing else it could mean — CDP has no such
139
+ * option here (Chrome's attach is its own --remote-debugging-port convention on the CDP client) and
140
+ * a user who passes one has already told us which browser they started. The one case that must
141
+ * fail loudly rather than be silently overridden is an EXPLICIT `--browser chrome`/CDP_BROWSER=chrome
142
+ * alongside an endpoint: honoring either half of that would silently ignore the other.
143
+ */
144
+ export function resolveBackend(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): BackendSelection {
145
+ const endpoint = resolveFirefoxEndpoint(argv, env);
146
+ let kind = resolveBrowserKind(argv, env);
147
+ const explicitBrowser = argv.includes("--browser") || env.CDP_BROWSER !== undefined;
148
+ if (endpoint !== undefined) {
149
+ if (explicitBrowser && kind === "chrome") {
150
+ throw new Error("--connect / CDP_FIREFOX_ENDPOINT is a Firefox attach option and is not valid with the chrome backend");
151
+ }
152
+ kind = "firefox";
153
+ }
154
+ return endpoint !== undefined ? { kind, endpoint } : { kind };
155
+ }
156
+
46
157
  export interface FirefoxSession {
47
158
  driver: BrowserDriver;
48
159
  dispose(): Promise<void>;
49
160
  }
50
161
 
51
- /** Launch a fresh Firefox process and construct its Driver. Caller owns dispose(). */
52
- export async function startFirefoxSession(opts?: { headless?: boolean }): Promise<FirefoxSession> {
162
+ /**
163
+ * Start a Firefox session and construct its Driver. Caller owns dispose().
164
+ * With `endpoint`, ATTACHES to the user's already-running Firefox; without it, LAUNCHES a fresh
165
+ * throwaway-profile one. The two dispose() bodies differ in exactly one way, and it is the safety
166
+ * invariant of this whole feature: only the launch path closes a process.
167
+ */
168
+ export async function startFirefoxSession(opts?: { headless?: boolean; endpoint?: string }): Promise<FirefoxSession> {
169
+ if (opts?.endpoint !== undefined) {
170
+ const driver = createFirefoxDriverForEndpoint(opts.endpoint);
171
+ let disposed = false;
172
+ return {
173
+ driver,
174
+ async dispose(): Promise<void> {
175
+ if (disposed) return;
176
+ disposed = true;
177
+ // The ONLY teardown in attach mode. driver.dispose() sends session.end (so Firefox frees
178
+ // its single session slot for the next attach) and closes the socket. There is deliberately
179
+ // no process close here: we did not start this browser, and killing a user's real
180
+ // logged-in Firefox because a CLI invocation ended would be unforgivable.
181
+ await driver.dispose().catch(() => undefined);
182
+ },
183
+ };
184
+ }
53
185
  const launched = await launchFirefox({ headless: opts?.headless });
54
186
  const driver = createFirefoxDriver(launched.port);
55
187
  let disposed = false;
@@ -71,13 +203,16 @@ export async function startFirefoxSession(opts?: { headless?: boolean }): Promis
71
203
 
72
204
  let sessionPromise: Promise<FirefoxSession> | undefined;
73
205
 
74
- /** Lazily launch Firefox at most once per server process; every caller shares it. */
75
- export function getOrCreateFirefoxSession(): Promise<FirefoxSession> {
76
- if (!sessionPromise) sessionPromise = startFirefoxSession();
206
+ /** Lazily start the Firefox session at most once per server process; every caller shares it.
207
+ * `endpoint` is read only on the first call, which is correct: the backend selection is fixed at
208
+ * server startup (mcp.ts resolves it from argv/env once), never per tool call. */
209
+ export function getOrCreateFirefoxSession(opts?: { endpoint?: string }): Promise<FirefoxSession> {
210
+ if (!sessionPromise) sessionPromise = startFirefoxSession({ endpoint: opts?.endpoint });
77
211
  return sessionPromise;
78
212
  }
79
213
 
80
- /** Idempotent shutdown hook: no-op if Firefox was never launched this process. */
214
+ /** Idempotent shutdown hook: no-op if no Firefox session was ever started this process. Covers both
215
+ * modes — in attach mode it ends the BiDi session without touching the user's browser process. */
81
216
  export async function disposeFirefoxSession(): Promise<void> {
82
217
  if (!sessionPromise) return;
83
218
  const p = sessionPromise;
@@ -286,27 +286,62 @@ export class BidiConnection {
286
286
  }
287
287
 
288
288
  /**
289
- * Open a BiDi connection to a Firefox debug port and create its session in
290
- * one step, per the established endpoint shape ws://127.0.0.1:<port>/session.
291
- * This module does not launch Firefox; pass a port from launch.ts's
292
- * launchFirefox() or any other running Firefox with the debug port open.
289
+ * Open a BiDi connection to an explicit WebSocket endpoint and create its
290
+ * session in one step. THE URL IS THE IDENTITY: a Firefox this toolkit
291
+ * launched is ws://127.0.0.1:<port>/session, but attach mode (backend.ts's
292
+ * normalizeBidiEndpoint) may hand over any host/port/path the user's own
293
+ * Firefox is listening on, so nothing here reconstructs a URL from a port.
294
+ * This module never launches Firefox; the endpoint must already be listening.
293
295
  * The returned connection is retained once (holders === 1): the caller owns
294
296
  * that first retain and must release() or dispose() it.
297
+ *
298
+ * NOTE for attach callers: Firefox permits exactly ONE active BiDi session, and
299
+ * closing a socket does NOT free that slot (verified on 153.0.3) — only
300
+ * session.end does. A second session.new against a Firefox that still holds an
301
+ * abandoned session fails with "Maximum number of active sessions"; driver.ts
302
+ * turns that into an actionable "disconnected" DriverError.
295
303
  */
296
- export async function connectBidiSession(
297
- port: number,
304
+ export async function connectBidiSessionUrl(
305
+ wsUrl: string,
298
306
  opts: {
299
307
  timeoutMs?: number;
300
308
  capabilities?: BidiCommands["session.new"]["params"]["capabilities"];
301
309
  } = {},
302
310
  ): Promise<BidiConnection> {
303
- const conn = new BidiConnection(`ws://127.0.0.1:${port}/session`, { timeoutMs: opts.timeoutMs });
311
+ const conn = new BidiConnection(wsUrl, { timeoutMs: opts.timeoutMs });
304
312
  await conn.connect();
305
- await conn.newSession(opts.capabilities ?? {});
313
+ try {
314
+ await conn.newSession(opts.capabilities ?? {});
315
+ } catch (e) {
316
+ // A REJECTED session.new still leaves an OPEN socket, and an open WebSocket is a live handle
317
+ // that keeps the event loop alive: without this the CLI printed "Maximum number of active
318
+ // sessions" and then hung forever instead of exiting (measured against Firefox 153.0.3 while
319
+ // a second client held the one session slot). Close it before rethrowing so a failed connect
320
+ // costs an error and nothing else.
321
+ conn.dispose();
322
+ throw e;
323
+ }
306
324
  conn.retain();
307
325
  return conn;
308
326
  }
309
327
 
328
+ /**
329
+ * Port-shaped convenience wrapper over connectBidiSessionUrl, for the launched
330
+ * Firefox whose endpoint is always the loopback shape ws://127.0.0.1:<port>/session.
331
+ * Pass a port from launch.ts's launchFirefox() or any other running Firefox with
332
+ * the debug port open on loopback. Kept exported for back-compat with callers
333
+ * that only ever hold a port.
334
+ */
335
+ export async function connectBidiSession(
336
+ port: number,
337
+ opts: {
338
+ timeoutMs?: number;
339
+ capabilities?: BidiCommands["session.new"]["params"]["capabilities"];
340
+ } = {},
341
+ ): Promise<BidiConnection> {
342
+ return connectBidiSessionUrl(`ws://127.0.0.1:${port}/session`, opts);
343
+ }
344
+
310
345
  /*
311
346
  * BiDi modules/commands used: session (session.new, session.subscribe,
312
347
  * session.unsubscribe). All other modules (browsingContext, script, input,
@@ -314,6 +349,9 @@ export async function connectBidiSession(
314
349
  * typed send()/on()/waitFor() surface above but this file itself calls none
315
350
  * of their commands directly, that is left to the driver and tool layers
316
351
  * built on top. Parity gap vs client.ts: there is no browserWsUrl/listTargets
317
- * equivalent here, BiDi's endpoint discovery is the single fixed
318
- * ws://127.0.0.1:<port>/session path, so no HTTP discovery step exists.
352
+ * equivalent here and no HTTP discovery step at all — the ws URL IS the
353
+ * endpoint identity. For a Firefox this toolkit launched that URL is derived
354
+ * from a port (the fixed loopback shape ws://127.0.0.1:<port>/session); for an
355
+ * attached one it is whatever the user configured, so it may carry a different
356
+ * host, port, or path and must be passed through verbatim.
319
357
  */