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/CHANGELOG.md +439 -0
- package/CONTRACT.md +11 -2
- package/README.md +89 -23
- package/dist/cli.js +1150 -111
- package/dist/index.js +1011 -99
- package/dist/mcp.js +1447 -167
- package/package.json +5 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +231 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +472 -25
- package/src/driver.ts +161 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +269 -27
- package/src/leases.ts +84 -4
- package/src/manifest.ts +295 -56
- package/src/mcp.ts +10 -2
- package/src/reap.ts +165 -0
- package/src/shared-tools.ts +303 -30
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
package/src/driver.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ADR-001: one browser-neutral Driver interface behind the
|
|
2
|
+
* ADR-001: one browser-neutral Driver interface behind the 45 tools.
|
|
3
3
|
*
|
|
4
4
|
* Context. Every tool in src/tools/ is a stateless one-shot on withPage():
|
|
5
5
|
* open a socket, act, always close. That is only correct because a CDP `Uid`
|
|
@@ -138,7 +138,11 @@ export type Capability =
|
|
|
138
138
|
| "snapshot.accessibilityTree" // native a11y dump, not a DOM-walk approximation
|
|
139
139
|
| "input.insertTextAtomic" // atomic value commit, not synthesized keystrokes
|
|
140
140
|
| "locate.text" // find a node by visible text substring
|
|
141
|
-
| "locate.xpath"
|
|
141
|
+
| "locate.xpath" // find a node by xpath
|
|
142
|
+
| "input.raw" // raw single mouse-event dispatch (dispatch_mouse's move/down/up primitive); Chrome only
|
|
143
|
+
| "input.html5Drag" // real HTML5 drag events (drag's mode:"html5"); Chrome only, see PageDriver.drag
|
|
144
|
+
| "browser.downloads" // capture a file download to a known path (wait_for_download); Chrome only
|
|
145
|
+
| "browser.permissions"; // grant/reset browser permissions for an origin (grant_permissions); Chrome only
|
|
142
146
|
|
|
143
147
|
/**
|
|
144
148
|
* Tools whose availability depends on a capability. A tool absent from this
|
|
@@ -157,6 +161,9 @@ export const REQUIRED_CAPABILITIES: Partial<Record<ToolName, readonly Capability
|
|
|
157
161
|
mock_request: ["network.intercept"],
|
|
158
162
|
list_mocks: ["network.intercept"],
|
|
159
163
|
clear_mocks: ["network.intercept"],
|
|
164
|
+
dispatch_mouse: ["input.raw"],
|
|
165
|
+
wait_for_download: ["browser.downloads"],
|
|
166
|
+
grant_permissions: ["browser.permissions"],
|
|
160
167
|
} as const;
|
|
161
168
|
|
|
162
169
|
/* --------------------------------- errors --------------------------------- */
|
|
@@ -213,6 +220,19 @@ export interface NavigateOptions {
|
|
|
213
220
|
url?: string;
|
|
214
221
|
reload?: boolean;
|
|
215
222
|
ignoreCache?: boolean;
|
|
223
|
+
/**
|
|
224
|
+
* Traverse the tab's session history instead of loading a URL: the browser Back / Forward
|
|
225
|
+
* buttons. Mutually exclusive with `url` and `reload` (the tool layer enforces that; a driver
|
|
226
|
+
* trusts its caller). Chrome: Page.getNavigationHistory + Page.navigateToHistoryEntry. Firefox:
|
|
227
|
+
* browsingContext.traverseHistory{delta:-1|+1}. Universal — both drivers implement it, so this is
|
|
228
|
+
* deliberately NOT a Capability.
|
|
229
|
+
*
|
|
230
|
+
* NO SILENT NO-OP. Asking to go back from the first history entry (or forward from the last) is a
|
|
231
|
+
* caller mistake, not a legal outcome: both drivers MUST throw an error naming the direction
|
|
232
|
+
* rather than return a success that navigated nowhere, because a no-op success reads as "you are
|
|
233
|
+
* on the previous page" and the caller acts on that.
|
|
234
|
+
*/
|
|
235
|
+
history?: "back" | "forward";
|
|
216
236
|
waitUntil?: "load" | "domcontentloaded";
|
|
217
237
|
timeoutMs?: number;
|
|
218
238
|
}
|
|
@@ -222,12 +242,93 @@ export interface NavigateResult {
|
|
|
222
242
|
/** CDP frameId or BiDi context id. Opaque. */
|
|
223
243
|
contextId: string;
|
|
224
244
|
reloaded?: boolean;
|
|
245
|
+
/** Which direction a `history` traversal actually moved. Present only for a history navigation,
|
|
246
|
+
* the same way `reloaded` is present only for a reload. */
|
|
247
|
+
traversed?: "back" | "forward";
|
|
225
248
|
waitedFor: "load" | "domcontentloaded" | "committed" | "timeout";
|
|
226
249
|
}
|
|
227
250
|
|
|
228
251
|
export interface MouseButtonOptions {
|
|
229
252
|
button?: "left" | "right" | "middle";
|
|
230
253
|
clickCount?: number;
|
|
254
|
+
/** "Alt" | "Control" | "Meta" | "Shift", held for the press/release. CDP bits: Alt=1,
|
|
255
|
+
* Control=2, Meta=4, Shift=8 (cdp/driver.ts's inputModifierBits). A non-empty array is
|
|
256
|
+
* "unsupported" on the Firefox/BiDi driver today: see click()'s implementation note. */
|
|
257
|
+
modifiers?: readonly string[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Where a drag ends. 1.8.0 Track P2 widened this past "an element": an absolute viewport point
|
|
262
|
+
* ({x,y}) and an offset from the source point ({dx,dy}) are both real destinations for widgets
|
|
263
|
+
* that have no droppable element to name — a range slider's track, a map canvas, a resize gutter.
|
|
264
|
+
* The three branches are discriminated structurally ("dx" in / "x" in), the same way
|
|
265
|
+
* PageDriver.scroll's anchor already discriminates an ElementLocator from a viewport point; no
|
|
266
|
+
* ElementLocator variant carries an `x` or `dx` field, so the check is exact and not a heuristic.
|
|
267
|
+
*/
|
|
268
|
+
export type DragDestination = ElementLocator | { x: number; y: number } | { dx: number; dy: number };
|
|
269
|
+
|
|
270
|
+
export interface DragOptions {
|
|
271
|
+
/**
|
|
272
|
+
* "mouse" (default): synthetic mousePressed/mouseMoved…/mouseReleased. Fine for widgets built on
|
|
273
|
+
* raw pointer events, and it is what `drag` has always done.
|
|
274
|
+
*
|
|
275
|
+
* "html5": a REAL HTML5 drag — Input.setInterceptDrags(true), the same press/move sequence, then
|
|
276
|
+
* the intercepted DragData replayed as Input.dispatchDragEvent dragEnter/dragOver/drop at the
|
|
277
|
+
* destination.
|
|
278
|
+
*
|
|
279
|
+
* WHAT THE DIFFERENCE ACTUALLY IS (measured on Chrome 151.0.7922.109, macOS, both --headless=new
|
|
280
|
+
* and headed — NOT assumed). Synthetic mouse events DO start a real drag in modern Chrome, so
|
|
281
|
+
* mouse mode is not inert on HTML5 drop zones the way the folklore says. What mouse mode does
|
|
282
|
+
* not control is WHICH drag events the page receives: that follows the interpolated pointer
|
|
283
|
+
* path, and at the default steps:2 a source-to-zone drag delivered dragstart and drop but ZERO
|
|
284
|
+
* dragover. A drop zone written the standard way — preventDefault inside dragover, which is how
|
|
285
|
+
* the HTML spec says you mark a valid drop target — therefore refused the drop outright, while
|
|
286
|
+
* steps:1 and steps:8 on the very same page happened to work. html5 mode removes the luck: the
|
|
287
|
+
* dragEnter/dragOver/drop triple is dispatched explicitly at the destination every time.
|
|
288
|
+
*
|
|
289
|
+
* Requires Capability "input.html5Drag" — Chrome only. A driver without that capability MUST
|
|
290
|
+
* throw "unsupported"; it must never silently downgrade to mouse mode, because a caller who
|
|
291
|
+
* asked for html5 asked precisely because mouse mode was not delivering the drop.
|
|
292
|
+
*/
|
|
293
|
+
mode?: "mouse" | "html5";
|
|
294
|
+
/** Interpolated mouseMoved events dispatched between the source and destination points, evenly
|
|
295
|
+
* spaced, the last one landing exactly on the destination. Default 2 (the pre-1.8.0 hardcoded
|
|
296
|
+
* behavior: midpoint, then destination). DnD libraries with a movement threshold or per-frame
|
|
297
|
+
* sampling need more. The tool layer validates the range; a driver trusts its caller. */
|
|
298
|
+
steps?: number;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The `steps` evenly-spaced points between `from` (exclusive) and `to` (inclusive), for a drag's
|
|
303
|
+
* interpolated move sequence. The last point is EXACTLY `to`, emitted as `to` itself rather than
|
|
304
|
+
* computed, so float error can never leave the pointer a fraction of a pixel short of the
|
|
305
|
+
* destination element. steps:1 is a single jump to the destination; steps:2 is
|
|
306
|
+
* midpoint-then-destination, the pre-1.8.0 hardcoded behavior.
|
|
307
|
+
*
|
|
308
|
+
* Lives here, in the backend-neutral module, because BOTH drivers need the identical geometry:
|
|
309
|
+
* the CDP driver turns each point into an Input.dispatchMouseEvent{mouseMoved} and the BiDi driver
|
|
310
|
+
* into a performActions pointerMove, but "where the pointer goes" must not differ by backend.
|
|
311
|
+
* Callers pass an already-validated integer >= 1 (the tool layer validates; a driver trusts it).
|
|
312
|
+
*/
|
|
313
|
+
export function interpolatePoints(
|
|
314
|
+
from: { x: number; y: number },
|
|
315
|
+
to: { x: number; y: number },
|
|
316
|
+
steps: number,
|
|
317
|
+
): Array<{ x: number; y: number }> {
|
|
318
|
+
const out: Array<{ x: number; y: number }> = [];
|
|
319
|
+
for (let i = 1; i < steps; i++) {
|
|
320
|
+
out.push({ x: from.x + ((to.x - from.x) * i) / steps, y: from.y + ((to.y - from.y) * i) / steps });
|
|
321
|
+
}
|
|
322
|
+
out.push({ x: to.x, y: to.y });
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface ScrollOptions {
|
|
327
|
+
/** Positive scrolls DOWN, positive scrolls RIGHT: wheel-event convention. The tool layer
|
|
328
|
+
* (shared-tools.ts) requires at least one of deltaX/deltaY; a driver trusts its caller and
|
|
329
|
+
* does not re-validate. */
|
|
330
|
+
deltaX?: number;
|
|
331
|
+
deltaY?: number;
|
|
231
332
|
}
|
|
232
333
|
|
|
233
334
|
export interface KeyPress {
|
|
@@ -414,6 +515,54 @@ export interface BrowserDriver {
|
|
|
414
515
|
/** Acquire a page handle. The caller MUST release() it. Prefer withDriver. */
|
|
415
516
|
page(selector: TargetSelector): Promise<PageDriver>;
|
|
416
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Install the activity beacon into a target and, where the backend can, into
|
|
520
|
+
* every document it navigates to afterwards. Resolves true when the beacon is
|
|
521
|
+
* in place. See src/activity.ts.
|
|
522
|
+
*
|
|
523
|
+
* OPTIONAL, per ADR-001's "absent rather than present-and-throwing" rule, but
|
|
524
|
+
* NOT a `Capability`: that union gates whole tools out of tools/list, and no
|
|
525
|
+
* tool is gated here. The beacon is an additive FIELD on claim_page and
|
|
526
|
+
* list_leases, both of which work unchanged without it, so the honest
|
|
527
|
+
* expression of a backend that cannot do this is an absent method producing an
|
|
528
|
+
* absent field — not a missing tool and not a throw.
|
|
529
|
+
*/
|
|
530
|
+
installActivityBeacon?(targetId: string): Promise<boolean>;
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Read the beacon's timestamp for a target, or null when the target has no
|
|
534
|
+
* beacon. WITHOUT THE LEASE GATE, which is required rather than convenient:
|
|
535
|
+
* claim_page{target} must read this BEFORE claiming, and under
|
|
536
|
+
* CDP_REQUIRE_LEASE a gated read would auto-acquire the lease and then collide
|
|
537
|
+
* with the explicit claim behind it (the same reason pickPage is gate-free;
|
|
538
|
+
* see leases-tools.ts's header).
|
|
539
|
+
*
|
|
540
|
+
* This is not a general gate bypass and must never become one: it returns one
|
|
541
|
+
* number and takes no expression, so it cannot be made to read or change
|
|
542
|
+
* anything else about the page.
|
|
543
|
+
*/
|
|
544
|
+
readActivityBeacon?(targetId: string): Promise<number | null>;
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Bounded liveness probe behind list_pages{probe:true}: run ONE Runtime.evaluate
|
|
548
|
+
* (or backend equivalent) capped at `timeoutMs`, and report whether it
|
|
549
|
+
* answered in time plus whatever the activity beacon said in that same round
|
|
550
|
+
* trip. See src/activity.ts's RENDERER_PROBE_EXPRESSION for exactly what is
|
|
551
|
+
* evaluated and why.
|
|
552
|
+
*
|
|
553
|
+
* MUST NEVER REJECT. A timeout, a thrown page-side exception, a dead
|
|
554
|
+
* connection, or a target that no longer exists are all
|
|
555
|
+
* `{responsive:false, beaconTs:null}` — indistinguishable from each other and
|
|
556
|
+
* from a genuinely wedged renderer, which is the honest answer: none of them
|
|
557
|
+
* let the caller drive that tab any faster than this call already told it.
|
|
558
|
+
*
|
|
559
|
+
* OPTIONAL and NOT a `Capability`, for the same reason installActivityBeacon
|
|
560
|
+
* and readActivityBeacon are not: no whole tool is gated, only an additive
|
|
561
|
+
* field on list_pages, so the honest expression of a backend that cannot do
|
|
562
|
+
* this is an absent method producing an absent field.
|
|
563
|
+
*/
|
|
564
|
+
probeRenderer?(targetId: string, timeoutMs?: number): Promise<{ responsive: boolean; beaconTs: number | null }>;
|
|
565
|
+
|
|
417
566
|
/** Tear down the transport: process exit (CLI) or shutdown (MCP server).
|
|
418
567
|
* Idempotent. MUST NOT be called by a tool. */
|
|
419
568
|
dispose(): Promise<void>;
|
|
@@ -446,7 +595,16 @@ export interface PageDriver {
|
|
|
446
595
|
/* input */
|
|
447
596
|
click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }>;
|
|
448
597
|
hover(loc: ElementLocator): Promise<{ x: number; y: number }>;
|
|
449
|
-
|
|
598
|
+
/** Press at `from`, move to `to`, release. `to` is an element, an absolute viewport point, or an
|
|
599
|
+
* offset from the resolved source point (DragDestination). opts.mode picks synthetic-mouse vs.
|
|
600
|
+
* real HTML5 drag events; opts.steps sets how many interpolated moves are dispatched. */
|
|
601
|
+
drag(from: ElementLocator, to: DragDestination, opts?: DragOptions): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }>;
|
|
602
|
+
/** Dispatch a wheel/scroll event at an anchor point. `anchor` is an ElementLocator (scrolled
|
|
603
|
+
* into view first, exactly like click/hover), an absolute viewport point, or `undefined` for
|
|
604
|
+
* the viewport center. Chrome: Input.dispatchMouseEvent{type:"mouseWheel"}. Firefox: BiDi's
|
|
605
|
+
* "wheel" input source via input.performActions. Universal: both drivers must implement it
|
|
606
|
+
* (absent from driver.ts's REQUIRED_CAPABILITIES, unlike dispatch_mouse). */
|
|
607
|
+
scroll(anchor: ElementLocator | { x: number; y: number } | undefined, opts: ScrollOptions): Promise<{ x: number; y: number }>;
|
|
450
608
|
/** Overwrite an element's value. One atomic commit under
|
|
451
609
|
* "input.insertTextAtomic", else synthesized keystrokes, so per-key handlers
|
|
452
610
|
* on the page WILL fire. Callers must not depend on either behavior. */
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Two exports:
|
|
5
5
|
* 1. `TOOLS` : the registry mapping every chrome-devtools-mcp tool name
|
|
6
6
|
* (snake_case string, all 29 of them) plus the toolkit's own superset
|
|
7
|
-
* tools,
|
|
7
|
+
* tools, 45 entries in all, to its raw-CDP implementation function. The
|
|
8
8
|
* CLI and any embedder dispatch through this single table.
|
|
9
9
|
* 2. The client primitives, re-exported so consumers can build their own
|
|
10
10
|
* flows on the same connection/timeout machinery the tools use.
|
|
@@ -41,6 +41,11 @@ import { takeHeapsnapshot } from "./tools/heap.ts";
|
|
|
41
41
|
import { lighthouseAudit } from "./tools/lighthouse.ts";
|
|
42
42
|
// --- network mocking (Fetch domain) : toolkit addition beyond the 29 parity tools ---
|
|
43
43
|
import { mockRequest, listMocks, clearMocks } from "./tools/network_mock.ts";
|
|
44
|
+
// --- raw mouse dispatch (Input.dispatchMouseEvent, one event per call) : chrome-only, capability "input.raw" ---
|
|
45
|
+
import { dispatchMouse } from "./tools/dispatch-mouse.ts";
|
|
46
|
+
// --- downloads + permissions (browser-endpoint state on a standing connection) : chrome-only, 1.8.0 Track P3 ---
|
|
47
|
+
import { waitForDownload } from "./tools/downloads.ts";
|
|
48
|
+
import { grantPermissions } from "./tools/permissions.ts";
|
|
44
49
|
|
|
45
50
|
/** A toolkit tool: a single typed-args function returning JSON-serializable data. */
|
|
46
51
|
export type ToolFn = (args: never) => Promise<unknown>;
|
|
@@ -67,8 +72,16 @@ function onCdp<A>(fn: (driver: ReturnType<typeof createCdpDriver>, args: A) => P
|
|
|
67
72
|
* opt-in tab ownership so many agents can share one browser), plus a 2-tool
|
|
68
73
|
* screen-recording pair (start_screen_recording/stop_screen_recording : a tab
|
|
69
74
|
* captured to an H.265 MP4 with per-frame durations, so a variable-rate
|
|
70
|
-
* repaint stream plays back at wallclock speed)
|
|
71
|
-
*
|
|
75
|
+
* repaint stream plays back at wallclock speed), plus a 2-tool input-parity
|
|
76
|
+
* addition (1.8.0 Track P1: `scroll`, a wheel-event dispatch at an anchor
|
|
77
|
+
* point sharing click/hover's element-locator and lease-gate contract; and
|
|
78
|
+
* `dispatch_mouse`, the raw move/down/up mouse-event primitive composable
|
|
79
|
+
* into anything a physical mouse can do, chrome-only per capability "input.raw"), plus a 2-tool
|
|
80
|
+
* browser-state addition (1.8.0 Track P3: `wait_for_download`, which captures a file download to a
|
|
81
|
+
* named path, and `grant_permissions`, which answers permission prompts for an origin; both
|
|
82
|
+
* chrome-only and both driven from a standing browser-endpoint connection, see
|
|
83
|
+
* tools/browser-session.ts).
|
|
84
|
+
* 29 + 1 + 3 + 3 + 3 + 2 + 2 + 2 = 45 entries total. Listed explicitly so the mapping is auditable at a glance and
|
|
72
85
|
* the CLI can `--list` it.
|
|
73
86
|
*
|
|
74
87
|
* 20 of the 29 MCP-parity tools (pages/navigation/evaluate/snapshot/interaction/
|
|
@@ -93,10 +106,11 @@ export const TOOLS = {
|
|
|
93
106
|
delete_cookies: onCdp(SHARED_TOOLS.delete_cookies),
|
|
94
107
|
// snapshot (1)
|
|
95
108
|
take_snapshot: onCdp(SHARED_TOOLS.take_snapshot),
|
|
96
|
-
// interaction (8
|
|
109
|
+
// interaction (9) : scroll is 1.8.0 Track P1, a toolkit addition beyond the 29 parity tools
|
|
97
110
|
click: onCdp(SHARED_TOOLS.click),
|
|
98
111
|
hover: onCdp(SHARED_TOOLS.hover),
|
|
99
112
|
drag: onCdp(SHARED_TOOLS.drag),
|
|
113
|
+
scroll: onCdp(SHARED_TOOLS.scroll),
|
|
100
114
|
fill: onCdp(SHARED_TOOLS.fill),
|
|
101
115
|
fill_form: onCdp(SHARED_TOOLS.fill_form),
|
|
102
116
|
type_text: onCdp(SHARED_TOOLS.type_text),
|
|
@@ -129,6 +143,14 @@ export const TOOLS = {
|
|
|
129
143
|
mock_request: mockRequest,
|
|
130
144
|
list_mocks: listMocks,
|
|
131
145
|
clear_mocks: clearMocks,
|
|
146
|
+
// raw mouse dispatch (1) : 1.8.0 Track P1 toolkit addition, chrome-only (capability "input.raw")
|
|
147
|
+
dispatch_mouse: dispatchMouse,
|
|
148
|
+
// downloads + permissions (2) : 1.8.0 Track P3 toolkit additions, chrome-only (capabilities
|
|
149
|
+
// "browser.downloads" / "browser.permissions"). Both drive browser-endpoint state that Chrome
|
|
150
|
+
// reverts when the issuing client disconnects, so both run on tools/browser-session.ts's standing
|
|
151
|
+
// connection rather than a per-call socket : see that module's header for the measurements.
|
|
152
|
+
wait_for_download: waitForDownload,
|
|
153
|
+
grant_permissions: grantPermissions,
|
|
132
154
|
// tab leases (3) : opt-in ownership so many agents can share one browser
|
|
133
155
|
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
|
134
156
|
release_page: onCdp(LEASE_TOOLS.release_page),
|
package/src/leases-tools.ts
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
* ((driver, args) => Promise<result>) so ONE implementation serves Chrome and
|
|
4
4
|
* Firefox, exactly as the 23 unified tools in that file do.
|
|
5
5
|
*/
|
|
6
|
+
import { beaconSupported, contentionWarning, installBeacon, readHumanActiveMs } from "./activity.ts";
|
|
6
7
|
import type { BrowserDriver } from "./driver.ts";
|
|
7
|
-
import { newTrackedPage } from "./origins.ts";
|
|
8
|
+
import { newTrackedPage, readOrigin } from "./origins.ts";
|
|
9
|
+
import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
|
|
10
|
+
import { pickPage, resolvePage } from "./shared-tools.ts";
|
|
11
|
+
import type { TargetSelector } from "./types.ts";
|
|
8
12
|
import {
|
|
9
13
|
claimLease,
|
|
10
14
|
defaultLabel,
|
|
11
15
|
leaseTtlMs,
|
|
12
16
|
listLeases,
|
|
13
17
|
releaseLease,
|
|
18
|
+
releaseLeaseFor,
|
|
19
|
+
requireLease,
|
|
20
|
+
tokenParts,
|
|
14
21
|
type LeaseBackend,
|
|
15
22
|
type LeaseSummary,
|
|
16
23
|
type LeaseToken,
|
|
@@ -31,41 +38,142 @@ export interface ClaimPageResult {
|
|
|
31
38
|
label: string;
|
|
32
39
|
ttlMs: number;
|
|
33
40
|
expiresAt: number;
|
|
41
|
+
/** true when this call CREATED the tab, false when it claimed one that was
|
|
42
|
+
* already open. The same distinction the creation ledger records, surfaced
|
|
43
|
+
* in the answer so a caller knows without a second lookup whether
|
|
44
|
+
* release_page will close the tab. */
|
|
45
|
+
opened: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Milliseconds since the last input this server cannot account for — i.e.
|
|
48
|
+
* since a HUMAN last touched this tab. See src/activity.ts for how that
|
|
49
|
+
* attribution is made and what it cannot prove.
|
|
50
|
+
*
|
|
51
|
+
* `null` means NO DATA, and never "no human": the tab carries no beacon (the
|
|
52
|
+
* usual case the first time anyone claims a person's tab), or every timestamp
|
|
53
|
+
* on it is explained by this server's own dispatches. ABSENT ENTIRELY on a
|
|
54
|
+
* backend with no beacon support, so a caller can tell "this browser cannot
|
|
55
|
+
* answer" from "this browser answered, and the answer is no data".
|
|
56
|
+
*/
|
|
57
|
+
humanActiveMs?: number | null;
|
|
58
|
+
/**
|
|
59
|
+
* Present only on a TAKEOVER of a tab a human used within the last 30s. A
|
|
60
|
+
* warning, never a refusal — the claim in this same result already succeeded.
|
|
61
|
+
* See contentionWarning in src/activity.ts for why refusing would be wrong.
|
|
62
|
+
*/
|
|
63
|
+
contention?: string;
|
|
34
64
|
}
|
|
35
65
|
|
|
36
66
|
/**
|
|
37
|
-
* Claim a tab
|
|
38
|
-
*
|
|
39
|
-
*
|
|
67
|
+
* Claim a tab, in one of two modes.
|
|
68
|
+
*
|
|
69
|
+
* OPEN: with neither `target` nor `targetId`, opens a fresh tab (optionally at
|
|
70
|
+
* `url`) and claims that, so "give me my own tab" is one call.
|
|
71
|
+
*
|
|
72
|
+
* TAKE OVER: with `target`, claims a tab that is ALREADY OPEN — the first-class
|
|
73
|
+
* path for "work in the tab I have open", which previously had none, because
|
|
74
|
+
* `targetId` demands an exact id a human never has to hand and no id at all
|
|
75
|
+
* meant a brand new tab. `target` takes the toolkit's whole selector grammar
|
|
76
|
+
* (active | index:N | url:<substr> | title:<substr> | <targetId>).
|
|
77
|
+
*
|
|
78
|
+
* `targetId` is unchanged and still accepted; `target` is the same thing with a
|
|
79
|
+
* grammar, so exactly one of the two may be given.
|
|
80
|
+
*
|
|
81
|
+
* WHY RESOLUTION IS GATE-FREE HERE. `target` resolves through pickPage, NOT
|
|
82
|
+
* resolvePage. resolvePage runs assertLeaseOk, which under CDP_REQUIRE_LEASE
|
|
83
|
+
* auto-acquires a lease on an unleased tab: routing a claim through it would
|
|
84
|
+
* claim the tab twice and collide with itself. The protection is not lost, only
|
|
85
|
+
* moved — claimLease below still refuses a tab a live process holds.
|
|
86
|
+
*
|
|
87
|
+
* TAKEOVER IS OF UNLEASED TABS ONLY. There is deliberately no force/steal
|
|
88
|
+
* option: a tab another live agent holds stays refused with the same conflict
|
|
89
|
+
* error as before. The tab this feature exists to take over is a human's, and a
|
|
90
|
+
* human's tab carries no lease.
|
|
91
|
+
*
|
|
92
|
+
* RESOLUTION NEVER CREATES A TAB. With `target` given, the open branch is
|
|
93
|
+
* unreachable: an unmatched selector is an error, never a silently substituted
|
|
94
|
+
* new tab, because "the tab I asked for is not there" and "here is an empty one
|
|
95
|
+
* instead" are answers a caller must be able to tell apart.
|
|
96
|
+
*
|
|
97
|
+
* THE ACTIVITY BEACON IS READ BEFORE THE CLAIM AND INSTALLED AFTER IT, and the
|
|
98
|
+
* order is not arbitrary in either half.
|
|
99
|
+
*
|
|
100
|
+
* Read first, because the read has to be GATE-FREE for exactly the reason the
|
|
101
|
+
* resolution above is: under CDP_REQUIRE_LEASE, anything that touches the tab
|
|
102
|
+
* through the lease gate auto-acquires a lease on it, and a claim that ran after
|
|
103
|
+
* that would collide with the lease taken on its own behalf. The driver members
|
|
104
|
+
* behind readHumanActiveMs bypass the gate and can do nothing but return one
|
|
105
|
+
* number (see driver.ts).
|
|
106
|
+
*
|
|
107
|
+
* Install after, because installing is a WRITE into the page, and this is the
|
|
108
|
+
* first moment we are entitled to make one: the claim has succeeded, so the tab
|
|
109
|
+
* is ours. It is also the only ordering that keeps the read honest, since
|
|
110
|
+
* installing first cannot invent a timestamp but re-arming a beacon on a tab
|
|
111
|
+
* whose claim then FAILED would leave our script running in somebody else's tab
|
|
112
|
+
* for nothing.
|
|
113
|
+
*
|
|
114
|
+
* A CLAIM IS NEVER REFUSED ON HUMAN ACTIVITY. Taking over a person's tab is the
|
|
115
|
+
* feature; a caller that asked for this tab gets this tab. Contention is
|
|
116
|
+
* reported in the result and the caller decides. See contentionWarning.
|
|
40
117
|
*/
|
|
41
118
|
export async function claimPage(
|
|
42
119
|
driver: BrowserDriver,
|
|
43
|
-
args: { targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
|
|
120
|
+
args: { target?: TargetSelector; targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
|
|
44
121
|
): Promise<ClaimPageResult> {
|
|
122
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
123
|
+
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
124
|
+
if (hasTarget && hasTargetId) {
|
|
125
|
+
throw new LeaseToolError(
|
|
126
|
+
"claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
45
129
|
const backend = backendOf(driver);
|
|
46
130
|
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
47
131
|
const pages = await driver.listPages();
|
|
48
|
-
let targetId
|
|
49
|
-
let url
|
|
50
|
-
|
|
132
|
+
let targetId: string;
|
|
133
|
+
let url: string;
|
|
134
|
+
let opened: boolean;
|
|
135
|
+
if (hasTarget) {
|
|
136
|
+
// Gate-free on purpose: see the header. No tab is created on this path,
|
|
137
|
+
// including when nothing matches.
|
|
138
|
+
const hit = await pickPage(driver, pages, args.target).catch((err: unknown) => {
|
|
139
|
+
throw new LeaseToolError(
|
|
140
|
+
`claim_page: no open tab matches target '${args.target}' (${(err as Error).message}). No tab was opened: claim_page never creates one when 'target' is given.`,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
targetId = hit.id;
|
|
144
|
+
url = hit.url;
|
|
145
|
+
opened = false;
|
|
146
|
+
} else if (hasTargetId) {
|
|
147
|
+
const existing = pages.find((p) => p.id === args.targetId);
|
|
148
|
+
if (!existing) throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
|
|
149
|
+
targetId = existing.id;
|
|
150
|
+
url = existing.url;
|
|
151
|
+
opened = false;
|
|
152
|
+
} else {
|
|
51
153
|
// Creation ledger, not the lease: this record survives release_page and
|
|
52
154
|
// expiry, so the tab stays attributable to `label` after the lease is gone.
|
|
53
|
-
// Claiming an EXISTING tab (the
|
|
54
|
-
// toolkit did not create it and provenance is not ownership.
|
|
155
|
+
// Claiming an EXISTING tab (the branches above) records nothing, because
|
|
156
|
+
// this toolkit did not create it and provenance is not ownership.
|
|
55
157
|
const page = await newTrackedPage(driver, backend, { url: args.url, label });
|
|
56
158
|
targetId = page.id;
|
|
57
159
|
url = page.url;
|
|
58
|
-
|
|
59
|
-
const existing = pages.find((p) => p.id === targetId);
|
|
60
|
-
if (!existing) throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
|
|
61
|
-
url = existing.url;
|
|
160
|
+
opened = true;
|
|
62
161
|
}
|
|
162
|
+
// Gate-free, before the claim, and only for a takeover: a tab this call is
|
|
163
|
+
// about to CREATE cannot have a human in it, so asking would be theatre.
|
|
164
|
+
const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
|
|
63
165
|
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
64
166
|
const { record, token } = await claimLease(backend, targetId, {
|
|
65
167
|
label,
|
|
66
168
|
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
67
169
|
liveIds,
|
|
68
170
|
});
|
|
171
|
+
// Best effort by construction (installBeacon never throws): a claim that
|
|
172
|
+
// succeeded must not be reported as a failure because a diagnostic script
|
|
173
|
+
// could not be injected. The cost is a tab that reports humanActiveMs null
|
|
174
|
+
// forever, which reads the same as a tab nobody has touched.
|
|
175
|
+
await installBeacon(driver, targetId);
|
|
176
|
+
const contention = contentionWarning(humanMs, { takeover: !opened });
|
|
69
177
|
return {
|
|
70
178
|
lease: token,
|
|
71
179
|
targetId,
|
|
@@ -73,31 +181,165 @@ export async function claimPage(
|
|
|
73
181
|
label: record.label,
|
|
74
182
|
ttlMs: record.ttlMs,
|
|
75
183
|
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
184
|
+
opened,
|
|
185
|
+
// Absent, not null, when the backend has no beacon at all: "cannot answer"
|
|
186
|
+
// and "answered, no data" are different facts (ADR-001's absent-over-
|
|
187
|
+
// throwing rule applied to a field rather than to a whole tool).
|
|
188
|
+
...(beaconSupported(driver) ? { humanActiveMs: humanMs } : {}),
|
|
189
|
+
...(contention !== undefined ? { contention } : {}),
|
|
76
190
|
};
|
|
77
191
|
}
|
|
78
192
|
|
|
79
|
-
/**
|
|
80
|
-
*
|
|
193
|
+
/**
|
|
194
|
+
* Give a lease back, and close the tab if this toolkit opened it.
|
|
195
|
+
*
|
|
196
|
+
* TWO WAYS IN, because an auto-acquired lease never handed the caller a token:
|
|
197
|
+
* `lease` is the 1.4.0 path, `target` resolves through shared-tools' resolvePage
|
|
198
|
+
* and is authorized by the same gate as any other call. Exactly one is required;
|
|
199
|
+
* accepting both would leave it ambiguous which one authorizes the close.
|
|
200
|
+
*
|
|
201
|
+
* WHY PROVENANCE DECIDES THE CLOSE. A lease says who is driving a tab, never who
|
|
202
|
+
* opened it. Closing on release is right for a tab this toolkit opened for an
|
|
203
|
+
* agent and wrong for a tab the human already had open and an agent merely
|
|
204
|
+
* claimed. origins.ts records exactly that distinction and its records outlive
|
|
205
|
+
* the lease, so the answer is still there at release time.
|
|
206
|
+
*
|
|
207
|
+
* A RELEASE THAT DID NOT HAPPEN NEVER CLOSES. An already-released or reclaimed
|
|
208
|
+
* lease reports released:false, and the tab may well belong to another agent by
|
|
209
|
+
* then; closing it would destroy a tab someone else is driving. This is the one
|
|
210
|
+
* rule in this function that is a safety property rather than a convenience.
|
|
211
|
+
*/
|
|
81
212
|
export async function releasePage(
|
|
82
|
-
|
|
83
|
-
args: { lease?: LeaseToken },
|
|
84
|
-
): Promise<{ released: boolean }> {
|
|
85
|
-
|
|
86
|
-
|
|
213
|
+
driver: BrowserDriver,
|
|
214
|
+
args: { lease?: LeaseToken; target?: TargetSelector; close?: boolean } = {},
|
|
215
|
+
): Promise<{ released: boolean; closed: boolean; targetId?: string }> {
|
|
216
|
+
const hasLease = typeof args.lease === "string" && args.lease.length > 0;
|
|
217
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
218
|
+
if (hasLease === hasTarget) {
|
|
219
|
+
throw new LeaseToolError(
|
|
220
|
+
"release_page takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds)",
|
|
221
|
+
);
|
|
87
222
|
}
|
|
88
|
-
|
|
223
|
+
|
|
224
|
+
const backend = backendOf(driver);
|
|
225
|
+
let targetId: string;
|
|
226
|
+
let released: boolean;
|
|
227
|
+
|
|
228
|
+
if (hasLease) {
|
|
229
|
+
const parts = tokenParts(args.lease as LeaseToken);
|
|
230
|
+
// A malformed token names no tab, so there is nothing to release and
|
|
231
|
+
// nothing to close. Idempotent rather than throwing, as in 1.4.0.
|
|
232
|
+
if (!parts) return { released: false, closed: false };
|
|
233
|
+
targetId = parts.targetId;
|
|
234
|
+
released = (await releaseLease(args.lease as LeaseToken)).released;
|
|
235
|
+
} else {
|
|
236
|
+
// resolvePage runs assertLeaseOk, which IS the authorization: a tab held by
|
|
237
|
+
// another process throws here, and under strict mode an unleased tab is
|
|
238
|
+
// acquired first, so the caller holds it by the time we release it.
|
|
239
|
+
const page = await resolvePage(driver, args.target);
|
|
240
|
+
targetId = page.id;
|
|
241
|
+
released = (await releaseLeaseFor(backend, targetId)).released;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!released) return { released: false, closed: false, ...(hasTarget ? { targetId } : {}) };
|
|
245
|
+
|
|
246
|
+
const shouldClose =
|
|
247
|
+
args.close === false ? false
|
|
248
|
+
: args.close === true ? true
|
|
249
|
+
: (await readOrigin(backend, targetId)) !== undefined;
|
|
250
|
+
if (!shouldClose) return { released: true, closed: false, targetId };
|
|
251
|
+
|
|
252
|
+
// The release already succeeded and is not undone by a failed close: the
|
|
253
|
+
// caller has genuinely given the lease back either way, and throwing here
|
|
254
|
+
// would report a release that did happen as an error.
|
|
255
|
+
const res = await driver.closePage(targetId).catch(() => ({ success: false }));
|
|
256
|
+
return { released: true, closed: res.success === true, targetId };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* One list_leases row: everything the lease store knows, plus what the browser
|
|
261
|
+
* can add.
|
|
262
|
+
*
|
|
263
|
+
* `humanActiveMs` lives HERE and not on LeaseSummary because leases.ts is
|
|
264
|
+
* deliberately free of any CDP or BiDi import ("it takes browser-derived input
|
|
265
|
+
* as data", see its staleReason) and answering this needs a live driver. Keeping
|
|
266
|
+
* the annotation in the tool layer is what preserves that, and it is the same
|
|
267
|
+
* split shared-tools.ts uses for ListedPage's provenance fields.
|
|
268
|
+
*
|
|
269
|
+
* `idleMs` and `expiresAt` need no driver at all — they are arithmetic on a
|
|
270
|
+
* record leases.ts already has in hand — but they live here for the same
|
|
271
|
+
* reason: this is the DISPLAY layer, and the rule "compute presentation
|
|
272
|
+
* fields where they are shown, not in the store that owns the data" should
|
|
273
|
+
* not have two different homes depending on whether a given field happens to
|
|
274
|
+
* need a browser.
|
|
275
|
+
*/
|
|
276
|
+
export interface LeaseRow extends LeaseSummary {
|
|
277
|
+
/** Ms since a human last touched this tab (src/activity.ts). ABSENT — never
|
|
278
|
+
* null — when the tab has no beacon, when every timestamp on it was this
|
|
279
|
+
* server's own, when the row belongs to the other backend, or when the read
|
|
280
|
+
* failed. A diagnosis tool must not fail because one page would not answer. */
|
|
281
|
+
humanActiveMs?: number;
|
|
282
|
+
/** now - lastUsedAt, computed fresh on every call and never stored: storing
|
|
283
|
+
* it would mean every row goes stale the instant it is written. ABSENT on
|
|
284
|
+
* an `unreadable` row, whose lastUsedAt is a zeroed placeholder (see
|
|
285
|
+
* LeaseSummary) that would otherwise print as "idle since the epoch". */
|
|
286
|
+
idleMs?: number;
|
|
287
|
+
/** lastUsedAt + ttlMs — the same value claim_page already returns under this
|
|
288
|
+
* name. Same omission rule as idleMs. */
|
|
289
|
+
expiresAt?: number;
|
|
89
290
|
}
|
|
90
291
|
|
|
91
|
-
/**
|
|
292
|
+
/**
|
|
293
|
+
* Enumerate active leases for diagnosis. Requires no token by design.
|
|
294
|
+
*
|
|
295
|
+
* The beacon annotation is per-row, best effort, and NEVER fatal: an evaluate
|
|
296
|
+
* that throws, a tab that closed mid-listing, or a page with no beacon all leave
|
|
297
|
+
* the field off that row and change nothing else. list_leases is what an
|
|
298
|
+
* operator runs when tabs already look wrong, so it failing on a wrong-looking
|
|
299
|
+
* tab would be exactly backwards.
|
|
300
|
+
*
|
|
301
|
+
* Only rows of THIS driver's backend are annotated, mirroring the liveBackend
|
|
302
|
+
* scoping just below: a Chrome driver cannot read a Firefox context, and asking
|
|
303
|
+
* it to would either error or, worse, resolve some unrelated id.
|
|
304
|
+
*/
|
|
92
305
|
export async function listLeasesTool(
|
|
93
306
|
driver: BrowserDriver,
|
|
94
307
|
_args: Record<string, never> = {} as Record<string, never>,
|
|
95
|
-
): Promise<{ leases:
|
|
96
|
-
const
|
|
308
|
+
): Promise<{ leases: LeaseRow[]; count: number; reaped?: ReapedTab[] }> {
|
|
309
|
+
const backend = backendOf(driver);
|
|
310
|
+
// Same reap as list_pages, and for the same reason: this is the other tool an
|
|
311
|
+
// operator runs when tabs look wrong, so it must not report a lease it is
|
|
312
|
+
// about to delete. Strict mode only.
|
|
313
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
314
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
315
|
+
const livePages = await driver.listPages();
|
|
316
|
+
const liveIds = livePages.map((p) => p.id);
|
|
97
317
|
// liveBackend scopes the target-gone test to the browser these ids came from,
|
|
98
318
|
// so leases held on the OTHER backend are not mislabeled as reclaimable.
|
|
99
|
-
const
|
|
100
|
-
|
|
319
|
+
const all = await listLeases({ liveIds, liveBackend: backend });
|
|
320
|
+
const kept = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
|
|
321
|
+
const liveSet = new Set(liveIds);
|
|
322
|
+
const now = Date.now();
|
|
323
|
+
const leases: LeaseRow[] = [];
|
|
324
|
+
for (const row of kept) {
|
|
325
|
+
// idleMs/expiresAt are pure arithmetic on the record itself, so they apply
|
|
326
|
+
// to BOTH backends and to leases on tabs that are not currently open — the
|
|
327
|
+
// browser has nothing to do with this computation. An unreadable row is
|
|
328
|
+
// the one exception: its lastUsedAt/ttlMs are zeroed placeholders (see
|
|
329
|
+
// LeaseSummary), and computing off zeros would print a meaningless number
|
|
330
|
+
// rather than the honest "we cannot say" an unreadable row already means.
|
|
331
|
+
const computed: LeaseRow = row.unreadable !== undefined ? row : { ...row, idleMs: now - row.lastUsedAt, expiresAt: row.lastUsedAt + row.ttlMs };
|
|
332
|
+
// A row this driver cannot speak to at all: other backend, unreadable
|
|
333
|
+
// record (its targetId came from a FILENAME, not from the browser), or a
|
|
334
|
+
// tab that is simply not open any more.
|
|
335
|
+
if (row.backend !== backend || row.unreadable !== undefined || !liveSet.has(row.targetId)) {
|
|
336
|
+
leases.push(computed);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const humanMs = await readHumanActiveMs(driver, backend, row.targetId, now).catch(() => null);
|
|
340
|
+
leases.push(humanMs === null ? computed : { ...computed, humanActiveMs: humanMs });
|
|
341
|
+
}
|
|
342
|
+
return { leases, count: leases.length, ...(reaped.length ? { reaped } : {}) };
|
|
101
343
|
}
|
|
102
344
|
|
|
103
345
|
export const LEASE_TOOLS = {
|