@mehmoodqureshi/chrome-mcp 0.9.1 → 0.9.3

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.
@@ -29,6 +29,7 @@ const helpers_1 = require("./helpers");
29
29
  const redact_1 = require("./redact");
30
30
  const locate_1 = require("./locate");
31
31
  const snapdiff_1 = require("./snapdiff");
32
+ const auth_wall_1 = require("../../shared/auth-wall");
32
33
  const audit_1 = require("./audit");
33
34
  const log_1 = require("./log");
34
35
  const tasks_1 = require("../bridge/tasks");
@@ -40,6 +41,7 @@ const TARGET_PROPS = {
40
41
  ref: zod_1.z.string().describe('Element ref from a prior read (exactly one of selector|ref)').optional(),
41
42
  };
42
43
  const tabIdField = zod_1.z.string().describe('Target tab id (defaults to the active tab)').optional();
44
+ const authWallField = zod_1.z.boolean().describe('Fail with [AUTH_REQUIRED] when the page this call lands on is a high-confidence sign-in wall (session expired). Off by default unless the server runs with --fail-on-auth-wall; snapshot still reports the verdict as `authWall` either way.').optional();
43
45
  /**
44
46
  * Frame targeting. Omitted = the top frame, which is what every call did before
45
47
  * frames were addressable. `allFrames` is the one to reach for when a selector
@@ -79,27 +81,27 @@ exports.TOOL_DEFINITIONS = [
79
81
  { name: 'tab_select', description: 'Make a tab active by tabId.', inputSchema: { tabId: zod_1.z.string() } },
80
82
  { name: 'tab_new', description: 'Open a NEW tab (optionally at a URL) and focus it. Prefer this over `navigate` when the user says "open"/"go to" a site — `navigate` REPLACES the current tab. Pass active:false to open in the background (used by parallel batches).', inputSchema: { url: zod_1.z.string().optional(), active: zod_1.z.boolean().optional() } },
81
83
  { name: 'tab_close', description: 'Close a tab by tabId.', inputSchema: { tabId: zod_1.z.string() } },
82
- { name: 'navigate', description: 'Navigate a tab to a URL, REPLACING its current page. Acts on the active tab unless tabId is given — to open a site without losing the current page, use `tab_new` instead.', inputSchema: { url: zod_1.z.string(), tabId: tabIdField, waitUntil: waitUntilField } },
83
- { name: 'back', description: 'Go back in history.', inputSchema: { tabId: tabIdField } },
84
- { name: 'forward', description: 'Go forward in history.', inputSchema: { tabId: tabIdField } },
85
- { name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: { tabId: tabIdField, waitUntil: waitUntilField } },
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 } },
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 } },
84
+ { name: 'navigate', description: 'Navigate a tab to a URL, REPLACING its current page. Acts on the active tab unless tabId is given — to open a site without losing the current page, use `tab_new` instead.', inputSchema: { url: zod_1.z.string(), tabId: tabIdField, waitUntil: waitUntilField, failOnAuthWall: authWallField } },
85
+ { name: 'back', description: 'Go back in history.', inputSchema: { tabId: tabIdField, failOnAuthWall: authWallField } },
86
+ { name: 'forward', description: 'Go forward in history.', inputSchema: { tabId: tabIdField, failOnAuthWall: authWallField } },
87
+ { name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: { tabId: tabIdField, waitUntil: waitUntilField, failOnAuthWall: authWallField } },
88
+ { 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, failOnAuthWall: authWallField } },
89
+ { 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, failOnAuthWall: authWallField } },
90
+ { 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, failOnAuthWall: authWallField } },
91
+ { 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, failOnAuthWall: authWallField } },
90
92
  { name: 'hover', description: 'Hover over an element.', inputSchema: { ...TARGET_PROPS, ...LOCATOR_PROPS, ...FRAME_PROPS, tabId: tabIdField, snapshotAfter: snapshotAfterField } },
91
93
  { 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 } },
94
+ { name: 'screenshot', description: 'Capture a screenshot (page or element). Default is JPEG (quality 70) at CSS-pixel size, which is several times smaller than PNG and reads fine. Pass format:"png" for lossless, quality 1-100 for JPEG, scale 2 for device pixels on a Retina display or 0.5 to shrink.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, fullPage: zod_1.z.boolean().optional(), format: zod_1.z.enum(['jpeg', 'png']).optional(), quality: zod_1.z.number().optional(), scale: zod_1.z.number().optional(), tabId: tabIdField } },
93
95
  { name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, tabId: tabIdField, maxBytes: maxBytesField } },
94
96
  { 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 } },
97
+ { 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(), failOnAuthWall: authWallField, ...FRAME_PROPS, tabId: tabIdField } },
96
98
  { 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 } },
97
99
  { 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 } },
98
100
  { 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 } },
101
+ { 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, failOnAuthWall: authWallField } },
100
102
  { 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
103
  { 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 } },
104
+ { 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, failOnAuthWall: authWallField } },
103
105
  { 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 } },
104
106
  { 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
107
  {
@@ -161,6 +163,7 @@ exports.TOOL_DEFINITIONS = [
161
163
  },
162
164
  },
163
165
  { name: 'chrome_status', description: 'Report backend/session status.', inputSchema: {} },
166
+ { name: 'auth_check', description: 'Is the tab sitting on a sign-in wall? Reads the page (URL, title, password fields, sign-in controls) and returns { authRequired, confidence, signals }. Use it after a navigate, or whenever a step fails unexpectedly, to tell "the session expired" apart from "the agent got lost". Pass failOnAuthWall:true to get an [AUTH_REQUIRED] error instead of a verdict, so a harness can bucket the run as an auth failure.', inputSchema: { failOnAuthWall: authWallField, ...FRAME_PROPS, tabId: tabIdField } },
164
167
  { 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).') } },
165
168
  { 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).') } },
166
169
  { name: 'tasks_list', description: 'List every task across all profiles under the data dir, with sizes and download counts.', inputSchema: {} },
@@ -198,15 +201,15 @@ const GATE_CONTEXT = 'cannot resolve the target tab URL for the policy gate';
198
201
  *
199
202
  * Prefers a URL the backend already reported over asking again: the extension
200
203
  * rides the tab's landing URL home on every result frame, which is what keeps a
201
- * gated call to ONE round-trip instead of two. That cache only ever describes
202
- * the active tab, so it is bypassed whenever an explicit `tabId` is in play.
204
+ * gated call to ONE round-trip instead of two. The active-tab cache serves calls
205
+ * without a `tabId`; the per-tab cache (fed by results for that tab and by any
206
+ * `tabs_list`) serves explicitly-targeted ones, so a parallel batch over N tabs
207
+ * gates on the one listing that opened it rather than N more.
203
208
  */
