cdp-toolkit 1.2.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/CHANGELOG.md +127 -0
- package/CONTRACT.md +1 -1
- package/README.md +53 -10
- package/dist/cli.js +392 -41
- package/dist/index.js +316 -39
- package/dist/mcp.js +521 -44
- package/package.json +4 -3
- package/src/bidi/driver.ts +136 -1
- package/src/cdp/driver.ts +145 -1
- package/src/driver.ts +106 -0
- package/src/firefox-tools.ts +1 -1
- package/src/index.ts +9 -2
- package/src/leases-tools.ts +8 -2
- package/src/leases.ts +8 -0
- package/src/manifest.ts +128 -2
- package/src/mcp.ts +1 -1
- package/src/origins.ts +266 -0
- package/src/shared-tools.ts +315 -10
package/src/manifest.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface ToolSpec {
|
|
|
25
25
|
export const MANIFEST: ToolSpec[] = [
|
|
26
26
|
{
|
|
27
27
|
"name": "list_pages",
|
|
28
|
-
"description": "Enumerate browser page targets via the CDP browser endpoint (GET /json/list). By default returns only page-type tabs; set 'all' to also include workers and background pages. Each entry carries the targetId used as a target selector elsewhere.",
|
|
28
|
+
"description": "Enumerate browser page targets via the CDP browser endpoint (GET /json/list). By default returns only page-type tabs; set 'all' to also include workers and background pages. Each entry carries the targetId used as a target selector elsewhere, plus an 'origin' field recording where the tab came from: 'agent' means this toolkit created it (the entry then also carries the creating 'label' and 'createdAt'), and this stays true after the creating agent releases its lease or dies, which is what makes a stray agent tab findable later. 'unknown' means there is no creation record. It never says 'human': the toolkit cannot prove a person opened a tab, so 'unknown' is the honest word for a tab it did not create, one opened before the toolkit ran, or one whose record could not be written. A tab whose record exists but could not be read reports origin 'unknown' plus 'originUnreadable', so a broken record is never mistaken for no record.",
|
|
29
29
|
"inputSchema": {
|
|
30
30
|
"type": "object",
|
|
31
31
|
"properties": {
|
|
@@ -192,7 +192,7 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
192
192
|
},
|
|
193
193
|
{
|
|
194
194
|
"name": "evaluate_script",
|
|
195
|
-
"description": "Run arbitrary JavaScript in the target page's main-world context over raw CDP and return the evaluated value (returnByValue). With no 'args' the 'expression' is evaluated as a raw expression; when 'args' is provided 'expression' must be a function literal (arrow or classic) invoked on globalThis with the args passed positionally. A thrown exception surfaces as an error; non-serializable returns (DOM nodes, functions) come back as their CDP description string.",
|
|
195
|
+
"description": "Run arbitrary JavaScript in the target page's main-world context over raw CDP and return the evaluated value (returnByValue). With no 'args' the 'expression' is evaluated as a raw expression; when 'args' is provided 'expression' must be a function literal (arrow or classic) invoked on globalThis with the args passed positionally. A thrown exception surfaces as an error; non-serializable returns (DOM nodes, functions) come back as their CDP description string. Pass 'savePath' to write the evaluated value to a JSON file instead: the response then carries only {path,bytes,type,target} and the value itself never appears in it, in any form. That is the way to read a credential (a JWT or session token out of localStorage, for example) without putting the secret into the caller's transcript.",
|
|
196
196
|
"inputSchema": {
|
|
197
197
|
"type": "object",
|
|
198
198
|
"properties": {
|
|
@@ -215,6 +215,10 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
215
215
|
"args": {
|
|
216
216
|
"type": "array",
|
|
217
217
|
"description": "Positional JSON-serializable arguments to pass to the expression, treating it as a function. No live element/page handle is bound."
|
|
218
|
+
},
|
|
219
|
+
"savePath": {
|
|
220
|
+
"type": "string",
|
|
221
|
+
"description": "Write the evaluated value to this file as JSON and KEEP IT OUT OF THE RESPONSE: with savePath set the result is {path,bytes,type,target} only, with no copy, preview or truncation of the value. Use it to read credentials without putting them in a transcript. An absolute path (starting with /) is used as-is; a relative path is resolved under the artifact dir (/tmp/cdp-toolkit). Missing parent directories are created. Omit it to get the value back inline, exactly as before."
|
|
218
222
|
}
|
|
219
223
|
},
|
|
220
224
|
"required": [
|
|
@@ -223,6 +227,128 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
223
227
|
"additionalProperties": false
|
|
224
228
|
}
|
|
225
229
|
},
|
|
230
|
+
{
|
|
231
|
+
"name": "list_cookies",
|
|
232
|
+
"description": "Read the cookie store for the target page, INCLUDING httpOnly cookies, which document.cookie cannot see and an evaluate_script call therefore cannot reach. Each cookie carries name, value, domain, path, expires (Unix seconds, -1 for a session cookie), size, httpOnly, secure, sameSite ('strict'|'lax'|'none'|'default') and session. The read is page-scoped, not browser-wide: it returns the cookies of the resolved tab (Chrome: Network.getCookies; Firefox: storage.getCookies partitioned by that browsing context), so point at a page on the site whose cookies you want. Filter with 'domain' and/or 'name'. Pass 'savePath' to write the cookie array to a JSON file instead: the response is then {path,bytes,count,target} only, with no cookie value in it in any form, which is how to capture a session cookie without putting the credential in the caller's transcript.",
|
|
233
|
+
"inputSchema": {
|
|
234
|
+
"type": "object",
|
|
235
|
+
"properties": {
|
|
236
|
+
"lease": {
|
|
237
|
+
"type": "string",
|
|
238
|
+
"description": "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs."
|
|
239
|
+
},
|
|
240
|
+
"target": {
|
|
241
|
+
"type": "string",
|
|
242
|
+
"description": "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
|
|
243
|
+
},
|
|
244
|
+
"domain": {
|
|
245
|
+
"type": "string",
|
|
246
|
+
"description": "Keep only cookies for this domain. A leading dot is ignored on both sides and subdomains of the given domain match too, so 'example.test' matches '.example.test' and 'app.example.test'. No wildcards."
|
|
247
|
+
},
|
|
248
|
+
"name": {
|
|
249
|
+
"type": "string",
|
|
250
|
+
"description": "Keep only the cookie with exactly this name. Exact match, not a substring."
|
|
251
|
+
},
|
|
252
|
+
"savePath": {
|
|
253
|
+
"type": "string",
|
|
254
|
+
"description": "Write the cookie array to this file as JSON and KEEP THE VALUES OUT OF THE RESPONSE: with savePath set the result is {path,bytes,count,target} only, with no copy, preview or truncation of any cookie value. An absolute path (starting with /) is used as-is; a relative path is resolved under the artifact dir (/tmp/cdp-toolkit). Missing parent directories are created. Omit it to get the cookies back inline."
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
"required": [],
|
|
258
|
+
"additionalProperties": false
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"name": "set_cookie",
|
|
263
|
+
"description": "Write one cookie into the target page's cookie store, INCLUDING an httpOnly or secure cookie, which document.cookie cannot create and an evaluate_script call therefore cannot either. Chrome uses Network.setCookie, Firefox uses storage.setCookie partitioned by the resolved browsing context. Either 'url' or 'domain' is REQUIRED: a cookie has to be attributed to a site and the call is refused with an error when neither is given, rather than guessing the current page's origin. The response is {set:true,target} and never echoes the value back, so a credential you just supplied does not land in the transcript twice. A 'set:true' is earned, not assumed: Chrome answers Network.setCookie with success:false when it declines a cookie (a domain the url does not belong to, a secure cookie on an insecure origin, an oversized value) and that refusal is raised as an error. On Firefox, 'domain' is derived from 'url' when only the url was given, because BiDi has no url parameter; a url with no host, such as about:blank or a data URL, is an error rather than a silent no-op. Read the result back with list_cookies when you need proof.",
|
|
264
|
+
"inputSchema": {
|
|
265
|
+
"type": "object",
|
|
266
|
+
"properties": {
|
|
267
|
+
"lease": {
|
|
268
|
+
"type": "string",
|
|
269
|
+
"description": "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs."
|
|
270
|
+
},
|
|
271
|
+
"target": {
|
|
272
|
+
"type": "string",
|
|
273
|
+
"description": "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
|
|
274
|
+
},
|
|
275
|
+
"name": {
|
|
276
|
+
"type": "string",
|
|
277
|
+
"description": "Cookie name. Required and non-empty."
|
|
278
|
+
},
|
|
279
|
+
"value": {
|
|
280
|
+
"type": "string",
|
|
281
|
+
"description": "Cookie value. Required. It is not echoed back in the response."
|
|
282
|
+
},
|
|
283
|
+
"url": {
|
|
284
|
+
"type": "string",
|
|
285
|
+
"description": "The URL the cookie is set for, for example 'https://example.com/'. Chrome derives domain, path and secure from it. Firefox derives only the domain from its host. Give this or 'domain'."
|
|
286
|
+
},
|
|
287
|
+
"domain": {
|
|
288
|
+
"type": "string",
|
|
289
|
+
"description": "Cookie domain, for example 'example.com' or '.example.com' for subdomains. Give this or 'url'. When both are given, this wins on Firefox and Chrome applies its own url plus domain consistency rule."
|
|
290
|
+
},
|
|
291
|
+
"path": {
|
|
292
|
+
"type": "string",
|
|
293
|
+
"description": "Cookie path. Passed through exactly as given and NOT defaulted to '/': with a 'url' Chrome derives the path from it, so an invented default here would widen a cookie you meant to scope narrowly."
|
|
294
|
+
},
|
|
295
|
+
"expires": {
|
|
296
|
+
"type": "number",
|
|
297
|
+
"description": "Expiry as Unix time in SECONDS, matching what list_cookies reports. Omit it for a session cookie, which is what both backends do when no expiry is supplied."
|
|
298
|
+
},
|
|
299
|
+
"httpOnly": {
|
|
300
|
+
"type": "boolean",
|
|
301
|
+
"description": "Set the httpOnly flag, making the cookie invisible to document.cookie. Only a protocol write like this one can create such a cookie."
|
|
302
|
+
},
|
|
303
|
+
"secure": {
|
|
304
|
+
"type": "boolean",
|
|
305
|
+
"description": "Set the secure flag. Chrome declines a secure cookie on an insecure origin, and that refusal surfaces as an error."
|
|
306
|
+
},
|
|
307
|
+
"sameSite": {
|
|
308
|
+
"type": "string",
|
|
309
|
+
"enum": ["strict", "lax", "none", "default"],
|
|
310
|
+
"description": "SameSite attribute, lowercase, matching what list_cookies reports. 'default' means the attribute is not set at all."
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
"required": ["name", "value"],
|
|
314
|
+
"additionalProperties": false
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"name": "delete_cookies",
|
|
319
|
+
"description": "Delete the named cookie from the target page's cookie store, httpOnly cookies included. Chrome uses Network.deleteCookies, Firefox uses storage.deleteCookies with a filter, partitioned by the resolved browsing context. Both 'name' and one of 'url' or 'domain' are REQUIRED: without a site constraint the call would delete by name across the whole partition, so it is refused with an error instead. Narrow further with 'path'. The response is {deleted:true,target} and carries NO count, because neither protocol reports how many cookies it removed and a number here would be invented; call list_cookies before and after when you need a real count. 'deleted:true' means the backend accepted and performed the deletion, not that a matching cookie existed, since deleting an absent cookie is a success on both backends.",
|
|
320
|
+
"inputSchema": {
|
|
321
|
+
"type": "object",
|
|
322
|
+
"properties": {
|
|
323
|
+
"lease": {
|
|
324
|
+
"type": "string",
|
|
325
|
+
"description": "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs."
|
|
326
|
+
},
|
|
327
|
+
"target": {
|
|
328
|
+
"type": "string",
|
|
329
|
+
"description": "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
|
|
330
|
+
},
|
|
331
|
+
"name": {
|
|
332
|
+
"type": "string",
|
|
333
|
+
"description": "Exact cookie name to delete. Required and non-empty. No wildcards: this removes a named cookie, not a swathe of them."
|
|
334
|
+
},
|
|
335
|
+
"url": {
|
|
336
|
+
"type": "string",
|
|
337
|
+
"description": "Delete cookies matching this URL, for example 'https://example.com/'. Give this or 'domain'. On Firefox only the host is used, because BiDi's filter has no url field."
|
|
338
|
+
},
|
|
339
|
+
"domain": {
|
|
340
|
+
"type": "string",
|
|
341
|
+
"description": "Delete cookies with this exact domain. Give this or 'url'."
|
|
342
|
+
},
|
|
343
|
+
"path": {
|
|
344
|
+
"type": "string",
|
|
345
|
+
"description": "Narrow the deletion to cookies with this exact path. Omit to match any path allowed by the other constraints."
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
"required": ["name"],
|
|
349
|
+
"additionalProperties": false
|
|
350
|
+
}
|
|
351
|
+
},
|
|
226
352
|
{
|
|
227
353
|
"name": "take_snapshot",
|
|
228
354
|
"description": "Capture the page's accessibility tree (Accessibility.getFullAXTree) as a compact indented text tree where each line is prefixed with [uid], the node's CDP backendDOMNodeId. These uids are the stateless element references that every interaction tool (click/hover/fill/etc.) feeds back to resolve a live DOM node (via DOM.resolveNode({ backendNodeId: uid })), so run this first to discover uids.",
|
package/src/mcp.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { toolAvailability } from "./capabilities.ts";
|
|
|
23
23
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
24
24
|
import { leaseFromArgs, withLeaseScope } from "./leases.ts";
|
|
25
25
|
|
|
26
|
-
const VERSION = "1.
|
|
26
|
+
const VERSION = "1.4.0";
|
|
27
27
|
|
|
28
28
|
// --browser is read once at startup (MCP has no per-call notion of backend): explicit flag,
|
|
29
29
|
// else CDP_BROWSER env var, else "chrome" (zero behavior change for existing users/configs).
|
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
|
+
}
|