cdp-toolkit 1.1.0 → 1.4.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/mcp.ts CHANGED
@@ -21,8 +21,9 @@ import { MANIFEST } from "./manifest.ts";
21
21
  import { resolveBrowserKind, stripBrowserFlag, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
22
22
  import { toolAvailability } from "./capabilities.ts";
23
23
  import { FIREFOX_TOOLS } from "./firefox-tools.ts";
24
+ import { leaseFromArgs, withLeaseScope } from "./leases.ts";
24
25
 
25
- const VERSION = "1.1.0";
26
+ const VERSION = "1.4.0";
26
27
 
27
28
  // --browser is read once at startup (MCP has no per-call notion of backend): explicit flag,
28
29
  // else CDP_BROWSER env var, else "chrome" (zero behavior change for existing users/configs).
@@ -65,30 +66,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
65
66
  };
66
67
  }
67
68
 
68
- if (BROWSER === "chrome") {
69
- const fn = dispatch[name];
70
- if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
69
+ // ONE lease scope per dispatch, wrapping BOTH backend branches. This is the
70
+ // reason no tool takes a lease parameter: the token rides the async context
71
+ // down to whichever resolution path the tool eventually reaches, so a tool
72
+ // added tomorrow is covered with no action from whoever writes it. Reading
73
+ // 'lease' off args here is the only place the MCP layer knows the key exists.
74
+ return withLeaseScope(leaseFromArgs(args), async () => {
75
+ if (BROWSER === "chrome") {
76
+ const fn = dispatch[name];
77
+ if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
78
+ try {
79
+ const result = await fn(args);
80
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
81
+ } catch (err) {
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
84
+ }
85
+ }
86
+
87
+ // Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
88
+ // ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
89
+ const neutralFn = neutralDispatch[name];
90
+ if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
71
91
  try {
72
- const result = await fn(args);
92
+ const session = await getOrCreateFirefoxSession();
93
+ const result = await neutralFn(session.driver, args);
73
94
  return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
74
95
  } catch (err) {
75
96
  const message = err instanceof Error ? err.message : String(err);
76
97
  return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
77
98
  }
78
- }
79
-
80
- // Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
81
- // ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
82
- const neutralFn = neutralDispatch[name];
83
- if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
84
- try {
85
- const session = await getOrCreateFirefoxSession();
86
- const result = await neutralFn(session.driver, args);
87
- return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
88
- } catch (err) {
89
- const message = err instanceof Error ? err.message : String(err);
90
- return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
91
- }
99
+ });
92
100
  });
93
101
 
94
102
  auditCoverage();
