chrome-bridge-mcp 1.19.0 → 1.20.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 +53 -0
- package/extension/lib/element-label.js +60 -0
- package/extension/manifest.json +1 -1
- package/extension/service-worker.js +4 -1
- package/package.json +1 -1
- package/server/index.js +6 -1
- package/server/observe.js +314 -0
- package/server/protocol.js +1 -1
- package/server/tools.js +49 -6
package/README.md
CHANGED
|
@@ -156,6 +156,8 @@ Environment variables, each with a matching CLI flag:
|
|
|
156
156
|
| `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
|
|
157
157
|
| `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
|
|
158
158
|
| `CHROME_BRIDGE_NO_JS` / `--no-js` | unset | No arbitrary JavaScript in the page: `execute_js` and `modify_dom` leave the schema, `wait_for(condition=function)` and `javascript:`/`data:` URLs are refused. `get_status` reports `js_evaluation` |
|
|
159
|
+
| `CHROME_BRIDGE_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 |
|
|
160
|
+
| `CHROME_BRIDGE_OBSERVE` | unset | `off` stops the local error log below. The plugin sets `hook`: its hooks keep the log and the server stays out of it |
|
|
159
161
|
| `CHROME_BRIDGE_WRITE_ROOT` / `--write-root` | unset | Every path the model chooses (`save_to`, `output_path`, exports) must be under this directory, checked before the browser does any work; the server's own state under `~/.config/chrome-bridge` stays writable. The CLI is your shell and is not restricted |
|
|
160
162
|
|
|
161
163
|
The bridge binds loopback, accepts extension connections only from a
|
|
@@ -171,6 +173,57 @@ exports and screenshots are **not** redacted and may carry cookies, tokens or
|
|
|
171
173
|
personal data. Don't point the automation at pages holding secrets you wouldn't
|
|
172
174
|
paste into a chat.
|
|
173
175
|
|
|
176
|
+
### Threat model
|
|
177
|
+
|
|
178
|
+
chrome-bridge gives an AI agent your real, logged-in browser. Scanners that
|
|
179
|
+
rate MCP servers by capability flag it, correctly, for code execution
|
|
180
|
+
(`execute_js`), requests that carry your cookies (`http_request`) and reading
|
|
181
|
+
private page data (`get_storage`, `get_tabs`). Those are the product, not bugs.
|
|
182
|
+
What matters is who can reach them and what a hostile page can make the agent
|
|
183
|
+
do with them.
|
|
184
|
+
|
|
185
|
+
- **Who can connect:** see above — loopback bind, `chrome-extension://` origin
|
|
186
|
+
check, `CHROME_BRIDGE_TOKEN` on both handshakes.
|
|
187
|
+
- **The real risk is prompt injection.** A page can ask the agent to run
|
|
188
|
+
JavaScript, send a request with your cookies, submit a form or upload a file.
|
|
189
|
+
Treat every page you automate as untrusted input, and review what the agent
|
|
190
|
+
proposes on sites that hold money or credentials.
|
|
191
|
+
- **Reduce the surface:** `--caps core` (the default) exposes the smallest set;
|
|
192
|
+
`--no-js` removes arbitrary JavaScript; `--write-root` fences every path the
|
|
193
|
+
model writes; `upload_file` refuses keys and credentials, and `--read-root`
|
|
194
|
+
fences it to one directory. For sessions that matter, automate in a separate
|
|
195
|
+
Chrome profile.
|
|
196
|
+
- **`--no-js` is not a sandbox:** `inject_css` stays, and CSS attribute
|
|
197
|
+
selectors with `url()` can leak attribute values to a remote host.
|
|
198
|
+
- **What leaves your machine** is what the browser sends on the agent's behalf.
|
|
199
|
+
chrome-bridge itself has no telemetry; the error log below stays local unless
|
|
200
|
+
you approve an issue.
|
|
201
|
+
|
|
202
|
+
### Local error log
|
|
203
|
+
|
|
204
|
+
When one of its own tools fails, chrome-bridge adds a line to a **local** file,
|
|
205
|
+
`${XDG_STATE_HOME:-~/.local/state}/claude-observe/chrome-bridge.jsonl` (mode
|
|
206
|
+
0600, in a 0700 folder), in the [claude-observe](https://github.com/frsorrentino/claude-observe)
|
|
207
|
+
format (the plugin's copy is in `observe/`). With the plugin, a `PostToolUseFailure` hook writes it; with the npm
|
|
208
|
+
server alone, the server does. The same error seen again is one line with a
|
|
209
|
+
count.
|
|
210
|
+
|
|
211
|
+
- **Kept:** the tool name, the error text after scrubbing (your home folder
|
|
212
|
+
becomes `~`; emails, URL queries, secrets and typed values are removed), the
|
|
213
|
+
names of the parameters and the length of text values, the chrome-bridge
|
|
214
|
+
version, the OS, the name of the working folder and of the Claude Code config
|
|
215
|
+
folder. With the plugin, also the Claude Code and model versions, the
|
|
216
|
+
session id and the names of the last three tools called.
|
|
217
|
+
- **Never kept:** parameter values — no URLs, selectors, typed text or page
|
|
218
|
+
content.
|
|
219
|
+
- **Nothing leaves your computer** unless you say yes. With the plugin, after a
|
|
220
|
+
few errors have piled up Claude may offer, at a natural moment, to send them
|
|
221
|
+
as one GitHub issue: it shows you the anonymized text first and sends it only
|
|
222
|
+
after your confirmation. The server alone never sends or offers anything.
|
|
223
|
+
- **Turn it off:** `{"enabled": false}` in `~/.config/claude-observe/config.json`
|
|
224
|
+
(every plugin that uses claude-observe), or `CHROME_BRIDGE_OBSERVE=off` for
|
|
225
|
+
the server. Keep the log but stop the offers: `{"propose": false}`.
|
|
226
|
+
|
|
174
227
|
## Troubleshooting
|
|
175
228
|
|
|
176
229
|
| Symptom | Cause / fix |
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* labelOf: il nome con cui un utente riconosce un elemento interattivo.
|
|
3
|
+
* Prima get_interactives prendeva aria-label || value || textContent: i campi
|
|
4
|
+
* con <label for>, <label> avvolgente o aria-labelledby uscivano senza nome
|
|
5
|
+
* (httpbin.org/forms/post: Customer name, Telephone, E-mail…) e radio e
|
|
6
|
+
* checkbox mostravano il value («small», «bacon») invece del testo visibile.
|
|
7
|
+
*
|
|
8
|
+
* Ordine: aria-label, aria-labelledby, <label> (el.labels copre for e
|
|
9
|
+
* avvolgente), testo o value per bottoni e altri elementi che non sono campi,
|
|
10
|
+
* title, placeholder. Spazi normalizzati, 80 caratteri.
|
|
11
|
+
*
|
|
12
|
+
* Non è un modulo ES: chrome.scripting serializza solo la funzione iniettata,
|
|
13
|
+
* quindi get_interactives inietta prima questo file (files:, world MAIN) e poi
|
|
14
|
+
* usa globalThis.__cbLabelOf. Nei test Node si importa per side effect, come
|
|
15
|
+
* stack-detect.js.
|
|
16
|
+
*/
|
|
17
|
+
(() => {
|
|
18
|
+
const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
19
|
+
// Nel testo di un <label> avvolgente non entrano il campo stesso né le
|
|
20
|
+
// opzioni di una select: «Size <select>…» deve dare «Size».
|
|
21
|
+
const SKIP = new Set(['INPUT', 'SELECT', 'TEXTAREA', 'SCRIPT', 'STYLE', 'TEMPLATE']);
|
|
22
|
+
const textOf = (node) => {
|
|
23
|
+
if (node.nodeType === 3) return node.textContent || '';
|
|
24
|
+
if (node.nodeType !== 1 || SKIP.has(node.tagName)) return '';
|
|
25
|
+
let out = '';
|
|
26
|
+
for (const c of node.childNodes) out += textOf(c);
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
const BUTTON_INPUT = /^(submit|button|reset)$/i;
|
|
30
|
+
|
|
31
|
+
function labelOf(el, doc) {
|
|
32
|
+
const d = doc || el.ownerDocument;
|
|
33
|
+
const attr = (n) => (el.getAttribute(n) || '').trim();
|
|
34
|
+
|
|
35
|
+
const aria = clean(attr('aria-label'));
|
|
36
|
+
if (aria) return aria;
|
|
37
|
+
|
|
38
|
+
const ids = attr('aria-labelledby').split(/\s+/).filter(Boolean);
|
|
39
|
+
if (ids.length && d) {
|
|
40
|
+
const t = clean(ids.map((id) => { const n = d.getElementById(id); return n ? n.textContent : ''; }).join(' '));
|
|
41
|
+
if (t) return t;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const lab of el.labels || []) {
|
|
45
|
+
const t = clean(textOf(lab));
|
|
46
|
+
if (t) return t;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tag = el.tagName;
|
|
50
|
+
const isField = tag === 'SELECT' || tag === 'TEXTAREA' || (tag === 'INPUT' && !BUTTON_INPUT.test(el.getAttribute('type') || ''));
|
|
51
|
+
if (!isField) {
|
|
52
|
+
const t = clean(tag === 'INPUT' ? el.value : el.textContent);
|
|
53
|
+
if (t) return t;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return clean(attr('title')) || clean(attr('placeholder'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
globalThis.__cbLabelOf = labelOf;
|
|
60
|
+
})();
|
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.20.1",
|
|
5
5
|
"description": "Bridge your browser to Claude Code: 59 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
|
|
6
6
|
"homepage_url": "https://github.com/frsorrentino/chrome-bridge",
|
|
7
7
|
"minimum_chrome_version": "135",
|
|
@@ -4633,11 +4633,14 @@ chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
|
|
|
4633
4633
|
|
|
4634
4634
|
async function cmdGetInteractives({ scope, limit = 100, visible_only = true, tab_id, frame_id }) {
|
|
4635
4635
|
const tabId = await resolveTabId(tab_id);
|
|
4636
|
+
// labelOf vive in un file a sé (testato in test/unit): va iniettato prima
|
|
4637
|
+
await chrome.scripting.executeScript({ target: scriptTarget(tabId, frame_id), files: ['lib/element-label.js'], world: 'MAIN' });
|
|
4636
4638
|
const results = await chrome.scripting.executeScript({
|
|
4637
4639
|
target: scriptTarget(tabId, frame_id),
|
|
4638
4640
|
func: (scopeSel, lim, visibleOnly) => {
|
|
4639
4641
|
const root = scopeSel ? document.querySelector(scopeSel) : document;
|
|
4640
4642
|
if (!root) return { count: 0, elements: [], note: 'Scope element not found' };
|
|
4643
|
+
const labelOf = globalThis.__cbLabelOf;
|
|
4641
4644
|
|
|
4642
4645
|
// Selettore stabile e riusabile: id > data-testid > path nth-of-type
|
|
4643
4646
|
const stableSelector = (el) => {
|
|
@@ -4677,7 +4680,7 @@ async function cmdGetInteractives({ scope, limit = 100, visible_only = true, tab
|
|
|
4677
4680
|
const top = document.elementFromPoint(cx, cy);
|
|
4678
4681
|
occluded = !!top && top !== el && !el.contains(top) && !top.contains(el);
|
|
4679
4682
|
}
|
|
4680
|
-
const label = (el
|
|
4683
|
+
const label = labelOf(el, document);
|
|
4681
4684
|
out.push({
|
|
4682
4685
|
selector: stableSelector(el),
|
|
4683
4686
|
tag: el.tagName.toLowerCase(),
|
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.20.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "MCP server with 59 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
|
|
7
7
|
"main": "server/index.js",
|
package/server/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
12
12
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
13
13
|
import { WSManager } from './ws-manager.js';
|
|
14
14
|
import { registerTools } from './tools.js';
|
|
15
|
+
import { createObserver } from './observe.js';
|
|
15
16
|
import { launchBrowser } from './launcher.js';
|
|
16
17
|
import { DEFAULT_PORT, VERSION } from './protocol.js';
|
|
17
18
|
|
|
@@ -37,6 +38,7 @@ function parseSecurity() {
|
|
|
37
38
|
return {
|
|
38
39
|
noJs: process.argv.includes('--no-js') || on(process.env.CHROME_BRIDGE_NO_JS),
|
|
39
40
|
writeRoot: argValue('--write-root') ?? process.env.CHROME_BRIDGE_WRITE_ROOT ?? null,
|
|
41
|
+
readRoot: argValue('--read-root') ?? process.env.CHROME_BRIDGE_READ_ROOT ?? null,
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -107,7 +109,10 @@ async function main() {
|
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
// 3. Registra i tool MCP (filtrati per capability)
|
|
110
|
-
|
|
112
|
+
// Errori dei tool annotati in locale (formato claude-observe) quando nessun
|
|
113
|
+
// hook del plugin li vede: vedi server/observe.js.
|
|
114
|
+
const observe = createObserver({ version: VERSION });
|
|
115
|
+
session.tools = registerTools(mcpServer, wsManager, parseCaps(), { ...parseSecurity(), observe });
|
|
111
116
|
|
|
112
117
|
// 4. Avvia il trasporto stdio MCP
|
|
113
118
|
const transport = new StdioServerTransport();
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errori dei tool annotati in locale quando il server gira senza il plugin
|
|
3
|
+
* (npm, cartella locale): nessun hook di Claude Code li vede, quindi li
|
|
4
|
+
* scrive il server stesso, nel formato di claude-observe (FORMAT.md, v1).
|
|
5
|
+
*
|
|
6
|
+
* Con il plugin installato gli errori li scrive l'hook PostToolUseFailure
|
|
7
|
+
* (observe/observe.py), che ha anche il contesto della sessione; il plugin
|
|
8
|
+
* passa CHROME_BRIDGE_OBSERVE=hook e qui non si scrive nulla, altrimenti ogni
|
|
9
|
+
* errore conterebbe due volte.
|
|
10
|
+
*
|
|
11
|
+
* Stesse regole della copia Python, perché i record finiscono nello stesso
|
|
12
|
+
* file e si deduplicano per id:
|
|
13
|
+
* - id = <tool[:12]>-<sha256(tool + "\0" + key)[:8]>, key = errore normalizzato;
|
|
14
|
+
* - dei parametri solo nomi dei campi e lunghezze, mai i valori;
|
|
15
|
+
* - errore ripulito (home, email, query degli URL, segreti, valori digitati);
|
|
16
|
+
* - due lock, in quest'ordine: flock su <dir>/.lock dove c'è flock(1), poi
|
|
17
|
+
* sempre la cartella <plugin>.jsonl.lock creata con mkdir; file 0600 in una
|
|
18
|
+
* cartella 0700, sostituzione atomica;
|
|
19
|
+
* - lock occupato oltre l'attesa: la voce va in <plugin>.pending.jsonl con
|
|
20
|
+
* una scrittura in append, e la incorpora il prossimo che ha il lock;
|
|
21
|
+
* - un file con record di formato più nuovo non si riscrive.
|
|
22
|
+
* scrub e normalize sono il porting di quelle di observe.py: il test
|
|
23
|
+
* observe-parity li confronta sugli stessi testi.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
import {
|
|
28
|
+
appendFileSync, chmodSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, statSync,
|
|
29
|
+
unlinkSync, writeSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { homedir, release, type } from 'node:os';
|
|
32
|
+
import { basename, join } from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
|
|
35
|
+
export const TOOL = 'chrome-bridge';
|
|
36
|
+
export const FORMAT = 1;
|
|
37
|
+
const MCP_PREFIX = 'mcp__chrome-bridge__';
|
|
38
|
+
const DEFAULTS = { enabled: true, dir: '', max_records: 2000, max_days: 90 };
|
|
39
|
+
|
|
40
|
+
// \w di Python è Unicode: qui lo stesso insieme, esplicito.
|
|
41
|
+
const W = '[\\p{L}\\p{N}_]';
|
|
42
|
+
|
|
43
|
+
export function scrub(text, limit = 300) {
|
|
44
|
+
let t = String(text ?? '');
|
|
45
|
+
const home = homedir();
|
|
46
|
+
if (home && home !== '/') t = t.split(home).join('~');
|
|
47
|
+
t = t.replace(new RegExp(`(?:${W}|[.+-])+@(?:${W}|-)+\\.(?:${W}|[.-])+`, 'gu'), '<EMAIL>');
|
|
48
|
+
t = t.replace(/(https?:\/\/[^/\s?#"'»]+)[^\s"'»]*/gu, '$1/…');
|
|
49
|
+
t = t.replace(/\b(token|password|passwd|secret|api[_-]?key|authorization|bearer)("?\s*[:=]\s*|\s+)\S+/giu, '$1$2<SECRET>');
|
|
50
|
+
t = t.replace(/\b(value_after|value|text)("?\s*[:=]?\s*)("[^"]*"|'[^']*'|«[^»]*»)/giu, '$1$2<STR>');
|
|
51
|
+
t = t.replace(/\b(?=[A-Za-z0-9_\-+/]*\d)(?=[A-Za-z0-9_\-+/]*[A-Za-z])[A-Za-z0-9_\-+/=]{24,}\b/gu, '<SECRET>');
|
|
52
|
+
return Array.from(t).slice(0, limit).join('');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalize(text) {
|
|
56
|
+
let t = scrub(text, 400);
|
|
57
|
+
t = t.replace(/«[^»]*»|"[^"]*"|'[^']*'/gu, '<STR>');
|
|
58
|
+
t = t.replace(new RegExp(`(~|\\.{0,2})/(?:${W}|[.\\-/~<>…])+`, 'gu'), '<PATH>');
|
|
59
|
+
t = t.replace(/\d+/gu, '<N>');
|
|
60
|
+
return Array.from(t.replace(/\s+/gu, ' ').trim()).slice(0, 300).join('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Di tool_input solo la forma: nomi dei campi, lunghezza dei testi, tipo del resto.
|
|
64
|
+
export function shape(input) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [k, v] of Object.entries(input ?? {})) {
|
|
67
|
+
if (typeof v === 'string') out[k] = Array.from(v).length;
|
|
68
|
+
else if (typeof v === 'boolean') out[k] = 'bool';
|
|
69
|
+
else if (typeof v === 'number') out[k] = 'number';
|
|
70
|
+
else if (Array.isArray(v)) out[k] = `list[${v.length}]`;
|
|
71
|
+
else if (v && typeof v === 'object') out[k] = `object[${Object.keys(v).length}]`;
|
|
72
|
+
else out[k] = 'null';
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function rid(tool, key) {
|
|
78
|
+
return `${tool.slice(0, 12)}-${createHash('sha256').update(`${tool}\0${key}`).digest('hex').slice(0, 8)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const expand = (p, env) => String(p ?? '')
|
|
82
|
+
.replace(/^~(?=$|\/)/, homedir())
|
|
83
|
+
.replace(/\$\{(\w+)\}|\$(\w+)/g, (m, a, b) => env[a ?? b] ?? m);
|
|
84
|
+
|
|
85
|
+
export function loadConfig(env = process.env) {
|
|
86
|
+
const base = env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
87
|
+
const path = env.CLAUDE_OBSERVE_CONFIG || join(base, 'claude-observe', 'config.json');
|
|
88
|
+
try {
|
|
89
|
+
return { ...DEFAULTS, ...(JSON.parse(readFileSync(path, 'utf8')) || {}) };
|
|
90
|
+
} catch {
|
|
91
|
+
return { ...DEFAULTS };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function boxDir(config, env = process.env) {
|
|
96
|
+
if (config.dir) return expand(config.dir, env);
|
|
97
|
+
return join(env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'claude-observe');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// L'account è la cartella di config di Claude Code: il confine fra organizzazioni.
|
|
101
|
+
function accountName(env) {
|
|
102
|
+
return basename(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Il record come lo scriverebbe record_external/record di observe.py, più la forma dei parametri. */
|
|
106
|
+
export function buildEntry({ name, args, message, durationMs, version, env = process.env, cwd = process.cwd() }) {
|
|
107
|
+
const call = name.startsWith(MCP_PREFIX) ? name : `${MCP_PREFIX}${name}`;
|
|
108
|
+
const err = String(message ?? '');
|
|
109
|
+
const key = normalize(`${call} ${err}`);
|
|
110
|
+
const context = { tool_version: version || '', os: `${type()} ${release()}` };
|
|
111
|
+
return {
|
|
112
|
+
id: rid(TOOL, key),
|
|
113
|
+
fields: {
|
|
114
|
+
context, source: 'server', kind: 'error', call, error: scrub(err), key,
|
|
115
|
+
account: accountName(env), project: basename(cwd),
|
|
116
|
+
},
|
|
117
|
+
example: { input_shape: shape(args), error_raw: scrub(err), duration_ms: durationMs ?? null, context },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readRecords(path) {
|
|
122
|
+
let text = '';
|
|
123
|
+
try { text = readFileSync(path, 'utf8'); } catch { return []; }
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const line of text.split('\n')) {
|
|
126
|
+
if (!line.trim()) continue;
|
|
127
|
+
try { out.push(JSON.parse(line)); } catch { /* riga rotta: si salta, come observe.py */ }
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function rotate(recs, config, now) {
|
|
133
|
+
const days = Number(config.max_days) || 90;
|
|
134
|
+
const cap = Number(config.max_records) || 2000;
|
|
135
|
+
let kept = recs.filter((r) => now - Number(r.last_seen || 0) <= days * 86400);
|
|
136
|
+
if (kept.length > cap) {
|
|
137
|
+
// Come observe.py: prima i record chiusi, poi i più vecchi.
|
|
138
|
+
kept.sort((a, b) => ((a.status !== 'done') - (b.status !== 'done')) || (Number(a.last_seen || 0) - Number(b.last_seen || 0)));
|
|
139
|
+
kept = kept.slice(kept.length - cap);
|
|
140
|
+
}
|
|
141
|
+
return kept;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Aggiunge o aggiorna il record nella lista: la stessa logica di apply() in
|
|
145
|
+
// observe.py (count, last_seen, fino a tre esempi, campi scritti a mano).
|
|
146
|
+
function applyRecord(recs, id, fields, example, now) {
|
|
147
|
+
let r = recs.find((x) => x.id === id);
|
|
148
|
+
if (!r) {
|
|
149
|
+
r = {
|
|
150
|
+
v: FORMAT, id, tool: TOOL, count: 0, first_seen: now, examples: [], workaround: null,
|
|
151
|
+
class: null, status: 'new', ...fields,
|
|
152
|
+
};
|
|
153
|
+
recs.push(r);
|
|
154
|
+
} else {
|
|
155
|
+
for (const k of ['workaround', 'class', 'note', 'context']) if (fields[k]) r[k] = fields[k];
|
|
156
|
+
if (r.status === 'done' && String(fields.source || '').startsWith('hook')) r.status = 'new';
|
|
157
|
+
}
|
|
158
|
+
r.count = Number(r.count || 0) + (example != null || !r.count ? 1 : 0);
|
|
159
|
+
r.last_seen = Math.max(Number(r.last_seen || 0), now);
|
|
160
|
+
if (example != null) r.examples = [...(r.examples || []).slice(-2), { ...example, at: now }];
|
|
161
|
+
return r;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const pendingPath = (dir) => join(dir, `${TOOL}.pending.jsonl`);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Legge, incorpora le voci in attesa, aggiorna e riscrive il file. Va
|
|
168
|
+
* chiamata con i lock presi. null = file di formato più nuovo, non toccato.
|
|
169
|
+
*/
|
|
170
|
+
export function applyEntry(dir, entry, config, nowSec = Date.now() / 1000) {
|
|
171
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
172
|
+
chmodSync(dir, 0o700);
|
|
173
|
+
const path = join(dir, `${TOOL}.jsonl`);
|
|
174
|
+
const recs = readRecords(path);
|
|
175
|
+
// Le voci rimaste in attesa (lock occupato, FORMAT.md «Pending»): si
|
|
176
|
+
// spostano da parte e si applicano come appena registrate; il file preso si
|
|
177
|
+
// cancella solo dopo la riscrittura.
|
|
178
|
+
const pending = pendingPath(dir);
|
|
179
|
+
let taken = null;
|
|
180
|
+
try {
|
|
181
|
+
const t = `${join(dir, `${TOOL}.pending`)}.${process.pid}.taking`;
|
|
182
|
+
renameSync(pending, t);
|
|
183
|
+
taken = t;
|
|
184
|
+
} catch { /* nessuna voce in attesa */ }
|
|
185
|
+
const takenText = taken ? readFileSync(taken, 'utf8') : '';
|
|
186
|
+
// Un file scritto da una copia più nuova (formato incompatibile): non si
|
|
187
|
+
// tocca, e le voci prese tornano in attesa.
|
|
188
|
+
if (recs.some((r) => Number(r.v || 1) > FORMAT)) {
|
|
189
|
+
if (taken) { appendFileSync(pending, takenText, { mode: 0o600 }); unlinkSync(taken); }
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
for (const line of takenText.split('\n')) {
|
|
193
|
+
if (!line.trim()) continue;
|
|
194
|
+
try {
|
|
195
|
+
const e = JSON.parse(line);
|
|
196
|
+
if (Number(e.v || 1) <= FORMAT && e.id) applyRecord(recs, e.id, e.fields || {}, e.example ?? null, Number(e.at) || nowSec);
|
|
197
|
+
} catch { /* riga rotta: si salta */ }
|
|
198
|
+
}
|
|
199
|
+
const r = entry ? applyRecord(recs, entry.id, entry.fields, entry.example, Math.round(nowSec * 1000) / 1000) : null;
|
|
200
|
+
const kept = rotate(recs, config, nowSec);
|
|
201
|
+
const tmp = `${path}.tmp`;
|
|
202
|
+
const fd = openSync(tmp, 'w', 0o600);
|
|
203
|
+
try {
|
|
204
|
+
writeSync(fd, kept.map((x) => JSON.stringify(x)).join('\n') + (kept.length ? '\n' : ''));
|
|
205
|
+
} finally {
|
|
206
|
+
closeSync(fd);
|
|
207
|
+
}
|
|
208
|
+
chmodSync(tmp, 0o600);
|
|
209
|
+
renameSync(tmp, path);
|
|
210
|
+
if (taken) unlinkSync(taken);
|
|
211
|
+
return r ?? true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Lock occupato oltre l'attesa: la voce non si perde, va in
|
|
216
|
+
* <plugin>.pending.jsonl con UNA scrittura in append (atomica senza lock per
|
|
217
|
+
* righe così piccole). La incorpora il prossimo scrittore che ha il lock.
|
|
218
|
+
*/
|
|
219
|
+
export function appendPending(dir, entry, nowSec = Date.now() / 1000) {
|
|
220
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
221
|
+
const line = JSON.stringify({
|
|
222
|
+
v: FORMAT, tool: TOOL, id: entry.id, fields: entry.fields, example: entry.example ?? null,
|
|
223
|
+
at: Math.round(nowSec * 1000) / 1000,
|
|
224
|
+
}) + '\n';
|
|
225
|
+
const fd = openSync(pendingPath(dir), 'a', 0o600);
|
|
226
|
+
try { writeSync(fd, line); } finally { closeSync(fd); }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Secondo lock del protocollo (FORMAT.md, «Lock»), sempre preso: la cartella
|
|
230
|
+
// <plugin>.jsonl.lock creata con mkdir, atomica ovunque. Chi non ha flock(1)
|
|
231
|
+
// prende solo questa. Una cartella più vecchia di 10 s è di un processo
|
|
232
|
+
// morto; l'attesa complessiva si ferma a deadline (circa 3 s dall'inizio).
|
|
233
|
+
export const LOCK_WAIT_MS = 3000;
|
|
234
|
+
const LOCK_STALE_MS = 10000;
|
|
235
|
+
export const BUSY = Symbol('lock busy');
|
|
236
|
+
|
|
237
|
+
export async function withDirLock(dir, fn, deadline = Date.now() + LOCK_WAIT_MS) {
|
|
238
|
+
const lock = join(dir, `${TOOL}.jsonl.lock`);
|
|
239
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
240
|
+
for (;;) {
|
|
241
|
+
try { mkdirSync(lock); break; } catch {
|
|
242
|
+
try {
|
|
243
|
+
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { rmdirSync(lock); continue; }
|
|
244
|
+
} catch { continue; /* sparita nel frattempo: si riprova subito */ }
|
|
245
|
+
if (Date.now() > deadline) return BUSY;
|
|
246
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
try { return fn(); } finally { try { rmdirSync(lock); } catch { /* già tolta */ } }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
253
|
+
// Codici d'uscita del figlio: lock occupato (flock -E o cartella) contro
|
|
254
|
+
// guasto vero. Solo il primo manda la voce in attesa.
|
|
255
|
+
const EXIT_BUSY = 75;
|
|
256
|
+
|
|
257
|
+
// Primo lock, dove c'è flock(1): la scrittura gira in un figlio sotto
|
|
258
|
+
// `flock -x <dir>/.lock`, lo stesso lock di fcntl.flock in observe.py. Le
|
|
259
|
+
// copie distribuite fino ad a787654 prendono solo quello: così nuove e vecchie
|
|
260
|
+
// convivono. Dentro, il figlio prende la cartella di lock e scrive.
|
|
261
|
+
function writeUnderFlock(dir, entry, env, deadline) {
|
|
262
|
+
const wait = String(Math.max(1, Math.ceil((deadline - Date.now()) / 1000)));
|
|
263
|
+
return new Promise((resolve) => {
|
|
264
|
+
const child = spawn('flock', ['-x', '-w', wait, '-E', String(EXIT_BUSY), join(dir, '.lock'), process.execPath, SELF, '--write'], {
|
|
265
|
+
stdio: ['pipe', 'ignore', 'ignore'], env,
|
|
266
|
+
});
|
|
267
|
+
child.on('error', () => resolve('no-flock'));
|
|
268
|
+
child.on('exit', (code) => resolve(code === 0 ? 'ok' : code === EXIT_BUSY ? 'busy' : 'failed'));
|
|
269
|
+
child.stdin.end(JSON.stringify({ dir, entry, deadline }));
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* L'osservatore del server, o null quando non deve scrivere: il plugin ha già
|
|
275
|
+
* l'hook (CHROME_BRIDGE_OBSERVE=hook) o l'utente l'ha spento (=off).
|
|
276
|
+
*/
|
|
277
|
+
export function createObserver({ env = process.env, version = '' } = {}) {
|
|
278
|
+
const mode = String(env.CHROME_BRIDGE_OBSERVE ?? '').toLowerCase();
|
|
279
|
+
if (['hook', 'off', '0', 'false', 'no'].includes(mode)) return null;
|
|
280
|
+
let chain = Promise.resolve();
|
|
281
|
+
return {
|
|
282
|
+
error(name, args, message, durationMs) {
|
|
283
|
+
chain = chain.then(async () => {
|
|
284
|
+
const config = loadConfig(env);
|
|
285
|
+
if (config.enabled === false || config.tools?.[TOOL]?.enabled === false) return;
|
|
286
|
+
const dir = boxDir(config, env);
|
|
287
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
288
|
+
const entry = buildEntry({ name, args, message, durationMs, version, env });
|
|
289
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
290
|
+
let how = await writeUnderFlock(dir, entry, env, deadline);
|
|
291
|
+
if (how === 'no-flock') {
|
|
292
|
+
how = (await withDirLock(dir, () => applyEntry(dir, entry, config), deadline)) === BUSY ? 'busy' : 'ok';
|
|
293
|
+
}
|
|
294
|
+
if (how === 'busy') appendPending(dir, entry);
|
|
295
|
+
}).catch(() => { /* annotare un errore non deve mai produrne un altro */ });
|
|
296
|
+
return chain;
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Modalità figlio: `node observe.js --write` con {dir, entry, deadline} su stdin.
|
|
302
|
+
if (process.argv[1] === SELF && process.argv[2] === '--write') {
|
|
303
|
+
let input = '';
|
|
304
|
+
process.stdin.on('data', (d) => { input += d; });
|
|
305
|
+
process.stdin.on('end', () => {
|
|
306
|
+
try {
|
|
307
|
+
const { dir, entry, deadline } = JSON.parse(input);
|
|
308
|
+
withDirLock(dir, () => applyEntry(dir, entry, loadConfig(process.env)), deadline)
|
|
309
|
+
.then((r) => process.exit(r === BUSY ? EXIT_BUSY : 0), () => process.exit(1));
|
|
310
|
+
} catch {
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
package/server/protocol.js
CHANGED
package/server/tools.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* e restituisce il risultato al client MCP.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { appendFile, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { basename, dirname, extname, join, resolve, sep } from 'node:path';
|
|
11
11
|
import { parse as parseHtml } from 'node-html-parser';
|
|
@@ -40,6 +40,14 @@ const JS_TOOLS = new Set(['execute_js', 'modify_dom']);
|
|
|
40
40
|
// Sotto CHROME_BRIDGE_WRITE_ROOT lo stato del server resta scrivibile: non è un
|
|
41
41
|
// percorso scelto dal modello.
|
|
42
42
|
const STATE_DIRS = [join(homedir(), '.config', 'chrome-bridge'), SESSIONS_DIR, RECORDINGS_DIR, FIXTURES_DIR];
|
|
43
|
+
// upload_file mette un file del disco in un form della pagina: una pagina ostile
|
|
44
|
+
// con prompt injection può chiedere ~/.ssh/id_ed25519 o un .env. Senza
|
|
45
|
+
// CHROME_BRIDGE_READ_ROOT questi percorsi sono rifiutati; con la radice
|
|
46
|
+
// impostata vale solo la radice, scelta a mano da chi la imposta (il caso
|
|
47
|
+
// legittimo: una chiave SSL da caricare nel pannello dell'hosting).
|
|
48
|
+
const SECRET_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.docker', '.azure', join('.config', 'gcloud'), join('.config', 'gh'), join('.config', 'chrome-bridge')]
|
|
49
|
+
.map((d) => join(homedir(), d));
|
|
50
|
+
const SECRET_FILES = /(^|[\\/])(\.env(\..*)?|\.netrc|\.npmrc|\.pypirc|\.git-credentials|id_(rsa|dsa|ecdsa|ed25519)(_sk)?|[^\\/]*\.(pem|key|p12|pfx|jks|keystore|kdbx))$/i;
|
|
43
51
|
|
|
44
52
|
// `hint` è il parametro REALE del tool chiamante che riduce i dati. Suggerire
|
|
45
53
|
// max_length quando 56 tool su 59 non lo espongono mandava il modello a
|
|
@@ -364,7 +372,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
364
372
|
* @param {import('@modelcontextprotocol/sdk/server/index.js').McpServer} server - MCP Server
|
|
365
373
|
* @param {import('./ws-manager.js').WSManager} wsManager - WebSocket manager
|
|
366
374
|
* @param {string} [caps='all'] - 'all', 'core', o lista di gruppi "audits,visual"
|
|
367
|
-
* @param {{noJs?: boolean, writeRoot?: string|null}} [options] - CHROME_BRIDGE_NO_JS / CHROME_BRIDGE_WRITE_ROOT
|
|
375
|
+
* @param {{noJs?: boolean, writeRoot?: string|null, readRoot?: string|null}} [options] - CHROME_BRIDGE_NO_JS / CHROME_BRIDGE_WRITE_ROOT / CHROME_BRIDGE_READ_ROOT
|
|
368
376
|
*/
|
|
369
377
|
/** Procedura leggibile da un'osservazione: un passo per riga, i campi sensibili marcati come passo umano. */
|
|
370
378
|
function observedProcedure(name, d) {
|
|
@@ -396,10 +404,43 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
396
404
|
if (inside(WRITE_ROOT) || STATE_DIRS.some((d) => inside(resolve(d)))) return p;
|
|
397
405
|
throw new Error(`Refusing to write ${abs}: outside CHROME_BRIDGE_WRITE_ROOT (${WRITE_ROOT})`);
|
|
398
406
|
};
|
|
407
|
+
const READ_ROOT = options.readRoot ? resolve(String(options.readRoot)) : null;
|
|
408
|
+
// realpath: un symlink innocuo verso ~/.ssh non deve passare.
|
|
409
|
+
const guardRead = async (p) => {
|
|
410
|
+
const abs = await realpath(resolve(String(p)));
|
|
411
|
+
const inside = (root) => abs === root || abs.startsWith(root + sep);
|
|
412
|
+
if (READ_ROOT) {
|
|
413
|
+
if (inside(READ_ROOT)) return abs;
|
|
414
|
+
throw new Error(`Refusing to read ${abs}: outside CHROME_BRIDGE_READ_ROOT (${READ_ROOT})`);
|
|
415
|
+
}
|
|
416
|
+
if (SECRET_DIRS.some(inside) || SECRET_FILES.test(abs)) {
|
|
417
|
+
throw new Error(`Refusing to upload ${abs}: looks like a key or credential file. To upload it on purpose, set CHROME_BRIDGE_READ_ROOT to a folder that contains it.`);
|
|
418
|
+
}
|
|
419
|
+
return abs;
|
|
420
|
+
};
|
|
399
421
|
const activeCaps = caps === 'all'
|
|
400
422
|
? ['core', ...Object.keys(TOOL_CAPS)]
|
|
401
423
|
: ['core', ...String(caps).split(',').map((s) => s.trim()).filter((s) => s && s !== 'core')];
|
|
402
424
|
|
|
425
|
+
// Un errore del tool (eccezione, o risultato isError) va all'osservatore,
|
|
426
|
+
// che lo annota in locale senza i valori dei parametri (server/observe.js).
|
|
427
|
+
// L'errore arriva comunque al chiamante, uguale.
|
|
428
|
+
const observer = options.observe ?? null;
|
|
429
|
+
const observed = (name, handler) => (observer ? async (args, extra) => {
|
|
430
|
+
const t0 = Date.now();
|
|
431
|
+
try {
|
|
432
|
+
const out = await handler(args, extra);
|
|
433
|
+
if (out?.isError) {
|
|
434
|
+
const text = (out.content ?? []).find((c) => c.type === 'text')?.text ?? '';
|
|
435
|
+
observer.error(name, args, text, Date.now() - t0);
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
} catch (err) {
|
|
439
|
+
observer.error(name, args, err?.message ?? String(err), Date.now() - t0);
|
|
440
|
+
throw err;
|
|
441
|
+
}
|
|
442
|
+
} : handler);
|
|
443
|
+
|
|
403
444
|
// Ogni registrazione passa da qui: il filtro capability scarta i tool opt-in
|
|
404
445
|
// fuori dai gruppi attivi, e le annotations vengono applicate da TOOL_ANNOTATIONS.
|
|
405
446
|
// Un solo wrapper per entrambe le cose, altrimenti con caps != 'all' le
|
|
@@ -417,10 +458,11 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
417
458
|
if (group && !enabled.has(group) && !enabled.has('all')) return;
|
|
418
459
|
}
|
|
419
460
|
const annotations = TOOL_ANNOTATIONS[name];
|
|
461
|
+
const run = observed(name, handler);
|
|
420
462
|
// Un tool senza voce resta registrato (meglio di un crash all'avvio):
|
|
421
463
|
// è il test tool-annotations a segnalarlo.
|
|
422
|
-
if (annotations) target.tool(name, desc, schema, annotations,
|
|
423
|
-
else target.tool(name, desc, schema,
|
|
464
|
+
if (annotations) target.tool(name, desc, schema, annotations, run);
|
|
465
|
+
else target.tool(name, desc, schema, run);
|
|
424
466
|
},
|
|
425
467
|
};
|
|
426
468
|
}
|
|
@@ -599,6 +641,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
599
641
|
// scoprire che esiste ma è in un gruppo disattivato.
|
|
600
642
|
js_evaluation: !noJs,
|
|
601
643
|
write_root: WRITE_ROOT,
|
|
644
|
+
read_root: READ_ROOT,
|
|
602
645
|
caps_active: activeCaps,
|
|
603
646
|
caps_available: ['core', ...Object.keys(TOOL_CAPS)],
|
|
604
647
|
session_tab_id: sessionTabId,
|
|
@@ -1499,12 +1542,12 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
1499
1542
|
'Set a file on input[type=file] from the server filesystem via DataTransfer (max 10MB).',
|
|
1500
1543
|
{
|
|
1501
1544
|
selector: z.string().describe('The file input to fill; ">>>" pierces shadow DOM'),
|
|
1502
|
-
path: z.string().describe('Absolute path on the server machine'),
|
|
1545
|
+
path: z.string().describe('Absolute path on the server machine; keys and credentials (~/.ssh, .env, *.pem) refused'),
|
|
1503
1546
|
mime_type: z.string().optional().describe('Default: inferred from extension'),
|
|
1504
1547
|
tab_id: tabId,
|
|
1505
1548
|
},
|
|
1506
1549
|
async ({ selector, path, mime_type, tab_id }) => {
|
|
1507
|
-
const buf = await readFile(path);
|
|
1550
|
+
const buf = await readFile(await guardRead(path));
|
|
1508
1551
|
if (buf.length > 10 * 1024 * 1024) throw new Error(`File too large: ${buf.length} bytes (max 10MB)`);
|
|
1509
1552
|
const mime = mime_type || MIME_BY_EXT[extname(path).toLowerCase()] || 'application/octet-stream';
|
|
1510
1553
|
const data = await send(MessageType.UPLOAD_FILE, {
|