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.
- package/CHANGELOG.md +51 -0
- package/README.md +53 -30
- package/bridge-server/background-runs.js +91 -4
- package/bridge-server/chrome-launcher.js +42 -1
- package/bridge-server/cli-run.js +2 -0
- package/bridge-server/downloads.js +173 -0
- package/bridge-server/executor.js +1 -1
- package/bridge-server/extension-id.js +25 -1
- package/bridge-server/feedback.js +255 -0
- package/bridge-server/index.js +29 -2
- package/bridge-server/limits.js +27 -1
- package/bridge-server/mcp-server.js +219 -18
- package/bridge-server/native-host.js +5 -3
- package/bridge-server/panel.js +21 -3
- package/bridge-server/probe.js +2 -1
- package/bridge-server/resolvers.js +287 -0
- package/bridge-server/server.js +2 -1
- package/bridge-server/setup.js +184 -27
- package/bridge-server/tools.js +153 -4
- package/bridge-server/workflows.js +298 -11
- package/extension/background.js +1 -1
- package/extension/engine.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/sidepanel.css +1 -1
- package/extension/sidepanel.html +23 -2
- package/extension/sidepanel.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,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
|
+
};
|
package/bridge-server/server.js
CHANGED
|
@@ -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([
|
package/bridge-server/setup.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 [
|
|
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:
|
|
100
|
+
allowed_origins: allowedOrigins(),
|
|
85
101
|
};
|
|
86
102
|
}
|
|
87
103
|
|
|
@@ -113,9 +129,49 @@ function defaultRegistryRunner(argv) {
|
|
|
113
129
|
};
|
|
114
130
|
}
|
|
115
131
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
143
|
-
command
|
|
144
|
-
result: run(
|
|
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
|
-
|
|
184
|
-
|
|
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(
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
503
|
+
out('BrowserZap doctor');
|
|
408
504
|
for (const r of results) {
|
|
409
|
-
|
|
410
|
-
if (!r.ok && 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
|
-
|
|
414
|
-
|
|
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.
|
|
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,
|