cdp-toolkit 1.6.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.
package/src/mcp.ts CHANGED
@@ -19,11 +19,12 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
19
19
  import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
20
20
  import { MANIFEST } from "./manifest.ts";
21
21
  import { resolveBrowserKind, stripBrowserFlag, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
22
+ import { disposeBrowserSession } from "./tools/browser-session.ts";
22
23
  import { toolAvailability } from "./capabilities.ts";
23
24
  import { FIREFOX_TOOLS } from "./firefox-tools.ts";
24
- import { leaseFromArgs, withLeaseScope } from "./leases.ts";
25
+ import { leaseFromArgs, markLongLivedProcess, withLeaseScope } from "./leases.ts";
25
26
 
26
- const VERSION = "1.6.0";
27
+ const VERSION = "1.8.0";
27
28
 
28
29
  // --browser is read once at startup (MCP has no per-call notion of backend): explicit flag,
29
30
  // else CDP_BROWSER env var, else "chrome" (zero behavior change for existing users/configs).
@@ -100,6 +101,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
100
101
  });
101
102
 
102
103
  auditCoverage();
104
+ // This process is the long-lived MCP server, which is what makes strict mode
105
+ // (CDP_REQUIRE_LEASE) safe to honor here and unsafe in cli.ts. See requireLease.
106
+ markLongLivedProcess();
103
107
  await server.connect(new StdioServerTransport());
