chrome-bridge-mcp 1.16.1 → 1.17.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 CHANGED
@@ -40,20 +40,34 @@ element refs, `click(ref="n1")` follows with no discovery turn in between.
40
40
  For `execute_js`, enable **Allow user scripts** in `chrome://extensions` →
41
41
  Chrome Bridge → Details (on Chrome 135-137, enable Developer Mode instead).
42
42
 
43
+ **As a plugin, without a clone:** in Claude Code, `/plugin marketplace add
44
+ frsorrentino/chrome-bridge` then `/plugin install chrome-bridge@chrome-bridge`
45
+ registers the MCP server from npm (all capabilities) together with the recipes
46
+ skill; clients that read [Agent Plugins 1.0](https://agent-plugins.org) get the
47
+ same from `plugin.json` + `mcp.json`. The extension still comes from the Web
48
+ Store or `extension/`. Pick one path: the plugin and `install.sh` would
49
+ register the same server twice.
50
+
43
51
  ## Why Chrome Bridge?
44
52
 
45
53
  | | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
46
54
  |---|---|---|---|---|
47
55
  | **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
48
- | **Tools** | **59** (38 core) | ~20 | ~50 | 23 core (71 total) |
56
+ | **Tools** | **59** (38 core) | 22 | 29 default (56 with flags) | 24 core (71 total) |
49
57
  | **Requires paid plan** | **No** | Yes (Pro+) | No | No |
50
58
  | **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
51
59
  | **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
52
- | **Audits (a11y/SEO/sec)** | **Yes** (one call, report on disk) | No | Partial | No |
60
+ | **Audits (a11y/SEO/sec)** | **Yes** (one call, report on disk) | No | Lighthouse | No |
53
61
  | **Headless / CI** | **Yes** | No | Yes | Yes |
54
62
  | **GIF / video** | No | **Yes** | Partial | No |
55
63
  | **Breakpoints / heap** | No | No | **Yes** | No |
56
64
 
65
+ Codex for Chrome (OpenAI, May 2026) sits in the Claude in Chrome column: an
66
+ official extension with the `debugger` permission, macOS and Windows only,
67
+ the ChatGPT app required. Claude in Chrome documents Linux desktop since
68
+ September 2026; ChromeOS and WSL stay out. Competitor figures measured on
69
+ 2026-09-01 and 2026-09-11 (`docs/analisi-2026-09-11-concorrenti.md`).
70
+
57
71
  It wins on **round trips, not payload size**: short element refs instead of the
58
72
  screenshot-and-click loop, `fill_form` filling N fields in one call, table
59
73
  filtering done server-side. Per single turn it actually costs slightly *more*.
@@ -141,6 +155,8 @@ Environment variables, each with a matching CLI flag:
141
155
  | `CHROME_BRIDGE_HOST` / `--host` | `127.0.0.1` | `0.0.0.0` **only** where the browser lives outside the container (ChromeOS/Crostini port-forward) — and only with a token |
142
156
  | `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
143
157
  | `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
158
+ | `CHROME_BRIDGE_NO_JS` / `--no-js` | unset | No arbitrary JavaScript in the page: `execute_js` and `modify_dom` leave the schema, `wait_for(condition=function)` and `javascript:`/`data:` URLs are refused. `get_status` reports `js_evaluation` |
159
+ | `CHROME_BRIDGE_WRITE_ROOT` / `--write-root` | unset | Every path the model chooses (`save_to`, `output_path`, exports) must be under this directory, checked before the browser does any work; the server's own state under `~/.config/chrome-bridge` stays writable. The CLI is your shell and is not restricted |
144
160
 
145
161
  The bridge binds loopback, accepts extension connections only from a
146
162
  `chrome-extension://` origin, and — when a token is set — requires it on both
@@ -172,7 +188,9 @@ paste into a chat.
172
188
  ## Documentation
173
189
 
174
190
  - [docs/TOOLS.md](docs/TOOLS.md) — all 59 tools, by group
191
+ - [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — what the bridge gets past and what it does not, one dated state per wall
175
192
  - [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
193
+ - [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — latency per tool on the real path, `npm run bench:latency`
176
194
  - [bench/RESULTS.md](bench/RESULTS.md) — raw runs and inclusion rule
177
195
  - [CHANGELOG.md](CHANGELOG.md)
178
196
 
@@ -181,7 +199,9 @@ paste into a chat.
181
199
  `npm test` (Chrome-free, ~22s) · `npm run test:e2e` (needs Chrome and a
182
200
  connected extension; with a bridge already on 8765:
183
201
  `CHROME_BRIDGE_PORT=8799 node test/test-devtools.js --launch`, which opens
184
- its own Chromium with `extension/`) · `npm run measure` (schema cost).
202
+ its own Chromium with `extension/`) · `npm run measure` (schema cost) ·
203
+ `npm run bench:latency` (milliseconds per tool, launches its own Chromium,
204
+ writes `docs/PERFORMANCE.md`).
185
205
 
186
206
  ## License
187
207
 
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Chrome concede 2 captureVisibleTab al secondo per estensione; la terza entro
3
+ * il secondo fallisce con MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND. Invece di
4
+ * far vedere la quota al modello, ogni cattura aspetta il tempo che manca a
5
+ * 520 ms dalla precedente. Orologio e sleep iniettabili per i test.
6
+ */
7
+ export function createPacer({ minIntervalMs = 520, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
8
+ let last = -Infinity;
9
+ const pace = async () => {
10
+ const wait = Math.max(0, last + minIntervalMs - now());
11
+ if (wait > 0) await sleep(wait);
12
+ last = now();
13
+ return wait;
14
+ };
15
+ pace.minIntervalMs = minIntervalMs;
16
+ return pace;
17
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Impronta della pagina: pochi interi che un'azione può cambiare. Il server
3
+ * la prende prima e dopo click/fill_form e riporta solo le differenze in
4
+ * page_changed: "il menu si è aperto" senza screenshot.
5
+ *
6
+ * Autocontenuta apposta: chrome.scripting.executeScript ne serializza il
7
+ * sorgente e la esegue nella pagina, dove niente di questo modulo esiste.
8
+ * Prende `doc` per essere testabile su un DOM finto (test/unit).
9
+ */
10
+ export function pageFingerprint(doc) {
11
+ const d = doc || document;
12
+ const count = (sel) => { try { return d.querySelectorAll(sel).length; } catch { return 0; } };
13
+ let checked = 0;
14
+ try { for (const el of d.querySelectorAll('input')) if (el.checked) checked += 1; } catch { /* nessun input */ }
15
+ let focus = 'body';
16
+ const a = d.activeElement;
17
+ if (a && a.tagName) {
18
+ const tag = a.tagName.toLowerCase();
19
+ const name = a.getAttribute ? a.getAttribute('name') : null;
20
+ focus = a.id ? `${tag}#${a.id}` : (name ? `${tag}[name="${name}"]` : tag);
21
+ }
22
+ const body = d.body;
23
+ const text = body ? (typeof body.innerText === 'string' ? body.innerText : (body.textContent || '')).length : 0;
24
+ return {
25
+ url: d.location ? d.location.href : null,
26
+ title: d.title || '',
27
+ nodes: d.getElementsByTagName('*').length,
28
+ text,
29
+ open: count('[open]'),
30
+ expanded: count('[aria-expanded="true"]'),
31
+ checked,
32
+ selected: count('[aria-selected="true"], [aria-pressed="true"]'),
33
+ dialogs: count('dialog[open], [role="dialog"], [role="alertdialog"]'),
34
+ focus,
35
+ };
36
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.16.1",
4
+ "version": "1.17.0",
5
5
  "description": "Bridge your browser to Claude Code: 59 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
6
6
  "homepage_url": "https://github.com/frsorrentino/chrome-bridge",
7
7
  "minimum_chrome_version": "135",
@@ -7,6 +7,8 @@
7
7
 
8
8
  import './telemetry.js';
9
9
  import { buildMarkdown } from './lib/page-markdown.js';
10
+ import { pageFingerprint } from './lib/page-fingerprint.js';
11
+ import { createPacer } from './lib/capture-pacing.js';
10
12
  import { computeTiles } from './lib/tile-layout.js';
11
13
  import { classifyDownload } from './lib/download-state.js';
12
14
  const { pushError } = globalThis.__cbTelemetry;
@@ -448,6 +450,8 @@ async function executeCommand(msg) {
448
450
  return await cmdKeyboardWalk(params);
449
451
  case 'handoff':
450
452
  return await cmdHandoff(params);
453
+ case 'page_fingerprint':
454
+ return await cmdPageFingerprint(params);
451
455
  case 'watch':
452
456
  return await cmdWatch(params);
453
457
  case 'observe':
@@ -586,8 +590,13 @@ async function runViaFunction(target, code) {
586
590
  // Su ChromeOS una finestra totalmente occlusa/congelata può non produrre mai
587
591
  // un frame: la promise di captureVisibleTab resterebbe pendente per sempre.
588
592
  // Meglio un errore parlante dopo 10s che un comando appeso.
593
+ // Quota Chrome: 2 catture al secondo. Il pacer distanzia le chiamate di
594
+ // 520 ms invece di far comparire MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND al
595
+ // secondo screenshot ravvicinato (trovato dal bench di latenza, 2026-09-11).
596
+ const paceCapture = createPacer();
589
597
 
590
- function captureVisible(windowId, options = { format: 'png' }) {
598
+ async function captureVisible(windowId, options = { format: 'png' }) {
599
+ await paceCapture();
591
600
  return new Promise((resolve, reject) => {
592
601
  const timer = setTimeout(() => {
593
602
  reject(new Error('Capture timed out after 10s: the window is not rendering frames (fully occluded, minimized, or frozen). Bring the Chrome window at least partially on screen and retry.'));
@@ -896,13 +905,17 @@ async function cmdTypeText({ selector, text, mode = 'set', tab_id, frame_id }) {
896
905
  await sleep(10);
897
906
  }
898
907
  el.dispatchEvent(new Event('change', { bubbles: true }));
899
- return { typed: true, tagName: tag, mode: 'keys' };
908
+ const afterKeys = String(getValue() ?? '');
909
+ return { typed: true, tagName: tag, mode: 'keys', value_after: afterKeys, mismatch: !afterKeys.endsWith(txt) };
900
910
  }
901
911
 
902
912
  setValue(txt);
903
913
  el.dispatchEvent(new Event('input', { bubbles: true }));
904
914
  el.dispatchEvent(new Event('change', { bubbles: true }));
905
- return { typed: true, tagName: tag };
915
+ // Rilettura: un campo controllato che scarta il valore risponde
916
+ // «ok» uguale, e il modello non ha modo di saperlo senza questo.
917
+ const after = String(getValue() ?? '');
918
+ return { typed: true, tagName: tag, value_after: after, mismatch: after !== txt };
906
919
  },
907
920
  args: [selector, text, mode],
908
921
  world: 'MAIN',
@@ -911,6 +924,18 @@ async function cmdTypeText({ selector, text, mode = 'set', tab_id, frame_id }) {
911
924
  return results?.[0]?.result ?? { typed: true };
912
925
  }
913
926
 
927
+ // Impronta per page_changed: pochi interi letti nell'ISOLATED world (nessuna
928
+ // CSP di mezzo), prima e dopo un'azione. Vedi lib/page-fingerprint.js.
929
+ async function cmdPageFingerprint({ tab_id, frame_id }) {
930
+ const tabId = await resolveTabId(tab_id);
931
+ const results = await chrome.scripting.executeScript({
932
+ target: scriptTarget(tabId, frame_id),
933
+ func: pageFingerprint,
934
+ args: [null],
935
+ });
936
+ return results?.[0]?.result ?? null;
937
+ }
938
+
914
939
  async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
915
940
  const tabId = await resolveTabId(tab_id);
916
941
 
@@ -1879,6 +1904,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
1879
1904
  const type = (el.type || '').toLowerCase();
1880
1905
  const disabled = el.disabled;
1881
1906
  const readOnly = el.readOnly;
1907
+ let chosen = null;
1882
1908
 
1883
1909
  if (disabled || readOnly) {
1884
1910
  report.push({ selector, success: true, tagName: tag, type, warning: disabled ? 'disabled' : 'readonly' });
@@ -1891,6 +1917,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
1891
1917
  for (const opt of el.options) {
1892
1918
  if (opt.value === value || opt.textContent.trim() === value) {
1893
1919
  el.value = opt.value;
1920
+ chosen = opt.value;
1894
1921
  found = true;
1895
1922
  break;
1896
1923
  }
@@ -1916,7 +1943,17 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
1916
1943
  el.dispatchEvent(new Event('focus', { bubbles: true }));
1917
1944
  el.dispatchEvent(new Event('input', { bubbles: true }));
1918
1945
  el.dispatchEvent(new Event('change', { bubbles: true }));
1919
- report.push({ selector, success: true, tagName: tag, type: type || tag });
1946
+ // Rilettura per campo: l'effetto, non l'azione
1947
+ const entry = { selector, success: true, tagName: tag, type: type || tag };
1948
+ if (tag === 'input' && (type === 'checkbox' || type === 'radio')) {
1949
+ const want = value === 'true' || value === '1';
1950
+ entry.checked_after = el.checked; entry.mismatch = el.checked !== want;
1951
+ } else if (tag === 'select') {
1952
+ entry.value_after = el.value; entry.mismatch = el.value !== chosen;
1953
+ } else {
1954
+ entry.value_after = String(el.value ?? ''); entry.mismatch = String(el.value ?? '') !== String(value);
1955
+ }
1956
+ report.push(entry);
1920
1957
  } catch (e) {
1921
1958
  report.push({ selector, success: false, error: e.message });
1922
1959
  }
@@ -2371,7 +2408,7 @@ async function cmdWatch({ action = 'add', name, selector, text, value_of, until,
2371
2408
 
2372
2409
  const handoffs = new Map(); // tabId → { id, resolve, timer, onNav }
2373
2410
 
2374
- function handoffBanner(id, message, pick) {
2411
+ function handoffBanner(id, message, pick, ask, pickMax) {
2375
2412
  const old = document.getElementById('cb-handoff-host');
2376
2413
  if (old) old.remove();
2377
2414
  const host = document.createElement('div');
@@ -2383,11 +2420,27 @@ function handoffBanner(id, message, pick) {
2383
2420
  .bar b{color:#ffb454}.bar span{flex:1}
2384
2421
  button{font:inherit;padding:6px 14px;border:0;border-radius:6px;cursor:pointer;background:#ffb454;color:#1a1a1a;font-weight:600}
2385
2422
  button.sec{background:#444;color:#fff}
2386
- </style><div class="bar"><b>Claude</b><span></span>${pick ? '<button class="pick">Pick element</button>' : ''}<button class="done">Done</button><button class="sec cancel">Cancel</button></div>`;
2423
+ input.ask{font:inherit;padding:6px 10px;border:0;border-radius:6px;min-width:220px;background:#fff;color:#1a1a1a}
2424
+ </style><div class="bar"><b>Claude</b><span></span>${ask ? '<input class="ask" type="text" placeholder="Your reply">' : ''}${pick ? '<button class="pick">Pick element</button>' : ''}<button class="done">Done</button><button class="sec cancel">Cancel</button></div>`;
2387
2425
  root.querySelector('span').textContent = message;
2388
2426
  document.documentElement.appendChild(host);
2389
- const finish = (result) => { host.remove(); cleanupPick(); chrome.runtime.sendMessage({ type: 'cb_handoff_done', id, result }); };
2390
- root.querySelector('.done').onclick = () => finish({ done: true, action: 'done' });
2427
+ // ask: la risposta scritta; pick_max: gli elementi indicati, con contorno
2428
+ // finché il banner resta. Done chiude in entrambi i casi (Invio nella casella).
2429
+ const picks = []; const marked = [];
2430
+ const askInput = root.querySelector('input.ask');
2431
+ const answerOf = () => (askInput ? askInput.value : undefined);
2432
+ const finish = (result) => { host.remove(); cleanupPick(); for (const m of marked) m.style.outline = m.__cbOutline ?? ''; chrome.runtime.sendMessage({ type: 'cb_handoff_done', id, result }); };
2433
+ const done = () => finish(picks.length
2434
+ ? { done: true, action: 'picked', answer: answerOf(), picked: picks[0], picked_all: picks }
2435
+ : { done: true, action: 'done', answer: answerOf() });
2436
+ const addPick = (el, p) => {
2437
+ picks.push(p);
2438
+ if (!marked.includes(el)) { el.__cbOutline = el.style.outline; el.style.outline = '2px solid #ffb454'; marked.push(el); }
2439
+ if (picks.length >= pickMax) { done(); return; }
2440
+ root.querySelector('span').textContent = `${message} — ${picks.length}/${pickMax} picked, click more or Done`;
2441
+ };
2442
+ root.querySelector('.done').onclick = done;
2443
+ if (askInput) { askInput.onkeydown = (e) => { if (e.key === 'Enter') done(); }; setTimeout(() => askInput.focus(), 0); }
2391
2444
  root.querySelector('.cancel').onclick = () => finish({ done: false, action: 'cancel' });
2392
2445
  let box = null; let onMove = null; let onClick = null;
2393
2446
  function cleanupPick() {
@@ -2415,7 +2468,7 @@ function handoffBanner(id, message, pick) {
2415
2468
  };
2416
2469
  const pickBtn = root.querySelector('.pick');
2417
2470
  if (pickBtn) pickBtn.onclick = () => {
2418
- root.querySelector('span').textContent = `${message} — now click the element`;
2471
+ root.querySelector('span').textContent = `${message} — ${pickMax > 1 ? `click up to ${pickMax} elements, then Done` : 'now click the element'}`;
2419
2472
  box = document.createElement('div');
2420
2473
  box.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483646;border:2px solid #ffb454;background:rgba(255,180,84,.15);transition:all .05s';
2421
2474
  document.documentElement.appendChild(box);
@@ -2435,7 +2488,7 @@ function handoffBanner(id, message, pick) {
2435
2488
  const isToggle = el.tagName === 'INPUT' && /^(checkbox|radio)$/.test(el.type);
2436
2489
  const label = (labelFor?.textContent || el.closest('label')?.textContent || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ');
2437
2490
  const text = (isToggle ? label || el.name : (el.innerText || el.value || el.getAttribute('aria-label') || label || '')).trim().replace(/\s+/g, ' ').slice(0, 80);
2438
- finish({ done: true, action: 'picked', picked: { selector: selectorOf(el), tag: el.tagName.toLowerCase(), text, rect: { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) } } });
2491
+ addPick(el, { selector: selectorOf(el), tag: el.tagName.toLowerCase(), text, rect: { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) } });
2439
2492
  };
2440
2493
  document.addEventListener('mousemove', onMove, true);
2441
2494
  document.addEventListener('click', onClick, true);
@@ -2443,12 +2496,12 @@ function handoffBanner(id, message, pick) {
2443
2496
  return true;
2444
2497
  }
2445
2498
 
2446
- async function cmdHandoff({ message, pick_element = false, timeout = 300000, tab_id }) {
2499
+ async function cmdHandoff({ message, pick_element = false, ask = false, pick_max = 1, timeout = 300000, tab_id }) {
2447
2500
  if (!message) throw new Error('Missing required parameter: message');
2448
2501
  const tabId = await resolveTabId(tab_id);
2449
2502
  if (handoffs.has(tabId)) throw new Error('A handoff is already waiting on this tab');
2450
2503
  const id = `h${Date.now()}`;
2451
- const inject = () => chrome.scripting.executeScript({ target: { tabId }, func: handoffBanner, args: [id, String(message), !!pick_element] });
2504
+ const inject = () => chrome.scripting.executeScript({ target: { tabId }, func: handoffBanner, args: [id, String(message), !!pick_element, !!ask, Math.max(1, Number(pick_max) || 1)] });
2452
2505
  try { await inject(); } catch (err) { throw new Error(`Cannot show the handoff banner on this page (${err.message}) — chrome:// and store pages are not injectable`); }
2453
2506
  await chrome.tabs.update(tabId, { active: true }).catch(() => {});
2454
2507
  return await new Promise((resolve) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
3
  "mcpName": "io.github.frsorrentino/chrome-bridge",
4
- "version": "1.16.1",
4
+ "version": "1.17.0",
5
5
  "type": "module",
6
6
  "description": "MCP server with 59 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
7
7
  "main": "server/index.js",
@@ -15,7 +15,8 @@
15
15
  "test:unit": "node --test test/unit/*.test.js",
16
16
  "test:e2e": "node test/test-devtools.js",
17
17
  "test:e2e:launch": "CHROME_BRIDGE_PORT=8799 node test/test-devtools.js --launch",
18
- "measure": "node tools/measure-schema.mjs"
18
+ "measure": "node tools/measure-schema.mjs",
19
+ "bench:latency": "node bench/latency.mjs"
19
20
  },
20
21
  "keywords": [
21
22
  "mcp",
package/server/cli.js CHANGED
@@ -22,6 +22,7 @@ import { consoleLines, networkLines, interactivesLines, linksLines } from './for
22
22
  import { runAssert } from './assertions.js';
23
23
  import { checkLinksBatch } from './link-checker.js';
24
24
  import { evaluateSecurityHeaders } from './security-headers.js';
25
+ import { windowLayout } from './layouts.js';
25
26
  import { toHar } from './har.js';
26
27
  import { decodeTrackingRequests, trackingLines } from './trackers.js';
27
28
  import { parseRedirectCsv, checkRedirects, redirectLines } from './redirects.js';
@@ -36,7 +37,7 @@ const INTERNAL_TYPES = new Set([
36
37
  ]);
37
38
 
38
39
  // Comandi virtuali: logica lato CLI (come i corrispondenti tool MCP lato server)
39
- const VIRTUAL_COMMANDS = new Set(['status', 'check_links', 'security_headers', 'replay', 'assert', 'track', 'redirects', 'audit', 'export', 'evidence']);
40
+ const VIRTUAL_COMMANDS = new Set(['status', 'window_layout', 'check_links', 'security_headers', 'replay', 'assert', 'track', 'redirects', 'audit', 'export', 'evidence']);
40
41
 
41
42
  const ALIASES = { tabs: 'get_tabs', js: 'execute_js', console: 'read_console', network: 'monitor_network', interactives: 'get_interactives' };
42
43
 
@@ -250,6 +251,10 @@ async function run(client, command, params, opts) {
250
251
  return JSON.stringify(await runAssert(client.sendCommand, params));
251
252
  }
252
253
  // Comandi virtuali
254
+ if (command === 'window_layout') {
255
+ if (!['save', 'restore', 'list', 'delete'].includes(params.action)) throw new Error('window_layout needs --action save|restore|list|delete (and --name for all but list)');
256
+ return JSON.stringify(await windowLayout(client.sendCommand, params));
257
+ }
253
258
  if (command === 'status') {
254
259
  const tabs = await client.sendCommand(MessageType.GET_TABS);
255
260
  return `server=ok extension=connected tabs=${Array.isArray(tabs) ? tabs.length : 0}`;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Delta fra due impronte di pagina (extension/lib/page-fingerprint.js), prima
3
+ * e dopo un'azione. Solo ciò che è cambiato, con segno: null quando la pagina
4
+ * è stabile, così page_changed costa zero byte nel caso comune. Un'impronta
5
+ * parziale (estensione vecchia: solo url/title) confronta solo i campi che ha.
6
+ */
7
+
8
+ const COUNTS = ['nodes', 'text', 'open', 'expanded', 'checked', 'selected', 'dialogs'];
9
+
10
+ export function fingerprintDelta(before, after) {
11
+ if (!before || !after) return null;
12
+ const d = {};
13
+ if (before.url !== undefined && after.url !== undefined && after.url !== before.url) d.url = after.url;
14
+ if (before.title !== undefined && after.title !== undefined && after.title !== before.title) d.title = after.title;
15
+ for (const k of COUNTS) {
16
+ if (typeof before[k] !== 'number' || typeof after[k] !== 'number') continue;
17
+ const diff = after[k] - before[k];
18
+ if (diff !== 0) d[k] = `${diff > 0 ? '+' : ''}${diff}`;
19
+ }
20
+ if (typeof before.focus === 'string' && typeof after.focus === 'string' && after.focus !== before.focus) d.focus = after.focus;
21
+ return Object.keys(d).length ? d : null;
22
+ }
package/server/index.js CHANGED
@@ -31,6 +31,15 @@ const PORT = process.env.CHROME_BRIDGE_PORT
31
31
 
32
32
  // Capability: default = solo set core (30 tool). --caps audits,visual o
33
33
  // CHROME_BRIDGE_CAPS attivano i gruppi opt-in; "all" registra tutto.
34
+ // Interruttori di sicurezza (README, «Configuration and security»).
35
+ function parseSecurity() {
36
+ const on = (v) => v != null && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
37
+ return {
38
+ noJs: process.argv.includes('--no-js') || on(process.env.CHROME_BRIDGE_NO_JS),
39
+ writeRoot: argValue('--write-root') ?? process.env.CHROME_BRIDGE_WRITE_ROOT ?? null,
40
+ };
41
+ }
42
+
34
43
  function parseCaps() {
35
44
  const i = process.argv.indexOf('--caps');
36
45
  if (i !== -1 && process.argv[i + 1]) return process.argv[i + 1];
@@ -94,7 +103,7 @@ async function main() {
94
103
  }
95
104
 
96
105
  // 3. Registra i tool MCP (filtrati per capability)
97
- session.tools = registerTools(mcpServer, wsManager, parseCaps());
106
+ session.tools = registerTools(mcpServer, wsManager, parseCaps(), parseSecurity());
98
107
 
99
108
  // 4. Avvia il trasporto stdio MCP
100
109
  const transport = new StdioServerTransport();
@@ -0,0 +1,134 @@
1
+ /**
2
+ * window_layout: salvare e ripristinare disposizioni di finestre per nome.
3
+ *
4
+ * Una sola implementazione per il tool MCP (tools.js) e per la CLI (cli.js):
5
+ * prima viveva inline nel tool e la CLI non la conosceva, e `claude-master
6
+ * layout save` usciva con «Unknown command: window_layout».
7
+ *
8
+ * Tutto lato server, componendo GET_TABS(include_windows) e VIEWPORT_RESIZE:
9
+ * nessun comando nuovo verso l'estensione. Gli id delle finestre non
10
+ * sopravvivono al riavvio del browser: il ripristino riconosce le finestre
11
+ * dagli URL delle loro schede (miglior sovrapposizione, a parità di tipo).
12
+ */
13
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import { MessageType } from './protocol.js';
17
+
18
+ export const LAYOUTS_FILE = process.env.CHROME_BRIDGE_LAYOUTS_FILE
19
+ || join(homedir(), '.config', 'chrome-bridge', 'layouts.json');
20
+
21
+ /**
22
+ * @param {(type: string, params?: object) => Promise<any>} send invio di un comando all'estensione
23
+ * @param {{ action: 'save'|'restore'|'list'|'delete', name?: string }} p
24
+ * @returns {Promise<object>} dati puri (il tool MCP li serializza, la CLI li stampa)
25
+ */
26
+ export async function windowLayout(send, { action, name }) {
27
+ const readLayouts = async () => {
28
+ try { return JSON.parse(await readFile(LAYOUTS_FILE, 'utf8')); } catch { return {}; }
29
+ };
30
+
31
+ if (action === 'list') {
32
+ const all = await readLayouts();
33
+ const rows = Object.entries(all).map(([n, l]) => ({ name: n, windows: l.windows.length, savedAt: l.savedAt }));
34
+ return { layouts: rows };
35
+ }
36
+
37
+ if (!name || !/^[\w-]+$/.test(name)) throw new Error('name is required and must match [\\w-]+');
38
+
39
+ if (action === 'delete') {
40
+ const all = await readLayouts();
41
+ const existed = Boolean(all[name]);
42
+ delete all[name];
43
+ await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
44
+ await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
45
+ return { deleted: name, existed };
46
+ }
47
+
48
+ // save e restore hanno bisogno della fotografia corrente
49
+ const snap = await send(MessageType.GET_TABS, { include_windows: true });
50
+ if (Array.isArray(snap) || !snap?.windows) {
51
+ // Un'estensione < 1.14.0 risponde con il solo array di schede: meglio
52
+ // dirlo che fallire su una proprietà mancante.
53
+ throw new Error('window_layout needs extension >= 1.14.0: get_tabs returned no window geometry');
54
+ }
55
+ const urlsByWindow = new Map();
56
+ for (const t of snap.tabs || []) {
57
+ if (!urlsByWindow.has(t.windowId)) urlsByWindow.set(t.windowId, []);
58
+ urlsByWindow.get(t.windowId).push(t.url || '');
59
+ }
60
+
61
+ if (action === 'save') {
62
+ const all = await readLayouts();
63
+ all[name] = {
64
+ savedAt: new Date().toISOString(),
65
+ windows: snap.windows.map((w) => ({
66
+ type: w.type,
67
+ state: w.state,
68
+ left: w.left, top: w.top, width: w.width, height: w.height,
69
+ tabs: urlsByWindow.get(w.id) || [],
70
+ })),
71
+ };
72
+ await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
73
+ await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
74
+ return { saved: name, windows: all[name].windows.length };
75
+ }
76
+
77
+ // restore
78
+ const all = await readLayouts();
79
+ const layout = all[name];
80
+ if (!layout) {
81
+ throw new Error(`Layout "${name}" not found — saved: ${Object.keys(all).join(', ') || 'none'}`);
82
+ }
83
+
84
+ // Matching: sovrapposizione degli URL (Jaccard), a parità di tipo. Greedy
85
+ // sulla coppia migliore. Gli id non entrano mai: non sopravvivono al
86
+ // riavvio del browser.
87
+ const current = snap.windows.map((w) => ({ ...w, urls: new Set(urlsByWindow.get(w.id) || []) }));
88
+ const pairs = [];
89
+ layout.windows.forEach((savedWin, si) => {
90
+ current.forEach((cur, ci) => {
91
+ if (savedWin.type !== cur.type) return;
92
+ const savedUrls = new Set(savedWin.tabs);
93
+ let shared = 0;
94
+ for (const u of savedUrls) if (cur.urls.has(u)) shared += 1;
95
+ const union = new Set([...savedUrls, ...cur.urls]).size || 1;
96
+ const score = shared / union;
97
+ if (score > 0) pairs.push({ si, ci, score });
98
+ });
99
+ });
100
+ pairs.sort((a, b) => b.score - a.score);
101
+
102
+ const usedSaved = new Set();
103
+ const usedCurrent = new Set();
104
+ const results = [];
105
+ for (const { si, ci, score } of pairs) {
106
+ if (usedSaved.has(si) || usedCurrent.has(ci)) continue;
107
+ usedSaved.add(si); usedCurrent.add(ci);
108
+ const savedWin = layout.windows[si];
109
+ const cur = current[ci];
110
+ const anyTab = (snap.tabs || []).find((t) => t.windowId === cur.id);
111
+ if (!anyTab) { results.push({ window_id: cur.id, error: 'no tab to address the window with' }); continue; }
112
+ // Una finestra da massimizzare riceve SOLO lo stato: i bounds verrebbero
113
+ // accettati e ignorati, e il confronto richiesto/ottenuto mentirebbe.
114
+ const params = savedWin.state === 'normal'
115
+ ? { tab_id: anyTab.id, left: savedWin.left, top: savedWin.top, width: savedWin.width, height: savedWin.height, state: 'normal' }
116
+ : { tab_id: anyTab.id, state: savedWin.state };
117
+ try {
118
+ const r = await send(MessageType.VIEWPORT_RESIZE, params);
119
+ results.push({ window_id: cur.id, score: Number(score.toFixed(2)), requested: params, window: r?.window ?? r?.actual ?? null });
120
+ } catch (e) {
121
+ results.push({ window_id: cur.id, score: Number(score.toFixed(2)), error: e.message });
122
+ }
123
+ }
124
+
125
+ const unmatchedSaved = layout.windows.filter((_, i) => !usedSaved.has(i)).length;
126
+ const unmatchedCurrent = current.filter((_, i) => !usedCurrent.has(i)).map((w) => w.id);
127
+ return {
128
+ restored: name,
129
+ matched: results.length,
130
+ unmatched_saved: unmatchedSaved,
131
+ unmatched_current: unmatchedCurrent,
132
+ results,
133
+ };
134
+ }
@@ -71,6 +71,7 @@ export const MessageType = Object.freeze({
71
71
  DISMISS_OVERLAYS: 'dismiss_overlays',
72
72
  KEYBOARD_WALK: 'keyboard_walk',
73
73
  HANDOFF: 'handoff',
74
+ PAGE_FINGERPRINT: 'page_fingerprint',
74
75
  WATCH: 'watch',
75
76
  OBSERVE: 'observe',
76
77
  READ_FORM: 'read_form',
@@ -91,7 +92,7 @@ export const MessageType = Object.freeze({
91
92
  });
92
93
 
93
94
  // Versione
94
- export const VERSION = '1.16.1';
95
+ export const VERSION = '1.17.0';
95
96
 
96
97
  // Configurazione
97
98
  export const DEFAULT_PORT = 8765;
package/server/tools.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
9
9
  import { homedir } from 'node:os';
10
- import { basename, dirname, extname, join } from 'node:path';
10
+ import { basename, dirname, extname, join, resolve, sep } from 'node:path';
11
11
  import { parse as parseHtml } from 'node-html-parser';
12
12
  import { z } from 'zod';
13
13
  import { runAssert } from './assertions.js';
@@ -21,17 +21,25 @@ import { runAudit, summarizeAudit, auditReport, AUDIT_KINDS, DEFAULT_KINDS } fro
21
21
  import { toPlaywrightTest } from './playwright-export.js';
22
22
  import { createResolver } from './sourcemaps.js';
23
23
  import { evaluateSecurityHeaders } from './security-headers.js';
24
+ import { windowLayout } from './layouts.js';
25
+ import { fingerprintDelta } from './effect.js';
24
26
  import { consoleLines, networkLines, interactivesLines, linksLines } from './formatters.js';
25
27
 
26
28
  const SESSIONS_DIR = join(homedir(), '.config', 'chrome-bridge', 'sessions');
27
29
  // Sovrascrivibile nei test: i layout sono un file solo, non una directory.
28
- const LAYOUTS_FILE = process.env.CHROME_BRIDGE_LAYOUTS_FILE
29
- || join(homedir(), '.config', 'chrome-bridge', 'layouts.json');
30
30
  const RECORDINGS_DIR = process.env.CHROME_BRIDGE_RECORD_DIR || join(homedir(), '.config', 'chrome-bridge', 'recordings');
31
31
  const FIXTURES_DIR = process.env.CHROME_BRIDGE_FIXTURES_DIR || join(homedir(), '.config', 'chrome-bridge', 'fixtures');
32
32
 
33
33
  // Comandi rumore per un replay: letture interne (tabSnapshot) o senza effetto
34
- const RECORD_EXCLUDE = new Set([MessageType.GET_TABS]);
34
+ const RECORD_EXCLUDE = new Set([MessageType.GET_TABS, MessageType.PAGE_FINGERPRINT]);
35
+
36
+ // CHROME_BRIDGE_NO_JS: i tool che eseguono codice arbitrario nella pagina.
37
+ // inject_css non c'è: il CSS non esegue niente. wait_for(condition=function)
38
+ // e le URL javascript:/data: sono rifiutate nel handler, non qui.
39
+ const JS_TOOLS = new Set(['execute_js', 'modify_dom']);
40
+ // Sotto CHROME_BRIDGE_WRITE_ROOT lo stato del server resta scrivibile: non è un
41
+ // percorso scelto dal modello.
42
+ const STATE_DIRS = [join(homedir(), '.config', 'chrome-bridge'), SESSIONS_DIR, RECORDINGS_DIR, FIXTURES_DIR];
35
43
 
36
44
  // `hint` è il parametro REALE del tool chiamante che riduce i dati. Suggerire
37
45
  // max_length quando 56 tool su 59 non lo espongono mandava il modello a
@@ -328,6 +336,7 @@ export const TOOL_ANNOTATIONS = {
328
336
  * @param {import('@modelcontextprotocol/sdk/server/index.js').McpServer} server - MCP Server
329
337
  * @param {import('./ws-manager.js').WSManager} wsManager - WebSocket manager
330
338
  * @param {string} [caps='all'] - 'all', 'core', o lista di gruppi "audits,visual"
339
+ * @param {{noJs?: boolean, writeRoot?: string|null}} [options] - CHROME_BRIDGE_NO_JS / CHROME_BRIDGE_WRITE_ROOT
331
340
  */
332
341
  /** Procedura leggibile da un'osservazione: un passo per riga, i campi sensibili marcati come passo umano. */
333
342
  function observedProcedure(name, d) {
@@ -345,8 +354,20 @@ function observedProcedure(name, d) {
345
354
  return lines.join('\n') + '\n';
346
355
  }
347
356
 
348
- export function registerTools(server, wsManager, caps = 'all') {
357
+ export function registerTools(server, wsManager, caps = 'all', options = {}) {
349
358
  const startedAt = Date.now();
359
+ const noJs = Boolean(options.noJs);
360
+ const WRITE_ROOT = options.writeRoot ? resolve(String(options.writeRoot)) : null;
361
+ // Rifiuta PRIMA del comando all'estensione: niente screenshot fatto per un
362
+ // file che non verrà scritto. Il percorso torna com'è, così i chiamanti
363
+ // possono usarlo inline.
364
+ const guardWrite = (p) => {
365
+ if (!WRITE_ROOT || p == null) return p;
366
+ const abs = resolve(String(p));
367
+ const inside = (root) => abs === root || abs.startsWith(root + sep);
368
+ if (inside(WRITE_ROOT) || STATE_DIRS.some((d) => inside(resolve(d)))) return p;
369
+ throw new Error(`Refusing to write ${abs}: outside CHROME_BRIDGE_WRITE_ROOT (${WRITE_ROOT})`);
370
+ };
350
371
  const activeCaps = caps === 'all'
351
372
  ? ['core', ...Object.keys(TOOL_CAPS)]
352
373
  : ['core', ...String(caps).split(',').map((s) => s.trim()).filter((s) => s && s !== 'core')];
@@ -362,6 +383,7 @@ export function registerTools(server, wsManager, caps = 'all') {
362
383
  : null;
363
384
  server = {
364
385
  tool(name, desc, schema, handler) {
386
+ if (noJs && JS_TOOLS.has(name)) return;
365
387
  if (enabled) {
366
388
  const group = TOOL_TO_CAP.get(name);
367
389
  if (group && !enabled.has(group) && !enabled.has('all')) return;
@@ -438,8 +460,15 @@ export function registerTools(server, wsManager, caps = 'all') {
438
460
  throw new Error('Either selector or ref is required');
439
461
  }
440
462
 
441
- // Snapshot url/title del tab target, per il delta post-azione.
463
+ // Impronta della pagina per il delta post-azione (page_changed): DOM in
464
+ // pochi interi più url/title. Un'estensione vecchia o una pagina non
465
+ // iniettabile (chrome://) tornano a url/title da get_tabs: nessun errore,
466
+ // solo meno dettaglio.
442
467
  async function tabSnapshot(tab_id) {
468
+ try {
469
+ const fp = await send(MessageType.PAGE_FINGERPRINT, { tab_id });
470
+ if (fp && typeof fp === 'object') return fp;
471
+ } catch { /* fallback sotto */ }
443
472
  try {
444
473
  const tabs = await send(MessageType.GET_TABS);
445
474
  const list = Array.isArray(tabs) ? tabs : [];
@@ -449,16 +478,11 @@ export function registerTools(server, wsManager, caps = 'all') {
449
478
  } catch { return null; }
450
479
  }
451
480
 
452
- // Delta compatto dopo un'azione: presente solo se url/title sono cambiati.
453
- // Costa pochi token quando scatta, zero quando la pagina è stabile, e
454
- // risparmia al client un giro di ispezione per capire "cosa è successo".
455
- function pageDelta(before, after) {
456
- if (!before || !after) return null;
457
- const delta = {};
458
- if (after.url !== before.url) delta.url = after.url;
459
- if (after.title !== before.title) delta.title = after.title;
460
- return Object.keys(delta).length ? delta : null;
461
- }
481
+ // Delta compatto dopo un'azione: solo ciò che è cambiato (url, title, conteggi
482
+ // DOM con segno, fuoco). Costa pochi token quando scatta, zero quando la
483
+ // pagina è stabile, e risparmia al client lo screenshot per capire "cosa è
484
+ // successo".
485
+ const pageDelta = fingerprintDelta;
462
486
 
463
487
  // Anteprima compatta dei primi interactives (con ref), allegata a navigate:
464
488
  // il client può agire subito senza un giro di discovery. Cappata e best-effort.
@@ -493,6 +517,8 @@ export function registerTools(server, wsManager, caps = 'all') {
493
517
  extension_version: wsManager.extVersion ?? null,
494
518
  // Un agente che non trova accessibility_audit non aveva modo di
495
519
  // scoprire che esiste ma è in un gruppo disattivato.
520
+ js_evaluation: !noJs,
521
+ write_root: WRITE_ROOT,
496
522
  caps_active: activeCaps,
497
523
  caps_available: ['core', ...Object.keys(TOOL_CAPS)],
498
524
  session_tab_id: sessionTabId,
@@ -535,6 +561,9 @@ export function registerTools(server, wsManager, caps = 'all') {
535
561
  tab_id: tabId,
536
562
  },
537
563
  async ({ url, tab_id }) => {
564
+ if (noJs && /^\s*(javascript|data|vbscript):/i.test(url)) {
565
+ throw new Error(`Refusing to navigate to ${url.slice(0, 40)}: JavaScript evaluation is disabled (CHROME_BRIDGE_NO_JS)`);
566
+ }
538
567
  const data = await send(MessageType.NAVIGATE, { url, tab_id });
539
568
  // Il tab navigato diventa il default di sessione per i comandi successivi
540
569
  if (data?.tabId != null) sessionTabId = data.tabId;
@@ -560,6 +589,7 @@ export function registerTools(server, wsManager, caps = 'all') {
560
589
  presets: z.array(z.enum(['mobile', 'tablet', 'desktop'])).optional().describe('One capture per preset, window restored; save_to = directory, one file each'),
561
590
  },
562
591
  async ({ tab_id, save_to, presets }) => {
592
+ guardWrite(save_to);
563
593
  // Matrice responsive: viewport_resize + screenshot per preset erano 2N
564
594
  // turni; qui è una chiamata, e la finestra torna com'era.
565
595
  if (presets?.length) {
@@ -637,7 +667,8 @@ export function registerTools(server, wsManager, caps = 'all') {
637
667
  server.tool(
638
668
  'click',
639
669
  'Click an element by CSS selector or by a ref (n1, n2…) from get_interactives or navigate. A real pointer sequence: it can submit, '
640
- + 'open a dialog or navigate — use wait_after. Not idempotent; a native confirm() blocks the bridge: handle_dialogs first.',
670
+ + 'open a dialog or navigate — use wait_after. Not idempotent; a native confirm() blocks the bridge: handle_dialogs first. '
671
+ + 'Returns page_changed (url, title and DOM deltas: nodes, text, open, expanded, checked, dialogs, focus): the effect, without a screenshot.',
641
672
  {
642
673
  selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
643
674
  ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
@@ -654,6 +685,9 @@ export function registerTools(server, wsManager, caps = 'all') {
654
685
  const data = await send(MessageType.CLICK, { selector: target, force, button: button ?? 'left', count: count ?? 1, frame_id, tab_id });
655
686
  // Niente attesa se il click non è andato a buon fine (es. elemento occluso)
656
687
  const waited = data?.occluded ? null : await applyWaitAfter(send, wait_after, tab_id);
688
+ // Senza wait_after un breve settle: i framework aggiornano il DOM dopo il
689
+ // click, non dentro; senza, l'impronta "dopo" vedrebbe quella "prima".
690
+ if (!data?.occluded && (wait_after ?? 'none') === 'none') await new Promise((r) => setTimeout(r, 150));
657
691
  const changed = data?.occluded ? null : pageDelta(before, await tabSnapshot(tab_id));
658
692
  const out = { ...data, ...(waited && { wait_after: waited }), ...(changed && { page_changed: changed }) };
659
693
  return {
@@ -670,7 +704,8 @@ export function registerTools(server, wsManager, caps = 'all') {
670
704
  'type_text',
671
705
  'Put text into an input, textarea or contenteditable, by selector or ref. Replaces the whole value through the '
672
706
  + 'native setter (React/Vue controlled inputs register it) and fires input and change. mode=keys emits '
673
- + 'keydown/input/keyup per character for autocomplete and masked fields: slower, use it only when mode=set leaves the field empty.',
707
+ + 'keydown/input/keyup per character for autocomplete and masked fields: slower, use it only when mode=set leaves the field empty. '
708
+ + 'Returns value_after; mismatch=true means the field did not keep the value: retry with mode=keys.',
674
709
  {
675
710
  selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
676
711
  ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
@@ -685,10 +720,13 @@ export function registerTools(server, wsManager, caps = 'all') {
685
720
  const data = await send(MessageType.TYPE_TEXT, { selector: target, text, mode, tab_id, frame_id });
686
721
  const waited = await applyWaitAfter(send, wait_after, tab_id);
687
722
  const out = waited ? { ...data, wait_after: waited } : data;
723
+ const warn = data?.mismatch
724
+ ? `\nthe field did not keep the value (value_after=${JSON.stringify(data.value_after ?? null)}): retry with mode=keys; if it still differs, the page rewrites it`
725
+ : '';
688
726
  return {
689
727
  content: [{
690
728
  type: 'text',
691
- text: jsonText(out),
729
+ text: jsonText(out) + warn,
692
730
  }],
693
731
  };
694
732
  }
@@ -708,6 +746,7 @@ export function registerTools(server, wsManager, caps = 'all') {
708
746
  save_to: saveToField('the page'),
709
747
  },
710
748
  async ({ mode, tab_id, frame_id, max_length, save_to }) => {
749
+ guardWrite(save_to);
711
750
  const data = await send(MessageType.READ_PAGE, { mode, tab_id, frame_id });
712
751
  const text = typeof data === 'string' ? data : JSON.stringify(data);
713
752
  if (save_to) return savedSummary(save_to, Buffer.from(text, 'utf8'), { mode: mode ?? 'text' });
@@ -963,6 +1002,7 @@ export function registerTools(server, wsManager, caps = 'all') {
963
1002
  text, selector, timeout: timeout ?? 10000, interval: interval ?? 200, tab_id, frame_id,
964
1003
  });
965
1004
  } else if (condition === 'function') {
1005
+ if (noJs) throw new Error('wait_for condition=function evaluates JavaScript, which is disabled (CHROME_BRIDGE_NO_JS): use condition=element or text');
966
1006
  data = await send(MessageType.WAIT_FOR_FUNCTION, {
967
1007
  expression, timeout: timeout ?? 10000, polling_ms: interval ?? 100, tab_id, frame_id,
968
1008
  });
@@ -1039,7 +1079,8 @@ export function registerTools(server, wsManager, caps = 'all') {
1039
1079
  server.tool(
1040
1080
  'fill_form',
1041
1081
  'Batch fill form fields with React-compatible events. Handles input, select, checkbox, radio, and textarea. '
1042
- + 'With submit_selector it also submits, so a repeated call submits twice — not safe to retry blindly.',
1082
+ + 'With submit_selector it also submits, so a repeated call submits twice — not safe to retry blindly. '
1083
+ + 'Each field reports value_after and mismatch; page_changed carries the DOM delta.',
1043
1084
  {
1044
1085
  fields: z.array(z.object({
1045
1086
  selector: z.string(),
@@ -1056,10 +1097,14 @@ export function registerTools(server, wsManager, caps = 'all') {
1056
1097
  const waited = await applyWaitAfter(send, wait_after, tab_id);
1057
1098
  const changed = pageDelta(before, await tabSnapshot(tab_id));
1058
1099
  const out = { ...data, ...(waited && { wait_after: waited }), ...(changed && { page_changed: changed }) };
1100
+ // I campi che non hanno tenuto il valore in testa, in chiaro: sono la
1101
+ // riga che il modello deve leggere prima del JSON.
1102
+ const mism = (data?.fields ?? []).filter((f) => f && f.mismatch);
1103
+ const head = mism.length ? mism.map((f) => `mismatch: ${f.selector} (${'checked_after' in f ? `checked_after=${f.checked_after}` : `value_after=${JSON.stringify(f.value_after ?? null)}`})`).join('\n') + '\n' : '';
1059
1104
  return {
1060
1105
  content: [{
1061
1106
  type: 'text',
1062
- text: jsonText(out),
1107
+ text: head + jsonText(out),
1063
1108
  }],
1064
1109
  };
1065
1110
  }
@@ -1615,18 +1660,25 @@ export function registerTools(server, wsManager, caps = 'all') {
1615
1660
  'handoff',
1616
1661
  'Hand the browser to the user for what only a person can do — 2FA, CAPTCHA, login, a choice — and wait: a banner in the page shows your '
1617
1662
  + 'message with Done/Cancel, the call returns on click or timeout, redirects included. pick_element: the user clicks an element and you get '
1618
- + 'its selector. Never type credentials yourself.',
1663
+ + 'its selector. Never type credentials yourself. ask: the user types a reply in the banner ("which of the three?") and you get it as answer. '
1664
+ + 'pick_max: with pick_element, up to N elements; the user presses Done when finished.',
1619
1665
  {
1620
1666
  message: z.string().describe('What the user should do, one line'),
1621
1667
  pick_element: z.boolean().optional().default(false).describe('Ask the user to click an element; returns its selector, text and box'),
1668
+ ask: z.boolean().optional().default(false).describe('Show a text box in the banner; the reply comes back as answer'),
1669
+ pick_max: z.number().optional().default(1).describe('With pick_element: up to N elements, the user presses Done when finished'),
1622
1670
  timeout: z.number().optional().default(300000).describe('ms to wait for the click, default 5 min'),
1623
1671
  tab_id: tabId,
1624
1672
  },
1625
- async ({ message, pick_element, timeout, tab_id }) => {
1626
- const d = await send(MessageType.HANDOFF, { message, pick_element, timeout, tab_id });
1673
+ async ({ message, pick_element, ask, pick_max, timeout, tab_id }) => {
1674
+ const d = await send(MessageType.HANDOFF, { message, pick_element, ask, pick_max, timeout, tab_id });
1627
1675
  const lines = [`handoff ${d.action}${d.url ? ` url=${d.url}` : ''}`];
1628
- if (d.picked) {
1629
- lines.push(`picked ${d.picked.selector}\t${d.picked.tag}\t${d.picked.text}\t@${d.picked.rect.x},${d.picked.rect.y} ${d.picked.rect.width}x${d.picked.rect.height}`);
1676
+ if (ask && typeof d.answer === 'string' && d.answer.trim()) lines.push(`answer: ${d.answer.trim()}`);
1677
+ const fmt = (p) => `${p.selector}\t${p.tag}\t${p.text}\t@${p.rect.x},${p.rect.y} ${p.rect.width}x${p.rect.height}`;
1678
+ if (Array.isArray(d.picked_all) && d.picked_all.length) {
1679
+ d.picked_all.forEach((p, i) => lines.push(`picked ${i + 1}/${d.picked_all.length} ${fmt(p)}`));
1680
+ } else if (d.picked) {
1681
+ lines.push(`picked ${fmt(d.picked)}`);
1630
1682
  }
1631
1683
  if (d.action === 'timeout') lines.push('the user did not click within the timeout: ask before retrying');
1632
1684
  return { content: [{ type: 'text', text: lines.join('\n') }] };
@@ -1697,6 +1749,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1697
1749
  tab_id: tabId,
1698
1750
  },
1699
1751
  async ({ kinds, save_to, scope, max_links, tab_id }) => {
1752
+ guardWrite(save_to);
1700
1753
  const info = await send(MessageType.GET_PAGE_INFO, { tab_id });
1701
1754
  const results = await runAudit(send, { kinds, scope, max_links, tab_id });
1702
1755
  const summary = summarizeAudit(results);
@@ -1866,6 +1919,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1866
1919
  tab_id: tabId,
1867
1920
  },
1868
1921
  async ({ output_path, tab_id }) => {
1922
+ guardWrite(output_path);
1869
1923
  const data = await send(MessageType.SAVE_PAGE, { tab_id });
1870
1924
  await writeFile(output_path, Buffer.from(data.mhtml_b64, 'base64'));
1871
1925
  return { content: [{ type: 'text', text: jsonText({ saved: output_path, size: data.size }) }] };
@@ -1887,6 +1941,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1887
1941
  max_length: z.number().optional().default(DEFAULT_MAX_OUTPUT).describe('Max chars of body returned inline'),
1888
1942
  },
1889
1943
  async ({ url, method, headers, body, save_to, max_length }) => {
1944
+ guardWrite(save_to);
1890
1945
  const data = await send(MessageType.HTTP_REQUEST, { url, method, headers, body, binary: Boolean(save_to) });
1891
1946
 
1892
1947
  if (save_to) {
@@ -1988,120 +2043,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1988
2043
  action: z.enum(['save', 'restore', 'list', 'delete']).describe('save snapshots every window; restore repositions the recognised ones'),
1989
2044
  name: z.string().optional().describe('Layout name, required except for list'),
1990
2045
  },
1991
- async ({ action, name }) => {
1992
- const readLayouts = async () => {
1993
- try { return JSON.parse(await readFile(LAYOUTS_FILE, 'utf8')); } catch { return {}; }
1994
- };
1995
-
1996
- if (action === 'list') {
1997
- const all = await readLayouts();
1998
- const rows = Object.entries(all).map(([n, l]) => ({ name: n, windows: l.windows.length, savedAt: l.savedAt }));
1999
- return { content: [{ type: 'text', text: jsonText({ layouts: rows }) }] };
2000
- }
2001
-
2002
- if (!name || !/^[\w-]+$/.test(name)) throw new Error('name is required and must match [\\w-]+');
2003
-
2004
- if (action === 'delete') {
2005
- const all = await readLayouts();
2006
- const existed = Boolean(all[name]);
2007
- delete all[name];
2008
- await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
2009
- await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
2010
- return { content: [{ type: 'text', text: jsonText({ deleted: name, existed }) }] };
2011
- }
2012
-
2013
- // save e restore hanno bisogno della fotografia corrente
2014
- const snap = await send(MessageType.GET_TABS, { include_windows: true });
2015
- if (Array.isArray(snap) || !snap?.windows) {
2016
- // Un'estensione < 1.14.0 risponde con il solo array di schede: meglio
2017
- // dirlo che fallire su una proprietà mancante.
2018
- throw new Error('window_layout needs extension >= 1.14.0: get_tabs returned no window geometry');
2019
- }
2020
- const urlsByWindow = new Map();
2021
- for (const t of snap.tabs || []) {
2022
- if (!urlsByWindow.has(t.windowId)) urlsByWindow.set(t.windowId, []);
2023
- urlsByWindow.get(t.windowId).push(t.url || '');
2024
- }
2025
-
2026
- if (action === 'save') {
2027
- const all = await readLayouts();
2028
- all[name] = {
2029
- savedAt: new Date().toISOString(),
2030
- windows: snap.windows.map((w) => ({
2031
- type: w.type,
2032
- state: w.state,
2033
- left: w.left, top: w.top, width: w.width, height: w.height,
2034
- tabs: urlsByWindow.get(w.id) || [],
2035
- })),
2036
- };
2037
- await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
2038
- await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
2039
- return { content: [{ type: 'text', text: jsonText({ saved: name, windows: all[name].windows.length }) }] };
2040
- }
2041
-
2042
- // restore
2043
- const all = await readLayouts();
2044
- const layout = all[name];
2045
- if (!layout) {
2046
- throw new Error(`Layout "${name}" not found — saved: ${Object.keys(all).join(', ') || 'none'}`);
2047
- }
2048
-
2049
- // Matching: sovrapposizione degli URL (Jaccard), a parità di tipo. Greedy
2050
- // sulla coppia migliore. Gli id non entrano mai: non sopravvivono al
2051
- // riavvio del browser.
2052
- const current = snap.windows.map((w) => ({ ...w, urls: new Set(urlsByWindow.get(w.id) || []) }));
2053
- const pairs = [];
2054
- layout.windows.forEach((savedWin, si) => {
2055
- current.forEach((cur, ci) => {
2056
- if (savedWin.type !== cur.type) return;
2057
- const savedUrls = new Set(savedWin.tabs);
2058
- let shared = 0;
2059
- for (const u of savedUrls) if (cur.urls.has(u)) shared += 1;
2060
- const union = new Set([...savedUrls, ...cur.urls]).size || 1;
2061
- const score = shared / union;
2062
- if (score > 0) pairs.push({ si, ci, score });
2063
- });
2064
- });
2065
- pairs.sort((a, b) => b.score - a.score);
2066
-
2067
- const usedSaved = new Set();
2068
- const usedCurrent = new Set();
2069
- const results = [];
2070
- for (const { si, ci, score } of pairs) {
2071
- if (usedSaved.has(si) || usedCurrent.has(ci)) continue;
2072
- usedSaved.add(si); usedCurrent.add(ci);
2073
- const savedWin = layout.windows[si];
2074
- const cur = current[ci];
2075
- const anyTab = (snap.tabs || []).find((t) => t.windowId === cur.id);
2076
- if (!anyTab) { results.push({ window_id: cur.id, error: 'no tab to address the window with' }); continue; }
2077
- // Una finestra da massimizzare riceve SOLO lo stato: i bounds verrebbero
2078
- // accettati e ignorati, e il confronto richiesto/ottenuto mentirebbe.
2079
- const params = savedWin.state === 'normal'
2080
- ? { tab_id: anyTab.id, left: savedWin.left, top: savedWin.top, width: savedWin.width, height: savedWin.height, state: 'normal' }
2081
- : { tab_id: anyTab.id, state: savedWin.state };
2082
- try {
2083
- const r = await send(MessageType.VIEWPORT_RESIZE, params);
2084
- results.push({ window_id: cur.id, score: Number(score.toFixed(2)), requested: params, window: r?.window ?? r?.actual ?? null });
2085
- } catch (e) {
2086
- results.push({ window_id: cur.id, score: Number(score.toFixed(2)), error: e.message });
2087
- }
2088
- }
2089
-
2090
- const unmatchedSaved = layout.windows.filter((_, i) => !usedSaved.has(i)).length;
2091
- const unmatchedCurrent = current.filter((_, i) => !usedCurrent.has(i)).map((w) => w.id);
2092
- return {
2093
- content: [{
2094
- type: 'text',
2095
- text: jsonText({
2096
- restored: name,
2097
- matched: results.length,
2098
- unmatched_saved: unmatchedSaved,
2099
- unmatched_current: unmatchedCurrent,
2100
- results,
2101
- }),
2102
- }],
2103
- };
2104
- }
2046
+ async ({ action, name }) => ({ content: [{ type: 'text', text: jsonText(await windowLayout(send, { action, name })) }] })
2105
2047
  );
2106
2048
 
2107
2049
  // --- http_auth ---
@@ -2246,6 +2188,7 @@ export function registerTools(server, wsManager, caps = 'all') {
2246
2188
  save_to: saveToField('the records as JSON'),
2247
2189
  },
2248
2190
  async ({ item_selector, fields, max_items, format, tab_id, max_length , save_to }) => {
2191
+ guardWrite(save_to);
2249
2192
  max_length = max_length ?? DEFAULT_MAX_OUTPUT;
2250
2193
  const html = await send(MessageType.READ_PAGE, { mode: 'html', tab_id });
2251
2194
  if (typeof html !== 'string') throw new Error('Could not read page HTML');
@@ -2314,6 +2257,8 @@ export function registerTools(server, wsManager, caps = 'all') {
2314
2257
  tab_id: tabId,
2315
2258
  },
2316
2259
  async ({ action, name, save_to, values, tab_id }) => {
2260
+ guardWrite(save_to);
2261
+ if (name && name.endsWith('.jsonl')) guardWrite(name);
2317
2262
  // "Guarda come faccio": la procedura la esegue l'umano nel suo browser,
2318
2263
  // noi la trascriviamo — mai i valori dei campi sensibili — nello stesso
2319
2264
  // jsonl che replay esegue, più un .md leggibile con i passi umani marcati.