cdp-toolkit 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +403 -0
- package/CONTRACT.md +9 -1
- package/README.md +58 -17
- package/dist/cli.js +1302 -158
- package/dist/index.js +1144 -145
- package/dist/mcp.js +1620 -244
- package/package.json +6 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +259 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +487 -25
- package/src/cdp/workers.ts +177 -0
- package/src/cli.ts +3 -1
- package/src/client.ts +52 -2
- package/src/driver.ts +187 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +127 -6
- package/src/leases.ts +1 -1
- package/src/manifest.ts +279 -46
- package/src/mcp.ts +6 -1
- package/src/origins.ts +41 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +372 -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/types.ts +20 -0
- package/src/workers.ts +159 -0
package/src/leases-tools.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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
8
|
import { newTrackedPage, readOrigin } from "./origins.ts";
|
|
8
9
|
import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
|
|
@@ -42,6 +43,24 @@ export interface ClaimPageResult {
|
|
|
42
43
|
* in the answer so a caller knows without a second lookup whether
|
|
43
44
|
* release_page will close the tab. */
|
|
44
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;
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
/**
|
|
@@ -54,7 +73,7 @@ export interface ClaimPageResult {
|
|
|
54
73
|
* path for "work in the tab I have open", which previously had none, because
|
|
55
74
|
* `targetId` demands an exact id a human never has to hand and no id at all
|
|
56
75
|
* meant a brand new tab. `target` takes the toolkit's whole selector grammar
|
|
57
|
-
* (active | index:N | url:<substr> | title:<substr> | <targetId>).
|
|
76
|
+
* (active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>).
|
|
58
77
|
*
|
|
59
78
|
* `targetId` is unchanged and still accepted; `target` is the same thing with a
|
|
60
79
|
* grammar, so exactly one of the two may be given.
|
|
@@ -74,6 +93,27 @@ export interface ClaimPageResult {
|
|
|
74
93
|
* unreachable: an unmatched selector is an error, never a silently substituted
|
|
75
94
|
* new tab, because "the tab I asked for is not there" and "here is an empty one
|
|
76
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.
|
|
77
117
|
*/
|
|
78
118
|
export async function claimPage(
|
|
79
119
|
driver: BrowserDriver,
|
|
@@ -83,7 +123,7 @@ export async function claimPage(
|
|
|
83
123
|
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
84
124
|
if (hasTarget && hasTargetId) {
|
|
85
125
|
throw new LeaseToolError(
|
|
86
|
-
"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.",
|
|
126
|
+
"claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | label:<name> | <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.",
|
|
87
127
|
);
|
|
88
128
|
}
|
|
89
129
|
const backend = backendOf(driver);
|
|
@@ -119,12 +159,21 @@ export async function claimPage(
|
|
|
119
159
|
url = page.url;
|
|
120
160
|
opened = true;
|
|
121
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);
|
|
122
165
|
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
123
166
|
const { record, token } = await claimLease(backend, targetId, {
|
|
124
167
|
label,
|
|
125
168
|
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
126
169
|
liveIds,
|
|
127
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 });
|
|
128
177
|
return {
|
|
129
178
|
lease: token,
|
|
130
179
|
targetId,
|
|
@@ -133,6 +182,11 @@ export async function claimPage(
|
|
|
133
182
|
ttlMs: record.ttlMs,
|
|
134
183
|
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
135
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 } : {}),
|
|
136
190
|
};
|
|
137
191
|
}
|
|
138
192
|
|
|
@@ -202,22 +256,89 @@ export async function releasePage(
|
|
|
202
256
|
return { released: true, closed: res.success === true, targetId };
|
|
203
257
|
}
|
|
204
258
|
|
|
205
|
-
/**
|
|
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;
|
|
290
|
+
}
|
|
291
|
+
|
|
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
|
+
*/
|
|
206
305
|
export async function listLeasesTool(
|
|
207
306
|
driver: BrowserDriver,
|
|
208
307
|
_args: Record<string, never> = {} as Record<string, never>,
|
|
209
|
-
): Promise<{ leases:
|
|
308
|
+
): Promise<{ leases: LeaseRow[]; count: number; reaped?: ReapedTab[] }> {
|
|
210
309
|
const backend = backendOf(driver);
|
|
211
310
|
// Same reap as list_pages, and for the same reason: this is the other tool an
|
|
212
311
|
// operator runs when tabs look wrong, so it must not report a lease it is
|
|
213
312
|
// about to delete. Strict mode only.
|
|
214
313
|
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
215
314
|
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
216
|
-
const
|
|
315
|
+
const livePages = await driver.listPages();
|
|
316
|
+
const liveIds = livePages.map((p) => p.id);
|
|
217
317
|
// liveBackend scopes the target-gone test to the browser these ids came from,
|
|
218
318
|
// so leases held on the OTHER backend are not mislabeled as reclaimable.
|
|
219
319
|
const all = await listLeases({ liveIds, liveBackend: backend });
|
|
220
|
-
const
|
|
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
|
+
}
|
|
221
342
|
return { leases, count: leases.length, ...(reaped.length ? { reaped } : {}) };
|
|
222
343
|
}
|
|
223
344
|
|
package/src/leases.ts
CHANGED
|
@@ -315,7 +315,7 @@ export async function releaseLeaseFor(backend: LeaseBackend, targetId: string):
|
|
|
315
315
|
* This exists so enforcement can stay at ONE point per resolution path while
|
|
316
316
|
* the token still reaches it. The token arrives as a key on a tool's args
|
|
317
317
|
* object, many frames above resolveTarget, and threading a parameter through
|
|
318
|
-
* all
|
|
318
|
+
* all 45 tools would put the burden on every future contributor to remember
|
|
319
319
|
* the check exists: one missed tool would silently defeat the feature for that
|
|
320
320
|
* tool. mcp.ts and cli.ts open this scope once per dispatch instead, so a tool
|
|
321
321
|
* added tomorrow is covered with no action from whoever writes it.
|