104
108
  console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`);
105
109
 
@@ -112,6 +116,10 @@ async function shutdown(): Promise<void> {
112
116
  if (shuttingDown) return;
113
117
  shuttingDown = true;
114
118
  await disposeFirefoxSession();
119
+ // The standing browser-endpoint connection behind wait_for_download / grant_permissions (1.8.0
120
+ // Track P3). Unlike Firefox it owns no OS process, so process.exit would collect it anyway; it is
121
+ // closed explicitly so shutdown does not depend on that, and is a no-op when neither tool ran.
122
+ await disposeBrowserSession();
115
123
  process.exit(0);
116
124
  }
117
125
  process.on("SIGINT", () => void shutdown());
package/src/reap.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Closing tabs whose agent died, without a background sweeper.
3
+ *
4
+ * WHY REAP AT ALL. release_page closes a tab an agent gives back, but an agent
5
+ * that crashes, is killed, or simply stops calling never gives anything back.
6
+ * Its tab stays open forever with a lease file nobody will ever release. That
7
+ * is the failure this module exists for, and it is the common one: a timeout is
8
+ * a more likely end to an agent than a clean shutdown.
9
+ *
10
+ * WHY REAP-ON-READ AND NOT A SWEEPER. Same reasoning as origins.ts's ledger: a
11
+ * background sweeper is a second lifetime to reason about and a process that
12
+ * has to be running. list_pages and list_leases already hold the browser's
13
+ * target list and the lease directory at the moment they run, so doing it there
14
+ * costs one extra pass over data already in hand and needs nothing scheduled.
15
+ *
16
+ * WHY THE SELECTION IS A PURE FUNCTION. Every mistake in this module closes a
17
+ * tab someone wanted. A pure selector over plain data is testable across the
18
+ * whole matrix of {provenance} x {lease state} x {liveness} with no browser and
19
+ * no filesystem, which is the only way to be confident about a destructive
20
+ * operation. The impure wrapper below does nothing but call it and act.
21
+ *
22
+ * WHY REAPING SPLIT FROM RECLAIMABILITY (1.8.0). Before this version `expired`
23
+ * meant one thing everywhere: at ttlMs, both "another agent may take this
24
+ * lease" AND "this tab gets closed" fired at once. That is wrong for the
25
+ * second question and right for the first: a lease going reclaimable at ttlMs
26
+ * is cheap and reversible, but closing someone's tab is not, and an agent 20
27
+ * minutes into a build between browser calls looks identical to one that died.
28
+ * leases.ts's staleReason keeps deciding reclaimability at ttlMs, unchanged;
29
+ * this module alone additionally waits reapGraceMs past that before treating
30
+ * `expired` as grounds to close anything. See staleAgentTabs' condition 4.
31
+ */
32
+ import type { BrowserDriver } from "./driver.ts";
33
+ import { listLeases, releaseLeaseFor, type LeaseBackend, type LeaseSummary } from "./leases.ts";
34
+ import { originIndex, type OriginSummary } from "./origins.ts";
35
+
36
+ export interface ReapedTab {
37
+ targetId: string;
38
+ label: string;
39
+ /** Why it was reaped. Deliberately narrower than LeaseStaleReason: see below. */
40
+ reason: "dead-pid" | "expired";
41
+ }
42
+
43
+ export interface ReapInput {
44
+ backend: LeaseBackend;
45
+ /**
46
+ * Ids from the PAGE-ONLY listing, never from an `all:true` listing. A worker,
47
+ * iframe or background page can carry a lease (pickPage's bare-id branch
48
+ * resolves non-page targets), and "close the stale tab" is meaningless for
49
+ * one. This is a different set from the one shared-tools' reapSet feeds to the
50
+ * origin ledger, which deliberately uses the unfiltered listing so a live but
51
+ * filtered-out target does not lose its provenance record. Two reaps, two
52
+ * questions, two id sets: do not merge them.
53
+ */
54
+ livePageIds: readonly string[];
55
+ origins: ReadonlyMap<string, OriginSummary>;
56
+ leases: readonly LeaseSummary[];
57
+ /** Clock for the grace check below. Threaded in rather than read internally
58
+ * (Date.now()) so this function stays pure and the whole matrix is testable
59
+ * with no wall clock. */
60
+ now: number;
61
+ /** How much longer an `expired` lease survives before its tab is destroyed,
62
+ * on top of the ttlMs that already made it reclaimable. See reapGraceMs(). */
63
+ reapGraceMs: number;
64
+ }
65
+
66
+ /**
67
+ * Select the tabs that may be closed. PURE: no I/O, no driver, no clock.
68
+ *
69
+ * All conditions must hold, and condition 4 (the grace check) is new in 1.8.0:
70
+ *
71
+ * 1. the lease row is for this backend and READABLE. An `unreadable` row is
72
+ * never reaped for the same reason listLeases reports it as stale:false -
73
+ * we cannot see who owns it, and a guess in the destructive direction is
74
+ * the one guess that cannot be undone.
75
+ * 2. it is stale for `dead-pid` or `expired`. NOT `target-gone`, which means
76
+ * the tab is already closed and there is nothing to do, and obviously not
77
+ * `false`.
78
+ * 3. the target is actually still open, per livePageIds.
79
+ * 4. THE GRACE WINDOW. `stale === "expired"` alone is the RECLAIMABILITY
80
+ * signal (see leases.ts's staleReason): it fires at ttlMs and is cheap and
81
+ * reversible — another agent may simply take the lease. Closing the tab is
82
+ * a different, destructive question, and it does not fire until an
83
+ * ADDITIONAL reapGraceMs has elapsed past that same ttlMs, so an agent that
84
+ * is 20 minutes into a build between browser calls gets a window to come
85
+ * back before its tab is destroyed. `dead-pid` skips this check entirely:
86
+ * a process that no longer exists is never coming back to reclaim
87
+ * anything, so there is nothing to wait for, and that is why dead-pid stays
88
+ * immediate while expired does not.
89
+ * 5. the toolkit OPENED it. An agent-created tab with NO lease is deliberately
90
+ * never reaped: that is exactly what new_page produces for a user who never
91
+ * touches leases, and closing those would be a data-loss bug rather than a
92
+ * cleanup. Only a tab that was claimed and then abandoned qualifies.
93
+ */
94
+ export function staleAgentTabs(input: ReapInput): ReapedTab[] {
95
+ const live = new Set(input.livePageIds);
96
+ const out: ReapedTab[] = [];
97
+ for (const row of input.leases) {
98
+ if (row.backend !== input.backend) continue;
99
+ if (row.unreadable !== undefined) continue;
100
+ if (row.stale !== "dead-pid" && row.stale !== "expired") continue;
101
+ if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs) continue;
102
+ if (!live.has(row.targetId)) continue;
103
+ const origin = input.origins.get(row.targetId);
104
+ if (origin === undefined || origin.unreadable !== undefined) continue;
105
+ out.push({ targetId: row.targetId, label: row.label, reason: row.stale });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * 45 minutes. Combined with the default 15-minute lease TTL (leases.ts), the
112
+ * total fuse from an agent's last touch to its tab being destroyed is 60
113
+ * minutes — generous, because the cost of guessing wrong here is a tab an
114
+ * agent was still using getting closed out from under it, not merely a lease
115
+ * another agent could have taken.
116
+ */
117
+ export const DEFAULT_REAP_GRACE_MS = 2_700_000;
118
+
119
+ /** Read per call, not at module load, so a test can redirect the value — same
120
+ * rule as leaseTtlMs() in leases.ts. Unlike a TTL, zero is a legitimate
121
+ * override (disables the grace period, reverting to at-TTL reaping), so the
122
+ * validity check is `>= 0` rather than `> 0`. */
123
+ export function reapGraceMs(): number {
124
+ const raw = Number(process.env.CDP_REAP_GRACE_MS);
125
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
126
+ }
127
+
128
+ /**
129
+ * Select, close, and drop the leases. Returns ONLY the tabs actually closed.
130
+ *
131
+ * A close that fails is not reported, and that asymmetry is deliberate: the
132
+ * caller filters its listing by this array, so reporting a tab we failed to
133
+ * close would hide a tab that is still open. Failing quietly leaves the tab
134
+ * visible and the lease in place, and the next read tries again.
135
+ *
136
+ * The lease file is unlinked; the ORIGIN record is not. listOrigins already
137
+ * reaps records for targets the browser no longer has, so the record disappears
138
+ * on the next listing. One lifetime rule for origin records, not three.
139
+ */
140
+ export async function reapStaleAgentTabs(driver: BrowserDriver, backend: LeaseBackend): Promise<ReapedTab[]> {
141
+ // ONE clock reading, shared with listLeases' own staleReason classification
142
+ // below: the "expired" verdict a lease reads at and the grace check applied
143
+ // to it here must agree on what "now" was, or the two could disagree at the
144
+ // margin.
145
+ const now = Date.now();
146
+ // The PAGE-ONLY listing: see ReapInput.livePageIds.
147
+ const livePageIds = (await driver.listPages()).map((p) => p.id);
148
+ const [origins, leases] = await Promise.all([
149
+ // undefined liveIds = reap nothing from the ledger here. Reaping origin
150
+ // records is list_pages' job and it uses a DIFFERENT id set (the unfiltered
151
+ // listing); doing it here too would delete records for targets that are
152
+ // live but filtered out of the page-only view.
153
+ originIndex(backend, undefined),
154
+ listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [] as LeaseSummary[]),
155
+ ]);
156
+ const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
157
+ const closed: ReapedTab[] = [];
158
+ for (const c of candidates) {
159
+ const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
160
+ if (res.success !== true) continue;
161
+ await releaseLeaseFor(backend, c.targetId);
162
+ closed.push(c);
163
+ }
164
+ return closed;
165
+ }
@@ -36,11 +36,16 @@
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, 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";
48
+ import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
44
49
 
45
50
  const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
46
51
  const STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
@@ -70,7 +75,9 @@ async function withPage<T>(driver: BrowserDriver, target: TargetSelector | undef
70
75
  }
71
76
  }
72
77
 
73
- async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Promise<PageInfo> {
78
+ /** Exported for leases-tools.ts's release_page{target}, which needs exactly this
79
+ * gate: resolving through here is what authorizes the release. */
80
+ export async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Promise<PageInfo> {
74
81
  const pages = await driver.listPages();
75
82
  const hit = await pickPage(driver, pages, selector);
76
83
  // THE third choke point. close_page and select_page resolve here and nowhere
@@ -93,8 +100,17 @@ async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Pr
93
100
  * every branch resolves against page-type targets only EXCEPT a bare-id lookup, which (like
94
101
  * legacy resolveTarget's `targets.find`) searches the FULL unfiltered listing, so passing an
95
102
  * exact non-page targetId (a worker, an iframe) still resolves.
103
+ *
104
+ * THE GATE-FREE HALF of resolvePage, and exported for the one caller that needs
105
+ * resolution WITHOUT authorization: claim_page{target}. Everything else must go
106
+ * through resolvePage. Claiming cannot run assertLeaseOk, because under
107
+ * CDP_REQUIRE_LEASE the gate AUTO-ACQUIRES a lease on an unleased tab, so a
108
+ * claim routed through it would take the tab twice — once implicitly with an
109
+ * auto lease, then again explicitly, and the second claim would collide with
110
+ * the first. Refusing a tab another process holds is still enforced, one layer
111
+ * down, by claimLease's own conflict check.
96
112
  */
97
- async function pickPage(driver: BrowserDriver, pages: PageInfo[], selector?: TargetSelector): Promise<PageInfo> {
113
+ export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selector?: TargetSelector): Promise<PageInfo> {
98
114
  if (selector === undefined || selector === "active") {
99
115
  if (!pages.length) throw new SharedToolError("no page targets open");
100
116
  return pages[0]!;
@@ -154,6 +170,31 @@ export interface ListedPage extends PageInfo {
154
170
  * genuinely unknown, but this field is what keeps an unreadable record from
155
171
  * being reported as a clean "nothing was ever recorded". */
156
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;
157
198
  }
158
199
 
159
200
  /**
@@ -176,19 +217,58 @@ async function reapSet(driver: BrowserDriver, pages: PageInfo[], all?: boolean):
176
217
  }
177
218
  }
178
219
 
179
- export async function listPages(driver: BrowserDriver, args: { all?: boolean } = {}): Promise<{ pages: ListedPage[]; count: number }> {
180
- const pages = await driver.listPages({ all: args.all });
220
+ export async function listPages(
221
+ driver: BrowserDriver,
222
+ args: { all?: boolean; probe?: boolean } = {},
223
+ ): Promise<{ pages: ListedPage[]; count: number; reaped?: ReapedTab[] }> {
224
+ // Reap FIRST, so a tab this call is about to close never appears in the
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) : [];
230
+ const reapedIds = new Set(reaped.map((r) => r.targetId));
231
+ const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
181
232
  // Never let provenance break the listing: originIndex does not throw, and a
182
233
  // missing or unreadable ledger yields an empty map, so every page falls back
183
234
  // to origin "unknown" exactly as a pre-ledger consumer always saw.
184
- const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
185
- const annotated: ListedPage[] = pages.map((p) => {
186
- const rec = ledger.get(p.id);
187
- if (!rec) return { ...p, origin: "unknown" };
188
- if (rec.unreadable !== undefined) return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
189
- return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
190
- });
191
- return { pages: annotated, count: annotated.length };
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
+ );
268
+ // `reaped` is present only when it has something to say, so the common-case
269
+ // shape is byte-identical to 1.4.0. Silently closing a tab is not acceptable:
270
+ // if a read closed something, the read says so.
271
+ return { pages: annotated, count: annotated.length, ...(reaped.length ? { reaped } : {}) };
192
272
  }
193
273
 
194
274
  export async function newPage(
@@ -199,13 +279,29 @@ export async function newPage(
199
279
  // Creates the tab AND writes its creation record. The record outlives any
200
280
  // lease taken below, which is the point: a released tab is still an agent's.
201
281
  const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
202
- if (args.claim !== true) return { targetId: p.id, url: p.url };
282
+ // Under strict mode a tab nobody holds is a tab nobody may drive, so creating
283
+ // one without claiming it would hand back a target the very next call has to
284
+ // auto-acquire anyway. Doing it here means the tab is never briefly unowned
285
+ // AND the caller is handed a token it can release with, which matters because
286
+ // an auto-acquired lease never returns one.
287
+ const explicit = args.claim === true;
288
+ if (!explicit && !requireLease()) return { targetId: p.id, url: p.url };
203
289
  // Atomic in the sense that matters: the tab is claimed before this call
204
290
  // returns, so no other agent can see it unclaimed and take it first.
205
291
  const { record, token } = await claimLease(backendOf(driver), p.id, {
206
292
  label,
207
293
  ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
294
+ // An explicit claim:true stays explicit under strict mode: the caller asked
295
+ // for the strong tier and must keep getting it, or turning strict on would
296
+ // silently WEAKEN every existing claim:true call site to pid-only.
297
+ auto: !explicit,
208
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);
209
305
  return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
210
306
  }
211
307
 
@@ -254,23 +350,65 @@ export interface LegacyNavigateResult {
254
350
  url: string;
255
351
  frameId: string;
256
352
  reloaded?: boolean;
353
+ /** Present only on a history traversal, mirroring how `reloaded` is present only on a reload. */
354
+ traversed?: "back" | "forward";
257
355
  waitedFor: "load" | "domcontentloaded" | "frameStoppedLoading" | "navigate-only";
258
356
  }
259
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
+
260
397
  export async function navigatePage(
261
398
  driver: BrowserDriver,
262
- 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
+ },
263
403
  ): Promise<LegacyNavigateResult> {
264
- const reload = args.reload === true;
265
- if (!reload && (!args.url || typeof args.url !== "string")) {
266
- throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
267
- }
404
+ const mode = resolveNavigateMode(args);
268
405
  return withPage(driver, args.target, async (page) => {
269
- const r = await page.navigate(args);
406
+ const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
270
407
  return {
271
408
  url: r.url,
272
409
  frameId: r.contextId,
273
410
  ...(r.reloaded ? { reloaded: r.reloaded } : {}),
411
+ ...(r.traversed ? { traversed: r.traversed } : {}),
274
412
  waitedFor: toLegacyWaitedFor(r.waitedFor),
275
413
  };
276
414
  });
@@ -588,14 +726,55 @@ export async function takeSnapshot(
588
726
 
589
727
  export async function click(
590
728
  driver: BrowserDriver,
591
- 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[] },
592
730
  ): Promise<{ clicked: true; x: number; y: number }> {
593
731
  return withPage(driver, args.target, async (page) => {
594
- 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 });
595
733
  return { clicked: true, x, y };
596
734
  });
597
735
  }
598
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
+
599
778
  export async function hover(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string }): Promise<{ hovered: true; x: number; y: number }> {
600
779
  return withPage(driver, args.target, async (page) => {
601
780
  const { x, y } = await page.hover(locatorOf(args));
@@ -603,14 +782,106 @@ export async function hover(driver: BrowserDriver, args: { target?: TargetSelect
603
782
  });
604
783
  }
605
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
+
606
866
  export async function drag(
607
867
  driver: BrowserDriver,
608
- args: { target?: TargetSelector; from: { uid?: DriverUid; selector?: string }; to: { uid?: DriverUid; selector?: string } },
609
- ): Promise<{ dragged: true; from: { x: number; y: number }; to: { x: number; y: number } }> {
610
- 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);
611
882
  return withPage(driver, args.target, async (page) => {
612
- const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
613
- 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 };
614
885
  });
615
886
  }
616
887
 
@@ -743,8 +1014,9 @@ export async function handleDialog(
743
1014
 
744
1015
  /* ------------------------------------- registry ------------------------------------- */
745
1016
 
746
- /** The 23 tools this file unifies, importable by both src/index.ts (Chrome) and
747
- * 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>. */
748
1020
  export const SHARED_TOOLS = {
749
1021
  list_pages: listPages,
750
1022
  new_page: newPage,
@@ -760,6 +1032,7 @@ export const SHARED_TOOLS = {
760
1032
  click,
761
1033
  hover,
762
1034
  drag,
1035
+ scroll,
763
1036
  fill,
764
1037
  fill_form: fillForm,
765
1038
  type_text: typeText,