cdp-toolkit 1.4.0 → 1.7.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 +251 -0
- package/CONTRACT.md +10 -3
- package/README.md +78 -30
- package/dist/cli.js +583 -92
- package/dist/index.js +568 -77
- package/dist/mcp.js +712 -140
- package/package.json +3 -2
- package/src/bidi/driver.ts +4 -0
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +3 -0
- package/src/driver.ts +4 -1
- package/src/index.ts +15 -4
- package/src/leases-tools.ts +147 -26
- package/src/leases.ts +84 -4
- package/src/manifest.ts +127 -47
- package/src/mcp.ts +5 -2
- package/src/reap.ts +114 -0
- package/src/shared-tools.ts +41 -8
- package/src/tools/screencast.ts +741 -0
package/src/reap.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
import type { BrowserDriver } from "./driver.ts";
|
|
23
|
+
import { listLeases, releaseLeaseFor, type LeaseBackend, type LeaseSummary } from "./leases.ts";
|
|
24
|
+
import { originIndex, type OriginSummary } from "./origins.ts";
|
|
25
|
+
|
|
26
|
+
export interface ReapedTab {
|
|
27
|
+
targetId: string;
|
|
28
|
+
label: string;
|
|
29
|
+
/** Why it was reaped. Deliberately narrower than LeaseStaleReason: see below. */
|
|
30
|
+
reason: "dead-pid" | "expired";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReapInput {
|
|
34
|
+
backend: LeaseBackend;
|
|
35
|
+
/**
|
|
36
|
+
* Ids from the PAGE-ONLY listing, never from an `all:true` listing. A worker,
|
|
37
|
+
* iframe or background page can carry a lease (pickPage's bare-id branch
|
|
38
|
+
* resolves non-page targets), and "close the stale tab" is meaningless for
|
|
39
|
+
* one. This is a different set from the one shared-tools' reapSet feeds to the
|
|
40
|
+
* origin ledger, which deliberately uses the unfiltered listing so a live but
|
|
41
|
+
* filtered-out target does not lose its provenance record. Two reaps, two
|
|
42
|
+
* questions, two id sets: do not merge them.
|
|
43
|
+
*/
|
|
44
|
+
livePageIds: readonly string[];
|
|
45
|
+
origins: ReadonlyMap<string, OriginSummary>;
|
|
46
|
+
leases: readonly LeaseSummary[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Select the tabs that may be closed. PURE: no I/O, no driver, no clock.
|
|
51
|
+
*
|
|
52
|
+
* All four conditions must hold, and condition 4 is the load-bearing one:
|
|
53
|
+
*
|
|
54
|
+
* 1. the lease row is for this backend and READABLE. An `unreadable` row is
|
|
55
|
+
* never reaped for the same reason listLeases reports it as stale:false -
|
|
56
|
+
* we cannot see who owns it, and a guess in the destructive direction is
|
|
57
|
+
* the one guess that cannot be undone.
|
|
58
|
+
* 2. it is stale for `dead-pid` or `expired`. NOT `target-gone`, which means
|
|
59
|
+
* the tab is already closed and there is nothing to do, and obviously not
|
|
60
|
+
* `false`.
|
|
61
|
+
* 3. the target is actually still open, per livePageIds.
|
|
62
|
+
* 4. the toolkit OPENED it. An agent-created tab with NO lease is deliberately
|
|
63
|
+
* never reaped: that is exactly what new_page produces for a user who never
|
|
64
|
+
* touches leases, and closing those would be a data-loss bug rather than a
|
|
65
|
+
* cleanup. Only a tab that was claimed and then abandoned qualifies.
|
|
66
|
+
*/
|
|
67
|
+
export function staleAgentTabs(input: ReapInput): ReapedTab[] {
|
|
68
|
+
const live = new Set(input.livePageIds);
|
|
69
|
+
const out: ReapedTab[] = [];
|
|
70
|
+
for (const row of input.leases) {
|
|
71
|
+
if (row.backend !== input.backend) continue;
|
|
72
|
+
if (row.unreadable !== undefined) continue;
|
|
73
|
+
if (row.stale !== "dead-pid" && row.stale !== "expired") continue;
|
|
74
|
+
if (!live.has(row.targetId)) continue;
|
|
75
|
+
const origin = input.origins.get(row.targetId);
|
|
76
|
+
if (origin === undefined || origin.unreadable !== undefined) continue;
|
|
77
|
+
out.push({ targetId: row.targetId, label: row.label, reason: row.stale });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Select, close, and drop the leases. Returns ONLY the tabs actually closed.
|
|
84
|
+
*
|
|
85
|
+
* A close that fails is not reported, and that asymmetry is deliberate: the
|
|
86
|
+
* caller filters its listing by this array, so reporting a tab we failed to
|
|
87
|
+
* close would hide a tab that is still open. Failing quietly leaves the tab
|
|
88
|
+
* visible and the lease in place, and the next read tries again.
|
|
89
|
+
*
|
|
90
|
+
* The lease file is unlinked; the ORIGIN record is not. listOrigins already
|
|
91
|
+
* reaps records for targets the browser no longer has, so the record disappears
|
|
92
|
+
* on the next listing. One lifetime rule for origin records, not three.
|
|
93
|
+
*/
|
|
94
|
+
export async function reapStaleAgentTabs(driver: BrowserDriver, backend: LeaseBackend): Promise<ReapedTab[]> {
|
|
95
|
+
// The PAGE-ONLY listing: see ReapInput.livePageIds.
|
|
96
|
+
const livePageIds = (await driver.listPages()).map((p) => p.id);
|
|
97
|
+
const [origins, leases] = await Promise.all([
|
|
98
|
+
// undefined liveIds = reap nothing from the ledger here. Reaping origin
|
|
99
|
+
// records is list_pages' job and it uses a DIFFERENT id set (the unfiltered
|
|
100
|
+
// listing); doing it here too would delete records for targets that are
|
|
101
|
+
// live but filtered out of the page-only view.
|
|
102
|
+
originIndex(backend, undefined),
|
|
103
|
+
listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [] as LeaseSummary[]),
|
|
104
|
+
]);
|
|
105
|
+
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
|
|
106
|
+
const closed: ReapedTab[] = [];
|
|
107
|
+
for (const c of candidates) {
|
|
108
|
+
const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
|
|
109
|
+
if (res.success !== true) continue;
|
|
110
|
+
await releaseLeaseFor(backend, c.targetId);
|
|
111
|
+
closed.push(c);
|
|
112
|
+
}
|
|
113
|
+
return closed;
|
|
114
|
+
}
|
package/src/shared-tools.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* (ADR-001), used by BOTH Chrome (via cdp/driver.ts's createCdpDriver()) and Firefox (via
|
|
4
4
|
* bidi/driver.ts's createFirefoxDriver()). This file replaces the split that used to exist
|
|
5
5
|
* between src/tools/*.ts (raw CDP, Chrome only) and src/neutral.ts (Driver-based, Firefox only)
|
|
6
|
-
* for the
|
|
6
|
+
* for 20 of the 23 tools below. The other three, the list_cookies/set_cookie/delete_cookies
|
|
7
|
+
* cookie group, landed here directly and never had a Chrome-only copy.
|
|
7
8
|
*
|
|
8
9
|
* THE HARD REQUIREMENT this file exists to satisfy: Chrome's return shape, timing, and quirks
|
|
9
10
|
* stay byte-identical to what src/tools/*.ts produced before this migration: that already
|
|
@@ -38,8 +39,9 @@ import type {
|
|
|
38
39
|
BrowserCookie, BrowserDriver, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
|
|
39
40
|
} from "./driver.ts";
|
|
40
41
|
import type { TargetSelector } from "./types.ts";
|
|
41
|
-
import { assertLeaseOk, claimLease, defaultLabel, leaseTtlMs, releaseLeaseFor, type LeaseBackend, type LeaseToken } from "./leases.ts";
|
|
42
|
+
import { assertLeaseOk, claimLease, defaultLabel, leaseTtlMs, releaseLeaseFor, requireLease, type LeaseBackend, type LeaseToken } from "./leases.ts";
|
|
42
43
|
import { newTrackedPage, originIndex, type PageOrigin } from "./origins.ts";
|
|
44
|
+
import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
|
|
43
45
|
|
|
44
46
|
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
45
47
|
const STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
@@ -69,7 +71,9 @@ async function withPage<T>(driver: BrowserDriver, target: TargetSelector | undef
|
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
/** Exported for leases-tools.ts's release_page{target}, which needs exactly this
|
|
75
|
+
* gate: resolving through here is what authorizes the release. */
|
|
76
|
+
export async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Promise<PageInfo> {
|
|
73
77
|
const pages = await driver.listPages();
|
|
74
78
|
const hit = await pickPage(driver, pages, selector);
|
|
75
79
|
// THE third choke point. close_page and select_page resolve here and nowhere
|
|
@@ -92,8 +96,17 @@ async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Pr
|
|
|
92
96
|
* every branch resolves against page-type targets only EXCEPT a bare-id lookup, which (like
|
|
93
97
|
* legacy resolveTarget's `targets.find`) searches the FULL unfiltered listing, so passing an
|
|
94
98
|
* exact non-page targetId (a worker, an iframe) still resolves.
|
|
99
|
+
*
|
|
100
|
+
* THE GATE-FREE HALF of resolvePage, and exported for the one caller that needs
|
|
101
|
+
* resolution WITHOUT authorization: claim_page{target}. Everything else must go
|
|
102
|
+
* through resolvePage. Claiming cannot run assertLeaseOk, because under
|
|
103
|
+
* CDP_REQUIRE_LEASE the gate AUTO-ACQUIRES a lease on an unleased tab, so a
|
|
104
|
+
* claim routed through it would take the tab twice — once implicitly with an
|
|
105
|
+
* auto lease, then again explicitly, and the second claim would collide with
|
|
106
|
+
* the first. Refusing a tab another process holds is still enforced, one layer
|
|
107
|
+
* down, by claimLease's own conflict check.
|
|
95
108
|
*/
|
|
96
|
-
async function pickPage(driver: BrowserDriver, pages: PageInfo[], selector?: TargetSelector): Promise<PageInfo> {
|
|
109
|
+
export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selector?: TargetSelector): Promise<PageInfo> {
|
|
97
110
|
if (selector === undefined || selector === "active") {
|
|
98
111
|
if (!pages.length) throw new SharedToolError("no page targets open");
|
|
99
112
|
return pages[0]!;
|
|
@@ -175,8 +188,15 @@ async function reapSet(driver: BrowserDriver, pages: PageInfo[], all?: boolean):
|
|
|
175
188
|
}
|
|
176
189
|
}
|
|
177
190
|
|
|
178
|
-
export async function listPages(
|
|
179
|
-
|
|
191
|
+
export async function listPages(
|
|
192
|
+
driver: BrowserDriver,
|
|
193
|
+
args: { all?: boolean } = {},
|
|
194
|
+
): Promise<{ pages: ListedPage[]; count: number; reaped?: ReapedTab[] }> {
|
|
195
|
+
// Reap FIRST, so a tab this call is about to close never appears in the
|
|
196
|
+
// listing it returns. Under strict mode only: see requireLease.
|
|
197
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backendOf(driver)) : [];
|
|
198
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
199
|
+
const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
|
|
180
200
|
// Never let provenance break the listing: originIndex does not throw, and a
|
|
181
201
|
// missing or unreadable ledger yields an empty map, so every page falls back
|
|
182
202
|
// to origin "unknown" exactly as a pre-ledger consumer always saw.
|
|
@@ -187,7 +207,10 @@ export async function listPages(driver: BrowserDriver, args: { all?: boolean } =
|
|
|
187
207
|
if (rec.unreadable !== undefined) return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
|
|
188
208
|
return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
|
|
189
209
|
});
|
|
190
|
-
|
|
210
|
+
// `reaped` is present only when it has something to say, so the common-case
|
|
211
|
+
// shape is byte-identical to 1.4.0. Silently closing a tab is not acceptable:
|
|
212
|
+
// if a read closed something, the read says so.
|
|
213
|
+
return { pages: annotated, count: annotated.length, ...(reaped.length ? { reaped } : {}) };
|
|
191
214
|
}
|
|
192
215
|
|
|
193
216
|
export async function newPage(
|
|
@@ -198,12 +221,22 @@ export async function newPage(
|
|
|
198
221
|
// Creates the tab AND writes its creation record. The record outlives any
|
|
199
222
|
// lease taken below, which is the point: a released tab is still an agent's.
|
|
200
223
|
const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
|
|
201
|
-
|
|
224
|
+
// Under strict mode a tab nobody holds is a tab nobody may drive, so creating
|
|
225
|
+
// one without claiming it would hand back a target the very next call has to
|
|
226
|
+
// auto-acquire anyway. Doing it here means the tab is never briefly unowned
|
|
227
|
+
// AND the caller is handed a token it can release with, which matters because
|
|
228
|
+
// an auto-acquired lease never returns one.
|
|
229
|
+
const explicit = args.claim === true;
|
|
230
|
+
if (!explicit && !requireLease()) return { targetId: p.id, url: p.url };
|
|
202
231
|
// Atomic in the sense that matters: the tab is claimed before this call
|
|
203
232
|
// returns, so no other agent can see it unclaimed and take it first.
|
|
204
233
|
const { record, token } = await claimLease(backendOf(driver), p.id, {
|
|
205
234
|
label,
|
|
206
235
|
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
236
|
+
// An explicit claim:true stays explicit under strict mode: the caller asked
|
|
237
|
+
// for the strong tier and must keep getting it, or turning strict on would
|
|
238
|
+
// silently WEAKEN every existing claim:true call site to pid-only.
|
|
239
|
+
auto: !explicit,
|
|
207
240
|
});
|
|
208
241
|
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
209
242
|
}
|