chrome-bridge-mcp 1.16.1 → 1.18.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 +100 -17
- package/package.json +3 -2
- package/server/cli.js +11 -3
- 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 +152 -157
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.18.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.'));
|
|
@@ -674,7 +683,7 @@ async function bitmapToBase64Capped(bitmap, maxSide = MAX_IMAGE_SIDE) {
|
|
|
674
683
|
|
|
675
684
|
// --- Implementazione comandi ---
|
|
676
685
|
|
|
677
|
-
async function cmdGetTabs({ include_windows = false } = {}) {
|
|
686
|
+
async function cmdGetTabs({ include_windows = false, ended } = {}) {
|
|
678
687
|
const tabs = await chrome.tabs.query({});
|
|
679
688
|
const list = tabs.map((t) => ({
|
|
680
689
|
id: t.id,
|
|
@@ -682,8 +691,17 @@ async function cmdGetTabs({ include_windows = false } = {}) {
|
|
|
682
691
|
title: t.title,
|
|
683
692
|
active: t.active,
|
|
684
693
|
windowId: t.windowId,
|
|
694
|
+
// Una scheda scartata da Chrome (memory saver) esiste ancora ma non ha un
|
|
695
|
+
// renderer: execute_js e compagni falliscono finché non torna in primo piano.
|
|
696
|
+
...(t.discarded ? { discarded: true } : {}),
|
|
685
697
|
}));
|
|
686
|
-
|
|
698
|
+
// ended = id che il server sa suoi e non trova più: la forma ad array resta
|
|
699
|
+
// quella di sempre per chi non lo chiede.
|
|
700
|
+
const open = new Set(list.map((t) => t.id));
|
|
701
|
+
const endings = Array.isArray(ended) && ended.length
|
|
702
|
+
? Object.fromEntries(ended.filter((id) => !open.has(id)).map((id) => [id, tabEndings.get(id) ?? { reason: 'unknown' }]))
|
|
703
|
+
: null;
|
|
704
|
+
if (!include_windows) return endings ? { tabs: list, ended: endings } : list;
|
|
687
705
|
|
|
688
706
|
// Senza geometria e stato non si può decidere dove mettere una finestra: si
|
|
689
707
|
// può solo indovinare. `left` in particolare è ciò che identifica il monitor
|
|
@@ -703,7 +721,7 @@ async function cmdGetTabs({ include_windows = false } = {}) {
|
|
|
703
721
|
// provarci.
|
|
704
722
|
scriptable: (w.tabs || []).some((t) => /^(https?|file):/i.test(t.url || '')),
|
|
705
723
|
}));
|
|
706
|
-
return { tabs: list, windows };
|
|
724
|
+
return endings ? { tabs: list, windows, ended: endings } : { tabs: list, windows };
|
|
707
725
|
}
|
|
708
726
|
|
|
709
727
|
async function cmdNavigate({ url, tab_id }) {
|
|
@@ -896,13 +914,17 @@ async function cmdTypeText({ selector, text, mode = 'set', tab_id, frame_id }) {
|
|
|
896
914
|
await sleep(10);
|
|
897
915
|
}
|
|
898
916
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
899
|
-
|
|
917
|
+
const afterKeys = String(getValue() ?? '');
|
|
918
|
+
return { typed: true, tagName: tag, mode: 'keys', value_after: afterKeys, mismatch: !afterKeys.endsWith(txt) };
|
|
900
919
|
}
|
|
901
920
|
|
|
902
921
|
setValue(txt);
|
|
903
922
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
904
923
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
905
|
-
|
|
924
|
+
// Rilettura: un campo controllato che scarta il valore risponde
|
|
925
|
+
// «ok» uguale, e il modello non ha modo di saperlo senza questo.
|
|
926
|
+
const after = String(getValue() ?? '');
|
|
927
|
+
return { typed: true, tagName: tag, value_after: after, mismatch: after !== txt };
|
|
906
928
|
},
|
|
907
929
|
args: [selector, text, mode],
|
|
908
930
|
world: 'MAIN',
|
|
@@ -911,6 +933,18 @@ async function cmdTypeText({ selector, text, mode = 'set', tab_id, frame_id }) {
|
|
|
911
933
|
return results?.[0]?.result ?? { typed: true };
|
|
912
934
|
}
|
|
913
935
|
|
|
936
|
+
// Impronta per page_changed: pochi interi letti nell'ISOLATED world (nessuna
|
|
937
|
+
// CSP di mezzo), prima e dopo un'azione. Vedi lib/page-fingerprint.js.
|
|
938
|
+
async function cmdPageFingerprint({ tab_id, frame_id }) {
|
|
939
|
+
const tabId = await resolveTabId(tab_id);
|
|
940
|
+
const results = await chrome.scripting.executeScript({
|
|
941
|
+
target: scriptTarget(tabId, frame_id),
|
|
942
|
+
func: pageFingerprint,
|
|
943
|
+
args: [null],
|
|
944
|
+
});
|
|
945
|
+
return results?.[0]?.result ?? null;
|
|
946
|
+
}
|
|
947
|
+
|
|
914
948
|
async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
|
|
915
949
|
const tabId = await resolveTabId(tab_id);
|
|
916
950
|
|
|
@@ -1164,12 +1198,26 @@ async function cmdMoveTab({ tab_id, window_id, new_window = false, window_type,
|
|
|
1164
1198
|
};
|
|
1165
1199
|
}
|
|
1166
1200
|
|
|
1201
|
+
// Perché una scheda non c'è più: «No tab with id» da solo non distingue una
|
|
1202
|
+
// chiusura a mano da una sostituzione. In memoria e limitato: se il service
|
|
1203
|
+
// worker riparte il registro si svuota e il motivo torna 'unknown'.
|
|
1204
|
+
const tabEndings = new Map();
|
|
1205
|
+
const TAB_ENDINGS_MAX = 200;
|
|
1206
|
+
const bridgeClosing = new Set();
|
|
1207
|
+
|
|
1208
|
+
function recordTabEnding(tabId, ending) {
|
|
1209
|
+
if (bridgeClosing.delete(tabId) && ending.reason === 'closed') ending = { reason: 'closed_by_bridge' };
|
|
1210
|
+
tabEndings.set(tabId, { ...ending, at: new Date().toISOString() });
|
|
1211
|
+
if (tabEndings.size > TAB_ENDINGS_MAX) tabEndings.delete(tabEndings.keys().next().value);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1167
1214
|
async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
|
|
1168
1215
|
if (!action) throw new Error('Missing required parameter: action');
|
|
1169
1216
|
const tabId = await resolveTabId(tab_id);
|
|
1170
1217
|
|
|
1171
1218
|
if (action === 'close') {
|
|
1172
|
-
|
|
1219
|
+
bridgeClosing.add(tabId);
|
|
1220
|
+
try { await chrome.tabs.remove(tabId); } catch (e) { bridgeClosing.delete(tabId); throw e; }
|
|
1173
1221
|
return { action, closed: tabId };
|
|
1174
1222
|
}
|
|
1175
1223
|
if (action === 'discard') {
|
|
@@ -1879,6 +1927,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
|
|
|
1879
1927
|
const type = (el.type || '').toLowerCase();
|
|
1880
1928
|
const disabled = el.disabled;
|
|
1881
1929
|
const readOnly = el.readOnly;
|
|
1930
|
+
let chosen = null;
|
|
1882
1931
|
|
|
1883
1932
|
if (disabled || readOnly) {
|
|
1884
1933
|
report.push({ selector, success: true, tagName: tag, type, warning: disabled ? 'disabled' : 'readonly' });
|
|
@@ -1891,6 +1940,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
|
|
|
1891
1940
|
for (const opt of el.options) {
|
|
1892
1941
|
if (opt.value === value || opt.textContent.trim() === value) {
|
|
1893
1942
|
el.value = opt.value;
|
|
1943
|
+
chosen = opt.value;
|
|
1894
1944
|
found = true;
|
|
1895
1945
|
break;
|
|
1896
1946
|
}
|
|
@@ -1916,7 +1966,17 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
|
|
|
1916
1966
|
el.dispatchEvent(new Event('focus', { bubbles: true }));
|
|
1917
1967
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1918
1968
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1919
|
-
|
|
1969
|
+
// Rilettura per campo: l'effetto, non l'azione
|
|
1970
|
+
const entry = { selector, success: true, tagName: tag, type: type || tag };
|
|
1971
|
+
if (tag === 'input' && (type === 'checkbox' || type === 'radio')) {
|
|
1972
|
+
const want = value === 'true' || value === '1';
|
|
1973
|
+
entry.checked_after = el.checked; entry.mismatch = el.checked !== want;
|
|
1974
|
+
} else if (tag === 'select') {
|
|
1975
|
+
entry.value_after = el.value; entry.mismatch = el.value !== chosen;
|
|
1976
|
+
} else {
|
|
1977
|
+
entry.value_after = String(el.value ?? ''); entry.mismatch = String(el.value ?? '') !== String(value);
|
|
1978
|
+
}
|
|
1979
|
+
report.push(entry);
|
|
1920
1980
|
} catch (e) {
|
|
1921
1981
|
report.push({ selector, success: false, error: e.message });
|
|
1922
1982
|
}
|
|
@@ -2371,7 +2431,7 @@ async function cmdWatch({ action = 'add', name, selector, text, value_of, until,
|
|
|
2371
2431
|
|
|
2372
2432
|
const handoffs = new Map(); // tabId → { id, resolve, timer, onNav }
|
|
2373
2433
|
|
|
2374
|
-
function handoffBanner(id, message, pick) {
|
|
2434
|
+
function handoffBanner(id, message, pick, ask, pickMax) {
|
|
2375
2435
|
const old = document.getElementById('cb-handoff-host');
|
|
2376
2436
|
if (old) old.remove();
|
|
2377
2437
|
const host = document.createElement('div');
|
|
@@ -2383,11 +2443,27 @@ function handoffBanner(id, message, pick) {
|
|
|
2383
2443
|
.bar b{color:#ffb454}.bar span{flex:1}
|
|
2384
2444
|
button{font:inherit;padding:6px 14px;border:0;border-radius:6px;cursor:pointer;background:#ffb454;color:#1a1a1a;font-weight:600}
|
|
2385
2445
|
button.sec{background:#444;color:#fff}
|
|
2386
|
-
|
|
2446
|
+
input.ask{font:inherit;padding:6px 10px;border:0;border-radius:6px;min-width:220px;background:#fff;color:#1a1a1a}
|
|
2447
|
+
</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
2448
|
root.querySelector('span').textContent = message;
|
|
2388
2449
|
document.documentElement.appendChild(host);
|
|
2389
|
-
|
|
2390
|
-
|
|
2450
|
+
// ask: la risposta scritta; pick_max: gli elementi indicati, con contorno
|
|
2451
|
+
// finché il banner resta. Done chiude in entrambi i casi (Invio nella casella).
|
|
2452
|
+
const picks = []; const marked = [];
|
|
2453
|
+
const askInput = root.querySelector('input.ask');
|
|
2454
|
+
const answerOf = () => (askInput ? askInput.value : undefined);
|
|
2455
|
+
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 }); };
|
|
2456
|
+
const done = () => finish(picks.length
|
|
2457
|
+
? { done: true, action: 'picked', answer: answerOf(), picked: picks[0], picked_all: picks }
|
|
2458
|
+
: { done: true, action: 'done', answer: answerOf() });
|
|
2459
|
+
const addPick = (el, p) => {
|
|
2460
|
+
picks.push(p);
|
|
2461
|
+
if (!marked.includes(el)) { el.__cbOutline = el.style.outline; el.style.outline = '2px solid #ffb454'; marked.push(el); }
|
|
2462
|
+
if (picks.length >= pickMax) { done(); return; }
|
|
2463
|
+
root.querySelector('span').textContent = `${message} — ${picks.length}/${pickMax} picked, click more or Done`;
|
|
2464
|
+
};
|
|
2465
|
+
root.querySelector('.done').onclick = done;
|
|
2466
|
+
if (askInput) { askInput.onkeydown = (e) => { if (e.key === 'Enter') done(); }; setTimeout(() => askInput.focus(), 0); }
|
|
2391
2467
|
root.querySelector('.cancel').onclick = () => finish({ done: false, action: 'cancel' });
|
|
2392
2468
|
let box = null; let onMove = null; let onClick = null;
|
|
2393
2469
|
function cleanupPick() {
|
|
@@ -2415,7 +2491,7 @@ function handoffBanner(id, message, pick) {
|
|
|
2415
2491
|
};
|
|
2416
2492
|
const pickBtn = root.querySelector('.pick');
|
|
2417
2493
|
if (pickBtn) pickBtn.onclick = () => {
|
|
2418
|
-
root.querySelector('span').textContent = `${message} — now click the element`;
|
|
2494
|
+
root.querySelector('span').textContent = `${message} — ${pickMax > 1 ? `click up to ${pickMax} elements, then Done` : 'now click the element'}`;
|
|
2419
2495
|
box = document.createElement('div');
|
|
2420
2496
|
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
2497
|
document.documentElement.appendChild(box);
|
|
@@ -2435,7 +2511,7 @@ function handoffBanner(id, message, pick) {
|
|
|
2435
2511
|
const isToggle = el.tagName === 'INPUT' && /^(checkbox|radio)$/.test(el.type);
|
|
2436
2512
|
const label = (labelFor?.textContent || el.closest('label')?.textContent || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ');
|
|
2437
2513
|
const text = (isToggle ? label || el.name : (el.innerText || el.value || el.getAttribute('aria-label') || label || '')).trim().replace(/\s+/g, ' ').slice(0, 80);
|
|
2438
|
-
|
|
2514
|
+
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
2515
|
};
|
|
2440
2516
|
document.addEventListener('mousemove', onMove, true);
|
|
2441
2517
|
document.addEventListener('click', onClick, true);
|
|
@@ -2443,12 +2519,12 @@ function handoffBanner(id, message, pick) {
|
|
|
2443
2519
|
return true;
|
|
2444
2520
|
}
|
|
2445
2521
|
|
|
2446
|
-
async function cmdHandoff({ message, pick_element = false, timeout = 300000, tab_id }) {
|
|
2522
|
+
async function cmdHandoff({ message, pick_element = false, ask = false, pick_max = 1, timeout = 300000, tab_id }) {
|
|
2447
2523
|
if (!message) throw new Error('Missing required parameter: message');
|
|
2448
2524
|
const tabId = await resolveTabId(tab_id);
|
|
2449
2525
|
if (handoffs.has(tabId)) throw new Error('A handoff is already waiting on this tab');
|
|
2450
2526
|
const id = `h${Date.now()}`;
|
|
2451
|
-
const inject = () => chrome.scripting.executeScript({ target: { tabId }, func: handoffBanner, args: [id, String(message), !!pick_element] });
|
|
2527
|
+
const inject = () => chrome.scripting.executeScript({ target: { tabId }, func: handoffBanner, args: [id, String(message), !!pick_element, !!ask, Math.max(1, Number(pick_max) || 1)] });
|
|
2452
2528
|
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
2529
|
await chrome.tabs.update(tabId, { active: true }).catch(() => {});
|
|
2454
2530
|
return await new Promise((resolve) => {
|
|
@@ -4508,9 +4584,16 @@ async function cmdHttpAuth({ action, username, password }) {
|
|
|
4508
4584
|
|
|
4509
4585
|
// --- Tab lifecycle: cleanup injection state ---
|
|
4510
4586
|
|
|
4511
|
-
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
4587
|
+
chrome.tabs.onRemoved.addListener((tabId, info) => {
|
|
4512
4588
|
browserNetLog.delete(tabId);
|
|
4513
4589
|
mainFrameHeaders.delete(tabId);
|
|
4590
|
+
recordTabEnding(tabId, { reason: info?.isWindowClosing ? 'window_closed' : 'closed' });
|
|
4591
|
+
});
|
|
4592
|
+
|
|
4593
|
+
// Chrome sostituisce l'id quando rimpiazza la scheda (prerender, alcuni
|
|
4594
|
+
// discard): per chi teneva il vecchio id è una scheda sparita senza chiusura.
|
|
4595
|
+
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
|
|
4596
|
+
recordTabEnding(removedTabId, { reason: 'replaced', replaced_by: addedTabId });
|
|
4514
4597
|
});
|
|
4515
4598
|
|
|
4516
4599
|
// --- get_interactives ---
|
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.18.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
|
|
|
@@ -118,10 +119,13 @@ function connect(port) {
|
|
|
118
119
|
sendCommand(type, params = {}) {
|
|
119
120
|
return new Promise((res, rej) => {
|
|
120
121
|
const command = createCommand(type, params);
|
|
122
|
+
// Come ws-manager: un timeout chiesto dal comando alza quello di trasporto.
|
|
123
|
+
const asked = Number(params?.timeout);
|
|
124
|
+
const timeout = Number.isFinite(asked) && asked > 0 ? Math.max(getTimeout(type), asked + 5000) : getTimeout(type);
|
|
121
125
|
const timer = setTimeout(() => {
|
|
122
126
|
pending.delete(command.id);
|
|
123
|
-
rej(new Error(`Command ${type} timed out after ${
|
|
124
|
-
},
|
|
127
|
+
rej(new Error(`Command ${type} timed out after ${timeout}ms`));
|
|
128
|
+
}, timeout);
|
|
125
129
|
pending.set(command.id, { res, rej, timer });
|
|
126
130
|
ws.send(JSON.stringify(command));
|
|
127
131
|
});
|
|
@@ -250,6 +254,10 @@ async function run(client, command, params, opts) {
|
|
|
250
254
|
return JSON.stringify(await runAssert(client.sendCommand, params));
|
|
251
255
|
}
|
|
252
256
|
// Comandi virtuali
|
|
257
|
+
if (command === 'window_layout') {
|
|
258
|
+
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)');
|
|
259
|
+
return JSON.stringify(await windowLayout(client.sendCommand, params));
|
|
260
|
+
}
|
|
253
261
|
if (command === 'status') {
|
|
254
262
|
const tabs = await client.sendCommand(MessageType.GET_TABS);
|
|
255
263
|
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.18.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
|
|
@@ -105,6 +113,34 @@ function fitArray(items, max, serialize) {
|
|
|
105
113
|
return items.slice(0, lo);
|
|
106
114
|
}
|
|
107
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Output di execute_js sotto `max` senza rompere il JSON: tagliare la
|
|
118
|
+
* serializzazione a metà stringa dava «Invalid control character» a chi la
|
|
119
|
+
* parsava. Una stringa si accorcia dentro il suo valore, un array per elementi
|
|
120
|
+
* (jsonText), il resto diventa un prefisso dichiarato come tale.
|
|
121
|
+
*/
|
|
122
|
+
export function jsResultText(data, max) {
|
|
123
|
+
const text = JSON.stringify(data);
|
|
124
|
+
if (text == null || text.length <= max) return text;
|
|
125
|
+
const hint = 'raise max_length, or save_to for the whole result';
|
|
126
|
+
const value = data?.result;
|
|
127
|
+
const wrap = (field, cut, total) => JSON.stringify({ [field]: cut, truncated: true, total_chars: total, hint });
|
|
128
|
+
const fitString = (field, full) => {
|
|
129
|
+
// Gli escape JSON allungano la stringa: il prefisso più lungo che sta nel
|
|
130
|
+
// tetto si cerca sulla serializzazione, non sui caratteri grezzi.
|
|
131
|
+
let lo = 0;
|
|
132
|
+
let hi = Math.min(full.length, max);
|
|
133
|
+
while (lo < hi) {
|
|
134
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
135
|
+
if (wrap(field, full.slice(0, mid), full.length).length <= max) lo = mid; else hi = mid - 1;
|
|
136
|
+
}
|
|
137
|
+
return wrap(field, full.slice(0, lo), full.length);
|
|
138
|
+
};
|
|
139
|
+
if (typeof value === 'string') return fitString('result', value);
|
|
140
|
+
if (Array.isArray(value)) return jsonText(data, max, hint);
|
|
141
|
+
return fitString('result_json_prefix', JSON.stringify(value));
|
|
142
|
+
}
|
|
143
|
+
|
|
108
144
|
/** Una riga (oggetto colonna→valore o array di celle) soddisfa il filtro where. */
|
|
109
145
|
function tableRowMatches(row, where) {
|
|
110
146
|
return Object.entries(where).every(([col, needle]) => {
|
|
@@ -328,6 +364,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
328
364
|
* @param {import('@modelcontextprotocol/sdk/server/index.js').McpServer} server - MCP Server
|
|
329
365
|
* @param {import('./ws-manager.js').WSManager} wsManager - WebSocket manager
|
|
330
366
|
* @param {string} [caps='all'] - 'all', 'core', o lista di gruppi "audits,visual"
|
|
367
|
+
* @param {{noJs?: boolean, writeRoot?: string|null}} [options] - CHROME_BRIDGE_NO_JS / CHROME_BRIDGE_WRITE_ROOT
|
|
331
368
|
*/
|
|
332
369
|
/** Procedura leggibile da un'osservazione: un passo per riga, i campi sensibili marcati come passo umano. */
|
|
333
370
|
function observedProcedure(name, d) {
|
|
@@ -345,8 +382,20 @@ function observedProcedure(name, d) {
|
|
|
345
382
|
return lines.join('\n') + '\n';
|
|
346
383
|
}
|
|
347
384
|
|
|
348
|
-
export function registerTools(server, wsManager, caps = 'all') {
|
|
385
|
+
export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
349
386
|
const startedAt = Date.now();
|
|
387
|
+
const noJs = Boolean(options.noJs);
|
|
388
|
+
const WRITE_ROOT = options.writeRoot ? resolve(String(options.writeRoot)) : null;
|
|
389
|
+
// Rifiuta PRIMA del comando all'estensione: niente screenshot fatto per un
|
|
390
|
+
// file che non verrà scritto. Il percorso torna com'è, così i chiamanti
|
|
391
|
+
// possono usarlo inline.
|
|
392
|
+
const guardWrite = (p) => {
|
|
393
|
+
if (!WRITE_ROOT || p == null) return p;
|
|
394
|
+
const abs = resolve(String(p));
|
|
395
|
+
const inside = (root) => abs === root || abs.startsWith(root + sep);
|
|
396
|
+
if (inside(WRITE_ROOT) || STATE_DIRS.some((d) => inside(resolve(d)))) return p;
|
|
397
|
+
throw new Error(`Refusing to write ${abs}: outside CHROME_BRIDGE_WRITE_ROOT (${WRITE_ROOT})`);
|
|
398
|
+
};
|
|
350
399
|
const activeCaps = caps === 'all'
|
|
351
400
|
? ['core', ...Object.keys(TOOL_CAPS)]
|
|
352
401
|
: ['core', ...String(caps).split(',').map((s) => s.trim()).filter((s) => s && s !== 'core')];
|
|
@@ -362,6 +411,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
362
411
|
: null;
|
|
363
412
|
server = {
|
|
364
413
|
tool(name, desc, schema, handler) {
|
|
414
|
+
if (noJs && JS_TOOLS.has(name)) return;
|
|
365
415
|
if (enabled) {
|
|
366
416
|
const group = TOOL_TO_CAP.get(name);
|
|
367
417
|
if (group && !enabled.has(group) && !enabled.has('all')) return;
|
|
@@ -438,8 +488,15 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
438
488
|
throw new Error('Either selector or ref is required');
|
|
439
489
|
}
|
|
440
490
|
|
|
441
|
-
//
|
|
491
|
+
// Impronta della pagina per il delta post-azione (page_changed): DOM in
|
|
492
|
+
// pochi interi più url/title. Un'estensione vecchia o una pagina non
|
|
493
|
+
// iniettabile (chrome://) tornano a url/title da get_tabs: nessun errore,
|
|
494
|
+
// solo meno dettaglio.
|
|
442
495
|
async function tabSnapshot(tab_id) {
|
|
496
|
+
try {
|
|
497
|
+
const fp = await send(MessageType.PAGE_FINGERPRINT, { tab_id });
|
|
498
|
+
if (fp && typeof fp === 'object') return fp;
|
|
499
|
+
} catch { /* fallback sotto */ }
|
|
443
500
|
try {
|
|
444
501
|
const tabs = await send(MessageType.GET_TABS);
|
|
445
502
|
const list = Array.isArray(tabs) ? tabs : [];
|
|
@@ -449,16 +506,11 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
449
506
|
} catch { return null; }
|
|
450
507
|
}
|
|
451
508
|
|
|
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
|
-
}
|
|
509
|
+
// Delta compatto dopo un'azione: solo ciò che è cambiato (url, title, conteggi
|
|
510
|
+
// DOM con segno, fuoco). Costa pochi token quando scatta, zero quando la
|
|
511
|
+
// pagina è stabile, e risparmia al client lo screenshot per capire "cosa è
|
|
512
|
+
// successo".
|
|
513
|
+
const pageDelta = fingerprintDelta;
|
|
462
514
|
|
|
463
515
|
// Anteprima compatta dei primi interactives (con ref), allegata a navigate:
|
|
464
516
|
// il client può agire subito senza un giro di discovery. Cappata e best-effort.
|
|
@@ -493,6 +545,8 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
493
545
|
extension_version: wsManager.extVersion ?? null,
|
|
494
546
|
// Un agente che non trova accessibility_audit non aveva modo di
|
|
495
547
|
// scoprire che esiste ma è in un gruppo disattivato.
|
|
548
|
+
js_evaluation: !noJs,
|
|
549
|
+
write_root: WRITE_ROOT,
|
|
496
550
|
caps_active: activeCaps,
|
|
497
551
|
caps_available: ['core', ...Object.keys(TOOL_CAPS)],
|
|
498
552
|
session_tab_id: sessionTabId,
|
|
@@ -508,21 +562,38 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
508
562
|
server.tool(
|
|
509
563
|
'get_tabs',
|
|
510
564
|
'List every open tab with id, url, title, active flag and mine (created by this session). Read-only. Find a tab_id when the implicit '
|
|
511
|
-
+ 'target is not the one you mean. include_windows adds position, size, state and type of each window — needed before moving or tiling.'
|
|
565
|
+
+ 'target is not the one you mean. include_windows adds position, size, state and type of each window — needed before moving or tiling. '
|
|
566
|
+
+ 'Session tabs gone since the last call come back once in closed_session_tabs with the reason (closed, window_closed, replaced, closed_by_bridge).',
|
|
512
567
|
{
|
|
513
568
|
include_windows: z.boolean().optional().default(false)
|
|
514
569
|
.describe('Also return the windows with bounds, state, type and tab count'),
|
|
515
570
|
},
|
|
516
571
|
async ({ include_windows }) => {
|
|
517
|
-
|
|
572
|
+
// `ended` = le schede che il server sa sue: l'estensione dà il motivo di
|
|
573
|
+
// quelle che non trova più. Senza schede di sessione la risposta resta
|
|
574
|
+
// quella di sempre.
|
|
575
|
+
const data = await send(MessageType.GET_TABS, {
|
|
576
|
+
include_windows: include_windows === true,
|
|
577
|
+
...(ownedTabs.size ? { ended: [...ownedTabs] } : {}),
|
|
578
|
+
});
|
|
518
579
|
const list = Array.isArray(data) ? data : (data?.tabs ?? []);
|
|
580
|
+
const windows = Array.isArray(data) ? null : data?.windows;
|
|
519
581
|
for (const t of list) if (ownedTabs.has(t.id)) t.mine = true;
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
};
|
|
582
|
+
const missing = [...ownedTabs].filter((id) => !list.some((t) => t.id === id));
|
|
583
|
+
const shaped = windows ? { tabs: list, windows } : list;
|
|
584
|
+
if (!missing.length) return { content: [{ type: 'text', text: jsonText(shaped) }] };
|
|
585
|
+
|
|
586
|
+
// Un'estensione precedente ignora `ended` e risponde con l'array: motivo ignoto.
|
|
587
|
+
const ended = (!Array.isArray(data) && data?.ended) || {};
|
|
588
|
+
const closed = missing.map((id) => ({ id, ...(ended[id] ?? { reason: 'unknown' }) }));
|
|
589
|
+
for (const c of closed) {
|
|
590
|
+
ownedTabs.delete(c.id);
|
|
591
|
+
const successor = c.replaced_by != null ? list.find((t) => t.id === c.replaced_by) : null;
|
|
592
|
+
// Scheda sostituita da Chrome: resta della sessione con il nuovo id.
|
|
593
|
+
if (successor) { ownedTabs.add(successor.id); successor.mine = true; }
|
|
594
|
+
if (sessionTabId === c.id) sessionTabId = successor?.id ?? null;
|
|
595
|
+
}
|
|
596
|
+
return { content: [{ type: 'text', text: jsonText({ tabs: list, ...(windows ? { windows } : {}), closed_session_tabs: closed }) }] };
|
|
526
597
|
}
|
|
527
598
|
);
|
|
528
599
|
|
|
@@ -535,6 +606,9 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
535
606
|
tab_id: tabId,
|
|
536
607
|
},
|
|
537
608
|
async ({ url, tab_id }) => {
|
|
609
|
+
if (noJs && /^\s*(javascript|data|vbscript):/i.test(url)) {
|
|
610
|
+
throw new Error(`Refusing to navigate to ${url.slice(0, 40)}: JavaScript evaluation is disabled (CHROME_BRIDGE_NO_JS)`);
|
|
611
|
+
}
|
|
538
612
|
const data = await send(MessageType.NAVIGATE, { url, tab_id });
|
|
539
613
|
// Il tab navigato diventa il default di sessione per i comandi successivi
|
|
540
614
|
if (data?.tabId != null) sessionTabId = data.tabId;
|
|
@@ -560,6 +634,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
560
634
|
presets: z.array(z.enum(['mobile', 'tablet', 'desktop'])).optional().describe('One capture per preset, window restored; save_to = directory, one file each'),
|
|
561
635
|
},
|
|
562
636
|
async ({ tab_id, save_to, presets }) => {
|
|
637
|
+
guardWrite(save_to);
|
|
563
638
|
// Matrice responsive: viewport_resize + screenshot per preset erano 2N
|
|
564
639
|
// turni; qui è una chiamata, e la finestra torna com'era.
|
|
565
640
|
if (presets?.length) {
|
|
@@ -615,21 +690,25 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
615
690
|
// --- execute_js ---
|
|
616
691
|
server.tool(
|
|
617
692
|
'execute_js',
|
|
618
|
-
'Run JavaScript in the page (MAIN world). Requires the extension\'s "Allow user scripts" toggle; errors explain setup if disabled.'
|
|
693
|
+
'Run JavaScript in the page (MAIN world). Requires the extension\'s "Allow user scripts" toggle; errors explain setup if disabled. '
|
|
694
|
+
+ 'A promise is awaited up to timeout. A cut result stays valid JSON and says truncated; save_to writes the whole result to disk.',
|
|
619
695
|
{
|
|
620
696
|
code: z.string().describe('JS evaluated in the page; the value of the last expression is returned'),
|
|
621
697
|
tab_id: tabId,
|
|
622
698
|
frame_id: frameId,
|
|
623
699
|
max_length: z.number().optional().default(20000).describe('Max output chars'),
|
|
700
|
+
timeout: z.number().optional().describe('Max ms to wait for the result (default 30000); raise it for long async work'),
|
|
701
|
+
save_to: saveToField('the result (a string as is, anything else as JSON)'),
|
|
624
702
|
},
|
|
625
|
-
async ({ code, tab_id, frame_id, max_length }) => {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
}
|
|
632
|
-
}
|
|
703
|
+
async ({ code, tab_id, frame_id, max_length, timeout, save_to }) => {
|
|
704
|
+
guardWrite(save_to);
|
|
705
|
+
const data = await send(MessageType.EXECUTE_JS, { code, tab_id, frame_id, ...(timeout ? { timeout } : {}) });
|
|
706
|
+
if (save_to) {
|
|
707
|
+
const value = data?.result;
|
|
708
|
+
const isString = typeof value === 'string';
|
|
709
|
+
return savedSummary(save_to, Buffer.from(isString ? value : JSON.stringify(value ?? null), 'utf8'), { format: isString ? 'text' : 'json' });
|
|
710
|
+
}
|
|
711
|
+
return { content: [{ type: 'text', text: jsResultText(data, max_length ?? DEFAULT_MAX_OUTPUT) }] };
|
|
633
712
|
}
|
|
634
713
|
);
|
|
635
714
|
|
|
@@ -637,7 +716,8 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
637
716
|
server.tool(
|
|
638
717
|
'click',
|
|
639
718
|
'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.'
|
|
719
|
+
+ 'open a dialog or navigate — use wait_after. Not idempotent; a native confirm() blocks the bridge: handle_dialogs first. '
|
|
720
|
+
+ 'Returns page_changed (url, title and DOM deltas: nodes, text, open, expanded, checked, dialogs, focus): the effect, without a screenshot.',
|
|
641
721
|
{
|
|
642
722
|
selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
|
|
643
723
|
ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
|
|
@@ -654,6 +734,9 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
654
734
|
const data = await send(MessageType.CLICK, { selector: target, force, button: button ?? 'left', count: count ?? 1, frame_id, tab_id });
|
|
655
735
|
// Niente attesa se il click non è andato a buon fine (es. elemento occluso)
|
|
656
736
|
const waited = data?.occluded ? null : await applyWaitAfter(send, wait_after, tab_id);
|
|
737
|
+
// Senza wait_after un breve settle: i framework aggiornano il DOM dopo il
|
|
738
|
+
// click, non dentro; senza, l'impronta "dopo" vedrebbe quella "prima".
|
|
739
|
+
if (!data?.occluded && (wait_after ?? 'none') === 'none') await new Promise((r) => setTimeout(r, 150));
|
|
657
740
|
const changed = data?.occluded ? null : pageDelta(before, await tabSnapshot(tab_id));
|
|
658
741
|
const out = { ...data, ...(waited && { wait_after: waited }), ...(changed && { page_changed: changed }) };
|
|
659
742
|
return {
|
|
@@ -670,7 +753,8 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
670
753
|
'type_text',
|
|
671
754
|
'Put text into an input, textarea or contenteditable, by selector or ref. Replaces the whole value through the '
|
|
672
755
|
+ '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.'
|
|
756
|
+
+ 'keydown/input/keyup per character for autocomplete and masked fields: slower, use it only when mode=set leaves the field empty. '
|
|
757
|
+
+ 'Returns value_after; mismatch=true means the field did not keep the value: retry with mode=keys.',
|
|
674
758
|
{
|
|
675
759
|
selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
|
|
676
760
|
ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
|
|
@@ -685,10 +769,13 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
685
769
|
const data = await send(MessageType.TYPE_TEXT, { selector: target, text, mode, tab_id, frame_id });
|
|
686
770
|
const waited = await applyWaitAfter(send, wait_after, tab_id);
|
|
687
771
|
const out = waited ? { ...data, wait_after: waited } : data;
|
|
772
|
+
const warn = data?.mismatch
|
|
773
|
+
? `\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`
|
|
774
|
+
: '';
|
|
688
775
|
return {
|
|
689
776
|
content: [{
|
|
690
777
|
type: 'text',
|
|
691
|
-
text: jsonText(out),
|
|
778
|
+
text: jsonText(out) + warn,
|
|
692
779
|
}],
|
|
693
780
|
};
|
|
694
781
|
}
|
|
@@ -708,6 +795,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
708
795
|
save_to: saveToField('the page'),
|
|
709
796
|
},
|
|
710
797
|
async ({ mode, tab_id, frame_id, max_length, save_to }) => {
|
|
798
|
+
guardWrite(save_to);
|
|
711
799
|
const data = await send(MessageType.READ_PAGE, { mode, tab_id, frame_id });
|
|
712
800
|
const text = typeof data === 'string' ? data : JSON.stringify(data);
|
|
713
801
|
if (save_to) return savedSummary(save_to, Buffer.from(text, 'utf8'), { mode: mode ?? 'text' });
|
|
@@ -963,6 +1051,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
963
1051
|
text, selector, timeout: timeout ?? 10000, interval: interval ?? 200, tab_id, frame_id,
|
|
964
1052
|
});
|
|
965
1053
|
} else if (condition === 'function') {
|
|
1054
|
+
if (noJs) throw new Error('wait_for condition=function evaluates JavaScript, which is disabled (CHROME_BRIDGE_NO_JS): use condition=element or text');
|
|
966
1055
|
data = await send(MessageType.WAIT_FOR_FUNCTION, {
|
|
967
1056
|
expression, timeout: timeout ?? 10000, polling_ms: interval ?? 100, tab_id, frame_id,
|
|
968
1057
|
});
|
|
@@ -1039,7 +1128,8 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1039
1128
|
server.tool(
|
|
1040
1129
|
'fill_form',
|
|
1041
1130
|
'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.'
|
|
1131
|
+
+ 'With submit_selector it also submits, so a repeated call submits twice — not safe to retry blindly. '
|
|
1132
|
+
+ 'Each field reports value_after and mismatch; page_changed carries the DOM delta.',
|
|
1043
1133
|
{
|
|
1044
1134
|
fields: z.array(z.object({
|
|
1045
1135
|
selector: z.string(),
|
|
@@ -1056,10 +1146,14 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1056
1146
|
const waited = await applyWaitAfter(send, wait_after, tab_id);
|
|
1057
1147
|
const changed = pageDelta(before, await tabSnapshot(tab_id));
|
|
1058
1148
|
const out = { ...data, ...(waited && { wait_after: waited }), ...(changed && { page_changed: changed }) };
|
|
1149
|
+
// I campi che non hanno tenuto il valore in testa, in chiaro: sono la
|
|
1150
|
+
// riga che il modello deve leggere prima del JSON.
|
|
1151
|
+
const mism = (data?.fields ?? []).filter((f) => f && f.mismatch);
|
|
1152
|
+
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
1153
|
return {
|
|
1060
1154
|
content: [{
|
|
1061
1155
|
type: 'text',
|
|
1062
|
-
text: jsonText(out),
|
|
1156
|
+
text: head + jsonText(out),
|
|
1063
1157
|
}],
|
|
1064
1158
|
};
|
|
1065
1159
|
}
|
|
@@ -1322,6 +1416,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1322
1416
|
return { content: [{ type: 'text', text: jsonText({ closed }) }] };
|
|
1323
1417
|
}
|
|
1324
1418
|
const data = await send(MessageType.TAB_ACTION, { action, bypass_cache, tab_id });
|
|
1419
|
+
if (action === 'close') ownedTabs.delete(data?.closed ?? tab_id ?? sessionTabId);
|
|
1325
1420
|
if (action === 'close' && (tab_id == null || tab_id === sessionTabId)) sessionTabId = null;
|
|
1326
1421
|
if (action === 'activate' && tab_id != null) sessionTabId = tab_id;
|
|
1327
1422
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
@@ -1615,18 +1710,25 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1615
1710
|
'handoff',
|
|
1616
1711
|
'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
1712
|
+ '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.'
|
|
1713
|
+
+ '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. '
|
|
1714
|
+
+ 'pick_max: with pick_element, up to N elements; the user presses Done when finished.',
|
|
1619
1715
|
{
|
|
1620
1716
|
message: z.string().describe('What the user should do, one line'),
|
|
1621
1717
|
pick_element: z.boolean().optional().default(false).describe('Ask the user to click an element; returns its selector, text and box'),
|
|
1718
|
+
ask: z.boolean().optional().default(false).describe('Show a text box in the banner; the reply comes back as answer'),
|
|
1719
|
+
pick_max: z.number().optional().default(1).describe('With pick_element: up to N elements, the user presses Done when finished'),
|
|
1622
1720
|
timeout: z.number().optional().default(300000).describe('ms to wait for the click, default 5 min'),
|
|
1623
1721
|
tab_id: tabId,
|
|
1624
1722
|
},
|
|
1625
|
-
async ({ message, pick_element, timeout, tab_id }) => {
|
|
1626
|
-
const d = await send(MessageType.HANDOFF, { message, pick_element, timeout, tab_id });
|
|
1723
|
+
async ({ message, pick_element, ask, pick_max, timeout, tab_id }) => {
|
|
1724
|
+
const d = await send(MessageType.HANDOFF, { message, pick_element, ask, pick_max, timeout, tab_id });
|
|
1627
1725
|
const lines = [`handoff ${d.action}${d.url ? ` url=${d.url}` : ''}`];
|
|
1628
|
-
if (d.
|
|
1629
|
-
|
|
1726
|
+
if (ask && typeof d.answer === 'string' && d.answer.trim()) lines.push(`answer: ${d.answer.trim()}`);
|
|
1727
|
+
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}`;
|
|
1728
|
+
if (Array.isArray(d.picked_all) && d.picked_all.length) {
|
|
1729
|
+
d.picked_all.forEach((p, i) => lines.push(`picked ${i + 1}/${d.picked_all.length} ${fmt(p)}`));
|
|
1730
|
+
} else if (d.picked) {
|
|
1731
|
+
lines.push(`picked ${fmt(d.picked)}`);
|
|
1630
1732
|
}
|
|
1631
1733
|
if (d.action === 'timeout') lines.push('the user did not click within the timeout: ask before retrying');
|
|
1632
1734
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
@@ -1697,6 +1799,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1697
1799
|
tab_id: tabId,
|
|
1698
1800
|
},
|
|
1699
1801
|
async ({ kinds, save_to, scope, max_links, tab_id }) => {
|
|
1802
|
+
guardWrite(save_to);
|
|
1700
1803
|
const info = await send(MessageType.GET_PAGE_INFO, { tab_id });
|
|
1701
1804
|
const results = await runAudit(send, { kinds, scope, max_links, tab_id });
|
|
1702
1805
|
const summary = summarizeAudit(results);
|
|
@@ -1866,6 +1969,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1866
1969
|
tab_id: tabId,
|
|
1867
1970
|
},
|
|
1868
1971
|
async ({ output_path, tab_id }) => {
|
|
1972
|
+
guardWrite(output_path);
|
|
1869
1973
|
const data = await send(MessageType.SAVE_PAGE, { tab_id });
|
|
1870
1974
|
await writeFile(output_path, Buffer.from(data.mhtml_b64, 'base64'));
|
|
1871
1975
|
return { content: [{ type: 'text', text: jsonText({ saved: output_path, size: data.size }) }] };
|
|
@@ -1887,6 +1991,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1887
1991
|
max_length: z.number().optional().default(DEFAULT_MAX_OUTPUT).describe('Max chars of body returned inline'),
|
|
1888
1992
|
},
|
|
1889
1993
|
async ({ url, method, headers, body, save_to, max_length }) => {
|
|
1994
|
+
guardWrite(save_to);
|
|
1890
1995
|
const data = await send(MessageType.HTTP_REQUEST, { url, method, headers, body, binary: Boolean(save_to) });
|
|
1891
1996
|
|
|
1892
1997
|
if (save_to) {
|
|
@@ -1988,120 +2093,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1988
2093
|
action: z.enum(['save', 'restore', 'list', 'delete']).describe('save snapshots every window; restore repositions the recognised ones'),
|
|
1989
2094
|
name: z.string().optional().describe('Layout name, required except for list'),
|
|
1990
2095
|
},
|
|
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
|
-
}
|
|
2096
|
+
async ({ action, name }) => ({ content: [{ type: 'text', text: jsonText(await windowLayout(send, { action, name })) }] })
|
|
2105
2097
|
);
|
|
2106
2098
|
|
|
2107
2099
|
// --- http_auth ---
|
|
@@ -2246,6 +2238,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
2246
2238
|
save_to: saveToField('the records as JSON'),
|
|
2247
2239
|
},
|
|
2248
2240
|
async ({ item_selector, fields, max_items, format, tab_id, max_length , save_to }) => {
|
|
2241
|
+
guardWrite(save_to);
|
|
2249
2242
|
max_length = max_length ?? DEFAULT_MAX_OUTPUT;
|
|
2250
2243
|
const html = await send(MessageType.READ_PAGE, { mode: 'html', tab_id });
|
|
2251
2244
|
if (typeof html !== 'string') throw new Error('Could not read page HTML');
|
|
@@ -2314,6 +2307,8 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
2314
2307
|
tab_id: tabId,
|
|
2315
2308
|
},
|
|
2316
2309
|
async ({ action, name, save_to, values, tab_id }) => {
|
|
2310
|
+
guardWrite(save_to);
|
|
2311
|
+
if (name && name.endsWith('.jsonl')) guardWrite(name);
|
|
2317
2312
|
// "Guarda come faccio": la procedura la esegue l'umano nel suo browser,
|
|
2318
2313
|
// noi la trascriviamo — mai i valori dei campi sensibili — nello stesso
|
|
2319
2314
|
// jsonl che replay esegue, più un .md leggibile con i passi umani marcati.
|