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.
@@ -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 };
@@ -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
- const SUBCOMMANDS = new Set(['setup', 'doctor', 'uninstall', 'run']);
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 "npm run health" for details.';
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 "npm run health" and check that the extension is loaded in Chrome.';
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 "npm run bridge:restart".'), { code: 'BRIDGE_DISCONNECTED' }));
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
- const { env, extra } = await executeTool(toolName, args, { onStart: (c) => { call.cancel = c.cancel; } });
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, 'Run "npm run health".');
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 "npm run health" in the BrowserZap folder.'), evidence };
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, extensionOrigin } = require('./extension-id');
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
- return { type: 'error', error: 'bridge_unavailable' };
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://') && origin !== extensionOrigin()) {
146
+ if (typeof origin === 'string' && origin.startsWith('chrome-extension://') && !allowedOrigins().includes(origin)) {
145
147
  logLine('wrong_origin');
146
148
  process.exit(1);
147
149
  }
@@ -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
  }
@@ -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
- if (/node/i.test(execFileSync('ps', ['-p', pid, '-o', 'comm='], { encoding: 'utf8' }))) pids.add(pid);
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];