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.
- package/CHANGELOG.md +71 -0
- package/README.md +31 -19
- package/THIRD_PARTY_NOTICES.md +3 -3
- package/bridge-server/extension-id.js +64 -1
- package/bridge-server/extension-version.js +65 -0
- package/bridge-server/feedback.js +12 -5
- package/bridge-server/index.js +36 -24
- package/bridge-server/limits.js +1 -1
- package/bridge-server/mcp-server.js +79 -4
- package/bridge-server/native-host.js +22 -3
- package/bridge-server/probe.js +56 -1
- package/bridge-server/server.js +11 -4
- package/bridge-server/setup.js +84 -25
- package/bridge-server/tools.js +28 -1
- package/package.json +4 -5
- package/extension/background.js +0 -1
- package/extension/engine.js +0 -1
- package/extension/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon32.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/logo.svg +0 -7
- package/extension/manifest.json +0 -47
- package/extension/sidepanel.css +0 -2
- package/extension/sidepanel.html +0 -138
- package/extension/sidepanel.js +0 -1
|
@@ -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
|
|
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
|
-
`${
|
|
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}
|
|
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 (
|
|
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
|
};
|
package/bridge-server/probe.js
CHANGED
|
@@ -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 };
|
package/bridge-server/server.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
package/bridge-server/setup.js
CHANGED
|
@@ -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 {
|
|
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)
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
420
|
+
'Allowed extensions',
|
|
401
421
|
expected.every((origin) => allowed.includes(origin)),
|
|
402
|
-
allowed.length ? allowed.join(', ') : 'no allowed_origins'
|
|
403
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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.
|
|
629
|
-
write(' (Chrome
|
|
630
|
-
|
|
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
|
};
|
package/bridge-server/tools.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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"
|