package/src/origins.ts ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Tab origins: a creation ledger recording every tab THIS TOOLKIT opened.
3
+ *
4
+ * WHY THIS IS NOT list_leases. A lease answers "who is DRIVING this tab right
5
+ * now". Provenance is a different and longer-lived question: "who OPENED this
6
+ * tab". The two diverge the moment an agent releases, expires, or dies, and
7
+ * that is exactly when a human most needs the answer, because from then on the
8
+ * agent's abandoned tab is indistinguishable from one the human opened. So a
9
+ * record here deliberately OUTLIVES the lease and is never removed by
10
+ * release_page, by expiry, or by reclamation.
11
+ *
12
+ * WHY ONE FILE PER TAB, IN THE LEASE DIRECTORY. Same reasoning as leases.ts:
13
+ * two MCP server processes drive one browser, so module state would protect
14
+ * one process and miss the collision that matters, and a single registry file
15
+ * would need its own lock. Exclusive-create ("wx") per tab gives the atomicity
16
+ * for free, and the filename carries enough to describe a row whose contents
17
+ * cannot be read.
18
+ *
19
+ * WHY THE ONLY DELETE IS A REAP ON READ. A background sweeper would be a
20
+ * second lifetime to reason about and a process that has to be running. The
21
+ * ledger is read rarely (list_pages) and the browser's own target list is
22
+ * already in hand at that moment, so dropping records for targets that no
23
+ * longer exist costs nothing and keeps the directory bounded without anything
24
+ * scheduled.
25
+ *
26
+ * WHAT THIS CANNOT PROVE. The absence of a record is not evidence that a human
27
+ * opened the tab: an agent driving Chrome by some other means, a tab restored
28
+ * from a previous session, or a record lost to a write failure all read the
29
+ * same way. That is why the annotation on list_pages says "unknown" and never
30
+ * "human". Never add a "human" value here.
31
+ */
32
+ import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
33
+ import { join } from "node:path";
34
+ import type { BrowserDriver, PageInfo } from "./driver.ts";
35
+ import { defaultLabel, leaseDir, type LeaseBackend } from "./leases.ts";
36
+
37
+ /** What list_pages can say about where a tab came from. There is deliberately
38
+ * no "human": see the file header. */
39
+ export type PageOrigin = "agent" | "unknown";
40
+
41
+ export interface OriginRecord {
42
+ backend: LeaseBackend;
43
+ targetId: string;
44
+ /** Caller-supplied agent label, or leases.ts's pid-derived default. */
45
+ label: string;
46
+ /** The MCP server process that created the tab. Recorded for diagnosis only:
47
+ * unlike a lease, this record stays meaningful after that pid is gone. */
48
+ pid: number;
49
+ createdAt: number;
50
+ }
51
+
52
+ /** Same character policy as leaseFile, so the two stores agree on what a
53
+ * filename-safe id looks like and a reader can compare them. */
54
+ function safeId(id: string): string {
55
+ return id.replace(/[^A-Za-z0-9._-]/g, "_");
56
+ }
57
+
58
+ /** Sibling of leases.ts's lease-<backend>-<targetId>.json, same directory. */
59
+ export function originFile(backend: LeaseBackend, targetId: string): string {
60
+ return join(leaseDir(), `origin-${backend}-${safeId(targetId)}.json`);
61
+ }
62
+
63
+ const ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
64
+
65
+ /**
66
+ * Record that this toolkit just created `targetId`.
67
+ *
68
+ * ATOMICITY MATCHES leases.ts: exclusive create, never a modify in place. A
69
+ * record is written once and thereafter only deleted, so two processes racing
70
+ * on the same id cannot interleave a half-written file.
71
+ *
72
+ * ON EEXIST THE NEW RECORD WINS, and that is the answer to target-id reuse.
73
+ * A browser is free to hand out an id that a closed tab used to hold. Reaping
74
+ * on read makes that benign in the normal case: the old record is dropped the
75
+ * first time anyone lists pages after the old tab closed, so the id is usually
76
+ * free by the time it is reused. If nobody read the ledger in between, the
77
+ * stale record is still sitting there when this runs, and the unlink-then-
78
+ * recreate below replaces it. The residual case is a reused id whose new tab
79
+ * this toolkit did NOT create: that tab inherits the old record and reports
80
+ * origin "agent" with a createdAt older than the tab itself. It errs toward
81
+ * flagging a tab as agent-created, which is the safe direction for a tool whose
82
+ * job is to help a human find stray agent tabs, and the stale createdAt is the
83
+ * tell. Do not "fix" it by trusting the record blindly in the other direction.
84
+ *
85
+ * FAILURES ARE SWALLOWED DELIBERATELY. This is an annotation, not the work the
86
+ * caller asked for: new_page must not fail because a provenance record could
87
+ * not be persisted. The cost is real and worth stating plainly: a tab created
88
+ * while the artifact directory is unwritable reports origin "unknown" forever,
89
+ * with no signal anywhere. If agent tabs ever start reading as unknown, an
90
+ * ENOSPC or EACCES here is the first place to look.
91
+ */
92
+ export async function recordOrigin(
93
+ backend: LeaseBackend,
94
+ targetId: string,
95
+ opts: { label?: string; now?: number } = {},
96
+ ): Promise<void> {
97
+ const record: OriginRecord = {
98
+ backend,
99
+ targetId,
100
+ label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
101
+ pid: process.pid,
102
+ createdAt: opts.now ?? Date.now(),
103
+ };
104
+ const file = originFile(backend, targetId);
105
+ try {
106
+ await mkdir(leaseDir(), { recursive: true });
107
+ try {
108
+ await writeFile(file, JSON.stringify(record), { flag: "wx" });
109
+ return;
110
+ } catch (err) {
111
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
112
+ }
113
+ // A record already exists for this id: the browser reused it. Replace
114
+ // rather than modify in place, so a reader never sees a partial rewrite.
115
+ await unlink(file).catch(() => undefined);
116
+ await writeFile(file, JSON.stringify(record), { flag: "wx" });
117
+ } catch {
118
+ /* annotation only, never fail the caller's real work: see the doc comment */
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Read one origin record, or undefined if this toolkit did not create the tab.
124
+ *
125
+ * UNLIKE readLease, AN UNREADABLE FILE IS NOT RETHROWN HERE. The asymmetry is
126
+ * deliberate and rests on what each answer authorizes. readLease's answer gates
127
+ * ADMISSION to a tab, so "unreadable" reading as "unleased" would hand a held
128
+ * tab to a stranger, and failing the call is the lesser harm. This answer gates
129
+ * nothing: it decorates a listing. Failing list_pages because one provenance
130
+ * file is unreadable would break a working tool over a cosmetic field. The
131
+ * honesty requirement is met a different way, by listOrigins, which reports an
132
+ * unreadable row rather than omitting it, so "unreadable" never silently reads
133
+ * as "no record". Callers that need that distinction use listOrigins, not this.
134
+ */
135
+ export async function readOrigin(backend: LeaseBackend, targetId: string): Promise<OriginRecord | undefined> {
136
+ let raw: string;
137
+ try {
138
+ raw = await readFile(originFile(backend, targetId), "utf8");
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ try {
143
+ const rec = JSON.parse(raw) as OriginRecord;
144
+ if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number") return rec;
145
+ } catch {
146
+ /* unparseable: see listOrigins, which surfaces it as an unreadable row */
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ export interface OriginSummary {
152
+ backend: LeaseBackend;
153
+ targetId: string;
154
+ label: string;
155
+ pid: number;
156
+ createdAt: number;
157
+ /**
158
+ * Present ONLY on a row this function could not read: the errno of the failed
159
+ * read, or "unparseable" for a file whose contents are not an origin record.
160
+ * Same convention, and the same reason, as LeaseSummary.unreadable: a
161
+ * silently skipped file is the one thing that makes a broken record
162
+ * impossible to diagnose, and it would make a tab that HAS a record read as
163
+ * a clean "no record at all". Every other field on such a row is a
164
+ * placeholder derived from the FILENAME and nothing else.
165
+ */
166
+ unreadable?: string;
167
+ }
168
+
169
+ /**
170
+ * Enumerate the ledger, dropping records for targets the browser no longer has.
171
+ *
172
+ * `liveIds` is the reap set and is optional on purpose. When it is undefined
173
+ * nothing is reaped: a caller that could not enumerate the browser's targets
174
+ * must not be able to delete the ledger by failing. Passing an EMPTY array is
175
+ * a real statement ("the browser has no targets") and does reap.
176
+ *
177
+ * `liveBackend` scopes the reap to the browser those ids came from, exactly as
178
+ * listLeases does: a Chrome target list says nothing about whether a Firefox
179
+ * context is still open, and reaping on it would delete live Firefox records.
180
+ */
181
+ export async function listOrigins(
182
+ opts: { liveIds?: readonly string[]; liveBackend?: LeaseBackend } = {},
183
+ ): Promise<OriginSummary[]> {
184
+ let names: string[] = [];
185
+ try {
186
+ names = await readdir(leaseDir());
187
+ } catch {
188
+ // Unlike listLeases, an unreadable directory is reported as an empty
189
+ // ledger rather than thrown: every consumer of this list degrades to
190
+ // origin "unknown", which is the honest answer when provenance cannot be
191
+ // read, and no caller of this function is making an authorization decision.
192
+ return [];
193
+ }
194
+ const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId(id)));
195
+ const out: OriginSummary[] = [];
196
+ for (const name of names) {
197
+ const parts = ORIGIN_FILE_RE.exec(name);
198
+ if (!parts) continue;
199
+ const backend = parts[1] as LeaseBackend;
200
+ const nameId = parts[2] as string;
201
+ // The reap test runs on the FILENAME id, which is the only id an unreadable
202
+ // row has. safeId() is applied to both sides so the comparison is like for
203
+ // like; an id that safeId rewrote is an identifying label, not a key, so a
204
+ // collision would at worst spare a record from reaping.
205
+ const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
206
+ if (scoped && !live.has(nameId)) {
207
+ await unlink(join(leaseDir(), name)).catch(() => undefined);
208
+ continue;
209
+ }
210
+ const fromName: OriginSummary = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
211
+ let rec: OriginRecord;
212
+ try {
213
+ rec = JSON.parse(await readFile(join(leaseDir(), name), "utf8")) as OriginRecord;
214
+ } catch (err) {
215
+ const code = (err as NodeJS.ErrnoException).code;
216
+ // Vanished between readdir and readFile: reaped by a concurrent reader.
217
+ if (code === "ENOENT") continue;
218
+ out.push({ ...fromName, unreadable: code ?? "unparseable" });
219
+ continue;
220
+ }
221
+ if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
222
+ out.push({ ...fromName, unreadable: "unparseable" });
223
+ continue;
224
+ }
225
+ out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * listOrigins keyed by targetId, for annotating a page listing.
232
+ *
233
+ * NEVER THROWS. The whole annotation is best-effort: a caller that cannot read
234
+ * the ledger reports every page as origin "unknown", which is what a consumer
235
+ * with no ledger at all has always seen. An empty map and a missing ledger are
236
+ * the same thing to the caller by construction.
237
+ */
238
+ export async function originIndex(
239
+ backend: LeaseBackend,
240
+ liveIds?: readonly string[],
241
+ ): Promise<Map<string, OriginSummary>> {
242
+ const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => [] as OriginSummary[]);
243
+ const index = new Map<string, OriginSummary>();
244
+ for (const row of rows) {
245
+ if (row.backend !== backend) continue;
246
+ index.set(row.targetId, row);
247
+ }
248
+ return index;
249
+ }
250
+
251
+ /**
252
+ * Open a tab AND record its provenance. THE ONLY WAY THE TOOLKIT SHOULD CREATE
253
+ * A PAGE. Both creation paths (new_page in shared-tools.ts, claim_page opening
254
+ * a fresh tab in leases-tools.ts) route through here so a third path added
255
+ * later cannot forget the ledger write by omission: `driver.newPage(` should
256
+ * appear in exactly one place outside the drivers themselves, which is here.
257
+ */
258
+ export async function newTrackedPage(
259
+ driver: BrowserDriver,
260
+ backend: LeaseBackend,
261
+ opts: { url?: string; label?: string } = {},
262
+ ): Promise<PageInfo> {
263
+ const page = await driver.newPage(opts.url);
264
+ await recordOrigin(backend, page.id, { label: opts.label });
265
+ return page;
266
+ }