chrome-bridge-mcp 1.29.3 → 1.30.1
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 +4 -4
- package/extension/lib/capture-hook.js +221 -0
- package/extension/lib/capture-names.js +35 -0
- package/extension/manifest.json +2 -2
- package/extension/service-worker.js +170 -0
- package/package.json +2 -2
- package/server/capture.js +108 -0
- package/server/index.js +6 -0
- package/server/protocol.js +3 -1
- package/server/tools.js +64 -3
- package/skills/chrome-bridge/SKILL.md +5 -1
package/README.md
CHANGED
|
@@ -164,12 +164,12 @@ user-script toggle isn't available.
|
|
|
164
164
|
|
|
165
165
|
## Tools
|
|
166
166
|
|
|
167
|
-
|
|
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
|
-

|
|
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 |
|
|
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
|
|
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,221 @@
|
|
|
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
|
+
names: new Map(),
|
|
29
|
+
seq: 0,
|
|
30
|
+
};
|
|
31
|
+
Object.defineProperty(window, '__cbCapture', { value: st, configurable: true });
|
|
32
|
+
|
|
33
|
+
const newId = () => `c${Date.now().toString(36)}${(st.seq++).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
34
|
+
|
|
35
|
+
function add(blob, name, source) {
|
|
36
|
+
const item = { id: newId(), name: String(name || ''), mime: blob.type || '', size: blob.size, source };
|
|
37
|
+
if (blob.size > st.max) item.error = `too large: ${blob.size} bytes, max_mb allows ${st.max}`;
|
|
38
|
+
else item.blob = blob;
|
|
39
|
+
st.items.push(item);
|
|
40
|
+
return item;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Un file preso da un URL (blob:, data:) è asincrono: l'item c'è subito,
|
|
44
|
+
// pending, così l'attesa del server lo vede e non chiude prima del tempo.
|
|
45
|
+
async function fromUrl(url, name, source) {
|
|
46
|
+
const item = { id: newId(), name: String(name || ''), source, pending: true };
|
|
47
|
+
st.items.push(item);
|
|
48
|
+
try {
|
|
49
|
+
const blob = st.blobs.get(url) ?? await (await fetch(url)).blob();
|
|
50
|
+
item.mime = blob.type || '';
|
|
51
|
+
item.size = blob.size;
|
|
52
|
+
if (blob.size > st.max) item.error = `too large: ${blob.size} bytes, max_mb allows ${st.max}`;
|
|
53
|
+
else item.blob = blob;
|
|
54
|
+
} catch (e) {
|
|
55
|
+
item.error = `could not read ${url.slice(0, 60)}: ${e.message}`;
|
|
56
|
+
}
|
|
57
|
+
delete item.pending;
|
|
58
|
+
return item;
|
|
59
|
+
}
|
|
60
|
+
st.fromUrl = fromUrl;
|
|
61
|
+
|
|
62
|
+
const origCreate = URL.createObjectURL;
|
|
63
|
+
URL.createObjectURL = function createObjectURL(obj) {
|
|
64
|
+
const url = origCreate.call(URL, obj);
|
|
65
|
+
if (st.armed && obj instanceof Blob) st.blobs.set(url, obj);
|
|
66
|
+
return url;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// a[download] su un URL http(s): lo scarica Chrome, e il service worker lo
|
|
70
|
+
// annulla a onCreated, quando Chrome non ha ancora scelto il nome. Qui si
|
|
71
|
+
// segna il nome dell'attributo, che il service worker chiede per quell'URL.
|
|
72
|
+
const noteName = (a) => {
|
|
73
|
+
if (st.armed && a instanceof HTMLAnchorElement && a.hasAttribute('download') && /^https?:/i.test(a.href || '')) {
|
|
74
|
+
const n = a.getAttribute('download');
|
|
75
|
+
if (n) st.names.set(a.href, n);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const capturable = (a) => a instanceof HTMLAnchorElement && a.hasAttribute('download') && /^(blob|data):/i.test(a.href || '');
|
|
80
|
+
const take = (a) => fromUrl(a.href, a.getAttribute('download') || '', a.href.startsWith('data:') ? 'data_url' : 'blob');
|
|
81
|
+
|
|
82
|
+
// Click veri o sintetici su un'ancora nel documento: bolla fino a window.
|
|
83
|
+
// Se la pagina ha già annullato il click, il download non era voluto.
|
|
84
|
+
const handled = new WeakSet();
|
|
85
|
+
window.addEventListener('click', (e) => {
|
|
86
|
+
if (!st.armed || e.defaultPrevented || handled.has(e)) return;
|
|
87
|
+
const a = e.composedPath().find((n) => n instanceof HTMLAnchorElement);
|
|
88
|
+
noteName(a);
|
|
89
|
+
if (!capturable(a)) return;
|
|
90
|
+
handled.add(e);
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
take(a);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// a.click() su un'ancora staccata: l'evento non arriva a window. Lo si
|
|
96
|
+
// spedisce qui, così i listener della pagina girano comunque. Anche un click
|
|
97
|
+
// sintetico attiva l'ancora (verificato 14/10: blob preso due volte, una
|
|
98
|
+
// dall'hook e una dal download): un listener sull'ancora, aggiunto dopo
|
|
99
|
+
// quelli della pagina, annulla l'azione nativa se la pagina non l'ha fatto.
|
|
100
|
+
const origClick = HTMLAnchorElement.prototype.click;
|
|
101
|
+
HTMLAnchorElement.prototype.click = function click() {
|
|
102
|
+
if (!st.armed || !capturable(this)) {
|
|
103
|
+
noteName(this);
|
|
104
|
+
return origClick.apply(this, arguments);
|
|
105
|
+
}
|
|
106
|
+
const ev = new MouseEvent('click', { bubbles: true, cancelable: true, composed: true, view: window });
|
|
107
|
+
let wanted = false;
|
|
108
|
+
const last = (e) => {
|
|
109
|
+
if (e !== ev || e.defaultPrevented) return;
|
|
110
|
+
wanted = true;
|
|
111
|
+
handled.add(e);
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
};
|
|
114
|
+
this.addEventListener('click', last);
|
|
115
|
+
try { this.dispatchEvent(ev); } finally { this.removeEventListener('click', last); }
|
|
116
|
+
if (wanted) take(this);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// --- showSaveFilePicker ---
|
|
120
|
+
const toBytes = async (d) => {
|
|
121
|
+
if (typeof d === 'string') return new TextEncoder().encode(d);
|
|
122
|
+
if (d instanceof Blob) return new Uint8Array(await d.arrayBuffer());
|
|
123
|
+
if (ArrayBuffer.isView(d)) return new Uint8Array(d.buffer, d.byteOffset, d.byteLength);
|
|
124
|
+
if (d instanceof ArrayBuffer) return new Uint8Array(d);
|
|
125
|
+
throw new TypeError('chrome-bridge capture: unsupported data for write()');
|
|
126
|
+
};
|
|
127
|
+
const isParams = (d) => d && typeof d === 'object' && !(d instanceof Blob) && !ArrayBuffer.isView(d) && !(d instanceof ArrayBuffer) && 'type' in d;
|
|
128
|
+
|
|
129
|
+
function makeHandle(name, mime) {
|
|
130
|
+
let committed = new Blob([], { type: mime });
|
|
131
|
+
const handle = {
|
|
132
|
+
kind: 'file',
|
|
133
|
+
name,
|
|
134
|
+
getFile: async () => new File([committed], name, { type: mime }),
|
|
135
|
+
createWritable: async (opts = {}) => {
|
|
136
|
+
let buf = new Uint8Array(0);
|
|
137
|
+
let len = 0;
|
|
138
|
+
let pos = 0;
|
|
139
|
+
if (opts.keepExistingData) {
|
|
140
|
+
buf = new Uint8Array(await committed.arrayBuffer());
|
|
141
|
+
len = buf.length;
|
|
142
|
+
}
|
|
143
|
+
const ensure = (n) => {
|
|
144
|
+
if (n > st.max) throw new DOMException(`chrome-bridge capture: ${name} over max_mb`, 'QuotaExceededError');
|
|
145
|
+
if (n <= buf.length) return;
|
|
146
|
+
const nb = new Uint8Array(Math.max(n, buf.length * 2, 64 * 1024));
|
|
147
|
+
nb.set(buf.subarray(0, len));
|
|
148
|
+
buf = nb;
|
|
149
|
+
};
|
|
150
|
+
const apply = async (d) => {
|
|
151
|
+
if (isParams(d)) {
|
|
152
|
+
if (d.type === 'seek') { pos = d.position; return; }
|
|
153
|
+
if (d.type === 'truncate') {
|
|
154
|
+
ensure(d.size);
|
|
155
|
+
if (d.size > len) buf.fill(0, len, d.size);
|
|
156
|
+
len = d.size;
|
|
157
|
+
if (pos > len) pos = len;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (d.position != null) pos = d.position;
|
|
161
|
+
d = d.data;
|
|
162
|
+
}
|
|
163
|
+
const b = await toBytes(d);
|
|
164
|
+
ensure(pos + b.length);
|
|
165
|
+
buf.set(b, pos);
|
|
166
|
+
pos += b.length;
|
|
167
|
+
if (pos > len) len = pos;
|
|
168
|
+
};
|
|
169
|
+
let closed = false;
|
|
170
|
+
const finish = () => {
|
|
171
|
+
if (closed) return;
|
|
172
|
+
closed = true;
|
|
173
|
+
committed = new Blob([buf.slice(0, len)], { type: mime });
|
|
174
|
+
add(committed, name, 'showSaveFilePicker');
|
|
175
|
+
};
|
|
176
|
+
const ws = new WritableStream({ write: (chunk) => apply(chunk), close: finish });
|
|
177
|
+
// write/seek/truncate/close diretti, come FileSystemWritableFileStream;
|
|
178
|
+
// pipeTo e getWriter passano dal sink qui sopra.
|
|
179
|
+
Object.assign(ws, {
|
|
180
|
+
write: (d) => apply(d),
|
|
181
|
+
seek: async (p) => { pos = p; },
|
|
182
|
+
truncate: async (size) => apply({ type: 'truncate', size }),
|
|
183
|
+
close: async () => finish(),
|
|
184
|
+
});
|
|
185
|
+
try { if (window.FileSystemWritableFileStream) Object.setPrototypeOf(ws, FileSystemWritableFileStream.prototype); } catch {}
|
|
186
|
+
return ws;
|
|
187
|
+
},
|
|
188
|
+
queryPermission: async () => 'granted',
|
|
189
|
+
requestPermission: async () => 'granted',
|
|
190
|
+
isSameEntry: async (other) => other === handle,
|
|
191
|
+
};
|
|
192
|
+
try { if (window.FileSystemFileHandle) Object.setPrototypeOf(handle, FileSystemFileHandle.prototype); } catch {}
|
|
193
|
+
return handle;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const origPicker = window.showSaveFilePicker;
|
|
197
|
+
if (typeof origPicker === 'function') {
|
|
198
|
+
window.showSaveFilePicker = function showSaveFilePicker(opts = {}) {
|
|
199
|
+
if (!st.armed) return origPicker.apply(this, arguments);
|
|
200
|
+
const first = opts?.types?.[0]?.accept ?? {};
|
|
201
|
+
const mime = Object.keys(first).find((m) => !m.includes('*')) || '';
|
|
202
|
+
const ext = [].concat(Object.values(first)[0] ?? [])[0] || '';
|
|
203
|
+
const name = opts?.suggestedName || `download${ext}`;
|
|
204
|
+
return Promise.resolve(makeHandle(String(name), mime));
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Dal service worker: elenco, lettura a pezzi, scarto.
|
|
209
|
+
st.list = () => st.items.map(({ blob, ...meta }) => meta);
|
|
210
|
+
st.read = async (id, offset, length) => {
|
|
211
|
+
const it = st.items.find((x) => x.id === id);
|
|
212
|
+
if (!it?.blob) throw new Error(`capture: no file ${id} in this page`);
|
|
213
|
+
const part = new Uint8Array(await it.blob.slice(offset, offset + length).arrayBuffer());
|
|
214
|
+
let s = '';
|
|
215
|
+
for (let i = 0; i < part.length; i += 0x8000) s += String.fromCharCode.apply(null, part.subarray(i, i + 0x8000));
|
|
216
|
+
return btoa(s);
|
|
217
|
+
};
|
|
218
|
+
st.drop = (id) => { st.items = st.items.filter((x) => x.id !== id); };
|
|
219
|
+
st.arm = (max) => { st.armed = true; st.max = max; };
|
|
220
|
+
st.disarm = () => { st.armed = false; st.items = []; st.blobs.clear(); st.names.clear(); };
|
|
221
|
+
})();
|
|
@@ -0,0 +1,35 @@
|
|
|
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, l'attributo download dell'ancora cliccata (come in Chrome, dopo
|
|
7
|
+
* Content-Disposition), il nome che Chrome aveva già scelto, l'ultimo segmento
|
|
8
|
+
* dell'URL. A onCreated Chrome di solito non ha ancora scelto il nome: senza
|
|
9
|
+
* l'attributo, <a href="/" download="x.html"> arrivava come download.html.
|
|
10
|
+
*/
|
|
11
|
+
export function dispositionName(header) {
|
|
12
|
+
const h = String(header || '');
|
|
13
|
+
const star = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(h);
|
|
14
|
+
if (star) {
|
|
15
|
+
try { return decodeURIComponent(star[2].trim().replace(/^"|"$/g, '')); } catch {}
|
|
16
|
+
}
|
|
17
|
+
const plain = /filename\s*=\s*("((?:[^"\\]|\\.)*)"|[^;]+)/i.exec(h);
|
|
18
|
+
if (plain) return (plain[2] != null ? plain[2].replace(/\\(.)/g, '$1') : plain[1]).trim();
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function nameFromUrl(url) {
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(url);
|
|
25
|
+
if (!/^https?:$/.test(u.protocol)) return '';
|
|
26
|
+
const last = u.pathname.split('/').filter(Boolean).pop() || '';
|
|
27
|
+
return decodeURIComponent(last);
|
|
28
|
+
} catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function downloadName({ disposition, chromeName, pageName, url }) {
|
|
34
|
+
return dispositionName(disposition) || String(pageName || '') || String(chromeName || '').split(/[\\/]/).pop() || nameFromUrl(url) || '';
|
|
35
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Bridge your browser to Claude Code:
|
|
4
|
+
"version": "1.30.1",
|
|
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,173 @@ 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
|
+
let pageName = '';
|
|
5116
|
+
try {
|
|
5117
|
+
const probes = await chrome.scripting.executeScript({
|
|
5118
|
+
target: { tabId, allFrames: true }, world: 'MAIN',
|
|
5119
|
+
func: (u) => window.__cbCapture?.names.get(u) ?? '',
|
|
5120
|
+
args: [item.url],
|
|
5121
|
+
});
|
|
5122
|
+
pageName = probes.map((r) => r.result).find(Boolean) || '';
|
|
5123
|
+
} catch {}
|
|
5124
|
+
entry.name = downloadName({ disposition: res.headers.get('content-disposition'), chromeName: item.filename, pageName, url: res.url || url });
|
|
5125
|
+
if (!res.ok) throw new Error(`refetch answered HTTP ${res.status} (a POST download or a one-time link cannot be fetched again)`);
|
|
5126
|
+
if (declared > max) {
|
|
5127
|
+
try { await res.body?.cancel(); } catch {}
|
|
5128
|
+
throw new Error(`too large: ${declared} bytes, max_mb allows ${max}`);
|
|
5129
|
+
}
|
|
5130
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
5131
|
+
if (bytes.length > max) throw new Error(`too large: ${bytes.length} bytes, max_mb allows ${max}`);
|
|
5132
|
+
entry.bytes = bytes;
|
|
5133
|
+
entry.size = bytes.length;
|
|
5134
|
+
} catch (e) {
|
|
5135
|
+
entry.error = e.message;
|
|
5136
|
+
}
|
|
5137
|
+
delete entry.pending;
|
|
5138
|
+
}
|
|
5139
|
+
|
|
5140
|
+
chrome.downloads.onCreated.addListener((item) => { captureDownload(item).catch(() => {}); });
|
|
5141
|
+
|
|
5142
|
+
async function cmdCaptureFiles({ op, tab_id, max_bytes, id, frame_id, offset = 0, length = 4 * 1024 * 1024 }) {
|
|
5143
|
+
const tabId = await resolveTabId(tab_id);
|
|
5144
|
+
if (op === 'arm') {
|
|
5145
|
+
const max = Math.max(1, Number(max_bytes) || 100 * 1024 * 1024);
|
|
5146
|
+
captureArmed.set(tabId, { max });
|
|
5147
|
+
await injectCaptureHook(tabId, max);
|
|
5148
|
+
return { armed: true, tabId, max_bytes: max };
|
|
5149
|
+
}
|
|
5150
|
+
if (op === 'disarm') {
|
|
5151
|
+
captureArmed.delete(tabId);
|
|
5152
|
+
for (const [k, v] of captureItems) if (v.tabId === tabId) captureItems.delete(k);
|
|
5153
|
+
try {
|
|
5154
|
+
await chrome.scripting.executeScript({ target: { tabId, allFrames: true }, world: 'MAIN', func: () => window.__cbCapture?.disarm() });
|
|
5155
|
+
} catch {}
|
|
5156
|
+
return { armed: false, tabId };
|
|
5157
|
+
}
|
|
5158
|
+
if (op === 'list') {
|
|
5159
|
+
const items = [];
|
|
5160
|
+
let frames = [];
|
|
5161
|
+
try {
|
|
5162
|
+
frames = await chrome.scripting.executeScript({ target: { tabId, allFrames: true }, world: 'MAIN', func: () => window.__cbCapture?.list() ?? null });
|
|
5163
|
+
} catch {}
|
|
5164
|
+
for (const f of frames) for (const it of f.result ?? []) items.push({ ...it, frame_id: f.frameId });
|
|
5165
|
+
for (const v of captureItems.values()) {
|
|
5166
|
+
if (v.tabId !== tabId) continue;
|
|
5167
|
+
const { bytes, tabId: _t, ...meta } = v;
|
|
5168
|
+
items.push(meta);
|
|
5169
|
+
}
|
|
5170
|
+
return { armed: captureArmed.has(tabId), tabId, items };
|
|
5171
|
+
}
|
|
5172
|
+
if (op === 'read') {
|
|
5173
|
+
if (!id) throw new Error('Missing required parameter: id');
|
|
5174
|
+
const held = captureItems.get(id);
|
|
5175
|
+
if (held) {
|
|
5176
|
+
if (!held.bytes) throw new Error(`capture: ${id} has no bytes (${held.error || 'pending'})`);
|
|
5177
|
+
return { b64: await blobToBase64(new Blob([held.bytes.subarray(offset, offset + length)])) };
|
|
5178
|
+
}
|
|
5179
|
+
const [r] = await chrome.scripting.executeScript({
|
|
5180
|
+
target: scriptTarget(tabId, frame_id ?? 0), world: 'MAIN',
|
|
5181
|
+
func: (i, o, l) => window.__cbCapture ? window.__cbCapture.read(i, o, l) : Promise.reject(new Error('capture hook gone: the page was reloaded')),
|
|
5182
|
+
args: [id, offset, length],
|
|
5183
|
+
});
|
|
5184
|
+
if (r?.error) throw new Error(r.error.message || String(r.error));
|
|
5185
|
+
if (typeof r?.result !== 'string') throw new Error(`capture: could not read ${id}`);
|
|
5186
|
+
return { b64: r.result };
|
|
5187
|
+
}
|
|
5188
|
+
if (op === 'drop') {
|
|
5189
|
+
if (!id) throw new Error('Missing required parameter: id');
|
|
5190
|
+
if (captureItems.delete(id)) return { dropped: id };
|
|
5191
|
+
try {
|
|
5192
|
+
await chrome.scripting.executeScript({ target: scriptTarget(tabId, frame_id ?? 0), world: 'MAIN', func: (i) => window.__cbCapture?.drop(i), args: [id] });
|
|
5193
|
+
} catch {}
|
|
5194
|
+
return { dropped: id };
|
|
5195
|
+
}
|
|
5196
|
+
throw new Error(`Unknown capture op: ${op}`);
|
|
5197
|
+
}
|
|
5198
|
+
|
|
5029
5199
|
// --- save_page (MHTML via pageCapture) ---
|
|
5030
5200
|
|
|
5031
5201
|
// --- 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.
|
|
4
|
+
"version": "1.30.1",
|
|
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).
|
|
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
|
});
|
package/server/protocol.js
CHANGED
|
@@ -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.
|
|
103
|
+
export const VERSION = '1.30.1';
|
|
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:
|
|
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.
|