@mehmoodqureshi/chrome-mcp 0.6.7 → 0.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/README.md +116 -0
- package/dist/shared/observers.d.ts +110 -0
- package/dist/shared/observers.js +127 -0
- package/dist/shared/page-fns.d.ts +46 -0
- package/dist/shared/page-fns.js +292 -0
- package/dist/shared/policy.d.ts +9 -0
- package/dist/shared/policy.js +16 -0
- package/dist/shared/protocol.d.ts +11 -2
- package/dist/shared/protocol.js +3 -0
- package/dist/shared/snapshot.d.ts +2 -0
- package/dist/shared/snapshot.js +8 -1
- package/dist/src/bridge/workspace.d.ts +6 -0
- package/dist/src/bridge/workspace.js +20 -0
- package/dist/src/config.js +31 -0
- package/dist/src/executor/extension-executor.d.ts +27 -13
- package/dist/src/executor/extension-executor.js +53 -12
- package/dist/src/executor/stub-executor.d.ts +19 -1
- package/dist/src/executor/stub-executor.js +22 -6
- package/dist/src/executor/types.d.ts +72 -13
- package/dist/src/mcp/audit.d.ts +39 -0
- package/dist/src/mcp/audit.js +60 -0
- package/dist/src/mcp/helpers.d.ts +4 -4
- package/dist/src/mcp/helpers.js +9 -4
- package/dist/src/mcp/locate.d.ts +45 -0
- package/dist/src/mcp/locate.js +105 -0
- package/dist/src/mcp/redact.d.ts +48 -0
- package/dist/src/mcp/redact.js +92 -0
- package/dist/src/mcp/snapdiff.d.ts +43 -0
- package/dist/src/mcp/snapdiff.js +92 -0
- package/dist/src/mcp/tools.js +429 -43
- package/dist/src/security/policy.d.ts +5 -0
- package/dist/src/security/policy.js +6 -0
- package/docs/BLUEPRINT.md +15 -1
- package/extension-dist/background.js +617 -214
- package/extension-dist/page-hook.js +215 -0
- package/package.json +1 -1
package/dist/src/mcp/tools.js
CHANGED
|
@@ -26,6 +26,10 @@ const envelopes_1 = require("./envelopes");
|
|
|
26
26
|
const limits_1 = require("./limits");
|
|
27
27
|
const batch_1 = require("./batch");
|
|
28
28
|
const helpers_1 = require("./helpers");
|
|
29
|
+
const redact_1 = require("./redact");
|
|
30
|
+
const locate_1 = require("./locate");
|
|
31
|
+
const snapdiff_1 = require("./snapdiff");
|
|
32
|
+
const audit_1 = require("./audit");
|
|
29
33
|
const log_1 = require("./log");
|
|
30
34
|
const tasks_1 = require("../bridge/tasks");
|
|
31
35
|
const workspace_1 = require("../bridge/workspace");
|
|
@@ -36,6 +40,35 @@ const TARGET_PROPS = {
|
|
|
36
40
|
ref: zod_1.z.string().describe('Element ref from a prior read (exactly one of selector|ref)').optional(),
|
|
37
41
|
};
|
|
38
42
|
const tabIdField = zod_1.z.string().describe('Target tab id (defaults to the active tab)').optional();
|
|
43
|
+
/**
|
|
44
|
+
* Frame targeting. Omitted = the top frame, which is what every call did before
|
|
45
|
+
* frames were addressable. `allFrames` is the one to reach for when a selector
|
|
46
|
+
* "should" match but does not: the element is almost always inside an iframe
|
|
47
|
+
* (checkout widgets, OAuth consent, embedded editors). Every frame is
|
|
48
|
+
* authorized against its own URL, so a scan never reaches a site the allowlist
|
|
49
|
+
* does not cover.
|
|
50
|
+
*/
|
|
51
|
+
const FRAME_PROPS = {
|
|
52
|
+
frameId: zod_1.z.number().describe('Act inside this frame (ids come from frames_list)').optional(),
|
|
53
|
+
allFrames: zod_1.z
|
|
54
|
+
.boolean()
|
|
55
|
+
.describe('Search every frame of the tab and act on the first that matches - use when a selector should match but does not (the element is in an iframe)')
|
|
56
|
+
.optional(),
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Target an element by role + accessible name instead of a CSS selector, so an
|
|
60
|
+
* action needs no snapshot first. Resolution is server-side and fails loudly on
|
|
61
|
+
* ambiguity rather than acting on the wrong element.
|
|
62
|
+
*/
|
|
63
|
+
const LOCATOR_PROPS = {
|
|
64
|
+
role: zod_1.z.string().describe('Target by ARIA role (e.g. button, link, textbox) - alternative to selector/ref').optional(),
|
|
65
|
+
name: zod_1.z.string().describe('Target by accessible name/visible label (pairs with role)').optional(),
|
|
66
|
+
nth: zod_1.z.number().describe('Pick the nth (0-based) match when a role+name locator is legitimately ambiguous').optional(),
|
|
67
|
+
};
|
|
68
|
+
const snapshotAfterField = zod_1.z
|
|
69
|
+
.boolean()
|
|
70
|
+
.describe('Return what CHANGED on the page after this action (added/removed/changed elements vs the last snapshot) instead of making you re-read the page')
|
|
71
|
+
.optional();
|
|
39
72
|
const maxBytesField = zod_1.z
|
|
40
73
|
.number()
|
|
41
74
|
.describe(`Cap the returned content at this many UTF-8 bytes (default ${limits_1.DEFAULT_MAX_OUTPUT_BYTES}). A truncated result reports truncated/totalBytes/returnedBytes. The full payload is still written to the task's results/ dir.`)
|
|
@@ -50,25 +83,83 @@ exports.TOOL_DEFINITIONS = [
|
|
|
50
83
|
{ name: 'back', description: 'Go back in history.', inputSchema: { tabId: tabIdField } },
|
|
51
84
|
{ name: 'forward', description: 'Go forward in history.', inputSchema: { tabId: tabIdField } },
|
|
52
85
|
{ name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: { tabId: tabIdField, waitUntil: waitUntilField } },
|
|
53
|
-
{ name: 'click', description: 'Click an element
|
|
54
|
-
{ name: 'type', description: 'Type text into an element. trusted=true sends real keystrokes (works on React/Vue controlled inputs).', inputSchema: { ...TARGET_PROPS, text: zod_1.z.string(), tabId: tabIdField, clear: zod_1.z.boolean().optional(), pressEnter: zod_1.z.boolean().optional(), keyEvents: zod_1.z.boolean().optional(), trusted: zod_1.z.boolean().optional() } },
|
|
55
|
-
{ name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema: { ...TARGET_PROPS, values: zod_1.z.array(zod_1.z.string()), tabId: tabIdField } },
|
|
86
|
+
{ name: 'click', description: 'Click an element. Target by selector, a snapshot ref, or role+name (e.g. role:"button", name:"Sign in") - the locator needs no snapshot first. trusted=true uses real OS-level input.', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, tabId: tabIdField, button: zod_1.z.enum(['left', 'right', 'middle']).optional(), clickCount: zod_1.z.number().optional(), trusted: zod_1.z.boolean().optional(), snapshotAfter: snapshotAfterField } },
|
|
87
|
+
{ name: 'type', description: 'Type text into an element (target by selector, ref, or role+name). trusted=true sends real keystrokes (works on React/Vue controlled inputs).', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, text: zod_1.z.string(), tabId: tabIdField, clear: zod_1.z.boolean().optional(), pressEnter: zod_1.z.boolean().optional(), keyEvents: zod_1.z.boolean().optional(), trusted: zod_1.z.boolean().optional(), snapshotAfter: snapshotAfterField } },
|
|
88
|
+
{ name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, values: zod_1.z.array(zod_1.z.string()), tabId: tabIdField, snapshotAfter: snapshotAfterField } },
|
|
56
89
|
{ name: 'press', description: 'Press a key (with optional modifiers).', inputSchema: { key: zod_1.z.string(), modifiers: zod_1.z.array(zod_1.z.string()).optional(), tabId: tabIdField } },
|
|
57
|
-
{ name: 'hover', description: 'Hover over an element.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField } },
|
|
58
|
-
{ name: 'scroll', description: 'Scroll the page or to an element.', inputSchema: { ...TARGET_PROPS, x: zod_1.z.number().optional(), y: zod_1.z.number().optional(), deltaX: zod_1.z.number().optional(), deltaY: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
59
|
-
{ name: 'screenshot', description: 'Capture a PNG screenshot (page or element).', inputSchema: { ...TARGET_PROPS, fullPage: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
60
|
-
{ name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField, maxBytes: maxBytesField } },
|
|
61
|
-
{ name: 'get_html', description: 'Get HTML of the page or an element. Output is capped (see maxBytes) and cut at a tag boundary; narrow it with `selector` rather than raising the cap when you can.', inputSchema: { ...TARGET_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
|
|
62
|
-
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with
|
|
90
|
+
{ name: 'hover', description: 'Hover over an element.', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, tabId: tabIdField, snapshotAfter: snapshotAfterField } },
|
|
91
|
+
{ name: 'scroll', description: 'Scroll the page or to an element.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, x: zod_1.z.number().optional(), y: zod_1.z.number().optional(), deltaX: zod_1.z.number().optional(), deltaY: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
92
|
+
{ name: 'screenshot', description: 'Capture a PNG screenshot (page or element).', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, fullPage: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
93
|
+
{ name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, tabId: tabIdField, maxBytes: maxBytesField } },
|
|
94
|
+
{ name: 'get_html', description: 'Get HTML of the page or an element. Output is capped (see maxBytes) and cut at a tag boundary; narrow it with `selector` rather than raising the cap when you can. Password field values are always blanked.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
|
|
95
|
+
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with refs to target by `ref` (more reliable than guessing CSS selectors). Pass diff:true to get only what changed since the last snapshot of this tab - far cheaper in a click/read loop. Password fields appear as secret:true with no value.', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), diff: zod_1.z.boolean().describe('Return added/removed/changed elements since the previous snapshot of this tab instead of the whole tree').optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
63
96
|
{ name: 'get_cookies', description: "Read cookies visible to the tab's URL (or a given url).", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
64
97
|
{ name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: { op: zod_1.z.enum(['get', 'set', 'remove', 'clear']), key: zod_1.z.string().optional(), value: zod_1.z.string().optional(), session: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
65
|
-
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
66
|
-
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
67
|
-
{ name: 'extract_links', description: 'Extract anchors from the page or a subtree. dedupe=true collapses links sharing an href (nav/footer noise); limit caps the count.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().optional(), dedupe: zod_1.z.boolean().optional(), limit: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
68
|
-
{ name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: { selector: zod_1.z.string().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
|
|
69
|
-
{ name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema: { fields: zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.boolean()])), submitSelector: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
98
|
+
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
99
|
+
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
100
|
+
{ name: 'extract_links', description: 'Extract anchors from the page or a subtree. dedupe=true collapses links sharing an href (nav/footer noise); limit caps the count.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().optional(), dedupe: zod_1.z.boolean().optional(), limit: zod_1.z.number().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
101
|
+
{ name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: { selector: zod_1.z.string().optional(), ...FRAME_PROPS, tabId: tabIdField, maxBytes: maxBytesField } },
|
|
102
|
+
{ name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema: { fields: zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.boolean()])), submitSelector: zod_1.z.string().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
70
103
|
{ name: 'download_file', description: 'Download a file by URL or from a link element.', inputSchema: { url: zod_1.z.string().optional(), ...TARGET_PROPS, suggestedName: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
71
104
|
{ name: 'upload_file', description: 'Set local file(s) on a file <input> (target by selector or ref) — uploads without the OS dialog. Requires --enable-uploads. `files` are absolute local paths.', inputSchema: { ...TARGET_PROPS, files: zod_1.z.array(zod_1.z.string()), tabId: tabIdField } },
|
|
105
|
+
{
|
|
106
|
+
name: 'frames_list',
|
|
107
|
+
description: "List the tab's frames (the top document plus every iframe the extension can reach), with each frame's id and URL. Use it when a selector that should match does not: the element is probably in one of these frames, and you can then pass frameId (or allFrames:true) to act inside it.",
|
|
108
|
+
inputSchema: { tabId: tabIdField },
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'console_logs',
|
|
112
|
+
description: 'Console output and uncaught errors recorded on the page (requires --enable-observers). This is how you find out WHY a page misbehaved rather than only what it looks like afterwards. Pass sinceSeq to poll for what is new, clear:true to drain.',
|
|
113
|
+
inputSchema: {
|
|
114
|
+
level: zod_1.z.enum(['log', 'info', 'warn', 'error', 'debug', 'exception']).describe('Only entries at this level').optional(),
|
|
115
|
+
...FRAME_PROPS,
|
|
116
|
+
sinceSeq: zod_1.z.number().describe('Only entries newer than this seq (from a previous call)').optional(),
|
|
117
|
+
limit: zod_1.z.number().describe('Max entries to return (default 200)').optional(),
|
|
118
|
+
clear: zod_1.z.boolean().describe('Empty the buffer after reading').optional(),
|
|
119
|
+
tabId: tabIdField,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'network_log',
|
|
124
|
+
description: "Requests the page made - fetch and XMLHttpRequest with method, URL, status and duration (requires --enable-observers). Set includeResources:true to also list scripts/images/styles from Resource Timing (those carry timing and size but no status). Does not include the document request or headers.",
|
|
125
|
+
inputSchema: {
|
|
126
|
+
urlContains: zod_1.z.string().describe('Only requests whose URL contains this substring').optional(),
|
|
127
|
+
...FRAME_PROPS,
|
|
128
|
+
failedOnly: zod_1.z.boolean().describe('Only requests that errored or returned status >= 400').optional(),
|
|
129
|
+
includeResources: zod_1.z.boolean().describe('Also include Resource Timing entries (scripts, images, styles)').optional(),
|
|
130
|
+
sinceSeq: zod_1.z.number().optional(),
|
|
131
|
+
limit: zod_1.z.number().optional(),
|
|
132
|
+
clear: zod_1.z.boolean().optional(),
|
|
133
|
+
tabId: tabIdField,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'dialogs',
|
|
138
|
+
description: "Native dialogs (alert/confirm/prompt/beforeunload) the page raised, and how they were answered (requires --enable-observers). With observers on, dialogs are intercepted rather than left to block the renderer - which is what otherwise turns a click that opens a confirm() into a mystery TIMEOUT. Set policy:'accept' to answer confirms with true.",
|
|
139
|
+
inputSchema: {
|
|
140
|
+
policy: zod_1.z.enum(['dismiss', 'accept']).describe("How to answer future dialogs (default dismiss: confirm->false, prompt->null)").optional(),
|
|
141
|
+
...FRAME_PROPS,
|
|
142
|
+
promptText: zod_1.z.string().describe("Text to answer prompt() with when policy is 'accept'").optional(),
|
|
143
|
+
sinceSeq: zod_1.z.number().optional(),
|
|
144
|
+
limit: zod_1.z.number().optional(),
|
|
145
|
+
clear: zod_1.z.boolean().optional(),
|
|
146
|
+
tabId: tabIdField,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'print_pdf',
|
|
151
|
+
description: "Render the page to PDF through Chrome's own print pipeline and save it to the task's results/ dir. Returns the path and size, not the bytes - a PDF is not something to spend context on.",
|
|
152
|
+
inputSchema: {
|
|
153
|
+
landscape: zod_1.z.boolean().optional(),
|
|
154
|
+
printBackground: zod_1.z.boolean().describe('Include background graphics (default true)').optional(),
|
|
155
|
+
scale: zod_1.z.number().describe('0.1 - 2.0').optional(),
|
|
156
|
+
paperWidth: zod_1.z.number().describe('Inches').optional(),
|
|
157
|
+
paperHeight: zod_1.z.number().describe('Inches').optional(),
|
|
158
|
+
pageRanges: zod_1.z.string().describe("e.g. '1-3, 5'").optional(),
|
|
159
|
+
preferCSSPageSize: zod_1.z.boolean().optional(),
|
|
160
|
+
tabId: tabIdField,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
72
163
|
{ name: 'chrome_status', description: 'Report backend/session status.', inputSchema: {} },
|
|
73
164
|
{ name: 'profile_use', description: 'Switch the active browser profile (identity). Subsequent downloads, results, screenshots, and the action log are stored under profiles/<name>/. Resets the active task to "default" unless you then call task_new.', inputSchema: { name: zod_1.z.string().describe('Profile name (becomes a folder; sanitized to a safe path segment).') } },
|
|
74
165
|
{ name: 'task_new', description: 'Start a new task (run) under the active profile. Creates profiles/<profile>/tasks/<name>/ with downloads/, results/, screenshots/ and makes it the active task so all captured artifacts land there.', inputSchema: { name: zod_1.z.string().describe('Task name (becomes a folder; sanitized to a safe path segment).') } },
|
|
@@ -161,9 +252,112 @@ async function gatedUrl(ex, tabId) {
|
|
|
161
252
|
*/
|
|
162
253
|
async function gate(ctx, method, opts = {}) {
|
|
163
254
|
const url = opts.url ?? ((0, policy_1.isUrlGated)(method) ? await gatedUrl(ctx.ex, opts.tabId) : '');
|
|
164
|
-
|
|
255
|
+
try {
|
|
256
|
+
(0, policy_1.assertUrlAllowed)(url, method, ctx.policy);
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
(0, audit_1.noteGate)(url, false);
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
(0, audit_1.noteGate)(url, true);
|
|
165
263
|
}
|
|
166
264
|
const tabId = (args) => (0, validators_1.optionalString)(args, 'tabId');
|
|
265
|
+
/** Frame targeting pulled off the raw args. */
|
|
266
|
+
const frameOpts = (args) => ({
|
|
267
|
+
frameId: (0, validators_1.optionalNumber)(args, 'frameId', { min: 0 }),
|
|
268
|
+
allFrames: (0, validators_1.optionalBoolean)(args, 'allFrames'),
|
|
269
|
+
});
|
|
270
|
+
const locatorOf = (args) => ({
|
|
271
|
+
role: (0, validators_1.optionalString)(args, 'role'),
|
|
272
|
+
name: (0, validators_1.optionalString)(args, 'name'),
|
|
273
|
+
nth: (0, validators_1.optionalNumber)(args, 'nth', { min: 0, max: 999 }),
|
|
274
|
+
});
|
|
275
|
+
/**
|
|
276
|
+
* Resolve whatever the caller used to point at an element: a CSS selector, a
|
|
277
|
+
* snapshot ref, or a role+name locator. Exactly one of the three — mixing them
|
|
278
|
+
* is a mistake worth reporting rather than silently preferring one.
|
|
279
|
+
*/
|
|
280
|
+
async function resolveTargetArg(ctx, a) {
|
|
281
|
+
const direct = (0, validators_1.optionalString)(a, 'selector') !== undefined || (0, validators_1.optionalString)(a, 'ref') !== undefined;
|
|
282
|
+
const loc = locatorOf(a);
|
|
283
|
+
if (direct && (0, locate_1.hasLocator)(loc)) {
|
|
284
|
+
throw new validators_1.McpToolError('give exactly one of selector | ref | role+name — not several at once');
|
|
285
|
+
}
|
|
286
|
+
if (!direct && (0, locate_1.hasLocator)(loc)) {
|
|
287
|
+
const found = await (0, locate_1.resolveLocator)(ctx.ex, loc, { tabId: tabId(a), ...frameOpts(a) });
|
|
288
|
+
return found.target;
|
|
289
|
+
}
|
|
290
|
+
// No locator: keep the original exactly-one-of validation and its message.
|
|
291
|
+
return (0, validators_1.requireTarget)(a);
|
|
292
|
+
}
|
|
293
|
+
/** Re-parse a redacted JSON string, falling back to the string when the
|
|
294
|
+
* substitution broke its syntax (a marker inside a string literal never does,
|
|
295
|
+
* but a caller is better served by the scrubbed text than by a throw). */
|
|
296
|
+
function safeParse(json) {
|
|
297
|
+
try {
|
|
298
|
+
return JSON.parse(json);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return json;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Compiled redaction config per policy object (compiling per call is waste). */
|
|
305
|
+
const redactionCache = new WeakMap();
|
|
306
|
+
function redaction(policy) {
|
|
307
|
+
if (!policy.redact)
|
|
308
|
+
return redact_1.NO_REDACTION;
|
|
309
|
+
const hit = redactionCache.get(policy);
|
|
310
|
+
if (hit)
|
|
311
|
+
return hit;
|
|
312
|
+
const cfg = {
|
|
313
|
+
enabled: true,
|
|
314
|
+
extra: (policy.redactPatterns ?? []).map(redact_1.compileRedactionPattern),
|
|
315
|
+
};
|
|
316
|
+
redactionCache.set(policy, cfg);
|
|
317
|
+
return cfg;
|
|
318
|
+
}
|
|
319
|
+
/** The scope a tab's remembered snapshot lives under. */
|
|
320
|
+
const snapScope = (a) => (0, snapdiff_1.scopeOf)((0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default', tabId(a));
|
|
321
|
+
/**
|
|
322
|
+
* Render an action's result, optionally with what the action CHANGED on the
|
|
323
|
+
* page. One extra round-trip buys the caller the delta instead of a full
|
|
324
|
+
* re-read, which is the expensive half of every click-then-look loop.
|
|
325
|
+
*/
|
|
326
|
+
async function actionResult(ctx, a, payload) {
|
|
327
|
+
if ((0, validators_1.optionalBoolean)(a, 'snapshotAfter') !== true)
|
|
328
|
+
return (0, envelopes_1.jsonResult)(payload);
|
|
329
|
+
const scope = snapScope(a);
|
|
330
|
+
const previous = (0, snapdiff_1.lastSnapshot)(scope);
|
|
331
|
+
const snap = await ctx.ex.snapshot({ tabId: tabId(a), max: 200, ...frameOpts(a) });
|
|
332
|
+
const diff = (0, snapdiff_1.diffSnapshots)(previous, snap);
|
|
333
|
+
const stored = (0, snapdiff_1.rememberSnapshot)(scope, snap);
|
|
334
|
+
return (0, envelopes_1.jsonResult)({ ...payload, changed: { ...diff, snapshotId: stored.id, url: snap.url } });
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Shared body of console_logs / network_log / dialogs: one gate, one read, and
|
|
338
|
+
* a straight answer when the hook is not there rather than an empty list that
|
|
339
|
+
* reads like "nothing happened".
|
|
340
|
+
*/
|
|
341
|
+
async function readObservers(ctx, a, which, extra = {}) {
|
|
342
|
+
await gate(ctx, 'observers', { tabId: tabId(a) });
|
|
343
|
+
if (!ctx.ex.observers) {
|
|
344
|
+
throw new types_1.ExecutorError('UNSUPPORTED', 'this backend cannot read in-page observers');
|
|
345
|
+
}
|
|
346
|
+
const res = await ctx.ex.observers({
|
|
347
|
+
tabId: tabId(a),
|
|
348
|
+
...frameOpts(a),
|
|
349
|
+
...which,
|
|
350
|
+
...extra,
|
|
351
|
+
sinceSeq: (0, validators_1.optionalNumber)(a, 'sinceSeq', { min: 0 }),
|
|
352
|
+
limit: (0, validators_1.optionalNumber)(a, 'limit', { min: 1, max: 1000 }),
|
|
353
|
+
clear: (0, validators_1.optionalBoolean)(a, 'clear'),
|
|
354
|
+
});
|
|
355
|
+
if (!res.installed) {
|
|
356
|
+
throw new validators_1.McpToolError('the observer hook is not present on this page. It is registered for allowlisted sites when the ' +
|
|
357
|
+
'server runs with --enable-observers; if it is on, reload the page so the hook is installed at load.');
|
|
358
|
+
}
|
|
359
|
+
return res;
|
|
360
|
+
}
|
|
167
361
|
/** The caller's output cap for a content read, or the default. */
|
|
168
362
|
const maxBytes = (args) => (0, validators_1.optionalNumber)(args, 'maxBytes', { min: limits_1.MIN_OUTPUT_BYTES, max: limits_1.MAX_OUTPUT_BYTES }) ?? limits_1.DEFAULT_MAX_OUTPUT_BYTES;
|
|
169
363
|
const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
|
|
@@ -224,33 +418,38 @@ exports.TOOL_HANDLERS = {
|
|
|
224
418
|
return (0, envelopes_1.jsonResult)(await ctx.ex.reload({ tabId: tabId(a), waitUntil: waitUntil(a) }));
|
|
225
419
|
},
|
|
226
420
|
click: async (a, ctx) => {
|
|
227
|
-
const t = (0, validators_1.requireTarget)(a);
|
|
228
421
|
await gate(ctx, 'click', { tabId: tabId(a) });
|
|
229
|
-
|
|
422
|
+
const t = await resolveTargetArg(ctx, a);
|
|
423
|
+
const res = await ctx.ex.click(t, {
|
|
230
424
|
tabId: tabId(a),
|
|
425
|
+
...frameOpts(a),
|
|
231
426
|
button: (0, validators_1.optionalString)(a, 'button'),
|
|
232
427
|
clickCount: (0, validators_1.optionalNumber)(a, 'clickCount', { min: 1, max: 3 }),
|
|
233
428
|
trusted: (0, validators_1.optionalBoolean)(a, 'trusted'),
|
|
234
|
-
})
|
|
429
|
+
});
|
|
430
|
+
return actionResult(ctx, a, res);
|
|
235
431
|
},
|
|
236
432
|
type: async (a, ctx) => {
|
|
237
|
-
const t = (0, validators_1.requireTarget)(a);
|
|
238
433
|
await gate(ctx, 'type', { tabId: tabId(a) });
|
|
239
|
-
|
|
434
|
+
const t = await resolveTargetArg(ctx, a);
|
|
435
|
+
const res = await ctx.ex.type(t, (0, validators_1.requireWithinLength)((0, validators_1.requireString)(a, 'text'), 'text', validators_1.MAX_TEXT_LEN), {
|
|
240
436
|
tabId: tabId(a),
|
|
437
|
+
...frameOpts(a),
|
|
241
438
|
clear: (0, validators_1.optionalBoolean)(a, 'clear'),
|
|
242
439
|
pressEnter: (0, validators_1.optionalBoolean)(a, 'pressEnter'),
|
|
243
440
|
keyEvents: (0, validators_1.optionalBoolean)(a, 'keyEvents'),
|
|
244
441
|
trusted: (0, validators_1.optionalBoolean)(a, 'trusted'),
|
|
245
|
-
})
|
|
442
|
+
});
|
|
443
|
+
return actionResult(ctx, a, res);
|
|
246
444
|
},
|
|
247
445
|
select_option: async (a, ctx) => {
|
|
248
|
-
const t = (0, validators_1.requireTarget)(a);
|
|
249
446
|
await gate(ctx, 'type', { tabId: tabId(a) }); // mutating
|
|
447
|
+
const t = await resolveTargetArg(ctx, a);
|
|
250
448
|
const values = (0, validators_1.optionalStringArray)(a, 'values');
|
|
251
449
|
if (!values || values.length === 0)
|
|
252
450
|
throw new validators_1.McpToolError('"values" must be a non-empty array of strings');
|
|
253
|
-
|
|
451
|
+
const res = await ctx.ex.selectOption(t, values, { tabId: tabId(a), ...frameOpts(a) });
|
|
452
|
+
return actionResult(ctx, a, res);
|
|
254
453
|
},
|
|
255
454
|
press: async (a, ctx) => {
|
|
256
455
|
await gate(ctx, 'press', { tabId: tabId(a) });
|
|
@@ -260,14 +459,16 @@ exports.TOOL_HANDLERS = {
|
|
|
260
459
|
}));
|
|
261
460
|
},
|
|
262
461
|
hover: async (a, ctx) => {
|
|
263
|
-
const t = (0, validators_1.requireTarget)(a);
|
|
264
462
|
await gate(ctx, 'hover', { tabId: tabId(a) });
|
|
265
|
-
|
|
463
|
+
const t = await resolveTargetArg(ctx, a);
|
|
464
|
+
const res = await ctx.ex.hover(t, { tabId: tabId(a), ...frameOpts(a) });
|
|
465
|
+
return actionResult(ctx, a, res);
|
|
266
466
|
},
|
|
267
467
|
scroll: async (a, ctx) => {
|
|
268
468
|
await gate(ctx, 'scroll', { tabId: tabId(a) });
|
|
269
469
|
return (0, envelopes_1.jsonResult)(await ctx.ex.scroll({
|
|
270
470
|
tabId: tabId(a),
|
|
471
|
+
...frameOpts(a),
|
|
271
472
|
x: (0, validators_1.optionalNumber)(a, 'x'),
|
|
272
473
|
y: (0, validators_1.optionalNumber)(a, 'y'),
|
|
273
474
|
deltaX: (0, validators_1.optionalNumber)(a, 'deltaX'),
|
|
@@ -279,6 +480,7 @@ exports.TOOL_HANDLERS = {
|
|
|
279
480
|
await gate(ctx, 'screenshot', { tabId: tabId(a) });
|
|
280
481
|
const shot = await ctx.ex.screenshot({
|
|
281
482
|
tabId: tabId(a),
|
|
483
|
+
...frameOpts(a),
|
|
282
484
|
fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
|
|
283
485
|
target: (0, validators_1.optionalTarget)(a),
|
|
284
486
|
});
|
|
@@ -288,26 +490,65 @@ exports.TOOL_HANDLERS = {
|
|
|
288
490
|
},
|
|
289
491
|
get_text: async (a, ctx) => {
|
|
290
492
|
await gate(ctx, 'get_text', { tabId: tabId(a) });
|
|
291
|
-
const res = await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a) });
|
|
493
|
+
const res = await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a), ...frameOpts(a) });
|
|
292
494
|
// Save the FULL read before capping — the artifact on disk stays lossless;
|
|
293
495
|
// only what crosses into the caller's context is bounded.
|
|
294
496
|
(0, workspace_1.saveResult)('get_text', 'json', JSON.stringify(res, null, 2));
|
|
295
|
-
const
|
|
296
|
-
|
|
497
|
+
const clean = (0, redact_1.redactText)(res.text, redaction(ctx.policy));
|
|
498
|
+
(0, audit_1.noteRedactions)(clean.redactions);
|
|
499
|
+
const capped = (0, limits_1.capText)(clean.value, maxBytes(a));
|
|
500
|
+
(0, audit_1.noteBytes)(capped.returnedBytes);
|
|
501
|
+
return (0, envelopes_1.jsonResult)({
|
|
502
|
+
...res,
|
|
503
|
+
text: capped.text,
|
|
504
|
+
...(0, limits_1.truncationMeta)(capped),
|
|
505
|
+
...(clean.redactions ? { redactions: clean.redactions } : {}),
|
|
506
|
+
});
|
|
297
507
|
},
|
|
298
508
|
get_html: async (a, ctx) => {
|
|
299
509
|
await gate(ctx, 'get_html', { tabId: tabId(a) });
|
|
300
|
-
const res = await ctx.ex.getHtml((0, validators_1.optionalTarget)(a), {
|
|
301
|
-
|
|
302
|
-
|
|
510
|
+
const res = await ctx.ex.getHtml((0, validators_1.optionalTarget)(a), {
|
|
511
|
+
tabId: tabId(a),
|
|
512
|
+
...frameOpts(a),
|
|
513
|
+
outer: (0, validators_1.optionalBoolean)(a, 'outer'),
|
|
514
|
+
});
|
|
515
|
+
// Password values go before the cap, so a truncated read cannot leak what a
|
|
516
|
+
// full one would have hidden.
|
|
517
|
+
const clean = (0, redact_1.redactHtml)(res.html, redaction(ctx.policy));
|
|
518
|
+
(0, audit_1.noteRedactions)(clean.redactions);
|
|
519
|
+
const capped = (0, limits_1.capHtml)(clean.value, maxBytes(a));
|
|
520
|
+
(0, audit_1.noteBytes)(capped.returnedBytes);
|
|
521
|
+
return (0, envelopes_1.jsonResult)({
|
|
522
|
+
...res,
|
|
523
|
+
html: capped.text,
|
|
524
|
+
...(0, limits_1.truncationMeta)(capped),
|
|
525
|
+
...(clean.redactions ? { redactions: clean.redactions } : {}),
|
|
526
|
+
});
|
|
303
527
|
},
|
|
304
528
|
snapshot: async (a, ctx) => {
|
|
305
529
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page structure
|
|
306
|
-
|
|
530
|
+
const snap = await ctx.ex.snapshot({
|
|
307
531
|
tabId: tabId(a),
|
|
532
|
+
...frameOpts(a),
|
|
308
533
|
interactiveOnly: (0, validators_1.optionalBoolean)(a, 'interactiveOnly'),
|
|
309
534
|
max: (0, validators_1.optionalNumber)(a, 'max', { min: 1, max: 1000 }),
|
|
310
|
-
})
|
|
535
|
+
});
|
|
536
|
+
const scope = snapScope(a);
|
|
537
|
+
const previous = (0, snapdiff_1.lastSnapshot)(scope);
|
|
538
|
+
const stored = (0, snapdiff_1.rememberSnapshot)(scope, snap);
|
|
539
|
+
if ((0, validators_1.optionalBoolean)(a, 'diff') !== true) {
|
|
540
|
+
return (0, envelopes_1.jsonResult)({ ...snap, snapshotId: stored.id });
|
|
541
|
+
}
|
|
542
|
+
const diff = (0, snapdiff_1.diffSnapshots)(previous, snap);
|
|
543
|
+
return (0, envelopes_1.jsonResult)({
|
|
544
|
+
url: snap.url,
|
|
545
|
+
title: snap.title,
|
|
546
|
+
snapshotId: stored.id,
|
|
547
|
+
...diff,
|
|
548
|
+
...(diff.since === null
|
|
549
|
+
? { note: 'no previous snapshot for this tab, so everything is reported as added' }
|
|
550
|
+
: {}),
|
|
551
|
+
});
|
|
311
552
|
},
|
|
312
553
|
get_cookies: async (a, ctx) => {
|
|
313
554
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // reads tab-scoped secrets; same domain gate as content reads
|
|
@@ -330,15 +571,31 @@ exports.TOOL_HANDLERS = {
|
|
|
330
571
|
},
|
|
331
572
|
eval: async (a, ctx) => {
|
|
332
573
|
await gate(ctx, 'eval', { tabId: tabId(a) });
|
|
333
|
-
|
|
574
|
+
const res = await ctx.ex.eval((0, validators_1.requireString)(a, 'expression'), {
|
|
334
575
|
tabId: tabId(a),
|
|
576
|
+
...frameOpts(a),
|
|
335
577
|
awaitPromise: (0, validators_1.optionalBoolean)(a, 'awaitPromise'),
|
|
336
|
-
})
|
|
578
|
+
});
|
|
579
|
+
// eval returns arbitrary page data — the widest read there is, so it gets
|
|
580
|
+
// the same scrub as the content reads.
|
|
581
|
+
const cfg = redaction(ctx.policy);
|
|
582
|
+
if (!cfg.enabled || res.value === undefined)
|
|
583
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
584
|
+
const asText = typeof res.value === 'string' ? res.value : JSON.stringify(res.value);
|
|
585
|
+
if (asText === undefined)
|
|
586
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
587
|
+
const clean = (0, redact_1.redactText)(asText, cfg);
|
|
588
|
+
if (clean.redactions === 0)
|
|
589
|
+
return (0, envelopes_1.jsonResult)(res);
|
|
590
|
+
(0, audit_1.noteRedactions)(clean.redactions);
|
|
591
|
+
const value = typeof res.value === 'string' ? clean.value : safeParse(clean.value);
|
|
592
|
+
return (0, envelopes_1.jsonResult)({ ...res, value, redactions: clean.redactions });
|
|
337
593
|
},
|
|
338
594
|
wait_for: async (a, ctx) => {
|
|
339
595
|
await gate(ctx, 'wait_for', { tabId: tabId(a) });
|
|
340
596
|
return (0, envelopes_1.jsonResult)(await ctx.ex.waitFor({
|
|
341
597
|
tabId: tabId(a),
|
|
598
|
+
...frameOpts(a),
|
|
342
599
|
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
343
600
|
textContains: (0, validators_1.optionalString)(a, 'textContains'),
|
|
344
601
|
gone: (0, validators_1.optionalBoolean)(a, 'gone'),
|
|
@@ -348,6 +605,7 @@ exports.TOOL_HANDLERS = {
|
|
|
348
605
|
extract_links: async (a, ctx) => {
|
|
349
606
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page content
|
|
350
607
|
const res = await (0, helpers_1.extractLinks)(ctx.ex, {
|
|
608
|
+
...frameOpts(a),
|
|
351
609
|
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
352
610
|
sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
|
|
353
611
|
dedupe: (0, validators_1.optionalBoolean)(a, 'dedupe'),
|
|
@@ -359,9 +617,16 @@ exports.TOOL_HANDLERS = {
|
|
|
359
617
|
},
|
|
360
618
|
read_as_markdown: async (a, ctx) => {
|
|
361
619
|
await gate(ctx, 'get_text', { tabId: tabId(a) });
|
|
362
|
-
const md = await (0, helpers_1.readAsMarkdown)(ctx.ex, {
|
|
620
|
+
const md = await (0, helpers_1.readAsMarkdown)(ctx.ex, {
|
|
621
|
+
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
622
|
+
tabId: tabId(a),
|
|
623
|
+
...frameOpts(a),
|
|
624
|
+
});
|
|
363
625
|
(0, workspace_1.saveResult)('read_as_markdown', 'md', md);
|
|
364
|
-
const
|
|
626
|
+
const clean = (0, redact_1.redactText)(md, redaction(ctx.policy));
|
|
627
|
+
(0, audit_1.noteRedactions)(clean.redactions);
|
|
628
|
+
const capped = (0, limits_1.capText)(clean.value, maxBytes(a));
|
|
629
|
+
(0, audit_1.noteBytes)(capped.returnedBytes);
|
|
365
630
|
// Markdown is returned as plain text, so the truncation notice has to ride in
|
|
366
631
|
// the body rather than as sibling JSON fields.
|
|
367
632
|
return (0, envelopes_1.textResult)(capped.truncated
|
|
@@ -379,6 +644,7 @@ exports.TOOL_HANDLERS = {
|
|
|
379
644
|
(0, validators_1.requireWithinLength)(val, `fields["${sel}"]`, validators_1.MAX_TEXT_LEN);
|
|
380
645
|
}
|
|
381
646
|
return (0, envelopes_1.jsonResult)(await (0, helpers_1.fillForm)(ctx.ex, {
|
|
647
|
+
...frameOpts(a),
|
|
382
648
|
fields: fields,
|
|
383
649
|
submitSelector: (0, validators_1.optionalString)(a, 'submitSelector'),
|
|
384
650
|
tabId: tabId(a),
|
|
@@ -414,9 +680,102 @@ exports.TOOL_HANDLERS = {
|
|
|
414
680
|
}
|
|
415
681
|
return (0, envelopes_1.jsonResult)(await ctx.ex.uploadFile(t, files, { tabId: tabId(a) }));
|
|
416
682
|
},
|
|
683
|
+
frames_list: async (a, ctx) => {
|
|
684
|
+
await gate(ctx, 'frames_list', { tabId: tabId(a) });
|
|
685
|
+
if (!ctx.ex.framesList)
|
|
686
|
+
throw new types_1.ExecutorError('UNSUPPORTED', 'this backend cannot enumerate frames');
|
|
687
|
+
const frames = await ctx.ex.framesList({ tabId: tabId(a) });
|
|
688
|
+
return (0, envelopes_1.jsonResult)({
|
|
689
|
+
frames,
|
|
690
|
+
count: frames.length,
|
|
691
|
+
...(frames.length > 1
|
|
692
|
+
? { hint: 'pass frameId (or allFrames:true) on a click/type/get_text to act inside one of these' }
|
|
693
|
+
: {}),
|
|
694
|
+
});
|
|
695
|
+
},
|
|
696
|
+
console_logs: async (a, ctx) => {
|
|
697
|
+
const res = await readObservers(ctx, a, { console: true });
|
|
698
|
+
const level = (0, validators_1.optionalString)(a, 'level');
|
|
699
|
+
const entries = (res.console ?? []).filter((e) => !level || e.level === level);
|
|
700
|
+
return (0, envelopes_1.jsonResult)({
|
|
701
|
+
entries,
|
|
702
|
+
count: entries.length,
|
|
703
|
+
...(res.dropped ? { dropped: true, dropNote: 'older entries were dropped from the in-page ring buffer' } : {}),
|
|
704
|
+
...(res.justInstalled ? { note: res.note } : {}),
|
|
705
|
+
});
|
|
706
|
+
},
|
|
707
|
+
network_log: async (a, ctx) => {
|
|
708
|
+
const res = await readObservers(ctx, a, { network: true }, {
|
|
709
|
+
includeResources: (0, validators_1.optionalBoolean)(a, 'includeResources'),
|
|
710
|
+
});
|
|
711
|
+
const contains = (0, validators_1.optionalString)(a, 'urlContains');
|
|
712
|
+
const failedOnly = (0, validators_1.optionalBoolean)(a, 'failedOnly') === true;
|
|
713
|
+
const entries = (res.network ?? []).filter((e) => {
|
|
714
|
+
if (contains && !e.url.includes(contains))
|
|
715
|
+
return false;
|
|
716
|
+
if (failedOnly && !e.error && (e.status ?? 0) < 400)
|
|
717
|
+
return false;
|
|
718
|
+
return true;
|
|
719
|
+
});
|
|
720
|
+
return (0, envelopes_1.jsonResult)({
|
|
721
|
+
entries,
|
|
722
|
+
count: entries.length,
|
|
723
|
+
...(res.dropped ? { dropped: true } : {}),
|
|
724
|
+
...(res.justInstalled ? { note: res.note } : {}),
|
|
725
|
+
});
|
|
726
|
+
},
|
|
727
|
+
dialogs: async (a, ctx) => {
|
|
728
|
+
const policy = (0, validators_1.optionalString)(a, 'policy');
|
|
729
|
+
if (policy && policy !== 'dismiss' && policy !== 'accept') {
|
|
730
|
+
throw new validators_1.McpToolError('"policy" must be "dismiss" or "accept"');
|
|
731
|
+
}
|
|
732
|
+
const res = await readObservers(ctx, a, { dialogs: true }, {
|
|
733
|
+
setPolicy: policy,
|
|
734
|
+
promptText: (0, validators_1.optionalString)(a, 'promptText'),
|
|
735
|
+
});
|
|
736
|
+
return (0, envelopes_1.jsonResult)({
|
|
737
|
+
entries: res.dialogs ?? [],
|
|
738
|
+
count: (res.dialogs ?? []).length,
|
|
739
|
+
policy: res.dialogPolicy,
|
|
740
|
+
...(res.justInstalled ? { note: res.note } : {}),
|
|
741
|
+
});
|
|
742
|
+
},
|
|
743
|
+
print_pdf: async (a, ctx) => {
|
|
744
|
+
await gate(ctx, 'print_pdf', { tabId: tabId(a) });
|
|
745
|
+
if (!ctx.ex.printPdf)
|
|
746
|
+
throw new types_1.ExecutorError('UNSUPPORTED', 'this backend cannot print to PDF');
|
|
747
|
+
const pdf = await ctx.ex.printPdf({
|
|
748
|
+
tabId: tabId(a),
|
|
749
|
+
landscape: (0, validators_1.optionalBoolean)(a, 'landscape'),
|
|
750
|
+
printBackground: (0, validators_1.optionalBoolean)(a, 'printBackground'),
|
|
751
|
+
scale: (0, validators_1.optionalNumber)(a, 'scale', { min: 0.1, max: 2 }),
|
|
752
|
+
paperWidth: (0, validators_1.optionalNumber)(a, 'paperWidth', { min: 0.1, max: 200 }),
|
|
753
|
+
paperHeight: (0, validators_1.optionalNumber)(a, 'paperHeight', { min: 0.1, max: 200 }),
|
|
754
|
+
pageRanges: (0, validators_1.optionalString)(a, 'pageRanges'),
|
|
755
|
+
preferCSSPageSize: (0, validators_1.optionalBoolean)(a, 'preferCSSPageSize'),
|
|
756
|
+
});
|
|
757
|
+
const bytes = Buffer.from(pdf.dataBase64, 'base64');
|
|
758
|
+
const path = (0, workspace_1.saveBinary)('print_pdf', 'pdf', bytes);
|
|
759
|
+
(0, audit_1.noteBytes)(bytes.byteLength);
|
|
760
|
+
// The bytes themselves are deliberately NOT returned: a PDF is megabytes of
|
|
761
|
+
// base64 that no model can read, and the file on disk is the useful artifact.
|
|
762
|
+
return (0, envelopes_1.jsonResult)({
|
|
763
|
+
path,
|
|
764
|
+
bytes: bytes.byteLength,
|
|
765
|
+
url: pdf.url,
|
|
766
|
+
title: pdf.title,
|
|
767
|
+
...(path ? {} : { note: 'no active task workspace, so the PDF was not saved to disk' }),
|
|
768
|
+
});
|
|
769
|
+
},
|
|
417
770
|
chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
|
|
418
771
|
// --- task workspace management (server-side; no browser needed) ---
|
|
419
|
-
profile_use: async (a) =>
|
|
772
|
+
profile_use: async (a) => {
|
|
773
|
+
// Snapshots are keyed by profile+tab, and a profile switch routes to a
|
|
774
|
+
// different browser entirely — keeping the old tree would diff a page
|
|
775
|
+
// against one from another machine.
|
|
776
|
+
(0, snapdiff_1.resetSnapshots)();
|
|
777
|
+
return (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ profile: (0, validators_1.requireString)(a, 'name') })));
|
|
778
|
+
},
|
|
420
779
|
task_new: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ task: (0, validators_1.requireString)(a, 'name') }))),
|
|
421
780
|
task_status: async () => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.getActiveWorkspace)())),
|
|
422
781
|
tasks_list: async () => (0, envelopes_1.jsonResult)((0, tasks_1.listTasks)((0, workspace_1.getActiveWorkspace)().dataDir)),
|
|
@@ -482,9 +841,28 @@ function summarizeArgs(rawArgs) {
|
|
|
482
841
|
}
|
|
483
842
|
return Object.keys(out).length ? out : undefined;
|
|
484
843
|
}
|
|
485
|
-
/**
|
|
486
|
-
|
|
487
|
-
|
|
844
|
+
/**
|
|
845
|
+
* Append one action record to the active task's history.jsonl (best-effort).
|
|
846
|
+
*
|
|
847
|
+
* The audit fields — which URL the policy was evaluated against, whether it
|
|
848
|
+
* denied, how many bytes came back, how many secrets were scrubbed — are the
|
|
849
|
+
* ones someone actually wants when reviewing what an agent did in their real
|
|
850
|
+
* browser, and they exist only inside the call.
|
|
851
|
+
*/
|
|
852
|
+
function recordHistory(tool, rawArgs, ok, extra = {}) {
|
|
853
|
+
const a = extra.audit ?? {};
|
|
854
|
+
(0, workspace_1.appendHistory)({
|
|
855
|
+
ts: new Date().toISOString(),
|
|
856
|
+
tool,
|
|
857
|
+
args: summarizeArgs(rawArgs),
|
|
858
|
+
ok,
|
|
859
|
+
...(extra.ms !== undefined ? { ms: extra.ms } : {}),
|
|
860
|
+
...(a.url ? { url: a.url } : {}),
|
|
861
|
+
...(a.denied ? { policy: 'denied' } : a.url ? { policy: 'allowed' } : {}),
|
|
862
|
+
...(a.bytes !== undefined ? { bytes: a.bytes } : {}),
|
|
863
|
+
...(a.redactions ? { redactions: a.redactions } : {}),
|
|
864
|
+
...(extra.error ? { error: extra.error } : {}),
|
|
865
|
+
});
|
|
488
866
|
}
|
|
489
867
|
/**
|
|
490
868
|
* Tools it is safe to re-issue after the extension drops mid-flight.
|
|
@@ -502,6 +880,7 @@ const RETRY_SAFE_TOOLS = new Set([
|
|
|
502
880
|
'get_text', 'get_html', 'snapshot', 'get_cookies',
|
|
503
881
|
'extract_links', 'read_as_markdown', 'screenshot',
|
|
504
882
|
'wait_for', 'navigate', 'reload',
|
|
883
|
+
'frames_list', 'print_pdf',
|
|
505
884
|
]);
|
|
506
885
|
/** Whether `err` is the transient bridge fault that a single retry can clear. */
|
|
507
886
|
function isRetryableFault(name, err) {
|
|
@@ -513,6 +892,13 @@ async function dispatchToolCall(name, rawArgs) {
|
|
|
513
892
|
return (0, envelopes_1.errorResult)(`unknown tool: ${name}`);
|
|
514
893
|
if (!allowCall(Date.now()))
|
|
515
894
|
return (0, envelopes_1.errorResult)('rate limit exceeded; slow down');
|
|
895
|
+
const started = Date.now();
|
|
896
|
+
// One audit record per call, carried through async hops so a parallel `batch`
|
|
897
|
+
// cannot cross-attribute one op's target URL to another's log line.
|
|
898
|
+
const { result: outcome } = await (0, audit_1.withAudit)(async (audit) => dispatchInner(name, handler, rawArgs, audit, started));
|
|
899
|
+
return outcome;
|
|
900
|
+
}
|
|
901
|
+
async function dispatchInner(name, handler, rawArgs, audit, started) {
|
|
516
902
|
try {
|
|
517
903
|
const mgr = (0, manager_1.getManager)();
|
|
518
904
|
// Workspace-management tools run server-side and must work even with no
|
|
@@ -533,12 +919,12 @@ async function dispatchToolCall(name, rawArgs) {
|
|
|
533
919
|
result = await handler((0, validators_1.asArgs)(rawArgs), { ex: reconnected, policy: mgr.policy });
|
|
534
920
|
(0, log_1.logDebug)(`${name}: retry after reconnect succeeded`);
|
|
535
921
|
}
|
|
536
|
-
recordHistory(name, rawArgs, !result.isError);
|
|
922
|
+
recordHistory(name, rawArgs, !result.isError, { ms: Date.now() - started, audit });
|
|
537
923
|
return result;
|
|
538
924
|
}
|
|
539
925
|
catch (err) {
|
|
540
926
|
const message = errMessage(err);
|
|
541
|
-
recordHistory(name, rawArgs, false, message);
|
|
927
|
+
recordHistory(name, rawArgs, false, { error: message, ms: Date.now() - started, audit });
|
|
542
928
|
return (0, envelopes_1.errorResult)(message);
|
|
543
929
|
}
|
|
544
930
|
}
|