cdp-toolkit 1.2.0 → 1.6.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 +223 -0
- package/CONTRACT.md +4 -2
- package/README.md +67 -20
- package/dist/cli.js +783 -63
- package/dist/index.js +694 -48
- package/dist/mcp.js +982 -66
- package/package.json +5 -3
- package/src/bidi/driver.ts +140 -1
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +148 -1
- package/src/driver.ts +110 -1
- package/src/firefox-tools.ts +1 -1
- package/src/index.ts +22 -4
- package/src/leases-tools.ts +9 -3
- package/src/leases.ts +9 -1
- package/src/manifest.ts +198 -2
- package/src/mcp.ts +1 -1
- package/src/origins.ts +266 -0
- package/src/shared-tools.ts +317 -11
- package/src/tools/screencast.ts +741 -0
package/src/leases.ts
CHANGED
|
@@ -65,6 +65,14 @@ export function leaseTtlMs(): number {
|
|
|
65
65
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** The label recorded when a caller supplies none. ONE definition, because it
|
|
69
|
+
* is now written by three call sites (claim_page, new_page, and the origin
|
|
70
|
+
* ledger) and a second rule would make the same tab describe itself two
|
|
71
|
+
* different ways depending on which of them wrote first. */
|
|
72
|
+
export function defaultLabel(): string {
|
|
73
|
+
return `pid-${process.pid}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
function safeId(id: string): string {
|
|
69
77
|
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
70
78
|
}
|
|
@@ -257,7 +265,7 @@ export async function releaseLeaseFor(backend: LeaseBackend, targetId: string):
|
|
|
257
265
|
* This exists so enforcement can stay at ONE point per resolution path while
|
|
258
266
|
* the token still reaches it. The token arrives as a key on a tool's args
|
|
259
267
|
* object, many frames above resolveTarget, and threading a parameter through
|
|
260
|
-
* all
|
|
268
|
+
* all 41 tools would put the burden on every future contributor to remember
|
|
261
269
|
* the check exists: one missed tool would silently defeat the feature for that
|
|
262
270
|
* tool. mcp.ts and cli.ts open this scope once per dispatch instead, so a tool
|
|
263
271
|
* added tomorrow is covered with no action from whoever writes it.
|
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.",
|
|
@@ -992,6 +1118,76 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
992
1118
|
"additionalProperties": false
|
|
993
1119
|
}
|
|
994
1120
|
},
|
|
1121
|
+
{
|
|
1122
|
+
"name": "start_screen_recording",
|
|
1123
|
+
"description": "Start recording the target tab to video over raw CDP (Page.startScreencast), holding one persistent connection open and spooling every Page.screencastFrame to disk. Chrome emits a frame ON REPAINT, not on a clock, so the stream is variable-rate: each frame is timestamped into a ledger and stop_screen_recording turns those timestamps into per-frame display durations, which is what makes a still page hold one long frame instead of the video racing or freezing. Every frame is acked immediately (an unacked frame stalls the stream). ffmpeg is probed here, not at stop, so a missing encoder fails before a recording is captured and thrown away. Must be paired with stop_screen_recording WITHIN THE SAME PROCESS (the frames are events on this connection and cannot be re-attached from another process, so the pair works under the MCP server, not the one-process-per-call CLI). Throws if a recording is already in progress for the target; recordings on DIFFERENT targets run concurrently. Chrome only: WebDriver BiDi has no screencast primitive, so both tools are absent under --browser firefox.",
|
|
1124
|
+
"inputSchema": {
|
|
1125
|
+
"type": "object",
|
|
1126
|
+
"properties": {
|
|
1127
|
+
"lease": {
|
|
1128
|
+
"type": "string",
|
|
1129
|
+
"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, which behave exactly as before."
|
|
1130
|
+
},
|
|
1131
|
+
"target": {
|
|
1132
|
+
"type": "string",
|
|
1133
|
+
"description": "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
1134
|
+
},
|
|
1135
|
+
"format": {
|
|
1136
|
+
"type": "string",
|
|
1137
|
+
"enum": [
|
|
1138
|
+
"jpeg",
|
|
1139
|
+
"png"
|
|
1140
|
+
],
|
|
1141
|
+
"description": "Frame image format. Defaults to jpeg (far smaller per repaint); png is lossless. quality applies to jpeg only."
|
|
1142
|
+
},
|
|
1143
|
+
"quality": {
|
|
1144
|
+
"type": "number",
|
|
1145
|
+
"description": "JPEG frame quality 0-100. Ignored for png. Omitted by default, which leaves Chrome's own default in place."
|
|
1146
|
+
},
|
|
1147
|
+
"maxWidth": {
|
|
1148
|
+
"type": "number",
|
|
1149
|
+
"description": "Cap the streamed frame width in pixels. Also the way to pin frame size if the viewport might change mid-recording."
|
|
1150
|
+
},
|
|
1151
|
+
"maxHeight": {
|
|
1152
|
+
"type": "number",
|
|
1153
|
+
"description": "Cap the streamed frame height in pixels."
|
|
1154
|
+
},
|
|
1155
|
+
"everyNthFrame": {
|
|
1156
|
+
"type": "number",
|
|
1157
|
+
"description": "Capture only every Nth repaint (1 = every frame). Reduces spool size on a busy page; per-frame durations keep playback at wallclock speed either way."
|
|
1158
|
+
},
|
|
1159
|
+
"bringToFront": {
|
|
1160
|
+
"type": "boolean",
|
|
1161
|
+
"description": "Activate the tab (Page.bringToFront) before recording. Default false. A fully backgrounded or occluded tab may never repaint, and a recording of one captures 0 frames."
|
|
1162
|
+
}
|
|
1163
|
+
},
|
|
1164
|
+
"required": [],
|
|
1165
|
+
"additionalProperties": false
|
|
1166
|
+
}
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
"name": "stop_screen_recording",
|
|
1170
|
+
"description": "Stop the recording started by start_screen_recording, assemble the spooled frames into an MP4 with ffmpeg using PER-FRAME durations from the ledger (frame N is held until frame N+1 painted; the last frame is held until this call), and return {path,bytes,durationMs,frameCount,encodedFrames,codec,encoder,width,height,droppedFrames,target}. Encoder ladder, probed once at start: hevc_videotoolbox then h264_videotoolbox then libx265 then libx264, with -tag:v hvc1 on HEVC so QuickTime plays it, +faststart, yuv420p, and even dimensions forced. With one recording in flight 'target' may be omitted; with several it is required, because guessing which to stop can lose another agent's recording. Throws if nothing is recording in this process, and throws rather than writing a silent empty video when 0 frames arrived. On an ffmpeg failure the spooled frames are KEPT and the spool path plus the exact command are named in the error. Encoded video tops out at 25 fps: ffmpeg's concat demuxer represents image timestamps on a 1/25s grid, so frames captured less than 40ms apart are coalesced deliberately (frameCount reports what was captured, encodedFrames what reached the video) instead of being dropped silently by ffmpeg. durationMs is the sum of the encoded durations; the file itself runs one 40ms step longer, because the concat demuxer needs the final frame repeated for its hold to count.",
|
|
1171
|
+
"inputSchema": {
|
|
1172
|
+
"type": "object",
|
|
1173
|
+
"properties": {
|
|
1174
|
+
"lease": {
|
|
1175
|
+
"type": "string",
|
|
1176
|
+
"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, which behave exactly as before."
|
|
1177
|
+
},
|
|
1178
|
+
"target": {
|
|
1179
|
+
"type": "string",
|
|
1180
|
+
"description": "Which recording to stop: 'active' (default, valid only when exactly one recording is in flight) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
1181
|
+
},
|
|
1182
|
+
"savePath": {
|
|
1183
|
+
"type": "string",
|
|
1184
|
+
"description": "Override the output file path. Default: /tmp/cdp-toolkit/screen-recording-<targetIdShort>-<stamp>.mp4."
|
|
1185
|
+
}
|
|
1186
|
+
},
|
|
1187
|
+
"required": [],
|
|
1188
|
+
"additionalProperties": false
|
|
1189
|
+
}
|
|
1190
|
+
},
|
|
995
1191
|
{
|
|
996
1192
|
"name": "take_heapsnapshot",
|
|
997
1193
|
"description": "Capture a V8 heap snapshot of the selected page target over raw CDP (HeapProfiler.takeHeapSnapshot, accumulating addHeapSnapshotChunk events) and write it as a .heapsnapshot JSON file loadable by the DevTools Memory panel; returns {path,bytes,chunks,target} only and does not parse/summarize the snapshot. Writes under /tmp/cdp-toolkit (CDP_ARTIFACT_DIR) unless savePath is given.",
|
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.6.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
|
+
}
|