chrome-bridge-mcp 1.20.0 → 1.21.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 +35 -6
- package/extension/lib/css-cascade.js +405 -0
- package/extension/manifest.json +2 -2
- package/extension/service-worker.js +38 -0
- package/package.json +2 -2
- package/server/index.js +1 -0
- package/server/protocol.js +2 -1
- package/server/tools.js +49 -4
- package/skills/chrome-bridge/SKILL.md +4 -1
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Chrome Bridge
|
|
2
2
|
|
|
3
|
+
> By [frsorrentino](https://github.com/frsorrentino) · npm `chrome-bridge-mcp` · not affiliated with other projects named "chrome-bridge".
|
|
4
|
+
|
|
3
5
|
    [](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
|
|
4
6
|
|
|
5
7
|
**An MCP server that gives Claude Code your real, logged-in Chrome — measured
|
|
6
8
|
2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome"
|
|
7
9
|
extension on a form-filling task, with ~3× the toolset and no paid plan.**
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
60 web-development tools (navigation, DOM inspection, visual regression, audits,
|
|
10
12
|
network mocking) over a local WebSocket bridge, plus a headless instance for CI.
|
|
11
13
|
Self-hosted, local-only.
|
|
12
14
|
|
|
@@ -53,7 +55,7 @@ register the same server twice.
|
|
|
53
55
|
| | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
|
|
54
56
|
|---|---|---|---|---|
|
|
55
57
|
| **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
|
|
56
|
-
| **Tools** | **
|
|
58
|
+
| **Tools** | **60** (39 core) | 22 | 29 default (56 with flags) | 24 core (71 total) |
|
|
57
59
|
| **Requires paid plan** | **No** | Yes (Pro+) | No | No |
|
|
58
60
|
| **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
|
|
59
61
|
| **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
|
|
@@ -115,16 +117,16 @@ user-script toggle isn't available.
|
|
|
115
117
|
|
|
116
118
|
## Tools
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
60 in total, in seven groups. Only `core` (39 tools) loads by default; the rest
|
|
119
121
|
are opt-in via `--caps`.
|
|
120
122
|
|
|
121
|
-

|
|
122
124
|
|
|
123
125
|
| Group | N | What's in it |
|
|
124
126
|
|---|---|---|
|
|
125
127
|
| Core & Navigation | 13 | tabs, windows, `navigate`, `screenshot`, `tile_windows` |
|
|
126
128
|
| Interaction | 11 | `click`, `fill_form`, `upload_file`, dialogs, clipboard |
|
|
127
|
-
| DOM & Inspection |
|
|
129
|
+
| DOM & Inspection | 11 | `read_page`, `extract`, `query_dom`, `get_css_styles`, `watch_dom` |
|
|
128
130
|
| Debugging & Network | 8 | `execute_js`, console, network log, mocking, `track_events` |
|
|
129
131
|
| Visual & Responsive | 5 | `screenshot_diff`, viewport and zoom, media emulation |
|
|
130
132
|
| Audits | 2 | `audit` (a11y, keyboard, SEO, security, links, vitals, css, resources, cache in one call), `cookie_audit` |
|
|
@@ -156,6 +158,7 @@ Environment variables, each with a matching CLI flag:
|
|
|
156
158
|
| `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
|
|
157
159
|
| `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
|
|
158
160
|
| `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` |
|
|
161
|
+
| `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 |
|
|
159
162
|
| `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 |
|
|
160
163
|
| `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 |
|
|
161
164
|
|
|
@@ -172,6 +175,32 @@ exports and screenshots are **not** redacted and may carry cookies, tokens or
|
|
|
172
175
|
personal data. Don't point the automation at pages holding secrets you wouldn't
|
|
173
176
|
paste into a chat.
|
|
174
177
|
|
|
178
|
+
### Threat model
|
|
179
|
+
|
|
180
|
+
chrome-bridge gives an AI agent your real, logged-in browser. Scanners that
|
|
181
|
+
rate MCP servers by capability flag it, correctly, for code execution
|
|
182
|
+
(`execute_js`), requests that carry your cookies (`http_request`) and reading
|
|
183
|
+
private page data (`get_storage`, `get_tabs`). Those are the product, not bugs.
|
|
184
|
+
What matters is who can reach them and what a hostile page can make the agent
|
|
185
|
+
do with them.
|
|
186
|
+
|
|
187
|
+
- **Who can connect:** see above — loopback bind, `chrome-extension://` origin
|
|
188
|
+
check, `CHROME_BRIDGE_TOKEN` on both handshakes.
|
|
189
|
+
- **The real risk is prompt injection.** A page can ask the agent to run
|
|
190
|
+
JavaScript, send a request with your cookies, submit a form or upload a file.
|
|
191
|
+
Treat every page you automate as untrusted input, and review what the agent
|
|
192
|
+
proposes on sites that hold money or credentials.
|
|
193
|
+
- **Reduce the surface:** `--caps core` (the default) exposes the smallest set;
|
|
194
|
+
`--no-js` removes arbitrary JavaScript; `--write-root` fences every path the
|
|
195
|
+
model writes; `upload_file` refuses keys and credentials, and `--read-root`
|
|
196
|
+
fences it to one directory. For sessions that matter, automate in a separate
|
|
197
|
+
Chrome profile.
|
|
198
|
+
- **`--no-js` is not a sandbox:** `inject_css` stays, and CSS attribute
|
|
199
|
+
selectors with `url()` can leak attribute values to a remote host.
|
|
200
|
+
- **What leaves your machine** is what the browser sends on the agent's behalf.
|
|
201
|
+
chrome-bridge itself has no telemetry; the error log below stays local unless
|
|
202
|
+
you approve an issue.
|
|
203
|
+
|
|
175
204
|
### Local error log
|
|
176
205
|
|
|
177
206
|
When one of its own tools fails, chrome-bridge adds a line to a **local** file,
|
|
@@ -217,7 +246,7 @@ count.
|
|
|
217
246
|
|
|
218
247
|
## Documentation
|
|
219
248
|
|
|
220
|
-
- [docs/TOOLS.md](docs/TOOLS.md) — all
|
|
249
|
+
- [docs/TOOLS.md](docs/TOOLS.md) — all 60 tools, by group
|
|
221
250
|
- [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — what the bridge gets past and what it does not, one dated state per wall
|
|
222
251
|
- [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
|
|
223
252
|
- [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — latency per tool on the real path, `npm run bench:latency`
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cssStylesOf: la cascata CSS di un elemento come la mostra il pannello Styles
|
|
3
|
+
* di DevTools — per ogni proprietà la dichiarazione che vince (selettore,
|
|
4
|
+
* foglio, posizione della regola, !important, @layer, @media) e quelle che ha
|
|
5
|
+
* battuto. `query_dom` dice quanto vale `margin-top`; questo dice chi lo ha
|
|
6
|
+
* deciso e da dove.
|
|
7
|
+
*
|
|
8
|
+
* Senza chrome.debugger non c'è CSS.getMatchedStylesForNode: si ricostruisce
|
|
9
|
+
* dal CSSOM. Ordine (CSS Cascade 5, origine autore): !important, stile inline,
|
|
10
|
+
* @layer (normali: senza layer > ultimo layer dichiarato > primo; important:
|
|
11
|
+
* inverso), specificità, ordine nel sorgente. I longhand sono l'unità della
|
|
12
|
+
* cascata: `margin: 0` compare come margin-top/right/bottom/left.
|
|
13
|
+
*
|
|
14
|
+
* Fuori portata, e detto nell'output o nella descrizione del tool: gli stili
|
|
15
|
+
* dello user agent (non esposti), @container, @scope e @starting-style (non
|
|
16
|
+
* valutabili senza il motore: contati in `skipped`), le regole :host del
|
|
17
|
+
* shadow root di un host, transizioni e animazioni. I fogli di altra origine
|
|
18
|
+
* senza CORS non espongono cssRules: elencati in `opaque_stylesheets`.
|
|
19
|
+
*
|
|
20
|
+
* Non è un modulo ES (vedi element-label.js): get_css_styles inietta prima
|
|
21
|
+
* questo file e poi usa globalThis.__cbCssStyles. In Node i test passano un
|
|
22
|
+
* CSSOM finto come terzo argomento.
|
|
23
|
+
*/
|
|
24
|
+
(() => {
|
|
25
|
+
// --- selettori: liste, parentesi, specificità ---
|
|
26
|
+
|
|
27
|
+
const isIdentChar = (ch) => /[\w-]/.test(ch) || ch.charCodeAt(0) > 127;
|
|
28
|
+
const skipIdent = (s, i) => {
|
|
29
|
+
while (i < s.length) {
|
|
30
|
+
if (s[i] === '\\') { i += 2; continue; }
|
|
31
|
+
if (!isIdentChar(s[i])) break;
|
|
32
|
+
i += 1;
|
|
33
|
+
}
|
|
34
|
+
return i;
|
|
35
|
+
};
|
|
36
|
+
const skipString = (s, i) => {
|
|
37
|
+
const q = s[i];
|
|
38
|
+
i += 1;
|
|
39
|
+
while (i < s.length && s[i] !== q) i += s[i] === '\\' ? 2 : 1;
|
|
40
|
+
return i + 1;
|
|
41
|
+
};
|
|
42
|
+
const matchParen = (s, i) => {
|
|
43
|
+
let depth = 0;
|
|
44
|
+
for (; i < s.length; i += 1) {
|
|
45
|
+
const ch = s[i];
|
|
46
|
+
if (ch === '"' || ch === "'") { i = skipString(s, i) - 1; continue; }
|
|
47
|
+
if (ch === '(') depth += 1;
|
|
48
|
+
else if (ch === ')') { depth -= 1; if (depth === 0) return i; }
|
|
49
|
+
}
|
|
50
|
+
return s.length;
|
|
51
|
+
};
|
|
52
|
+
/** Una lista di selettori spezzata sulle virgole di primo livello. */
|
|
53
|
+
const splitList = (s) => {
|
|
54
|
+
const out = [];
|
|
55
|
+
let depth = 0;
|
|
56
|
+
let start = 0;
|
|
57
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
58
|
+
const ch = s[i];
|
|
59
|
+
if (ch === '"' || ch === "'") { i = skipString(s, i) - 1; continue; }
|
|
60
|
+
if (ch === '(' || ch === '[') depth += 1;
|
|
61
|
+
else if (ch === ')' || ch === ']') depth -= 1;
|
|
62
|
+
else if (ch === ',' && depth === 0) { out.push(s.slice(start, i).trim()); start = i + 1; }
|
|
63
|
+
}
|
|
64
|
+
out.push(s.slice(start).trim());
|
|
65
|
+
return out.filter(Boolean);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const LEGACY_PSEUDO_ELEMENTS = new Set(['before', 'after', 'first-line', 'first-letter']);
|
|
69
|
+
const ARG_SPECIFICITY = new Set(['is', 'not', 'has', 'matches', 'any', '-webkit-any', '-moz-any']);
|
|
70
|
+
const cmpSpec = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
71
|
+
const add = (t, u) => { t[0] += u[0]; t[1] += u[1]; t[2] += u[2]; };
|
|
72
|
+
const maxSpec = (list) => list.map(specificity).reduce((m, x) => (cmpSpec(x, m) > 0 ? x : m), [0, 0, 0]);
|
|
73
|
+
|
|
74
|
+
/** Specificità (id, classi/attributi/pseudo-classi, tipi/pseudo-elementi) di un selettore complesso. */
|
|
75
|
+
function specificity(sel) {
|
|
76
|
+
const t = [0, 0, 0];
|
|
77
|
+
const s = String(sel);
|
|
78
|
+
let i = 0;
|
|
79
|
+
while (i < s.length) {
|
|
80
|
+
const ch = s[i];
|
|
81
|
+
if (ch === '"' || ch === "'") { i = skipString(s, i); continue; }
|
|
82
|
+
if (ch === '#') { t[0] += 1; i = skipIdent(s, i + 1); continue; }
|
|
83
|
+
if (ch === '.') { t[1] += 1; i = skipIdent(s, i + 1); continue; }
|
|
84
|
+
if (ch === '[') {
|
|
85
|
+
t[1] += 1;
|
|
86
|
+
const close = s.indexOf(']', i);
|
|
87
|
+
i = close === -1 ? s.length : close + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (ch === ':') {
|
|
91
|
+
const pseudoElement = s[i + 1] === ':';
|
|
92
|
+
const start = i + (pseudoElement ? 2 : 1);
|
|
93
|
+
const end = skipIdent(s, start);
|
|
94
|
+
const name = s.slice(start, end).toLowerCase();
|
|
95
|
+
i = end;
|
|
96
|
+
let inner = null;
|
|
97
|
+
if (s[i] === '(') { const close = matchParen(s, i); inner = s.slice(i + 1, close); i = close + 1; }
|
|
98
|
+
if (pseudoElement || LEGACY_PSEUDO_ELEMENTS.has(name)) { t[2] += 1; continue; }
|
|
99
|
+
if (name === 'where') continue;
|
|
100
|
+
if (ARG_SPECIFICITY.has(name)) { if (inner != null) add(t, maxSpec(splitList(inner))); continue; }
|
|
101
|
+
t[1] += 1;
|
|
102
|
+
if (inner != null) {
|
|
103
|
+
if (name === 'host' || name === 'host-context') add(t, maxSpec(splitList(inner)));
|
|
104
|
+
else if (name === 'nth-child' || name === 'nth-last-child') {
|
|
105
|
+
const of = /\bof\s+(.+)$/i.exec(inner);
|
|
106
|
+
if (of) add(t, maxSpec(splitList(of[1])));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ch === '*') {
|
|
112
|
+
i += 1;
|
|
113
|
+
if (s[i] === '|') { i += 1; if (s[i] === '*') i += 1; else { t[2] += 1; i = skipIdent(s, i); } }
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (isIdentChar(ch) || ch === '\\') {
|
|
117
|
+
i = skipIdent(s, i);
|
|
118
|
+
if (s[i] === '|' && s[i + 1] !== '=') { // ns|tag: conta il tipo, non il namespace
|
|
119
|
+
i += 1;
|
|
120
|
+
if (s[i] === '*') { i += 1; continue; }
|
|
121
|
+
i = skipIdent(s, i);
|
|
122
|
+
}
|
|
123
|
+
t[2] += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
i += 1; // combinatori, spazi, & già risolto
|
|
127
|
+
}
|
|
128
|
+
return t;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- cascata ---
|
|
132
|
+
|
|
133
|
+
const INHERITED = new Set([
|
|
134
|
+
'border-collapse', 'border-spacing', 'caption-side', 'color', 'color-scheme', 'cursor', 'direction',
|
|
135
|
+
'empty-cells', 'font', 'font-family', 'font-feature-settings', 'font-kerning', 'font-optical-sizing',
|
|
136
|
+
'font-palette', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-synthesis',
|
|
137
|
+
'font-variant', 'font-variant-caps', 'font-variant-east-asian', 'font-variant-ligatures',
|
|
138
|
+
'font-variant-numeric', 'font-variant-position', 'font-variation-settings', 'font-weight',
|
|
139
|
+
'hanging-punctuation', 'hyphens', 'image-rendering', 'letter-spacing', 'line-break', 'line-height',
|
|
140
|
+
'list-style', 'list-style-image', 'list-style-position', 'list-style-type', 'orphans', 'overflow-wrap',
|
|
141
|
+
'paint-order', 'pointer-events', 'quotes', 'ruby-align', 'ruby-position', 'tab-size', 'text-align',
|
|
142
|
+
'text-align-last', 'text-combine-upright', 'text-decoration-skip-ink', 'text-emphasis',
|
|
143
|
+
'text-emphasis-color', 'text-emphasis-position', 'text-emphasis-style', 'text-indent', 'text-justify',
|
|
144
|
+
'text-orientation', 'text-rendering', 'text-shadow', 'text-size-adjust', 'text-transform',
|
|
145
|
+
'text-underline-offset', 'text-underline-position', 'text-wrap', 'text-wrap-mode', 'text-wrap-style',
|
|
146
|
+
'visibility', 'white-space', 'white-space-collapse', 'widows', 'word-break', 'word-spacing', 'word-wrap',
|
|
147
|
+
'writing-mode', 'accent-color', 'caret-color', 'scrollbar-color', 'print-color-adjust',
|
|
148
|
+
'forced-color-adjust', 'math-depth', 'math-style', '-webkit-text-fill-color', '-webkit-text-stroke',
|
|
149
|
+
'-webkit-text-stroke-color', '-webkit-text-stroke-width',
|
|
150
|
+
]);
|
|
151
|
+
const inherits = (p) => p.startsWith('--') || INHERITED.has(p);
|
|
152
|
+
const MAX_OVERRIDDEN = 5;
|
|
153
|
+
const MAX_ANCESTORS = 40;
|
|
154
|
+
|
|
155
|
+
const kindOf = (rule) => rule?.constructor?.name ?? '';
|
|
156
|
+
const sheetLabel = (href, ownerNode, n) => {
|
|
157
|
+
if (href) return href.split('#')[0].split('?')[0].split('/').pop() || href;
|
|
158
|
+
if (ownerNode?.id) return `<style>#${ownerNode.id}`;
|
|
159
|
+
return ownerNode ? `<style> #${n}` : `adopted #${n}`;
|
|
160
|
+
};
|
|
161
|
+
const describe = (el) => {
|
|
162
|
+
const raw = typeof el.className === 'string' ? el.className : (el.getAttribute?.('class') ?? '');
|
|
163
|
+
const classes = raw.trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
|
164
|
+
return (el.tagName || '').toLowerCase() + (el.id ? `#${el.id}` : '') + classes.map((c) => `.${c}`).join('');
|
|
165
|
+
};
|
|
166
|
+
const safeMatches = (el, sel) => { try { return el.matches(sel); } catch { return false; } };
|
|
167
|
+
const resolveNested = (sel, parents) => {
|
|
168
|
+
if (!parents) return sel;
|
|
169
|
+
const parent = `:is(${parents.join(', ')})`;
|
|
170
|
+
return sel.includes('&') ? sel.split('&').join(parent) : `${parent} ${sel}`;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Le regole di stile di una radice (document o shadow root), in ordine di cascata, con layer e condizioni. */
|
|
174
|
+
function collect(root, env) {
|
|
175
|
+
const sheets = [];
|
|
176
|
+
const opaque = [];
|
|
177
|
+
const skipped = [];
|
|
178
|
+
const rules = [];
|
|
179
|
+
const layerOrder = new Map();
|
|
180
|
+
const layerCounters = new Map();
|
|
181
|
+
let anonymous = 0;
|
|
182
|
+
let unnamed = 0;
|
|
183
|
+
let order = 0;
|
|
184
|
+
const registerLayer = (path) => {
|
|
185
|
+
for (let i = 1; i <= path.length; i += 1) {
|
|
186
|
+
const key = path.slice(0, i).join('.');
|
|
187
|
+
if (layerOrder.has(key)) continue;
|
|
188
|
+
const parent = path.slice(0, i - 1).join('.');
|
|
189
|
+
const n = layerCounters.get(parent) ?? 0;
|
|
190
|
+
layerCounters.set(parent, n + 1);
|
|
191
|
+
layerOrder.set(key, n);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const layerPath = (base, name) => {
|
|
195
|
+
const path = (base ?? []).concat(name === '' ? [`(anonymous ${(anonymous += 1)})`] : name.split('.'));
|
|
196
|
+
registerLayer(path);
|
|
197
|
+
return path;
|
|
198
|
+
};
|
|
199
|
+
const rankOf = (path) => path && path.map((_, i) => layerOrder.get(path.slice(0, i + 1).join('.')));
|
|
200
|
+
const countRules = (list) => {
|
|
201
|
+
let n = 0;
|
|
202
|
+
for (const r of list ?? []) { n += 1; if (r.cssRules) n += countRules(r.cssRules); }
|
|
203
|
+
return n;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const walk = (list, ctx) => {
|
|
207
|
+
for (const rule of list) {
|
|
208
|
+
const kind = kindOf(rule);
|
|
209
|
+
if (kind === 'CSSKeyframesRule' || kind === 'CSSPageRule') continue;
|
|
210
|
+
if (rule.selectorText != null && rule.style) {
|
|
211
|
+
const index = ctx.sheet.rules;
|
|
212
|
+
ctx.sheet.rules += 1;
|
|
213
|
+
const selectors = splitList(rule.selectorText).map((s) => resolveNested(s, ctx.parentSelectors));
|
|
214
|
+
rules.push({ selectors, sheet: ctx.sheet.label, index, layer: ctx.layer, conditions: ctx.conditions, style: rule.style, order: (order += 1) });
|
|
215
|
+
if (rule.cssRules?.length) walk(rule.cssRules, { ...ctx, parentSelectors: selectors, parentIndex: index });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (kind === 'CSSNestedDeclarations' && rule.style && ctx.parentSelectors) {
|
|
219
|
+
rules.push({ selectors: ctx.parentSelectors, sheet: ctx.sheet.label, index: ctx.parentIndex, layer: ctx.layer, conditions: ctx.conditions, style: rule.style, order: (order += 1) });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (kind === 'CSSImportRule') {
|
|
223
|
+
const mediaText = rule.media?.mediaText ?? '';
|
|
224
|
+
if (mediaText && !env.matchMedia(mediaText)) continue;
|
|
225
|
+
if (rule.supportsText && !env.supports(rule.supportsText)) continue;
|
|
226
|
+
const layer = rule.layerName == null ? ctx.layer : layerPath(ctx.layer, rule.layerName);
|
|
227
|
+
addSheet(rule.styleSheet, rule.href, { ...ctx, layer });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (kind === 'CSSLayerStatementRule') {
|
|
231
|
+
for (const name of rule.nameList ?? []) layerPath(ctx.layer, name);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (kind === 'CSSLayerBlockRule') {
|
|
235
|
+
walk(rule.cssRules, { ...ctx, layer: layerPath(ctx.layer, rule.name ?? '') });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (kind === 'CSSMediaRule') {
|
|
239
|
+
const text = rule.conditionText ?? rule.media?.mediaText ?? '';
|
|
240
|
+
if (!env.matchMedia(text)) continue;
|
|
241
|
+
walk(rule.cssRules, { ...ctx, conditions: ctx.conditions.concat(`@media ${text}`) });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (kind === 'CSSSupportsRule') {
|
|
245
|
+
if (!env.supports(rule.conditionText)) continue;
|
|
246
|
+
walk(rule.cssRules, { ...ctx, conditions: ctx.conditions.concat(`@supports ${rule.conditionText}`) });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (rule.cssRules && (kind === 'CSSContainerRule' || kind === 'CSSScopeRule' || kind === 'CSSStartingStyleRule')) {
|
|
250
|
+
const label = kind === 'CSSContainerRule' ? `@container ${rule.conditionText}`
|
|
251
|
+
: kind === 'CSSScopeRule' ? `@scope (${rule.start ?? ''})` : '@starting-style';
|
|
252
|
+
skipped.push({ condition: label, rules: countRules(rule.cssRules) });
|
|
253
|
+
}
|
|
254
|
+
// @font-face, @property, @namespace, @counter-style: niente da cascata
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const addSheet = (sheet, href, ctx) => {
|
|
258
|
+
let list = null;
|
|
259
|
+
try { list = sheet?.cssRules ?? null; } catch { list = null; }
|
|
260
|
+
if (!list) { opaque.push(href ?? sheet?.href ?? '(unknown)'); return; }
|
|
261
|
+
const ownHref = sheet.href ?? href ?? null;
|
|
262
|
+
const entry = { label: sheetLabel(ownHref, sheet.ownerNode, ownHref ? 0 : (unnamed += 1)), href: ownHref, rules: 0 };
|
|
263
|
+
sheets.push(entry);
|
|
264
|
+
walk(list, { ...ctx, sheet: entry });
|
|
265
|
+
};
|
|
266
|
+
for (const sheet of env.styleSheetsOf(root)) addSheet(sheet, null, { sheet: null, layer: null, conditions: [], parentSelectors: null, parentIndex: null });
|
|
267
|
+
return { rules, sheets, opaque, skipped, rankOf };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Confronto di cascata fra due dichiarazioni della stessa proprietà: > 0 se vince a.
|
|
271
|
+
const cmpLayer = (ra, rb, important) => {
|
|
272
|
+
const ua = ra == null;
|
|
273
|
+
const ub = rb == null;
|
|
274
|
+
if (ua && ub) return 0;
|
|
275
|
+
if (ua !== ub) return (ua === !important) ? 1 : -1; // senza layer: vince fra le normali, perde fra le important
|
|
276
|
+
const n = Math.min(ra.length, rb.length);
|
|
277
|
+
for (let i = 0; i < n; i += 1) {
|
|
278
|
+
if (ra[i] !== rb[i]) return ((ra[i] > rb[i]) === !important) ? 1 : -1; // normali: l'ultimo layer vince
|
|
279
|
+
}
|
|
280
|
+
if (ra.length === rb.length) return 0;
|
|
281
|
+
return ((ra.length < rb.length) === !important) ? 1 : -1; // il layer padre batte i suoi sottolayer fra le normali
|
|
282
|
+
};
|
|
283
|
+
const cmpDecl = (a, b) => {
|
|
284
|
+
if (a.important !== b.important) return a.important ? 1 : -1;
|
|
285
|
+
if (a.inline !== b.inline) return a.inline ? 1 : -1;
|
|
286
|
+
return cmpLayer(a.layerRank, b.layerRank, a.important) || cmpSpec(a.spec, b.spec) || a.order - b.order;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const declarationsOf = (style) => {
|
|
290
|
+
const out = [];
|
|
291
|
+
for (let i = 0; i < style.length; i += 1) {
|
|
292
|
+
const prop = style[i];
|
|
293
|
+
out.push({ prop, value: style.getPropertyValue(prop), important: style.getPropertyPriority(prop) === 'important' });
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/** Per ogni proprietà dichiarata su `el`, le dichiarazioni in ordine di cascata (vincitrice prima). */
|
|
299
|
+
function candidatesFor(el, { rules, rankOf }) {
|
|
300
|
+
const byProp = new Map();
|
|
301
|
+
let matched = 0;
|
|
302
|
+
const push = (d) => { if (!byProp.has(d.prop)) byProp.set(d.prop, []); byProp.get(d.prop).push(d); };
|
|
303
|
+
for (const rule of rules) {
|
|
304
|
+
const joined = rule.selectors.length === 1 ? rule.selectors[0] : rule.selectors.join(', ');
|
|
305
|
+
if (!safeMatches(el, joined)) continue;
|
|
306
|
+
matched += 1;
|
|
307
|
+
const matching = rule.selectors.length === 1 ? rule.selectors : rule.selectors.filter((s) => safeMatches(el, s));
|
|
308
|
+
let selector = matching[0] ?? rule.selectors[0];
|
|
309
|
+
let spec = specificity(selector);
|
|
310
|
+
for (const s of matching.slice(1)) { const sp = specificity(s); if (cmpSpec(sp, spec) > 0) { spec = sp; selector = s; } }
|
|
311
|
+
const layerRank = rankOf(rule.layer);
|
|
312
|
+
for (const d of declarationsOf(rule.style)) {
|
|
313
|
+
push({ ...d, inline: false, selector, sheet: rule.sheet, index: rule.index, layer: rule.layer, conditions: rule.conditions, layerRank, spec, order: rule.order });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (el.style?.length) {
|
|
317
|
+
for (const d of declarationsOf(el.style)) push({ ...d, inline: true, layerRank: null, spec: [0, 0, 0], order: Infinity });
|
|
318
|
+
}
|
|
319
|
+
for (const list of byProp.values()) list.sort((a, b) => cmpDecl(b, a));
|
|
320
|
+
return { byProp, matched };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const fmtSource = (d) => {
|
|
324
|
+
if (d.inline) return { inline: true };
|
|
325
|
+
const out = { selector: d.selector, sheet: d.sheet, rule: d.index };
|
|
326
|
+
if (d.layer) out.layer = d.layer.join('.');
|
|
327
|
+
if (d.conditions?.length) out.conditions = d.conditions;
|
|
328
|
+
return out;
|
|
329
|
+
};
|
|
330
|
+
const fmtEntry = (list, computed, inheritedFrom) => {
|
|
331
|
+
const [winner, ...rest] = list;
|
|
332
|
+
const entry = { value: winner.value };
|
|
333
|
+
if (winner.important) entry.important = true;
|
|
334
|
+
if (computed != null) entry.computed = computed;
|
|
335
|
+
if (inheritedFrom) entry.inherited_from = inheritedFrom;
|
|
336
|
+
entry.source = fmtSource(winner);
|
|
337
|
+
entry.overridden = rest.slice(0, MAX_OVERRIDDEN).map((o) => ({ value: o.value, ...(o.important ? { important: true } : {}), ...fmtSource(o) }));
|
|
338
|
+
if (rest.length > MAX_OVERRIDDEN) entry.overridden_more = rest.length - MAX_OVERRIDDEN;
|
|
339
|
+
return entry;
|
|
340
|
+
};
|
|
341
|
+
const parentOf = (node) => node.parentElement ?? (node.parentNode?.host ?? null);
|
|
342
|
+
|
|
343
|
+
const defaultEnv = () => ({
|
|
344
|
+
styleSheetsOf: (root) => [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])],
|
|
345
|
+
getComputedStyle: (el) => getComputedStyle(el),
|
|
346
|
+
matchMedia: (q) => matchMedia(q).matches,
|
|
347
|
+
supports: (c) => CSS.supports(c),
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {Element} el
|
|
352
|
+
* @param {{properties?: string[]|null, include_inherited?: boolean, selector?: string}} [opts]
|
|
353
|
+
* @param {object} [env] - CSSOM finto nei test
|
|
354
|
+
*/
|
|
355
|
+
function cssStylesOf(el, opts = {}, env = defaultEnv()) {
|
|
356
|
+
const wanted = Array.isArray(opts.properties) && opts.properties.length ? opts.properties.map((p) => String(p).trim().toLowerCase()) : null;
|
|
357
|
+
const want = (p) => !wanted || wanted.some((q) => p === q || p.startsWith(`${q}-`));
|
|
358
|
+
const collections = new Map();
|
|
359
|
+
const collectionOf = (node) => {
|
|
360
|
+
const root = node.getRootNode ? node.getRootNode() : (node.ownerDocument ?? document);
|
|
361
|
+
if (!collections.has(root)) collections.set(root, collect(root, env));
|
|
362
|
+
return collections.get(root);
|
|
363
|
+
};
|
|
364
|
+
const own = collectionOf(el);
|
|
365
|
+
const computed = env.getComputedStyle(el);
|
|
366
|
+
const computedOf = (p) => computed.getPropertyValue(p);
|
|
367
|
+
const usedSheets = new Set();
|
|
368
|
+
const noteSheets = (list) => { for (const d of list) if (!d.inline) usedSheets.add(d.sheet); };
|
|
369
|
+
|
|
370
|
+
const properties = {};
|
|
371
|
+
const { byProp, matched } = candidatesFor(el, own);
|
|
372
|
+
for (const [prop, list] of byProp) {
|
|
373
|
+
if (!want(prop)) continue;
|
|
374
|
+
properties[prop] = fmtEntry(list, computedOf(prop), null);
|
|
375
|
+
noteSheets(list);
|
|
376
|
+
}
|
|
377
|
+
if (opts.include_inherited) {
|
|
378
|
+
let node = parentOf(el);
|
|
379
|
+
for (let depth = 0; node && node.nodeType === 1 && depth < MAX_ANCESTORS; depth += 1, node = parentOf(node)) {
|
|
380
|
+
const ancestor = candidatesFor(node, collectionOf(node));
|
|
381
|
+
for (const [prop, list] of ancestor.byProp) {
|
|
382
|
+
if (properties[prop] || !inherits(prop) || !want(prop)) continue;
|
|
383
|
+
properties[prop] = fmtEntry(list, computedOf(prop), describe(node));
|
|
384
|
+
noteSheets(list);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const sorted = {};
|
|
390
|
+
for (const k of Object.keys(properties).sort()) sorted[k] = properties[k];
|
|
391
|
+
const out = { selector: opts.selector ?? null, element: describe(el), matched_rules: matched };
|
|
392
|
+
const sheets = [];
|
|
393
|
+
for (const c of collections.values()) for (const s of c.sheets) if (usedSheets.has(s.label)) sheets.push(s.href ? { sheet: s.label, href: s.href, rules: s.rules } : { sheet: s.label, rules: s.rules });
|
|
394
|
+
out.stylesheets = sheets;
|
|
395
|
+
const opaque = [...collections.values()].flatMap((c) => c.opaque);
|
|
396
|
+
if (opaque.length) out.opaque_stylesheets = opaque;
|
|
397
|
+
const skipped = [...collections.values()].flatMap((c) => c.skipped);
|
|
398
|
+
if (skipped.length) out.skipped = skipped;
|
|
399
|
+
out.properties = sorted;
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
globalThis.__cbCssStyles = cssStylesOf;
|
|
404
|
+
globalThis.__cbCssSpecificity = specificity;
|
|
405
|
+
})();
|
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.21.0",
|
|
5
|
+
"description": "Bridge your browser to Claude Code: 60 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": [
|
|
@@ -357,6 +357,8 @@ async function executeCommand(msg) {
|
|
|
357
357
|
return await cmdGetPerformance(params);
|
|
358
358
|
case 'query_dom':
|
|
359
359
|
return await cmdQueryDom(params);
|
|
360
|
+
case 'get_css_styles':
|
|
361
|
+
return await cmdGetCssStyles(params);
|
|
360
362
|
case 'modify_dom':
|
|
361
363
|
return await cmdModifyDom(params);
|
|
362
364
|
case 'inject_css':
|
|
@@ -1564,6 +1566,42 @@ async function cmdQueryDom({ selector, properties, limit = 50, tab_id, frame_id
|
|
|
1564
1566
|
return { count: elements.length, elements };
|
|
1565
1567
|
}
|
|
1566
1568
|
|
|
1569
|
+
// --- DevTools: get_css_styles ---
|
|
1570
|
+
|
|
1571
|
+
// La cascata sta in lib/css-cascade.js, iniettato prima della funzione come
|
|
1572
|
+
// element-label.js: chrome.scripting serializza solo la funzione. Mondo
|
|
1573
|
+
// ISOLATED: il CSSOM è lo stesso e la pagina non vede né manomette la libreria.
|
|
1574
|
+
async function cmdGetCssStyles({ selector, properties, include_inherited = false, tab_id, frame_id }) {
|
|
1575
|
+
if (!selector) throw new Error('Missing required parameter: selector');
|
|
1576
|
+
const tabId = await resolveTabId(tab_id);
|
|
1577
|
+
const target = scriptTarget(tabId, frame_id);
|
|
1578
|
+
await chrome.scripting.executeScript({ target, files: ['lib/css-cascade.js'], world: 'ISOLATED' });
|
|
1579
|
+
const results = await chrome.scripting.executeScript({
|
|
1580
|
+
target,
|
|
1581
|
+
func: (sel, props, inherited) => {
|
|
1582
|
+
function deepQuery(sel) {
|
|
1583
|
+
if (!sel.includes('>>>')) return document.querySelector(sel);
|
|
1584
|
+
const parts = sel.split('>>>').map((s) => s.trim());
|
|
1585
|
+
let ctx = document;
|
|
1586
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1587
|
+
const found = ctx.querySelector(parts[i]);
|
|
1588
|
+
if (!found) return null;
|
|
1589
|
+
if (i === parts.length - 1) return found;
|
|
1590
|
+
if (!found.shadowRoot) return null;
|
|
1591
|
+
ctx = found.shadowRoot;
|
|
1592
|
+
}
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
const el = deepQuery(sel);
|
|
1596
|
+
if (!el) throw new Error(`Element not found: ${sel}`);
|
|
1597
|
+
return globalThis.__cbCssStyles(el, { selector: sel, properties: props, include_inherited: inherited });
|
|
1598
|
+
},
|
|
1599
|
+
args: [selector, properties ?? null, Boolean(include_inherited)],
|
|
1600
|
+
world: 'ISOLATED',
|
|
1601
|
+
});
|
|
1602
|
+
return results?.[0]?.result ?? {};
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1567
1605
|
// --- DevTools: modify_dom ---
|
|
1568
1606
|
|
|
1569
1607
|
async function cmdModifyDom({ selector, action, name, value, className, tab_id, frame_id }) {
|
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.21.0",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "MCP server
|
|
6
|
+
"description": "Chrome Bridge by frsorrentino: MCP server that gives Claude Code your real, logged-in Chrome — 60 tools, 2.75× fewer turns than Claude in Chrome, ChromeOS included.",
|
|
7
7
|
"main": "server/index.js",
|
|
8
8
|
"bin": {
|
|
9
9
|
"chrome-bridge-mcp": "server/index.js",
|
package/server/index.js
CHANGED
|
@@ -38,6 +38,7 @@ function parseSecurity() {
|
|
|
38
38
|
return {
|
|
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
|
+
readRoot: argValue('--read-root') ?? process.env.CHROME_BRIDGE_READ_ROOT ?? null,
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
package/server/protocol.js
CHANGED
|
@@ -18,6 +18,7 @@ export const MessageType = Object.freeze({
|
|
|
18
18
|
GET_STORAGE: 'get_storage',
|
|
19
19
|
GET_PERFORMANCE: 'get_performance',
|
|
20
20
|
QUERY_DOM: 'query_dom',
|
|
21
|
+
GET_CSS_STYLES: 'get_css_styles',
|
|
21
22
|
MODIFY_DOM: 'modify_dom',
|
|
22
23
|
INJECT_CSS: 'inject_css',
|
|
23
24
|
READ_CONSOLE: 'read_console',
|
|
@@ -92,7 +93,7 @@ export const MessageType = Object.freeze({
|
|
|
92
93
|
});
|
|
93
94
|
|
|
94
95
|
// Versione
|
|
95
|
-
export const VERSION = '1.
|
|
96
|
+
export const VERSION = '1.21.0';
|
|
96
97
|
|
|
97
98
|
// Configurazione
|
|
98
99
|
export const DEFAULT_PORT = 8765;
|
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
|
|
@@ -299,6 +307,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
299
307
|
measure_spacing: ro(),
|
|
300
308
|
monitor_network: ro(true),
|
|
301
309
|
query_dom: ro(),
|
|
310
|
+
get_css_styles: ro(),
|
|
302
311
|
read_page: ro(),
|
|
303
312
|
screenshot: ro(),
|
|
304
313
|
wait_for: ro(),
|
|
@@ -364,7 +373,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
364
373
|
* @param {import('@modelcontextprotocol/sdk/server/index.js').McpServer} server - MCP Server
|
|
365
374
|
* @param {import('./ws-manager.js').WSManager} wsManager - WebSocket manager
|
|
366
375
|
* @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
|
|
376
|
+
* @param {{noJs?: boolean, writeRoot?: string|null, readRoot?: string|null}} [options] - CHROME_BRIDGE_NO_JS / CHROME_BRIDGE_WRITE_ROOT / CHROME_BRIDGE_READ_ROOT
|
|
368
377
|
*/
|
|
369
378
|
/** Procedura leggibile da un'osservazione: un passo per riga, i campi sensibili marcati come passo umano. */
|
|
370
379
|
function observedProcedure(name, d) {
|
|
@@ -396,6 +405,20 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
396
405
|
if (inside(WRITE_ROOT) || STATE_DIRS.some((d) => inside(resolve(d)))) return p;
|
|
397
406
|
throw new Error(`Refusing to write ${abs}: outside CHROME_BRIDGE_WRITE_ROOT (${WRITE_ROOT})`);
|
|
398
407
|
};
|
|
408
|
+
const READ_ROOT = options.readRoot ? resolve(String(options.readRoot)) : null;
|
|
409
|
+
// realpath: un symlink innocuo verso ~/.ssh non deve passare.
|
|
410
|
+
const guardRead = async (p) => {
|
|
411
|
+
const abs = await realpath(resolve(String(p)));
|
|
412
|
+
const inside = (root) => abs === root || abs.startsWith(root + sep);
|
|
413
|
+
if (READ_ROOT) {
|
|
414
|
+
if (inside(READ_ROOT)) return abs;
|
|
415
|
+
throw new Error(`Refusing to read ${abs}: outside CHROME_BRIDGE_READ_ROOT (${READ_ROOT})`);
|
|
416
|
+
}
|
|
417
|
+
if (SECRET_DIRS.some(inside) || SECRET_FILES.test(abs)) {
|
|
418
|
+
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.`);
|
|
419
|
+
}
|
|
420
|
+
return abs;
|
|
421
|
+
};
|
|
399
422
|
const activeCaps = caps === 'all'
|
|
400
423
|
? ['core', ...Object.keys(TOOL_CAPS)]
|
|
401
424
|
: ['core', ...String(caps).split(',').map((s) => s.trim()).filter((s) => s && s !== 'core')];
|
|
@@ -619,6 +642,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
619
642
|
// scoprire che esiste ma è in un gruppo disattivato.
|
|
620
643
|
js_evaluation: !noJs,
|
|
621
644
|
write_root: WRITE_ROOT,
|
|
645
|
+
read_root: READ_ROOT,
|
|
622
646
|
caps_active: activeCaps,
|
|
623
647
|
caps_available: ['core', ...Object.keys(TOOL_CAPS)],
|
|
624
648
|
session_tab_id: sessionTabId,
|
|
@@ -959,6 +983,27 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
959
983
|
}
|
|
960
984
|
);
|
|
961
985
|
|
|
986
|
+
// --- get_css_styles ---
|
|
987
|
+
server.tool(
|
|
988
|
+
'get_css_styles',
|
|
989
|
+
'Why an element looks the way it does: for each CSS property the winning declaration — selector, stylesheet, '
|
|
990
|
+
+ 'rule position, !important, @layer, @media — and the ones it overrides, like the Styles panel of DevTools; '
|
|
991
|
+
+ 'query_dom gives the computed value, this gives who set it. Read-only, from the CSSOM: stylesheets of '
|
|
992
|
+
+ 'another origin without CORS are opaque and listed by href; user-agent styles, @container and @scope rules '
|
|
993
|
+
+ 'are not evaluated. properties narrows the output.',
|
|
994
|
+
{
|
|
995
|
+
selector: z.string().describe('CSS selector; ">>>" pierces shadow DOM. Only the first match is inspected'),
|
|
996
|
+
properties: z.array(z.string()).optional().describe('Only these, e.g. ["margin-top","--brand"]; margin covers its longhands. Omitted = every property a matching rule sets'),
|
|
997
|
+
include_inherited: z.boolean().optional().default(false).describe('Also inherited properties (color, font-*, custom properties) with the ancestor that sets them'),
|
|
998
|
+
tab_id: tabId,
|
|
999
|
+
frame_id: frameId,
|
|
1000
|
+
},
|
|
1001
|
+
async ({ selector, properties, include_inherited, tab_id, frame_id }) => {
|
|
1002
|
+
const data = await send(MessageType.GET_CSS_STYLES, { selector, properties, include_inherited, tab_id, frame_id });
|
|
1003
|
+
return { content: [{ type: 'text', text: jsonText(data, DEFAULT_MAX_OUTPUT, 'properties') }] };
|
|
1004
|
+
}
|
|
1005
|
+
);
|
|
1006
|
+
|
|
962
1007
|
// --- modify_dom ---
|
|
963
1008
|
server.tool(
|
|
964
1009
|
'modify_dom',
|
|
@@ -1519,12 +1564,12 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
1519
1564
|
'Set a file on input[type=file] from the server filesystem via DataTransfer (max 10MB).',
|
|
1520
1565
|
{
|
|
1521
1566
|
selector: z.string().describe('The file input to fill; ">>>" pierces shadow DOM'),
|
|
1522
|
-
path: z.string().describe('Absolute path on the server machine'),
|
|
1567
|
+
path: z.string().describe('Absolute path on the server machine; keys and credentials (~/.ssh, .env, *.pem) refused'),
|
|
1523
1568
|
mime_type: z.string().optional().describe('Default: inferred from extension'),
|
|
1524
1569
|
tab_id: tabId,
|
|
1525
1570
|
},
|
|
1526
1571
|
async ({ selector, path, mime_type, tab_id }) => {
|
|
1527
|
-
const buf = await readFile(path);
|
|
1572
|
+
const buf = await readFile(await guardRead(path));
|
|
1528
1573
|
if (buf.length > 10 * 1024 * 1024) throw new Error(`File too large: ${buf.length} bytes (max 10MB)`);
|
|
1529
1574
|
const mime = mime_type || MIME_BY_EXT[extname(path).toLowerCase()] || 'application/octet-stream';
|
|
1530
1575
|
const data = await send(MessageType.UPLOAD_FILE, {
|
|
@@ -140,7 +140,10 @@ top group and re-running both.
|
|
|
140
140
|
Triggers: "does it use the design's fonts/colours?", "does it match the
|
|
141
141
|
Figma?", «somiglia al design?».
|
|
142
142
|
`query_dom({selector, properties:['font-family','font-size','color','background-color','margin','padding']})`
|
|
143
|
-
on the key elements; compare with the token file in the repo.
|
|
143
|
+
on the key elements; compare with the token file in the repo. Why a value is
|
|
144
|
+
what it is («chi mi azzera il margine?»): `get_css_styles({selector, properties:['margin-top']})`
|
|
145
|
+
→ the winning rule with selector, stylesheet and rule position, plus the ones it
|
|
146
|
+
overrides; `include_inherited:true` for `color` and `font-*`. Spacing between
|
|
144
147
|
two elements: `measure_spacing({selector1, selector2})`. Visual comparison:
|
|
145
148
|
`viewport_resize({width, height})` to the mockup's size, then
|
|
146
149
|
`screenshot_diff({action:'baseline', name:'home', from_file:'./mockups/home.png'})`
|