browserzap 0.5.0 → 0.5.5
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/CHANGELOG.md +20 -0
- package/README.md +53 -30
- package/bridge-server/chrome-launcher.js +42 -1
- package/bridge-server/downloads.js +173 -0
- package/bridge-server/executor.js +1 -1
- package/bridge-server/extension-id.js +25 -1
- package/bridge-server/feedback.js +255 -0
- package/bridge-server/index.js +29 -2
- package/bridge-server/mcp-server.js +194 -9
- package/bridge-server/native-host.js +5 -3
- package/bridge-server/panel.js +7 -0
- package/bridge-server/probe.js +2 -1
- package/bridge-server/server.js +2 -1
- package/bridge-server/setup.js +184 -27
- package/bridge-server/tools.js +110 -0
- package/extension/background.js +1 -1
- package/extension/engine.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/sidepanel.css +1 -1
- package/extension/sidepanel.html +23 -2
- package/extension/sidepanel.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserZap — Comment mode batches kept on this device (docs/COMMENT_MODE.md).
|
|
3
|
+
*
|
|
4
|
+
* The person's comments arrive from the side panel in one `feedback_submit`
|
|
5
|
+
* panel request and are written here for the agent's `feedback_get`:
|
|
6
|
+
*
|
|
7
|
+
* <config dir>/feedback/<batch id>/batch.json comments, element details, component, page
|
|
8
|
+
* <config dir>/feedback/<batch id>/c<n>.png the element screenshot (sensitive fields covered)
|
|
9
|
+
* <config dir>/feedback/<batch id>/c<n>-a<k>.* images the person attached
|
|
10
|
+
*
|
|
11
|
+
* Folders are 0700 and files 0600. Nothing is uploaded anywhere. A batch is
|
|
12
|
+
* kept BROWSERZAP_FEEDBACK_DAYS days (default 7) and at most MAX_BATCHES
|
|
13
|
+
* batches; the oldest go first. Comments never contain form values: the
|
|
14
|
+
* extension sends labels and element details only.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
|
|
23
|
+
const { configDir } = require('./secret');
|
|
24
|
+
|
|
25
|
+
const DEFAULT_KEEP_DAYS = 7;
|
|
26
|
+
const MAX_BATCHES = 50;
|
|
27
|
+
const MAX_COMMENTS = 30;
|
|
28
|
+
const MAX_ATTACHMENTS = 3;
|
|
29
|
+
const MAX_IMAGE_CHARS = 6 * 1024 * 1024;
|
|
30
|
+
const ID_RE = /^fb-\d{14}-[0-9a-f]{6}$/;
|
|
31
|
+
const IMAGE_TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' };
|
|
32
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
33
|
+
|
|
34
|
+
class FeedbackError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.code = 'INVALID_ARGUMENT';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const feedbackDir = () => path.join(configDir(), 'feedback');
|
|
42
|
+
|
|
43
|
+
function keepDays() {
|
|
44
|
+
const raw = process.env.BROWSERZAP_FEEDBACK_DAYS;
|
|
45
|
+
if (raw === undefined || raw === '') return DEFAULT_KEEP_DAYS;
|
|
46
|
+
const n = Number(raw);
|
|
47
|
+
return Number.isFinite(n) && n >= 0 && n <= 365 ? n : DEFAULT_KEEP_DAYS;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const str = (v, max) => (typeof v === 'string' ? v.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').slice(0, max) : null);
|
|
51
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : null);
|
|
52
|
+
|
|
53
|
+
function rectOf(r) {
|
|
54
|
+
if (!r || typeof r !== 'object') return null;
|
|
55
|
+
return { x: num(r.x), y: num(r.y), width: num(r.width), height: num(r.height) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Plain JSON of bounded size: target objects are small; anything deeper or larger is dropped. */
|
|
59
|
+
function smallJson(value, maxChars = 2000) {
|
|
60
|
+
try {
|
|
61
|
+
const text = JSON.stringify(value);
|
|
62
|
+
return text && text.length <= maxChars ? JSON.parse(text) : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function elementOf(info) {
|
|
69
|
+
const i = info && typeof info === 'object' ? info : {};
|
|
70
|
+
return {
|
|
71
|
+
role: str(i.role, 60),
|
|
72
|
+
name: str(i.name, 300),
|
|
73
|
+
target: smallJson(i.target),
|
|
74
|
+
targetUnique: i.targetUnique === true,
|
|
75
|
+
tag: str(i.tag, 40),
|
|
76
|
+
id: str(i.id, 200),
|
|
77
|
+
testId: str(i.testId, 200),
|
|
78
|
+
classes: Array.isArray(i.classes) ? i.classes.map((c) => str(c, 100)).filter(Boolean).slice(0, 6) : [],
|
|
79
|
+
domPath: str(i.domPath, 600),
|
|
80
|
+
text: str(i.text, 200),
|
|
81
|
+
sensitive: i.sensitive === true ? true : undefined,
|
|
82
|
+
rect: rectOf(i.rect),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function componentOf(c) {
|
|
87
|
+
if (!c || typeof c !== 'object') return null;
|
|
88
|
+
const source = c.source && typeof c.source === 'object' && typeof c.source.file === 'string'
|
|
89
|
+
? { file: str(c.source.file, 400), line: num(c.source.line), column: num(c.source.column) }
|
|
90
|
+
: null;
|
|
91
|
+
return {
|
|
92
|
+
framework: str(c.framework, 20),
|
|
93
|
+
component: str(c.component, 100),
|
|
94
|
+
chain: Array.isArray(c.chain) ? c.chain.map((n) => str(n, 100)).filter(Boolean).slice(0, 5) : [],
|
|
95
|
+
source,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pageOf(p) {
|
|
100
|
+
const page = p && typeof p === 'object' ? p : {};
|
|
101
|
+
const url = str(page.url, 2000);
|
|
102
|
+
if (!url || !/^https?:\/\//i.test(url)) throw new FeedbackError('Every comment needs the http(s) page it was made on.');
|
|
103
|
+
return {
|
|
104
|
+
url,
|
|
105
|
+
title: str(page.title, 200),
|
|
106
|
+
viewport: page.viewport && typeof page.viewport === 'object' ? { width: num(page.viewport.width), height: num(page.viewport.height) } : null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function imageData(data, what) {
|
|
111
|
+
if (typeof data !== 'string' || !data) return null;
|
|
112
|
+
if (data.length > MAX_IMAGE_CHARS) throw new FeedbackError(`${what} is too large.`);
|
|
113
|
+
if (!BASE64_RE.test(data)) throw new FeedbackError(`${what} is not a valid image.`);
|
|
114
|
+
return Buffer.from(data, 'base64');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function newId(now = new Date()) {
|
|
118
|
+
const p = (n, w = 2) => String(n).padStart(w, '0');
|
|
119
|
+
const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
120
|
+
return `fb-${stamp}-${crypto.randomBytes(3).toString('hex')}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writePrivate(file, content) {
|
|
124
|
+
fs.writeFileSync(file, content, { mode: 0o600 });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Check and store one batch. Returns { id, dir, count }. Throws FeedbackError on bad input. */
|
|
128
|
+
function saveBatch(batch, { now = new Date() } = {}) {
|
|
129
|
+
const comments = batch && Array.isArray(batch.comments) ? batch.comments : null;
|
|
130
|
+
if (!comments || comments.length === 0) throw new FeedbackError('The batch has no comments.');
|
|
131
|
+
if (comments.length > MAX_COMMENTS) throw new FeedbackError(`A batch holds at most ${MAX_COMMENTS} comments.`);
|
|
132
|
+
|
|
133
|
+
const id = newId(now);
|
|
134
|
+
const dir = path.join(feedbackDir(), id);
|
|
135
|
+
const files = [];
|
|
136
|
+
const stored = comments.map((c, index) => {
|
|
137
|
+
if (!c || typeof c !== 'object') throw new FeedbackError(`Comment ${index + 1} is not an object.`);
|
|
138
|
+
const text = str(c.text, 2000)?.trim();
|
|
139
|
+
if (!text) throw new FeedbackError(`Comment ${index + 1} has no text.`);
|
|
140
|
+
const n = Number.isInteger(c.n) && c.n > 0 && c.n < 1000 ? c.n : index + 1;
|
|
141
|
+
const shot = imageData(c.screenshot, `The screenshot of comment ${n}`);
|
|
142
|
+
const attachments = Array.isArray(c.attachments) ? c.attachments.slice(0, MAX_ATTACHMENTS) : [];
|
|
143
|
+
const entry = {
|
|
144
|
+
n,
|
|
145
|
+
text,
|
|
146
|
+
page: pageOf(c.page),
|
|
147
|
+
element: elementOf(c.info),
|
|
148
|
+
component: componentOf(c.component),
|
|
149
|
+
screenshot: null,
|
|
150
|
+
attachments: [],
|
|
151
|
+
createdAt: num(c.createdAt),
|
|
152
|
+
};
|
|
153
|
+
if (shot) {
|
|
154
|
+
entry.screenshot = `c${n}.png`;
|
|
155
|
+
files.push([entry.screenshot, shot]);
|
|
156
|
+
}
|
|
157
|
+
attachments.forEach((a, k) => {
|
|
158
|
+
const ext = a && IMAGE_TYPES[a.mimeType];
|
|
159
|
+
const data = ext ? imageData(a.data, `Image ${k + 1} of comment ${n}`) : null;
|
|
160
|
+
if (!data) return;
|
|
161
|
+
const name = `c${n}-a${k + 1}.${ext}`;
|
|
162
|
+
entry.attachments.push(name);
|
|
163
|
+
files.push([name, data]);
|
|
164
|
+
});
|
|
165
|
+
return entry;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
169
|
+
for (const [name, data] of files) writePrivate(path.join(dir, name), data);
|
|
170
|
+
const record = { id, createdAt: now.toISOString(), delivered: false, deliveredAt: null, comments: stored };
|
|
171
|
+
writePrivate(path.join(dir, 'batch.json'), JSON.stringify(record, null, 2));
|
|
172
|
+
prune({ now: now.getTime() });
|
|
173
|
+
return { id, dir, count: stored.length };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readBatch(id) {
|
|
177
|
+
if (!ID_RE.test(id)) return null;
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(fs.readFileSync(path.join(feedbackDir(), id, 'batch.json'), 'utf8'));
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function batchIds() {
|
|
186
|
+
try {
|
|
187
|
+
return fs.readdirSync(feedbackDir()).filter((d) => ID_RE.test(d)).sort();
|
|
188
|
+
} catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Newest first: { id, createdAt, count, pages, delivered }. */
|
|
194
|
+
function list({ includeDelivered = false } = {}) {
|
|
195
|
+
return batchIds().reverse()
|
|
196
|
+
.map((id) => readBatch(id))
|
|
197
|
+
.filter((b) => b && (includeDelivered || !b.delivered))
|
|
198
|
+
.map((b) => ({
|
|
199
|
+
id: b.id,
|
|
200
|
+
createdAt: b.createdAt,
|
|
201
|
+
count: b.comments.length,
|
|
202
|
+
pages: [...new Set(b.comments.map((c) => c.page.url))].slice(0, 5),
|
|
203
|
+
delivered: b.delivered,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** One batch with absolute file paths, or the newest one not yet delivered. */
|
|
208
|
+
function get(id) {
|
|
209
|
+
const chosen = id ?? list()[0]?.id;
|
|
210
|
+
const batch = chosen ? readBatch(chosen) : null;
|
|
211
|
+
if (!batch) return null;
|
|
212
|
+
const dir = path.join(feedbackDir(), batch.id);
|
|
213
|
+
return {
|
|
214
|
+
...batch,
|
|
215
|
+
dir,
|
|
216
|
+
comments: batch.comments.map((c) => ({
|
|
217
|
+
...c,
|
|
218
|
+
screenshot: c.screenshot ? path.join(dir, c.screenshot) : null,
|
|
219
|
+
attachments: c.attachments.map((a) => path.join(dir, a)),
|
|
220
|
+
})),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function markDelivered(id, { now = new Date() } = {}) {
|
|
225
|
+
const batch = readBatch(id);
|
|
226
|
+
if (!batch || batch.delivered) return false;
|
|
227
|
+
batch.delivered = true;
|
|
228
|
+
batch.deliveredAt = now.toISOString();
|
|
229
|
+
writePrivate(path.join(feedbackDir(), id, 'batch.json'), JSON.stringify(batch, null, 2));
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function pendingCount() {
|
|
234
|
+
return list().length;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Delete batches older than the retention, and the oldest beyond MAX_BATCHES. Never throws. */
|
|
238
|
+
function prune({ now = Date.now(), days = keepDays() } = {}) {
|
|
239
|
+
const cutoff = now - days * 86_400_000;
|
|
240
|
+
const ids = batchIds();
|
|
241
|
+
let removed = 0;
|
|
242
|
+
ids.forEach((id, index) => {
|
|
243
|
+
const batch = readBatch(id);
|
|
244
|
+
const created = batch ? Date.parse(batch.createdAt) : 0;
|
|
245
|
+
if (created < cutoff || index < ids.length - MAX_BATCHES) {
|
|
246
|
+
try {
|
|
247
|
+
fs.rmSync(path.join(feedbackDir(), id), { recursive: true, force: true });
|
|
248
|
+
removed++;
|
|
249
|
+
} catch { /* in use: next time */ }
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
return removed;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { saveBatch, list, get, markDelivered, pendingCount, prune, feedbackDir, FeedbackError, MAX_COMMENTS };
|
package/bridge-server/index.js
CHANGED
|
@@ -265,7 +265,16 @@ function uninstallExtension(purge, { register = true } = {}) {
|
|
|
265
265
|
*/
|
|
266
266
|
const command = args[0] && !args[0].startsWith('-') ? args[0] : null;
|
|
267
267
|
|
|
268
|
-
|
|
268
|
+
// Inside WSL there is no Chrome to pair with and no Windows registry: BrowserZap
|
|
269
|
+
// runs on the Windows side and the agent in WSL starts it through interop.
|
|
270
|
+
if (require('./setup').insideWsl() && !process.env.BROWSERZAP_ALLOW_LINUX) {
|
|
271
|
+
process.stderr.write('BrowserZap runs on Windows, next to Chrome. From WSL, start it through Windows:\n' +
|
|
272
|
+
' claude mcp add browserzap -- cmd.exe /c browserzap\n' +
|
|
273
|
+
'(install it on Windows first: npm install -g browserzap, then browserzap setup). See docs/WSL.md.\n');
|
|
274
|
+
process.exit(2);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const SUBCOMMANDS = new Set(['setup', 'doctor', 'uninstall', 'run', 'restart-bridge']);
|
|
269
278
|
|
|
270
279
|
if (command === 'setup') {
|
|
271
280
|
// From an installed package, copy the extension to a folder that outlives an
|
|
@@ -298,12 +307,29 @@ if (command === 'setup') {
|
|
|
298
307
|
`);
|
|
299
308
|
process.exit(1);
|
|
300
309
|
});
|
|
310
|
+
} else if (command === 'restart-bridge') {
|
|
311
|
+
// A bridge that will not pair (another secret, an old version) holds the
|
|
312
|
+
// port. Stop the node process holding it (never anything that is not node);
|
|
313
|
+
// the extension's native host starts the current one with this install's secret.
|
|
314
|
+
const { stopBridgeOnPort, isListening } = require('./probe');
|
|
315
|
+
const portIdx = args.indexOf('--port');
|
|
316
|
+
const port = portIdx !== -1 && args[portIdx + 1] ? parseInt(args[portIdx + 1], 10) : DEFAULT_PORT;
|
|
317
|
+
stopBridgeOnPort(port).then(async (pids) => {
|
|
318
|
+
const still = await isListening(port);
|
|
319
|
+
process.stdout.write(pids.length
|
|
320
|
+
? `Stopped the bridge on 127.0.0.1:${port} (pid ${pids.join(', ')}). Open the BrowserZap side panel (or press Reconnect) to start the current one.\n`
|
|
321
|
+
: `No BrowserZap bridge was holding 127.0.0.1:${port}.${still ? ' Something else listens there; it was left alone.' : ''}\n`);
|
|
322
|
+
process.exit(still ? 1 : 0);
|
|
323
|
+
}).catch((err) => {
|
|
324
|
+
process.stderr.write(`[browserzap] restart-bridge failed: ${err.message}\n`);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
});
|
|
301
327
|
} else if (command === 'uninstall') {
|
|
302
328
|
uninstallExtension(args.includes('--purge'), { register: !args.includes('--no-registry') });
|
|
303
329
|
} else if (command === 'help') {
|
|
304
330
|
args.push('--help');
|
|
305
331
|
} else if (command) {
|
|
306
|
-
process.stderr.write(`Unknown command "${command}". Use: setup, doctor, run, uninstall, or --help.\n`);
|
|
332
|
+
process.stderr.write(`Unknown command "${command}". Use: setup, doctor, restart-bridge, run, uninstall, or --help.\n`);
|
|
307
333
|
process.exit(2);
|
|
308
334
|
}
|
|
309
335
|
|
|
@@ -468,6 +494,7 @@ Usage:
|
|
|
468
494
|
browserzap setup One-time setup: register the native messaging host and
|
|
469
495
|
optionally add the MCP server to detected clients
|
|
470
496
|
browserzap doctor Check the host manifest, registry, extension id and bridge
|
|
497
|
+
browserzap restart-bridge Replace a bridge that will not pair (another secret or version)
|
|
471
498
|
browserzap uninstall [--purge] Remove the host registration and installed extension copy
|
|
472
499
|
browserzap run <workflow> [--params <file.csv|file.json>] [--stop-on-error]
|
|
473
500
|
Run a saved workflow with no agent; one run per row (Pro for
|
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
const { WebSocket } = require('ws');
|
|
25
25
|
const readline = require('readline');
|
|
26
26
|
const crypto = require('crypto');
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
27
29
|
|
|
28
|
-
const { getOrCreateSecret } = require('./secret');
|
|
30
|
+
const { getOrCreateSecret, configDir } = require('./secret');
|
|
29
31
|
const { BIND_HOST, DEFAULT_PORT, PROTOCOL_VERSION, sanitizeSessionName } = require('./server');
|
|
30
32
|
const { ensureBridge: ensureBridgeListening } = require('./ensure-bridge');
|
|
31
33
|
const { LIMITS, CALL_DEADLINE_MS, STATUS_DEADLINE_MS } = require('./limits');
|
|
@@ -37,6 +39,8 @@ const { createWorkflowService, checkStep, stepLogEntry } = require('./workflow-s
|
|
|
37
39
|
const { createSessionLog, describeArgs, describeResult } = require('./session-log');
|
|
38
40
|
const workflows = require('./workflows');
|
|
39
41
|
const screenshots = require('./screenshots');
|
|
42
|
+
const feedback = require('./feedback');
|
|
43
|
+
const downloads = require('./downloads');
|
|
40
44
|
const { createRunRegistry, compactBatch, RunError } = require('./background-runs');
|
|
41
45
|
|
|
42
46
|
const { WorkflowError, Recorder } = workflows;
|
|
@@ -189,12 +193,12 @@ function connectToBridge() {
|
|
|
189
193
|
|
|
190
194
|
function describeBridgeProblem() {
|
|
191
195
|
if (lastCloseCode === 4003) {
|
|
192
|
-
return 'The bridge rejected this server\'s pairing token. Run "
|
|
196
|
+
return 'The bridge rejected this server\'s pairing token. Run "browserzap doctor" for details; "browserzap restart-bridge" replaces a bridge from another install.';
|
|
193
197
|
}
|
|
194
198
|
if (lastCloseCode === 4005) {
|
|
195
199
|
return `${lastCloseReason || 'The bridge speaks a different protocol version.'} Restart the bridge and this MCP server after rebuilding.`;
|
|
196
200
|
}
|
|
197
|
-
return 'The local BrowserZap bridge is not reachable. Run "
|
|
201
|
+
return 'The local BrowserZap bridge is not reachable. Run "browserzap doctor" and check that the extension is loaded in Chrome.';
|
|
198
202
|
}
|
|
199
203
|
|
|
200
204
|
function ensureSession(timeoutMs = 3000) {
|
|
@@ -227,7 +231,7 @@ function bridgeRequest(type, payload = {}, timeoutMs = 3000) {
|
|
|
227
231
|
const id = `${SESSION_ID}-b${++bridgeSeq}`;
|
|
228
232
|
const timer = setTimeout(() => {
|
|
229
233
|
bridgeReplies.delete(id);
|
|
230
|
-
reject(Object.assign(new Error('The bridge did not answer. It may be an older version: run "
|
|
234
|
+
reject(Object.assign(new Error('The bridge did not answer. It may be an older version: run "browserzap restart-bridge".'), { code: 'BRIDGE_DISCONNECTED' }));
|
|
231
235
|
}, timeoutMs);
|
|
232
236
|
bridgeReplies.set(id, (msg) => { clearTimeout(timer); resolve(msg); });
|
|
233
237
|
ws.send(JSON.stringify({ type, id, ...payload }));
|
|
@@ -508,6 +512,13 @@ async function callToolWith(progress, requestId, toolName, args) {
|
|
|
508
512
|
|
|
509
513
|
if (toolName === 'browser_list_profiles' || toolName === 'browser_select_profile') return finish(await profileTool(traceId, toolName, args));
|
|
510
514
|
|
|
515
|
+
if (toolName === 'browser_downloads') return finish(await downloadsTool(traceId, args));
|
|
516
|
+
|
|
517
|
+
if (toolName === 'feedback_list' || toolName === 'feedback_get') {
|
|
518
|
+
const { env, extra } = feedbackTool(traceId, toolName, args);
|
|
519
|
+
return finish(env, extra);
|
|
520
|
+
}
|
|
521
|
+
|
|
511
522
|
if (LOCAL_TOOLS.has(toolName)) return finish(await workflowTool(requestId, traceId, toolName, args, progress));
|
|
512
523
|
// A background run is driving this session's tab: reading is fine, acting would interleave with its steps.
|
|
513
524
|
const running = backgroundRuns.active();
|
|
@@ -526,7 +537,19 @@ async function callToolWith(progress, requestId, toolName, args) {
|
|
|
526
537
|
const notReady = await ensureBrowserForCall(traceId, progress, () => call.cancelled);
|
|
527
538
|
if (notReady) return finish(notReady);
|
|
528
539
|
if (call.cancelled) return finish(errorEnvelope(traceId, 'CANCELLED', 'Cancelled by the client.'));
|
|
529
|
-
|
|
540
|
+
let toolArgs = args;
|
|
541
|
+
if (toolName === 'file_upload') {
|
|
542
|
+
const checked = checkUpload(traceId, args);
|
|
543
|
+
if (checked.error) return finish(checked.error);
|
|
544
|
+
toolArgs = checked.args;
|
|
545
|
+
}
|
|
546
|
+
// A download the agent starts is saved here, with no "Save as" dialog (dialogs.ts).
|
|
547
|
+
if (toolName === 'element_click' || toolName === 'browser_press_key') toolArgs = { ...toolArgs, downloadDir: downloadFolder() };
|
|
548
|
+
// A click or Enter may start a download: note the folder's names first (one directory read).
|
|
549
|
+
const watchDownloads = toolName === 'element_click' || toolName === 'browser_press_key';
|
|
550
|
+
const downloadsBefore = watchDownloads ? downloads.names(downloadFolder()) : null;
|
|
551
|
+
const { env, extra } = await executeTool(toolName, toolArgs, { onStart: (c) => { call.cancel = c.cancel; } });
|
|
552
|
+
if (downloadsBefore && env.ok) noteDownloads(env, downloadsBefore);
|
|
530
553
|
if (recorder) recorder.observe(toolName, args, env);
|
|
531
554
|
return finish(env, extra);
|
|
532
555
|
} finally {
|
|
@@ -552,6 +575,160 @@ async function executeTool(toolName, args, { allowedOrigins = ALLOWED_ORIGINS, o
|
|
|
552
575
|
return { env, extra };
|
|
553
576
|
}
|
|
554
577
|
|
|
578
|
+
// ─── Downloads (ROADMAP 0.5.4) ───────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
/** When this MCP server started: browser_downloads lists what arrived since then by default. */
|
|
581
|
+
const SESSION_STARTED_AT = Date.now();
|
|
582
|
+
let downloadDirCache = null;
|
|
583
|
+
const downloadFolder = () => (downloadDirCache ??= downloads.downloadDir());
|
|
584
|
+
|
|
585
|
+
/** A file that appeared in the Downloads folder during a click is said in its result. */
|
|
586
|
+
function noteDownloads(env, before) {
|
|
587
|
+
const started = downloads.arrived(before, downloadFolder());
|
|
588
|
+
if (started.length === 0) return;
|
|
589
|
+
env.evidence = { ...(env.evidence ?? {}), downloads: started };
|
|
590
|
+
if (typeof env.summary === 'string') {
|
|
591
|
+
env.summary = `${env.summary} It started a download: ${started.map(downloads.describe).join(', ')}.`;
|
|
592
|
+
}
|
|
593
|
+
if (started.some((d) => d.state === 'in_progress') && !env.nextAction) {
|
|
594
|
+
env.nextAction = 'Call browser_downloads with waitForMs to wait until the download finishes.';
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function downloadsTool(traceId, args) {
|
|
599
|
+
const dir = downloadFolder();
|
|
600
|
+
let sinceMs = SESSION_STARTED_AT;
|
|
601
|
+
if (typeof args.since === 'string') {
|
|
602
|
+
const parsed = Date.parse(args.since);
|
|
603
|
+
if (Number.isNaN(parsed)) return errorEnvelope(traceId, 'INVALID_ARGUMENT', 'since must be an ISO date and time, e.g. 2026-10-29T10:00:00Z.');
|
|
604
|
+
sinceMs = parsed;
|
|
605
|
+
}
|
|
606
|
+
const waitMs = Number.isInteger(args.waitForMs) ? args.waitForMs : 0;
|
|
607
|
+
const { entries, timedOut } = waitMs > 0
|
|
608
|
+
? await downloads.waitFor(dir, sinceMs, waitMs)
|
|
609
|
+
: { entries: downloads.since(dir, sinceMs), timedOut: false };
|
|
610
|
+
const running = entries.filter((e) => e.state === 'in_progress').length;
|
|
611
|
+
let summary = entries.length === 0
|
|
612
|
+
? `No downloads in ${dir} since ${new Date(sinceMs).toISOString()}${timedOut ? ` (waited ${waitMs} ms)` : ''}.`
|
|
613
|
+
: `${entries.length} download(s)${running ? `, ${running} still in progress` : ', all complete'}: ${entries.slice(-5).map(downloads.describe).join(', ')}.`;
|
|
614
|
+
// Nothing arrived and Chrome asks where to save: the person is probably looking at a "Save as" dialog.
|
|
615
|
+
const asks = entries.length === 0 ? downloads.asksWhereToSave({ dataDir: chromeLauncher.userDataDir() }) : null;
|
|
616
|
+
if (asks) summary = `${summary} ${downloads.ASK_HINT}`;
|
|
617
|
+
return okEnvelope(traceId, summary, { downloads: entries, folder: dir }, { timedOut, ...(asks ? { chromeAsksWhereToSave: true } : {}) });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ─── File upload (ROADMAP 0.5.4) ─────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
623
|
+
|
|
624
|
+
/** What a file looks like when it must never leave the machine through file_upload, or null. */
|
|
625
|
+
function secretLooking(file) {
|
|
626
|
+
const lower = file.toLowerCase().replace(/\\/g, '/');
|
|
627
|
+
const name = path.basename(lower);
|
|
628
|
+
const config = configDir().toLowerCase().replace(/\\/g, '/');
|
|
629
|
+
if (lower.startsWith(`${config}/`)) return 'BrowserZap\'s own data (pairing secret, licence, workflows)';
|
|
630
|
+
if (/\/\.(ssh|gnupg|aws|azure|kube|docker|config\/gcloud)\//.test(lower)) return 'a credentials folder';
|
|
631
|
+
if (/\/(google\/chrome|microsoft\/edge|bravesoftware|mozilla\/firefox)\/(user data|profiles?)\//.test(lower)) return 'a browser profile (cookies, saved passwords)';
|
|
632
|
+
if (/^(id_(rsa|dsa|ecdsa|ed25519)(\.pub)?|known_hosts|authorized_keys|\.netrc|\.npmrc|\.pypirc|\.git-credentials|credentials(\.json)?|\.env(\..+)?)$/.test(name)) return 'a key or credentials file';
|
|
633
|
+
if (/\.(pem|key|p12|pfx|jks|keystore|kdbx|ppk|ovpn)$/.test(name)) return 'a private key or password store';
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Check a file_upload path before anything reaches the browser: absolute, an
|
|
639
|
+
* existing regular file, ≤ 50 MB, and inside BROWSERZAP_UPLOAD_DIR when that is
|
|
640
|
+
* set. The extension gets the resolved path plus the name and size it shows in
|
|
641
|
+
* the approval. The file's content is never read here.
|
|
642
|
+
*/
|
|
643
|
+
function checkUpload(traceId, args) {
|
|
644
|
+
const raw = String(args.path ?? '');
|
|
645
|
+
if (!path.isAbsolute(raw)) {
|
|
646
|
+
return { error: errorEnvelope(traceId, 'INVALID_ARGUMENT', 'path must be an absolute path on this computer.') };
|
|
647
|
+
}
|
|
648
|
+
let real;
|
|
649
|
+
let stat;
|
|
650
|
+
try {
|
|
651
|
+
real = fs.realpathSync(raw);
|
|
652
|
+
stat = fs.statSync(real);
|
|
653
|
+
} catch {
|
|
654
|
+
return { error: errorEnvelope(traceId, 'INVALID_ARGUMENT', `No file at ${raw}.`) };
|
|
655
|
+
}
|
|
656
|
+
if (!stat.isFile()) return { error: errorEnvelope(traceId, 'INVALID_ARGUMENT', `${raw} is not a file.`) };
|
|
657
|
+
// A page can try to talk the agent into uploading secrets (prompt injection).
|
|
658
|
+
// The person approves every file, and these are refused even then.
|
|
659
|
+
const secret = secretLooking(real);
|
|
660
|
+
if (secret) {
|
|
661
|
+
return { error: errorEnvelope(traceId, 'POLICY_BLOCKED', `${path.basename(real)} looks like ${secret}; file_upload never sends it.`, 'Choose the document the task needs, or ask the user to attach it themselves.') };
|
|
662
|
+
}
|
|
663
|
+
if (stat.size > MAX_UPLOAD_BYTES) {
|
|
664
|
+
return { error: errorEnvelope(traceId, 'INVALID_ARGUMENT', `${path.basename(real)} is ${Math.round(stat.size / 1048576)} MB; file_upload takes files up to 50 MB.`) };
|
|
665
|
+
}
|
|
666
|
+
const dir = process.env.BROWSERZAP_UPLOAD_DIR;
|
|
667
|
+
if (dir) {
|
|
668
|
+
let allowed;
|
|
669
|
+
try { allowed = fs.realpathSync(dir); } catch { allowed = path.resolve(dir); }
|
|
670
|
+
const rel = path.relative(allowed, real);
|
|
671
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
672
|
+
return { error: errorEnvelope(traceId, 'POLICY_BLOCKED', `Uploads are limited to ${allowed} (BROWSERZAP_UPLOAD_DIR).`) };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return { args: { ...args, path: real, fileName: path.basename(real), sizeBytes: stat.size } };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ─── Comment mode batches (docs/COMMENT_MODE.md) ─────────────────────────────
|
|
679
|
+
|
|
680
|
+
/** Inline images per feedback_get: enough to see every comment, small enough for any client. */
|
|
681
|
+
const FEEDBACK_INLINE_IMAGES = 12;
|
|
682
|
+
const FEEDBACK_INLINE_BYTES = 6 * 1024 * 1024;
|
|
683
|
+
const MIME_BY_EXT = { png: 'image/png', jpg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' };
|
|
684
|
+
|
|
685
|
+
function feedbackTool(traceId, toolName, args) {
|
|
686
|
+
if (toolName === 'feedback_list') {
|
|
687
|
+
const batches = feedback.list({ includeDelivered: args.includeDelivered === true });
|
|
688
|
+
const pending = batches.filter((b) => !b.delivered).length;
|
|
689
|
+
return {
|
|
690
|
+
env: okEnvelope(traceId, batches.length
|
|
691
|
+
? `${batches.length} comment batch(es)${pending ? `, ${pending} not yet read` : ''}.`
|
|
692
|
+
: 'No comments from the person. They add them with Comment mode in the BrowserZap side panel.', { batches }),
|
|
693
|
+
extra: [],
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const batch = feedback.get(args.id);
|
|
697
|
+
if (!batch) {
|
|
698
|
+
return {
|
|
699
|
+
env: args.id
|
|
700
|
+
? errorEnvelope(traceId, 'INVALID_ARGUMENT', `No comment batch "${args.id}".`, 'Call feedback_list to see the batches.')
|
|
701
|
+
: okEnvelope(traceId, 'No unread comments from the person. They add them with Comment mode in the BrowserZap side panel, then press Send.', { batch: null }),
|
|
702
|
+
extra: [],
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
if (args.markDelivered !== false) feedback.markDelivered(batch.id);
|
|
706
|
+
const extra = [];
|
|
707
|
+
let bytes = 0;
|
|
708
|
+
if (args.images !== false) {
|
|
709
|
+
for (const c of batch.comments) {
|
|
710
|
+
for (const file of [c.screenshot, ...c.attachments].filter(Boolean)) {
|
|
711
|
+
if (extra.length >= FEEDBACK_INLINE_IMAGES) break;
|
|
712
|
+
try {
|
|
713
|
+
const data = fs.readFileSync(file);
|
|
714
|
+
if (bytes + data.length > FEEDBACK_INLINE_BYTES) continue;
|
|
715
|
+
bytes += data.length;
|
|
716
|
+
extra.push({ type: 'text', text: `Comment ${c.n}: ${file === c.screenshot ? 'the element' : 'image the person attached'}` });
|
|
717
|
+
extra.push({ type: 'image', data: data.toString('base64'), mimeType: MIME_BY_EXT[path.extname(file).slice(1)] ?? 'image/png' });
|
|
718
|
+
} catch { /* pruned meanwhile: the path is still listed */ }
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const pages = [...new Set(batch.comments.map((c) => c.page.url))];
|
|
723
|
+
return {
|
|
724
|
+
env: okEnvelope(traceId,
|
|
725
|
+
`${batch.comments.length} comment(s) from the person on ${pages.length === 1 ? pages[0] : `${pages.length} pages`}.`,
|
|
726
|
+
{ batch },
|
|
727
|
+
{}),
|
|
728
|
+
extra,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
555
732
|
// ─── Workflows and plan (docs/WORKFLOWS.md, docs/PRO.md) ─────────────────────
|
|
556
733
|
|
|
557
734
|
/** The active recording for this MCP session, if any. In memory only. */
|
|
@@ -823,7 +1000,7 @@ const backgroundRuns = createRunRegistry({
|
|
|
823
1000
|
});
|
|
824
1001
|
|
|
825
1002
|
/** Tools that only read, allowed while a background run drives the tab. */
|
|
826
|
-
const READ_WHILE_RUNNING = new Set(['browser_status', 'page_inspect', 'screenshot_capture', 'browser_list_tabs', 'browser_list_profiles']);
|
|
1003
|
+
const READ_WHILE_RUNNING = new Set(['browser_status', 'page_inspect', 'screenshot_capture', 'page_console', 'page_network', 'browser_list_tabs', 'browser_list_profiles']);
|
|
827
1004
|
|
|
828
1005
|
/**
|
|
829
1006
|
* "5 of 5 done" / "3 of 5 done, 2 need attention" as a Chrome notification
|
|
@@ -882,7 +1059,7 @@ async function profileTool(traceId, toolName, args) {
|
|
|
882
1059
|
? await bridgeRequest('profiles_list')
|
|
883
1060
|
: await bridgeRequest('profile_select', { profile: args.profile });
|
|
884
1061
|
} catch (err) {
|
|
885
|
-
return errorEnvelope(traceId, err.code || 'BRIDGE_DISCONNECTED', err.message, '
|
|
1062
|
+
return errorEnvelope(traceId, err.code || 'BRIDGE_DISCONNECTED', err.message, 'Ask the user to run "browserzap doctor".');
|
|
886
1063
|
}
|
|
887
1064
|
const profiles = reply.data?.profiles ?? [];
|
|
888
1065
|
if (!reply.ok) {
|
|
@@ -931,7 +1108,7 @@ async function statusTool(traceId, args) {
|
|
|
931
1108
|
evidence.bridge = 'connected';
|
|
932
1109
|
} catch {
|
|
933
1110
|
return { ...errorEnvelope(traceId, 'BRIDGE_DISCONNECTED', describeBridgeProblem(),
|
|
934
|
-
'Ask the user to run "
|
|
1111
|
+
'Ask the user to run "browserzap doctor"; every failed line says what to do.'), evidence };
|
|
935
1112
|
}
|
|
936
1113
|
}
|
|
937
1114
|
try {
|
|
@@ -951,6 +1128,10 @@ async function statusTool(traceId, args) {
|
|
|
951
1128
|
const result = await call.promise;
|
|
952
1129
|
const env = envelope(call.id, result);
|
|
953
1130
|
env.evidence = { ...evidence, extension: 'connected' };
|
|
1131
|
+
const waiting = feedback.pendingCount();
|
|
1132
|
+
if (waiting && typeof env.summary === 'string') {
|
|
1133
|
+
env.summary = `${env.summary} The person sent ${waiting} comment batch(es) from Comment mode: call feedback_get.`;
|
|
1134
|
+
}
|
|
954
1135
|
return env;
|
|
955
1136
|
} catch {
|
|
956
1137
|
evidence.extension = 'not_connected';
|
|
@@ -1012,7 +1193,11 @@ async function handleRequest(request) {
|
|
|
1012
1193
|
'For a task the user repeats, record it once with workflow_record and replay it with workflow_run. ' +
|
|
1013
1194
|
'Tabs: work in the session\'s main tab and navigate it for each new task. Open a helper with ' +
|
|
1014
1195
|
'browser_new_tab only for a side trip (research, a comparison) and close it with browser_close_tab when done, ' +
|
|
1015
|
-
'which takes you back to the main tab. A session keeps at most 3 tabs between tasks; its tabs close when it ends.'
|
|
1196
|
+
'which takes you back to the main tab. A session keeps at most 3 tabs between tasks; its tabs close when it ends. ' +
|
|
1197
|
+
'When the user mentions their BrowserZap feedback or comments, call feedback_get: it returns the notes they ' +
|
|
1198
|
+
'made on page elements in Comment mode, with components, source files and screenshots. When debugging a page, ' +
|
|
1199
|
+
'page_console and page_network show what it logged and requested. A click that downloads a file says so; ' +
|
|
1200
|
+
'browser_downloads lists and waits for downloads. file_upload always needs the user\'s approval per file.',
|
|
1016
1201
|
});
|
|
1017
1202
|
break;
|
|
1018
1203
|
}
|
|
@@ -26,7 +26,7 @@ const { getOrCreateSecret } = require('./secret');
|
|
|
26
26
|
const { DEFAULT_PORT, PROTOCOL_VERSION, fileLogger } = require('./server');
|
|
27
27
|
const { ensureBridge } = require('./ensure-bridge');
|
|
28
28
|
const { helloProbe } = require('./probe');
|
|
29
|
-
const { HOST_NAME,
|
|
29
|
+
const { HOST_NAME, allowedOrigins } = require('./extension-id');
|
|
30
30
|
|
|
31
31
|
/** Inbound frames above this are rejected before parsing. Chrome's own host→Chrome cap is 1 MiB. */
|
|
32
32
|
const MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
@@ -130,7 +130,9 @@ async function handleRequest(request, options = {}) {
|
|
|
130
130
|
});
|
|
131
131
|
if (!verified.ok) {
|
|
132
132
|
logLine('bridge_not_verified', { port, code: verified.code });
|
|
133
|
-
|
|
133
|
+
// 4003: a BrowserZap bridge is listening but holds another install's secret
|
|
134
|
+
// (D-39). The extension shows the fix instead of staying grey.
|
|
135
|
+
return { type: 'error', error: verified.code === 4003 ? 'bridge_foreign' : 'bridge_unavailable' };
|
|
134
136
|
}
|
|
135
137
|
verified.ws.close();
|
|
136
138
|
logLine('hello_served', { port });
|
|
@@ -141,7 +143,7 @@ function main() {
|
|
|
141
143
|
// Defense in depth: allowed_origins is the real control, but refuse a caller
|
|
142
144
|
// that is not the BrowserZap extension if Chrome tells us who it is.
|
|
143
145
|
const origin = process.argv[2];
|
|
144
|
-
if (typeof origin === 'string' && origin.startsWith('chrome-extension://') &&
|
|
146
|
+
if (typeof origin === 'string' && origin.startsWith('chrome-extension://') && !allowedOrigins().includes(origin)) {
|
|
145
147
|
logLine('wrong_origin');
|
|
146
148
|
process.exit(1);
|
|
147
149
|
}
|
package/bridge-server/panel.js
CHANGED
|
@@ -39,6 +39,7 @@ const { createWorkflowService, checkStep, stepLogEntry } = require('./workflow-s
|
|
|
39
39
|
const { createSessionLog, describeResult } = require('./session-log');
|
|
40
40
|
const { parseParamSets } = require('./param-sets');
|
|
41
41
|
const screenshots = require('./screenshots');
|
|
42
|
+
const feedback = require('./feedback');
|
|
42
43
|
|
|
43
44
|
const PANEL_SESSION = 'panel';
|
|
44
45
|
const PANEL_SESSION_NAME = 'BrowserZap workflows';
|
|
@@ -424,6 +425,12 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
424
425
|
case 'delete':
|
|
425
426
|
workflows.remove(String(args.name));
|
|
426
427
|
return { ok: true, data: {} };
|
|
428
|
+
case 'feedback_submit': {
|
|
429
|
+
// Comment mode (docs/COMMENT_MODE.md): stored on this device for the agent's feedback_get.
|
|
430
|
+
const saved = feedback.saveBatch(args.batch);
|
|
431
|
+
log('feedback_saved', { batch: saved.id, comments: saved.count });
|
|
432
|
+
return { ok: true, data: { batchId: saved.id, count: saved.count } };
|
|
433
|
+
}
|
|
427
434
|
default:
|
|
428
435
|
return { ok: false, error: { code: 'INVALID_ARGUMENT', message: `Unknown panel request "${op}".` } };
|
|
429
436
|
}
|
package/bridge-server/probe.js
CHANGED
|
@@ -101,7 +101,8 @@ function nodePidsOnPort(port) {
|
|
|
101
101
|
return [...pids].filter((pid) => /node\.exe/i.test(execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8', windowsHide: true })));
|
|
102
102
|
}
|
|
103
103
|
for (const pid of execFileSync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' }).split(/\s+/).filter(Boolean)) {
|
|
104
|
-
|
|
104
|
+
// `comm` can be Node's thread name ("MainThread") on Linux; the command line names node.
|
|
105
|
+
if (/(^|\/)node(\.exe)?(\s|$)/i.test(execFileSync('ps', ['-p', pid, '-o', 'args='], { encoding: 'utf8' }).trim())) pids.add(pid);
|
|
105
106
|
}
|
|
106
107
|
} catch { /* netstat/lsof missing or nothing found */ }
|
|
107
108
|
return [...pids];
|