cdp-toolkit 1.7.0 → 1.9.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.
@@ -24,14 +24,18 @@
24
24
  */
25
25
  import type { TargetSelector } from "../types.ts";
26
26
  import {
27
- UID_STAMP_ATTR, isDriverError,
27
+ UID_STAMP_ATTR, isDriverError, interpolatePoints,
28
28
  type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
29
+ type DragDestination, type DragOptions,
29
30
  type SetCookieParams,
30
31
  type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel,
31
32
  type MouseButtonOptions, type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo,
32
- type ScreenshotOptions, type SnapshotNode, type UidStability,
33
+ type ScreenshotOptions, type ScrollOptions, type SnapshotNode, type UidStability,
33
34
  } from "../driver.ts";
34
35
  import { assertLeaseOk } from "../leases.ts";
36
+ import { resolveLiveLabel } from "../origins.ts";
37
+ import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
38
+ import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
35
39
  import { BidiConnection, BidiError, connectBidiSession } from "./client.ts";
36
40
  import { takeStampedSnapshot } from "./snapshot.ts";
37
41
  import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
@@ -39,7 +43,7 @@ import type {
39
43
  BrowsingContextId, BrowsingContextInfo, BrowsingContextLocator, BrowsingContextReadinessState,
40
44
  BrowsingContextCaptureScreenshotParameters, ScriptSharedReference, ScriptRemoteValue, ScriptLocalValue,
41
45
  ScriptNodeRemoteValue, ScriptExceptionDetails, InputSourceActions, InputPointerSourceAction, InputKeySourceAction,
42
- NetworkCookie,
46
+ InputWheelSourceAction, NetworkCookie,
43
47
  } from "./protocol.ts";
44
48
 
45
49
  /* ---------------------------------- cookies ---------------------------------- */
@@ -256,6 +260,14 @@ const KEY_VALUES: Record<string, string> = {
256
260
  const MODIFIER_VALUES: Record<string, string> = { shift: "\uE008", control: "\uE009", ctrl: "\uE009", alt: "\uE00A", meta: "\uE03D", cmd: "\uE03D", command: "\uE03D" };
257
261
  const BUTTON_CODES: Record<"left" | "right" | "middle", number> = { left: 0, middle: 1, right: 2 };
258
262
 
263
+ /** Same scroll-settle technique as cdp/driver.ts's ARM_SCROLL_SETTLE_WATCH / awaitScrollSettle,
264
+ * see that file's doc comment for the full rationale: a capture-phase 'scroll' listener on
265
+ * window, debounced 60ms with a 500ms cap, so scroll()'s caller observes the settled position. */
266
+ const ARM_SCROLL_SETTLE_WATCH_SOURCE =
267
+ "function(){window.__cdpScrollSettle=new Promise((resolve)=>{let t;const done=()=>{window.removeEventListener('scroll',on,true);resolve(true);};" +
268
+ "const on=()=>{clearTimeout(t);t=setTimeout(done,60);};window.addEventListener('scroll',on,true);t=setTimeout(done,60);setTimeout(done,500);});}";
269
+ const AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}";
270
+
259
271
  /**
260
272
  * Firefox 153 capabilities. Only what was empirically verified is declared: emulate.deviceMetrics
261
273
  * (browsingContext.setViewport) and the userAgent path (universal, no capability token) both work.
@@ -301,6 +313,22 @@ async function getConnection(port: number, timeoutMs?: number): Promise<BidiConn
301
313
  return conn;
302
314
  }
303
315
 
316
+ /**
317
+ * Contexts that already carry a beacon preload script, keyed port:context ->
318
+ * the script id BiDi handed back.
319
+ *
320
+ * WHY FIREFOX NEEDS NO HELD CONNECTION where Chrome does. This driver's
321
+ * LifetimeModel is "session": ONE BidiConnection is memoized per port above and
322
+ * lives until dispose(), so a preload script registered on it stays registered
323
+ * across navigations for free. Chrome's equivalent registration is cleared when
324
+ * the registering client disconnects, and this toolkit's CDP lifetime is
325
+ * "per-call", which is why cdp/driver.ts has to hold a socket per beaconed tab
326
+ * and this file does not. The map exists only to avoid stacking a second
327
+ * registration on a context that already has one; the in-page script is
328
+ * idempotent, so a duplicate would be harmless but pointless.
329
+ */
330
+ const bidiBeaconScripts = new Map<string, string>();
331
+
304
332
  /* ----------------------------------- context/page lookup ----------------------------------- */