204
209
  async function gatedUrl(ex, tabId) {
205
- if (!tabId) {
206
- const known = ex.cachedActiveUrl?.();
207
- if (known)
208
- return known;
209
- }
210
+ const known = tabId ? ex.cachedTabUrl?.(tabId) : ex.cachedActiveUrl?.();
211
+ if (known)
212
+ return known;
210
213
  let tabs;
211
214
  try {
212
215
  tabs = await ex.tabsList();
@@ -317,6 +320,51 @@ function redaction(policy) {
317
320
  return cfg;
318
321
  }
319
322
  /** The scope a tab's remembered snapshot lives under. */
323
+ /**
324
+ * Throw `[AUTH_REQUIRED]` when the caller asked for it and the page is a
325
+ * high-confidence sign-in wall. Medium-confidence verdicts never fail a call: a
326
+ * lone password field on an otherwise ordinary page is not worth aborting over.
327
+ */
328
+ function failIfAuthWall(wall, url, a, policy) {
329
+ if (!wall || wall.confidence !== 'high')
330
+ return;
331
+ if (!authGuardOn(a, policy))
332
+ return;
333
+ throw new types_1.ExecutorError('AUTH_REQUIRED', (0, auth_wall_1.describeAuthWall)(wall, url));
334
+ }
335
+ /** The guard is on for this call when the caller asked, or the server runs with `--fail-on-auth-wall`. */
336
+ function authGuardOn(a, policy) {
337
+ return (0, validators_1.optionalBoolean)(a, 'failOnAuthWall') === true || policy.failOnAuthWall === true;
338
+ }
339
+ /**
340
+ * After an action or history move: when the guard is on, look at the page the
341
+ * tab landed on and fail with `AUTH_REQUIRED` if it is a sign-in wall. Costs
342
+ * one snapshot round-trip, and only when the guard is on. The snapshot is
343
+ * remembered for this tab so a later `snapshot { diff: true }` stays coherent.
344
+ */
345
+ async function guardAuthWall(ctx, a) {
346
+ if (!authGuardOn(a, ctx.policy))
347
+ return;
348
+ const snap = await ctx.ex.snapshot({ tabId: tabId(a), interactiveOnly: true, max: 200 });
349
+ (0, snapdiff_1.rememberSnapshot)(snapScope(a), snap);
350
+ failIfAuthWall((0, auth_wall_1.detectAuthWall)(snap), snap.url, a, ctx.policy);
351
+ }
352
+ /**
353
+ * A wait that timed out on a page that has become a sign-in wall is an auth
354
+ * failure, not a slow page. With the guard on, reclassify it.
355
+ */
356
+ async function reclassifyTimeout(ctx, a, err) {
357
+ if (err instanceof types_1.ExecutorError && err.code === 'TIMEOUT' && authGuardOn(a, ctx.policy)) {
358
+ await guardAuthWall(ctx, a);
359
+ }
360
+ throw err;
361
+ }
362
+ /** The `authWall` field to spread onto a page-read result: present only when detected. */
363
+ function authWallOf(ctx, snap, a) {
364
+ const wall = (0, auth_wall_1.detectAuthWall)(snap);
365
+ failIfAuthWall(wall, snap.url, a, ctx.policy);
366
+ return wall ? { authWall: wall } : {};
367
+ }
320
368
  const snapScope = (a) => (0, snapdiff_1.scopeOf)((0, workspace_1.peekActiveWorkspace)()?.profile ?? 'default', tabId(a));
321
369
  /**
322
370
  * Render an action's result, optionally with what the action CHANGED on the
@@ -324,13 +372,20 @@ const snapScope = (a) => (0, snapdiff_1.scopeOf)((0, workspace_1.peekActiveWorks
324
372
  * re-read, which is the expensive half of every click-then-look loop.
325
373
  */
326
374
  async function actionResult(ctx, a, payload) {
327
- if ((0, validators_1.optionalBoolean)(a, 'snapshotAfter') !== true)
375
+ const wantDiff = (0, validators_1.optionalBoolean)(a, 'snapshotAfter') === true;
376
+ const guard = authGuardOn(a, ctx.policy);
377
+ if (!wantDiff && !guard)
328
378
  return (0, envelopes_1.jsonResult)(payload);
329
379
  const scope = snapScope(a);
330
380
  const previous = (0, snapdiff_1.lastSnapshot)(scope);
331
381
  const snap = await ctx.ex.snapshot({ tabId: tabId(a), max: 200, ...frameOpts(a) });
332
- const diff = (0, snapdiff_1.diffSnapshots)(previous, snap);
382
+ // One snapshot serves both: the auth guard reads it first (and aborts the
383
+ // call if the action landed on a sign-in wall), then the diff is built.
384
+ failIfAuthWall((0, auth_wall_1.detectAuthWall)(snap), snap.url, a, ctx.policy);
333
385
  const stored = (0, snapdiff_1.rememberSnapshot)(scope, snap);
386
+ if (!wantDiff)
387
+ return (0, envelopes_1.jsonResult)(payload);
388
+ const diff = (0, snapdiff_1.diffSnapshots)(previous, snap);
334
389
  return (0, envelopes_1.jsonResult)({ ...payload, changed: { ...diff, snapshotId: stored.id, url: snap.url } });
335
390
  }
336
391
  /**
@@ -403,19 +458,31 @@ exports.TOOL_HANDLERS = {
403
458
  navigate: async (a, ctx) => {
404
459
  const url = (0, validators_1.requireString)(a, 'url');
405
460
  await gate(ctx, 'navigate', { url });
406
- return (0, envelopes_1.jsonResult)(await ctx.ex.navigate({ url, tabId: tabId(a), waitUntil: waitUntil(a) }));
461
+ const nav = await ctx.ex.navigate({ url, tabId: tabId(a), waitUntil: waitUntil(a) });
462
+ if (!authGuardOn(a, ctx.policy))
463
+ return (0, envelopes_1.jsonResult)(nav);
464
+ // Guard on: the check costs one snapshot round-trip after the navigation.
465
+ const snap = await ctx.ex.snapshot({ tabId: tabId(a), interactiveOnly: true, max: 200 });
466
+ (0, snapdiff_1.rememberSnapshot)(snapScope(a), snap);
467
+ return (0, envelopes_1.jsonResult)({ ...nav, ...authWallOf(ctx, snap, a) });
407
468
  },
408
469
  back: async (a, ctx) => {
409
470
  await gate(ctx, 'back', { tabId: tabId(a) });
410
- return (0, envelopes_1.jsonResult)(await ctx.ex.back(tabId(a)));
471
+ const res = await ctx.ex.back(tabId(a));
472
+ await guardAuthWall(ctx, a);
473
+ return (0, envelopes_1.jsonResult)(res);
411
474
  },
412
475
  forward: async (a, ctx) => {
413
476
  await gate(ctx, 'forward', { tabId: tabId(a) });
414
- return (0, envelopes_1.jsonResult)(await ctx.ex.forward(tabId(a)));
477
+ const res = await ctx.ex.forward(tabId(a));
478
+ await guardAuthWall(ctx, a);
479
+ return (0, envelopes_1.jsonResult)(res);
415
480
  },
416
481
  reload: async (a, ctx) => {
417
482
  await gate(ctx, 'reload', { tabId: tabId(a) });
418
- return (0, envelopes_1.jsonResult)(await ctx.ex.reload({ tabId: tabId(a), waitUntil: waitUntil(a) }));
483
+ const res = await ctx.ex.reload({ tabId: tabId(a), waitUntil: waitUntil(a) });
484
+ await guardAuthWall(ctx, a);
485
+ return (0, envelopes_1.jsonResult)(res);
419
486
  },
420
487
  click: async (a, ctx) => {
421
488
  await gate(ctx, 'click', { tabId: tabId(a) });
@@ -453,10 +520,12 @@ exports.TOOL_HANDLERS = {
453
520
  },
454
521
  press: async (a, ctx) => {
455
522
  await gate(ctx, 'press', { tabId: tabId(a) });
456
- return (0, envelopes_1.jsonResult)(await ctx.ex.press((0, validators_1.requireString)(a, 'key'), {
523
+ const res = await ctx.ex.press((0, validators_1.requireString)(a, 'key'), {
457
524
  tabId: tabId(a),
458
525
  modifiers: (0, validators_1.optionalStringArray)(a, 'modifiers'),
459
- }));
526
+ });
527
+ await guardAuthWall(ctx, a); // Enter on a form is the classic way to land on a wall
528
+ return (0, envelopes_1.jsonResult)(res);
460
529
  },
461
530
  hover: async (a, ctx) => {
462
531
  await gate(ctx, 'hover', { tabId: tabId(a) });
@@ -478,13 +547,20 @@ exports.TOOL_HANDLERS = {
478
547
  },
479
548
  screenshot: async (a, ctx) => {
480
549
  await gate(ctx, 'screenshot', { tabId: tabId(a) });
550
+ const format = (0, validators_1.optionalString)(a, 'format');
551
+ if (format !== undefined && format !== 'jpeg' && format !== 'png') {
552
+ throw new validators_1.McpToolError('"format" must be "jpeg" or "png"');
553
+ }
481
554
  const shot = await ctx.ex.screenshot({
482
555
  tabId: tabId(a),
483
556
  ...frameOpts(a),
484
557
  fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
485
558
  target: (0, validators_1.optionalTarget)(a),
559
+ format,
560
+ quality: (0, validators_1.optionalNumber)(a, 'quality', { min: 1, max: 100 }),
561
+ scale: (0, validators_1.optionalNumber)(a, 'scale', { min: 0.1, max: 4 }),
486
562
  });
487
- (0, workspace_1.saveScreenshot)(shot.dataBase64);
563
+ (0, workspace_1.saveScreenshot)(shot.dataBase64, shot.mimeType === 'image/jpeg' ? 'jpg' : 'png');
488
564
  const caption = shot.truncated ? `(truncated; full height ${shot.fullHeight}px)` : undefined;
489
565
  return (0, envelopes_1.imageResult)(shot.dataBase64, shot.mimeType, caption);
490
566
  },
@@ -536,14 +612,16 @@ exports.TOOL_HANDLERS = {
536
612
  const scope = snapScope(a);
537
613
  const previous = (0, snapdiff_1.lastSnapshot)(scope);
538
614
  const stored = (0, snapdiff_1.rememberSnapshot)(scope, snap);
615
+ const authWall = authWallOf(ctx, snap, a);
539
616
  if ((0, validators_1.optionalBoolean)(a, 'diff') !== true) {
540
- return (0, envelopes_1.jsonResult)({ ...snap, snapshotId: stored.id });
617
+ return (0, envelopes_1.jsonResult)({ ...snap, snapshotId: stored.id, ...authWall });
541
618
  }
542
619
  const diff = (0, snapdiff_1.diffSnapshots)(previous, snap);
543
620
  return (0, envelopes_1.jsonResult)({
544
621
  url: snap.url,
545
622
  title: snap.title,
546
623
  snapshotId: stored.id,
624
+ ...authWall,
547
625
  ...diff,
548
626
  ...(diff.since === null
549
627
  ? { note: 'no previous snapshot for this tab, so everything is reported as added' }
@@ -593,14 +671,19 @@ exports.TOOL_HANDLERS = {
593
671
  },
594
672
  wait_for: async (a, ctx) => {
595
673
  await gate(ctx, 'wait_for', { tabId: tabId(a) });
596
- return (0, envelopes_1.jsonResult)(await ctx.ex.waitFor({
597
- tabId: tabId(a),
598
- ...frameOpts(a),
599
- selector: (0, validators_1.optionalString)(a, 'selector'),
600
- textContains: (0, validators_1.optionalString)(a, 'textContains'),
601
- gone: (0, validators_1.optionalBoolean)(a, 'gone'),
602
- timeoutMs: (0, validators_1.optionalNumber)(a, 'timeoutMs', { min: 0, max: 120_000 }),
603
- }));
674
+ try {
675
+ return (0, envelopes_1.jsonResult)(await ctx.ex.waitFor({
676
+ tabId: tabId(a),
677
+ ...frameOpts(a),
678
+ selector: (0, validators_1.optionalString)(a, 'selector'),
679
+ textContains: (0, validators_1.optionalString)(a, 'textContains'),
680
+ gone: (0, validators_1.optionalBoolean)(a, 'gone'),
681
+ timeoutMs: (0, validators_1.optionalNumber)(a, 'timeoutMs', { min: 0, max: 120_000 }),
682
+ }));
683
+ }
684
+ catch (err) {
685
+ return reclassifyTimeout(ctx, a, err);
686
+ }
604
687
  },
605
688
  extract_links: async (a, ctx) => {
606
689
  await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page content
@@ -643,12 +726,14 @@ exports.TOOL_HANDLERS = {
643
726
  if (typeof val === 'string')
644
727
  (0, validators_1.requireWithinLength)(val, `fields["${sel}"]`, validators_1.MAX_TEXT_LEN);
645
728
  }
646
- return (0, envelopes_1.jsonResult)(await (0, helpers_1.fillForm)(ctx.ex, {
729
+ const res = await (0, helpers_1.fillForm)(ctx.ex, {
647
730
  ...frameOpts(a),
648
731
  fields: fields,
649
732
  submitSelector: (0, validators_1.optionalString)(a, 'submitSelector'),
650
733
  tabId: tabId(a),
651
- }));
734
+ });
735
+ await guardAuthWall(ctx, a);
736
+ return (0, envelopes_1.jsonResult)(res);
652
737
  },
653
738
  download_file: async (a, ctx) => {
654
739
  await gate(ctx, 'download_file');
@@ -768,6 +853,18 @@ exports.TOOL_HANDLERS = {
768
853
  });
769
854
  },
770
855
  chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
856
+ auth_check: async (a, ctx) => {
857
+ await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page structure
858
+ const snap = await ctx.ex.snapshot({ tabId: tabId(a), ...frameOpts(a), interactiveOnly: true, max: 200 });
859
+ const wall = (0, auth_wall_1.detectAuthWall)(snap);
860
+ failIfAuthWall(wall, snap.url, a, ctx.policy);
861
+ return (0, envelopes_1.jsonResult)({
862
+ url: snap.url,
863
+ title: snap.title,
864
+ authRequired: wall !== null,
865
+ ...(wall ? { confidence: wall.confidence, signals: wall.signals } : {}),
866
+ });
867
+ },
771
868
  // --- task workspace management (server-side; no browser needed) ---
772
869
  profile_use: async (a) => {
773
870
  // Snapshots are keyed by profile+tab, and a profile switch routes to a
@@ -877,7 +974,7 @@ function recordHistory(tool, rawArgs, ok, extra = {}) {
877
974
  */
878
975
  const RETRY_SAFE_TOOLS = new Set([
879
976
  'tabs_list', 'chrome_status',
880
- 'get_text', 'get_html', 'snapshot', 'get_cookies',
977
+ 'get_text', 'get_html', 'snapshot', 'get_cookies', 'auth_check',
881
978
  'extract_links', 'read_as_markdown', 'screenshot',
882
979
  'wait_for', 'navigate', 'reload',
883
980
  'frames_list', 'print_pdf',
@@ -32,6 +32,10 @@ export interface Policy extends WirePolicy {
32
32
  redact?: boolean;
33
33
  /** Extra redaction patterns (regex sources) supplied by the operator. */
34
34
  redactPatterns?: string[];
35
+ /** `--fail-on-auth-wall`: every navigating tool and action fails with
36
+ * `AUTH_REQUIRED` when the page it lands on is a high-confidence sign-in
37
+ * wall, so an expired session is never scored as some other failure. */
38
+ failOnAuthWall?: boolean;
35
39
  }
36
40
  /** The SAFE default: deny everything until the user opts in. */
37
41
  export declare const DEFAULT_POLICY: Readonly<Policy>;
@@ -43,6 +43,7 @@ exports.DEFAULT_POLICY = Object.freeze({
43
43
  allowObservers: false,
44
44
  redact: false,
45
45
  redactPatterns: [],
46
+ failOnAuthWall: false,
46
47
  });
47
48
  /** Merge a partial (from a policy file and/or CLI flags) over the safe default. */
48
49
  function resolvePolicy(partial) {
@@ -57,6 +58,7 @@ function resolvePolicy(partial) {
57
58
  allowObservers: partial?.allowObservers ?? exports.DEFAULT_POLICY.allowObservers,
58
59
  redact: partial?.redact ?? exports.DEFAULT_POLICY.redact,
59
60
  redactPatterns: partial?.redactPatterns ?? [...(exports.DEFAULT_POLICY.redactPatterns ?? [])],
61
+ failOnAuthWall: partial?.failOnAuthWall ?? exports.DEFAULT_POLICY.failOnAuthWall,
60
62
  };
61
63
  }
62
64
  // ---------------------------------------------------------------------------
package/docs/BLUEPRINT.md CHANGED
@@ -173,7 +173,7 @@ export interface EvalResult { ok: boolean; value?: unknown; type?: string; error
173
173
  export interface WaitResult { matched: boolean; ref?: string; waitedMs: number; }
174
174
  export interface ActionOk { ok: true; }
175
175
  export interface ScreenshotResult {
176
- dataBase64: string; mimeType: 'image/png';
176
+ dataBase64: string; mimeType: 'image/png' | 'image/jpeg'; // jpeg q70 @ CSS px by default; format/quality/scale opt-in
177
177
  width: number; height: number; truncated: boolean; fullHeight?: number; // fullPage cap metadata
178
178
  }
179
179
  export interface DownloadResult { path: string; backend: BackendKind; bytes: number; mimeType?: string; suggestedName?: string; }
@@ -350,7 +350,7 @@ export type ExecutorErrorCode =
350
350
 
351
351
  ## 5. The Complete MCP Tool Surface
352
352
 
353
- 38 tools. `readOnly` is metadata (not a JSON-Schema field) consumed by the host
353
+ 39 tools. `readOnly` is metadata (not a JSON-Schema field) consumed by the host
354
354
  and by **safe-mode** (shipped in v1, default ON). Every handler:
355
355
  `withReadyExecutor()` → validate args (`requireTarget` for selector|ref) →
356
356
  **`policy.assertUrlAllowed(currentTabUrl, method)`** → call executor/helper →