cdp-toolkit 1.10.0 → 2.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "1.10.0",
3
+ "version": "2.0.0",
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",
@@ -79,16 +79,18 @@
79
79
  "mcp:smoke": "bun run ./test/mcp-smoke.ts",
80
80
  "firefox:smoke": "bun run ./test/firefox-smoke.ts",
81
81
  "firefox:attach:smoke": "bun run ./test/firefox-attach-smoke.ts",
82
+ "firefox:coord:smoke": "bun run ./test/firefox-session-coord-smoke.ts",
82
83
  "lease:smoke": "bun run ./test/lease-smoke.ts",
83
84
  "staleness:smoke": "bun run ./test/staleness-smoke.ts",
84
85
  "input:smoke": "bun run ./test/input-smoke.ts",
85
86
  "extension:smoke": "bun run ./test/extension-smoke.ts"
86
87
  },
87
88
  "devDependencies": {
89
+ "@modelcontextprotocol/client": "^2.0.0",
88
90
  "@types/node": "22.10.10",
89
91
  "typescript": "5.7.3"
90
92
  },
91
93
  "dependencies": {
92
- "@modelcontextprotocol/sdk": "1.29.0"
94
+ "@modelcontextprotocol/server": "^2.0.0"
93
95
  }
94
96
  }
@@ -40,11 +40,16 @@ import {
40
40
  type ScreenshotOptions, type ScreenshotResult, type ScrollOptions, type SnapshotNode, type UidStability,
41
41
  type BandConsumer, type CaptureDelivery,
42
42
  } from "../driver.ts";
43
- import { assertLeaseOk } from "../leases.ts";
43
+ import { assertLeaseOk, defaultLabel } from "../leases.ts";
44
44
  import { resolveLiveLabel } from "../origins.ts";
45
45
  import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
46
46
  import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
47
47
  import { BidiConnection, BidiError, connectBidiSessionUrl } from "./client.ts";
48
+ import {
49
+ acquireSessionSlot, releaseSessionSlot, SessionSlotBusyError,
50
+ type AcquireResult, type SessionSlotHandle,
51
+ } from "./session-lease.ts";
52
+ import { defaultMarionettePort, forceClearOrphanSession } from "./marionette.ts";
48
53
  import { takeStampedSnapshot } from "./snapshot.ts";
49
54
  import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
