cdp-toolkit 1.7.0 → 1.8.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.
@@ -36,10 +36,14 @@
36
36
  import { mkdir, writeFile } from "node:fs/promises";
37
37
  import { dirname, join } from "node:path";
38
38
  import type {
39
- BrowserCookie, BrowserDriver, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
39
+ BrowserCookie, BrowserDriver, Capability, DragDestination, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
40
40
  } from "./driver.ts";
41
41
  import type { TargetSelector } from "./types.ts";
42
- import { assertLeaseOk, claimLease, defaultLabel, leaseTtlMs, releaseLeaseFor, requireLease, type LeaseBackend, type LeaseToken } from "./leases.ts";
42
+ import {
43
+ assertLeaseOk, claimLease, defaultLabel, leaseTtlMs, listLeases, releaseLeaseFor, requireLease,
44
+ type LeaseBackend, type LeaseStaleReason, type LeaseToken,
45
+ } from "./leases.ts";
46
+ import { installBeacon, probeRendererActivity, rendererProbeSupported } from "./activity.ts";
43
47
  import { newTrackedPage, originIndex, type PageOrigin } from "./origins.ts";
44
48
  import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
45
49
 
@@ -166,6 +170,31 @@ export interface ListedPage extends PageInfo {
166
170
  * genuinely unknown, but this field is what keeps an unreadable record from
167
171
  * being reported as a clean "nothing was ever recorded". */
168
172
  originUnreadable?: string;
173
+ /**
174
+ * Present only for a tab under an active, READABLE lease of THIS backend.
175
+ * idleMs and expiresAt are computed fresh on every call (now-lastUsedAt and
176
+ * lastUsedAt+ttlMs), never read off disk — same rule as list_leases' own
177
+ * LeaseRow, and unconditional on `probe`: a lease can exist whether or not a
178
+ * caller ever passes probe:true, and this costs nothing beyond a lease-dir
179
+ * read this call already pays for under strict mode.
180
+ */
181
+ lease?: { label: string; pid: number; idleMs: number; expiresAt: number; stale: LeaseStaleReason | false };
182
+ /**
183
+ * Present only when { probe: true } was passed AND this backend can answer
184
+ * it (see rendererProbeSupported in src/activity.ts) AND this entry is a
185
+ * page-type target — a worker or iframe has no `window` to carry the beacon
186
+ * global, so probing one would be a guaranteed false negative rather than a
187
+ * genuine liveness answer. true when a bounded Runtime.evaluate answered
188
+ * within budget; false on a timeout, a thrown page-side exception, or a
189
+ * target the driver could not reach at all — the wedged-tab case this field
190
+ * exists to surface, reported as data rather than failing the whole call.
191
+ */
192
+ responsive?: boolean;
193
+ /** Ms since a human last touched this tab, present only where probe:true
194
+ * answered AND the same round trip found human-attributed beacon data.
195
+ * Same rule as claim_page/list_leases: absence is "no answer", never "no
196
+ * human". */
197
+ humanActiveMs?: number;
169
198
  }
170
199
 
