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/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.
|
|
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
|
+
}
|
package/src/shared-tools.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
180
|
-
|
|
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(
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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: {
|
|
609
|
-
|
|
610
|
-
|
|
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),
|
|
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
|
|
747
|
-
* src/firefox-tools.ts (Firefox). Each function is
|
|
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,
|