chrome-bridge-mcp 1.29.3 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -164,12 +164,12 @@ user-script toggle isn't available.
164
164
 
165
165
  ## Tools
166
166
 
167
- 66 in total. `core` (43 tools, every tool used in 101 real sessions) loads by
167
+ 67 in total. `core` (44 tools, every tool used in 101 real sessions) loads by
168
168
  default; the other 23 sit in seven optional caps that the agent switches on
169
169
  mid-session with `get_status({enable: ["visual"]})`, with no restart
170
170
  (`--caps` still sets them at startup).
171
171
 
172
- ![66 tools in eight groups, from clicking a button to tracing a page](assets/readme/card4-toolbox.png)
172
+ ![67 tools in eight groups, from clicking a button to tracing a page](assets/readme/card4-toolbox.png)
173
173
 
174
174
  | Group | N | What's in it |
175
175
  |---|---|---|
@@ -179,7 +179,7 @@ mid-session with `get_status({enable: ["visual"]})`, with no restart
179
179
  | Debugging & Network | 8 | `execute_js`, console, network log, mocking, `track_events` |
180
180
  | Visual & Responsive | 5 | `screenshot_diff`, viewport and zoom, media emulation |
181
181
  | Audits | 2 | `audit` (a11y, keyboard, SEO, security, links, vitals, css, resources, cache in one call), `cookie_audit` |
182
- | State, Storage & Files | 9 | storage, fixtures, MHTML, recording, `assert` |
182
+ | State, Storage & Files | 10 | storage, fixtures, MHTML, `capture_downloads` (exports saved with no "Save as" dialog), recording, `assert` |
183
183
  | Motion & Performance | 6 | `animations`, `frames` (long frames, CLS, INP), `perf_trace`, `screencast`, `lighthouse` and `heap_snapshot` (launch mode) |
184
184
 
185
185
  Every tool, with the notes that matter: [docs/TOOLS.md](docs/TOOLS.md).
@@ -316,7 +316,7 @@ count.
316
316
 
317
317
  ## Documentation
318
318
 