171
200
  /**
@@ -190,23 +219,52 @@ async function reapSet(driver: BrowserDriver, pages: PageInfo[], all?: boolean):
190
219
 
191
220
  export async function listPages(
192
221
  driver: BrowserDriver,
193
- args: { all?: boolean } = {},
222
+ args: { all?: boolean; probe?: boolean } = {},
194
223
  ): Promise<{ pages: ListedPage[]; count: number; reaped?: ReapedTab[] }> {
195
224
  // Reap FIRST, so a tab this call is about to close never appears in the
196
- // listing it returns. Under strict mode only: see requireLease.
197
- const reaped = requireLease() ? await reapStaleAgentTabs(driver, backendOf(driver)) : [];
225
+ // listing it returns, and so the lease read just below never reports a lease
226
+ // this same call is in the middle of deleting. Under strict mode only: see
227
+ // requireLease.
228
+ const backend = backendOf(driver);
229
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
198
230
  const reapedIds = new Set(reaped.map((r) => r.targetId));
199
231
  const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
200
232
  // Never let provenance break the listing: originIndex does not throw, and a
201
233
  // missing or unreadable ledger yields an empty map, so every page falls back
202
234
  // to origin "unknown" exactly as a pre-ledger consumer always saw.
203
- const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
204
- const annotated: ListedPage[] = pages.map((p) => {
205
- const rec = ledger.get(p.id);
206
- if (!rec) return { ...p, origin: "unknown" };
207
- if (rec.unreadable !== undefined) return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
208
- return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
209
- });
235
+ const ledger = await originIndex(backend, await reapSet(driver, pages, args.all));
236
+ const now = Date.now();
237
+ // Observability (1.8.0): every READABLE lease of this backend, keyed by
238
+ // target. Read unconditionally, not gated on `probe`, because a lease can
239
+ // exist whether or not a caller ever asks for a renderer ping.
240
+ const leaseByTarget = new Map(
241
+ (await listLeases({ now, liveIds: pages.map((p) => p.id), liveBackend: backend }))
242
+ .filter((l) => l.backend === backend && l.unreadable === undefined)
243
+ .map((l) => [l.targetId, l] as const),
244
+ );
245
+ const doProbe = args.probe === true && rendererProbeSupported(driver);
246
+ const annotated: ListedPage[] = await Promise.all(
247
+ pages.map(async (p) => {
248
+ const rec = ledger.get(p.id);
249
+ const base: ListedPage =
250
+ !rec ? { ...p, origin: "unknown" }
251
+ : rec.unreadable !== undefined ? { ...p, origin: "unknown", originUnreadable: rec.unreadable }
252
+ : { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
253
+ const leaseRow = leaseByTarget.get(p.id);
254
+ if (leaseRow) {
255
+ base.lease = { label: leaseRow.label, pid: leaseRow.pid, idleMs: now - leaseRow.lastUsedAt, expiresAt: leaseRow.lastUsedAt + leaseRow.ttlMs, stale: leaseRow.stale };
256
+ }
257
+ // Page-type only: see ListedPage.responsive. Concurrent across pages
258
+ // (this whole map runs inside Promise.all), so one wedged tab's 500ms
259
+ // budget never serializes onto the next tab's.
260
+ if (doProbe && (p.type === undefined || p.type === "page")) {
261
+ const probe = await probeRendererActivity(driver, backend, p.id, now);
262
+ base.responsive = probe.responsive;
263
+ if (probe.humanActiveMs !== undefined) base.humanActiveMs = probe.humanActiveMs;
264
+ }
265
+ return base;
266
+ }),
267
+ );
210
268
  // `reaped` is present only when it has something to say, so the common-case
211
269
  // shape is byte-identical to 1.4.0. Silently closing a tab is not acceptable:
212
270
  // if a read closed something, the read says so.
@@ -238,6 +296,12 @@ export async function newPage(
238
296
  // silently WEAKEN every existing claim:true call site to pid-only.
239
297
  auto: !explicit,
240
298
  });
299
+ // Install the activity beacon on the same terms claim_page does: at claim
300
+ // time, after the claim, best effort (installBeacon never throws). A tab
301
+ // beaconed now is a tab that can ANSWER later — when this agent has released
302
+ // it and a person has picked it up, which is the case the beacon exists for.
303
+ // Nothing here is read back: a tab created a moment ago has no human history.
304
+ await installBeacon(driver, p.id);
241
305
  return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
242
306
  }
243
307
 
@@ -286,23 +350,65 @@ export interface LegacyNavigateResult {
286
350
  url: string;
287
351
  frameId: string;
288
352
  reloaded?: boolean;
353
+ /** Present only on a history traversal, mirroring how `reloaded` is present only on a reload. */
354
+ traversed?: "back" | "forward";
289
355
  waitedFor: "load" | "domcontentloaded" | "frameStoppedLoading" | "navigate-only";
290
356
  }
291
357
 
