@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.
Files changed (36) hide show
  1. package/README.md +116 -0
  2. package/dist/shared/observers.d.ts +110 -0
  3. package/dist/shared/observers.js +127 -0
  4. package/dist/shared/page-fns.d.ts +46 -0
  5. package/dist/shared/page-fns.js +292 -0
  6. package/dist/shared/policy.d.ts +9 -0
  7. package/dist/shared/policy.js +16 -0
  8. package/dist/shared/protocol.d.ts +11 -2
  9. package/dist/shared/protocol.js +3 -0
  10. package/dist/shared/snapshot.d.ts +2 -0
  11. package/dist/shared/snapshot.js +8 -1
  12. package/dist/src/bridge/workspace.d.ts +6 -0
  13. package/dist/src/bridge/workspace.js +20 -0
  14. package/dist/src/config.js +31 -0
  15. package/dist/src/executor/extension-executor.d.ts +27 -13
  16. package/dist/src/executor/extension-executor.js +53 -12
  17. package/dist/src/executor/stub-executor.d.ts +19 -1
  18. package/dist/src/executor/stub-executor.js +22 -6
  19. package/dist/src/executor/types.d.ts +72 -13
  20. package/dist/src/mcp/audit.d.ts +39 -0
  21. package/dist/src/mcp/audit.js +60 -0
  22. package/dist/src/mcp/helpers.d.ts +4 -4
  23. package/dist/src/mcp/helpers.js +9 -4
  24. package/dist/src/mcp/locate.d.ts +45 -0
  25. package/dist/src/mcp/locate.js +105 -0
  26. package/dist/src/mcp/redact.d.ts +48 -0
  27. package/dist/src/mcp/redact.js +92 -0
  28. package/dist/src/mcp/snapdiff.d.ts +43 -0
  29. package/dist/src/mcp/snapdiff.js +92 -0
  30. package/dist/src/mcp/tools.js +429 -43
  31. package/dist/src/security/policy.d.ts +5 -0
  32. package/dist/src/security/policy.js +6 -0
  33. package/docs/BLUEPRINT.md +15 -1
  34. package/extension-dist/background.js +617 -214
  35. package/extension-dist/page-hook.js +215 -0
  36. package/package.json +1 -1
@@ -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 (target by selector or a snapshot ref). trusted=true uses real OS-level input.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField, button: zod_1.z.enum(['left', 'right', 'middle']).optional(), clickCount: zod_1.z.number().optional(), trusted: zod_1.z.boolean().optional() } },
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 stable refs to target by `ref` (more reliable than guessing CSS selectors).', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), tabId: tabIdField } },
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
- (0, policy_1.assertUrlAllowed)(url, method, ctx.policy);
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
- return (0, envelopes_1.jsonResult)(await ctx.ex.click(t, {
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
- return (0, envelopes_1.jsonResult)(await ctx.ex.type(t, (0, validators_1.requireWithinLength)((0, validators_1.requireString)(a, 'text'), 'text', validators_1.MAX_TEXT_LEN), {
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
- return (0, envelopes_1.jsonResult)(await ctx.ex.selectOption(t, values, { tabId: tabId(a) }));
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
- return (0, envelopes_1.jsonResult)(await ctx.ex.hover(t, { tabId: tabId(a) }));
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 capped = (0, limits_1.capText)(res.text, maxBytes(a));
296
- return (0, envelopes_1.jsonResult)({ ...res, text: capped.text, ...(0, limits_1.truncationMeta)(capped) });
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), { tabId: tabId(a), outer: (0, validators_1.optionalBoolean)(a, 'outer') });
301
- const capped = (0, limits_1.capHtml)(res.html, maxBytes(a));
302
- return (0, envelopes_1.jsonResult)({ ...res, html: capped.text, ...(0, limits_1.truncationMeta)(capped) });
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
- return (0, envelopes_1.jsonResult)(await ctx.ex.snapshot({
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
- return (0, envelopes_1.jsonResult)(await ctx.ex.eval((0, validators_1.requireString)(a, 'expression'), {
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, { selector: (0, validators_1.optionalString)(a, 'selector'), tabId: tabId(a) });
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 capped = (0, limits_1.capText)(md, maxBytes(a));
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) => (0, envelopes_1.jsonResult)(workspaceView((0, workspace_1.switchWorkspace)({ profile: (0, validators_1.requireString)(a, 'name') }))),
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
- /** Append one action record to the active task's history.jsonl (best-effort). */
486
- function recordHistory(tool, rawArgs, ok, error) {
487
- (0, workspace_1.appendHistory)({ ts: new Date().toISOString(), tool, args: summarizeArgs(rawArgs), ok, ...(error ? { error } : {}) });
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
  }