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/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
+ }
@@ -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 20 tools below.
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
- async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Promise<PageInfo> {
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(driver: BrowserDriver, args: { all?: boolean } = {}): Promise<{ pages: ListedPage[]; count: number }> {
179
- const pages = await driver.listPages({ all: args.all });
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
- return { pages: annotated, count: annotated.length };
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
- if (args.claim !== true) return { targetId: p.id, url: p.url };
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
  }