358
+ /** What a navigate_page call is actually asking for. Exactly one of the three. */
359
+ export type NavigateMode =
360
+ | { mode: "url"; url: string }
361
+ | { mode: "reload" }
362
+ | { mode: "history"; history: "back" | "forward" };
363
+
364
+ /**
365
+ * The three-way exclusivity check for navigate_page's url / reload / history, split out and
366
+ * exported so every refusal is pinned by a unit test without a browser (the same reason
367
+ * resolveScrollAnchor and resolveDragDestination live at this layer).
368
+ *
369
+ * EXCLUSIVE, NOT PRECEDENCE-ORDERED. `{url, history:"back"}` is a caller who does not know what
370
+ * they want; silently honoring one of them would navigate somewhere the caller did not ask for and
371
+ * report success. Refusing names both, so the mistake is visible in the error rather than in the
372
+ * page that comes back.
373
+ */
374
+ export function resolveNavigateMode(args: { url?: string; reload?: boolean; history?: unknown }): NavigateMode {
375
+ const reload = args.reload === true;
376
+ const hasUrl = typeof args.url === "string" && args.url.length > 0;
377
+ const hasHistory = args.history !== undefined && args.history !== null;
378
+ const given = [hasUrl ? "url" : "", reload ? "reload" : "", hasHistory ? "history" : ""].filter(Boolean);
379
+ if (given.length > 1) {
380
+ throw new SharedToolError(`navigate_page: 'url', 'reload' and 'history' are mutually exclusive (got ${given.join(" + ")})`);
381
+ }
382
+ if (hasHistory) {
383
+ if (args.history !== "back" && args.history !== "forward") {
384
+ throw new SharedToolError(`navigate_page: 'history' must be 'back' or 'forward' (got ${JSON.stringify(args.history)})`);
385
+ }
386
+ return { mode: "history", history: args.history };
387
+ }
388
+ if (reload) return { mode: "reload" };
389
+ if (!hasUrl) {
390
+ throw new SharedToolError(
391
+ "navigate_page: 'url' is required (or pass reload:true to reload the current page, or history:'back'|'forward' to go back/forward in this tab's session history)",
392
+ );
393
+ }
394
+ return { mode: "url", url: args.url as string };
395
+ }
396
+
292
397
  export async function navigatePage(
293
398
  driver: BrowserDriver,
294
- args: { target?: TargetSelector; url?: string; reload?: boolean; ignoreCache?: boolean; waitUntil?: "load" | "domcontentloaded"; timeoutMs?: number },
399
+ args: {
400
+ target?: TargetSelector; url?: string; reload?: boolean; ignoreCache?: boolean;
401
+ history?: "back" | "forward"; waitUntil?: "load" | "domcontentloaded"; timeoutMs?: number;
402
+ },
295
403
  ): Promise<LegacyNavigateResult> {
296
- const reload = args.reload === true;
297
- if (!reload && (!args.url || typeof args.url !== "string")) {
298
- throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
299
- }
404
+ const mode = resolveNavigateMode(args);
300
405
  return withPage(driver, args.target, async (page) => {
301
- const r = await page.navigate(args);
406
+ const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
302
407
  return {
303
408
  url: r.url,
304
409
  frameId: r.contextId,
305
410
  ...(r.reloaded ? { reloaded: r.reloaded } : {}),
411
+ ...(r.traversed ? { traversed: r.traversed } : {}),
306
412
  waitedFor: toLegacyWaitedFor(r.waitedFor),
307
413
  };
308
414
  });
@@ -620,14 +726,55 @@ export async function takeSnapshot(
620
726
 
621
727
  export async function click(
622
728
  driver: BrowserDriver,
623
- args: { target?: TargetSelector; uid?: DriverUid; selector?: string; button?: "left" | "right" | "middle"; clickCount?: number },
729
+ args: { target?: TargetSelector; uid?: DriverUid; selector?: string; button?: "left" | "right" | "middle"; clickCount?: number; modifiers?: string[] },
624
730
  ): Promise<{ clicked: true; x: number; y: number }> {
625
731
  return withPage(driver, args.target, async (page) => {
626
- const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
732
+ const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1, modifiers: args.modifiers });
627
733
  return { clicked: true, x, y };
628
734
  });
629
735
  }
630
736
 
