browserzap 0.6.0 → 0.7.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.
@@ -32,6 +32,7 @@ const { BIND_HOST, DEFAULT_PORT, PROTOCOL_VERSION, sanitizeSessionName } = requi
32
32
  const { ensureBridge: ensureBridgeListening } = require('./ensure-bridge');
33
33
  const { LIMITS, CALL_DEADLINE_MS, STATUS_DEADLINE_MS } = require('./limits');
34
34
  const chromeLauncher = require('./chrome-launcher');
35
+ const { MIN_EXTENSION_VERSION, isOlderThan } = require('./extension-version');
35
36
  const { MCP_TOOLS, LOCAL_TOOLS, validateArguments } = require('./tools');
36
37
  const { currentPlan, hasFeature } = require('./entitlements');
37
38
  const { envelope, errorEnvelope, executeStep, createActionClient } = require('./executor');
@@ -482,6 +483,12 @@ async function callToolWith(progress, requestId, toolName, args) {
482
483
  const started = Date.now();
483
484
  const traceId = `${SESSION_ID}-t${++traceSeq}`;
484
485
  const finish = (env, extraContent) => {
486
+ // The person can message the agent from the side panel at any time; the
487
+ // agent learns of it on its next call. browser_status says it itself.
488
+ if (toolName !== 'browser_status' && !toolName.startsWith('feedback_') && typeof env.summary === 'string') {
489
+ const waiting = feedback.pendingCount();
490
+ if (waiting) env.summary = `${env.summary} ${unreadFeedbackNote(waiting)}`;
491
+ }
485
492
  log('tool_call', {
486
493
  tool: toolName,
487
494
  traceId: env.traceId,
@@ -557,6 +564,24 @@ async function callToolWith(progress, requestId, toolName, args) {
557
564
  }
558
565
  }
559
566
 
567
+ /**
568
+ * An action the connected build does not know (an extension older than the tool
569
+ * surface, src/actions/browser-actions.ts) comes back as `Unknown action: …`
570
+ * with INVALID_ARGUMENT. Report that as `needs_user` naming the build, so the
571
+ * person is told to update instead of the agent retrying a call that cannot
572
+ * work. Same code, same `ok`, the call itself is not refused here.
573
+ */
574
+ async function nameUnsupportedAction(env) {
575
+ if (env.ok !== false || env.error?.code !== 'INVALID_ARGUMENT') return;
576
+ if (typeof env.error.message !== 'string' || !env.error.message.startsWith('Unknown action:')) return;
577
+ const version = await connectedExtensionVersion();
578
+ env.state = 'needs_user';
579
+ env.summary = version
580
+ ? `The BrowserZap extension (${version}) does not support this action; update it from chrome://extensions → Update.`
581
+ : 'The BrowserZap extension does not support this action and did not report its version, which means it is older than 0.7.0; update it from chrome://extensions → Update.';
582
+ env.nextAction = 'Ask the user to update the BrowserZap extension. Chrome updates store extensions by itself; to update now open chrome://extensions → Update.';
583
+ }
584
+
560
585
  /**
561
586
  * Run one browser tool through the extension and build its envelope. Live
562
587
  * calls and workflow steps both come through here, so a replayed step gets
@@ -565,6 +590,7 @@ async function callToolWith(progress, requestId, toolName, args) {
565
590
  */
566
591
  async function executeTool(toolName, args, { allowedOrigins = ALLOWED_ORIGINS, onStart } = {}) {
567
592
  const env = await executeStep(startAction, toolName, args, { allowedOrigins, onStart });
593
+ await nameUnsupportedAction(env);
568
594
  const extra = [];
569
595
  if (typeof env.screenshot === 'string') {
570
596
  const format = env.format || 'jpeg';
@@ -677,6 +703,11 @@ function checkUpload(traceId, args) {
677
703
 
678
704
  // ─── Comment mode batches (docs/COMMENT_MODE.md) ─────────────────────────────
679
705
 
706
+ /** The sentence that tells the agent the person is waiting for it to read something. */
707
+ function unreadFeedbackNote(waiting) {
708
+ return `The person sent ${waiting} comment batch(es) or message(s) from the BrowserZap side panel: call feedback_get now and act on them.`;
709
+ }
710
+
680
711
  /** Inline images per feedback_get: enough to see every comment, small enough for any client. */
681
712
  const FEEDBACK_INLINE_IMAGES = 12;
682
713
  const FEEDBACK_INLINE_BYTES = 6 * 1024 * 1024;
@@ -719,10 +750,14 @@ function feedbackTool(traceId, toolName, args) {
719
750
  }
720
751
  }
721
752
  }
722
- const pages = [...new Set(batch.comments.map((c) => c.page.url))];
753
+ const pages = [...new Set(batch.comments.map((c) => c.page?.url).filter(Boolean))];
754
+ const messages = batch.comments.filter((c) => c.kind === 'message').length;
755
+ const pinned = batch.comments.length - messages;
756
+ const what = [messages ? `${messages} message(s)` : '', pinned ? `${pinned} comment(s)` : ''].filter(Boolean).join(' and ');
757
+ const where = pages.length === 0 ? '' : ` on ${pages.length === 1 ? pages[0] : `${pages.length} pages`}`;
723
758
  return {
724
759
  env: okEnvelope(traceId,
725
- `${batch.comments.length} comment(s) from the person on ${pages.length === 1 ? pages[0] : `${pages.length} pages`}.`,
760
+ `${what} from the person${where}.`,
726
761
  { batch },
727
762
  {}),
728
763
  extra,
@@ -1014,7 +1049,7 @@ const backgroundRuns = createRunRegistry({
1014
1049
  });
1015
1050
 
1016
1051
  /** Tools that only read, allowed while a background run drives the tab. */
1017
- const READ_WHILE_RUNNING = new Set(['browser_status', 'page_inspect', 'screenshot_capture', 'page_console', 'page_network', 'browser_list_tabs', 'browser_list_profiles']);
1052
+ const READ_WHILE_RUNNING = new Set(['browser_status', 'page_inspect', 'screenshot_capture', 'page_console', 'page_network', 'browser_list_tabs', 'browser_list_profiles', 'chat_reply']);
1018
1053
 
1019
1054
  /**
1020
1055
  * "5 of 5 done" / "3 of 5 done, 2 need attention" as a Chrome notification
@@ -1100,6 +1135,38 @@ function planEvidence() {
1100
1135
  return { plan, licenseStatus, features, limits, expiresAt, notice, proPackage: Boolean(pro), recording: recorder ? recorder.name : null };
1101
1136
  }
1102
1137
 
1138
+ /** The profile a status/report should speak about: the selected one, else the first. */
1139
+ function relevantProfile(profiles) {
1140
+ if (!Array.isArray(profiles) || profiles.length === 0) return null;
1141
+ return profiles.find((p) => p.selected) ?? profiles[0];
1142
+ }
1143
+
1144
+ /** The version the connected extension reported, or null when it is older than 0.7.0 (WP5). */
1145
+ async function connectedExtensionVersion() {
1146
+ try {
1147
+ const reply = await bridgeRequest('profiles_list', {}, 1500);
1148
+ if (reply?.ok !== true) return null;
1149
+ const profile = relevantProfile(reply.data?.profiles);
1150
+ return typeof profile?.extensionVersion === 'string' ? profile.extensionVersion : null;
1151
+ } catch {
1152
+ return null;
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * What browser_status says about a store build that lags this bridge (WP5, F4).
1158
+ * A notice only: `ok` never changes and no call is ever refused for it.
1159
+ */
1160
+ function extensionVersionNotice(version) {
1161
+ if (version === null) {
1162
+ return 'The BrowserZap extension did not report its version, which means it is older than 0.7.0. Chrome updates store extensions automatically; to update now open chrome://extensions → Update.';
1163
+ }
1164
+ if (isOlderThan(version, MIN_EXTENSION_VERSION)) {
1165
+ return `The BrowserZap extension is older than this bridge (${version} < ${MIN_EXTENSION_VERSION}). Chrome updates store extensions automatically; to update now open chrome://extensions → Update.`;
1166
+ }
1167
+ return null;
1168
+ }
1169
+
1103
1170
  async function statusTool(traceId, args) {
1104
1171
  const evidence = {
1105
1172
  bridge: sessionStarted ? 'connected' : lastCloseCode === 4003 ? 'unauthorized' : lastCloseCode === 4005 ? 'version_mismatch' : 'unreachable',
@@ -1117,6 +1184,7 @@ async function statusTool(traceId, args) {
1117
1184
  sessionLog: process.env.BROWSERZAP_LOG === 'off' ? null : sessionLog.file(),
1118
1185
  screenshots: { folder: screenshots.screenshotDir(), keepDays: screenshots.keepDays() },
1119
1186
  profiles: null,
1187
+ extensionVersion: null,
1120
1188
  };
1121
1189
  if (!sessionStarted) {
1122
1190
  try {
@@ -1131,12 +1199,17 @@ async function statusTool(traceId, args) {
1131
1199
  const reply = await bridgeRequest('profiles_list', {}, 1500);
1132
1200
  evidence.profiles = reply.data?.profiles ?? null;
1133
1201
  } catch { /* an older bridge: no profile list */ }
1202
+ evidence.extensionVersion = relevantProfile(evidence.profiles)?.extensionVersion ?? null;
1134
1203
  if (Array.isArray(evidence.profiles) && evidence.profiles.length > 1 && !evidence.profiles.some((p) => p.selected)) {
1204
+ // The extension did answer (with its profile list), so the version notice
1205
+ // belongs here too; the error itself is unchanged.
1206
+ const notice = extensionVersionNotice(evidence.extensionVersion);
1135
1207
  return {
1136
1208
  ...errorEnvelope(traceId, 'PROFILE_REQUIRED',
1137
1209
  `${evidence.profiles.length} Chrome profiles are connected (${evidence.profiles.map((p) => `"${p.label}"`).join(', ')}). Choose one first.`,
1138
1210
  'Call browser_select_profile with the profile the user means.'),
1139
1211
  evidence: { ...evidence, extension: 'connected' },
1212
+ ...(notice ? { notice } : {}),
1140
1213
  };
1141
1214
  }
1142
1215
  const call = startAction('status', { includeTab: args.includeTab === true }, STATUS_DEADLINE_MS);
@@ -1144,9 +1217,11 @@ async function statusTool(traceId, args) {
1144
1217
  const result = await call.promise;
1145
1218
  const env = envelope(call.id, result);
1146
1219
  env.evidence = { ...evidence, extension: 'connected' };
1220
+ const notice = extensionVersionNotice(evidence.extensionVersion);
1221
+ if (notice) env.notice = notice;
1147
1222
  const waiting = feedback.pendingCount();
1148
1223
  if (waiting && typeof env.summary === 'string') {
1149
- env.summary = `${env.summary} The person sent ${waiting} comment batch(es) from Comment mode: call feedback_get.`;
1224
+ env.summary = `${env.summary} ${unreadFeedbackNote(waiting)}`;
1150
1225
  }
1151
1226
  return env;
1152
1227
  } catch {
@@ -22,11 +22,11 @@
22
22
  * closes or after a bounded wait with no request.
23
23
  */
24
24
 
25
- const { getOrCreateSecret } = require('./secret');
25
+ const { getOrCreateSecret, configDir } = 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, allowedOrigins } = require('./extension-id');
29
+ const { HOST_NAME, allowedOrigins, allowedExtensionIds } = 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;
@@ -139,11 +139,29 @@ async function handleRequest(request, options = {}) {
139
139
  return { type: 'hello', port, token, protocolVersion: PROTOCOL_VERSION };
140
140
  }
141
141
 
142
+ /**
143
+ * Whether Chrome's reported caller origin may start this helper.
144
+ *
145
+ * Defense in depth: allowed_origins in the host manifest is the real control.
146
+ * Chrome may pass no origin at all, and anything that is not a
147
+ * chrome-extension:// origin is not ours to judge here. The set of extension
148
+ * ids comes from `dir` (store id only, plus the unpacked id after
149
+ * `browserzap setup --dev`), so it is the same answer `setup` wrote.
150
+ */
151
+ function isAllowedOrigin(origin, { dir } = {}) {
152
+ if (typeof origin !== 'string' || !origin.startsWith('chrome-extension://')) return true;
153
+ try {
154
+ return allowedOrigins(allowedExtensionIds({ dir })).includes(origin);
155
+ } catch {
156
+ return false; // a config dir we cannot read allows nothing beyond the manifest
157
+ }
158
+ }
159
+
142
160
  function main() {
143
161
  // Defense in depth: allowed_origins is the real control, but refuse a caller
144
162
  // that is not the BrowserZap extension if Chrome tells us who it is.
145
163
  const origin = process.argv[2];
146
- if (typeof origin === 'string' && origin.startsWith('chrome-extension://') && !allowedOrigins().includes(origin)) {
164
+ if (!isAllowedOrigin(origin, { dir: configDir() })) {
147
165
  logLine('wrong_origin');
148
166
  process.exit(1);
149
167
  }
@@ -204,4 +222,5 @@ module.exports = {
204
222
  decodeMessage,
205
223
  handleRequest,
206
224
  configuredPort,
225
+ isAllowedOrigin,
207
226
  };
@@ -82,6 +82,61 @@ function helloProbe({ port, token, session, name, timeoutMs = 3000 }) {
82
82
  });
83
83
  }
84
84
 
85
+ /**
86
+ * Ask the bridge which Chrome profiles are connected (`browserzap doctor`).
87
+ *
88
+ * Connects like helloProbe, then sends the bridge's own `profiles_list`
89
+ * request (answered by server.js with `{ type: 'bridge_reply', id, ok, data }`)
90
+ * and ends its session on close as helloProbe does, so no session lingers.
91
+ * Resolves { ok: true, profiles } or { ok: false, profiles: [] }. Never logs
92
+ * the token.
93
+ */
94
+ function listProfiles({ port, token, session, name, timeoutMs = 2000 }) {
95
+ const requestId = 'doctor-profiles';
96
+ return new Promise((resolve) => {
97
+ const ws = new WebSocket(`ws://${BIND_HOST}:${port}`);
98
+ let settled = false;
99
+ const finish = (result, { gracefully = false } = {}) => {
100
+ if (settled) return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ try {
104
+ if (gracefully) ws.close();
105
+ else ws.terminate();
106
+ } catch { /* already closed */ }
107
+ resolve(result);
108
+ };
109
+ const timer = setTimeout(() => finish({ ok: false, profiles: [] }), timeoutMs);
110
+ ws.on('open', () => {
111
+ ws.send(JSON.stringify({ type: 'hello', role: 'agent', token: token ?? '', protocolVersion: PROTOCOL_VERSION, session, name }));
112
+ });
113
+ ws.on('message', (data) => {
114
+ let msg;
115
+ try {
116
+ msg = JSON.parse(String(data));
117
+ } catch {
118
+ return; // the timer covers a stuck handshake
119
+ }
120
+ if (msg.type === 'session_started') {
121
+ // Same rule as helloProbe: a probe's session ends with its socket.
122
+ const close = ws.close.bind(ws);
123
+ ws.close = (...closeArgs) => {
124
+ try { ws.send(JSON.stringify({ type: 'session_end', session: msg.session })); } catch { /* closing anyway */ }
125
+ close(...closeArgs);
126
+ };
127
+ ws.send(JSON.stringify({ type: 'profiles_list', id: requestId }));
128
+ return;
129
+ }
130
+ if (msg.type === 'bridge_reply' && msg.id === requestId) {
131
+ const profiles = msg.data?.profiles ?? msg.evidence?.profiles;
132
+ finish(Array.isArray(profiles) ? { ok: true, profiles } : { ok: false, profiles: [] }, { gracefully: true });
133
+ }
134
+ });
135
+ ws.on('close', () => finish({ ok: false, profiles: [] }));
136
+ ws.on('error', () => finish({ ok: false, profiles: [] }));
137
+ });
138
+ }
139
+
85
140
  /** The protocol a refusing bridge says it speaks ("Version mismatch: this bridge speaks protocol 2, …"), or null. */
86
141
  function bridgeProtocolFrom(reason) {
87
142
  const m = /speaks protocol (\d+)/.exec(String(reason || ''));
@@ -121,4 +176,4 @@ async function stopBridgeOnPort(port) {
121
176
  return pids;
122
177
  }
123
178
 
124
- module.exports = { isListening, helloProbe, bridgeProtocolFrom, nodePidsOnPort, stopBridgeOnPort };
179
+ module.exports = { isListening, helloProbe, listProfiles, bridgeProtocolFrom, nodePidsOnPort, stopBridgeOnPort };
@@ -74,7 +74,7 @@ const VALID_AGENT_ACTIONS = new Set([
74
74
  'status', 'inspect', 'navigate', 'click', 'fill', 'select_option', 'wait_for',
75
75
  'screenshot', 'press_key', 'scroll', 'pause', 'resume',
76
76
  'new_tab', 'list_tabs', 'switch_tab', 'close_tab', 'notify', 'upload',
77
- 'console', 'network',
77
+ 'console', 'network', 'say',
78
78
  ]);
79
79
 
80
80
  const VALID_MESSAGE_TYPES = new Set([
@@ -157,8 +157,9 @@ function createBridgeServer(options = {}) {
157
157
  * Connected browser profiles. Every Chrome profile has its own copy of the
158
158
  * extension (its own instance id, tabs and logins), and each may connect
159
159
  * once (multi-profile, docs/MULTI_PROFILE.md). The same install reconnecting
160
- * replaces its own socket.
161
- * @type {Map<string, { ws: WebSocket, browser: string, label: string, connectedAt: number }>}
160
+ * replaces its own socket. `extensionVersion` is what the extension reported
161
+ * in its hello (WP5); null when it is older and reports nothing.
162
+ * @type {Map<string, { ws: WebSocket, browser: string, label: string, connectedAt: number, extensionVersion: string | null }>}
162
163
  */
163
164
  const profiles = new Map();
164
165
  /** @type {Map<WebSocket, string>} */
@@ -183,6 +184,8 @@ function createBridgeServer(options = {}) {
183
184
  };
184
185
  const profileList = (selected) => [...profiles.entries()].map(([id, p]) => ({
185
186
  id, label: p.label, browser: p.browser, connectedAt: new Date(p.connectedAt).toISOString(), selected: id === selected,
187
+ // What the store build said in its hello; null for a build older than 0.7.0.
188
+ extensionVersion: p.extensionVersion ?? null,
186
189
  }));
187
190
 
188
191
  // The side panel's workflow runner: a built-in session that drives the
@@ -552,13 +555,17 @@ function createBridgeServer(options = {}) {
552
555
  // came from auto-pairing or from a pasted token (ADR-009).
553
556
  pairing.recordPairing(id);
554
557
  const browser = typeof hello.browser === 'string' ? hello.browser : 'unknown';
558
+ // Additive hello field (WP5): a build older than 0.7.0 sends nothing, which
559
+ // is accepted as it always was. A reconnect that omits it keeps the version
560
+ // this profile reported before.
561
+ const extensionVersion = typeof hello.extensionVersion === 'string' && hello.extensionVersion ? hello.extensionVersion.slice(0, 32) : null;
555
562
  const existing = profiles.get(id);
556
563
  if (existing && existing.ws !== ws && existing.ws.readyState === WebSocket.OPEN) {
557
564
  log('Browser profile reconnected (replacing old connection)');
558
565
  existing.ws.close(CLOSE_REPLACED, 'Replaced by new extension connection');
559
566
  }
560
567
  const label = sanitizeSessionName(hello.label) ?? existing?.label ?? `Profile ${profiles.size + (existing ? 0 : 1)}`;
561
- profiles.set(id, { ws, browser, label, connectedAt: Date.now() });
568
+ profiles.set(id, { ws, browser, label, connectedAt: Date.now(), extensionVersion: extensionVersion ?? existing?.extensionVersion ?? null });
562
569
  socketToProfile.set(ws, id);
563
570
  log(`Browser profile "${label}" connected (${browser}, ${id.slice(0, 8)}); ${profiles.size} connected`);
564
571
 
@@ -25,12 +25,20 @@ const { configDir } = require('./secret');
25
25
  const { isListening, helloProbe } = require('./probe');
26
26
  const { DEFAULT_PORT } = require('./server');
27
27
  const { readSecret } = require('./secret');
28
- const { EXTENSION_ID, STORE_EXTENSION_ID, HOST_NAME, allowedOrigins, extensionIdFromKey } = require('./extension-id');
28
+ const {
29
+ EXTENSION_ID,
30
+ STORE_EXTENSION_ID,
31
+ HOST_NAME,
32
+ allowedOrigins,
33
+ allowedExtensionIds,
34
+ setDevExtensionAllowed,
35
+ extensionIdFromKey,
36
+ } = require('./extension-id');
29
37
 
30
38
  const HOST_DIR_NAME = 'native-host';
31
39
  const NATIVE_HOST_SCRIPT = path.join(__dirname, 'native-host.js');
32
40
  const MCP_ENTRY = path.join(__dirname, 'index.js');
33
- /** The Chrome Web Store listing (STORE_EXTENSION_ID); it opens once the listing is published. */
41
+ /** The Chrome Web Store listing (STORE_EXTENSION_ID): the only install path for a user (D3). */
34
42
  const STORE_URL = `https://chromewebstore.google.com/detail/${STORE_EXTENSION_ID}`;
35
43
 
36
44
  /** HKCU only: no administrator rights, and the key is per-user by design. */
@@ -97,7 +105,8 @@ function hostManifest({ dir = configDir(), platform = process.platform, nodePath
97
105
  description: 'BrowserZap bridge helper: starts the local bridge and hands this extension its pairing token.',
98
106
  path: launcherPath(dir, platform),
99
107
  type: 'stdio',
100
- allowed_origins: allowedOrigins(),
108
+ // Store id only, unless `setup --dev` saved the developer build (D4).
109
+ allowed_origins: allowedOrigins(allowedExtensionIds({ dir })),
101
110
  };
102
111
  }
103
112
 
@@ -356,10 +365,17 @@ function write(line = '') {
356
365
  process.stdout.write(`${line}\n`);
357
366
  }
358
367
 
359
- /** A file the extension may be loaded from, for the doctor's id check. */
368
+ /**
369
+ * A file the extension may be loaded from, for the doctor's id check, or null.
370
+ *
371
+ * An npm install ships no extension at all (D3), so "no manifest" is a normal
372
+ * answer and never a path to a file that is not there (F1).
373
+ */
360
374
  function extensionManifestPath(root = path.join(__dirname, '..')) {
361
375
  const built = path.join(root, 'extension', 'manifest.json');
362
- return fs.existsSync(built) ? built : path.join(root, 'src', 'manifest.json');
376
+ if (fs.existsSync(built)) return built;
377
+ const source = path.join(root, 'src', 'manifest.json');
378
+ return fs.existsSync(source) ? source : null;
363
379
  }
364
380
 
365
381
  /**
@@ -395,12 +411,16 @@ async function doctor({
395
411
  );
396
412
 
397
413
  const allowed = Array.isArray(manifest?.allowed_origins) ? manifest.allowed_origins : [];
398
- const expected = allowedOrigins();
414
+ const expectedIds = allowedExtensionIds({ dir });
415
+ const expected = allowedOrigins(expectedIds);
416
+ const mode = expectedIds.length > 1
417
+ ? 'store + developer build (setup --dev)'
418
+ : `store only (${STORE_EXTENSION_ID})`;
399
419
  record(
400
- expected.length > 1 ? 'Host allows the unpacked and store extension ids' : 'Host allows this extension id',
420
+ 'Allowed extensions',
401
421
  expected.every((origin) => allowed.includes(origin)),
402
- allowed.length ? allowed.join(', ') : 'no allowed_origins',
403
- `Run: browserzap setup (expected ${expected.join(', ')})`,
422
+ `${mode}: ${allowed.length ? allowed.join(', ') : 'no allowed_origins'}`,
423
+ 'Run: browserzap setup',
404
424
  );
405
425
 
406
426
  const launcher = manifest?.path;
@@ -460,17 +480,23 @@ async function doctor({
460
480
  }
461
481
 
462
482
  const extManifestFile = extensionManifest;
463
- let extKey = null;
464
- try {
465
- extKey = JSON.parse(fs.readFileSync(extManifestFile, 'utf8')).key ?? null;
466
- } catch { /* no manifest yet */ }
467
- const derivedId = extensionIdFromKey(extKey);
468
- record(
469
- 'Extension id matches its manifest key',
470
- derivedId === EXTENSION_ID,
471
- derivedId ? `${derivedId} (${extManifestFile})` : `no key in ${extManifestFile}`,
472
- 'The key in src/manifest.json changed. The native host manifest must be updated and setup re-run.',
473
- );
483
+ if (!extManifestFile) {
484
+ // An npm install ships no extension: the store build is the one users have,
485
+ // and the host manifest already allows it. Nothing to derive.
486
+ record('Store extension id', true, `${STORE_EXTENSION_ID} (allowed by the native host)`);
487
+ } else {
488
+ let extKey = null;
489
+ try {
490
+ extKey = JSON.parse(fs.readFileSync(extManifestFile, 'utf8')).key ?? null;
491
+ } catch { /* no manifest yet */ }
492
+ const derivedId = extensionIdFromKey(extKey);
493
+ record(
494
+ 'Extension id matches its manifest key',
495
+ derivedId === EXTENSION_ID,
496
+ derivedId ? `${derivedId} (${extManifestFile})` : `no key in ${extManifestFile}`,
497
+ 'The key in src/manifest.json changed. The native host manifest must be updated and setup re-run.',
498
+ );
499
+ }
474
500
 
475
501
  const secret = readSecret();
476
502
  const listening = await isListening(port);
@@ -498,6 +524,24 @@ async function doctor({
498
524
  : 'Open the BrowserZap side panel; the background service starts the bridge through the native host.',
499
525
  );
500
526
 
527
+ // The version the connected extension reports (F4). Informational: an older
528
+ // store build still works, and Chrome updates it on its own.
529
+ let extensionVersion = 'not available';
530
+ if (probe.ok) {
531
+ try {
532
+ const listed = await require('./probe').listProfiles({
533
+ port,
534
+ token: secret,
535
+ session: 'doctor-profiles',
536
+ name: 'doctor',
537
+ timeoutMs: 2000,
538
+ });
539
+ const profile = listed.ok ? (listed.profiles.find((p) => p.selected) ?? listed.profiles[0]) : null;
540
+ extensionVersion = profile ? (profile.extensionVersion ?? 'not reported (older than 0.7.0)') : 'not available';
541
+ } catch { /* informational only */ }
542
+ }
543
+ record('Extension version', true, extensionVersion);
544
+
501
545
  let width = 0;
502
546
  for (const r of results) width = Math.max(width, r.name.length);
503
547
  out('BrowserZap doctor');
@@ -560,18 +604,25 @@ function wslCommand({ nodePath = process.execPath, serverPath = MCP_ENTRY, globa
560
604
  * `browserzap setup`. `args` is everything after the command word.
561
605
  * Returns the process exit code.
562
606
  */
563
- async function setupCommand(args = [], { extensionPath = null } = {}) {
607
+ async function setupCommand(args = [], { extensionPath = null, dir = configDir() } = {}) {
564
608
  const yes = args.includes('--yes') || args.includes('-y');
565
609
  const skipClients = args.includes('--no-clients');
566
610
  const register = !args.includes('--no-registry');
611
+ const dev = args.includes('--dev');
612
+
613
+ // Saved before the host is registered: the manifest's allowed_origins must
614
+ // name the ids this run enables (D4). A plain `setup` switches a developer
615
+ // machine back to store only, and says so below.
616
+ setDevExtensionAllowed(dir, dev);
567
617
 
568
- const result = registerNativeHost({ register });
618
+ const result = registerNativeHost({ dir, register });
569
619
  write('BrowserZap setup');
570
620
  if (fromNpxCache()) {
571
621
  write(' Warning: this copy runs from the npx cache, which npm may clear; Chrome and your agent would then');
572
622
  write(' lose BrowserZap. Install it once with `npm install -g browserzap`, then run `browserzap setup`.');
573
623
  }
574
624
  write(` Extension id: ${EXTENSION_ID}`);
625
+ write(` Extension mode: ${dev ? 'store + developer build (setup --dev)' : 'store only'}`);
575
626
  write(` Host manifest: ${result.manifestPaths[0]}`);
576
627
  write(` Launcher: ${result.launcher}`);
577
628
  if (result.registryKey && register) {
@@ -625,11 +676,17 @@ async function setupCommand(args = [], { extensionPath = null } = {}) {
625
676
  }
626
677
 
627
678
  write('Next:');
628
- write(` 1. Add the extension: ${STORE_URL}`);
629
- write(' (Chrome Web Store; the listing is in review. Until it opens there:');
630
- write(` chrome://extensions → Developer mode → Load unpacked → ${extensionPath ?? 'the extension/ folder of this package'})`);
679
+ write(` 1. Install the extension: ${STORE_URL}`);
680
+ write(' (Chrome keeps it updated.)');
681
+ if (extensionPath) {
682
+ // A source checkout only: the store is the install path for a user (D3).
683
+ write(` Developer build: chrome://extensions → Developer mode → Load unpacked → ${extensionPath}`);
684
+ }
631
685
  write(' 2. Open the BrowserZap side panel. It connects automatically; no token to paste.');
632
686
  write(' 3. Open your MCP client and start work. `browserzap doctor` re-checks this setup.');
687
+ if (!dev && extensionPath) {
688
+ write(' (To connect an unpacked build instead, run: browserzap setup --dev)');
689
+ }
633
690
  return result.ok || !result.registryKey ? 0 : 1;
634
691
  }
635
692
 
@@ -650,6 +707,7 @@ function confirm(question) {
650
707
  }
651
708
 
652
709
  module.exports = {
710
+ STORE_URL,
653
711
  launcherTargets,
654
712
  blockingPolicy,
655
713
  fromNpxCache,
@@ -674,6 +732,7 @@ module.exports = {
674
732
  defaultRegistryRunner,
675
733
  planMcpClients,
676
734
  applyMcpPlan,
735
+ extensionManifestPath,
677
736
  doctor,
678
737
  setupCommand,
679
738
  };
@@ -341,6 +341,19 @@ const MCP_TOOLS = [
341
341
  properties: { tabId: { type: 'integer', minimum: 0 } },
342
342
  },
343
343
  },
344
+ {
345
+ name: 'chat_reply',
346
+ description:
347
+ 'Write to the person in the BrowserZap side panel chat, as your message. Use it to answer a message they sent ' +
348
+ '(read with feedback_get) or to say what you are about to do. Plain text, shown only on this computer; never put ' +
349
+ 'passwords or other secrets in it.',
350
+ inputSchema: {
351
+ type: 'object',
352
+ additionalProperties: false,
353
+ required: ['message'],
354
+ properties: { message: { type: 'string', minLength: 1, maxLength: 2000 } },
355
+ },
356
+ },
344
357
  {
345
358
  name: 'browser_set_session_name',
346
359
  description: 'Name this session; the name appears on the Chrome tab group so the user knows what it is doing.',
@@ -680,6 +693,7 @@ const ANNOTATIONS = {
680
693
  screenshot_capture: ['Screenshot', READ],
681
694
  browser_press_key: ['Press key', { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }],
682
695
  browser_scroll: ['Scroll', { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }],
696
+ chat_reply: ['Reply in the side panel', { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }],
683
697
  task_pause: ['Hand over to the user', { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }],
684
698
  task_resume: ['Wait for the user', LOCAL_READ],
685
699
  browser_new_tab: ['New tab', { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }],
@@ -736,6 +750,7 @@ const TOOL_TO_ACTION = {
736
750
  browser_scroll: 'scroll',
737
751
  task_pause: 'pause',
738
752
  task_resume: 'resume',
753
+ chat_reply: 'say',
739
754
  browser_new_tab: 'new_tab',
740
755
  browser_list_tabs: 'list_tabs',
741
756
  browser_switch_tab: 'switch_tab',
@@ -814,4 +829,16 @@ function validateArguments(tool, args) {
814
829
  return check(tool.inputSchema, args ?? {}, 'arguments');
815
830
  }
816
831
 
817
- module.exports = { MCP_TOOLS, TOOL_TO_ACTION, INTERNAL_ACTIONS, PAGE_TOOLS, LOCAL_TOOLS, validateArguments, check };
832
+ /**
833
+ * Tools held back for internal testing (0.8). They are offered only when
834
+ * BROWSERZAP_EXPERIMENTAL lists their feature, e.g. BROWSERZAP_EXPERIMENTAL=chat.
835
+ * A hidden tool is also refused if called (mcp-server.js looks tools up here).
836
+ */
837
+ const EXPERIMENTAL_TOOLS = Object.freeze({ chat_reply: 'chat' });
838
+ const enabledExperiments = new Set(String(process.env.BROWSERZAP_EXPERIMENTAL ?? '').split(',').map((s) => s.trim()).filter(Boolean));
839
+ for (let i = MCP_TOOLS.length - 1; i >= 0; i--) {
840
+ const feature = EXPERIMENTAL_TOOLS[MCP_TOOLS[i].name];
841
+ if (feature && !enabledExperiments.has(feature)) MCP_TOOLS.splice(i, 1);
842
+ }
843
+
844
+ module.exports = { EXPERIMENTAL_TOOLS, MCP_TOOLS, TOOL_TO_ACTION, INTERNAL_ACTIONS, PAGE_TOOLS, LOCAL_TOOLS, validateArguments, check };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browserzap",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Let your AI agent (Claude Code, Codex, Cursor or any MCP client) work in your own Chrome, with your logins, your approval and a Stop button.",
5
5
  "homepage": "https://browserzap.com",
6
6
  "bugs": {
@@ -31,13 +31,11 @@
31
31
  },
32
32
  "files": [
33
33
  "bridge-server/",
34
- "extension/",
35
34
  "README.md",
36
35
  "CHANGELOG.md",
37
36
  "LICENSE",
38
37
  "LICENSE-UPSTREAM",
39
- "THIRD_PARTY_NOTICES.md",
40
- "!extension/**/*.map"
38
+ "THIRD_PARTY_NOTICES.md"
41
39
  ],
42
40
  "engines": {
43
41
  "node": ">=22"
@@ -89,7 +87,8 @@
89
87
  "ts-loader": "^9.5.1",
90
88
  "typescript": "^5.6.3",
91
89
  "webpack": "^5.96.1",
92
- "webpack-cli": "^5.1.4"
90
+ "webpack-cli": "^5.1.4",
91
+ "preact": "^10.29.8"
93
92
  },
94
93
  "dependencies": {
95
94
  "ws": "^8.22.0"