319
- - [docs/TOOLS.md](docs/TOOLS.md) — all 66 tools, by group
319
+ - [docs/TOOLS.md](docs/TOOLS.md) — all 67 tools, by group
320
320
  - [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — what the bridge gets past and what it does not, one dated state per wall
321
321
  - [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
322
322
  - [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — latency per tool on the real path, `npm run bench:latency`
@@ -0,0 +1,206 @@
1
+ /**
2
+ * capture_downloads, lato pagina: i file che la pagina costruisce da sé
3
+ * arrivano al server senza passare dal download di Chrome.
4
+ *
5
+ * Il problema (14/10/2026): un click su «Esporta» apre la finestra di sistema
6
+ * «Salva con nome», che nessun tool può premere; su ChromeOS con «Chiedi dove
7
+ * salvare ciascun file» si apre anche con saveAs:false. Qui il file viene preso
8
+ * prima che diventi un download:
9
+ * - a[download] con href blob: o data:, cliccato davvero o con a.click() su un
10
+ * elemento staccato dal DOM (il caso più comune: createElement, click, revoke);
11
+ * - window.showSaveFilePicker: un handle finto raccoglie le scritture e alla
12
+ * close consegna il file;
13
+ * - URL.createObjectURL tiene un riferimento al Blob mentre la cattura è armata,
14
+ * così un revokeObjectURL subito dopo il click non lo perde.
15
+ *
16
+ * I file restano nella pagina (window.__cbCapture.items) finché il server li
17
+ * legge a pezzi e li butta. Disarmata, la pagina si comporta come senza hook.
18
+ *
19
+ * Non è un modulo ES: si inietta con files: in MAIN world (come element-label.js).
20
+ */
21
+ (() => {
22
+ if (window.__cbCapture) return;
23
+ const st = {
24
+ armed: false,
25
+ max: 0,
26
+ items: [],
27
+ blobs: new Map(),
28
+ seq: 0,
29
+ };
30
+ Object.defineProperty(window, '__cbCapture', { value: st, configurable: true });
31
+
32
+ const newId = () => `c${Date.now().toString(36)}${(st.seq++).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
33
+
34
+ function add(blob, name, source) {
35
+ const item = { id: newId(), name: String(name || ''), mime: blob.type || '', size: blob.size, source };
36
+ if (blob.size > st.max) item.error = `too large: ${blob.size} bytes, max_mb allows ${st.max}`;
37
+ else item.blob = blob;
38
+ st.items.push(item);
39
+ return item;
40
+ }
41
+
42
+ // Un file preso da un URL (blob:, data:) è asincrono: l'item c'è subito,
43
+ // pending, così l'attesa del server lo vede e non chiude prima del tempo.
44
+ async function fromUrl(url, name, source) {
45
+ const item = { id: newId(), name: String(name || ''), source, pending: true };
46
+ st.items.push(item);
47
+ try {
48
+ const blob = st.blobs.get(url) ?? await (await fetch(url)).blob();
49
+ item.mime = blob.type || '';
50
+ item.size = blob.size;
51
+ if (blob.size > st.max) item.error = `too large: ${blob.size} bytes, max_mb allows ${st.max}`;
52
+ else item.blob = blob;
53
+ } catch (e) {
54
+ item.error = `could not read ${url.slice(0, 60)}: ${e.message}`;
55
+ }
56
+ delete item.pending;
57
+ return item;
58
+ }
59
+ st.fromUrl = fromUrl;
60
+
61
+ const origCreate = URL.createObjectURL;
62
+ URL.createObjectURL = function createObjectURL(obj) {
63
+ const url = origCreate.call(URL, obj);
64
+ if (st.armed && obj instanceof Blob) st.blobs.set(url, obj);
65
+ return url;
66
+ };
67
+
68
+ const capturable = (a) => a instanceof HTMLAnchorElement && a.hasAttribute('download') && /^(blob|data):/i.test(a.href || '');
69
+ const take = (a) => fromUrl(a.href, a.getAttribute('download') || '', a.href.startsWith('data:') ? 'data_url' : 'blob');
70
+
71
+ // Click veri o sintetici su un'ancora nel documento: bolla fino a window.
72
+ // Se la pagina ha già annullato il click, il download non era voluto.
73
+ const handled = new WeakSet();
74
+ window.addEventListener('click', (e) => {
75
+ if (!st.armed || e.defaultPrevented || handled.has(e)) return;
76
+ const a = e.composedPath().find((n) => n instanceof HTMLAnchorElement);
77
+ if (!capturable(a)) return;
78
+ handled.add(e);
79
+ e.preventDefault();
80
+ take(a);
81
+ });
82
+
83
+ // a.click() su un'ancora staccata: l'evento non arriva a window. Lo si
84
+ // spedisce qui, così i listener della pagina girano comunque. Anche un click
85
+ // sintetico attiva l'ancora (verificato 14/10: blob preso due volte, una
86
+ // dall'hook e una dal download): un listener sull'ancora, aggiunto dopo
87
+ // quelli della pagina, annulla l'azione nativa se la pagina non l'ha fatto.
88
+ const origClick = HTMLAnchorElement.prototype.click;
89
+ HTMLAnchorElement.prototype.click = function click() {
90
+ if (!st.armed || !capturable(this)) return origClick.apply(this, arguments);
91
+ const ev = new MouseEvent('click', { bubbles: true, cancelable: true, composed: true, view: window });
92
+ let wanted = false;
93
+ const last = (e) => {
94
+ if (e !== ev || e.defaultPrevented) return;
95
+ wanted = true;
96
+ handled.add(e);
97
+ e.preventDefault();
98
+ };
99
+ this.addEventListener('click', last);
100
+ try { this.dispatchEvent(ev); } finally { this.removeEventListener('click', last); }
101
+ if (wanted) take(this);
102
+ };
103
+
104
+ // --- showSaveFilePicker ---
105
+ const toBytes = async (d) => {
106
+ if (typeof d === 'string') return new TextEncoder().encode(d);
107
+ if (d instanceof Blob) return new Uint8Array(await d.arrayBuffer());
108
+ if (ArrayBuffer.isView(d)) return new Uint8Array(d.buffer, d.byteOffset, d.byteLength);
109
+ if (d instanceof ArrayBuffer) return new Uint8Array(d);
110
+ throw new TypeError('chrome-bridge capture: unsupported data for write()');
111
+ };
112
+ const isParams = (d) => d && typeof d === 'object' && !(d instanceof Blob) && !ArrayBuffer.isView(d) && !(d instanceof ArrayBuffer) && 'type' in d;
113
+
114
+ function makeHandle(name, mime) {
115
+ let committed = new Blob([], { type: mime });
116
+ const handle = {
117
+ kind: 'file',
118
+ name,
119
+ getFile: async () => new File([committed], name, { type: mime }),
120
+ createWritable: async (opts = {}) => {
121
+ let buf = new Uint8Array(0);
122
+ let len = 0;
123
+ let pos = 0;
124
+ if (opts.keepExistingData) {
125
+ buf = new Uint8Array(await committed.arrayBuffer());
126
+ len = buf.length;
127
+ }
128
+ const ensure = (n) => {
129
+ if (n > st.max) throw new DOMException(`chrome-bridge capture: ${name} over max_mb`, 'QuotaExceededError');
130
+ if (n <= buf.length) return;
131
+ const nb = new Uint8Array(Math.max(n, buf.length * 2, 64 * 1024));
132
+ nb.set(buf.subarray(0, len));
133
+ buf = nb;
134
+ };
135
+ const apply = async (d) => {
136
+ if (isParams(d)) {
137
+ if (d.type === 'seek') { pos = d.position; return; }
138
+ if (d.type === 'truncate') {
139
+ ensure(d.size);
140
+ if (d.size > len) buf.fill(0, len, d.size);
141
+ len = d.size;
142
+ if (pos > len) pos = len;
143
+ return;
144
+ }
145
+ if (d.position != null) pos = d.position;
146
+ d = d.data;
147
+ }
148
+ const b = await toBytes(d);
149
+ ensure(pos + b.length);
150
+ buf.set(b, pos);
151
+ pos += b.length;
152
+ if (pos > len) len = pos;
153
+ };
154
+ let closed = false;
155
+ const finish = () => {
156
+ if (closed) return;
157
+ closed = true;
158
+ committed = new Blob([buf.slice(0, len)], { type: mime });
159
+ add(committed, name, 'showSaveFilePicker');
160
+ };
161
+ const ws = new WritableStream({ write: (chunk) => apply(chunk), close: finish });
162
+ // write/seek/truncate/close diretti, come FileSystemWritableFileStream;
163
+ // pipeTo e getWriter passano dal sink qui sopra.
164
+ Object.assign(ws, {
165
+ write: (d) => apply(d),
166
+ seek: async (p) => { pos = p; },
167
+ truncate: async (size) => apply({ type: 'truncate', size }),
168
+ close: async () => finish(),
169
+ });
170
+ try { if (window.FileSystemWritableFileStream) Object.setPrototypeOf(ws, FileSystemWritableFileStream.prototype); } catch {}
171
+ return ws;
172
+ },
173
+ queryPermission: async () => 'granted',
174
+ requestPermission: async () => 'granted',
175
+ isSameEntry: async (other) => other === handle,
176
+ };
177
+ try { if (window.FileSystemFileHandle) Object.setPrototypeOf(handle, FileSystemFileHandle.prototype); } catch {}
178
+ return handle;
179
+ }
180
+
181
+ const origPicker = window.showSaveFilePicker;
182
+ if (typeof origPicker === 'function') {
183
+ window.showSaveFilePicker = function showSaveFilePicker(opts = {}) {
184
+ if (!st.armed) return origPicker.apply(this, arguments);
185
+ const first = opts?.types?.[0]?.accept ?? {};
186
+ const mime = Object.keys(first).find((m) => !m.includes('*')) || '';
187
+ const ext = [].concat(Object.values(first)[0] ?? [])[0] || '';
188
+ const name = opts?.suggestedName || `download${ext}`;
189
+ return Promise.resolve(makeHandle(String(name), mime));
190
+ };
191
+ }
192
+
193
+ // Dal service worker: elenco, lettura a pezzi, scarto.
194
+ st.list = () => st.items.map(({ blob, ...meta }) => meta);
195
+ st.read = async (id, offset, length) => {
196
+ const it = st.items.find((x) => x.id === id);
197
+ if (!it?.blob) throw new Error(`capture: no file ${id} in this page`);
198
+ const part = new Uint8Array(await it.blob.slice(offset, offset + length).arrayBuffer());
199
+ let s = '';
200
+ for (let i = 0; i < part.length; i += 0x8000) s += String.fromCharCode.apply(null, part.subarray(i, i + 0x8000));
201
+ return btoa(s);
202
+ };
203
+ st.drop = (id) => { st.items = st.items.filter((x) => x.id !== id); };
204
+ st.arm = (max) => { st.armed = true; st.max = max; };
205
+ st.disarm = () => { st.armed = false; st.items = []; st.blobs.clear(); };
206
+ })();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Il nome di un file intercettato da capture_downloads, prima della pulizia
3
+ * del server (server/capture.js, che decide il nome sul disco).
4
+ *
5
+ * Ordine: filename* di Content-Disposition (RFC 5987, UTF-8 percent-encoded),
6
+ * filename, il nome che Chrome aveva già scelto, l'ultimo segmento dell'URL.
7
+ */
8
+ export function dispositionName(header) {
9
+ const h = String(header || '');
10
+ const star = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(h);
11
+ if (star) {
12
+ try { return decodeURIComponent(star[2].trim().replace(/^"|"$/g, '')); } catch {}
13
+ }
14
+ const plain = /filename\s*=\s*("((?:[^"\\]|\\.)*)"|[^;]+)/i.exec(h);
15
+ if (plain) return (plain[2] != null ? plain[2].replace(/\\(.)/g, '$1') : plain[1]).trim();
16
+ return '';
17
+ }
18
+
19
+ export function nameFromUrl(url) {
20
+ try {
21
+ const u = new URL(url);
22
+ if (!/^https?:$/.test(u.protocol)) return '';
23
+ const last = u.pathname.split('/').filter(Boolean).pop() || '';
24
+ return decodeURIComponent(last);
25
+ } catch {
26
+ return '';
27
+ }
28
+ }
29
+
30
+ export function downloadName({ disposition, chromeName, url }) {
31
+ return dispositionName(disposition) || String(chromeName || '').split(/[\\/]/).pop() || nameFromUrl(url) || '';
32
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.29.3",
5
- "description": "Bridge your browser to Claude Code: 66 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
4
+ "version": "1.30.0",
5
+ "description": "Bridge your browser to Claude Code: 67 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",
8
8
  "permissions": [
@@ -11,6 +11,7 @@ import { pageFingerprint } from './lib/page-fingerprint.js';
11
11
  import { createPacer } from './lib/capture-pacing.js';
12
12
  import { computeTiles } from './lib/tile-layout.js';
13
13
  import { classifyDownload } from './lib/download-state.js';
14
+ import { downloadName } from './lib/capture-names.js';
14
15
  import { findTextInPage } from './lib/find-text.js';
15
16
  import { elementOutcome, watchNavErrors, navErrorMessage } from './lib/command-outcome.js';
16
17
  import { withDebugger, keyEvents, mouseClickEvents, holdDebugger, releaseDebugger, debuggerHeld } from './lib/trusted-input.js';
@@ -520,6 +521,8 @@ async function executeCommand(msg) {
520
521
  return await cmdSetGeolocation(params);
521
522
  case 'manage_downloads':
522
523
  return await cmdManageDownloads(params);
524
+ case 'capture_files':
525
+ return await cmdCaptureFiles(params);
523
526
  case 'save_page':
524
527
  return await cmdSavePage(params);
525
528
  case 'wait_for_text':
@@ -5026,6 +5029,164 @@ async function cmdManageDownloads({ action, url, filename, timeout = 30000, limi
5026
5029
  throw new Error(`Unknown action: ${action}`);
5027
5030
  }
5028
5031
 
5032
+ // --- capture_downloads ---
5033
+
5034
+ /**
5035
+ * I file che la pagina crea o scarica arrivano al server senza la finestra
5036
+ * «Salva con nome» (14/10/2026). Due vie:
5037
+ * - nella pagina, lib/capture-hook.js prende blob, data URL e
5038
+ * showSaveFilePicker prima che diventino un download;
5039
+ * - qui, un download che parte comunque mentre la scheda è armata viene
5040
+ * annullato appena creato, e i byte si riprendono con i cookie del browser
5041
+ * (http/https), dalla pagina (blob:) o dall'URL stesso (data:).
5042
+ * I byte restano nella pagina o qui finché il server li legge a pezzi.
5043
+ */
5044
+ const captureArmed = new Map(); // tabId → { max }
5045
+ const captureItems = new Map(); // id → { tabId, name, mime, size, bytes, source, error, url, pending }
5046
+ let captureSeq = 0;
5047
+
5048
+ async function injectCaptureHook(tabId, max, frameIds) {
5049
+ const target = frameIds ? { tabId, frameIds } : { tabId, allFrames: true };
5050
+ await chrome.scripting.executeScript({ target, files: ['lib/capture-hook.js'], world: 'MAIN', injectImmediately: true });
5051
+ await chrome.scripting.executeScript({ target, func: (m) => window.__cbCapture?.arm(m), args: [max], world: 'MAIN', injectImmediately: true });
5052
+ }
5053
+
5054
+ // Una navigazione nella scheda armata porta un documento senza hook.
5055
+ chrome.webNavigation.onCommitted.addListener(({ tabId, frameId }) => {
5056
+ const armed = captureArmed.get(tabId);
5057
+ if (armed) injectCaptureHook(tabId, armed.max, [frameId]).catch(() => {});
5058
+ });
5059
+
5060
+ function captureTabFor(item) {
5061
+ if (captureArmed.size === 1) return captureArmed.keys().next().value;
5062
+ return null;
5063
+ }
5064
+
5065
+ async function captureDownload(item) {
5066
+ if (!captureArmed.size || item.byExtensionId === chrome.runtime.id) return;
5067
+ let tabId = captureTabFor(item);
5068
+ if (tabId == null) {
5069
+ const ref = item.referrer || item.url;
5070
+ for (const id of captureArmed.keys()) {
5071
+ try {
5072
+ const tab = await chrome.tabs.get(id);
5073
+ if (tab.url && ref && new URL(tab.url).origin === new URL(ref).origin) { tabId = id; break; }
5074
+ } catch {}
5075
+ }
5076
+ tabId ??= captureArmed.keys().next().value;
5077
+ }
5078
+ const { max } = captureArmed.get(tabId);
5079
+ // Prima di tutto fermarlo: la finestra di salvataggio non deve aprirsi.
5080
+ let browserCopy = null;
5081
+ try { await chrome.downloads.cancel(item.id); } catch {}
5082
+ try {
5083
+ const [now] = await chrome.downloads.search({ id: item.id });
5084
+ if (now?.state === 'complete') browserCopy = now.filename;
5085
+ else await chrome.downloads.erase({ id: item.id });
5086
+ } catch {}
5087
+
5088
+ const url = item.finalUrl || item.url;
5089
+ const id = `d${Date.now().toString(36)}${(captureSeq++).toString(36)}`;
5090
+ const entry = { id, tabId, name: '', mime: item.mime || '', source: 'download', url, pending: true, ...(browserCopy && { browser_copy: browserCopy }) };
5091
+ captureItems.set(id, entry);
5092
+ try {
5093
+ if (/^blob:/i.test(url)) {
5094
+ // Il Blob vive nella pagina: lo prende l'hook, che lo tiene in mappa
5095
+ // anche se la pagina ha già chiamato revokeObjectURL.
5096
+ const origin = url.slice(5).replace(/\/[^/]*$/, '');
5097
+ const probes = await chrome.scripting.executeScript({
5098
+ target: { tabId, allFrames: true }, world: 'MAIN',
5099
+ func: (u, o) => (window.__cbCapture?.blobs.has(u) ? 2 : (location.origin === o ? 1 : 0)),
5100
+ args: [url, origin],
5101
+ });
5102
+ const best = probes.filter((r) => r.result > 0).sort((a, b) => b.result - a.result)[0];
5103
+ if (!best) throw new Error('blob URL from a frame without capture hook');
5104
+ captureItems.delete(id);
5105
+ await chrome.scripting.executeScript({
5106
+ target: { tabId, frameIds: [best.frameId] }, world: 'MAIN',
5107
+ func: (u, n) => window.__cbCapture?.fromUrl(u, n, 'download').then(() => true),
5108
+ args: [url, item.filename ? item.filename.split(/[\\/]/).pop() : ''],
5109
+ });
5110
+ return;
5111
+ }
5112
+ const res = await fetch(url, { credentials: 'include', redirect: 'follow' });
5113
+ const declared = Number(res.headers.get('content-length') || 0);
5114
+ entry.mime = res.headers.get('content-type') || entry.mime;
5115
+ entry.name = downloadName({ disposition: res.headers.get('content-disposition'), chromeName: item.filename, url: res.url || url });
5116
+ if (!res.ok) throw new Error(`refetch answered HTTP ${res.status} (a POST download or a one-time link cannot be fetched again)`);
5117
+ if (declared > max) {
5118
+ try { await res.body?.cancel(); } catch {}
5119
+ throw new Error(`too large: ${declared} bytes, max_mb allows ${max}`);
5120
+ }
5121
+ const bytes = new Uint8Array(await res.arrayBuffer());
5122
+ if (bytes.length > max) throw new Error(`too large: ${bytes.length} bytes, max_mb allows ${max}`);
5123
+ entry.bytes = bytes;
5124
+ entry.size = bytes.length;
5125
+ } catch (e) {
5126
+ entry.error = e.message;
5127
+ }
5128
+ delete entry.pending;
5129
+ }
5130
+
5131
+ chrome.downloads.onCreated.addListener((item) => { captureDownload(item).catch(() => {}); });
5132
+
5133
+ async function cmdCaptureFiles({ op, tab_id, max_bytes, id, frame_id, offset = 0, length = 4 * 1024 * 1024 }) {
5134
+ const tabId = await resolveTabId(tab_id);
5135
+ if (op === 'arm') {
5136
+ const max = Math.max(1, Number(max_bytes) || 100 * 1024 * 1024);
5137
+ captureArmed.set(tabId, { max });
5138
+ await injectCaptureHook(tabId, max);
5139
+ return { armed: true, tabId, max_bytes: max };
5140
+ }
5141
+ if (op === 'disarm') {
5142
+ captureArmed.delete(tabId);
5143
+ for (const [k, v] of captureItems) if (v.tabId === tabId) captureItems.delete(k);
5144
+ try {
5145
+ await chrome.scripting.executeScript({ target: { tabId, allFrames: true }, world: 'MAIN', func: () => window.__cbCapture?.disarm() });
5146
+ } catch {}
5147
+ return { armed: false, tabId };
5148
+ }
5149
+ if (op === 'list') {
5150
+ const items = [];
5151
+ let frames = [];
5152
+ try {
5153
+ frames = await chrome.scripting.executeScript({ target: { tabId, allFrames: true }, world: 'MAIN', func: () => window.__cbCapture?.list() ?? null });
5154
+ } catch {}
5155
+ for (const f of frames) for (const it of f.result ?? []) items.push({ ...it, frame_id: f.frameId });
5156
+ for (const v of captureItems.values()) {
5157
+ if (v.tabId !== tabId) continue;
5158
+ const { bytes, tabId: _t, ...meta } = v;
5159
+ items.push(meta);
5160
+ }
5161
+ return { armed: captureArmed.has(tabId), tabId, items };
5162
+ }
5163
+ if (op === 'read') {
5164
+ if (!id) throw new Error('Missing required parameter: id');
5165
+ const held = captureItems.get(id);
5166
+ if (held) {
5167
+ if (!held.bytes) throw new Error(`capture: ${id} has no bytes (${held.error || 'pending'})`);
5168
+ return { b64: await blobToBase64(new Blob([held.bytes.subarray(offset, offset + length)])) };
5169
+ }
5170
+ const [r] = await chrome.scripting.executeScript({
5171
+ target: scriptTarget(tabId, frame_id ?? 0), world: 'MAIN',
5172
+ func: (i, o, l) => window.__cbCapture ? window.__cbCapture.read(i, o, l) : Promise.reject(new Error('capture hook gone: the page was reloaded')),
5173
+ args: [id, offset, length],
5174
+ });
5175
+ if (r?.error) throw new Error(r.error.message || String(r.error));
5176
+ if (typeof r?.result !== 'string') throw new Error(`capture: could not read ${id}`);
5177
+ return { b64: r.result };
5178
+ }
5179
+ if (op === 'drop') {
5180
+ if (!id) throw new Error('Missing required parameter: id');
5181
+ if (captureItems.delete(id)) return { dropped: id };
5182
+ try {
5183
+ await chrome.scripting.executeScript({ target: scriptTarget(tabId, frame_id ?? 0), world: 'MAIN', func: (i) => window.__cbCapture?.drop(i), args: [id] });
5184
+ } catch {}
5185
+ return { dropped: id };
5186
+ }
5187
+ throw new Error(`Unknown capture op: ${op}`);
5188
+ }
5189
+
5029
5190
  // --- save_page (MHTML via pageCapture) ---
5030
5191
 
5031
5192
  // --- http_request ---
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
3
  "mcpName": "io.github.frsorrentino/chrome-bridge",
4
- "version": "1.29.3",
4
+ "version": "1.30.0",
5
5
  "type": "module",
6
- "description": "Chrome Bridge by frsorrentino: MCP server that lets Claude Code borrow a tab from your own logged-in Chrome and hand it back — logins, 2FA and CAPTCHAs stay with you (handoff). 66 tools; 2.8-4.3× cheaper and up to 9.3× fewer turns than Claude in Chrome, measured; ChromeOS included.",
6
+ "description": "Chrome Bridge by frsorrentino: MCP server that lets Claude Code borrow a tab from your own logged-in Chrome and hand it back — logins, 2FA and CAPTCHAs stay with you (handoff). 67 tools; 2.8-4.3× cheaper and up to 9.3× fewer turns than Claude in Chrome, measured; ChromeOS included.",
7
7
  "main": "server/index.js",
8
8
  "bin": {
9
9
  "chrome-bridge-mcp": "server/index.js",
@@ -0,0 +1,108 @@
1
+ /**
2
+ * capture_downloads, lato server: i file presi dalla pagina (blob, data URL,
3
+ * showSaveFilePicker, download annullati) arrivano qui a pezzi, come
4
+ * upload_file al contrario, e vengono scritti nella cartella scelta all'arm.
5
+ *
6
+ * Il nome lo decide la pagina, quindi è input ostile: si tiene solo l'ultimo
7
+ * segmento, senza caratteri di controllo né riservati, senza punti iniziali
8
+ * (niente .bashrc, .env), e un file esistente non viene mai sovrascritto.
9
+ */
10
+ import { open, rm } from 'node:fs/promises';
11
+ import { extname, join } from 'node:path';
12
+ import { MessageType } from './protocol.js';
13
+
14
+ export const CAPTURE_CHUNK_BYTES = 4 * 1024 * 1024;
15
+ export const CAPTURE_DEFAULT_MB = 100;
16
+ export const CAPTURE_MAX_MB = 200;
17
+
18
+ const EXT_BY_MIME = {
19
+ 'application/pdf': '.pdf', 'text/csv': '.csv', 'application/json': '.json', 'text/plain': '.txt',
20
+ 'application/zip': '.zip', 'image/png': '.png', 'image/jpeg': '.jpg', 'image/svg+xml': '.svg',
21
+ 'text/html': '.html', 'application/xml': '.xml', 'text/xml': '.xml', 'image/webp': '.webp',
22
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
23
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
24
+ };
25
+ const MAX_NAME_BYTES = 180;
26
+
27
+ export function sanitizeFileName(name, mime = '') {
28
+ let n = String(name ?? '').split(/[\\/]/).pop().normalize('NFC');
29
+ n = n.replace(/[\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^[.\s]+/, '').replace(/[.\s]+$/, '');
30
+ if (/^(con|prn|aux|nul|com\d|lpt\d)(\..*)?$/i.test(n)) n = `_${n}`;
31
+ if (!n) n = `download${EXT_BY_MIME[String(mime).split(';')[0].trim().toLowerCase()] ?? ''}`;
32
+ if (Buffer.byteLength(n) > MAX_NAME_BYTES) {
33
+ const ext = extname(n).slice(0, 16);
34
+ let stem = n.slice(0, n.length - ext.length);
35
+ while (Buffer.byteLength(stem + ext) > MAX_NAME_BYTES) stem = stem.slice(0, -1);
36
+ n = stem + ext;
37
+ }
38
+ return n;
39
+ }
40
+
41
+ /** Apre in esclusiva `dir/name`, o `name (1)`, `name (2)`… se esiste già. */
42
+ export async function openUnique(dir, name) {
43
+ const ext = extname(name);
44
+ const stem = name.slice(0, name.length - ext.length);
45
+ for (let i = 0; i < 1000; i++) {
46
+ const path = join(dir, i ? `${stem} (${i})${ext}` : name);
47
+ try {
48
+ return { path, fh: await open(path, 'wx') };
49
+ } catch (e) {
50
+ if (e.code !== 'EEXIST') throw e;
51
+ }
52
+ }
53
+ throw new Error(`capture: too many files named ${name} in ${dir}`);
54
+ }
55
+
56
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
57
+
58
+ /**
59
+ * Aspetta i file catturati nella scheda, li scrive in `dir` e li toglie dalla
60
+ * pagina. Appena compare il primo file aspetta ancora `settle` ms: un export
61
+ * può produrne più d'uno.
62
+ */
63
+ export async function collectCaptured(send, { tab_id, dir, timeout = 15000, settle = 700, poll = 250, chunk = CAPTURE_CHUNK_BYTES }) {
64
+ const started = Date.now();
65
+ let firstSeen = null;
66
+ let list;
67
+ for (;;) {
68
+ list = await send(MessageType.CAPTURE_FILES, { op: 'list', tab_id });
69
+ const items = list?.items ?? [];
70
+ if (items.length && firstSeen == null) firstSeen = Date.now();
71
+ if (items.length && !items.some((i) => i.pending) && Date.now() - firstSeen >= settle) break;
72
+ if (Date.now() - started >= timeout) break;
73
+ await sleep(poll);
74
+ }
75
+ const saved = [];
76
+ const skipped = [];
77
+ const pending = [];
78
+ for (const it of list?.items ?? []) {
79
+ if (it.pending) { pending.push({ name: it.name, source: it.source }); continue; }
80
+ const drop = () => send(MessageType.CAPTURE_FILES, { op: 'drop', id: it.id, frame_id: it.frame_id, tab_id }).catch(() => {});
81
+ if (it.error) {
82
+ skipped.push({ name: it.name, source: it.source, error: it.error, ...(it.url && { url: it.url }) });
83
+ await drop();
84
+ continue;
85
+ }
86
+ const { path, fh } = await openUnique(dir, sanitizeFileName(it.name, it.mime));
87
+ let written = 0;
88
+ try {
89
+ for (let offset = 0; offset < it.size; offset += chunk) {
90
+ const { b64 } = await send(MessageType.CAPTURE_FILES, { op: 'read', id: it.id, frame_id: it.frame_id, offset, length: chunk, tab_id });
91
+ const buf = Buffer.from(b64 ?? '', 'base64');
92
+ await fh.write(buf);
93
+ written += buf.length;
94
+ }
95
+ await fh.close();
96
+ if (written !== it.size) throw new Error(`${written} bytes arrived, ${it.size} expected`);
97
+ } catch (e) {
98
+ await fh.close().catch(() => {});
99
+ await rm(path, { force: true });
100
+ skipped.push({ name: it.name, source: it.source, error: e.message });
101
+ await drop();
102
+ continue;
103
+ }
104
+ saved.push({ path, bytes: written, mime: it.mime || undefined, source: it.source, ...(it.browser_copy && { browser_copy: it.browser_copy }) });
105
+ await drop();
106
+ }
107
+ return { saved, skipped, pending, armed: list?.armed ?? false, waited_ms: Date.now() - started };
108
+ }
package/server/index.js CHANGED
@@ -82,6 +82,12 @@ async function main() {
82
82
  // Il 42% delle sessioni reali caricava i tool con 2+ ToolSearch (media 2,0).
83
83
  'When tools are deferred, batch every tool needed for the next workflow into ONE ToolSearch call using exact names (select:name1,name2,...); do not search one tool at a time.',
84
84
  'Missing optional tools: call get_status({enable:["group",...]}) once for all required groups (audits, visual, network, storage, dom, files, perf; "all" enables all). After tools/list_changed, use them next turn; if still deferred, batch-search their names once. No restart or user configuration needed.',
85
+ // 14/10/2026: nei flussi di lavoro Claude restava fermo sulla finestra
86
+ // «Salva con nome», di sistema e fuori portata dell'estensione. Su ChromeOS
87
+ // con «Chiedi dove salvare ciascun file» si apre anche con saveAs:false.
88
+ 'Saving a file: never click a Download/Export button and wait for the browser download, the OS "Save as" dialog can open and no tool can press it. '
89
+ + 'A file with a URL: http_request with save_to (browser cookies, bytes written to disk, no dialog). '
90
+ + 'A file the page builds itself (blob, data URL, showSaveFilePicker, a download the page starts): capture_downloads arm, then click, then capture_downloads wait.',
85
91
  'A tab in a minimized, covered or background window does not render: screenshots fail, timers slow down. get_page_info reports visibility (page_hidden in a result means the same); bring the window on screen, or create_tab new_window with bounds.',
86
92
  ].join(' '),
87
93
  });
@@ -62,6 +62,7 @@ export const MessageType = Object.freeze({
62
62
  CLIPBOARD: 'clipboard',
63
63
  SET_GEOLOCATION: 'set_geolocation',
64
64
  MANAGE_DOWNLOADS: 'manage_downloads',
65
+ CAPTURE_FILES: 'capture_files',
65
66
  SAVE_PAGE: 'save_page',
66
67
  HTTP_REQUEST: 'http_request',
67
68
  SET_ZOOM: 'set_zoom',
@@ -99,7 +100,7 @@ export const MessageType = Object.freeze({
99
100
  });
100
101
 
101
102
  // Versione
102
- export const VERSION = '1.29.3';
103
+ export const VERSION = '1.30.0';
103
104
 
104
105
  // Configurazione
105
106
  export const DEFAULT_PORT = 8765;
@@ -153,6 +154,7 @@ export function getTimeout(type) {
153
154
  || type === MessageType.UPLOAD_FILE
154
155
  || type === MessageType.UPLOAD_CHUNK
155
156
  || type === MessageType.MANAGE_DOWNLOADS
157
+ || type === MessageType.CAPTURE_FILES
156
158
  || type === MessageType.SAVE_PAGE
157
159
  || type === MessageType.HTTP_REQUEST
158
160
  || type === MessageType.TILE_WINDOWS
package/server/tools.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import { createHash } from 'node:crypto';
9
9
  import { appendFile, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
10
10
  import { homedir } from 'node:os';
11
- import { basename, dirname, extname, join, resolve, sep } from 'node:path';
11
+ import { basename, dirname, extname, isAbsolute, join, resolve, sep } from 'node:path';
12
12
  import { parse as parseHtml } from 'node-html-parser';
13
13
  import { z } from 'zod';
14
14
  import { runAssert } from './assertions.js';
@@ -27,6 +27,7 @@ import { fingerprintDelta } from './effect.js';
27
27
  import { analyzeTrace, traceEventsOf } from './trace-analysis.js';
28
28
  import { assembleVideo } from './video.js';
29
29
  import { uploadBuffer, MAX_UPLOAD_BYTES } from './upload.js';
30
+ import { collectCaptured, CAPTURE_DEFAULT_MB, CAPTURE_MAX_MB } from './capture.js';
30
31
  import { cdpConnect, targetWsUrl, takeHeapSnapshot, summarizeHeap, diffHeap } from './heap.js';
31
32
  import { lighthouseArgs, runLighthouse, summarizeLighthouse } from './lighthouse.js';
32
33
  import { consoleLines, networkLines, interactivesLines, interactiveLine, linksLines } from './formatters.js';
@@ -364,6 +365,7 @@ export const TOOL_ANNOTATIONS = {
364
365
  get_storage: ro(),
365
366
  get_tabs: ro(),
366
367
  manage_downloads: rw({ open: true }), // action=download scrive un file sul disco e va in rete
368
+ capture_downloads: rw({ open: true }), // scrive file nuovi (mai sovrascritti), un download annullato si riprende dalla rete
367
369
  measure_spacing: ro(),
368
370
  animations: rw(), // con action clicca, passa sopra, scorre o preme un tasto
369
371
  frames: rw(),
@@ -2810,7 +2812,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2810
2812
  'manage_downloads',
2811
2813
  'List downloads, start one, or wait for the newest to finish. Files land in the browser Downloads folder, not on the server. '
2812
2814
  + 'download reports the real state: with Chrome set to ask where to save, it comes back waiting_for_user — bytes fetched, '
2813
- + 'nothing moves until someone picks a destination.',
2815
+ + 'nothing moves until someone picks a destination. To save a file that has a URL use http_request with save_to instead: '
2816
+ + 'browser cookies, bytes on disk, no dialog; a file the page builds goes through capture_downloads.',
2814
2817
  {
2815
2818
  action: z.enum(['list', 'wait_for_complete', 'download']).describe('download fetches with the browser cookies and reports the real state; wait_for_complete blocks until the newest finishes'),
2816
2819
  url: z.string().optional().describe('What to download (action=download); sent with the session cookies of its origin'),
@@ -2821,6 +2824,63 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2821
2824
  async ({ action, url, filename, timeout, limit }) => {
2822
2825
  if (action === 'download' && !url) throw new Error('action=download requires url');
2823
2826
  const data = await send(MessageType.MANAGE_DOWNLOADS, { action, url, filename, timeout, limit });
2827
+ // Fermo sul selettore: nessun tool lo preme, la strada è un'altra.
2828
+ if (data?.state === 'waiting_for_user') {
2829
+ data.hint = `The save dialog needs a person. Instead: http_request {url: "${data.url ?? url}", save_to: "/absolute/path"} `
2830
+ + 'fetches with the same cookies and writes the bytes without a dialog.';
2831
+ }
2832
+ return { content: [{ type: 'text', text: jsonText(data) }] };
2833
+ }
2834
+ );
2835
+
2836
+ // --- capture_downloads ---
2837
+ // Cartella e tetto per scheda armata. I nomi li sceglie la pagina: li pulisce
2838
+ // server/capture.js, e la cartella non può stare fra le chiavi.
2839
+ const captures = new Map();
2840
+ const captureTab = (tab_id) => tab_id ?? (captures.size === 1 ? captures.keys().next().value : undefined);
2841
+ server.tool(
2842
+ 'capture_downloads',
2843
+ 'Save files the page builds itself, with no browser download and no "Save as" dialog: blob and data URL links (a[download]), '
2844
+ + 'window.showSaveFilePicker writes, and downloads the page starts (cancelled at once, bytes fetched again with the browser cookies). '
2845
+ + 'arm with save_dir, then click Export/Download, then wait: it returns the saved paths. A file with a URL: http_request save_to instead. '
2846
+ + 'Names are cleaned, existing files never overwritten, size capped by max_mb.',
2847
+ {
2848
+ action: z.enum(['arm', 'wait', 'disarm']).describe('arm before the click; wait returns what arrived and stays armed; disarm restores the page'),
2849
+ save_dir: z.string().optional().describe('arm: absolute directory for the files; created if missing'),
2850
+ max_mb: z.number().optional().describe(`arm: per-file cap in MB (default ${CAPTURE_DEFAULT_MB}, max ${CAPTURE_MAX_MB}); a bigger file is skipped`),
2851
+ timeout: z.number().optional().default(15000).describe('wait: max ms for the first file'),
2852
+ tab_id: tabId,
2853
+ },
2854
+ async ({ action, save_dir, max_mb, timeout, tab_id }) => {
2855
+ if (action === 'arm') {
2856
+ if (!save_dir || !isAbsolute(save_dir)) throw new Error('arm requires save_dir, an absolute directory');
2857
+ const dir = resolve(save_dir);
2858
+ guardWrite(dir);
2859
+ const inside = (p, root) => p === root || p.startsWith(root + sep);
2860
+ if (SECRET_DIRS.some((d) => inside(dir, d))) throw new Error(`Refusing to write into ${dir}: a key or credential folder`);
2861
+ await mkdir(dir, { recursive: true });
2862
+ const real = await realpath(dir);
2863
+ if (SECRET_DIRS.some((d) => inside(real, d))) throw new Error(`Refusing to write into ${real}: a key or credential folder`);
2864
+ guardWrite(real);
2865
+ const mb = Math.min(CAPTURE_MAX_MB, Math.max(1, max_mb ?? CAPTURE_DEFAULT_MB));
2866
+ const data = await send(MessageType.CAPTURE_FILES, { op: 'arm', tab_id, max_bytes: Math.round(mb * 1024 * 1024) });
2867
+ captures.set(data.tabId, { dir: real, mb });
2868
+ return { content: [{ type: 'text', text: jsonText({ armed: true, tab_id: data.tabId, save_dir: real, max_mb: mb, next: 'click the Export/Download control, then capture_downloads wait' }) }] };
2869
+ }
2870
+ const tid = captureTab(tab_id);
2871
+ if (action === 'disarm') {
2872
+ const data = await send(MessageType.CAPTURE_FILES, { op: 'disarm', tab_id: tid });
2873
+ captures.delete(data.tabId);
2874
+ return { content: [{ type: 'text', text: jsonText({ armed: false, tab_id: data.tabId }) }] };
2875
+ }
2876
+ const armed = captures.get(tid);
2877
+ if (!armed) throw new Error('Nothing armed for this tab: capture_downloads arm with save_dir first');
2878
+ const data = await collectCaptured(send, { tab_id: tid, dir: armed.dir, timeout });
2879
+ if (!data.saved.length && !data.skipped.length) {
2880
+ data.hint = data.armed
2881
+ ? 'Nothing captured: no file built by the page and no download started. Check the click hit the control; a file with a URL goes through http_request save_to.'
2882
+ : 'The page lost the capture (reload or navigation): arm again, then click.';
2883
+ }
2824
2884
  return { content: [{ type: 'text', text: jsonText(data) }] };
2825
2885
  }
2826
2886
  );
@@ -2846,7 +2906,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2846
2906
  'http_request',
2847
2907
  'HTTP request sent from the browser, with the logged-in user\'s cookies: fetches what a server-side request gets a login page for '
2848
2908
  + '(invoices, authenticated JSON, exports). Text bodies inline (capped by max_length); save_to writes the bytes — the way to read a PDF, '
2849
- + 'since read_page returns nothing on a PDF tab.',
2909
+ + 'since read_page returns nothing on a PDF tab, and the way to save any file that has a URL: no click on Download, '
2910
+ + 'no browser download, no "Save as" dialog.',
2850
2911
  {
2851
2912
  url: z.string().describe('Absolute URL. Cookies are sent for its origin'),
2852
2913
  method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']).optional().default('GET').describe('HEAD fetches headers only, without the body'),
@@ -173,4 +173,8 @@ out.push((await cb.extract_table({where:{id}})).rows[0]); } return out;'
173
173
  need `chrome.debugger`, which this extension deliberately doesn't hold.
174
174
  - Certificate details and DNS queries: use a checker page.
175
175
  - CAPTCHA, 2FA, passwords: the user does it in the browser; the tab stays theirs.
176
- - A PDF open in the viewer: download it (`manage_downloads`) and read the file.
176
+ - A PDF open in the viewer: `http_request` with `save_to` (its URL, the browser cookies) and read the file.
177
+ - Saving any file: never click Download and wait for the browser download, the
178
+ OS "Save as" dialog can open and nothing can press it. A file with a URL:
179
+ `http_request` `save_to`; a file the page builds (Export button, blob):
180
+ `capture_downloads` arm, click, wait.