737
+ /**
738
+ * scroll's anchor rule: exactly one of `{uid}`, `{selector}`, or `{x,y}` — or none of the three,
739
+ * which means the viewport center. `x`/`y` must arrive together; one without the other is refused
740
+ * rather than silently treated as "half an anchor". Exported for the unit tests, which pin the
741
+ * rule without a browser.
742
+ */
743
+ export function resolveScrollAnchor(args: { uid?: DriverUid; selector?: string; x?: number; y?: number }): ElementLocator | { x: number; y: number } | undefined {
744
+ const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== ("" as unknown);
745
+ const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
746
+ const hasX = args.x !== undefined;
747
+ const hasY = args.y !== undefined;
748
+ if (hasX !== hasY) throw new SharedToolError("scroll: 'x' and 'y' must be provided together");
749
+ const hasXY = hasX && hasY;
750
+ const anchorCount = [hasUid, hasSelector, hasXY].filter(Boolean).length;
751
+ if (anchorCount > 1) throw new SharedToolError("scroll: provide at most one of { uid }, { selector }, or { x, y } (omit all three to scroll at the viewport center)");
752
+ if (hasUid || hasSelector) return locatorOf({ uid: args.uid, selector: args.selector });
753
+ if (hasXY) return { x: args.x as number, y: args.y as number };
754
+ return undefined;
755
+ }
756
+
757
+ /** scroll's delta rule: at least one of deltaX/deltaY is required (a no-op scroll is refused
758
+ * rather than silently dispatched). Exported for the unit tests. */
759
+ export function resolveScrollDelta(args: { deltaX?: number; deltaY?: number }): { deltaX: number; deltaY: number } {
760
+ const deltaX = args.deltaX ?? 0;
761
+ const deltaY = args.deltaY ?? 0;
762
+ if (deltaX === 0 && deltaY === 0) throw new SharedToolError("scroll requires at least one of { deltaX } or { deltaY }");
763
+ return { deltaX, deltaY };
764
+ }
765
+
766
+ export async function scroll(
767
+ driver: BrowserDriver,
768
+ args: { target?: TargetSelector; uid?: DriverUid; selector?: string; x?: number; y?: number; deltaX?: number; deltaY?: number },
769
+ ): Promise<{ x: number; y: number; deltaX: number; deltaY: number; target: { id: string; url: string; title: string } }> {
770
+ const anchor = resolveScrollAnchor(args);
771
+ const { deltaX, deltaY } = resolveScrollDelta(args);
772
+ return withPage(driver, args.target, async (page) => {
773
+ const { x, y } = await page.scroll(anchor, { deltaX, deltaY });
774
+ return { x, y, deltaX, deltaY, target: target3(page.info) };
775
+ });
776
+ }
777
+
631
778
  export async function hover(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string }): Promise<{ hovered: true; x: number; y: number }> {
632
779
  return withPage(driver, args.target, async (page) => {
633
780
  const { x, y } = await page.hover(locatorOf(args));
@@ -635,14 +782,106 @@ export async function hover(driver: BrowserDriver, args: { target?: TargetSelect
635
782
  });
636
783
  }
637
784
 
785
+ /** drag's `to`: exactly one of { uid } | { selector } | { x, y }, x and y together. Exported for
786
+ * the unit tests, which pin the rule without a browser. */
787
+ export function resolveDragTo(to: { uid?: DriverUid; selector?: string; x?: number; y?: number }): DragDestination {
788
+ const hasUid = to.uid !== undefined && to.uid !== null && to.uid !== ("" as unknown);
789
+ const hasSelector = typeof to.selector === "string" && to.selector.length > 0;
790
+ const hasX = to.x !== undefined;
791
+ const hasY = to.y !== undefined;
792
+ if (hasX !== hasY) throw new SharedToolError("drag: 'to.x' and 'to.y' must be provided together");
793
+ const hasXY = hasX && hasY;
794
+ const count = [hasUid, hasSelector, hasXY].filter(Boolean).length;
795
+ if (count !== 1) throw new SharedToolError("drag: 'to' takes exactly one of { uid }, { selector }, or { x, y }");
796
+ if (hasXY) {
797
+ if (!Number.isFinite(to.x) || !Number.isFinite(to.y)) throw new SharedToolError("drag: 'to.x' and 'to.y' must be finite numbers");
798
+ return { x: to.x as number, y: to.y as number };
799
+ }
800
+ return locatorOf({ uid: to.uid, selector: to.selector });
801
+ }
802
+
803
+ /**
804
+ * drag's destination rule: EXACTLY one of `to` (where the drag ends) or `by` (how far it moves
805
+ * from the source point). Both is a contradiction and neither leaves nowhere to drag to, so both
806
+ * are refused rather than resolved by precedence — a caller who passed both meant one of them and
807
+ * silently picking is how the wrong widget gets dragged. `by` needs at least one of dx/dy; the
808
+ * other defaults to 0, so `by:{dx:40}` is a pure horizontal slider nudge. Exported for the tests.
809
+ */
810
+ export function resolveDragDestination(args: {
811
+ to?: { uid?: DriverUid; selector?: string; x?: number; y?: number };
812
+ by?: { dx?: number; dy?: number };
813
+ }): DragDestination {
814
+ const hasTo = args.to !== undefined && args.to !== null;
815
+ const hasBy = args.by !== undefined && args.by !== null;
816
+ if (hasTo === hasBy) throw new SharedToolError("drag requires exactly one of { to } or { by }");
817
+ if (hasBy) {
818
+ const by = args.by as { dx?: number; dy?: number };
819
+ const hasDx = by.dx !== undefined;
820
+ const hasDy = by.dy !== undefined;
821
+ if (!hasDx && !hasDy) throw new SharedToolError("drag: 'by' requires at least one of { dx } or { dy }");
822
+ const dx = by.dx ?? 0;
823
+ const dy = by.dy ?? 0;
824
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) throw new SharedToolError("drag: 'by.dx' and 'by.dy' must be finite numbers");
825
+ return { dx, dy };
826
+ }
827
+ return resolveDragTo(args.to as { uid?: DriverUid; selector?: string; x?: number; y?: number });
828
+ }
829
+
830
+ /** Upper bound on drag's interpolated moves. 500 already exceeds what any per-frame-sampling DnD
831
+ * library needs (a 60fps drag lasting 8s), and each step is a round-tripped CDP command, so an
832
+ * accidental `steps: 100000` would otherwise wedge the call for minutes. Refused, not clamped. */
833
+ const MAX_DRAG_STEPS = 500;
834
+
835
+ /** drag's `steps`: an integer in [1, MAX_DRAG_STEPS], default 2 (the pre-1.8.0 midpoint +
836
+ * destination sequence). Exported for the unit tests. */
837
+ export function resolveDragSteps(steps?: number): number {
838
+ if (steps === undefined) return 2;
839
+ if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_DRAG_STEPS) {
840
+ throw new SharedToolError(`drag: 'steps' must be an integer between 1 and ${MAX_DRAG_STEPS} (default 2)`);
841
+ }
842
+ return steps;
843
+ }
844
+
845
+ /**
846
+ * drag's `mode`, plus the ONE backend gate in this file. mode:"html5" needs Capability
847
+ * "input.html5Drag", which only the Chrome driver declares (Chrome's Input.setInterceptDrags /
848
+ * dragIntercepted / dispatchDragEvent have no WebDriver BiDi equivalent). Per ADR-001 this is a
849
+ * PARAM-level gap, not a whole-tool one: `drag` stays in tools/list on Firefox because mouse mode
850
+ * works there, and asking for html5 on Firefox is a clear validation error — never a silent
851
+ * downgrade to mouse mode, which would report success for a drop that never happened.
852
+ * Exported for the unit tests.
853
+ */
854
+ export function resolveDragMode(mode: string | undefined, capabilities: ReadonlySet<Capability>): "mouse" | "html5" {
855
+ if (mode === undefined || mode === "mouse") return "mouse";
856
+ if (mode !== "html5") throw new SharedToolError(`drag: unknown mode '${mode}' (use 'mouse' or 'html5')`);
857
+ if (!capabilities.has("input.html5Drag")) {
858
+ throw new SharedToolError(
859
+ "drag: mode:'html5' is not supported by this backend (WebDriver BiDi has no drag-interception primitive). " +
860
+ "Omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.",
861
+ );
862
+ }
863
+ return "html5";
864
+ }
865
+
638
866
  export async function drag(
639
867
  driver: BrowserDriver,
640
- args: { target?: TargetSelector; from: { uid?: DriverUid; selector?: string }; to: { uid?: DriverUid; selector?: string } },
641
- ): Promise<{ dragged: true; from: { x: number; y: number }; to: { x: number; y: number } }> {
642
- if (!args.from || !args.to) throw new SharedToolError("drag requires { from } and { to }");
868
+ args: {
869
+ target?: TargetSelector;
870
+ from: { uid?: DriverUid; selector?: string };
871
+ to?: { uid?: DriverUid; selector?: string; x?: number; y?: number };
872
+ by?: { dx?: number; dy?: number };
873
+ mode?: "mouse" | "html5";
874
+ steps?: number;
875
+ },
876
+ ): Promise<{ dragged: true; mode: "mouse" | "html5"; steps: number; from: { x: number; y: number }; to: { x: number; y: number } }> {
877
+ if (!args.from) throw new SharedToolError("drag requires { from }");
878
+ const destination = resolveDragDestination(args);
879
+ const steps = resolveDragSteps(args.steps);
880
+ // Capability check BEFORE withPage: an unsupported mode must not claim a lease or open a socket.
881
+ const mode = resolveDragMode(args.mode, driver.capabilities);
643
882
  return withPage(driver, args.target, async (page) => {
644
- const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
645
- return { dragged: true, from, to };
883
+ const { from, to } = await page.drag(locatorOf(args.from), destination, { mode, steps });
884
+ return { dragged: true, mode, steps, from, to };
646
885
  });
647
886
  }
