browserzap 0.5.0 → 0.6.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.
@@ -7,6 +7,11 @@
7
7
  * extension id. The private half lives outside the repository at
8
8
  * %USERPROFILE%\.browserzap-extension\key.pem and is never committed.
9
9
  *
10
+ * The Chrome Web Store signs its own build and does not accept a manifest
11
+ * `key`, so the store build may get a different id. Fill in
12
+ * STORE_EXTENSION_ID from the developer dashboard after the first upload; the
13
+ * native host then lets both builds in (docs/RELEASE.md §3b).
14
+ *
10
15
  * Everything that has to name the extension — the native messaging host
11
16
  * manifest's allowed_origins, `browserzap doctor`, the tests — reads the id
12
17
  * from here. `extensionIdFromKey` derives it again from the manifest key, so a
@@ -22,6 +27,12 @@ const crypto = require('crypto');
22
27
  /** chrome-extension://gienkaoaimdjmcjdejjanmbkpfakmkno/ */
23
28
  const EXTENSION_ID = 'gienkaoaimdjmcjdejjanmbkpfakmkno';
24
29
 
30
+ /** The Chrome Web Store build's id (assigned on the first upload, 2026-10-29). */
31
+ const STORE_EXTENSION_ID = 'bbnmhaonicbmljhbahimpgfafbjoijpe';
32
+
33
+ /** Every build the native host lets in: the unpacked build first, then the store build. */
34
+ const ALLOWED_EXTENSION_IDS = Object.freeze([EXTENSION_ID, STORE_EXTENSION_ID].filter(Boolean));
35
+
25
36
  /** Name Chrome uses for the native messaging host; also the registry key's leaf. */
26
37
  const HOST_NAME = 'com.browserzap.bridge';
27
38
 
@@ -29,6 +40,11 @@ function extensionOrigin(id = EXTENSION_ID) {
29
40
  return `chrome-extension://${id}/`;
30
41
  }
31
42
 
43
+ /** allowed_origins for the native host manifest, one per allowed build. */
44
+ function allowedOrigins(ids = ALLOWED_EXTENSION_IDS) {
45
+ return ids.map((id) => extensionOrigin(id));
46
+ }
47
+
32
48
  /**
33
49
  * Chrome's extension id for a manifest `key` value (base64 SPKI DER).
34
50
  * Returns null when the key is missing or is not a valid public key.
@@ -51,4 +67,12 @@ function extensionIdFromKey(key) {
51
67
  .join('');
52
68
  }
53
69
 
54
- module.exports = { EXTENSION_ID, HOST_NAME, extensionOrigin, extensionIdFromKey };
70
+ module.exports = {
71
+ EXTENSION_ID,
72
+ STORE_EXTENSION_ID,
73
+ ALLOWED_EXTENSION_IDS,
74
+ HOST_NAME,
75
+ extensionOrigin,
76
+ allowedOrigins,
77
+ extensionIdFromKey,
78
+ };
@@ -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
@@ -26,6 +26,13 @@ const LIMITS = Object.freeze({
26
26
  SETTLE_QUIET_MS: 500,
27
27
  /** How long that settle waits for a busy page (it costs nothing on a quiet one). */
28
28
  SETTLE_MAX_MS: 5000,
29
+ /** A wait whose condition makes no progress for this long ends early: a page that is stuck does not get better. */
30
+ STALL_MS: 3000,
31
+ /**
32
+ * Before "Re-run failed" resumes at the step that failed, that step's target
33
+ * must be actionable within this. A bounded probe, not a pause (A6-09).
34
+ */
35
+ RESUME_PROBE_MS: 3000,
29
36
  /** Longest per-step pacing a person may set on a saved workflow step. */
30
37
  MAX_STEP_PACING_MS: 30000,
31
38
  /** A tab with no agent action for this long is detached, so Chrome's "debugging" bar goes away (D-35). */
@@ -60,6 +67,16 @@ const STATUS_DEADLINE_MS = 4_000;
60
67
  */
61
68
  const RUN_DEADLINE_MS = 600_000;
62
69
 
70
+ /**
71
+ * Resolver engine bounds (`bridge-server/resolvers.js`, 0.6.0, D-37).
72
+ * A step may spend at most MAX_STEP_RESOLVE_MS resolving blockers in place,
73
+ * across at most MAX_RESOLVER_ATTEMPTS attempts, and a reversible click with
74
+ * no effect at all is repeated at most MAX_INPLACE_CLICK_RETRIES times (AU-08).
75
+ */
76
+ const MAX_STEP_RESOLVE_MS = 45_000;
77
+ const MAX_RESOLVER_ATTEMPTS = 2;
78
+ const MAX_INPLACE_CLICK_RETRIES = 2;
79
+
63
80
  /**
64
81
  * How long the caller waits for the extension to answer one call: the sum of
65
82
  * every wait the call can do, plus a margin, and never less than
@@ -97,4 +114,13 @@ function callDeadlineMs(tool, args = {}) {
97
114
  return Math.max(CALL_DEADLINE_MS, waits + DEADLINE_MARGIN_MS);
98
115
  }
99
116
 
100
- module.exports = { LIMITS, CALL_DEADLINE_MS, STATUS_DEADLINE_MS, RUN_DEADLINE_MS, callDeadlineMs };
117
+ module.exports = {
118
+ LIMITS,
119
+ CALL_DEADLINE_MS,
120
+ STATUS_DEADLINE_MS,
121
+ RUN_DEADLINE_MS,
122
+ MAX_STEP_RESOLVE_MS,
123
+ MAX_RESOLVER_ATTEMPTS,
124
+ MAX_INPLACE_CLICK_RETRIES,
125
+ callDeadlineMs,
126
+ };