50
55
  import type {
@@ -131,6 +136,20 @@ function mapBidiError(e: unknown): DriverError {
131
136
  if (code === "unsupported operation") return driverError("unsupported", e.message);
132
137
  if (!code && /timed out/i.test(e.message)) return driverError("timeout", e.message);
133
138
  if (!code && /(disposed|not open|connection closed)/i.test(e.message)) return driverError("disconnected", e.message);
139
+ // NS_ERROR_FILE_ACCESS_DENIED from browsingContext.navigate means the browser process was
140
+ // refused at the OS level while opening the file:// target — not a protocol failure. Two
141
+ // mechanisms produce it (#6): (1) the automation prefs that relax file:// handling were never
142
+ // applied — launch.ts now seeds them via user.js, so this should not surface for a
143
+ // toolkit-launched Firefox; (2) kernel-level confinement of the browser itself (Ubuntu's snap
144
+ // Firefox is the common one: a snap process cannot see host paths outside its mount
145
+ // namespace), which no preference can bypass. The error must name the way out rather than
146
+ // relay the raw nsresult.
147
+ if (/NS_ERROR_FILE_ACCESS_DENIED/i.test(e.message)) {
148
+ return driverError(
149
+ "page-error",
150
+ `${e.message} — the browser process was not allowed to open this file:// path. For a snap- or AppArmor-confined Firefox (Ubuntu default) that is kernel-level and no preference fixes it: serve the directory over a local http server (e.g. python3 -m http.server) and navigate to the http://127.0.0.1:<port>/ URL instead. If this Firefox was launched by cdp-toolkit and still fails, please report it at https://github.com/sblattj/cdp-toolkit/issues.`,
151
+ );
152
+ }
134
153
  return driverError("page-error", e.message);
135
154
  }
136
155
  return driverError("page-error", e instanceof Error ? e.message : String(e));
@@ -320,39 +339,174 @@ const BIDI_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
320
339
  // the only identity both modes share. getConnection retains a holder on every call (fresh or
321
340
  // reused); callers release() it.
322
341
  const connections = new Map<string, BidiConnection>();
342
+ // This process's OWN Firefox BiDi session-slot ownership, keyed by endpoint (see session-lease.ts).
343
+ // The slot LOCK is a file on disk — that is the cross-process source of truth — and this map is our
344
+ // in-process record of which endpoints WE currently hold a live claim on, so dispose() releases
345
+ // exactly the slots we took, and a re-dial after a dead socket reuses our own claim instead of
346
+ // contending with our own pid. Populated in getConnection's create body; cleared on dispose() and
347
+ // on a terminal dial failure that never established a session.
348
+ const slotHandles = new Map<string, SessionSlotHandle>();
349
+ // In-flight create dedup, keyed by endpoint. Two page() acquisitions can both cache-miss and call
350
+ // getConnection concurrently; without this both would run the acquire+dial body, and the second
351
+ // would either contend for the slot we just took (same pid → wait then SessionSlotBusyError against
352
+ // ourselves) or open a second BiDi session. The first caller stores its create-promise here and
353
+ // every other awaits it; cleared in a finally once the create settles.
354
+ const connecting = new Map<string, Promise<BidiConnection>>();
355
+
356
+ /**
357
+ * Release a slot claim we took in THIS getConnection call after a dial that never established a
358
+ * live session, so a later attempt re-acquires cleanly. When `owned` is true we did NOT acquire in
359
+ * this call (we reused a pre-existing claim — our previous socket died without dispose), so the
360
+ * handle predates this call and must be left intact: we still own Firefox's slot, only the socket
361
+ * failed to re-establish.
362
+ */
363
+ async function releaseSlotOnDialFailure(endpoint: string, owned: boolean): Promise<void> {
364
+ if (owned) return;
365
+ const h = slotHandles.get(endpoint);
366
+ if (h) {
367
+ await releaseSessionSlot(h).catch(() => undefined);
368
+ slotHandles.delete(endpoint);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * The actionable error for issue #4: Firefox is holding an orphaned BiDi session on this endpoint
374
+ * that Marionette recovery could NOT auto-clear (Firefox launched without --marionette, or the
375
+ * Marionette channel timed out / could not confirm). Distinguished from the live-busy case (issue
376
+ * #5, a LIVE lease holder) because here NO live process holds our lease — a session Firefox itself
377
+ * stranded is wedging the slot, and the fix is to enable Marionette or restart Firefox.
378
+ */
379
+ function orphanNotClearedError(endpoint: string): DriverError {
380
+ return driverError("disconnected",
381
+ `Firefox holds an orphaned WebDriver BiDi session on ${endpoint} that could not be auto-cleared. ` +
382
+ `Marionette recovery needs Firefox launched with --marionette (default port 2828, or set ` +
383
+ `CDP_FIREFOX_MARIONETTE_PORT); otherwise restart Firefox to free the slot. (A client killed without ` +
384
+ `session.end can leave a session Firefox does not reap.)`);
385
+ }
386
+
387
+ // getConnection: the ONE Firefox-session choke point. Beyond memoizing a BidiConnection per endpoint
388
+ // it now runs the cross-process SESSION-slot coordination (session-lease.ts) plus Marionette orphan
389
+ // recovery (marionette.ts). Firefox serves exactly ONE WebDriver-BiDi session per browser, and it
390
+ // never reaps an abandoned one (verified: still held 10s after an abrupt socket death). Two ills
391
+ // follow that this closes: (#5) two LIVE processes attaching to one user-launched Firefox used to
392
+ // hard-wedge on the raw "Maximum number of active sessions" with no way to tell a live holder from a
393
+ // crash; the lease now names the LIVE holder and says wait-and-retry. (#4) a process killed without
394
+ // session.end used to leave the slot occupied until Firefox restarted; when the lease shows the
395
+ // prior holder's PID is DEAD, or Firefox itself still refuses on a re-dial, a blind Marionette
396
+ // WebDriver:DeleteSession (which shares Firefox's one session slot) force-clears the orphan WITHOUT
397
+ // killing the user's browser, and we retry. Marionette recovery is best-effort and degrades to a
398
+ // clear, actionable error when Firefox has no --marionette port (the launch-mode norm).
323
399
  async function getConnection(endpoint: string, timeoutMs?: number): Promise<BidiConnection> {
324
400
  const existing = connections.get(endpoint);
325
401
  if (existing && existing.isOpen) { existing.retain(); return existing; }
326
402
  // A cached connection whose socket has died (ws.onclose fired) can only reject every
327
403
  // send() with "connection not open" — reusing it would strand the endpoint permanently.
328
- // Evict it so the code below re-dials and creates a fresh BiDi session. No cleanup is
329
- // needed: a dead connection already had `closed` set and rejectAll() cleared its per-call
330
- // timers, and BidiConnection holds no heartbeat/keepalive resource to leak (see client.ts).
404
+ // Evict it so the code below re-dials and creates a fresh BiDi session. We deliberately do NOT
405
+ // release the session slot here: a dead socket is transient and WE still own Firefox's slot, so
406
+ // the create body below re-dials WITHOUT re-acquiring (its `owned` branch), avoiding a needless
407
+ // contention against our own pid. No other cleanup is needed: a dead connection already had
408
+ // `closed` set and rejectAll() cleared its per-call timers, and BidiConnection holds no
409
+ // heartbeat/keepalive resource to leak (see client.ts).
331
410
  if (existing) connections.delete(endpoint);
332
- // Firefox 153's default unhandledPromptBehavior is "dismiss": a page-blocking alert()/confirm()
333
- // gets auto-dismissed before handleDialog() ever runs, and Firefox's own userPromptOpened event
334
- // reports handler:"dismiss" on it (verified: a bare session.new left "no such alert" on the very
335
- // next handleUserPrompt call). Setting "ignore" here is what makes handleDialog() able to see
336
- // and answer a live prompt at all.
337
- const conn = await connectBidiSessionUrl(endpoint, {
338
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
339
- capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } },
340
- }).catch((e: unknown) => {
341
- // Firefox allows exactly ONE active BiDi session and never reaps an abandoned one (verified:
342
- // still held 10s after an abrupt socket death). A client killed without session.end therefore
343
- // leaves the slot occupied until Firefox restarts, and the raw wire message ("Maximum number of
344
- // active sessions") tells the user nothing about how to get unstuck. Force-ending a session we
345
- // do not own is not an option (there is no id to end), so this maps the error to instructions.
346
- if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
347
- throw driverError("disconnected",
348
- `Firefox already has an active WebDriver BiDi session on ${endpoint}; it allows only one. ` +
349
- `Close the other client, or restart Firefox, then retry. (A previously killed client can leave a ` +
350
- `session that Firefox does not reap.)`);
411
+
412
+ // Concurrency: dedup in-flight creates on this endpoint. An awaiter shares the create's outcome
413
+ // but needs its OWN holder — the create's connectBidiSessionUrl retains exactly once, for the
414
+ // caller that started it, so every other caller retains again for itself (callers all release()).
415
+ const inflight = connecting.get(endpoint);
416
+ if (inflight) {
417
+ const conn = await inflight;
418
+ conn.retain();
419
+ return conn;
420
+ }
421
+
422
+ const create = (async (): Promise<BidiConnection> => {
423
+ const mport = defaultMarionettePort();
424
+ // Do we ALREADY hold this endpoint's slot? (e.g. our previous socket died without dispose, so
425
+ // the eviction above kept the claim.) If so, re-dial WITHOUT re-acquiring: acquireSessionSlot
426
+ // against our own live pid would just poll to its wait ceiling and then throw
427
+ // SessionSlotBusyError against ourselves. We already own Firefox's slot; only the socket needs
428
+ // re-establishing (and if Firefox stranded our old session, the re-dial's retry path clears it).
429
+ const owned = slotHandles.has(endpoint);
430
+ if (!owned) {
431
+ // Cross-process coordination (session-lease.ts): take the endpoint's single BiDi session slot,
432
+ // classifying any current holder. A LIVE holder (another agent legitimately driving this
433
+ // Firefox) throws SessionSlotBusyError; a DEAD holder's orphan is stolen and surfaced as
434
+ // staleHolder so we can force-clear Firefox's stranded session before we call session.new.
435
+ let acq: AcquireResult;
436
+ try {
437
+ acq = await acquireSessionSlot(endpoint, { label: defaultLabel(), marionettePort: mport });
438
+ } catch (e) {
439
+ if (e instanceof SessionSlotBusyError) {
440
+ // Issue #5: a distinguishable LIVE holder, NOT a wedge. Map to an actionable disconnected
441
+ // error that names who holds the one session and why Firefox can serve only one per browser.
442
+ const who = e.holderLabel ? `'${e.holderLabel}' (pid ${e.holderPid})` : "another live process";
443
+ throw driverError("disconnected",
444
+ `Firefox's single WebDriver BiDi session on ${endpoint} is held by a LIVE process ${who}. ` +
445
+ `Firefox serves exactly ONE BiDi session per browser instance, so this server cannot take it ` +
446
+ `while that process is running. Wait for it to release the session and retry, or point this ` +
447
+ `server at a different endpoint/browser.`);
448
+ }
449
+ throw e;
450
+ }
451
+ slotHandles.set(endpoint, acq.handle);
452
+ if (acq.staleHolder) {
453
+ // Issue #4 auto-recovery: we stole a DEAD holder's lease, but Firefox itself may still hold
454
+ // that holder's orphaned BiDi session. Force-clear it over Marionette BEFORE dialing, or
455
+ // session.new below will still hit "Maximum number of active sessions". Best-effort: a false
456
+ // result means Firefox has no --marionette (launch mode, or the user never enabled it); we
457
+ // still dial, and the re-dial retry path below surfaces a clear error if the orphan persists.
458
+ await forceClearOrphanSession(acq.staleHolder.marionettePort ?? mport);
459
+ }
460
+ }
461
+
462
+ // Firefox 153's default unhandledPromptBehavior is "dismiss": a page-blocking alert()/confirm()
463
+ // gets auto-dismissed before handleDialog() ever runs, and Firefox's own userPromptOpened event
464
+ // reports handler:"dismiss" on it (verified: a bare session.new left "no such alert" on the very
465
+ // next handleUserPrompt call). Setting "ignore" here is what makes handleDialog() able to see
466
+ // and answer a live prompt at all.
467
+ const dialOpts = {
468
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
469
+ capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" as const } } },
470
+ };
471
+ let conn: BidiConnection;
472
+ try {
473
+ conn = await connectBidiSessionUrl(endpoint, dialOpts); // already retained once
474
+ } catch (e) {
475
+ if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
476
+ // Firefox still holds a BiDi session our lease didn't account for: a FOREIGN client we don't
477
+ // coordinate with, OUR OWN orphan from a dead socket (the `owned` re-dial), or a pre-lease
478
+ // orphan. Recovery: blind Marionette force-clear, then retry the dial EXACTLY once.
479
+ const cleared = await forceClearOrphanSession(mport);
480
+ if (!cleared) {
481
+ await releaseSlotOnDialFailure(endpoint, owned);
482
+ throw orphanNotClearedError(endpoint);
483
+ }
484
+ try {
485
+ conn = await connectBidiSessionUrl(endpoint, dialOpts); // already retained once
486
+ } catch (e2) {
487
+ await releaseSlotOnDialFailure(endpoint, owned);
488
+ if (e2 instanceof BidiError && /maximum number of active sessions/i.test(e2.message)) {
489
+ throw orphanNotClearedError(endpoint); // cleared, yet still refused: treat as unrecoverable
490
+ }
491
+ throw e2;
492
+ }
493
+ } else {
494
+ // Any other dial failure never established a session: release a slot we took THIS call and
495
+ // rethrow unchanged (the `owned` re-dial never acquired, so its claim is left intact).
496
+ await releaseSlotOnDialFailure(endpoint, owned);
497
+ throw e;
498
+ }
351
499
  }
352
- throw e;
353
- }); // already retained once
354
- connections.set(endpoint, conn);
355
- return conn;
500
+ connections.set(endpoint, conn);
501
+ return conn;
502
+ })();
503
+
504
+ connecting.set(endpoint, create);
505
+ try {
506
+ return await create;
507
+ } finally {
508
+ connecting.delete(endpoint);
509
+ }
356
510
  }
357
511
 
358
512
  /**
@@ -515,7 +669,21 @@ class BidiPageDriver implements PageDriver {
515
669
  const sendOpts = opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
516
670
  try {
517
671
  if (reload) {
518
- const r = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
672
+ // Firefox rejects ANY `ignoreCache` argument to browsingContext.reload — even an
673
+ // explicit `false` — with `Argument "ignoreCache" is not supported yet` (observed on
674
+ // Firefox 153.0.3): the server-side validator refuses the key unconditionally, it does
675
+ // not read its value. So the key is OMITTED from the params unless a hard reload was
676
+ // actually requested, and a requested hard reload fails fast HERE with a
677
+ // backend-capability error instead of an opaque mid-flight protocol rejection — the
678
+ // same doctrine as click()'s modifiers on this backend. If a future Firefox starts
679
+ // honoring the argument, re-verify and lift the guard.
680
+ if (opts.ignoreCache === true) {
681
+ throw driverError(
682
+ "unsupported",
683
+ "navigate: ignoreCache:true (hard reload) is not supported on the Firefox WebDriver-BiDi backend — Firefox rejects the ignoreCache argument outright. A plain reload (reload:true, no ignoreCache) works.",
684
+ );
685
+ }
686
+ const r = await this.conn.send("browsingContext.reload", { context: this.contextId, wait }, sendOpts);
519
687
  return { url: r.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
520
688
  }
521
689
  if (history) {
@@ -1333,8 +1501,17 @@ class BidiBrowserDriver implements BrowserDriver {
1333
1501
  for (const key of [...bidiBeaconScripts.keys()]) {
1334
1502
  if (key.startsWith(`${this.endpoint}:`)) bidiBeaconScripts.delete(key);
1335
1503
  }
1504
+ // Drop any in-flight create record defensively: dispose() ends this endpoint's life, so a
1505
+ // pending create must not leave a stale promise that a later getConnection would await.
1506
+ connecting.delete(this.endpoint);
1336
1507
  const conn = connections.get(this.endpoint);
1337
- if (!conn) return;
1508
+ if (!conn) {
1509
+ // No live connection, but we may still hold the session slot lock (e.g. a create acquired the
1510
+ // slot then failed to establish a session on an `owned` re-dial, which leaves the claim). A
1511
+ // clean dispose must free it cross-process so the next process is not falsely told we're LIVE.
1512
+ await this.releaseSessionSlotIfHeld();
1513
+ return;
1514
+ }
1338
1515
  connections.delete(this.endpoint);
1339
1516
  // Cleanly END the BiDi session so Firefox frees its single session slot for the next
1340
1517
  // attach (a launch-mode Firefox is killed anyway; this matters for attach mode where the
@@ -1343,6 +1520,19 @@ class BidiBrowserDriver implements BrowserDriver {
1343
1520
  // Best-effort: harmless if the socket is already gone.
1344
1521
  await conn.send("session.end", {}).catch(() => undefined);
1345
1522
  conn.dispose();
1523
+ // Release OUR cross-process session-slot lock so the next process (this or another) can take the
1524
+ // slot cleanly. releaseSessionSlot only unlinks a record still stamped with our (pid, createdAt),
1525
+ // so it never clobbers a claim a later process made after stealing ours. (session-lease.ts)
1526
+ await this.releaseSessionSlotIfHeld();
1527
+ }
1528
+
1529
+ /** Free this endpoint's session-slot lock if this process holds it, and forget the handle.
1530
+ * Best-effort and idempotent — safe to call whether or not a live connection existed. */
1531
+ private async releaseSessionSlotIfHeld(): Promise<void> {
1532
+ const h = slotHandles.get(this.endpoint);
1533
+ if (!h) return;
1534
+ await releaseSessionSlot(h).catch(() => undefined);
1535
+ slotHandles.delete(this.endpoint);
1346
1536
  }
1347
1537
  }