648
887
 
@@ -775,8 +1014,9 @@ export async function handleDialog(
775
1014
 
776
1015
  /* ------------------------------------- registry ------------------------------------- */
777
1016
 
778
- /** The 23 tools this file unifies, importable by both src/index.ts (Chrome) and
779
- * src/firefox-tools.ts (Firefox). Each function is (driver, args) => Promise<result>. */
1017
+ /** The 24 tools this file unifies (23 pre-1.8.0, plus `scroll`, Track P1), importable by both
1018
+ * src/index.ts (Chrome) and src/firefox-tools.ts (Firefox). Each function is
1019
+ * (driver, args) => Promise<result>. */
780
1020
  export const SHARED_TOOLS = {
781
1021
  list_pages: listPages,
782
1022
  new_page: newPage,
@@ -792,6 +1032,7 @@ export const SHARED_TOOLS = {
792
1032
  click,
793
1033
  hover,
794
1034
  drag,
1035
+ scroll,
795
1036
  fill,
796
1037
  fill_form: fillForm,
797
1038
  type_text: typeText,
@@ -0,0 +1,246 @@
1
+ /**
2
+ * browser-session.ts: ONE standing connection to the CDP **browser** endpoint, held open across
3
+ * tool calls, owning the browser-client-scoped state behind `wait_for_download` and
4
+ * `grant_permissions` (1.8.0 Track P3).
5
+ *
6
+ * WHY A STANDING CONNECTION IS NOT OPTIONAL HERE
7
+ * ==============================================
8
+ * The 1.8.0 spec described wait_for_download as "the server subscribes to
9
+ * Browser.downloadWillBegin/downloadProgress and keeps a ring buffer", which silently assumes a
10
+ * connection that outlives a tool call. This toolkit's CDP lifetime is per-call (ADR-001,
11
+ * driver.ts: "per-call": acquiring opens a transport and release() closes it), so that assumption
12
+ * had to be checked rather than inherited — the same spec had already shipped one false
13
+ * protocol assertion that Track S1 caught the same way.
14
+ *
15
+ * MEASURED, against an isolated headless Chrome 151 on macOS (three controls, one variable — the
16
+ * arming client's liveness — with the click, the page, and the download path held identical):
17
+ *
18
+ * A. armer connection OPEN across the click .............. BOTH downloads landed in downloadPath;
19
+ * Browser.downloadWillBegin + downloadProgress{state:"completed"} arrived on the armer.
20
+ * B. armer connection CLOSED before the click ............ ZERO files. No events anywhere.
21
+ * C. armed from the PAGE endpoint, then closed .......... ZERO files.
22
+ *
23
+ * So `Browser.setDownloadBehavior{behavior:"allowAndName", downloadPath, eventsEnabled:true}` does
24
+ * NOT persist browser-side after the arming client disconnects: it is per-client state that Chrome
25
+ * reverts on disconnect, and headless Chrome's default is to deny downloads, so the file does not
26
+ * merely go somewhere else — it never happens at all.
27
+ *
28
+ * The same experiment run for permissions gives the same verdict: `Browser.grantPermissions` on
29
+ * either endpoint, followed by that connection closing, leaves
30
+ * `navigator.permissions.query({name:"geolocation"}).state` at "prompt", never "granted". Both
31
+ * features therefore need a connection someone keeps open. That is this module.
32
+ *
33
+ * WHAT A DIRECTORY WATCHER WOULD AND WOULD NOT BUY. Watching downloadPath for a new file (and its
34
+ * .crdownload disappearing) was the obvious per-call fallback, and it does not work on its own:
35
+ * with nothing armed, the download is denied and the directory stays empty forever, so the watcher
36
+ * would poll a directory that can never fill. Once something IS armed, the standing connection
37
+ * already receives `Browser.downloadProgress{state:"completed", filePath}` — which carries the
38
+ * finished path directly, so there is nothing left for a watcher to discover. It is not implemented
39
+ * because it would be strictly redundant, not because it was overlooked.
40
+ *
41
+ * LIFETIME AND ITS HONEST LIMIT. The connection is opened LAZILY, on the first call to one of the
42
+ * two tools, so a session that never downloads and never grants permissions opens nothing and pays
43
+ * nothing. It then lives until the process exits. Under the MCP server (a long-lived process) that
44
+ * spans many tool calls, which is what makes "arm, click in a later call, wait in a later call
45
+ * still" work. Under the CLI — one process per command — it dies at process exit, so both tools are
46
+ * MCP-server capabilities, exactly like network_mock.ts's persistent fake backends and recorder.ts's
47
+ * background mode, and for exactly the same reason.
48
+ *
49
+ * If the connection dies (browser exited, socket dropped), the arm and every grant made through it
50
+ * are gone with it; the session is marked dead and the next call opens a fresh one and RE-ARMS the
51
+ * download behavior it remembers. Permission grants cannot be replayed that way — this module never
52
+ * pretends a grant survived a reconnect, because a caller told "granted" for a permission the
53
+ * browser has since forgotten would act on it.
54
+ */
55
+ import { mkdir } from "node:fs/promises";
56
+ import { join } from "node:path";
57
+ import { openBrowser, type CdpConnection } from "../client.ts";
58
+
59
+ /** Artifact root, captured at import exactly like every other module here (screencast, heap, ...). */
60
+ const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
61
+
62
+ /** Where armed downloads are written, before wait_for_download renames each guid to its filename. */
63
+ export const DOWNLOADS_DIR = join(ARTIFACT_DIR, "downloads");
64
+
65
+ /** How many finished downloads the ring buffer keeps. Same intent as recorder.ts's buffers: enough
66
+ * that a caller who triggered several downloads before waiting gets them all, bounded so a
67
+ * long-lived server cannot grow this without limit. */
68
+ export const DOWNLOAD_BUFFER_LIMIT = 50;
69
+
70
+ /** One download, as the standing connection observed it. */
71
+ export interface DownloadRecord {
72
+ guid: string;
73
+ /** Source URL, from Browser.downloadWillBegin. */
74
+ url?: string;
75
+ /** The page's own name for the file (the `download` attribute / Content-Disposition). UNTRUSTED:
76
+ * it is page-controlled, so downloads.ts sanitizes it before it ever reaches a filesystem path. */
77
+ suggestedFilename?: string;
78
+ /** Bytes received, from the terminal Browser.downloadProgress. */
79
+ bytes?: number;
80
+ /** Absolute path Chrome wrote, straight off the completed event — never reconstructed by us. */
81
+ filePath?: string;
82
+ state: "inProgress" | "completed" | "canceled";
83
+ /** True once a wait_for_download call has returned this record, so two waits never answer with
84
+ * the same download. */
85
+ consumed: boolean;
86
+ }
87
+
88
+ interface BrowserSession {
89
+ conn: CdpConnection;
90
+ /** The path passed to setDownloadBehavior on this connection; undefined until armed. */
91
+ armedPath?: string;
92
+ downloads: DownloadRecord[];
93
+ /** Resolvers waiting for the next completed download, in FIFO order. */
94
+ waiters: Array<(r: DownloadRecord) => void>;
95
+ dead: boolean;
96
+ }
97
+
98
+ let session: BrowserSession | undefined;
99
+
100
+ /** True for the errors that mean "this socket is gone", not "the command was rejected". */
101
+ function isDisconnect(e: unknown): boolean {
102
+ const m = e instanceof Error ? e.message : String(e);
103
+ return /connection (not open|closed)|closed by caller|websocket|ECONNREFUSED|socket/i.test(m);
104
+ }
105
+
106
+ function record(s: BrowserSession, guid: string): DownloadRecord {
107
+ let r = s.downloads.find((d) => d.guid === guid);
108
+ if (!r) {
109
+ r = { guid, state: "inProgress", consumed: false };
110
+ s.downloads.push(r);
111
+ // Drop the OLDEST already-consumed record first, and only fall back to dropping an unconsumed
112
+ // one when the buffer is full of them: evicting a download nobody has collected yet is how a
113
+ // caller silently loses a file they are about to ask for.
114
+ while (s.downloads.length > DOWNLOAD_BUFFER_LIMIT) {
115
+ const i = s.downloads.findIndex((d) => d.consumed);
116
+ s.downloads.splice(i >= 0 ? i : 0, 1);
117
+ }
118
+ }
119
+ return r;
120
+ }
121
+
122
+ /** Wire the download event handlers onto a freshly opened connection. */
123
+ function subscribe(s: BrowserSession): void {
124
+ s.conn.on("Browser.downloadWillBegin", (p) => {
125
+ const r = record(s, String(p.guid));
126
+ if (typeof p.url === "string") r.url = p.url;
127
+ if (typeof p.suggestedFilename === "string") r.suggestedFilename = p.suggestedFilename;
128
+ });
129
+ s.conn.on("Browser.downloadProgress", (p) => {
130
+ const r = record(s, String(p.guid));
131
+ if (typeof p.receivedBytes === "number") r.bytes = p.receivedBytes;
132
+ if (typeof p.filePath === "string") r.filePath = p.filePath;
133
+ const state = String(p.state);
134
+ if (state === "completed" || state === "canceled") {
135
+ r.state = state;
136
+ // Hand a completed download straight to the longest-waiting caller. Marking it consumed HERE
137
+ // (not in the waiter) closes the race where a second waiter, scheduled between this callback
138
+ // and the first waiter resuming, scans the buffer and claims the same record.
139
+ if (state === "completed") {
140
+ const waiter = s.waiters.shift();
141
+ if (waiter) {
142
+ r.consumed = true;
143
+ waiter(r);
144
+ }
145
+ }
146
+ }
147
+ });
148
+ }
149
+
150
+ /**
151
+ * The standing connection, opened on first use and reused forever after. Re-arms the download
152
+ * behavior when a dead connection is replaced, so a browser restart does not silently leave the
153
+ * caller unarmed while every call still reports success.
154
+ */
155
+ export async function getBrowserSession(): Promise<BrowserSession> {
156
+ if (session && !session.dead) return session;
157
+ const previousPath = session?.armedPath;
158
+ const conn = await openBrowser();
159
+ const s: BrowserSession = { conn, downloads: [], waiters: [], dead: false };
160
+ subscribe(s);
161
+ session = s;
162
+ if (previousPath) await armDownloadsAt(previousPath);
163
+ return s;
164
+ }
165
+
166
+ /** Send on the standing connection, marking the session dead if the socket turned out to be gone. */
167
+ export async function browserSend<T = Record<string, unknown>>(method: string, params: Record<string, unknown> = {}): Promise<T> {
168
+ const s = await getBrowserSession();
169
+ try {
170
+ return await s.conn.send<T>(method, params);
171
+ } catch (e) {
172
+ if (isDisconnect(e)) {
173
+ s.dead = true;
174
+ // One retry on a fresh connection: a socket that died between calls is the expected case
175
+ // (the browser was restarted), not an error the caller can act on.
176
+ const fresh = await getBrowserSession();
177
+ return await fresh.conn.send<T>(method, params);
178
+ }
179
+ throw e;
180
+ }
181
+ }
182
+
183
+ /** Point Chrome's downloads at `path` on the standing connection, creating the directory first. */
184
+ async function armDownloadsAt(path: string): Promise<void> {
185
+ await mkdir(path, { recursive: true });
186
+ const s = await getBrowserSession();
187
+ await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
188
+ s.armedPath = path;
189
+ }
190
+
191
+ /**
192
+ * Ensure downloads are captured to DOWNLOADS_DIR. Idempotent and cheap to repeat: re-issuing
193
+ * setDownloadBehavior with identical parameters is accepted by Chrome (verified), so every
194
+ * wait_for_download call can arm without tracking whether some earlier call already did.
195
+ */
196
+ export async function armDownloads(): Promise<{ downloadPath: string }> {
197
+ const s = await getBrowserSession();
198
+ if (s.armedPath !== DOWNLOADS_DIR) await armDownloadsAt(DOWNLOADS_DIR);
199
+ return { downloadPath: DOWNLOADS_DIR };
200
+ }
201
+
202
+ /** Completed-but-uncollected downloads, oldest first. */
203
+ export function pendingDownloads(): DownloadRecord[] {
204
+ return (session?.downloads ?? []).filter((d) => d.state === "completed" && !d.consumed);
205
+ }
206
+
207
+ /**
208
+ * The next completed download nobody has collected: one already buffered, else the next one to
209
+ * finish, else a timeout. Buffered-first is what makes "click in one tool call, wait in a later
210
+ * one" work — the standing connection recorded the completion while no tool call was running.
211
+ */
212
+ export async function nextCompletedDownload(timeoutMs: number): Promise<DownloadRecord> {
213
+ const s = await getBrowserSession();
214
+ const buffered = s.downloads.find((d) => d.state === "completed" && !d.consumed);
215
+ if (buffered) {
216
+ buffered.consumed = true;
217
+ return buffered;
218
+ }
219
+ return new Promise<DownloadRecord>((resolve, reject) => {
220
+ const waiter = (r: DownloadRecord): void => {
221
+ clearTimeout(timer);
222
+ resolve(r);
223
+ };
224
+ const timer = setTimeout(() => {
225
+ const i = s.waiters.indexOf(waiter);
226
+ if (i >= 0) s.waiters.splice(i, 1);
227
+ reject(new Error(
228
+ `wait_for_download: no download completed within ${timeoutMs}ms. Downloads are only captured while this server holds the arming connection, so the triggering click must come AFTER a wait_for_download call (use arm:true to arm without waiting).`,
229
+ ));
230
+ }, timeoutMs);
231
+ s.waiters.push(waiter);
232
+ });
233
+ }
234
+
235
+ /** Close the standing connection. For process shutdown and tests; safe to call when none is open. */
236
+ export async function disposeBrowserSession(): Promise<void> {
237
+ const s = session;
238
+ session = undefined;
239
+ if (!s) return;
240
+ s.dead = true;
241
+ try {
242
+ s.conn.close();
243
+ } catch {
244
+ /* already gone */
245
+ }
246
+ }