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.
@@ -0,0 +1,287 @@
1
+ 'use strict';
2
+
3
+ const { LIMITS } = require('./limits');
4
+
5
+ /**
6
+ * Resolver engine — the registry (0.6.0, D-37).
7
+ *
8
+ * Pure: no I/O, no page access, no timers, no clocks. Given a failed (or
9
+ * ineffective) workflow step and the envelope the extension returned, say what
10
+ * the step is blocked on and what would clear it. The runner then waits for
11
+ * that condition as an ordinary `wait_for_state` call and retries the same
12
+ * step — so every wait and every retry still goes through the normal tool path
13
+ * and extension policy.
14
+ *
15
+ * Design: docs/NEXT_RELEASE_SCOPE_0.6.md (§3 of the 0.5 doc);
16
+ * the wait kinds it relies on: docs/ADR/ADR-007-waiting-and-timeouts.md.
17
+ *
18
+ * Adding a resolver is one entry here plus a fixture that fails without it.
19
+ * The validation rules at the bottom are checked by tests/resolvers.test.mjs.
20
+ */
21
+
22
+ /** A change here that alters behaviour resets learned demotions (step profile). */
23
+ const REGISTRY_VERSION = 1;
24
+
25
+ /**
26
+ * Codes that are never auto-resolved: a person, a policy, or a unique target is
27
+ * needed. A resolver that claimed one of these would be guessing.
28
+ */
29
+ const FORBIDDEN_CODES = Object.freeze([
30
+ 'USER_CONFIRMATION_REQUIRED',
31
+ 'USER_ACTION_REQUIRED',
32
+ 'POLICY_BLOCKED',
33
+ 'TARGET_AMBIGUOUS',
34
+ ]);
35
+
36
+ /** Risks that must never be repeated in place (audit AU-08). */
37
+ const CONSEQUENTIAL_RISKS = new Set(['external_side_effect', 'destructive', 'financial']);
38
+
39
+ /** Tools a resolver may retry in place. A read-only tool is never a workflow step. */
40
+ const RETRYABLE_TOOLS = new Set([
41
+ 'browser_navigate', 'element_click', 'field_fill', 'select_option', 'wait_for_state', 'browser_press_key',
42
+ ]);
43
+
44
+ /**
45
+ * Failures that prove the action never ran: the target was missing, unusable or
46
+ * a stale ref. Only these may be retried for a click or a key press; anything
47
+ * else may already have submitted something (AU-08).
48
+ */
49
+ const ACTION_DID_NOT_RUN_CODES = new Set(['TARGET_NOT_FOUND', 'TARGET_NOT_INTERACTABLE', 'STALE_SNAPSHOT']);
50
+
51
+ /**
52
+ * Failures a resolver may act on: the page has not got there yet. Anything else
53
+ * — a bad argument, a Pro gate, a missing workflow, a cancelled run — is
54
+ * deterministic, and waiting for the page cannot change it (ADR-007: only
55
+ * failures that can clear up are waited on). A successful call that changed
56
+ * nothing has no code at all and is classified by its evidence.
57
+ */
58
+ const TRANSIENT_CODES = Object.freeze([
59
+ 'TIMEOUT', 'TARGET_NOT_FOUND', 'TARGET_NOT_INTERACTABLE', 'STALE_SNAPSHOT',
60
+ 'NAVIGATION_FAILED', 'BRIDGE_DISCONNECTED', 'POSTCONDITION_NOT_MET', 'SITE_ERROR',
61
+ ]);
62
+
63
+ /** The wait kinds the extension understands (registered in doWaitFor). */
64
+ const WAIT_KINDS = Object.freeze(['urlContains', 'text', 'target', 'targetGone', 'actionable', 'indicatorGone', 'networkQuiet', 'requestDone']);
65
+
66
+ /**
67
+ * Ordered: the more specific answer wins. The condition hierarchy follows the
68
+ * design — the step's own expectation, then actionability, then a specific
69
+ * request, then a UI signal, then global network quiet (as a fallback only).
70
+ */
71
+ const RESOLVERS = Object.freeze([
72
+ {
73
+ id: 'expectation_unmet',
74
+ safety: 'retry_reversible',
75
+ then: 'retry_step',
76
+ // About what the step produced, so it can never be a pre-wait: an
77
+ // expectation cannot be there before the step that produces it runs.
78
+ promotable: false,
79
+ budgetMs: { floor: 500, default: 5000, ceiling: 30000 },
80
+ matches: ({ evidence, step }) => Boolean(step.expect) && evidence.expectationMet === false,
81
+ wait: ({ step }) => waitForRecordedExpectation(step.expect),
82
+ },
83
+ {
84
+ id: 'click_no_effect',
85
+ safety: 'retry_reversible',
86
+ then: 'retry_step',
87
+ budgetMs: { floor: 500, default: 3000, ceiling: 10000 },
88
+ // The narrow AU-08 exception: reversible, nothing visible changed, and no
89
+ // request went out. A click that started a request is waited on instead.
90
+ matches: ({ evidence, step }) => step.tool === 'element_click'
91
+ && evidence.noObservableChange === true
92
+ && evidence.risk === 'reversible'
93
+ && Number(evidence.requestsStarted ?? 0) === 0,
94
+ wait: ({ step }) => ({ actionable: step.args.target, networkQuiet: true }),
95
+ },
96
+ {
97
+ id: 'target_not_ready',
98
+ safety: 'retry_reversible',
99
+ then: 'retry_step',
100
+ budgetMs: { floor: 500, default: 5000, ceiling: 20000 },
101
+ matches: ({ code }) => code === 'TARGET_NOT_INTERACTABLE',
102
+ wait: ({ step }) => ({ actionable: step.args.target }),
103
+ },
104
+ {
105
+ id: 'target_late',
106
+ safety: 'retry_reversible',
107
+ then: 'retry_step',
108
+ budgetMs: { floor: 500, default: 8000, ceiling: 30000 },
109
+ matches: ({ code, evidence }) => code === 'TARGET_NOT_FOUND'
110
+ && (Number(evidence.pendingRequests ?? 0) > 0 || Boolean(evidence.busyIndicator)),
111
+ wait: ({ step }) => ({ target: step.args.target }),
112
+ },
113
+ {
114
+ id: 'api_pending',
115
+ safety: 'retry_reversible',
116
+ then: 'retry_step',
117
+ budgetMs: { floor: 1000, default: 15000, ceiling: 45000 },
118
+ matches: ({ evidence }) => evidence.likelyCause === 'server_slow'
119
+ || Number(evidence.pendingRequests ?? 0) > 0
120
+ || Boolean(evidence.slowestRequest),
121
+ // The step's own recorded request is the strongest signal; global quiet is
122
+ // only the capped fallback.
123
+ wait: ({ step }) => (step.expect && step.expect.request
124
+ ? { requestDone: step.expect.request }
125
+ : { networkQuiet: true }),
126
+ },
127
+ {
128
+ id: 'still_rendering',
129
+ safety: 'retry_reversible',
130
+ then: 'retry_step',
131
+ budgetMs: { floor: 500, default: 10000, ceiling: 30000 },
132
+ matches: ({ evidence }) => Boolean(evidence.busyIndicator) || evidence.likelyCause === 'still_rendering',
133
+ wait: () => ({ indicatorGone: true }),
134
+ },
135
+ {
136
+ id: 'site_error',
137
+ safety: 'group',
138
+ then: 'retry_group',
139
+ budgetMs: { floor: 0, default: 0, ceiling: 0 },
140
+ matches: ({ code, evidence }) => code === 'SITE_ERROR' || evidence.likelyCause === 'site_error',
141
+ wait: () => null,
142
+ },
143
+ {
144
+ id: 'context_lost',
145
+ safety: 'group',
146
+ then: 'retry_group',
147
+ budgetMs: { floor: 0, default: 0, ceiling: 0 },
148
+ matches: ({ evidence }) => evidence.contextLost === true,
149
+ wait: () => null,
150
+ },
151
+ ]);
152
+
153
+ /** The wait args for a recorded expectation (`appears` and `request` can combine). */
154
+ function waitForRecordedExpectation(expect) {
155
+ if (!expect) return null;
156
+ const args = {};
157
+ if (expect.appears) args.target = expect.appears;
158
+ if (expect.request && expect.request.pathContains) args.requestDone = expect.request;
159
+ return Object.keys(args).length > 0 ? args : null;
160
+ }
161
+
162
+ /**
163
+ * How long the recorded-expectation wait may look for its evidence: the
164
+ * person's `maxWaitMs` when set, otherwise the settle the recorder measured
165
+ * (the learned pace for that step), otherwise the default expectation timeout.
166
+ * It is the budget of a bounded poll that ends the moment the evidence is
167
+ * there — never a pause (A6-10). Clamped to the shared limits.
168
+ */
169
+ function expectBudgetMs(expect, maxWaitMs = null) {
170
+ const asked = Number.isFinite(maxWaitMs) && maxWaitMs > 0 ? maxWaitMs : null;
171
+ const recorded = Number.isFinite(expect?.settleMs) && expect.settleMs > 0 ? expect.settleMs : null;
172
+ const ms = asked ?? recorded ?? LIMITS.DEFAULT_EXPECT_TIMEOUT_MS;
173
+ return Math.max(LIMITS.SETTLE_QUIET_MS, Math.min(ms, LIMITS.MAX_WAIT_MS));
174
+ }
175
+
176
+ /** The failure a classifier works from, normalised from either envelope shape. */
177
+ function normalise(env) {
178
+ return {
179
+ code: env?.error?.code ?? env?.code ?? null,
180
+ evidence: env?.evidence ?? {},
181
+ state: env?.state ?? (env?.ok === false ? 'failed' : 'completed'),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * What is this step blocked on? Returns `{ id, safety, then, wait, budgetMs }`,
187
+ * or null when nothing safe could clear it (a person, a policy, ambiguity, or
188
+ * an unknown failure). A resolver whose `wait` is null only selects the
189
+ * existing group-recovery path.
190
+ */
191
+ function classify(env, step, options = {}) {
192
+ const failure = normalise(env);
193
+ if (!step || !RETRYABLE_TOOLS.has(step.tool)) return null;
194
+ if (failure.code && FORBIDDEN_CODES.includes(failure.code)) return null;
195
+ if (failure.code && !TRANSIENT_CODES.includes(failure.code)) return null;
196
+ const demoted = Array.isArray(options.demoted) ? options.demoted : [];
197
+ for (const resolver of RESOLVERS) {
198
+ if (demoted.includes(resolver.id)) continue;
199
+ if (!resolver.matches({ ...failure, step })) continue;
200
+ const wait = resolver.wait({ step, evidence: failure.evidence });
201
+ if (resolver.then === 'retry_step' && !wait) return null;
202
+ return {
203
+ id: resolver.id,
204
+ safety: resolver.safety,
205
+ then: resolver.then,
206
+ wait,
207
+ budgetMs: resolveBudget(resolver, options),
208
+ };
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /** One resolver by id, for a step profile that learned to pre-wait. */
214
+ function resolverById(id) {
215
+ return RESOLVERS.find((resolver) => resolver.id === id && resolver.then === 'retry_step') ?? null;
216
+ }
217
+
218
+ /** The wait args for a resolver id applied to this step (promotion from the step profile). */
219
+ function waitArgsFor(id, step) {
220
+ const resolver = resolverById(id);
221
+ return resolver ? resolver.wait({ step, evidence: {} }) : null;
222
+ }
223
+
224
+ /**
225
+ * May a step profile wait for this blocker *before* acting? Only a blocker about
226
+ * what the step is waiting on — never one about what it produces.
227
+ */
228
+ function promotable(id) {
229
+ const resolver = resolverById(id);
230
+ return Boolean(resolver) && resolver.promotable !== false;
231
+ }
232
+
233
+ /**
234
+ * How long to wait for this resolver, in this order: the resolver's own default,
235
+ * the step's learned p95 × 1.5 (once it has ≥ 3 runs), the person's patience,
236
+ * an explicit cap, then the resolver's floor/ceiling.
237
+ */
238
+ function resolveBudget(resolver, { learnedMs = null, patience = 'normal', maxWaitMs = null } = {}) {
239
+ const { floor, default: fallback, ceiling } = resolver.budgetMs;
240
+ let ms = fallback;
241
+ if (Number.isFinite(learnedMs) && learnedMs > 0) ms = Math.round(learnedMs * 1.5);
242
+ const factor = patience === 'fast' ? 0.5 : patience === 'patient' ? 3 : 1;
243
+ ms = Math.round(ms * factor);
244
+ if (Number.isFinite(maxWaitMs) && maxWaitMs > 0) ms = Math.min(ms, maxWaitMs);
245
+ return Math.max(floor, Math.min(ms, ceiling));
246
+ }
247
+
248
+ /**
249
+ * May this step be retried in place under AU-08?
250
+ *
251
+ * - the resolver must actually retry the step;
252
+ * - its tool must be one a workflow may repeat;
253
+ * - a consequential step is never repeated, whatever the failure code;
254
+ * - a click or key press is repeated only when the action provably did not run,
255
+ * or under the click_no_effect resolver's own narrow rule;
256
+ * - everything else (fills, selects, navigations, waits) is safe to repeat.
257
+ */
258
+ function mayRetryInPlace(resolver, env, step) {
259
+ if (!resolver || resolver.then !== 'retry_step') return false;
260
+ if (!step || !RETRYABLE_TOOLS.has(step.tool)) return false;
261
+ const { code, evidence } = normalise(env);
262
+ if (CONSEQUENTIAL_RISKS.has(evidence.risk)) return false;
263
+ if (resolver.id === 'click_no_effect') return true;
264
+ if (step.tool === 'element_click' || step.tool === 'browser_press_key') {
265
+ return ACTION_DID_NOT_RUN_CODES.has(code);
266
+ }
267
+ return true;
268
+ }
269
+
270
+ module.exports = {
271
+ REGISTRY_VERSION,
272
+ FORBIDDEN_CODES,
273
+ CONSEQUENTIAL_RISKS,
274
+ RETRYABLE_TOOLS,
275
+ ACTION_DID_NOT_RUN_CODES,
276
+ TRANSIENT_CODES,
277
+ WAIT_KINDS,
278
+ RESOLVERS,
279
+ classify,
280
+ resolveBudget,
281
+ mayRetryInPlace,
282
+ resolverById,
283
+ promotable,
284
+ waitArgsFor,
285
+ waitForRecordedExpectation,
286
+ expectBudgetMs,
287
+ };
@@ -73,7 +73,8 @@ const MAX_EXTENSION_MESSAGES_PER_SECOND = 500;
73
73
  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
- 'new_tab', 'list_tabs', 'switch_tab', 'close_tab', 'notify',
76
+ 'new_tab', 'list_tabs', 'switch_tab', 'close_tab', 'notify', 'upload',
77
+ 'console', 'network',
77
78
  ]);
78
79
 
79
80
  const VALID_MESSAGE_TYPES = new Set([
@@ -25,14 +25,23 @@ 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, HOST_NAME, extensionOrigin, extensionIdFromKey } = require('./extension-id');
28
+ const { EXTENSION_ID, STORE_EXTENSION_ID, HOST_NAME, allowedOrigins, extensionIdFromKey } = require('./extension-id');
29
29
 
30
30
  const HOST_DIR_NAME = 'native-host';
31
31
  const NATIVE_HOST_SCRIPT = path.join(__dirname, 'native-host.js');
32
32
  const MCP_ENTRY = path.join(__dirname, 'index.js');
33
+ /** The Chrome Web Store listing (STORE_EXTENSION_ID); it opens once the listing is published. */
34
+ const STORE_URL = `https://chromewebstore.google.com/detail/${STORE_EXTENSION_ID}`;
33
35
 
34
36
  /** HKCU only: no administrator rights, and the key is per-user by design. */
35
37
  const REGISTRY_KEY = `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_NAME}`;
38
+ /**
39
+ * Microsoft Edge reads its own key (ROADMAP 0.5.3). Edge installs Chrome Web Store
40
+ * extensions and loads the same unpacked folder, with the same extension id, so the
41
+ * one host manifest serves both browsers.
42
+ */
43
+ const EDGE_REGISTRY_KEY = `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${HOST_NAME}`;
44
+ const REGISTRY_KEYS = [REGISTRY_KEY, EDGE_REGISTRY_KEY];
36
45
 
37
46
  function hostDir(dir = configDir()) {
38
47
  return path.join(dir, HOST_DIR_NAME);
@@ -60,9 +69,16 @@ function hostManifestPath(dir = configDir()) {
60
69
  function manifestTargets({ platform = process.platform, home = os.homedir(), dir = configDir() } = {}) {
61
70
  if (platform === 'win32') return [hostManifestPath(dir)];
62
71
  if (platform === 'darwin') {
63
- return [path.join(home, 'Library', 'Application Support', 'Google', 'Chrome', 'NativeMessagingHosts', `${HOST_NAME}.json`)];
72
+ const support = path.join(home, 'Library', 'Application Support');
73
+ return [
74
+ path.join(support, 'Google', 'Chrome', 'NativeMessagingHosts', `${HOST_NAME}.json`),
75
+ path.join(support, 'Microsoft Edge', 'NativeMessagingHosts', `${HOST_NAME}.json`),
76
+ ];
64
77
  }
65
- return [path.join(home, '.config', 'google-chrome', 'NativeMessagingHosts', `${HOST_NAME}.json`)];
78
+ return [
79
+ path.join(home, '.config', 'google-chrome', 'NativeMessagingHosts', `${HOST_NAME}.json`),
80
+ path.join(home, '.config', 'microsoft-edge', 'NativeMessagingHosts', `${HOST_NAME}.json`),
81
+ ];
66
82
  }
67
83
 
68
84
  function launcherContents({ platform = process.platform, nodePath = process.execPath, hostScript = NATIVE_HOST_SCRIPT } = {}) {
@@ -81,7 +97,7 @@ function hostManifest({ dir = configDir(), platform = process.platform, nodePath
81
97
  description: 'BrowserZap bridge helper: starts the local bridge and hands this extension its pairing token.',
82
98
  path: launcherPath(dir, platform),
83
99
  type: 'stdio',
84
- allowed_origins: [extensionOrigin()],
100
+ allowed_origins: allowedOrigins(),
85
101
  };
86
102
  }
87
103
 
@@ -113,9 +129,49 @@ function defaultRegistryRunner(argv) {
113
129
  };
114
130
  }
115
131
 
116
- /** Read the default (unnamed) value of the host's HKCU key, or null. */
117
- function readRegisteredManifest({ run = defaultRegistryRunner } = {}) {
118
- const result = run(['query', REGISTRY_KEY, '/ve']);
132
+ /**
133
+ * The Node binary and host script a launcher runs: the first two quoted paths
134
+ * in it (`"node" "native-host.js" %*` on Windows, `exec "node" "…" "$@"` elsewhere).
135
+ */
136
+ function launcherTargets(text) {
137
+ const quoted = [...String(text).matchAll(/"([^"]+)"/g)].map((m) => m[1]).filter((p) => p !== '$@');
138
+ return { node: quoted[0] ?? null, script: quoted[1] ?? null };
139
+ }
140
+
141
+ const POLICY_ROOTS = ['HKLM\\SOFTWARE\\Policies\\Google\\Chrome', 'HKCU\\SOFTWARE\\Policies\\Google\\Chrome'];
142
+
143
+ /** Values of one Chrome list policy (`reg query <root>\\<name>` prints ` 1 REG_SZ value`), across HKLM and HKCU. */
144
+ function policyList(name, run) {
145
+ const values = [];
146
+ for (const root of POLICY_ROOTS) {
147
+ const result = run(['query', `${root}\\${name}`]);
148
+ if (result.status !== 0) continue;
149
+ for (const match of String(result.stdout ?? '').matchAll(/^\s+\S+\s+REG_SZ\s+(.+?)\s*$/gm)) values.push(match[1]);
150
+ }
151
+ return values;
152
+ }
153
+
154
+ /**
155
+ * Chrome enterprise policy that stops BrowserZap, in words, or null. Checks
156
+ * the native-messaging block/allow lists and the extension install lists.
157
+ */
158
+ function blockingPolicy(run, { hostName = HOST_NAME, extensionId = EXTENSION_ID } = {}) {
159
+ const nmBlock = policyList('NativeMessagingBlocklist', run);
160
+ const nmAllow = policyList('NativeMessagingAllowlist', run);
161
+ if (nmBlock.includes(hostName) || (nmBlock.includes('*') && !nmAllow.includes(hostName))) {
162
+ return `Chrome policy NativeMessagingBlocklist blocks ${nmBlock.includes(hostName) ? hostName : 'all native helpers'}`;
163
+ }
164
+ const extBlock = policyList('ExtensionInstallBlocklist', run);
165
+ const extAllow = policyList('ExtensionInstallAllowlist', run);
166
+ if (extBlock.includes(extensionId) || (extBlock.includes('*') && !extAllow.includes(extensionId))) {
167
+ return `Chrome policy ExtensionInstallBlocklist blocks ${extBlock.includes(extensionId) ? 'the BrowserZap extension' : 'extensions that are not allow-listed'}`;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /** Read the default (unnamed) value of the host's HKCU key (Chrome's unless `key` is given), or null. */
173
+ function readRegisteredManifest({ run = defaultRegistryRunner, key = REGISTRY_KEY } = {}) {
174
+ const result = run(['query', key, '/ve']);
119
175
  if (result.status !== 0) return null;
120
176
  // REG_SZ C:\path\to\manifest.json
121
177
  const match = /REG_SZ\s+(.+?)\s*$/m.exec(result.stdout ?? '');
@@ -139,10 +195,10 @@ function registerNativeHost({
139
195
  const written = writeNativeHost({ dir, platform, nodePath, home });
140
196
  const registry = [];
141
197
  if (platform === 'win32' && register) {
142
- registry.push({
143
- command: ['add', REGISTRY_KEY, '/ve', '/t', 'REG_SZ', '/d', written.manifestPaths[0], '/f'],
144
- result: run(['add', REGISTRY_KEY, '/ve', '/t', 'REG_SZ', '/d', written.manifestPaths[0], '/f']),
145
- });
198
+ for (const key of REGISTRY_KEYS) {
199
+ const command = ['add', key, '/ve', '/t', 'REG_SZ', '/d', written.manifestPaths[0], '/f'];
200
+ registry.push({ command, result: run(command) });
201
+ }
146
202
  }
147
203
  return {
148
204
  ...written,
@@ -180,10 +236,8 @@ function unregisterNativeHost({
180
236
 
181
237
  const registry = [];
182
238
  if (platform === 'win32' && register) {
183
- registry.push({
184
- command: ['delete', REGISTRY_KEY, '/f'],
185
- result: run(['delete', REGISTRY_KEY, '/f']),
186
- });
239
+ // An install from before Edge support has no Edge key: that delete fails harmlessly.
240
+ for (const key of REGISTRY_KEYS) registry.push({ command: ['delete', key, '/f'], result: run(['delete', key, '/f']) });
187
241
  }
188
242
  for (const file of manifests) fs.rmSync(file, { force: true });
189
243
  fs.rmSync(launcherPath(dir, platform), { force: true });
@@ -320,6 +374,7 @@ async function doctor({
320
374
  register = true,
321
375
  port = DEFAULT_PORT,
322
376
  extensionManifest = extensionManifestPath(),
377
+ out = write,
323
378
  } = {}) {
324
379
  const results = [];
325
380
  const record = (name, ok, detail, nextAction) => results.push({ name, ok, detail, nextAction });
@@ -340,22 +395,46 @@ async function doctor({
340
395
  );
341
396
 
342
397
  const allowed = Array.isArray(manifest?.allowed_origins) ? manifest.allowed_origins : [];
398
+ const expected = allowedOrigins();
343
399
  record(
344
- 'Host allows this extension id',
345
- allowed.includes(extensionOrigin()),
400
+ expected.length > 1 ? 'Host allows the unpacked and store extension ids' : 'Host allows this extension id',
401
+ expected.every((origin) => allowed.includes(origin)),
346
402
  allowed.length ? allowed.join(', ') : 'no allowed_origins',
347
- `Run: browserzap setup (expected ${extensionOrigin()})`,
403
+ `Run: browserzap setup (expected ${expected.join(', ')})`,
348
404
  );
349
405
 
350
406
  const launcher = manifest?.path;
407
+ const launcherOk = typeof launcher === 'string' && fs.existsSync(launcher);
351
408
  record(
352
409
  'Host launcher exists',
353
- typeof launcher === 'string' && fs.existsSync(launcher),
410
+ launcherOk,
354
411
  launcher ?? 'the manifest has no path',
355
412
  'Run: browserzap setup',
356
413
  );
414
+ if (launcherOk) {
415
+ // The launcher names Node and the host script by full path. A Node version
416
+ // switch (fnm, Volta, nvm) or a reinstall elsewhere leaves it pointing at
417
+ // nothing, and the side panel stays grey.
418
+ let text = '';
419
+ try { text = fs.readFileSync(launcher, 'utf8'); } catch { /* unreadable: reported below */ }
420
+ const targets = launcherTargets(text);
421
+ const missing = [targets.node, targets.script].filter((p) => !p || !fs.existsSync(p));
422
+ record(
423
+ 'Launcher finds Node and the helper',
424
+ missing.length === 0,
425
+ missing.length === 0 ? targets.node : `missing: ${missing.map((p) => p ?? '(no path)').join(', ')}`,
426
+ 'Node or BrowserZap moved (a Node version switch with fnm, Volta or nvm, or a reinstall). Run: browserzap setup',
427
+ );
428
+ }
357
429
 
358
430
  if (platform === 'win32' && register) {
431
+ const policy = blockingPolicy(run);
432
+ record(
433
+ 'Not blocked by Chrome policy',
434
+ policy === null,
435
+ policy ?? 'no blocking policy found',
436
+ 'This computer is managed. Ask IT to allow the native helper com.browserzap.bridge and the BrowserZap extension.',
437
+ );
359
438
  const registered = readRegisteredManifest({ run });
360
439
  record(
361
440
  'Registered with Chrome (HKCU)',
@@ -363,6 +442,13 @@ async function doctor({
363
442
  registered ?? `nothing at ${REGISTRY_KEY}`,
364
443
  'Run: browserzap setup',
365
444
  );
445
+ const edge = readRegisteredManifest({ run, key: EDGE_REGISTRY_KEY });
446
+ record(
447
+ 'Registered with Edge (HKCU)',
448
+ edge !== null,
449
+ edge ?? `nothing at ${EDGE_REGISTRY_KEY}`,
450
+ 'Run: browserzap setup (adds Microsoft Edge next to Chrome)',
451
+ );
366
452
  if (registered && path.resolve(registered) !== path.resolve(manifestFile)) {
367
453
  record(
368
454
  'Registry points at this manifest',
@@ -393,28 +479,83 @@ async function doctor({
393
479
  probe = await helloProbe({ port, token: secret, session: 'doctor', name: 'doctor', timeoutMs: 3000 });
394
480
  if (probe.ok) probe.ws.close();
395
481
  }
482
+ // 4003: something on the port speaks our protocol but holds a different
483
+ // secret: a bridge from another install, another BROWSERZAP_HOME, or a
484
+ // leftover test. The extension cannot pair until it is replaced.
485
+ const foreign = listening && !probe.ok && probe.code === 4003;
396
486
  record(
397
487
  'Bridge reachable',
398
488
  listening && probe.ok,
399
489
  listening
400
- ? (probe.ok ? `authenticated handshake accepted on 127.0.0.1:${port}` : `listening, but ${probe.reason ?? 'the handshake failed'}`)
490
+ ? (probe.ok
491
+ ? `authenticated handshake accepted on 127.0.0.1:${port}`
492
+ : foreign
493
+ ? `a bridge with a different pairing secret holds 127.0.0.1:${port}`
494
+ : `listening, but ${probe.reason ?? 'the handshake failed'}`)
401
495
  : `nothing listening on 127.0.0.1:${port} (the native host starts it on demand)`,
402
- 'Open the BrowserZap side panel; the background service starts the bridge through the native host.',
496
+ foreign
497
+ ? 'Replace it: browserzap restart-bridge (stops the BrowserZap bridge on that port; the side panel starts the right one).'
498
+ : 'Open the BrowserZap side panel; the background service starts the bridge through the native host.',
403
499
  );
404
500
 
405
501
  let width = 0;
406
502
  for (const r of results) width = Math.max(width, r.name.length);
407
- write('BrowserZap doctor');
503
+ out('BrowserZap doctor');
408
504
  for (const r of results) {
409
- write(` ${r.ok ? 'PASS' : 'FAIL'} ${r.name.padEnd(width)} ${r.detail}`);
410
- if (!r.ok && r.nextAction) write(` ${' '.repeat(width)} → ${r.nextAction}`);
505
+ out(` ${r.ok ? 'PASS' : 'FAIL'} ${r.name.padEnd(width)} ${r.detail}`);
506
+ if (!r.ok && r.nextAction) out(` ${' '.repeat(width)} → ${r.nextAction}`);
507
+ }
508
+ const asks = require('./downloads').asksWhereToSave({ dataDir: require('./chrome-launcher').userDataDir({ platform, home }) });
509
+ if (asks) {
510
+ out(' NOTE Chrome asks where to save each download: a download an agent starts opens a "Save as" dialog it cannot answer.');
511
+ out(' → Turn off "Ask where to save each file before downloading" in chrome://settings/downloads');
411
512
  }
412
513
  const failed = results.filter((r) => !r.ok);
413
- write('');
414
- write(failed.length === 0 ? 'All checks passed.' : `${failed.length} check(s) failed.`);
514
+ out('');
515
+ out(failed.length === 0 ? 'All checks passed.' : `${failed.length} check(s) failed.`);
415
516
  return failed.length === 0 ? 0 : 1;
416
517
  }
417
518
 
519
+ /**
520
+ * WSL (ROADMAP 0.5.3, docs/WSL.md): an agent inside WSL starts BrowserZap's
521
+ * Windows MCP server through interop (`cmd.exe /c …`), so the bridge stays on
522
+ * Windows loopback and nothing is exposed to the WSL network. True when this
523
+ * Windows machine has WSL.
524
+ */
525
+ function hasWsl({ platform = process.platform, env = process.env, exists = fs.existsSync } = {}) {
526
+ if (platform !== 'win32') return false;
527
+ return exists(path.join(env.SystemRoot || env.windir || 'C:\\Windows', 'System32', 'wsl.exe'));
528
+ }
529
+
530
+ /** True inside WSL (a Linux kernel built by Microsoft), where BrowserZap must run on the Windows side. */
531
+ function insideWsl({ platform = process.platform, env = process.env, readFile = (f) => fs.readFileSync(f, 'utf8') } = {}) {
532
+ if (platform !== 'linux') return false;
533
+ if (env.WSL_DISTRO_NAME) return true;
534
+ try {
535
+ // WSL2: "…-microsoft-standard-WSL2"; WSL1: "…-Microsoft". Not an Azure VM's "-azure" kernel.
536
+ return /microsoft-standard|wsl|-microsoft\b/i.test(readFile('/proc/version'));
537
+ } catch {
538
+ return false;
539
+ }
540
+ }
541
+
542
+ /** C:\Program Files\nodejs\node.exe → /mnt/c/Program Files/nodejs/node.exe (WSL's default mount). */
543
+ function wslPath(winPath) {
544
+ const m = /^([A-Za-z]):[\\/](.*)$/.exec(winPath);
545
+ return m ? `/mnt/${m[1].toLowerCase()}/${m[2].replace(/\\/g, '/')}` : winPath;
546
+ }
547
+
548
+ /**
549
+ * The command an agent inside WSL registers: the Windows MCP server, started
550
+ * through interop. A global install is on the Windows PATH (`cmd.exe /c browserzap`);
551
+ * otherwise Windows node.exe is run by its /mnt path, which needs no cmd quoting.
552
+ */
553
+ function wslCommand({ nodePath = process.execPath, serverPath = MCP_ENTRY, globalInstall = !fromNpxCache() && !fs.existsSync(path.join(__dirname, '..', 'src')) } = {}) {
554
+ return globalInstall
555
+ ? 'claude mcp add browserzap -- cmd.exe /c browserzap'
556
+ : `claude mcp add browserzap -- '${wslPath(nodePath)}' '${serverPath}'`;
557
+ }
558
+
418
559
  /**
419
560
  * `browserzap setup`. `args` is everything after the command word.
420
561
  * Returns the process exit code.
@@ -476,8 +617,17 @@ async function setupCommand(args = [], { extensionPath = null } = {}) {
476
617
  write('');
477
618
  }
478
619
 
620
+ if (hasWsl()) {
621
+ write('Using Claude Code (or another agent) inside WSL? Run this in WSL; BrowserZap still runs on Windows:');
622
+ write(` ${wslCommand()}`);
623
+ write(' (other clients: command "cmd.exe", args ["/c", "browserzap"]; see docs/WSL.md)');
624
+ write('');
625
+ }
626
+
479
627
  write('Next:');
480
- write(` 1. chrome://extensions → Developer mode → Load unpacked → ${extensionPath ?? 'the extension/ folder of this package'}`);
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'})`);
481
631
  write(' 2. Open the BrowserZap side panel. It connects automatically; no token to paste.');
482
632
  write(' 3. Open your MCP client and start work. `browserzap doctor` re-checks this setup.');
483
633
  return result.ok || !result.registryKey ? 0 : 1;
@@ -500,10 +650,17 @@ function confirm(question) {
500
650
  }
501
651
 
502
652
  module.exports = {
653
+ launcherTargets,
654
+ blockingPolicy,
503
655
  fromNpxCache,
504
656
  REGISTRY_KEY,
657
+ EDGE_REGISTRY_KEY,
505
658
  HOST_NAME,
506
659
  MCP_ENTRY,
660
+ hasWsl,
661
+ insideWsl,
662
+ wslCommand,
663
+ wslPath,
507
664
  hostDir,
508
665
  hostManifestPath,
509
666
  launcherPath,