cdp-toolkit 2.0.0 → 2.2.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": "2.0.0",
3
+ "version": "2.2.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",
@@ -80,6 +80,7 @@
80
80
  "firefox:smoke": "bun run ./test/firefox-smoke.ts",
81
81
  "firefox:attach:smoke": "bun run ./test/firefox-attach-smoke.ts",
82
82
  "firefox:coord:smoke": "bun run ./test/firefox-session-coord-smoke.ts",
83
+ "firefox:multi:smoke": "bun run ./test/firefox-multi-agent-smoke.ts",
83
84
  "lease:smoke": "bun run ./test/lease-smoke.ts",
84
85
  "staleness:smoke": "bun run ./test/staleness-smoke.ts",
85
86
  "input:smoke": "bun run ./test/input-smoke.ts",
@@ -29,6 +29,9 @@
29
29
  * function through script.callFunction and PARSE its text back into SnapshotNode[], which is
30
30
  * genuinely new integration work, not a reimplementation of the walker itself.
31
31
  */
32
+ import { spawn } from "node:child_process";
33
+ import { fileURLToPath } from "node:url";
34
+ import { existsSync } from "node:fs";
32
35
  import type { TargetSelector } from "../types.ts";
33
36
  import {
34
37
  UID_STAMP_ATTR, isDriverError, interpolatePoints, clearViewportRecord, readViewportRecord, renderRestoreFailureNote, writeViewportRecord,
@@ -40,15 +43,16 @@ import {
40
43
  type ScreenshotOptions, type ScreenshotResult, type ScrollOptions, type SnapshotNode, type UidStability,
41
44
  type BandConsumer, type CaptureDelivery,
42
45
  } from "../driver.ts";
43
- import { assertLeaseOk, defaultLabel } from "../leases.ts";
46
+ import { assertLeaseOk, defaultLabel, isPidAlive } from "../leases.ts";
44
47
  import { resolveLiveLabel } from "../origins.ts";
45
48
  import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
46
49
  import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
47
50
  import { BidiConnection, BidiError, connectBidiSessionUrl } from "./client.ts";
48
51
  import {
49
- acquireSessionSlot, releaseSessionSlot, SessionSlotBusyError,
50
- type AcquireResult, type SessionSlotHandle,
52
+ acquireSessionSlot, advertiseMux, muxDaemonLogFile, readSessionSlot, releaseSessionSlot, sessionWaitMs, SessionSlotBusyError,
53
+ type AcquireResult, type SessionSlotHandle, type SessionSlotRecord,
51
54
  } from "./session-lease.ts";
55
+ import { startBidiMux, type BidiMux } from "./mux.ts";
52
56
  import { defaultMarionettePort, forceClearOrphanSession } from "./marionette.ts";
53
57
  import { takeStampedSnapshot } from "./snapshot.ts";
54
58
  import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
@@ -346,12 +350,150 @@ const connections = new Map<string, BidiConnection>();
346
350
  // contending with our own pid. Populated in getConnection's create body; cleared on dispose() and
347
351
  // on a terminal dial failure that never established a session.
348
352
  const slotHandles = new Map<string, SessionSlotHandle>();
353
+ // The BiDi multiplexer WE host, keyed by the FIREFOX endpoint whose real session it fronts (see
354
+ // ./mux.ts). Populated by getConnection's holder path right after the real connection is cached;
355
+ // closed first thing in dispose(), so joiners see their sockets drop while we still hold the slot.
356
+ // Absent when this process is not a holder, when startBidiMux failed (the holder just runs alone),
357
+ // or under CDP_FIREFOX_MUX=off.
358
+ const muxes = new Map<string, BidiMux>();
359
+ // Firefox endpoints this process reached THROUGH another process's mux rather than by owning the
360
+ // session. Keyed by the FIREFOX endpoint (the connections map's key) even though the socket really
361
+ // points at the holder's loopback mux, so page()/the beacon map/dispose all stay keyed on one
362
+ // identity. Membership means exactly two things: we hold NO session slot for this endpoint (never
363
+ // release one), and a dead socket here must NOT take the `owned` re-dial branch — it must re-run
364
+ // acquire, which either joins the new holder or wins the slot outright.
365
+ const joined = new Set<string>();
349
366
  // In-flight create dedup, keyed by endpoint. Two page() acquisitions can both cache-miss and call
350
367
  // getConnection concurrently; without this both would run the acquire+dial body, and the second
351
368
  // would either contend for the slot we just took (same pid → wait then SessionSlotBusyError against
352
369
  // ourselves) or open a second BiDi session. The first caller stores its create-promise here and
353
370
  // every other awaits it; cleared in a finally once the create settles.
354
371
  const connecting = new Map<string, Promise<BidiConnection>>();
372
+ // Per-endpoint SESSION EPOCH: how many times THIS process has established a connection to this
373
+ // endpoint (as a holder, or through a mux). Firefox regenerates every browsing-context id whenever
374
+ // its single BiDi session is re-created, so any id this process handed out before an epoch bump is
375
+ // dead — measured on Firefox 153.0.3: after session.end + session.new the tab survives at the same
376
+ // url but browsingContext.getTree reports a NEW context id. The epoch is what lets a
377
+ // "no such frame"/"no context matching" failure say WHY instead of reading like a typo. We track it
378
+ // per ENDPOINT, not per id (the brief's permitted simplification): the hint is appended whenever
379
+ // epoch > 1, which can over-attach the hint to an id that was simply closed, and never under-attach.
380
+ const sessionEpochs = new Map<string, number>();
381
+ // Reverse lookup so resolveContext (which only has a BidiConnection) can find its endpoint's epoch.
382
+ const connEndpoints = new WeakMap<BidiConnection, string>();
383
+
384
+ function bumpEpoch(endpoint: string, conn: BidiConnection): void {
385
+ sessionEpochs.set(endpoint, (sessionEpochs.get(endpoint) ?? 0) + 1);
386
+ connEndpoints.set(conn, endpoint);
387
+ }
388
+
389
+ /** Appended to a no-such-target error once this process has re-connected at least once. */
390
+ const STALE_ID_HINT =
391
+ " — the Firefox BiDi session was re-established after its previous holder went away, and Firefox " +
392
+ "assigns new context ids per session — re-resolve the tab with list_pages (url:/title: selectors) " +
393
+ "and claim it again";
394
+
395
+ function withStaleIdHint(conn: BidiConnection, err: unknown): unknown {
396
+ const endpoint = connEndpoints.get(conn);
397
+ if (endpoint === undefined) return err;
398
+ if ((sessionEpochs.get(endpoint) ?? 0) <= 1) return err;
399
+ const e = err as DriverError;
400
+ if (!isDriverError(e) || e.code !== "no-such-target") return err;
401
+ if (e.message.includes("re-established")) return err;
402
+ e.message += STALE_ID_HINT;
403
+ return e;
404
+ }
405
+
406
+ /**
407
+ * Which mux strategy this connection uses. `off` is the pre-mux behavior (no mux hosted,
408
+ * an advertised one ignored). `host` hosts the mux IN THIS PROCESS — correct for LAUNCH mode
409
+ * (a private Firefox this toolkit owns, which dies with us anyway) and an opt-in for anyone who
410
+ * does not want a stray process. Unset/`daemon` in ATTACH mode is the DEFAULT and the whole point
411
+ * of the daemon: the real Firefox session must not live in a client process, because when that
412
+ * client exits — even cleanly — Firefox regenerates every context id and every other client's
413
+ * cached ids, tab leases and origin records go stale.
414
+ */
415
+ type MuxMode = "off" | "host" | "daemon";
416
+ function muxMode(attached: boolean): MuxMode {
417
+ const raw = process.env.CDP_FIREFOX_MUX;
418
+ if (raw === "off") return "off";
419
+ if (raw === "host") return "host";
420
+ if (raw === "daemon") return "daemon";
421
+ return attached ? "daemon" : "host";
422
+ }
423
+
424
+ /** Absolute path of the daemon entry point, or undefined if we cannot find one to spawn.
425
+ * Order: explicit env override (tests), the source tree (`bun run src/…`), then the built
426
+ * bundle. `import.meta.url` inside dist is dist/{mcp,cli,index}.js, so the sibling is
427
+ * dist/bidi/mux-daemon.js (scripts/build.ts roots entrypoints at src/). */
428
+ function resolveDaemonScript(): string | undefined {
429
+ const override = process.env.CDP_FIREFOX_MUX_DAEMON;
430
+ if (override) return override;
431
+ for (const rel of ["./mux-daemon.ts", "./mux-daemon.js", "./bidi/mux-daemon.js", "../bidi/mux-daemon.js"]) {
432
+ let p: string;
433
+ try {
434
+ p = fileURLToPath(new URL(rel, import.meta.url));
435
+ } catch {
436
+ continue;
437
+ }
438
+ if (existsSync(p)) return p;
439
+ }
440
+ return undefined;
441
+ }
442
+
443
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
444
+
445
+ // The label stamped on a slot record WE take. defaultLabel() is "pid-<pid>", which is right for an
446
+ // MCP server but useless for the daemon, whose record a human reads when they wonder what process
447
+ // is holding their browser. hostFirefoxMux sets it to "mux-daemon(spawned by pid N)".
448
+ let slotLabelOverride: string | undefined;
449
+
450
+ /** A LIVE holder advertising a mux, or undefined. */
451
+ async function liveAdvertisedHolder(endpoint: string): Promise<{ endpoint: string; holder: SessionSlotRecord } | undefined> {
452
+ const rec = await readSessionSlot(endpoint).catch(() => undefined);
453
+ if (!rec || !rec.muxEndpoint || !isPidAlive(rec.pid)) return undefined;
454
+ return { endpoint: rec.muxEndpoint, holder: rec };
455
+ }
456
+
457
+ /**
458
+ * DAEMON MODE: get a mux endpoint to join WITHOUT ever becoming the holder in this process.
459
+ * Returns the advertised endpoint, or undefined meaning "no daemon could be brought up — fall
460
+ * back to the in-process host path", which is what turns a genuinely unavailable browser back
461
+ * into the existing actionable busy/Marionette errors instead of a mystery timeout.
462
+ * Throws only for the deadline case, which is a real failure with a real diagnostic (the log).
463
+ */
464
+ async function ensureMuxDaemon(endpoint: string): Promise<{ endpoint: string; holder: SessionSlotRecord } | undefined> {
465
+ const already = await liveAdvertisedHolder(endpoint);
466
+ if (already) return already;
467
+
468
+ const script = resolveDaemonScript();
469
+ if (!script) return undefined; // no daemon to spawn: host it ourselves
470
+
471
+ let exited = false;
472
+ const child = spawn(process.execPath, [script, endpoint, String(process.pid)], {
473
+ detached: true,
474
+ stdio: "ignore",
475
+ env: { ...process.env, CDP_FIREFOX_MUX: "host" },
476
+ });
477
+ child.once("error", () => { exited = true; });
478
+ // Fires even on an unref'ed child while WE are alive, which is exactly the fast negative signal
479
+ // we need: a daemon that refused (busy slot, Marionette missing, dial error) exits non-zero at
480
+ // once, and we fall back rather than waiting out the deadline.
481
+ child.once("exit", () => { exited = true; });
482
+ child.unref();
483
+
484
+ const deadline = Date.now() + sessionWaitMs() + 5_000;
485
+ while (Date.now() < deadline) {
486
+ const hit = await liveAdvertisedHolder(endpoint);
487
+ if (hit) return hit;
488
+ if (exited) return undefined; // daemon gave up: host in-process and surface ITS error
489
+ await sleep(50);
490
+ }
491
+ throw driverError("disconnected",
492
+ `Firefox's single WebDriver BiDi session on ${endpoint} could not be brought up: a mux daemon was ` +
493
+ `spawned (${script}) but never advertised a multiplexer within ${sessionWaitMs() + 5_000}ms. ` +
494
+ `Its diagnostics are in ${muxDaemonLogFile(endpoint)}. Retry, or set CDP_FIREFOX_MUX=host to run ` +
495
+ `the session in this process instead.`);
496
+ }
355
497
 
356
498
  /**
357
499
  * Release a slot claim we took in THIS getConnection call after a dial that never established a
@@ -396,7 +538,20 @@ function orphanNotClearedError(endpoint: string): DriverError {
396
538
  // WebDriver:DeleteSession (which shares Firefox's one session slot) force-clears the orphan WITHOUT
397
539
  // killing the user's browser, and we retry. Marionette recovery is best-effort and degrades to a
398
540
  // clear, actionable error when Firefox has no --marionette port (the launch-mode norm).
399
- async function getConnection(endpoint: string, timeoutMs?: number): Promise<BidiConnection> {
541
+ //
542
+ // AND THE THIRD OUTCOME, THE MUX JOIN. "Wait and retry" was the honest answer while one process
543
+ // could only ever hand the session to the next one. It no longer is: the holder now hosts a
544
+ // loopback BiDi multiplexer over its one real session (./mux.ts) and advertises it in the slot
545
+ // record, so when acquire reports a LIVE holder WITH a muxEndpoint we do not wait at all — we dial
546
+ // the holder's mux with the same connectBidiSessionUrl and the same dialOpts we would use against
547
+ // Firefox, and cache the resulting socket under the FIREFOX endpoint key. Downstream, a joined
548
+ // connection is indistinguishable from a real one; the only bookkeeping is the `joined` set, which
549
+ // records that we hold NO slot (so dispose releases nothing and ends nothing) and that a dead
550
+ // socket here must re-run acquire rather than take the `owned` re-dial shortcut. A live holder with
551
+ // NO mux is still plain busy, exactly as before. On the holder side, the only addition is that
552
+ // after the real connection is cached we start a mux and advertise it — best-effort, because a
553
+ // holder that fails to start one is simply a holder that serves nobody but itself.
554
+ async function getConnection(endpoint: string, timeoutMs?: number, attached = false): Promise<BidiConnection> {
400
555
  const existing = connections.get(endpoint);
401
556
  if (existing && existing.isOpen) { existing.retain(); return existing; }
402
557
  // A cached connection whose socket has died (ws.onclose fired) can only reject every
@@ -421,20 +576,63 @@ async function getConnection(endpoint: string, timeoutMs?: number): Promise<Bidi
421
576
 
422
577
  const create = (async (): Promise<BidiConnection> => {
423
578
  const mport = defaultMarionettePort();
579
+ // Firefox 153's default unhandledPromptBehavior is "dismiss": a page-blocking alert()/confirm()
580
+ // gets auto-dismissed before handleDialog() ever runs, and Firefox's own userPromptOpened event
581
+ // reports handler:"dismiss" on it (verified: a bare session.new left "no such alert" on the very
582
+ // next handleUserPrompt call). Setting "ignore" here is what makes handleDialog() able to see
583
+ // and answer a live prompt at all. Declared before the acquire so the JOIN path can dial the
584
+ // holder's mux with byte-identical options — a joined connection must be indistinguishable.
585
+ const dialOpts = {
586
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
587
+ capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" as const } } },
588
+ };
589
+
424
590
  // Do we ALREADY hold this endpoint's slot? (e.g. our previous socket died without dispose, so
425
591
  // the eviction above kept the claim.) If so, re-dial WITHOUT re-acquiring: acquireSessionSlot
426
592
  // against our own live pid would just poll to its wait ceiling and then throw
427
593
  // SessionSlotBusyError against ourselves. We already own Firefox's slot; only the socket needs
428
594
  // 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) {
595
+ let owned = slotHandles.has(endpoint);
596
+ // ...UNLESS the dead socket we just evicted was a JOINED one. A joiner owns no slot and no
597
+ // session, so the `owned` shortcut would re-dial Firefox directly against a session someone
598
+ // else holds. Drop the join marker and run the full acquire, which either joins whoever holds
599
+ // the slot NOW (the old holder recovered, or a new one took over) or wins the slot outright.
600
+ if (joined.has(endpoint)) { joined.delete(endpoint); owned = false; }
601
+
602
+ // DAEMON MODE (the default in ATTACH mode). Never become the holder in-process: either join a
603
+ // daemon that already advertises a mux, or spawn one and join it as soon as it does. The real
604
+ // session then outlives every client, which is the entire point — see ensureMuxDaemon.
605
+ // A daemon that refuses returns undefined and we fall through to the host path below, so an
606
+ // unavailable browser still produces the existing actionable busy/Marionette errors.
607
+ if (!owned && muxMode(attached) === "daemon") {
608
+ const target = await ensureMuxDaemon(endpoint);
609
+ if (target) {
610
+ try {
611
+ const joinedConn = await connectBidiSessionUrl(target.endpoint, dialOpts); // already retained once
612
+ connections.set(endpoint, joinedConn);
613
+ joined.add(endpoint);
614
+ bumpEpoch(endpoint, joinedConn);
615
+ return joinedConn;
616
+ } catch (e) {
617
+ // The daemon died between advertising and our dial. Fall through to the ordinary
618
+ // acquire loop, which steals the orphan record and re-runs the whole flow.
619
+ console.error(`[cdp-toolkit] firefox mux daemon at ${target.endpoint} could not be dialed: ${e instanceof Error ? e.message : String(e)}`);
620
+ }
621
+ }
622
+ }
623
+
624
+ // The acquire runs in a loop only so the JOIN path can retry itself exactly ONCE (below);
625
+ // every other path breaks out on its first pass.
626
+ for (let attempt = 0; ; attempt++) {
627
+ if (owned) break;
431
628
  // 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.
629
+ // classifying any current holder. A LIVE holder that ADVERTISES A MUX is JOINED rather than
630
+ // waited on; a LIVE holder without one still throws SessionSlotBusyError exactly as before; a
631
+ // DEAD holder's orphan is stolen and surfaced as staleHolder so we can force-clear Firefox's
632
+ // stranded session before we call session.new.
435
633
  let acq: AcquireResult;
436
634
  try {
437
- acq = await acquireSessionSlot(endpoint, { label: defaultLabel(), marionettePort: mport });
635
+ acq = await acquireSessionSlot(endpoint, { label: slotLabelOverride ?? defaultLabel(), marionettePort: mport });
438
636
  } catch (e) {
439
637
  if (e instanceof SessionSlotBusyError) {
440
638
  // Issue #5: a distinguishable LIVE holder, NOT a wedge. Map to an actionable disconnected
@@ -448,7 +646,35 @@ async function getConnection(endpoint: string, timeoutMs?: number): Promise<Bidi
448
646
  }
449
647
  throw e;
450
648
  }
451
- slotHandles.set(endpoint, acq.handle);
649
+
650
+ if (acq.joinMux) {
651
+ // JOIN: the live holder fronts its one real session with a loopback BiDi mux, so we dial
652
+ // THAT instead of Firefox. We take no slot and open no real session — but we cache the
653
+ // socket under the FIREFOX endpoint key, so page(), the beacon map and dispose() are all
654
+ // keyed on one identity and neither knows nor cares that the bytes go via the holder.
655
+ const { endpoint: muxEndpoint, holder } = acq.joinMux;
656
+ try {
657
+ const joinedConn = await connectBidiSessionUrl(muxEndpoint, dialOpts); // already retained once
658
+ connections.set(endpoint, joinedConn);
659
+ joined.add(endpoint);
660
+ bumpEpoch(endpoint, joinedConn);
661
+ return joinedConn;
662
+ } catch (e) {
663
+ // A holder can die between advertising and our dial, leaving a stale muxEndpoint in a
664
+ // record whose pid is about to read dead. Re-run the WHOLE acquire once: we will either
665
+ // steal that orphan and become the holder ourselves, or join whoever took over.
666
+ if (attempt === 0) continue;
667
+ const who = `'${holder.label}' (pid ${holder.pid})`;
668
+ throw driverError("disconnected",
669
+ `Firefox's single WebDriver BiDi session on ${endpoint} is held by a LIVE process ${who}, ` +
670
+ `which advertises a BiDi multiplexer at ${muxEndpoint}, but that multiplexer could not be ` +
671
+ `dialed (${e instanceof Error ? e.message : String(e)}). The holder is most likely shutting ` +
672
+ `down; retry in a moment, or point this server at a different endpoint/browser.`);
673
+ }
674
+ }
675
+
676
+ // We took the slot ourselves: the ordinary HOLDER path, unchanged.
677
+ if (acq.handle) slotHandles.set(endpoint, acq.handle);
452
678
  if (acq.staleHolder) {
453
679
  // Issue #4 auto-recovery: we stole a DEAD holder's lease, but Firefox itself may still hold
454
680
  // that holder's orphaned BiDi session. Force-clear it over Marionette BEFORE dialing, or
@@ -457,17 +683,9 @@ async function getConnection(endpoint: string, timeoutMs?: number): Promise<Bidi
457
683
  // still dial, and the re-dial retry path below surfaces a clear error if the orphan persists.
458
684
  await forceClearOrphanSession(acq.staleHolder.marionettePort ?? mport);
459
685
  }
686
+ break;
460
687
  }
461
688
 
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
689
  let conn: BidiConnection;
472
690
  try {
473
691
  conn = await connectBidiSessionUrl(endpoint, dialOpts); // already retained once
@@ -498,6 +716,22 @@ async function getConnection(endpoint: string, timeoutMs?: number): Promise<Bidi
498
716
  }
499
717
  }
500
718
  connections.set(endpoint, conn);
719
+ bumpEpoch(endpoint, conn);
720
+ // We are the HOLDER. Front our one real session with a loopback BiDi mux and advertise it in
721
+ // the slot record, so any other process that wants this Firefox JOINS us instead of being told
722
+ // the slot is busy. Strictly additive and strictly best-effort: any failure here leaves a
723
+ // perfectly good holder that simply serves nobody but itself (exactly pre-mux behavior), so it
724
+ // is logged to stderr and swallowed rather than failing the connection the caller asked for.
725
+ if (muxMode(attached) !== "off") {
726
+ try {
727
+ const mux = await startBidiMux(conn);
728
+ muxes.set(endpoint, mux);
729
+ const h = slotHandles.get(endpoint);
730
+ if (h) await advertiseMux(h, mux.endpoint);
731
+ } catch (e) {
732
+ console.error(`[cdp-toolkit] firefox mux not started: ${e instanceof Error ? e.message : String(e)}`);
733
+ }
734
+ }
501
735
  return conn;
502
736
  })();
503
737
 
@@ -553,7 +787,10 @@ async function resolveContext(
553
787
  ): Promise<BrowsingContextInfo> {
554
788
  const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => { throw mapBidiError(e); });
555
789
  if (!contexts.length) throw driverError("no-such-target", "no browsing contexts available");
556
- const hit = await pickContext(conn, contexts, selector);
790
+ // A no-such-target here is the exact symptom of Firefox's per-session context-id regeneration
791
+ // once this process has re-connected at least once, so the error explains it rather than reading
792
+ // like a typo. The CODE is left untouched (still no-such-target), so caller handling is unchanged.
793
+ const hit = await pickContext(conn, contexts, selector).catch((e) => { throw withStaleIdHint(conn, e); });
557
794
  // liveIds deliberately not passed: hit is by construction a member of contexts, so target-gone
558
795
  // could never fire for the context just resolved (see leases.ts's assertLeaseOk callers).
559
796
  await assertLeaseOk("firefox", hit.context, { lease: opts.lease, url: hit.url });
@@ -1381,10 +1618,10 @@ class BidiBrowserDriver implements BrowserDriver {
1381
1618
  readonly snapshotFidelity = "dom-heuristic" as const;
1382
1619
  /** `endpoint` is a BiDi ws URL (see getConnection): derived from a port for a launched
1383
1620
  * Firefox, user-supplied for an attached one. It is this driver's whole identity. */
1384
- constructor(private readonly endpoint: string, private readonly timeoutMs?: number) {}
1621
+ constructor(private readonly endpoint: string, private readonly timeoutMs?: number, private readonly attached = false) {}
1385
1622
 
1386
1623
  async listPages(): Promise<PageInfo[]> {
1387
- const conn = await getConnection(this.endpoint, this.timeoutMs);
1624
+ const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
1388
1625
  try {
1389
1626
  const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => { throw mapBidiError(e); });
1390
1627
  return Promise.all(contexts.map((c) => pageInfoOf(conn, c)));
@@ -1393,7 +1630,7 @@ class BidiBrowserDriver implements BrowserDriver {
1393
1630
  }
1394
1631
  }
1395
1632
  async newPage(url?: string): Promise<PageInfo> {
1396
- const conn = await getConnection(this.endpoint, this.timeoutMs);
1633
+ const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
1397
1634
  try {
1398
1635
  const { context } = await conn.send("browsingContext.create", { type: "tab" }).catch((e) => { throw mapBidiError(e); });
1399
1636
  // Established fact: navigating the default about:home context to a data: URL is rejected,
@@ -1405,7 +1642,7 @@ class BidiBrowserDriver implements BrowserDriver {
1405
1642
  }
1406
1643
  }
1407
1644
  async closePage(id: string): Promise<{ success: boolean }> {
1408
- const conn = await getConnection(this.endpoint, this.timeoutMs);
1645
+ const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
1409
1646
  try {
1410
1647
  await conn.send("browsingContext.close", { context: id }).catch((e) => { throw mapBidiError(e); });
1411
1648
  return { success: true };
@@ -1414,7 +1651,7 @@ class BidiBrowserDriver implements BrowserDriver {
1414
1651
  }
1415
1652
  }
1416
1653
  async activatePage(id: string): Promise<PageInfo> {
1417
- const conn = await getConnection(this.endpoint, this.timeoutMs);
1654
+ const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
1418
1655
  try {
1419
1656
  await conn.send("browsingContext.activate", { context: id }).catch((e) => { throw mapBidiError(e); });
1420
1657
  const ctx = await resolveContext(conn, id);
@@ -1424,7 +1661,7 @@ class BidiBrowserDriver implements BrowserDriver {
1424
1661
  }
1425
1662
  }
1426
1663
  async page(selector: TargetSelector): Promise<PageDriver> {
1427
- const conn = await getConnection(this.endpoint, this.timeoutMs);
1664
+ const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
1428
1665
  let ctx: BrowsingContextInfo;
1429
1666
  try {
1430
1667
  ctx = await resolveContext(conn, selector);
@@ -1452,7 +1689,7 @@ class BidiBrowserDriver implements BrowserDriver {
1452
1689
  * an annotation that must never fail real work.
1453
1690
  */
1454
1691
  async installActivityBeacon(contextId: string): Promise<boolean> {
1455
- const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => undefined);
1692
+ const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached).catch(() => undefined);
1456
1693
  if (!conn) return false;
1457
1694
  try {
1458
1695
  const key = `${this.endpoint}:${contextId}`;
@@ -1479,7 +1716,7 @@ class BidiBrowserDriver implements BrowserDriver {
1479
1716
  /** Read the beacon timestamp for a context, or null when it has none. Gate-free;
1480
1717
  * see the declaration in ../driver.ts for why that is required here. */
1481
1718
  async readActivityBeacon(contextId: string): Promise<number | null> {
1482
- const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => undefined);
1719
+ const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached).catch(() => undefined);
1483
1720
  if (!conn) return null;
1484
1721
  try {
1485
1722
  const res = await conn.send("script.evaluate", {
@@ -1504,15 +1741,34 @@ class BidiBrowserDriver implements BrowserDriver {
1504
1741
  // Drop any in-flight create record defensively: dispose() ends this endpoint's life, so a
1505
1742
  // pending create must not leave a stale promise that a later getConnection would await.
1506
1743
  connecting.delete(this.endpoint);
1744
+ // Close our multiplexer FIRST, before anything can end the real session under it. Joiners see
1745
+ // their sockets close (1001) and start polling the slot — which we still hold at this instant,
1746
+ // so they wait rather than racing us — and by the time we release below, the slot is genuinely
1747
+ // free for one of them to take and become the next holder. Best-effort and idempotent.
1748
+ const mux = muxes.get(this.endpoint);
1749
+ if (mux) {
1750
+ muxes.delete(this.endpoint);
1751
+ await mux.close().catch(() => undefined);
1752
+ }
1507
1753
  const conn = connections.get(this.endpoint);
1508
1754
  if (!conn) {
1509
1755
  // No live connection, but we may still hold the session slot lock (e.g. a create acquired the
1510
1756
  // slot then failed to establish a session on an `owned` re-dial, which leaves the claim). A
1511
1757
  // clean dispose must free it cross-process so the next process is not falsely told we're LIVE.
1758
+ joined.delete(this.endpoint);
1512
1759
  await this.releaseSessionSlotIfHeld();
1513
1760
  return;
1514
1761
  }
1515
1762
  connections.delete(this.endpoint);
1763
+ if (joined.has(this.endpoint)) {
1764
+ // JOINED: this socket points at another process's mux, not at Firefox. We own neither the
1765
+ // real session nor any slot, so there is nothing to end and nothing to release — sending
1766
+ // session.end would only be answered locally by the mux, and releaseSessionSlot would at best
1767
+ // be a no-op. Drop the socket and forget the join. The holder's session, and our tabs, live on.
1768
+ joined.delete(this.endpoint);
1769
+ conn.dispose();
1770
+ return;
1771
+ }
1516
1772
  // Cleanly END the BiDi session so Firefox frees its single session slot for the next
1517
1773
  // attach (a launch-mode Firefox is killed anyway; this matters for attach mode where the
1518
1774
  // user's browser lives on). Verified: closing the socket alone does NOT free the slot, and
@@ -1544,12 +1800,55 @@ export function createFirefoxDriver(port: number, opts?: { timeoutMs?: number })
1544
1800
  return new BidiBrowserDriver(`ws://127.0.0.1:${port}/session`, opts?.timeoutMs);
1545
1801
  }
1546
1802
 
1803
+ /**
1804
+ * Become the HOLDER of this endpoint's one Firefox BiDi session and front it with a mux, for the
1805
+ * detached daemon in ./mux-daemon.ts. Deliberately a thin wrapper over getConnection's existing
1806
+ * holder path (slot acquire, dead-holder steal, Marionette orphan force-clear, dial, startBidiMux,
1807
+ * advertiseMux) rather than a second implementation of it — the daemon must behave EXACTLY like
1808
+ * the in-process holder it replaces. Requires CDP_FIREFOX_MUX=host in the environment: without it
1809
+ * getConnection would take the daemon path and spawn another daemon.
1810
+ *
1811
+ * Rejects if the slot is held by someone else (including another daemon that won the race, which
1812
+ * arrives as a JOIN — a daemon must never join, it exits), if the dial fails, or if the mux could
1813
+ * not be started. `dispose` closes the mux, ends the real session, and releases the slot.
1814
+ */
1815
+ export async function hostFirefoxMux(endpoint: string, opts: { label?: string } = {}): Promise<{ mux: BidiMux; conn: BidiConnection; dispose(): Promise<void> }> {
1816
+ if (opts.label) slotLabelOverride = opts.label;
1817
+ if (process.env.CDP_FIREFOX_MUX !== "host") {
1818
+ throw new Error("hostFirefoxMux requires CDP_FIREFOX_MUX=host (set it before importing this module)");
1819
+ }
1820
+ const conn = await getConnection(endpoint, undefined, true);
1821
+ if (joined.has(endpoint)) {
1822
+ // Another holder (very likely a racing daemon) owns the session; we joined it. A daemon that
1823
+ // joins would be a second process serving a session it does not own, so refuse and let the
1824
+ // caller exit non-zero. Drop the socket we just opened first.
1825
+ joined.delete(endpoint);
1826
+ connections.delete(endpoint);
1827
+ conn.dispose();
1828
+ throw new Error(`endpoint ${endpoint} is already served by another holder's multiplexer`);
1829
+ }
1830
+ const mux = muxes.get(endpoint);
1831
+ if (!mux) {
1832
+ const driver = new BidiBrowserDriver(endpoint, undefined, true);
1833
+ await driver.dispose().catch(() => undefined);
1834
+ throw new Error(`could not start a BiDi multiplexer for ${endpoint}`);
1835
+ }
1836
+ const driver = new BidiBrowserDriver(endpoint, undefined, true);
1837
+ return {
1838
+ mux,
1839
+ conn,
1840
+ // BidiBrowserDriver.dispose is the ONE teardown that closes the mux first, then session.end,
1841
+ // then releases the slot — reused rather than re-derived so the daemon can never drift from it.
1842
+ dispose: () => driver.dispose(),
1843
+ };
1844
+ }
1845
+
1547
1846
  /** Construct the same driver for ATTACH mode: `wsUrl` is the BiDi endpoint of a Firefox the user
1548
1847
  * started themselves with --remote-debugging-port, already normalized by ../backend.ts's
1549
1848
  * normalizeBidiEndpoint. This driver NEVER owns that process — dispose() ends the BiDi session and
1550
1849
  * closes the socket, and nothing anywhere in this path kills a browser it did not launch. */
1551
1850
  export function createFirefoxDriverForEndpoint(wsUrl: string, opts?: { timeoutMs?: number }): BrowserDriver {
1552
- return new BidiBrowserDriver(wsUrl, opts?.timeoutMs);
1851
+ return new BidiBrowserDriver(wsUrl, opts?.timeoutMs, true);
1553
1852
  }
1554
1853
  // resolveContext is exported for ONE reason: it is the Firefox lease choke
1555
1854
  // point, and a choke point nothing can call directly is a choke point nothing
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The detached BiDi mux DAEMON: a tiny process whose only job is to OWN Firefox's single
3
+ * WebDriver-BiDi session and serve it to every cdp-toolkit process through a loopback mux (./mux.ts).
4
+ *
5
+ * WHY IT IS A SEPARATE PROCESS. Firefox 153.0.3 regenerates EVERY top-level browsing-context id
6
+ * whenever its one BiDi session is re-created — measured: a clean session.end + session.new keeps
7
+ * the tab (same url) but browsingContext.getTree reports a new id; a SIGKILLed holder is worse, as
8
+ * Firefox never reaps the dead session at all and only a Marionette WebDriver:DeleteSession frees
9
+ * it. So while the session lived inside a client process, the moment that client exited — even
10
+ * cleanly, e.g. the first agent session finishing while a second is mid-task — every other client's
11
+ * cached target ids, tab leases and origin records went stale. Hosting the session here means no
12
+ * client's lifetime is the session's lifetime.
13
+ *
14
+ * WHY IT STILL EXITS. A daemon nobody asked for is a leak, so it exits as soon as it is unused:
15
+ * CDP_FIREFOX_MUX_IDLE_MS (default 15s) with zero clients, counted from startup too, so a spawner
16
+ * that dies before connecting leaves nothing behind. It also exits when Firefox goes away or on a
17
+ * signal, always through the same dispose (mux close -> session.end -> slot release).
18
+ *
19
+ * Argv: <endpoint> <spawnerPid>. Env read exactly as the driver reads it (CDP_ARTIFACT_DIR,
20
+ * CDP_FIREFOX_MARIONETTE_PORT, CDP_FIREFOX_SESSION_WAIT_MS), plus CDP_FIREFOX_MUX_IDLE_MS.
21
+ * Spawned detached with stdio:"ignore", so it has NO console: every diagnostic goes to
22
+ * <leaseDir()>/ff-mux-<safeEndpoint>.log, best-effort, never throwing.
23
+ */
24
+ import { appendFileSync, mkdirSync } from "node:fs";
25
+ import { dirname } from "node:path";
26
+ import { muxDaemonLogFile } from "./session-lease.ts";
27
+
28
+ const DEFAULT_IDLE_MS = 15_000;
29
+ const CLIENT_POLL_MS = 500;
30
+ const DISPOSE_TIMEOUT_MS = 10_000;
31
+
32
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
33
+
34
+ const endpoint = process.argv[2];
35
+ const spawnerPid = process.argv[3] ?? "?";
36
+
37
+ function idleMs(): number {
38
+ const raw = Number(process.env.CDP_FIREFOX_MUX_IDLE_MS);
39
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_IDLE_MS;
40
+ }
41
+
42
+ /** Append one diagnostic line. Never throws: a daemon must not die because a log write failed. */
43
+ function log(line: string): void {
44
+ if (!endpoint) return;
45
+ try {
46
+ const file = muxDaemonLogFile(endpoint);
47
+ mkdirSync(dirname(file), { recursive: true });
48
+ appendFileSync(file, `${new Date().toISOString()} pid=${process.pid} ${line}\n`);
49
+ } catch {
50
+ /* best effort */
51
+ }
52
+ }
53
+
54
+ function fail(reason: string): never {
55
+ log(`EXIT(1) ${reason}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ async function main(): Promise<void> {
60
+ if (!endpoint) fail("no endpoint argument");
61
+ // Must be set BEFORE the driver module is imported so its holder path is the one that runs;
62
+ // this is also the flag hostFirefoxMux asserts on.
63
+ process.env.CDP_FIREFOX_MUX = "host";
64
+ log(`start endpoint=${endpoint} spawner=${spawnerPid} idleMs=${idleMs()}`);
65
+
66
+ const { hostFirefoxMux } = await import("./driver.ts");
67
+ let hosted: Awaited<ReturnType<typeof hostFirefoxMux>>;
68
+ try {
69
+ hosted = await hostFirefoxMux(endpoint, { label: `mux-daemon(spawned by pid ${spawnerPid})` });
70
+ } catch (e) {
71
+ // Busy slot, a racing daemon that won (arrives as a join), Marionette unavailable, dial error.
72
+ // Every one of these is "this daemon is not the holder", and the spawner's fallback path
73
+ // reproduces the actionable error in-process. Nothing was written that is not ours to write.
74
+ fail(`could not host: ${e instanceof Error ? e.message : String(e)}`);
75
+ }
76
+ log(`advertised mux=${hosted.mux.endpoint}`);
77
+
78
+ let shuttingDown = false;
79
+ const shutdown = async (reason: string): Promise<void> => {
80
+ if (shuttingDown) return;
81
+ shuttingDown = true;
82
+ clearInterval(timer);
83
+ log(`shutdown begin: ${reason}`);
84
+ // BOUNDED. dispose() closes the mux's listening socket, and node's server.close() only calls
85
+ // back once every accepted connection is gone — a client that connects while we are closing can
86
+ // therefore hold it open indefinitely. A daemon that cannot die is worse than one that dies
87
+ // without a clean session.end (the slot record it leaves behind carries a DEAD pid, which the
88
+ // next process steals and force-clears over Marionette, the existing #4 recovery path). So the
89
+ // teardown gets a deadline and we exit either way.
90
+ await Promise.race([hosted.dispose().catch(() => undefined), sleep(DISPOSE_TIMEOUT_MS)]);
91
+ log(`EXIT(0) ${reason}`);
92
+ process.exit(0);
93
+ };
94
+
95
+ // Idle exit. The clock starts NOW, not at the first client, so a spawner that never connects
96
+ // (crashed, or fell back) cannot leave a daemon behind.
97
+ let idleSince: number | undefined = Date.now();
98
+ const timer = setInterval(() => {
99
+ if (shuttingDown) return;
100
+ if (!hosted.conn.isOpen) {
101
+ void shutdown("upstream Firefox connection closed");
102
+ return;
103
+ }
104
+ if (hosted.mux.clients() > 0) {
105
+ idleSince = undefined;
106
+ return;
107
+ }
108
+ if (idleSince === undefined) idleSince = Date.now();
109
+ if (Date.now() - idleSince >= idleMs()) void shutdown(`idle for ${idleMs()}ms with no clients`);
110
+ }, Math.max(50, Math.min(CLIENT_POLL_MS, Math.floor(idleMs() / 4))));
111
+
112
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
113
+ process.on(sig, () => void shutdown(`signal ${sig}`));
114
+ }
115
+ // A dispose on the way out of an unexpected throw, so we never strand Firefox's session.
116
+ process.on("uncaughtException", (e) => {
117
+ log(`uncaught: ${e instanceof Error ? e.message : String(e)}`);
118
+ void shutdown("uncaught exception");
119
+ });
120
+ }
121
+
122
+ void main().catch((e: unknown) => fail(`startup threw: ${e instanceof Error ? e.message : String(e)}`));