chrome-bridge-mcp 1.27.0 → 1.29.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 +7 -4
- package/extension/manifest.json +1 -1
- package/extension/popup.css +1 -0
- package/extension/popup.js +1 -0
- package/extension/service-worker.js +121 -14
- package/package.json +1 -1
- package/server/cli.js +3 -4
- package/server/index.js +1 -0
- package/server/launcher.js +47 -3
- package/server/protocol.js +4 -1
- package/server/tools.js +76 -9
- package/server/upload.js +37 -0
- package/server/ws-manager.js +41 -2
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ CI. Self-hosted, local-only. Works on ChromeOS.
|
|
|
25
25
|
**Requires** Node.js 18+ and Chrome 135+.
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
git clone
|
|
28
|
+
git clone https://github.com/frsorrentino/chrome-bridge.git
|
|
29
29
|
cd chrome-bridge && ./install.sh
|
|
30
30
|
```
|
|
31
31
|
|
|
@@ -169,13 +169,13 @@ 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
|
|---|---|---|
|
|
176
176
|
| Core & Navigation | 13 | tabs, windows, `navigate`, `screenshot`, `tile_windows` |
|
|
177
177
|
| Interaction | 11 | `click`, `fill_form`, `upload_file`, dialogs, clipboard |
|
|
178
|
-
| DOM & Inspection |
|
|
178
|
+
| DOM & Inspection | 12 | `read_page`, `extract`, `extract_table`, `query_dom`, `get_css_styles`, `watch_dom` |
|
|
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` |
|
|
@@ -207,6 +207,7 @@ Environment variables, each with a matching CLI flag:
|
|
|
207
207
|
| `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 |
|
|
208
208
|
| `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
|
|
209
209
|
| `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. Optional caps can also be switched on at runtime with `get_status({enable})` |
|
|
210
|
+
| `CHROME_BRIDGE_ALWAYS_LOAD` / `--always-load` | 22 most used tools | Tools kept in context when the client loads the server whole; the others carry `_meta['anthropic/alwaysLoad'] = false` and wait behind tool search (Claude Code 2.1.285+). `all` loads every tool; a comma list picks them |
|
|
210
211
|
| `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` |
|
|
211
212
|
| `CHROME_BRIDGE_READ_ROOT` / `--read-root` | unset | Where `upload_file` may read from. Unset: any file except keys and credentials (`~/.ssh`, `~/.aws`, `~/.gnupg`, `.env*`, `id_*`, `*.pem`, `*.key`, …), symlinks resolved first. Set: only files under this directory, whatever their name — the way to upload a certificate key on purpose |
|
|
212
213
|
| `CHROME_BRIDGE_BROWSER` | unset | Launch mode only: the browser binary, e.g. `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`. Unset: the first of Chromium, Edge, Brave, Google Chrome found in the standard paths (on Linux Chromium, then Google Chrome). Google Chrome 137+ cannot load the extension |
|
|
@@ -304,12 +305,14 @@ count.
|
|
|
304
305
|
| `read_console` returns `note=Instrumentation not loaded` | The page was opened before the extension, "Capture console & metrics" is off, or the page isn't injectable (`chrome://`). Reload it. |
|
|
305
306
|
| Screenshot times out, or `image readback failed` | A minimized or fully covered window stops producing frames; captures fail after 10 s. Bring the window forward. |
|
|
306
307
|
| `wait_for`/`scroll` return `page_hidden: true`, pages stop updating | Same cause: Chrome does not render a hidden page and slows its timers (after a few minutes, to one wake-up per minute). `get_page_info` reports `visibility`. Bring the window on screen, or `create_tab` with `new_window` and bounds. |
|
|
307
|
-
| `screenshot presets` says `NOT APPLIED` | Presets resize the window; they do not emulate a phone (no device pixel ratio, UA or touch). The window manager enforces a minimum width, and no window exceeds the screen (e.g. at most a 1536×686 viewport on a 1536×864 ChromeOS screen). For phone emulation
|
|
308
|
+
| `screenshot presets` says `NOT APPLIED` | Presets resize the window; they do not emulate a phone (no device pixel ratio, UA or touch). The window manager enforces a minimum width, and no window exceeds the screen (e.g. at most a 1536×686 viewport on a 1536×864 ChromeOS screen). For phone emulation use `emulate_media` with `device` (via debugger), or a headless browser for larger viewports. |
|
|
308
309
|
| `type_text` returns `mismatch: true` | The field rejected the value. Events from an extension have `isTrusted: false`, and some widgets (date pickers, search boxes with tokenizers) discard them. Try `mode: 'keys'`, then the site's own controls (the calendar buttons), or `handoff`. |
|
|
309
310
|
| `[media removed: request limit]` instead of a screenshot | Written by the MCP client, not by the bridge: too many images in one request. Use `save_to`, or `element_screenshot` with `region` and a small `scale`. |
|
|
310
311
|
| Commands work, then stop | The MV3 service worker restarted and in-memory state (network log, diff baselines, HTTP auth) was reset. Re-run the monitoring call. |
|
|
311
312
|
| Extension dropped on every ChromeOS reboot | Install from the Web Store instead of Load unpacked. |
|
|
312
313
|
| Tool missing from the list | It's in an opt-in group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
|
|
314
|
+
| `claude mcp get` shows no command, arguments or environment | Expected with the plugin: since Claude Code 2.1.285 it hides them for stdio servers that plugins provide (variable names stay). `get_status` reports the server and extension versions, mode, host, port and caps; `/mcp` shows the connection. |
|
|
315
|
+
| Popup says «In attesa: un altro browser è collegato», `get_status` lists `refused_browsers` | Two browsers with the extension reach the same port. The one already connected keeps the bridge; the other retries every 15 s and takes over when the first disconnects. `get_status` → `browser` names the connected one. Before this, the last one to connect took over silently. |
|
|
313
316
|
|
|
314
317
|
## Documentation
|
|
315
318
|
|
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.29.0",
|
|
5
5
|
"description": "Bridge your browser to Claude Code: 66 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",
|
package/extension/popup.css
CHANGED
|
@@ -58,6 +58,7 @@ body {
|
|
|
58
58
|
.dot.connected { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
|
|
59
59
|
.dot.connecting { background: #f39c12; }
|
|
60
60
|
.dot.disconnected { background: var(--err); }
|
|
61
|
+
.dot.refused { background: #f39c12; }
|
|
61
62
|
#status-text { font-weight: 600; }
|
|
62
63
|
.status-row .ws-url {
|
|
63
64
|
margin-left: auto;
|
package/extension/popup.js
CHANGED
|
@@ -99,7 +99,38 @@ const KEEPALIVE_ALARM = 'chrome-bridge-keepalive';
|
|
|
99
99
|
let ws = null;
|
|
100
100
|
let reconnectTimer = null;
|
|
101
101
|
let reconnectDelay = RECONNECT_BASE_MS;
|
|
102
|
-
let connectionState = 'disconnected'; // 'connected' | 'connecting' | 'disconnected'
|
|
102
|
+
let connectionState = 'disconnected'; // 'connected' | 'connecting' | 'disconnected' | 'refused'
|
|
103
|
+
// Rifiutata perché un altro browser tiene la porta: si riprova piano, senza
|
|
104
|
+
// riempire il log del server a ogni secondo.
|
|
105
|
+
const REFUSED_RETRY_MS = 15000;
|
|
106
|
+
let refusedBy = null;
|
|
107
|
+
|
|
108
|
+
// Identità del browser per il server: un id che resta fra un riavvio e
|
|
109
|
+
// l'altro (lo stesso browser che si riconnette sostituisce la sua vecchia
|
|
110
|
+
// connessione, un altro browser no) e un'etichetta leggibile in get_status.
|
|
111
|
+
// Calcolata una volta: all'apertura del socket l'ext_init deve partire
|
|
112
|
+
// subito, o il server chiude la connessione non identificata (sotto carico lo
|
|
113
|
+
// storage può metterci secondi). Prima di loadConfig launchMode non è noto:
|
|
114
|
+
// si chiede dopo il primo connect.
|
|
115
|
+
let identityPromise = null;
|
|
116
|
+
function cachedIdentity() {
|
|
117
|
+
identityPromise ??= browserIdentity().catch(() => ({ id: null, label: 'Chromium-based browser' }));
|
|
118
|
+
return identityPromise;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function browserIdentity() {
|
|
122
|
+
let { browserId } = await chrome.storage.local.get({ browserId: null });
|
|
123
|
+
if (!browserId) {
|
|
124
|
+
browserId = crypto.randomUUID();
|
|
125
|
+
await chrome.storage.local.set({ browserId });
|
|
126
|
+
}
|
|
127
|
+
const brands = navigator.userAgentData?.brands ?? [];
|
|
128
|
+
const brand = brands.find((b) => !/Not.?A.?Brand|^Chromium$/i.test(b.brand)) ?? brands.find((b) => /Chromium/i.test(b.brand));
|
|
129
|
+
const os = (await chrome.runtime.getPlatformInfo().catch(() => null))?.os ?? navigator.userAgentData?.platform ?? '';
|
|
130
|
+
const label = [brand ? `${brand.brand} ${brand.version}` : 'Chromium-based browser', os && `on ${os}`, launchMode && '(launch mode)']
|
|
131
|
+
.filter(Boolean).join(' ');
|
|
132
|
+
return { id: browserId, label };
|
|
133
|
+
}
|
|
103
134
|
|
|
104
135
|
let serverVersion = null;
|
|
105
136
|
let sessionStats = { toolCallCount: 0, lastTool: null, lastToolTs: null, recentErrors: [] };
|
|
@@ -184,6 +215,7 @@ function connect() {
|
|
|
184
215
|
}
|
|
185
216
|
|
|
186
217
|
setConnectionState('connecting');
|
|
218
|
+
cachedIdentity();
|
|
187
219
|
|
|
188
220
|
try {
|
|
189
221
|
ws = new WebSocket(wsUrl);
|
|
@@ -195,12 +227,14 @@ function connect() {
|
|
|
195
227
|
|
|
196
228
|
const socket = ws;
|
|
197
229
|
|
|
198
|
-
ws.onopen = () => {
|
|
230
|
+
ws.onopen = async () => {
|
|
199
231
|
console.log('[chrome-bridge] Connected to MCP server');
|
|
200
|
-
const init = { type: 'ext_init', version: chrome.runtime.getManifest().version };
|
|
232
|
+
const init = { type: 'ext_init', version: chrome.runtime.getManifest().version, browser: await cachedIdentity() };
|
|
201
233
|
if (extToken) init.token = extToken;
|
|
202
|
-
|
|
234
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
235
|
+
socket.send(JSON.stringify(init));
|
|
203
236
|
setConnectionState('connected');
|
|
237
|
+
refusedBy = null;
|
|
204
238
|
reconnectDelay = RECONNECT_BASE_MS; // Reset backoff
|
|
205
239
|
};
|
|
206
240
|
|
|
@@ -209,7 +243,7 @@ function connect() {
|
|
|
209
243
|
console.log('[chrome-bridge] Disconnected from MCP server');
|
|
210
244
|
ws = null;
|
|
211
245
|
serverVersion = null;
|
|
212
|
-
setConnectionState('disconnected');
|
|
246
|
+
setConnectionState(refusedBy ? 'refused' : 'disconnected');
|
|
213
247
|
scheduleReconnect();
|
|
214
248
|
};
|
|
215
249
|
|
|
@@ -233,6 +267,12 @@ function connect() {
|
|
|
233
267
|
return;
|
|
234
268
|
}
|
|
235
269
|
|
|
270
|
+
if (msg.type === 'ext_init_refused') {
|
|
271
|
+
refusedBy = msg.connected || 'another browser';
|
|
272
|
+
console.warn(`[chrome-bridge] Refused: ${msg.reason}`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
236
276
|
// Handshake: il server dichiara la sua versione
|
|
237
277
|
if (msg.type === 'ext_init_ok') {
|
|
238
278
|
serverVersion = msg.version || null;
|
|
@@ -271,7 +311,7 @@ function scheduleReconnect() {
|
|
|
271
311
|
reconnectTimer = setTimeout(() => {
|
|
272
312
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
273
313
|
connect();
|
|
274
|
-
}, reconnectDelay);
|
|
314
|
+
}, refusedBy ? REFUSED_RETRY_MS : reconnectDelay);
|
|
275
315
|
}
|
|
276
316
|
|
|
277
317
|
function sendMessage(obj) {
|
|
@@ -458,6 +498,10 @@ async function executeCommand(msg) {
|
|
|
458
498
|
return await cmdScreencast(params);
|
|
459
499
|
case 'cdp_target':
|
|
460
500
|
return await cmdCdpTarget(params);
|
|
501
|
+
case 'upload_chunk':
|
|
502
|
+
return await cmdUploadChunk(params);
|
|
503
|
+
case 'handoff_end':
|
|
504
|
+
return await cmdHandoffEnd(params);
|
|
461
505
|
case 'list_event_listeners':
|
|
462
506
|
return await cmdListEventListeners(params);
|
|
463
507
|
case 'monitor_websocket':
|
|
@@ -1222,6 +1266,25 @@ async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
|
|
|
1222
1266
|
|
|
1223
1267
|
// --- create_tab ---
|
|
1224
1268
|
|
|
1269
|
+
// Bounds reali di una finestra appena creata, confrontati con quelli chiesti.
|
|
1270
|
+
// Il window manager può cambiarli in silenzio: su ChromeOS una finestra di
|
|
1271
|
+
// 320 px nasce larga 501 (15-16/09). Senza il confronto il chiamante misura a
|
|
1272
|
+
// una larghezza che non ha.
|
|
1273
|
+
async function windowBoundsReport(windowId, requested) {
|
|
1274
|
+
const asked = Object.fromEntries(Object.entries(requested).filter(([, v]) => typeof v === 'number'));
|
|
1275
|
+
if (!Object.keys(asked).length) return {};
|
|
1276
|
+
const w = await chrome.windows.get(windowId);
|
|
1277
|
+
const actual = { left: w.left, top: w.top, width: w.width, height: w.height };
|
|
1278
|
+
const differ = Object.keys(asked).filter((k) => actual[k] !== asked[k]);
|
|
1279
|
+
return {
|
|
1280
|
+
window_bounds: actual,
|
|
1281
|
+
...(differ.length && {
|
|
1282
|
+
bounds_note: `The window manager changed ${differ.map((k) => `${k} ${asked[k]}→${actual[k]}`).join(', ')}`
|
|
1283
|
+
+ ' (on ChromeOS a window is at least ~500 px wide). For a narrower viewport use emulate_media({device:{width,height}}) (visual group, debugger).',
|
|
1284
|
+
}),
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1225
1288
|
async function cmdCreateTab({ url, active = true, new_window = false, left, top, width, height }) {
|
|
1226
1289
|
if (new_window) {
|
|
1227
1290
|
// windows.create con i bounds apre direttamente sul monitor scelto: left è
|
|
@@ -1236,9 +1299,11 @@ async function cmdCreateTab({ url, active = true, new_window = false, left, top,
|
|
|
1236
1299
|
if (url && tab) {
|
|
1237
1300
|
await waitForComplete(tab.id);
|
|
1238
1301
|
const updated = await chrome.tabs.get(tab.id);
|
|
1239
|
-
return { id: updated.id, url: updated.url, title: updated.title, window_id: win.id
|
|
1302
|
+
return { id: updated.id, url: updated.url, title: updated.title, window_id: win.id,
|
|
1303
|
+
...await windowBoundsReport(win.id, { left, top, width, height }) };
|
|
1240
1304
|
}
|
|
1241
|
-
return { id: tab?.id, url: tab?.url || 'chrome://newtab', title: tab?.title || '', window_id: win.id
|
|
1305
|
+
return { id: tab?.id, url: tab?.url || 'chrome://newtab', title: tab?.title || '', window_id: win.id,
|
|
1306
|
+
...await windowBoundsReport(win.id, { left, top, width, height }) };
|
|
1242
1307
|
}
|
|
1243
1308
|
const opts = { active };
|
|
1244
1309
|
if (url) opts.url = url;
|
|
@@ -1408,6 +1473,7 @@ async function cmdMoveTab({ tab_id, window_id, new_window = false, window_type,
|
|
|
1408
1473
|
new_window: true,
|
|
1409
1474
|
index: 0,
|
|
1410
1475
|
same_window: before.windowId === win.id,
|
|
1476
|
+
...await windowBoundsReport(win.id, { left, top, width, height }),
|
|
1411
1477
|
};
|
|
1412
1478
|
}
|
|
1413
1479
|
const moved = await chrome.tabs.move(tab_id, { windowId: window_id, index });
|
|
@@ -2762,6 +2828,19 @@ async function cmdHandoff({ message, pick_element = false, ask = false, pick_max
|
|
|
2762
2828
|
});
|
|
2763
2829
|
}
|
|
2764
2830
|
|
|
2831
|
+
// handoff_end: l'utente ha risposto nel terminale (elicitation MCP) prima che
|
|
2832
|
+
// nella pagina. Il banner va tolto e l'handoff in attesa finisce con quella
|
|
2833
|
+
// risposta, così la chiamata handoff originale ritorna una sola volta.
|
|
2834
|
+
async function cmdHandoffEnd({ action = 'done', answer, tab_id }) {
|
|
2835
|
+
const tabId = await resolveTabId(tab_id);
|
|
2836
|
+
const entry = handoffs.get(tabId);
|
|
2837
|
+
if (!entry) return { ended: false };
|
|
2838
|
+
try { await chrome.scripting.executeScript({ target: { tabId }, func: () => document.getElementById('cb-handoff-host')?.remove() }); } catch { /* tab chiusa */ }
|
|
2839
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
2840
|
+
entry.resolve({ done: action === 'done', action, url: tab?.url ?? null, via: 'terminal', ...(typeof answer === 'string' && { answer }) });
|
|
2841
|
+
return { ended: true };
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2765
2844
|
// --- keyboard_walk ---
|
|
2766
2845
|
// Ordine di tabulazione calcolato (tabindex>0 crescente, poi ordine DOM) e
|
|
2767
2846
|
// focus programmatico elemento per elemento. NON sono veri tasti Tab: un
|
|
@@ -3575,27 +3654,55 @@ async function cmdGetFrames({ tab_id }) {
|
|
|
3575
3654
|
|
|
3576
3655
|
// --- upload_file ---
|
|
3577
3656
|
|
|
3578
|
-
|
|
3657
|
+
// upload_chunk: i file oltre 6 MB arrivano a pezzi (server/upload.js). La
|
|
3658
|
+
// pagina li tiene in window.__cbUploads finché upload_file li monta, o un
|
|
3659
|
+
// abort li butta.
|
|
3660
|
+
async function cmdUploadChunk({ upload_id, index, total, content_b64, abort = false, tab_id }) {
|
|
3661
|
+
if (!upload_id) throw new Error('Missing required parameter: upload_id');
|
|
3662
|
+
const tabId = await resolveTabId(tab_id);
|
|
3663
|
+
const results = await chrome.scripting.executeScript({
|
|
3664
|
+
target: { tabId },
|
|
3665
|
+
func: (id, i, n, b64, drop) => {
|
|
3666
|
+
const all = (window.__cbUploads ??= {});
|
|
3667
|
+
if (drop) { delete all[id]; return { aborted: true }; }
|
|
3668
|
+
const u = (all[id] ??= { parts: [], total: n });
|
|
3669
|
+
u.parts[i] = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
3670
|
+
return { received: i + 1, total: n };
|
|
3671
|
+
},
|
|
3672
|
+
args: [upload_id, index ?? 0, total ?? 1, content_b64 ?? '', abort],
|
|
3673
|
+
world: 'MAIN',
|
|
3674
|
+
});
|
|
3675
|
+
return results?.[0]?.result ?? { received: 0 };
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
async function cmdUploadFile({ selector, name, mime_type, content_b64, upload_id, size, tab_id }) {
|
|
3579
3679
|
if (!selector) throw new Error('Missing required parameter: selector');
|
|
3580
|
-
if (!content_b64) throw new Error('Missing required parameter: content_b64');
|
|
3680
|
+
if (!content_b64 && !upload_id) throw new Error('Missing required parameter: content_b64');
|
|
3581
3681
|
const tabId = await resolveTabId(tab_id);
|
|
3582
3682
|
const results = await chrome.scripting.executeScript({
|
|
3583
3683
|
target: { tabId },
|
|
3584
|
-
func: (sel, fname, mime, b64) => {
|
|
3684
|
+
func: (sel, fname, mime, b64, id, expected) => {
|
|
3685
|
+
const pieces = id ? window.__cbUploads?.[id] : null;
|
|
3686
|
+
if (id) delete window.__cbUploads?.[id];
|
|
3585
3687
|
const el = document.querySelector(sel);
|
|
3586
3688
|
if (!el) throw new Error(`Element not found: ${sel}`);
|
|
3587
3689
|
// Un throw qui si perderebbe (elementOutcome lo leggerebbe come «not found»): esito esplicito.
|
|
3588
3690
|
if (!(el instanceof HTMLInputElement) || el.type !== 'file') return { uploaded: false, error: 'Element is not an input[type=file]' };
|
|
3589
|
-
|
|
3691
|
+
if (id && (!pieces || pieces.parts.filter(Boolean).length !== pieces.total)) {
|
|
3692
|
+
return { uploaded: false, error: `Upload ${id} incomplete: ${pieces ? pieces.parts.filter(Boolean).length : 0}/${pieces?.total ?? '?'} pieces (page reloaded meanwhile?)` };
|
|
3693
|
+
}
|
|
3694
|
+
const bytes = id ? new Blob(pieces.parts) : Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
3695
|
+
const length = id ? bytes.size : bytes.length;
|
|
3696
|
+
if (id && expected != null && length !== expected) return { uploaded: false, error: `Upload ${id}: ${length} bytes arrived, ${expected} expected` };
|
|
3590
3697
|
const file = new File([bytes], fname, { type: mime });
|
|
3591
3698
|
const dt = new DataTransfer();
|
|
3592
3699
|
dt.items.add(file);
|
|
3593
3700
|
el.files = dt.files;
|
|
3594
3701
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3595
3702
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
3596
|
-
return { uploaded: fname, size:
|
|
3703
|
+
return { uploaded: fname, size: length, mime, ...(id && { pieces: pieces.total }) };
|
|
3597
3704
|
},
|
|
3598
|
-
args: [selector, name, mime_type, content_b64],
|
|
3705
|
+
args: [selector, name, mime_type, content_b64 ?? null, upload_id ?? null, size ?? null],
|
|
3599
3706
|
world: 'MAIN',
|
|
3600
3707
|
});
|
|
3601
3708
|
return elementOutcome(results, selector);
|
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.29.0",
|
|
5
5
|
"type": "module",
|
|
6
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.",
|
|
7
7
|
"main": "server/index.js",
|
package/server/cli.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* chrome-bridge screenshot --out /tmp/shot.png
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { uploadBuffer } from './upload.js';
|
|
16
17
|
import WebSocket from 'ws';
|
|
17
18
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
18
19
|
import { basename, extname, join } from 'node:path';
|
|
@@ -421,11 +422,9 @@ async function run(client, command, params, opts) {
|
|
|
421
422
|
// Comandi con file I/O lato CLI
|
|
422
423
|
if (command === 'upload_file') {
|
|
423
424
|
const buf = await readFile(params.path);
|
|
424
|
-
if (buf.length > 10 * 1024 * 1024) throw new Error(`File too large: ${buf.length} bytes (max 10MB)`);
|
|
425
425
|
const mime = params.mime_type || MIME_BY_EXT[extname(params.path).toLowerCase()] || 'application/octet-stream';
|
|
426
|
-
const data = await client.sendCommand(
|
|
427
|
-
selector: params.selector, name: basename(params.path), mime_type: mime,
|
|
428
|
-
content_b64: buf.toString('base64'), tab_id: params.tab_id,
|
|
426
|
+
const data = await uploadBuffer((t, p) => client.sendCommand(t, p), {
|
|
427
|
+
selector: params.selector, name: basename(params.path), mime_type: mime, buf, tab_id: params.tab_id,
|
|
429
428
|
});
|
|
430
429
|
return JSON.stringify(data);
|
|
431
430
|
}
|
package/server/index.js
CHANGED
|
@@ -39,6 +39,7 @@ function parseSecurity() {
|
|
|
39
39
|
noJs: process.argv.includes('--no-js') || on(process.env.CHROME_BRIDGE_NO_JS),
|
|
40
40
|
writeRoot: argValue('--write-root') ?? process.env.CHROME_BRIDGE_WRITE_ROOT ?? null,
|
|
41
41
|
readRoot: argValue('--read-root') ?? process.env.CHROME_BRIDGE_READ_ROOT ?? null,
|
|
42
|
+
alwaysLoad: argValue('--always-load') ?? process.env.CHROME_BRIDGE_ALWAYS_LOAD ?? null,
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
|
package/server/launcher.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
|
-
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { dirname, join, win32 } from 'node:path';
|
|
@@ -71,9 +71,45 @@ export function findBrowser() {
|
|
|
71
71
|
return found;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
const LAUNCH_PREFIX = 'chrome-bridge-launch-';
|
|
75
|
+
const STALE_WITHOUT_OWNER_MS = 24 * 3600 * 1000;
|
|
76
|
+
|
|
77
|
+
function processAlive(pid) {
|
|
78
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Cartelle di sessioni launch finite male o mai pulite: su Windows Edge tiene
|
|
82
|
+
// i file del profilo anche dopo la chiusura, rm falliva in silenzio e restavano
|
|
83
|
+
// circa 47 MB a sessione (01-02/10/2026: 73 cartelle, 3,5 GB in %TEMP%). Ogni
|
|
84
|
+
// cartella dice di chi è (owner.json con il pid del server): si cancella se
|
|
85
|
+
// quel processo non esiste più. Senza owner.json (versioni fino alla 1.27) solo
|
|
86
|
+
// dopo 24 ore, per non toccare una sessione in corso.
|
|
87
|
+
export async function sweepStaleLaunchDirs({ dir = tmpdir(), now = Date.now(), alive = processAlive } = {}) {
|
|
88
|
+
const removed = [];
|
|
89
|
+
let names = [];
|
|
90
|
+
try { names = await readdir(dir); } catch { return removed; }
|
|
91
|
+
for (const name of names.filter((n) => n.startsWith(LAUNCH_PREFIX))) {
|
|
92
|
+
const path = join(dir, name);
|
|
93
|
+
let stale;
|
|
94
|
+
try {
|
|
95
|
+
const owner = JSON.parse(await readFile(join(path, 'owner.json'), 'utf8'));
|
|
96
|
+
stale = !alive(owner.pid);
|
|
97
|
+
} catch {
|
|
98
|
+
try { stale = now - (await stat(path)).mtimeMs > STALE_WITHOUT_OWNER_MS; } catch { stale = false; }
|
|
99
|
+
}
|
|
100
|
+
if (!stale) continue;
|
|
101
|
+
try {
|
|
102
|
+
await rm(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
|
103
|
+
removed.push(name);
|
|
104
|
+
} catch { /* ancora bloccata: ci riprova il prossimo avvio */ }
|
|
105
|
+
}
|
|
106
|
+
return removed;
|
|
107
|
+
}
|
|
108
|
+
|
|
74
109
|
/** Prepara dir temporanee: copia estensione + launch.json, profilo con dev mode. */
|
|
75
110
|
export async function prepareLaunch(port) {
|
|
76
|
-
const base = await mkdtemp(join(tmpdir(),
|
|
111
|
+
const base = await mkdtemp(join(tmpdir(), LAUNCH_PREFIX));
|
|
112
|
+
await writeFile(join(base, 'owner.json'), JSON.stringify({ pid: process.pid, started: new Date().toISOString() }));
|
|
77
113
|
const extDir = join(base, 'ext');
|
|
78
114
|
const profileDir = join(base, 'profile');
|
|
79
115
|
|
|
@@ -95,6 +131,8 @@ export async function prepareLaunch(port) {
|
|
|
95
131
|
*/
|
|
96
132
|
export async function launchBrowser({ port, headless = false }) {
|
|
97
133
|
const browser = findBrowser();
|
|
134
|
+
const swept = await sweepStaleLaunchDirs();
|
|
135
|
+
if (swept.length) console.error(`[chrome-bridge] removed ${swept.length} leftover launch profile(s) from earlier sessions`);
|
|
98
136
|
const { base, extDir, profileDir } = await prepareLaunch(port);
|
|
99
137
|
|
|
100
138
|
const args = [
|
|
@@ -128,7 +166,13 @@ export async function launchBrowser({ port, headless = false }) {
|
|
|
128
166
|
proc.once('exit', () => { clearTimeout(t); resolve(); });
|
|
129
167
|
});
|
|
130
168
|
}
|
|
131
|
-
|
|
169
|
+
// Su Windows i processi figli del browser rilasciano i file qualche
|
|
170
|
+
// istante dopo l'uscita del principale: ritentativi invece di un solo
|
|
171
|
+
// tentativo silenzioso. Se non basta, la spazzata del prossimo avvio la
|
|
172
|
+
// trova (owner.json con un pid morto).
|
|
173
|
+
await rm(base, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 }).catch((e) => {
|
|
174
|
+
console.error(`[chrome-bridge] could not remove ${base} (${e.code ?? e.message}); the next launch removes it`);
|
|
175
|
+
});
|
|
132
176
|
};
|
|
133
177
|
|
|
134
178
|
console.error(`[chrome-bridge] launched ${browser}${headless ? ' (headless)' : ''} pid=${proc.pid}, ws port ${port}`);
|
package/server/protocol.js
CHANGED
|
@@ -44,6 +44,7 @@ export const MessageType = Object.freeze({
|
|
|
44
44
|
MOVE_TAB: 'move_tab',
|
|
45
45
|
TILE_WINDOWS: 'tile_windows',
|
|
46
46
|
UPLOAD_FILE: 'upload_file',
|
|
47
|
+
UPLOAD_CHUNK: 'upload_chunk',
|
|
47
48
|
WAIT_FOR_NAVIGATION: 'wait_for_navigation',
|
|
48
49
|
WAIT_FOR_NETWORK_IDLE: 'wait_for_network_idle',
|
|
49
50
|
WAIT_FOR_TEXT: 'wait_for_text',
|
|
@@ -82,6 +83,7 @@ export const MessageType = Object.freeze({
|
|
|
82
83
|
PERF_TRACE: 'perf_trace',
|
|
83
84
|
SCREENCAST: 'screencast',
|
|
84
85
|
CDP_TARGET: 'cdp_target',
|
|
86
|
+
HANDOFF_END: 'handoff_end',
|
|
85
87
|
|
|
86
88
|
// Risposte (extension → server)
|
|
87
89
|
RESULT: 'result',
|
|
@@ -97,7 +99,7 @@ export const MessageType = Object.freeze({
|
|
|
97
99
|
});
|
|
98
100
|
|
|
99
101
|
// Versione
|
|
100
|
-
export const VERSION = '1.
|
|
102
|
+
export const VERSION = '1.29.0';
|
|
101
103
|
|
|
102
104
|
// Configurazione
|
|
103
105
|
export const DEFAULT_PORT = 8765;
|
|
@@ -149,6 +151,7 @@ export function getTimeout(type) {
|
|
|
149
151
|
|| type === MessageType.WAIT_FOR_NETWORK_IDLE
|
|
150
152
|
|| type === MessageType.WAIT_FOR_TEXT
|
|
151
153
|
|| type === MessageType.UPLOAD_FILE
|
|
154
|
+
|| type === MessageType.UPLOAD_CHUNK
|
|
152
155
|
|| type === MessageType.MANAGE_DOWNLOADS
|
|
153
156
|
|| type === MessageType.SAVE_PAGE
|
|
154
157
|
|| type === MessageType.HTTP_REQUEST
|
package/server/tools.js
CHANGED
|
@@ -26,6 +26,7 @@ import { windowLayout } from './layouts.js';
|
|
|
26
26
|
import { fingerprintDelta } from './effect.js';
|
|
27
27
|
import { analyzeTrace, traceEventsOf } from './trace-analysis.js';
|
|
28
28
|
import { assembleVideo } from './video.js';
|
|
29
|
+
import { uploadBuffer, MAX_UPLOAD_BYTES } from './upload.js';
|
|
29
30
|
import { cdpConnect, targetWsUrl, takeHeapSnapshot, summarizeHeap, diffHeap } from './heap.js';
|
|
30
31
|
import { lighthouseArgs, runLighthouse, summarizeLighthouse } from './lighthouse.js';
|
|
31
32
|
import { consoleLines, networkLines, interactivesLines, interactiveLine, linksLines } from './formatters.js';
|
|
@@ -273,6 +274,24 @@ export const TOOL_CAPS = {
|
|
|
273
274
|
files: ['save_page', 'manage_downloads', 'session_record'],
|
|
274
275
|
};
|
|
275
276
|
|
|
277
|
+
// Tool sempre caricati nel contesto quando il client carica il server per
|
|
278
|
+
// intero (alwaysLoad del plugin). Gli altri portano
|
|
279
|
+
// _meta['anthropic/alwaysLoad'] = false e restano dietro la ricerca dei tool
|
|
280
|
+
// (Claude Code 2.1.285+): lo schema pagato a ogni sessione scende, e un tool
|
|
281
|
+
// rimandato si trova con ToolSearch. Scelti dall'uso reale: ogni tool usato
|
|
282
|
+
// almeno 5 volte in 61 sessioni dell'autore (docs/analisi-2026-09-01.md, B.4),
|
|
283
|
+
// più extract_table e get_css_styles: le istruzioni del server li indicano per
|
|
284
|
+
// tabelle e debug, e nel benchmark appaiato del 01/10 (w128a) ogni run heavy
|
|
285
|
+
// caricava extract_table con ToolSearch, un turno in più, e le debug
|
|
286
|
+
// get_css_styles.
|
|
287
|
+
// CHROME_BRIDGE_ALWAYS_LOAD=all li rimette tutti, o una lista ne sceglie altri.
|
|
288
|
+
export const EAGER_TOOLS = [
|
|
289
|
+
'execute_js', 'navigate', 'screenshot', 'find_text', 'click', 'get_interactives', 'create_tab', 'get_tabs',
|
|
290
|
+
'tab_action', 'tile_windows', 'full_page_screenshot', 'extract', 'read_page', 'get_status', 'scroll',
|
|
291
|
+
'wait_for', 'element_screenshot', 'type_text', 'window_layout', 'fill_form', 'extract_table', 'get_css_styles',
|
|
292
|
+
];
|
|
293
|
+
const DEFER_META = { 'anthropic/alwaysLoad': false };
|
|
294
|
+
|
|
276
295
|
// Parametri ubiqui: un solo testo, così `tab_id` non significa una cosa in un
|
|
277
296
|
// tool e un'altra nel gemello. test/unit/tool-parameters.test.js lo verifica.
|
|
278
297
|
// save_to: il payload va su disco e nel contesto resta solo il percorso più un
|
|
@@ -499,8 +518,14 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
499
518
|
// fuori dai gruppi attivi, e le annotations vengono applicate da TOOL_ANNOTATIONS.
|
|
500
519
|
// Un solo wrapper per entrambe le cose, altrimenti con caps != 'all' le
|
|
501
520
|
// annotations sparivano insieme al filtro.
|
|
521
|
+
// Il server MCP a basso livello (capability del client, elicitation): il
|
|
522
|
+
// wrapper qui sotto espone solo tool().
|
|
523
|
+
const lowLevelServer = server.server ?? null;
|
|
502
524
|
{
|
|
503
525
|
const target = server;
|
|
526
|
+
const eagerOpt = options.alwaysLoad ?? null;
|
|
527
|
+
const eager = eagerOpt === 'all' ? null
|
|
528
|
+
: new Set(eagerOpt ? String(eagerOpt).split(',').map((s) => s.trim()).filter(Boolean) : EAGER_TOOLS);
|
|
504
529
|
const enabled = caps !== 'all'
|
|
505
530
|
? new Set(String(caps).split(',').map((s) => s.trim()).filter(Boolean))
|
|
506
531
|
: null;
|
|
@@ -517,6 +542,9 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
517
542
|
// Un tool senza voce resta registrato (meglio di un crash all'avvio):
|
|
518
543
|
// è il test tool-annotations a segnalarlo.
|
|
519
544
|
const reg = annotations ? target.tool(name, desc, schema, annotations, run) : target.tool(name, desc, schema, run);
|
|
545
|
+
// Assegnato e non con update(): update() manderebbe tools/list_changed
|
|
546
|
+
// per ogni tool, prima ancora che il client sia collegato.
|
|
547
|
+
if (eager && !eager.has(name) && reg && typeof reg === 'object') reg._meta = { ...(reg._meta ?? {}), ...DEFER_META };
|
|
520
548
|
if (inactive) { reg.disable(); dormant.set(name, reg); }
|
|
521
549
|
},
|
|
522
550
|
};
|
|
@@ -843,6 +871,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
843
871
|
port: wsManager.port,
|
|
844
872
|
version: VERSION,
|
|
845
873
|
extension_version: wsManager.extVersion ?? null,
|
|
874
|
+
...(wsManager.browser && { browser: wsManager.browser.label }),
|
|
875
|
+
...(wsManager.refusedBrowsers?.length && { refused_browsers: wsManager.refusedBrowsers.map((r) => `${r.label} at ${r.at}`) }),
|
|
846
876
|
// Un agente che non trova accessibility_audit non aveva modo di
|
|
847
877
|
// scoprire che esiste ma è in un gruppo disattivato.
|
|
848
878
|
js_evaluation: !noJs,
|
|
@@ -2219,7 +2249,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
2219
2249
|
// --- upload_file ---
|
|
2220
2250
|
server.tool(
|
|
2221
2251
|
'upload_file',
|
|
2222
|
-
'Set a file on input[type=file] from the server filesystem via DataTransfer (max
|
|
2252
|
+
'Set a file on input[type=file] from the server filesystem via DataTransfer (max 200 MB; over 6 MB it travels in pieces). Keys and credentials (~/.ssh, ~/.aws, '
|
|
2223
2253
|
+ '~/.gnupg, .env*, *.pem, *.key, id_*…) are refused, and a --read-root on the server limits the readable tree: the call fails '
|
|
2224
2254
|
+ 'with an error, nothing is read.',
|
|
2225
2255
|
{
|
|
@@ -2229,12 +2259,12 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
2229
2259
|
tab_id: tabId,
|
|
2230
2260
|
},
|
|
2231
2261
|
async ({ selector, path, mime_type, tab_id }) => {
|
|
2232
|
-
const
|
|
2233
|
-
|
|
2262
|
+
const abs = await guardRead(path);
|
|
2263
|
+
const { size } = await stat(abs);
|
|
2264
|
+
if (size > MAX_UPLOAD_BYTES) throw new Error(`File too large: ${size} bytes (max ${MAX_UPLOAD_BYTES / 1024 / 1024} MB)`);
|
|
2265
|
+
const buf = await readFile(abs);
|
|
2234
2266
|
const mime = mime_type || MIME_BY_EXT[extname(path).toLowerCase()] || 'application/octet-stream';
|
|
2235
|
-
const data = await send(
|
|
2236
|
-
selector, name: basename(path), mime_type: mime, content_b64: buf.toString('base64'), tab_id,
|
|
2237
|
-
});
|
|
2267
|
+
const data = await uploadBuffer(send, { selector, name: basename(path), mime_type: mime, buf, tab_id });
|
|
2238
2268
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
2239
2269
|
}
|
|
2240
2270
|
);
|
|
@@ -2516,11 +2546,13 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
2516
2546
|
ask: z.boolean().optional().default(false).describe('Show a text box in the banner; the reply comes back as answer'),
|
|
2517
2547
|
pick_max: z.number().optional().default(1).describe('With pick_element: up to N elements, the user presses Done when finished'),
|
|
2518
2548
|
timeout: z.number().optional().default(300000).describe('ms to wait for the click, default 5 min'),
|
|
2549
|
+
in_terminal: z.boolean().optional().default(true).describe('Also ask in the Claude Code terminal (MCP elicitation) when the client supports it; the first answer wins. Not for pick_element'),
|
|
2519
2550
|
tab_id: tabId,
|
|
2520
2551
|
},
|
|
2521
|
-
async ({ message, pick_element, ask, pick_max, timeout, tab_id }) => {
|
|
2522
|
-
const
|
|
2523
|
-
const
|
|
2552
|
+
async ({ message, pick_element, ask, pick_max, timeout, in_terminal, tab_id }) => {
|
|
2553
|
+
const banner = send(MessageType.HANDOFF, { message, pick_element, ask, pick_max, timeout, tab_id });
|
|
2554
|
+
const d = in_terminal !== false && !pick_element ? await withTerminalAsk(banner, { message, ask, tab_id }) : await banner;
|
|
2555
|
+
const lines = [`handoff ${d.action}${d.url ? ` url=${d.url}` : ''}${d.via === 'terminal' ? ' via=terminal' : ''}`];
|
|
2524
2556
|
if (ask && typeof d.answer === 'string' && d.answer.trim()) lines.push(`answer: ${d.answer.trim()}`);
|
|
2525
2557
|
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}`;
|
|
2526
2558
|
if (Array.isArray(d.picked_all) && d.picked_all.length) {
|
|
@@ -2529,10 +2561,45 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
2529
2561
|
lines.push(`picked ${fmt(d.picked)}`);
|
|
2530
2562
|
}
|
|
2531
2563
|
if (d.action === 'timeout') lines.push('the user did not click within the timeout: ask before retrying');
|
|
2564
|
+
if (d.banner_left_open) lines.push('answered in the terminal; the page banner is still open (extension older than the server): the user can close it');
|
|
2532
2565
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
2533
2566
|
}
|
|
2534
2567
|
);
|
|
2535
2568
|
|
|
2569
|
+
// handoff anche nel terminale: chi lavora con Claude Code spesso guarda il
|
|
2570
|
+
// terminale, non il browser. Una richiesta form (elicitation MCP) corre in
|
|
2571
|
+
// parallelo al banner e vince la prima risposta: dal terminale si chiude il
|
|
2572
|
+
// banner (handoff_end), dal banner si annulla la richiesta nel terminale.
|
|
2573
|
+
// La modalità URL dell'elicitation non serve qui: aprirebbe il link nel
|
|
2574
|
+
// browser predefinito, che può non essere il Chrome in cui lavora l'agente.
|
|
2575
|
+
async function withTerminalAsk(banner, { message, ask, tab_id }) {
|
|
2576
|
+
const lowLevel = lowLevelServer;
|
|
2577
|
+
if (!lowLevel?.getClientCapabilities?.()?.elicitation) return banner;
|
|
2578
|
+
const abort = new AbortController();
|
|
2579
|
+
const requestedSchema = ask
|
|
2580
|
+
? { type: 'object', properties: { answer: { type: 'string', title: 'Reply' } }, required: ['answer'] }
|
|
2581
|
+
: { type: 'object', properties: {} };
|
|
2582
|
+
const terminal = lowLevel.elicitInput(
|
|
2583
|
+
{ mode: 'form', message: `${message}\n\nDo it in Chrome (a banner there waits too), then accept here; decline to cancel.`, requestedSchema },
|
|
2584
|
+
{ signal: abort.signal, timeout: 24 * 3600 * 1000 },
|
|
2585
|
+
).then((r) => ({ from: 'terminal', r }), () => null);
|
|
2586
|
+
const first = await Promise.race([banner.then((d) => ({ from: 'banner', d })), terminal.then((t) => t ?? banner.then((d) => ({ from: 'banner', d })))]);
|
|
2587
|
+
if (first.from === 'banner') {
|
|
2588
|
+
abort.abort();
|
|
2589
|
+
return first.d;
|
|
2590
|
+
}
|
|
2591
|
+
const accepted = first.r.action === 'accept';
|
|
2592
|
+
const action = accepted ? 'done' : 'cancel';
|
|
2593
|
+
const answer = accepted && ask ? String(first.r.content?.answer ?? '') : undefined;
|
|
2594
|
+
try {
|
|
2595
|
+
const end = await send(MessageType.HANDOFF_END, { action, ...(answer != null && { answer }), tab_id });
|
|
2596
|
+
if (end?.ended) return banner;
|
|
2597
|
+
} catch { /* estensione fino alla 1.27: handoff_end non esiste */ }
|
|
2598
|
+
// Il banner non si è potuto chiudere (estensione vecchia, o handoff già
|
|
2599
|
+
// finito): vale la risposta del terminale, senza aspettare il banner.
|
|
2600
|
+
return { done: accepted, action, url: null, via: 'terminal', ...(answer != null && { answer }), banner_left_open: true };
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2536
2603
|
// --- watch ---
|
|
2537
2604
|
server.tool(
|
|
2538
2605
|
'watch',
|
package/server/upload.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* upload_file oltre i 10 MB: il file passa a pezzi.
|
|
3
|
+
*
|
|
4
|
+
* Fino alla 1.27 il file viaggiava intero in un solo messaggio, in base64,
|
|
5
|
+
* con un tetto di 10 MB (il 01/10/2026 un video per X è stato ricompresso
|
|
6
|
+
* apposta). Ora sopra CHUNK_BYTES il server manda upload_chunk uno alla volta
|
|
7
|
+
* (ciascuno ben sotto i 32 MB di maxPayload del bridge) e la pagina li
|
|
8
|
+
* accumula; upload_file monta il File dai pezzi. Sotto CHUNK_BYTES resta il
|
|
9
|
+
* messaggio unico, che funziona anche con le estensioni fino alla 1.27.
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { MessageType } from './protocol.js';
|
|
13
|
+
|
|
14
|
+
export const CHUNK_BYTES = 6 * 1024 * 1024;
|
|
15
|
+
export const MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
export async function uploadBuffer(send, { selector, name, mime_type, buf, tab_id }) {
|
|
18
|
+
if (buf.length > MAX_UPLOAD_BYTES) throw new Error(`File too large: ${buf.length} bytes (max ${MAX_UPLOAD_BYTES / 1024 / 1024} MB)`);
|
|
19
|
+
if (buf.length <= CHUNK_BYTES) {
|
|
20
|
+
return send(MessageType.UPLOAD_FILE, { selector, name, mime_type, content_b64: buf.toString('base64'), tab_id });
|
|
21
|
+
}
|
|
22
|
+
const upload_id = `u${randomBytes(6).toString('hex')}`;
|
|
23
|
+
const total = Math.ceil(buf.length / CHUNK_BYTES);
|
|
24
|
+
try {
|
|
25
|
+
for (let index = 0; index < total; index++) {
|
|
26
|
+
const part = buf.subarray(index * CHUNK_BYTES, (index + 1) * CHUNK_BYTES);
|
|
27
|
+
await send(MessageType.UPLOAD_CHUNK, { upload_id, index, total, content_b64: part.toString('base64'), tab_id });
|
|
28
|
+
}
|
|
29
|
+
} catch (err) {
|
|
30
|
+
await send(MessageType.UPLOAD_CHUNK, { upload_id, abort: true, tab_id }).catch(() => {});
|
|
31
|
+
if (/Unknown command type: upload_chunk/.test(err.message)) {
|
|
32
|
+
throw new Error(`Files over ${CHUNK_BYTES / 1024 / 1024} MB need the extension from version 1.28: update it, or upload a smaller file`);
|
|
33
|
+
}
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
return send(MessageType.UPLOAD_FILE, { selector, name, mime_type, upload_id, size: buf.length, tab_id });
|
|
37
|
+
}
|
package/server/ws-manager.js
CHANGED
|
@@ -23,6 +23,16 @@ export function relayExpiry(msg) {
|
|
|
23
23
|
return Math.max(PENDING_RELAY_TTL_MS, base, Number.isFinite(asked) && asked > 0 ? asked + 5000 : 0) + 5000;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Stesso browser: stesso id persistente. Le estensioni fino alla 1.27 non
|
|
28
|
+
* mandano un id: fra due di queste vale la sostituzione di sempre (non si
|
|
29
|
+
* possono distinguere), mentre un id contro nessun id sono browser diversi.
|
|
30
|
+
*/
|
|
31
|
+
export function sameBrowser(a, b) {
|
|
32
|
+
if (!a?.id && !b?.id) return true;
|
|
33
|
+
return Boolean(a?.id && b?.id && a.id === b.id);
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
export class WSManager {
|
|
27
37
|
constructor(port = DEFAULT_PORT, opts = {}) {
|
|
28
38
|
this.port = port;
|
|
@@ -49,6 +59,8 @@ export class WSManager {
|
|
|
49
59
|
this.relayExtConnected = undefined; // relay mode: stato estensione riportato dal primary
|
|
50
60
|
this.relayExtVersion = null;
|
|
51
61
|
this._extVersion = null; // versione estensione da ext_init (primary)
|
|
62
|
+
this._browser = null; // { id, label } del browser collegato, da ext_init
|
|
63
|
+
this.refusedBrowsers = []; // ultimi browser rifiutati perché un altro era già collegato
|
|
52
64
|
|
|
53
65
|
// --- primary mode ---
|
|
54
66
|
this.wss = null;
|
|
@@ -99,6 +111,11 @@ export class WSManager {
|
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
/** Versione dell'estensione collegata (da ext_init), o null. */
|
|
114
|
+
/** Browser collegato ({ id, label }) e ultimi rifiutati; in relay mode non noti. */
|
|
115
|
+
get browser() {
|
|
116
|
+
return this.mode === 'relay' ? null : this._browser;
|
|
117
|
+
}
|
|
118
|
+
|
|
102
119
|
get extVersion() {
|
|
103
120
|
return this.mode === 'relay' ? (this.relayExtVersion ?? null) : (this._extVersion ?? null);
|
|
104
121
|
}
|
|
@@ -304,7 +321,7 @@ export class WSManager {
|
|
|
304
321
|
ws.terminate();
|
|
305
322
|
return;
|
|
306
323
|
}
|
|
307
|
-
this._setupChromeClient(ws, msg.version ?? null);
|
|
324
|
+
this._setupChromeClient(ws, msg.version ?? null, msg.browser ?? null);
|
|
308
325
|
return;
|
|
309
326
|
}
|
|
310
327
|
|
|
@@ -316,8 +333,29 @@ export class WSManager {
|
|
|
316
333
|
ws.on('close', () => clearTimeout(idTimer));
|
|
317
334
|
}
|
|
318
335
|
|
|
319
|
-
_setupChromeClient(ws, extVersion = null) {
|
|
336
|
+
_setupChromeClient(ws, extVersion = null, browser = null) {
|
|
337
|
+
// Un secondo browser non prende più la connessione a quello collegato:
|
|
338
|
+
// il 01/10/2026 un Chromium di prova sulla stessa porta ha rubato la
|
|
339
|
+
// connessione al Chrome dell'utente, e i comandi delle altre sessioni
|
|
340
|
+
// finivano nel browser sbagliato. Lo stesso browser che si riconnette
|
|
341
|
+
// (service worker riavviato) sostituisce la sua vecchia connessione; un
|
|
342
|
+
// browser diverso aspetta che l'altro si scolleghi o smetta di rispondere.
|
|
343
|
+
const current = this.client;
|
|
344
|
+
const alive = current && current.readyState === WebSocket.OPEN
|
|
345
|
+
&& Date.now() - this.lastPong <= this.pingIntervalMs * 2 + this.pongGraceMs;
|
|
346
|
+
if (alive && !sameBrowser(this._browser, browser)) {
|
|
347
|
+
const refused = { label: browser?.label ?? 'a browser with an older extension', id: browser?.id ?? null, at: new Date().toISOString() };
|
|
348
|
+
this.refusedBrowsers = [refused, ...this.refusedBrowsers.filter((r) => r.id !== refused.id || r.label !== refused.label)].slice(0, 5);
|
|
349
|
+
const connected = this._browser?.label ?? 'another browser';
|
|
350
|
+
console.error(`[chrome-bridge] Refused ${refused.label}: ${connected} is already connected on this port`);
|
|
351
|
+
try {
|
|
352
|
+
ws.send(JSON.stringify({ type: 'ext_init_refused', connected, reason: `${connected} is already connected to this chrome-bridge` }));
|
|
353
|
+
} catch {}
|
|
354
|
+
ws.close(4409, 'Another browser is connected');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
320
357
|
this._extVersion = extVersion;
|
|
358
|
+
this._browser = browser;
|
|
321
359
|
// Lo skew server/estensione era invisibile: con la latenza di review del
|
|
322
360
|
// Chrome Web Store è la norma, non l'eccezione.
|
|
323
361
|
if (extVersion && extVersion !== VERSION) {
|
|
@@ -363,6 +401,7 @@ export class WSManager {
|
|
|
363
401
|
if (this.client === ws) {
|
|
364
402
|
this.client = null;
|
|
365
403
|
this._extVersion = null;
|
|
404
|
+
this._browser = null;
|
|
366
405
|
this._broadcastExtState();
|
|
367
406
|
// Rigetta pending locali
|
|
368
407
|
this._rejectAllPending('Extension disconnected');
|