305
333
  async function safeTitle(conn: BidiConnection, contextId: BrowsingContextId): Promise<string> {
306
334
  try {
@@ -358,6 +386,32 @@ async function pickContext(
358
386
  for (const c of contexts) if ((await safeTitle(conn, c.context)).includes(needle)) return c;
359
387
  throw driverError("no-such-target", `no context with title containing '${needle}'`);
360
388
  }
389
+ if (selector.startsWith("label:")) {
390
+ const label = selector.slice(6);
391
+ const { matchIds, knownLabels } = await resolveLiveLabel("firefox", label, contexts.map((c) => c.context));
392
+ if (matchIds.length > 1) {
393
+ throw driverError("no-such-target", `label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
394
+ }
395
+ if (matchIds.length === 1) {
396
+ const hit = contexts.find((c) => c.context === matchIds[0]);
397
+ if (hit) return hit;
398
+ }
399
+ throw driverError(
400
+ "no-such-target",
401
+ knownLabels.length
402
+ ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})`
403
+ : `no live target with label '${label}' (no labels are currently assigned to any open target)`,
404
+ );
405
+ }
406
+ if (isWorkerSelector(selector)) {
407
+ // Capability-gated, not unimplemented: "unsupported" is the DriverErrorCode
408
+ // that tells a caller to check the capability rather than retry. The tool
409
+ // itself stays available on Firefox — only this arm of the grammar is
410
+ // refused, exactly as drag's mode:'html5' is. Refusing loudly beats the
411
+ // alternative the bare-id fall-through would produce, which is a confusing
412
+ // "no context matching 'worker:abc'" that reads like a typo.
413
+ throw driverError("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
414
+ }
361
415
  const exact = contexts.find((c) => c.context === selector);
362
416
  if (exact) return exact;
363
417
  throw driverError("no-such-target", `no context matching '${selector}'`);
@@ -369,9 +423,51 @@ class BidiPageDriver implements PageDriver {
369
423
  private bodyCollector?: string;
370
424
  constructor(private readonly conn: BidiConnection, private readonly contextId: BrowsingContextId, readonly info: PageInfo, readonly browser: BrowserDriver) {}
371
425
 
426
+ /** `location.href` and `document.readyState` in one round trip, NUL-joined so the result comes
427
+ * back as one plain string (deserializeRemote's simplest shape). Empty strings on any failure —
428
+ * a transient evaluate error during a navigation is normal and must not abort a poll. */
429
+ private async urlAndReadyState(): Promise<[string, string]> {
430
+ const raw = await this.conn.send("script.evaluate", {
431
+ expression: "location.href + '\\u0000' + document.readyState",
432
+ target: { context: this.contextId }, awaitPromise: false,
433
+ }).then((r) => (r.type === "success" ? String(deserializeRemote(r.result) ?? "") : "")).catch(() => "");
434
+ const [url = "", ready = ""] = raw.split("\u0000");
435
+ return [url, ready];
436
+ }
437
+
438
+ /**
439
+ * Bounded readiness wait after a history traversal.
440
+ *
441
+ * WHY THIS EXISTS AT ALL. browsingContext.navigate and .reload both take a `wait` readiness state
442
+ * and block server-side until it is reached, which is why the two branches below simply return
443
+ * `waitedFor: waitUntil`. browsingContext.traverseHistory takes NO `wait` parameter and returns
444
+ * as soon as the traversal is dispatched, so this driver owns the wait for that one path — the
445
+ * same position cdp/driver.ts is in for every navigation.
446
+ *
447
+ * Polling rather than a `browsingContext.load` subscription, matching waitForText's in-file
448
+ * pattern: a bfcache restore does not necessarily re-fire a load event, so an event wait could
449
+ * hang on a perfectly successful traversal while a poll observes the settled document directly.
450
+ *
451
+ * KNOWN LIMIT, stated rather than hidden: "we moved" is inferred from the url changing. A
452
+ * traversal between two history entries with the IDENTICAL url therefore cannot be confirmed and
453
+ * burns the whole budget before returning "timeout" — with the correct url, and after the
454
+ * traversal really did happen. That is the honest reading of the only signal available here.
455
+ */
456
+ private async settleAfterTraversal(beforeUrl: string, waitUntil: "load" | "domcontentloaded", timeoutMs: number): Promise<{ waitedFor: NavigateResult["waitedFor"]; url: string }> {
457
+ const ready = waitUntil === "domcontentloaded" ? ["interactive", "complete"] : ["complete"];
458
+ const deadline = Date.now() + timeoutMs;
459
+ for (;;) {
460
+ const [url, state] = await this.urlAndReadyState();
461
+ if (url && url !== beforeUrl && ready.includes(state)) return { waitedFor: waitUntil, url };
462
+ if (Date.now() >= deadline) return { waitedFor: "timeout", url: url || beforeUrl };
463
+ await new Promise((r) => setTimeout(r, 50));
464
+ }
465
+ }
466
+
372
467
  async navigate(opts: NavigateOptions): Promise<NavigateResult> {
373
468
  const reload = opts.reload === true;
374
- if (!reload && !opts.url) throw driverError("page-error", "navigate: 'url' is required unless reload:true");
469
+ const history = opts.history;
470
+ if (!reload && !history && !opts.url) throw driverError("page-error", "navigate: 'url' is required unless reload:true");
375
471
  const waitUntil = opts.waitUntil ?? "load";
376
472
  const wait: BrowsingContextReadinessState = waitUntil === "domcontentloaded" ? "interactive" : "complete";
377
473
  const sendOpts = opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
@@ -380,6 +476,21 @@ class BidiPageDriver implements PageDriver {
380
476
  const r = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
381
477
  return { url: r.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
382
478
  }
479
+ if (history) {
480
+ const [beforeUrl] = await this.urlAndReadyState();
481
+ // NavigateOptions.history forbids a silent no-op. Firefox answers a traversal past either
482
+ // end of the session history with a "no such history entry" error, which mapBidiError would
483
+ // relay verbatim — a message that never names the direction the caller asked for. Rewrapped
484
+ // here so back-at-the-start and forward-at-the-end read the same on both backends.
485
+ await this.conn.send("browsingContext.traverseHistory", { context: this.contextId, delta: history === "back" ? -1 : 1 }, sendOpts)
486
+ .catch((e) => {
487
+ const msg = e instanceof Error ? e.message : String(e);
488
+ if (/no such history entry|history/i.test(msg)) throw driverError("page-error", `navigate: no history entry to go ${history} to (${msg})`);
489
+ throw e;
490
+ });
491
+ const settled = await this.settleAfterTraversal(beforeUrl, waitUntil, opts.timeoutMs ?? 15_000);
492
+ return { url: settled.url, contextId: this.contextId, traversed: history, waitedFor: settled.waitedFor };
493
+ }
383
494
  const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url as string, wait }, sendOpts);
384
495
  return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
385
496
  } catch (e) {
@@ -454,7 +565,25 @@ class BidiPageDriver implements PageDriver {
454
565
  if (!nodes.length) throw driverError("stale-uid", `uid does not resolve: ${uid}`);
455
566
  }
456
567
 
568
+ /**
569
+ * THE INPUT DISPATCH CHOKE POINT for Firefox, and the direct counterpart of
570
+ * cdp/driver.ts's dispatchInput. Every synthesized input this driver produces
571
+ * is already funnelled through this one method (hover, click, scroll, drag,
572
+ * setValue, typeText, pressKey all build actions and call it), so the
573
+ * dispatch-log write that keeps the toolkit's own input from reading as a
574
+ * human's needs exactly one line, here. See ../activity.ts.
575
+ *
576
+ * scroll and the generalised drag arrived on the OTHER 1.8.0 branch (Track P)
577
+ * and inherited the log for free by building on this method — which is the
578
+ * whole argument for a choke point, and the reason this list is worth keeping
579
+ * accurate rather than deleting.
580
+ *
581
+ * NOT routed through here: input.setFiles (upload_file). Same reasoning as the
582
+ * CDP side — it fires none of the beacon's events, so logging it could only
583
+ * suppress a real human input, never prevent a false one.
584
+ */
457
585
  private async performActions(actions: InputSourceActions[]): Promise<void> {
586
+ recordDispatch("firefox", this.contextId);
458
587
  try {
459
588
  await this.conn.send("input.performActions", { context: this.contextId, actions });
460
589
  } catch (e) {
@@ -474,7 +603,18 @@ class BidiPageDriver implements PageDriver {
474
603
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions: [{ type: "pointerMove", x, y, duration: 0 }] }]);
475
604
  return { x, y };
476
605
  }
606
+ // modifiers is new in 1.8.0 (Track P1). Chrome expresses it as a bitmask on the same
607
+ // mousePressed/mouseReleased events (cdp/driver.ts's inputModifierBits); WebDriver BiDi has no
608
+ // equivalent field on a pointerDown/pointerUp action; a modifier chord instead requires a SECOND
609
+ // "key" input source ticked in lockstep with the pointer source across the same performActions
610
+ // call, which the spec explicitly permits skipping ("throw a clear error when modifiers are
611
+ // passed on firefox" — see cdp-toolkit's 1.8.0 spec, Track P1 §3) and this driver was not
612
+ // verified against real Firefox for this pass, so it throws "unsupported" rather than guess at
613
+ // untested tick-synchronization semantics.
477
614
  async click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }> {
615
+ if (opts?.modifiers && opts.modifiers.length > 0) {
616
+ throw driverError("unsupported", "click: 'modifiers' is not supported by the Firefox/BiDi driver; omit 'modifiers' or use --browser chrome for a modifier click.");
617
+ }
478
618
  const ref = await resolveElementLocator(this.conn, this.contextId, loc);
479
619
  const { x, y } = await this.centerOf(ref);
480
620
  const button = BUTTON_CODES[opts?.button ?? "left"];
@@ -483,17 +623,63 @@ class BidiPageDriver implements PageDriver {
483
623
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
484
624
  return { x, y };
485
625
  }
486
- async drag(from: ElementLocator, to: ElementLocator): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
626
+ // 1.8.0 Track P1: BiDi's "wheel" input source (a distinct source type from "pointer"/"key")
627
+ // carries x/y/deltaX/deltaY directly on its one "scroll" action — no tick synchronization with
628
+ // another source needed, unlike click's modifiers above, which is what makes this tractable.
629
+ // Same settle-wait rationale as cdp/driver.ts's armScrollSettleWatch: a wheel-triggered scroll
630
+ // is a browser-animated effect, not a synchronous DOM mutation, so a caller reading scroll
631
+ // position immediately after performActions resolves risks the pre-scroll value. Not empirically
632
+ // verified against real Firefox this pass (no Firefox available in this environment), but applied
633
+ // for consistency with the verified Chrome behavior rather than left as a known, unfixed gap.
634
+ async scroll(anchor: ElementLocator | { x: number; y: number } | undefined, opts: ScrollOptions): Promise<{ x: number; y: number }> {
635
+ const { x, y } = await this.resolveScrollPoint(anchor);
636
+ const deltaX = Math.round(opts.deltaX ?? 0);
637
+ const deltaY = Math.round(opts.deltaY ?? 0);
638
+ await this.callFunctionValue(ARM_SCROLL_SETTLE_WATCH_SOURCE, []);
639
+ const actions: InputWheelSourceAction[] = [{ type: "scroll", x: Math.round(x), y: Math.round(y), deltaX, deltaY }];
640
+ await this.performActions([{ type: "wheel", id: "cdp-wheel", actions }]);
641
+ await this.callFunctionValue(AWAIT_SCROLL_SETTLE_SOURCE, []).catch(() => undefined);
642
+ return { x, y };
643
+ }
644
+ private async resolveScrollPoint(anchor: ElementLocator | { x: number; y: number } | undefined): Promise<{ x: number; y: number }> {
645
+ if (anchor === undefined) {
646
+ const v = await this.callFunctionValue("function(){return {x: window.innerWidth / 2, y: window.innerHeight / 2};}", []);
647
+ if (!v || typeof v !== "object") throw driverError("page-error", "could not measure viewport for scroll center");
648
+ return v as { x: number; y: number };
649
+ }
650
+ if ("x" in anchor) return anchor;
651
+ return this.centerOf(await resolveElementLocator(this.conn, this.contextId, anchor));
652
+ }
653
+ // 1.8.0 Track P2. mode:"html5" is REFUSED here, never silently downgraded to mouse mode: WebDriver
654
+ // BiDi has no drag-interception primitive at all (no setInterceptDrags equivalent, no drag event
655
+ // to subscribe to, no dispatchDragEvent), so there is nothing to port. The param-level refusal
656
+ // mirrors click's modifiers-on-Firefox above — `drag` itself stays in tools/list because mouse
657
+ // mode is fully supported here. `to` accepting a point or an offset, and `steps`, are both
658
+ // backend-neutral and DO work on this driver.
659
+ async drag(from: ElementLocator, to: DragDestination, opts?: DragOptions): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
660
+ if (opts?.mode === "html5") {
661
+ throw driverError(
662
+ "unsupported",
663
+ "drag: mode:'html5' is not supported by the Firefox/BiDi driver (WebDriver BiDi has no drag-interception primitive); " +
664
+ "omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.",
665
+ );
666
+ }
487
667
  const fromPt = await this.centerOf(await resolveElementLocator(this.conn, this.contextId, from));
488
- const toPt = await this.centerOf(await resolveElementLocator(this.conn, this.contextId, to));
668
+ const toPt = await this.resolveDragDestination(to, fromPt);
669
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
489
670
  const actions: InputPointerSourceAction[] = [
490
671
  { type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 }, { type: "pointerDown", button: 0 },
491
- { type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
492
- { type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 }, { type: "pointerUp", button: 0 },
493
672
  ];
673
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) actions.push({ type: "pointerMove", x: pt.x, y: pt.y, duration: 50 });
674
+ actions.push({ type: "pointerUp", button: 0 });
494
675
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
495
676
  return { from: fromPt, to: toPt };
496
677
  }
678
+ private async resolveDragDestination(to: DragDestination, fromPt: { x: number; y: number }): Promise<{ x: number; y: number }> {
679
+ if ("dx" in to) return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
680
+ if ("x" in to) return to;
681
+ return this.centerOf(await resolveElementLocator(this.conn, this.contextId, to));
682
+ }
497
683
  // No atomic value-commit primitive over BiDi (established fact): every key goes through
498
684
  // input.performActions, batched into ONE call regardless of string length, so setValue/typeText
499
685
  // cost exactly 2 round trips (one evaluate to focus/clear, one performActions for the keys).
@@ -921,7 +1107,72 @@ class BidiBrowserDriver implements BrowserDriver {
921
1107
  const info = await pageInfoOf(conn, ctx);
922
1108
  return new BidiPageDriver(conn, ctx.context, info, this);
923
1109
  }
1110
+
1111
+ /**
1112
+ * Install the activity beacon (../activity.ts) into a context and every
1113
+ * document it navigates to afterwards.
1114
+ *
1115
+ * Both legs matter: script.addPreloadScript arms every FUTURE document (and
1116
+ * survives navigation for free here, see bidiBeaconScripts above), while the
1117
+ * script.evaluate arms the document already loaded, which a preload script
1118
+ * never retroactively reaches.
1119
+ *
1120
+ * DELIBERATELY GATE-FREE (no resolveContext), like its CDP twin and for the
1121
+ * same reason: the claim_page{target} path reads and installs around a claim
1122
+ * that the lease gate would otherwise take on its own behalf. Returning false
1123
+ * rather than throwing is the contract — installBeacon's caller treats this as
1124
+ * an annotation that must never fail real work.
1125
+ */
1126
+ async installActivityBeacon(contextId: string): Promise<boolean> {
1127
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => undefined);
1128
+ if (!conn) return false;
1129
+ try {
1130
+ const key = `${this.port}:${contextId}`;
1131
+ if (!bidiBeaconScripts.has(key)) {
1132
+ const registered = await conn.send("script.addPreloadScript", {
1133
+ functionDeclaration: BEACON_FUNCTION_DECLARATION,
1134
+ contexts: [contextId],
1135
+ });
1136
+ bidiBeaconScripts.set(key, registered.script);
1137
+ }
1138
+ await conn.send("script.evaluate", {
1139
+ expression: BEACON_SOURCE,
1140
+ target: { context: contextId },
1141
+ awaitPromise: false,
1142
+ });
1143
+ return true;
1144
+ } catch {
1145
+ return false;
1146
+ } finally {
1147
+ conn.release();
1148
+ }
1149
+ }
1150
+
1151
+ /** Read the beacon timestamp for a context, or null when it has none. Gate-free;
1152
+ * see the declaration in ../driver.ts for why that is required here. */
1153
+ async readActivityBeacon(contextId: string): Promise<number | null> {
1154
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => undefined);
1155
+ if (!conn) return null;
1156
+ try {
1157
+ const res = await conn.send("script.evaluate", {
1158
+ expression: BEACON_READ_EXPRESSION,
1159
+ target: { context: contextId },
1160
+ awaitPromise: false,
1161
+ });
1162
+ if (res.type !== "success") return null;
1163
+ const value = deserializeRemote(res.result);
1164
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1165
+ } catch {
1166
+ return null;
1167
+ } finally {
1168
+ conn.release();
1169
+ }
1170
+ }
1171
+
924
1172
  async dispose(): Promise<void> {
1173
+ for (const key of [...bidiBeaconScripts.keys()]) {
1174
+ if (key.startsWith(`${this.port}:`)) bidiBeaconScripts.delete(key);
1175
+ }
925
1176
  const conn = connections.get(this.port);
926
1177
  if (!conn) return;
927
1178
  connections.delete(this.port);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Per-backend tool availability: filters the 41-tool registry down to what a
2
+ * Per-backend tool availability: filters the 45-tool registry down to what a
3
3
  * given DriverKind can actually run, per ADR-001's rule that tools/list
4
4
  * discovers gaps, not runtime "unsupported" throws. Pure and I/O-free: driver
5
5
  * construction reads a static readonly `capabilities` set, it does not dial a