@mehmoodqureshi/chrome-mcp 0.6.6 → 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/cli.js +13 -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 +45 -1
- package/dist/src/executor/stub-executor.js +53 -8
- 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/batch.js +61 -9
- package/dist/src/mcp/helpers.d.ts +4 -4
- package/dist/src/mcp/helpers.js +9 -4
- package/dist/src/mcp/limits.d.ts +43 -0
- package/dist/src/mcp/limits.js +87 -0
- package/dist/src/mcp/locate.d.ts +45 -0
- package/dist/src/mcp/locate.js +105 -0
- package/dist/src/mcp/log.d.ts +17 -0
- package/dist/src/mcp/log.js +43 -0
- package/dist/src/mcp/redact.d.ts +48 -0
- package/dist/src/mcp/redact.js +92 -0
- package/dist/src/mcp/server.d.ts +1 -2
- package/dist/src/mcp/server.js +16 -12
- package/dist/src/mcp/snapdiff.d.ts +43 -0
- package/dist/src/mcp/snapdiff.js +92 -0
- package/dist/src/mcp/tools.js +481 -41
- 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
|
@@ -23,8 +23,14 @@ const types_1 = require("../executor/types");
|
|
|
23
23
|
const manager_1 = require("../executor/manager");
|
|
24
24
|
const policy_1 = require("../security/policy");
|
|
25
25
|
const envelopes_1 = require("./envelopes");
|
|
26
|
+
const limits_1 = require("./limits");
|
|
26
27
|
const batch_1 = require("./batch");
|
|
27
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");
|
|
33
|
+
const log_1 = require("./log");
|
|
28
34
|
const tasks_1 = require("../bridge/tasks");
|
|
29
35
|
const workspace_1 = require("../bridge/workspace");
|
|
30
36
|
const validators_1 = require("./validators");
|
|
@@ -34,6 +40,39 @@ const TARGET_PROPS = {
|
|
|
34
40
|
ref: zod_1.z.string().describe('Element ref from a prior read (exactly one of selector|ref)').optional(),
|
|
35
41
|
};
|
|
36
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();
|
|
72
|
+
const maxBytesField = zod_1.z
|
|
73
|
+
.number()
|
|
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.`)
|
|
75
|
+
.optional();
|
|
37
76
|
const waitUntilField = zod_1.z.enum(['load', 'domcontentloaded', 'networkidle']).describe('When to consider navigation done').optional();
|
|
38
77
|
exports.TOOL_DEFINITIONS = [
|
|
39
78
|
{ name: 'tabs_list', description: 'List open browser tabs.', inputSchema: {} },
|
|
@@ -44,25 +83,83 @@ exports.TOOL_DEFINITIONS = [
|
|
|
44
83
|
{ name: 'back', description: 'Go back in history.', inputSchema: { tabId: tabIdField } },
|
|
45
84
|
{ name: 'forward', description: 'Go forward in history.', inputSchema: { tabId: tabIdField } },
|
|
46
85
|
{ name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: { tabId: tabIdField, waitUntil: waitUntilField } },
|
|
47
|
-
{ name: 'click', description: 'Click an element
|
|
48
|
-
{ 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() } },
|
|
49
|
-
{ 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 } },
|
|
50
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 } },
|
|
51
|
-
{ name: 'hover', description: 'Hover over an element.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField } },
|
|
52
|
-
{ 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 } },
|
|
53
|
-
{ name: 'screenshot', description: 'Capture a PNG screenshot (page or element).', inputSchema: { ...TARGET_PROPS, fullPage: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
54
|
-
{ name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField } },
|
|
55
|
-
{ name: 'get_html', description: 'Get HTML of the page or an element.', inputSchema: { ...TARGET_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
56
|
-
{ 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 } },
|
|
57
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 } },
|
|
58
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 } },
|
|
59
|
-
{ 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 } },
|
|
60
|
-
{ 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 } },
|
|
61
|
-
{ 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 } },
|
|
62
|
-
{ name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: { selector: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
63
|
-
{ 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 } },
|
|
64
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 } },
|
|
65
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
|
+
},
|
|
66
163
|
{ name: 'chrome_status', description: 'Report backend/session status.', inputSchema: {} },
|
|
67
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).') } },
|
|
68
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).') } },
|
|
@@ -78,6 +175,7 @@ exports.TOOL_DEFINITIONS = [
|
|
|
78
175
|
mode: zod_1.z.enum(['parallel', 'serial']).describe('Default "parallel".').optional(),
|
|
79
176
|
stopOnError: zod_1.z.boolean().describe('Serial mode only: stop after the first failing op (the rest are skipped).').optional(),
|
|
80
177
|
maxConcurrency: zod_1.z.number().describe('Parallel mode: max ops in flight at once (default 6).').optional(),
|
|
178
|
+
maxResultBytes: zod_1.z.number().describe('Total payload budget across all ops (default 1048576). Ops past the budget are replaced by a one-line summary instead of their content, so a 50-op screenshot/get_html batch cannot flood the caller.').optional(),
|
|
81
179
|
},
|
|
82
180
|
},
|
|
83
181
|
];
|
|
@@ -154,9 +252,114 @@ async function gatedUrl(ex, tabId) {
|
|
|
154
252
|
*/
|
|
155
253
|
async function gate(ctx, method, opts = {}) {
|
|
156
254
|
const url = opts.url ?? ((0, policy_1.isUrlGated)(method) ? await gatedUrl(ctx.ex, opts.tabId) : '');
|
|
157
|
-
|
|
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);
|
|
158
263
|
}
|
|
159
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
|
+
}
|
|
361
|
+
/** The caller's output cap for a content read, or the default. */
|
|
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;
|
|
160
363
|
const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
|
|
161
364
|
/** Tools that don't act on a single tab (so `tabId` is irrelevant) — exempt from
|
|
162
365
|
* the parallel-batch explicit-tabId requirement. Everything else falls back to
|
|
@@ -215,33 +418,38 @@ exports.TOOL_HANDLERS = {
|
|
|
215
418
|
return (0, envelopes_1.jsonResult)(await ctx.ex.reload({ tabId: tabId(a), waitUntil: waitUntil(a) }));
|
|
216
419
|
},
|
|
217
420
|
click: async (a, ctx) => {
|
|
218
|
-
const t = (0, validators_1.requireTarget)(a);
|
|
219
421
|
await gate(ctx, 'click', { tabId: tabId(a) });
|
|
220
|
-
|
|
422
|
+
const t = await resolveTargetArg(ctx, a);
|
|
423
|
+
const res = await ctx.ex.click(t, {
|
|
221
424
|
tabId: tabId(a),
|
|
425
|
+
...frameOpts(a),
|
|
222
426
|
button: (0, validators_1.optionalString)(a, 'button'),
|
|
223
427
|
clickCount: (0, validators_1.optionalNumber)(a, 'clickCount', { min: 1, max: 3 }),
|
|
224
428
|
trusted: (0, validators_1.optionalBoolean)(a, 'trusted'),
|
|
225
|
-
})
|
|
429
|
+
});
|
|
430
|
+
return actionResult(ctx, a, res);
|
|
226
431
|
},
|
|
227
432
|
type: async (a, ctx) => {
|
|
228
|
-
const t = (0, validators_1.requireTarget)(a);
|
|
229
433
|
await gate(ctx, 'type', { tabId: tabId(a) });
|
|
230
|
-
|
|
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), {
|
|
231
436
|
tabId: tabId(a),
|
|
437
|
+
...frameOpts(a),
|
|
232
438
|
clear: (0, validators_1.optionalBoolean)(a, 'clear'),
|
|
233
439
|
pressEnter: (0, validators_1.optionalBoolean)(a, 'pressEnter'),
|
|
234
440
|
keyEvents: (0, validators_1.optionalBoolean)(a, 'keyEvents'),
|
|
235
441
|
trusted: (0, validators_1.optionalBoolean)(a, 'trusted'),
|
|
236
|
-
})
|
|
442
|
+
});
|
|
443
|
+
return actionResult(ctx, a, res);
|
|
237
444
|
},
|
|
238
445
|
select_option: async (a, ctx) => {
|
|
239
|
-
const t = (0, validators_1.requireTarget)(a);
|
|
240
446
|
await gate(ctx, 'type', { tabId: tabId(a) }); // mutating
|
|
447
|
+
const t = await resolveTargetArg(ctx, a);
|
|
241
448
|
const values = (0, validators_1.optionalStringArray)(a, 'values');
|
|
242
449
|
if (!values || values.length === 0)
|
|
243
450
|
throw new validators_1.McpToolError('"values" must be a non-empty array of strings');
|
|
244
|
-
|
|
451
|
+
const res = await ctx.ex.selectOption(t, values, { tabId: tabId(a), ...frameOpts(a) });
|
|
452
|
+
return actionResult(ctx, a, res);
|
|
245
453
|
},
|
|
246
454
|
press: async (a, ctx) => {
|
|
247
455
|
await gate(ctx, 'press', { tabId: tabId(a) });
|
|
@@ -251,14 +459,16 @@ exports.TOOL_HANDLERS = {
|
|
|
251
459
|
}));
|
|
252
460
|
},
|
|
253
461
|
hover: async (a, ctx) => {
|
|
254
|
-
const t = (0, validators_1.requireTarget)(a);
|
|
255
462
|
await gate(ctx, 'hover', { tabId: tabId(a) });
|
|
256
|
-
|
|
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);
|
|
257
466
|
},
|
|
258
467
|
scroll: async (a, ctx) => {
|
|
259
468
|
await gate(ctx, 'scroll', { tabId: tabId(a) });
|
|
260
469
|
return (0, envelopes_1.jsonResult)(await ctx.ex.scroll({
|
|
261
470
|
tabId: tabId(a),
|
|
471
|
+
...frameOpts(a),
|
|
262
472
|
x: (0, validators_1.optionalNumber)(a, 'x'),
|
|
263
473
|
y: (0, validators_1.optionalNumber)(a, 'y'),
|
|
264
474
|
deltaX: (0, validators_1.optionalNumber)(a, 'deltaX'),
|
|
@@ -270,6 +480,7 @@ exports.TOOL_HANDLERS = {
|
|
|
270
480
|
await gate(ctx, 'screenshot', { tabId: tabId(a) });
|
|
271
481
|
const shot = await ctx.ex.screenshot({
|
|
272
482
|
tabId: tabId(a),
|
|
483
|
+
...frameOpts(a),
|
|
273
484
|
fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
|
|
274
485
|
target: (0, validators_1.optionalTarget)(a),
|
|
275
486
|
});
|
|
@@ -279,21 +490,65 @@ exports.TOOL_HANDLERS = {
|
|
|
279
490
|
},
|
|
280
491
|
get_text: async (a, ctx) => {
|
|
281
492
|
await gate(ctx, 'get_text', { tabId: tabId(a) });
|
|
282
|
-
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) });
|
|
494
|
+
// Save the FULL read before capping — the artifact on disk stays lossless;
|
|
495
|
+
// only what crosses into the caller's context is bounded.
|
|
283
496
|
(0, workspace_1.saveResult)('get_text', 'json', JSON.stringify(res, null, 2));
|
|
284
|
-
|
|
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
|
+
});
|
|
285
507
|
},
|
|
286
508
|
get_html: async (a, ctx) => {
|
|
287
509
|
await gate(ctx, 'get_html', { tabId: tabId(a) });
|
|
288
|
-
|
|
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
|
+
});
|
|
289
527
|
},
|
|
290
528
|
snapshot: async (a, ctx) => {
|
|
291
529
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page structure
|
|
292
|
-
|
|
530
|
+
const snap = await ctx.ex.snapshot({
|
|
293
531
|
tabId: tabId(a),
|
|
532
|
+
...frameOpts(a),
|
|
294
533
|
interactiveOnly: (0, validators_1.optionalBoolean)(a, 'interactiveOnly'),
|
|
295
534
|
max: (0, validators_1.optionalNumber)(a, 'max', { min: 1, max: 1000 }),
|
|
296
|
-
})
|
|
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
|
+
});
|
|
297
552
|
},
|
|
298
553
|
get_cookies: async (a, ctx) => {
|
|
299
554
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // reads tab-scoped secrets; same domain gate as content reads
|
|
@@ -316,15 +571,31 @@ exports.TOOL_HANDLERS = {
|
|
|
316
571
|
},
|
|
317
572
|
eval: async (a, ctx) => {
|
|
318
573
|
await gate(ctx, 'eval', { tabId: tabId(a) });
|
|
319
|
-
|
|
574
|
+
const res = await ctx.ex.eval((0, validators_1.requireString)(a, 'expression'), {
|
|
320
575
|
tabId: tabId(a),
|
|
576
|
+
...frameOpts(a),
|
|
321
577
|
awaitPromise: (0, validators_1.optionalBoolean)(a, 'awaitPromise'),
|
|
322
|
-
})
|
|
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 });
|
|
323
593
|
},
|
|
324
594
|
wait_for: async (a, ctx) => {
|
|
325
595
|
await gate(ctx, 'wait_for', { tabId: tabId(a) });
|
|
326
596
|
return (0, envelopes_1.jsonResult)(await ctx.ex.waitFor({
|
|
327
597
|
tabId: tabId(a),
|
|
598
|
+
...frameOpts(a),
|
|
328
599
|
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
329
600
|
textContains: (0, validators_1.optionalString)(a, 'textContains'),
|
|
330
601
|
gone: (0, validators_1.optionalBoolean)(a, 'gone'),
|
|
@@ -334,6 +605,7 @@ exports.TOOL_HANDLERS = {
|
|
|
334
605
|
extract_links: async (a, ctx) => {
|
|
335
606
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page content
|
|
336
607
|
const res = await (0, helpers_1.extractLinks)(ctx.ex, {
|
|
608
|
+
...frameOpts(a),
|
|
337
609
|
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
338
610
|
sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
|
|
339
611
|
dedupe: (0, validators_1.optionalBoolean)(a, 'dedupe'),
|
|
@@ -345,9 +617,21 @@ exports.TOOL_HANDLERS = {
|
|
|
345
617
|
},
|
|
346
618
|
read_as_markdown: async (a, ctx) => {
|
|
347
619
|
await gate(ctx, 'get_text', { tabId: tabId(a) });
|
|
348
|
-
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
|
+
});
|
|
349
625
|
(0, workspace_1.saveResult)('read_as_markdown', 'md', md);
|
|
350
|
-
|
|
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);
|
|
630
|
+
// Markdown is returned as plain text, so the truncation notice has to ride in
|
|
631
|
+
// the body rather than as sibling JSON fields.
|
|
632
|
+
return (0, envelopes_1.textResult)(capped.truncated
|
|
633
|
+
? `${capped.text}\n\n[truncated: ${capped.returnedBytes} of ${capped.totalBytes} bytes — raise maxBytes or narrow with selector; the full document was saved to the task's results/ dir]`
|
|
634
|
+
: capped.text);
|
|
351
635
|
},
|
|
352
636
|
fill_form: async (a, ctx) => {
|
|
353
637
|
await gate(ctx, 'type', { tabId: tabId(a) }); // mutating
|
|
@@ -360,6 +644,7 @@ exports.TOOL_HANDLERS = {
|
|
|
360
644
|
(0, validators_1.requireWithinLength)(val, `fields["${sel}"]`, validators_1.MAX_TEXT_LEN);
|
|
361
645
|
}
|
|
362
646
|
return (0, envelopes_1.jsonResult)(await (0, helpers_1.fillForm)(ctx.ex, {
|
|
647
|
+
...frameOpts(a),
|
|
363
648
|
fields: fields,
|
|
364
649
|
submitSelector: (0, validators_1.optionalString)(a, 'submitSelector'),
|
|
365
650
|
tabId: tabId(a),
|
|
@@ -395,9 +680,102 @@ exports.TOOL_HANDLERS = {
|
|
|
395
680
|
}
|
|
396
681
|
return (0, envelopes_1.jsonResult)(await ctx.ex.uploadFile(t, files, { tabId: tabId(a) }));
|
|
397
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
|
+
},
|
|
398
770
|
chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
|
|
399
771
|
// --- task workspace management (server-side; no browser needed) ---
|
|
400
|
-
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
|
+
},
|
|
401
779
|
task_new: async (a) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ task: (0, validators_1.requireString)(a, 'name') }))),
|
|
402
780
|
task_status: async () => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.getActiveWorkspace)())),
|
|
403
781
|
tasks_list: async () => (0, envelopes_1.jsonResult)((0, tasks_1.listTasks)((0, workspace_1.getActiveWorkspace)().dataDir)),
|
|
@@ -463,9 +841,50 @@ function summarizeArgs(rawArgs) {
|
|
|
463
841
|
}
|
|
464
842
|
return Object.keys(out).length ? out : undefined;
|
|
465
843
|
}
|
|
466
|
-
/**
|
|
467
|
-
|
|
468
|
-
|
|
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
|
+
});
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Tools it is safe to re-issue after the extension drops mid-flight.
|
|
869
|
+
*
|
|
870
|
+
* MV3 recycles the extension's service worker on its own schedule, so a command
|
|
871
|
+
* can be in flight when the socket goes away — a fault that has nothing to do
|
|
872
|
+
* with the call and that the user currently fixes by re-issuing the identical
|
|
873
|
+
* request by hand. Only idempotent calls are eligible: repeating a `click` or a
|
|
874
|
+
* `type` could submit a form twice, which is not a cost worth paying to avoid one
|
|
875
|
+
* error message. `navigate` is included because landing on the same URL twice is
|
|
876
|
+
* the same end state.
|
|
877
|
+
*/
|
|
878
|
+
const RETRY_SAFE_TOOLS = new Set([
|
|
879
|
+
'tabs_list', 'chrome_status',
|
|
880
|
+
'get_text', 'get_html', 'snapshot', 'get_cookies',
|
|
881
|
+
'extract_links', 'read_as_markdown', 'screenshot',
|
|
882
|
+
'wait_for', 'navigate', 'reload',
|
|
883
|
+
'frames_list', 'print_pdf',
|
|
884
|
+
]);
|
|
885
|
+
/** Whether `err` is the transient bridge fault that a single retry can clear. */
|
|
886
|
+
function isRetryableFault(name, err) {
|
|
887
|
+
return err instanceof types_1.ExecutorError && err.code === 'EXTENSION_DISCONNECTED' && RETRY_SAFE_TOOLS.has(name);
|
|
469
888
|
}
|
|
470
889
|
async function dispatchToolCall(name, rawArgs) {
|
|
471
890
|
const handler = exports.TOOL_HANDLERS[name];
|
|
@@ -473,18 +892,39 @@ async function dispatchToolCall(name, rawArgs) {
|
|
|
473
892
|
return (0, envelopes_1.errorResult)(`unknown tool: ${name}`);
|
|
474
893
|
if (!allowCall(Date.now()))
|
|
475
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) {
|
|
476
902
|
try {
|
|
477
903
|
const mgr = (0, manager_1.getManager)();
|
|
478
904
|
// Workspace-management tools run server-side and must work even with no
|
|
479
905
|
// browser paired, so they skip the executor readiness check.
|
|
480
906
|
const ex = NO_BACKEND_TOOLS.has(name) ? null : await mgr.ensureReady();
|
|
481
|
-
|
|
482
|
-
|
|
907
|
+
let result;
|
|
908
|
+
try {
|
|
909
|
+
result = await handler((0, validators_1.asArgs)(rawArgs), { ex, policy: mgr.policy });
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
if (!isRetryableFault(name, err))
|
|
913
|
+
throw err;
|
|
914
|
+
// Re-pair (ensureReady resolves the new connection) and try once more. A
|
|
915
|
+
// second failure propagates untouched, so a genuinely unpaired browser
|
|
916
|
+
// still reports EXTENSION_DISCONNECTED rather than retrying forever.
|
|
917
|
+
(0, log_1.logDebug)(`${name}: extension disconnected mid-call; re-pairing and retrying once`);
|
|
918
|
+
const reconnected = await mgr.ensureReady();
|
|
919
|
+
result = await handler((0, validators_1.asArgs)(rawArgs), { ex: reconnected, policy: mgr.policy });
|
|
920
|
+
(0, log_1.logDebug)(`${name}: retry after reconnect succeeded`);
|
|
921
|
+
}
|
|
922
|
+
recordHistory(name, rawArgs, !result.isError, { ms: Date.now() - started, audit });
|
|
483
923
|
return result;
|
|
484
924
|
}
|
|
485
925
|
catch (err) {
|
|
486
926
|
const message = errMessage(err);
|
|
487
|
-
recordHistory(name, rawArgs, false, message);
|
|
927
|
+
recordHistory(name, rawArgs, false, { error: message, ms: Date.now() - started, audit });
|
|
488
928
|
return (0, envelopes_1.errorResult)(message);
|
|
489
929
|
}
|
|
490
930
|
}
|