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 +23 -3
- package/extension/lib/capture-pacing.js +17 -0
- package/extension/lib/page-fingerprint.js +36 -0
- package/extension/manifest.json +1 -1
- package/extension/service-worker.js +65 -12
- package/package.json +3 -2
- package/server/cli.js +6 -1
- package/server/effect.js +22 -0
- package/server/index.js +10 -1
- package/server/layouts.js +134 -0
- package/server/protocol.js +2 -1
- package/server/tools.js +85 -140
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) |
|
|
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 |
|
|
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
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2390
|
-
|
|
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
|
-
|
|
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.
|
|
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}`;
|
package/server/effect.js
ADDED
|
@@ -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
|
+
}
|
package/server/protocol.js
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
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:
|
|
453
|
-
// Costa pochi token quando scatta, zero quando la
|
|
454
|
-
// risparmia al client
|
|
455
|
-
|
|
456
|
-
|
|
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.
|
|
1629
|
-
|
|
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.
|