1348
1538
 
@@ -22,7 +22,7 @@
22
22
  * node:os and node:path.
23
23
  */
24
24
  import { spawn, type ChildProcess } from "node:child_process";
25
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
25
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
26
26
  import { createConnection, createServer } from "node:net";
27
27
  import { tmpdir } from "node:os";
28
28
  import { join } from "node:path";
@@ -145,6 +145,55 @@ function waitForExit(proc: ChildProcess, deadlineMs: number): Promise<void> {
145
145
  });
146
146
  }
147
147
 
148
+ /**
149
+ * Automation prefs seeded into every THROWAWAY profile this toolkit launches (#6).
150
+ *
151
+ * Firefox's Remote Agent has its own recommended automation-pref bundle, but it is gated on
152
+ * `remote.prefs.recommended`, which DEFAULTS TO FALSE — a bare `--remote-debugging-port`
153
+ * launch therefore runs with consumer prefs (verified in mozilla-central's
154
+ * remote/shared/RecommendedPreferences.sys.mjs; `defineLazyPreferenceGetter(..., "remote.prefs.recommended", false)`).
155
+ * The two file://-relevant consequences, and the reason each pref is here:
156
+ *
157
+ * - `remote.prefs.recommended: true` — flips on Firefox's own bundle at BiDi session start. This
158
+ * is the same mechanism geckodriver/Marionette automation leans on.
159
+ * - `security.fileuri.strict_origin_policy: false` — a member of that bundle, also set explicitly
160
+ * so the behavior does not depend on the bundle's exact contents per Firefox version. Without
161
+ * it, file:// documents get unique opaque origins and sibling file:// subresources fail.
162
+ * - `dom.file.createInChild: true` — the bundle's file-API automation companion pref.
163
+ *
164
+ * Only the throwaway profile is seeded. An explicit `profilePath` belongs to its owner: writing
165
+ * prefs into someone's real profile (where `security.fileuri.strict_origin_policy: false` would
166
+ * persist as a security relaxation of their daily browsing) is not this toolkit's call to make.
167
+ */
168
+ const LAUNCH_USER_JS_PREFS: Record<string, boolean> = {
169
+ "remote.prefs.recommended": true,
170
+ "security.fileuri.strict_origin_policy": false,
171
+ "dom.file.createInChild": true,
172
+ };
173
+
174
+ /**
175
+ * Seed `user.js` in a profile directory with the toolkit's automation prefs. Firefox reads
176
+ * user.js exactly once, at profile startup — which is why this runs BEFORE spawn, never after.
177
+ * Merges with an existing user.js (never rewrites a pref that file already sets, never drops a
178
+ * line it did not write) so the function is idempotent and safe on a reused profile dir.
179
+ * Exported for unit tests; returns the file's final content.
180
+ */
181
+ export function writeLaunchUserJs(profilePath: string): string {
182
+ const target = join(profilePath, "user.js");
183
+ const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
184
+ const present = new Set<string>();
185
+ for (const line of existing.split("\n")) {
186
+ const m = /^user_pref\("([^"]+)"\s*,/.exec(line.trim());
187
+ if (m) present.add(m[1]!);
188
+ }
189
+ const additions = Object.entries(LAUNCH_USER_JS_PREFS)
190
+ .filter(([k]) => !present.has(k))
191
+ .map(([k, v]) => `user_pref("${k}", ${JSON.stringify(v)});`);
192
+ const content = (existing.length > 0 ? existing.replace(/\n*$/, "\n") : "") + additions.join("\n") + (additions.length > 0 ? "\n" : "");
193
+ if (content !== existing) writeFileSync(target, content, "utf-8");
194
+ return content;
195
+ }
196
+
148
197
  /**
149
198
  * Launch Firefox with the BiDi debug port enabled and resolve once the port
150
199
  * actually accepts a TCP connection. Never resolves on a fixed sleep.
@@ -156,6 +205,7 @@ export async function launchFirefox(opts: LaunchFirefoxOptions = {}): Promise<La
156
205
 
157
206
  const isThrowaway = opts.profilePath === undefined;
158
207
  const profilePath = opts.profilePath ?? mkdtempSync(join(tmpdir(), "cdp-toolkit-ff-profile-"));
208
+ if (isThrowaway) writeLaunchUserJs(profilePath);
159
209
 
160
210
  const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
161
211
  if (opts.headless) args.push("--headless");