browserzap 0.4.0 → 0.5.5
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 +25 -0
- package/README.md +53 -30
- package/bridge-server/chrome-launcher.js +42 -1
- package/bridge-server/downloads.js +173 -0
- package/bridge-server/executor.js +148 -148
- 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 +10 -0
- package/bridge-server/mcp-server.js +215 -12
- package/bridge-server/native-host.js +5 -3
- package/bridge-server/panel.js +7 -0
- package/bridge-server/probe.js +124 -114
- package/bridge-server/screenshots.js +130 -130
- package/bridge-server/secret.js +127 -127
- package/bridge-server/server.js +45 -7
- package/bridge-server/session-log.js +188 -188
- package/bridge-server/setup.js +184 -27
- package/bridge-server/tools.js +125 -1
- 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
|
@@ -1,148 +1,148 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BrowserZap — one way to run a browser tool and describe its result.
|
|
3
|
-
*
|
|
4
|
-
* Shared by the MCP server (agent calls, agent-started workflow runs) and the
|
|
5
|
-
* bridge's panel runner (workflows started from the side panel with no agent).
|
|
6
|
-
* Both send the same action to the extension and get the same envelope back,
|
|
7
|
-
* so a step behaves identically whoever started it: approvals, sensitive
|
|
8
|
-
* fields, ambiguity and origin checks all live in the extension.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
'use strict';
|
|
12
|
-
|
|
13
|
-
const { TOOL_TO_ACTION, PAGE_TOOLS } = require('./tools');
|
|
14
|
-
const { callDeadlineMs } = require('./limits');
|
|
15
|
-
|
|
16
|
-
const ENVELOPE_KEYS = new Set(['state', 'summary', 'evidence', 'nextAction']);
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* The standard response envelope (docs/TOOL_CONTRACT.md §2):
|
|
20
|
-
* { ok, traceId, state, summary, evidence, nextAction, error, ...payload }
|
|
21
|
-
*/
|
|
22
|
-
function envelope(traceId, result) {
|
|
23
|
-
const data = result.data || {};
|
|
24
|
-
const ok = result.success === true;
|
|
25
|
-
const out = {
|
|
26
|
-
ok,
|
|
27
|
-
traceId,
|
|
28
|
-
state: data.state || (ok ? 'completed' : 'failed'),
|
|
29
|
-
summary: data.summary || (ok ? 'Done.' : result.error || 'Failed.'),
|
|
30
|
-
evidence: data.evidence || {},
|
|
31
|
-
nextAction: data.nextAction ?? null,
|
|
32
|
-
error: ok ? null : { code: result.code || 'INTERNAL', message: result.error || 'Unknown error' },
|
|
33
|
-
};
|
|
34
|
-
for (const [key, value] of Object.entries(data)) {
|
|
35
|
-
if (!ENVELOPE_KEYS.has(key)) out[key] = value;
|
|
36
|
-
}
|
|
37
|
-
if (result.pageState) out.page = { url: result.pageState.url, title: result.pageState.title };
|
|
38
|
-
return out;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function errorEnvelope(traceId, code, message, nextAction = null, state = 'failed') {
|
|
42
|
-
return { ok: false, traceId, state, summary: message, evidence: {}, nextAction, error: { code, message } };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Run one browser tool through the extension.
|
|
47
|
-
*
|
|
48
|
-
* startAction(action, params, deadlineMs) → { id, promise, cancel }
|
|
49
|
-
*
|
|
50
|
-
* The deadline covers every wait the call itself may do (limits.js callDeadlineMs).
|
|
51
|
-
* `allowedOrigins` narrows (never widens) what the page actions may touch.
|
|
52
|
-
* Never throws: a lost bridge, a timeout or a cancel come back as envelopes.
|
|
53
|
-
*/
|
|
54
|
-
async function executeStep(startAction, toolName, args, { allowedOrigins = null, onStart } = {}) {
|
|
55
|
-
const action = TOOL_TO_ACTION[toolName];
|
|
56
|
-
const params = { ...args };
|
|
57
|
-
if (allowedOrigins && PAGE_TOOLS.has(toolName)) params.allowedOrigins = allowedOrigins;
|
|
58
|
-
|
|
59
|
-
const call = startAction(action, params, callDeadlineMs(toolName, args));
|
|
60
|
-
onStart?.(call);
|
|
61
|
-
try {
|
|
62
|
-
return envelope(call.id, await call.promise);
|
|
63
|
-
} catch (err) {
|
|
64
|
-
const code = err.code || 'INTERNAL';
|
|
65
|
-
const next = code === 'BRIDGE_DISCONNECTED'
|
|
66
|
-
? 'Check that the BrowserZap extension is loaded and paired ("
|
|
67
|
-
: code === 'TIMEOUT'
|
|
68
|
-
? 'Check the page with page_inspect before repeating anything.'
|
|
69
|
-
: null;
|
|
70
|
-
return errorEnvelope(call.id, code, err.message, next);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Send actions to the extension and match their results, for one session.
|
|
76
|
-
* Used by the MCP server (an agent) and the panel runner (the side panel), so
|
|
77
|
-
* both time out, cancel and fail on a lost connection the same way.
|
|
78
|
-
*
|
|
79
|
-
* send(msg) → false when the message could not be sent
|
|
80
|
-
* ready() → resolves when sending is possible (default: at once)
|
|
81
|
-
* cancelMessage → the error text for a caller-side cancel
|
|
82
|
-
*
|
|
83
|
-
* start(action, params, deadlineMs) returns { id, promise, cancel }: the
|
|
84
|
-
* promise resolves with the extension's result message, or rejects with an
|
|
85
|
-
* error whose `code` is TIMEOUT, CANCELLED or BRIDGE_DISCONNECTED.
|
|
86
|
-
*/
|
|
87
|
-
function createActionClient({ session, send, ready = () => Promise.resolve(), deadlineMs, cancelMessage = 'Cancelled.' }) {
|
|
88
|
-
const pending = new Map();
|
|
89
|
-
let nextId = 0;
|
|
90
|
-
|
|
91
|
-
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
92
|
-
|
|
93
|
-
function start(action, params, callDeadlineMs = deadlineMs) {
|
|
94
|
-
const id = `${session}-${++nextId}`;
|
|
95
|
-
// A cancel can arrive before the call is sent (while waiting for the bridge
|
|
96
|
-
// session); it must stop the call from being sent at all (audit AU-14).
|
|
97
|
-
let cancelledEarly = false;
|
|
98
|
-
let cancel = () => { cancelledEarly = true; };
|
|
99
|
-
const promise = ready().then(() => new Promise((resolve, reject) => {
|
|
100
|
-
if (cancelledEarly) {
|
|
101
|
-
reject(failure('CANCELLED', cancelMessage));
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
const timer = setTimeout(() => {
|
|
105
|
-
if (!pending.delete(id)) return;
|
|
106
|
-
send({ type: 'cancel', id, session });
|
|
107
|
-
reject(failure('TIMEOUT', `No result from the browser within ${Math.round(callDeadlineMs / 1000)} s.`));
|
|
108
|
-
}, callDeadlineMs);
|
|
109
|
-
pending.set(id, {
|
|
110
|
-
resolve: (msg) => { clearTimeout(timer); resolve(msg); },
|
|
111
|
-
reject: (err) => { clearTimeout(timer); reject(err); },
|
|
112
|
-
});
|
|
113
|
-
cancel = () => {
|
|
114
|
-
if (!pending.delete(id)) return;
|
|
115
|
-
clearTimeout(timer);
|
|
116
|
-
send({ type: 'cancel', id, session });
|
|
117
|
-
reject(failure('CANCELLED', cancelMessage));
|
|
118
|
-
};
|
|
119
|
-
if (!send({ type: 'action', action, id, session, params })) {
|
|
120
|
-
pending.delete(id);
|
|
121
|
-
clearTimeout(timer);
|
|
122
|
-
reject(failure('BRIDGE_DISCONNECTED', 'The BrowserZap extension is not connected to the bridge.'));
|
|
123
|
-
}
|
|
124
|
-
}));
|
|
125
|
-
return { id, promise, cancel: () => cancel() };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Hand a result message to its waiting call. False when no call waits for it. */
|
|
129
|
-
function deliver(msg) {
|
|
130
|
-
const entry = msg?.type === 'result' ? pending.get(msg.id) : undefined;
|
|
131
|
-
if (!entry) return false;
|
|
132
|
-
pending.delete(msg.id);
|
|
133
|
-
entry.resolve(msg);
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Fail every waiting call (the connection is gone). */
|
|
138
|
-
function failAll(code, message, detail) {
|
|
139
|
-
for (const [id, { reject }] of pending) {
|
|
140
|
-
pending.delete(id);
|
|
141
|
-
reject(Object.assign(failure(code, message), detail ? { detail } : {}));
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return { start, deliver, failAll };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
module.exports = { envelope, errorEnvelope, executeStep, createActionClient };
|
|
1
|
+
/**
|
|
2
|
+
* BrowserZap — one way to run a browser tool and describe its result.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the MCP server (agent calls, agent-started workflow runs) and the
|
|
5
|
+
* bridge's panel runner (workflows started from the side panel with no agent).
|
|
6
|
+
* Both send the same action to the extension and get the same envelope back,
|
|
7
|
+
* so a step behaves identically whoever started it: approvals, sensitive
|
|
8
|
+
* fields, ambiguity and origin checks all live in the extension.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { TOOL_TO_ACTION, PAGE_TOOLS } = require('./tools');
|
|
14
|
+
const { callDeadlineMs } = require('./limits');
|
|
15
|
+
|
|
16
|
+
const ENVELOPE_KEYS = new Set(['state', 'summary', 'evidence', 'nextAction']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The standard response envelope (docs/TOOL_CONTRACT.md §2):
|
|
20
|
+
* { ok, traceId, state, summary, evidence, nextAction, error, ...payload }
|
|
21
|
+
*/
|
|
22
|
+
function envelope(traceId, result) {
|
|
23
|
+
const data = result.data || {};
|
|
24
|
+
const ok = result.success === true;
|
|
25
|
+
const out = {
|
|
26
|
+
ok,
|
|
27
|
+
traceId,
|
|
28
|
+
state: data.state || (ok ? 'completed' : 'failed'),
|
|
29
|
+
summary: data.summary || (ok ? 'Done.' : result.error || 'Failed.'),
|
|
30
|
+
evidence: data.evidence || {},
|
|
31
|
+
nextAction: data.nextAction ?? null,
|
|
32
|
+
error: ok ? null : { code: result.code || 'INTERNAL', message: result.error || 'Unknown error' },
|
|
33
|
+
};
|
|
34
|
+
for (const [key, value] of Object.entries(data)) {
|
|
35
|
+
if (!ENVELOPE_KEYS.has(key)) out[key] = value;
|
|
36
|
+
}
|
|
37
|
+
if (result.pageState) out.page = { url: result.pageState.url, title: result.pageState.title };
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function errorEnvelope(traceId, code, message, nextAction = null, state = 'failed') {
|
|
42
|
+
return { ok: false, traceId, state, summary: message, evidence: {}, nextAction, error: { code, message } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run one browser tool through the extension.
|
|
47
|
+
*
|
|
48
|
+
* startAction(action, params, deadlineMs) → { id, promise, cancel }
|
|
49
|
+
*
|
|
50
|
+
* The deadline covers every wait the call itself may do (limits.js callDeadlineMs).
|
|
51
|
+
* `allowedOrigins` narrows (never widens) what the page actions may touch.
|
|
52
|
+
* Never throws: a lost bridge, a timeout or a cancel come back as envelopes.
|
|
53
|
+
*/
|
|
54
|
+
async function executeStep(startAction, toolName, args, { allowedOrigins = null, onStart } = {}) {
|
|
55
|
+
const action = TOOL_TO_ACTION[toolName];
|
|
56
|
+
const params = { ...args };
|
|
57
|
+
if (allowedOrigins && PAGE_TOOLS.has(toolName)) params.allowedOrigins = allowedOrigins;
|
|
58
|
+
|
|
59
|
+
const call = startAction(action, params, callDeadlineMs(toolName, args));
|
|
60
|
+
onStart?.(call);
|
|
61
|
+
try {
|
|
62
|
+
return envelope(call.id, await call.promise);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const code = err.code || 'INTERNAL';
|
|
65
|
+
const next = code === 'BRIDGE_DISCONNECTED'
|
|
66
|
+
? 'Check that the BrowserZap extension is loaded and paired ("browserzap doctor").'
|
|
67
|
+
: code === 'TIMEOUT'
|
|
68
|
+
? 'Check the page with page_inspect before repeating anything.'
|
|
69
|
+
: null;
|
|
70
|
+
return errorEnvelope(call.id, code, err.message, next);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Send actions to the extension and match their results, for one session.
|
|
76
|
+
* Used by the MCP server (an agent) and the panel runner (the side panel), so
|
|
77
|
+
* both time out, cancel and fail on a lost connection the same way.
|
|
78
|
+
*
|
|
79
|
+
* send(msg) → false when the message could not be sent
|
|
80
|
+
* ready() → resolves when sending is possible (default: at once)
|
|
81
|
+
* cancelMessage → the error text for a caller-side cancel
|
|
82
|
+
*
|
|
83
|
+
* start(action, params, deadlineMs) returns { id, promise, cancel }: the
|
|
84
|
+
* promise resolves with the extension's result message, or rejects with an
|
|
85
|
+
* error whose `code` is TIMEOUT, CANCELLED or BRIDGE_DISCONNECTED.
|
|
86
|
+
*/
|
|
87
|
+
function createActionClient({ session, send, ready = () => Promise.resolve(), deadlineMs, cancelMessage = 'Cancelled.' }) {
|
|
88
|
+
const pending = new Map();
|
|
89
|
+
let nextId = 0;
|
|
90
|
+
|
|
91
|
+
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
92
|
+
|
|
93
|
+
function start(action, params, callDeadlineMs = deadlineMs) {
|
|
94
|
+
const id = `${session}-${++nextId}`;
|
|
95
|
+
// A cancel can arrive before the call is sent (while waiting for the bridge
|
|
96
|
+
// session); it must stop the call from being sent at all (audit AU-14).
|
|
97
|
+
let cancelledEarly = false;
|
|
98
|
+
let cancel = () => { cancelledEarly = true; };
|
|
99
|
+
const promise = ready().then(() => new Promise((resolve, reject) => {
|
|
100
|
+
if (cancelledEarly) {
|
|
101
|
+
reject(failure('CANCELLED', cancelMessage));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
if (!pending.delete(id)) return;
|
|
106
|
+
send({ type: 'cancel', id, session });
|
|
107
|
+
reject(failure('TIMEOUT', `No result from the browser within ${Math.round(callDeadlineMs / 1000)} s.`));
|
|
108
|
+
}, callDeadlineMs);
|
|
109
|
+
pending.set(id, {
|
|
110
|
+
resolve: (msg) => { clearTimeout(timer); resolve(msg); },
|
|
111
|
+
reject: (err) => { clearTimeout(timer); reject(err); },
|
|
112
|
+
});
|
|
113
|
+
cancel = () => {
|
|
114
|
+
if (!pending.delete(id)) return;
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
send({ type: 'cancel', id, session });
|
|
117
|
+
reject(failure('CANCELLED', cancelMessage));
|
|
118
|
+
};
|
|
119
|
+
if (!send({ type: 'action', action, id, session, params })) {
|
|
120
|
+
pending.delete(id);
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
reject(failure('BRIDGE_DISCONNECTED', 'The BrowserZap extension is not connected to the bridge.'));
|
|
123
|
+
}
|
|
124
|
+
}));
|
|
125
|
+
return { id, promise, cancel: () => cancel() };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Hand a result message to its waiting call. False when no call waits for it. */
|
|
129
|
+
function deliver(msg) {
|
|
130
|
+
const entry = msg?.type === 'result' ? pending.get(msg.id) : undefined;
|
|
131
|
+
if (!entry) return false;
|
|
132
|
+
pending.delete(msg.id);
|
|
133
|
+
entry.resolve(msg);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Fail every waiting call (the connection is gone). */
|
|
138
|
+
function failAll(code, message, detail) {
|
|
139
|
+
for (const [id, { reject }] of pending) {
|
|
140
|
+
pending.delete(id);
|
|
141
|
+
reject(Object.assign(failure(code, message), detail ? { detail } : {}));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { start, deliver, failAll };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { envelope, errorEnvelope, executeStep, createActionClient };
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* extension id. The private half lives outside the repository at
|
|
8
8
|
* %USERPROFILE%\.browserzap-extension\key.pem and is never committed.
|
|
9
9
|
*
|
|
10
|
+
* The Chrome Web Store signs its own build and does not accept a manifest
|
|
11
|
+
* `key`, so the store build may get a different id. Fill in
|
|
12
|
+
* STORE_EXTENSION_ID from the developer dashboard after the first upload; the
|
|
13
|
+
* native host then lets both builds in (docs/RELEASE.md §3b).
|
|
14
|
+
*
|
|
10
15
|
* Everything that has to name the extension — the native messaging host
|
|
11
16
|
* manifest's allowed_origins, `browserzap doctor`, the tests — reads the id
|
|
12
17
|
* from here. `extensionIdFromKey` derives it again from the manifest key, so a
|
|
@@ -22,6 +27,12 @@ const crypto = require('crypto');
|
|
|
22
27
|
/** chrome-extension://gienkaoaimdjmcjdejjanmbkpfakmkno/ */
|
|
23
28
|
const EXTENSION_ID = 'gienkaoaimdjmcjdejjanmbkpfakmkno';
|
|
24
29
|
|
|
30
|
+
/** The Chrome Web Store build's id (assigned on the first upload, 2026-10-29). */
|
|
31
|
+
const STORE_EXTENSION_ID = 'bbnmhaonicbmljhbahimpgfafbjoijpe';
|
|
32
|
+
|
|
33
|
+
/** Every build the native host lets in: the unpacked build first, then the store build. */
|
|
34
|
+
const ALLOWED_EXTENSION_IDS = Object.freeze([EXTENSION_ID, STORE_EXTENSION_ID].filter(Boolean));
|
|
35
|
+
|
|
25
36
|
/** Name Chrome uses for the native messaging host; also the registry key's leaf. */
|
|
26
37
|
const HOST_NAME = 'com.browserzap.bridge';
|
|
27
38
|
|
|
@@ -29,6 +40,11 @@ function extensionOrigin(id = EXTENSION_ID) {
|
|
|
29
40
|
return `chrome-extension://${id}/`;
|
|
30
41
|
}
|
|
31
42
|
|
|
43
|
+
/** allowed_origins for the native host manifest, one per allowed build. */
|
|
44
|
+
function allowedOrigins(ids = ALLOWED_EXTENSION_IDS) {
|
|
45
|
+
return ids.map((id) => extensionOrigin(id));
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/**
|
|
33
49
|
* Chrome's extension id for a manifest `key` value (base64 SPKI DER).
|
|
34
50
|
* Returns null when the key is missing or is not a valid public key.
|
|
@@ -51,4 +67,12 @@ function extensionIdFromKey(key) {
|
|
|
51
67
|
.join('');
|
|
52
68
|
}
|
|
53
69
|
|
|
54
|
-
module.exports = {
|
|
70
|
+
module.exports = {
|
|
71
|
+
EXTENSION_ID,
|
|
72
|
+
STORE_EXTENSION_ID,
|
|
73
|
+
ALLOWED_EXTENSION_IDS,
|
|
74
|
+
HOST_NAME,
|
|
75
|
+
extensionOrigin,
|
|
76
|
+
allowedOrigins,
|
|
77
|
+
extensionIdFromKey,
|
|
78
|
+
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserZap — Comment mode batches kept on this device (docs/COMMENT_MODE.md).
|
|
3
|
+
*
|
|
4
|
+
* The person's comments arrive from the side panel in one `feedback_submit`
|
|
5
|
+
* panel request and are written here for the agent's `feedback_get`:
|
|
6
|
+
*
|
|
7
|
+
* <config dir>/feedback/<batch id>/batch.json comments, element details, component, page
|
|
8
|
+
* <config dir>/feedback/<batch id>/c<n>.png the element screenshot (sensitive fields covered)
|
|
9
|
+
* <config dir>/feedback/<batch id>/c<n>-a<k>.* images the person attached
|
|
10
|
+
*
|
|
11
|
+
* Folders are 0700 and files 0600. Nothing is uploaded anywhere. A batch is
|
|
12
|
+
* kept BROWSERZAP_FEEDBACK_DAYS days (default 7) and at most MAX_BATCHES
|
|
13
|
+
* batches; the oldest go first. Comments never contain form values: the
|
|
14
|
+
* extension sends labels and element details only.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
|
|
23
|
+
const { configDir } = require('./secret');
|
|
24
|
+
|
|
25
|
+
const DEFAULT_KEEP_DAYS = 7;
|
|
26
|
+
const MAX_BATCHES = 50;
|
|
27
|
+
const MAX_COMMENTS = 30;
|
|
28
|
+
const MAX_ATTACHMENTS = 3;
|
|
29
|
+
const MAX_IMAGE_CHARS = 6 * 1024 * 1024;
|
|
30
|
+
const ID_RE = /^fb-\d{14}-[0-9a-f]{6}$/;
|
|
31
|
+
const IMAGE_TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' };
|
|
32
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
33
|
+
|
|
34
|
+
class FeedbackError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.code = 'INVALID_ARGUMENT';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const feedbackDir = () => path.join(configDir(), 'feedback');
|
|
42
|
+
|
|
43
|
+
function keepDays() {
|
|
44
|
+
const raw = process.env.BROWSERZAP_FEEDBACK_DAYS;
|
|
45
|
+
if (raw === undefined || raw === '') return DEFAULT_KEEP_DAYS;
|
|
46
|
+
const n = Number(raw);
|
|
47
|
+
return Number.isFinite(n) && n >= 0 && n <= 365 ? n : DEFAULT_KEEP_DAYS;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const str = (v, max) => (typeof v === 'string' ? v.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').slice(0, max) : null);
|
|
51
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : null);
|
|
52
|
+
|
|
53
|
+
function rectOf(r) {
|
|
54
|
+
if (!r || typeof r !== 'object') return null;
|
|
55
|
+
return { x: num(r.x), y: num(r.y), width: num(r.width), height: num(r.height) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Plain JSON of bounded size: target objects are small; anything deeper or larger is dropped. */
|
|
59
|
+
function smallJson(value, maxChars = 2000) {
|
|
60
|
+
try {
|
|
61
|
+
const text = JSON.stringify(value);
|
|
62
|
+
return text && text.length <= maxChars ? JSON.parse(text) : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function elementOf(info) {
|
|
69
|
+
const i = info && typeof info === 'object' ? info : {};
|
|
70
|
+
return {
|
|
71
|
+
role: str(i.role, 60),
|
|
72
|
+
name: str(i.name, 300),
|
|
73
|
+
target: smallJson(i.target),
|
|
74
|
+
targetUnique: i.targetUnique === true,
|
|
75
|
+
tag: str(i.tag, 40),
|
|
76
|
+
id: str(i.id, 200),
|
|
77
|
+
testId: str(i.testId, 200),
|
|
78
|
+
classes: Array.isArray(i.classes) ? i.classes.map((c) => str(c, 100)).filter(Boolean).slice(0, 6) : [],
|
|
79
|
+
domPath: str(i.domPath, 600),
|
|
80
|
+
text: str(i.text, 200),
|
|
81
|
+
sensitive: i.sensitive === true ? true : undefined,
|
|
82
|
+
rect: rectOf(i.rect),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function componentOf(c) {
|
|
87
|
+
if (!c || typeof c !== 'object') return null;
|
|
88
|
+
const source = c.source && typeof c.source === 'object' && typeof c.source.file === 'string'
|
|
89
|
+
? { file: str(c.source.file, 400), line: num(c.source.line), column: num(c.source.column) }
|
|
90
|
+
: null;
|
|
91
|
+
return {
|
|
92
|
+
framework: str(c.framework, 20),
|
|
93
|
+
component: str(c.component, 100),
|
|
94
|
+
chain: Array.isArray(c.chain) ? c.chain.map((n) => str(n, 100)).filter(Boolean).slice(0, 5) : [],
|
|
95
|
+
source,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pageOf(p) {
|
|
100
|
+
const page = p && typeof p === 'object' ? p : {};
|
|
101
|
+
const url = str(page.url, 2000);
|
|
102
|
+
if (!url || !/^https?:\/\//i.test(url)) throw new FeedbackError('Every comment needs the http(s) page it was made on.');
|
|
103
|
+
return {
|
|
104
|
+
url,
|
|
105
|
+
title: str(page.title, 200),
|
|
106
|
+
viewport: page.viewport && typeof page.viewport === 'object' ? { width: num(page.viewport.width), height: num(page.viewport.height) } : null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function imageData(data, what) {
|
|
111
|
+
if (typeof data !== 'string' || !data) return null;
|
|
112
|
+
if (data.length > MAX_IMAGE_CHARS) throw new FeedbackError(`${what} is too large.`);
|
|
113
|
+
if (!BASE64_RE.test(data)) throw new FeedbackError(`${what} is not a valid image.`);
|
|
114
|
+
return Buffer.from(data, 'base64');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function newId(now = new Date()) {
|
|
118
|
+
const p = (n, w = 2) => String(n).padStart(w, '0');
|
|
119
|
+
const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
120
|
+
return `fb-${stamp}-${crypto.randomBytes(3).toString('hex')}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writePrivate(file, content) {
|
|
124
|
+
fs.writeFileSync(file, content, { mode: 0o600 });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Check and store one batch. Returns { id, dir, count }. Throws FeedbackError on bad input. */
|
|
128
|
+
function saveBatch(batch, { now = new Date() } = {}) {
|
|
129
|
+
const comments = batch && Array.isArray(batch.comments) ? batch.comments : null;
|
|
130
|
+
if (!comments || comments.length === 0) throw new FeedbackError('The batch has no comments.');
|
|
131
|
+
if (comments.length > MAX_COMMENTS) throw new FeedbackError(`A batch holds at most ${MAX_COMMENTS} comments.`);
|
|
132
|
+
|
|
133
|
+
const id = newId(now);
|
|
134
|
+
const dir = path.join(feedbackDir(), id);
|
|
135
|
+
const files = [];
|
|
136
|
+
const stored = comments.map((c, index) => {
|
|
137
|
+
if (!c || typeof c !== 'object') throw new FeedbackError(`Comment ${index + 1} is not an object.`);
|
|
138
|
+
const text = str(c.text, 2000)?.trim();
|
|
139
|
+
if (!text) throw new FeedbackError(`Comment ${index + 1} has no text.`);
|
|
140
|
+
const n = Number.isInteger(c.n) && c.n > 0 && c.n < 1000 ? c.n : index + 1;
|
|
141
|
+
const shot = imageData(c.screenshot, `The screenshot of comment ${n}`);
|
|
142
|
+
const attachments = Array.isArray(c.attachments) ? c.attachments.slice(0, MAX_ATTACHMENTS) : [];
|
|
143
|
+
const entry = {
|
|
144
|
+
n,
|
|
145
|
+
text,
|
|
146
|
+
page: pageOf(c.page),
|
|
147
|
+
element: elementOf(c.info),
|
|
148
|
+
component: componentOf(c.component),
|
|
149
|
+
screenshot: null,
|
|
150
|
+
attachments: [],
|
|
151
|
+
createdAt: num(c.createdAt),
|
|
152
|
+
};
|
|
153
|
+
if (shot) {
|
|
154
|
+
entry.screenshot = `c${n}.png`;
|
|
155
|
+
files.push([entry.screenshot, shot]);
|
|
156
|
+
}
|
|
157
|
+
attachments.forEach((a, k) => {
|
|
158
|
+
const ext = a && IMAGE_TYPES[a.mimeType];
|
|
159
|
+
const data = ext ? imageData(a.data, `Image ${k + 1} of comment ${n}`) : null;
|
|
160
|
+
if (!data) return;
|
|
161
|
+
const name = `c${n}-a${k + 1}.${ext}`;
|
|
162
|
+
entry.attachments.push(name);
|
|
163
|
+
files.push([name, data]);
|
|
164
|
+
});
|
|
165
|
+
return entry;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
169
|
+
for (const [name, data] of files) writePrivate(path.join(dir, name), data);
|
|
170
|
+
const record = { id, createdAt: now.toISOString(), delivered: false, deliveredAt: null, comments: stored };
|
|
171
|
+
writePrivate(path.join(dir, 'batch.json'), JSON.stringify(record, null, 2));
|
|
172
|
+
prune({ now: now.getTime() });
|
|
173
|
+
return { id, dir, count: stored.length };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readBatch(id) {
|
|
177
|
+
if (!ID_RE.test(id)) return null;
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(fs.readFileSync(path.join(feedbackDir(), id, 'batch.json'), 'utf8'));
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function batchIds() {
|
|
186
|
+
try {
|
|
187
|
+
return fs.readdirSync(feedbackDir()).filter((d) => ID_RE.test(d)).sort();
|
|
188
|
+
} catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Newest first: { id, createdAt, count, pages, delivered }. */
|
|
194
|
+
function list({ includeDelivered = false } = {}) {
|
|
195
|
+
return batchIds().reverse()
|
|
196
|
+
.map((id) => readBatch(id))
|
|
197
|
+
.filter((b) => b && (includeDelivered || !b.delivered))
|
|
198
|
+
.map((b) => ({
|
|
199
|
+
id: b.id,
|
|
200
|
+
createdAt: b.createdAt,
|
|
201
|
+
count: b.comments.length,
|
|
202
|
+
pages: [...new Set(b.comments.map((c) => c.page.url))].slice(0, 5),
|
|
203
|
+
delivered: b.delivered,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** One batch with absolute file paths, or the newest one not yet delivered. */
|
|
208
|
+
function get(id) {
|
|
209
|
+
const chosen = id ?? list()[0]?.id;
|
|
210
|
+
const batch = chosen ? readBatch(chosen) : null;
|
|
211
|
+
if (!batch) return null;
|
|
212
|
+
const dir = path.join(feedbackDir(), batch.id);
|
|
213
|
+
return {
|
|
214
|
+
...batch,
|
|
215
|
+
dir,
|
|
216
|
+
comments: batch.comments.map((c) => ({
|
|
217
|
+
...c,
|
|
218
|
+
screenshot: c.screenshot ? path.join(dir, c.screenshot) : null,
|
|
219
|
+
attachments: c.attachments.map((a) => path.join(dir, a)),
|
|
220
|
+
})),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function markDelivered(id, { now = new Date() } = {}) {
|
|
225
|
+
const batch = readBatch(id);
|
|
226
|
+
if (!batch || batch.delivered) return false;
|
|
227
|
+
batch.delivered = true;
|
|
228
|
+
batch.deliveredAt = now.toISOString();
|
|
229
|
+
writePrivate(path.join(feedbackDir(), id, 'batch.json'), JSON.stringify(batch, null, 2));
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function pendingCount() {
|
|
234
|
+
return list().length;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Delete batches older than the retention, and the oldest beyond MAX_BATCHES. Never throws. */
|
|
238
|
+
function prune({ now = Date.now(), days = keepDays() } = {}) {
|
|
239
|
+
const cutoff = now - days * 86_400_000;
|
|
240
|
+
const ids = batchIds();
|
|
241
|
+
let removed = 0;
|
|
242
|
+
ids.forEach((id, index) => {
|
|
243
|
+
const batch = readBatch(id);
|
|
244
|
+
const created = batch ? Date.parse(batch.createdAt) : 0;
|
|
245
|
+
if (created < cutoff || index < ids.length - MAX_BATCHES) {
|
|
246
|
+
try {
|
|
247
|
+
fs.rmSync(path.join(feedbackDir(), id), { recursive: true, force: true });
|
|
248
|
+
removed++;
|
|
249
|
+
} catch { /* in use: next time */ }
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
return removed;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { saveBatch, list, get, markDelivered, pendingCount, prune, feedbackDir, FeedbackError, MAX_COMMENTS };
|
package/bridge-server/index.js
CHANGED
|
@@ -265,7 +265,16 @@ function uninstallExtension(purge, { register = true } = {}) {
|
|
|
265
265
|
*/
|
|
266
266
|
const command = args[0] && !args[0].startsWith('-') ? args[0] : null;
|
|
267
267
|
|
|
268
|
-
|
|
268
|
+
// Inside WSL there is no Chrome to pair with and no Windows registry: BrowserZap
|
|
269
|
+
// runs on the Windows side and the agent in WSL starts it through interop.
|
|
270
|
+
if (require('./setup').insideWsl() && !process.env.BROWSERZAP_ALLOW_LINUX) {
|
|
271
|
+
process.stderr.write('BrowserZap runs on Windows, next to Chrome. From WSL, start it through Windows:\n' +
|
|
272
|
+
' claude mcp add browserzap -- cmd.exe /c browserzap\n' +
|
|
273
|
+
'(install it on Windows first: npm install -g browserzap, then browserzap setup). See docs/WSL.md.\n');
|
|
274
|
+
process.exit(2);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const SUBCOMMANDS = new Set(['setup', 'doctor', 'uninstall', 'run', 'restart-bridge']);
|
|
269
278
|
|
|
270
279
|
if (command === 'setup') {
|
|
271
280
|
// From an installed package, copy the extension to a folder that outlives an
|
|
@@ -298,12 +307,29 @@ if (command === 'setup') {
|
|
|
298
307
|
`);
|
|
299
308
|
process.exit(1);
|
|
300
309
|
});
|
|
310
|
+
} else if (command === 'restart-bridge') {
|
|
311
|
+
// A bridge that will not pair (another secret, an old version) holds the
|
|
312
|
+
// port. Stop the node process holding it (never anything that is not node);
|
|
313
|
+
// the extension's native host starts the current one with this install's secret.
|
|
314
|
+
const { stopBridgeOnPort, isListening } = require('./probe');
|
|
315
|
+
const portIdx = args.indexOf('--port');
|
|
316
|
+
const port = portIdx !== -1 && args[portIdx + 1] ? parseInt(args[portIdx + 1], 10) : DEFAULT_PORT;
|
|
317
|
+
stopBridgeOnPort(port).then(async (pids) => {
|
|
318
|
+
const still = await isListening(port);
|
|
319
|
+
process.stdout.write(pids.length
|
|
320
|
+
? `Stopped the bridge on 127.0.0.1:${port} (pid ${pids.join(', ')}). Open the BrowserZap side panel (or press Reconnect) to start the current one.\n`
|
|
321
|
+
: `No BrowserZap bridge was holding 127.0.0.1:${port}.${still ? ' Something else listens there; it was left alone.' : ''}\n`);
|
|
322
|
+
process.exit(still ? 1 : 0);
|
|
323
|
+
}).catch((err) => {
|
|
324
|
+
process.stderr.write(`[browserzap] restart-bridge failed: ${err.message}\n`);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
});
|
|
301
327
|
} else if (command === 'uninstall') {
|
|
302
328
|
uninstallExtension(args.includes('--purge'), { register: !args.includes('--no-registry') });
|
|
303
329
|
} else if (command === 'help') {
|
|
304
330
|
args.push('--help');
|
|
305
331
|
} else if (command) {
|
|
306
|
-
process.stderr.write(`Unknown command "${command}". Use: setup, doctor, run, uninstall, or --help.\n`);
|
|
332
|
+
process.stderr.write(`Unknown command "${command}". Use: setup, doctor, restart-bridge, run, uninstall, or --help.\n`);
|
|
307
333
|
process.exit(2);
|
|
308
334
|
}
|
|
309
335
|
|
|
@@ -468,6 +494,7 @@ Usage:
|
|
|
468
494
|
browserzap setup One-time setup: register the native messaging host and
|
|
469
495
|
optionally add the MCP server to detected clients
|
|
470
496
|
browserzap doctor Check the host manifest, registry, extension id and bridge
|
|
497
|
+
browserzap restart-bridge Replace a bridge that will not pair (another secret or version)
|
|
471
498
|
browserzap uninstall [--purge] Remove the host registration and installed extension copy
|
|
472
499
|
browserzap run <workflow> [--params <file.csv|file.json>] [--stop-on-error]
|
|
473
500
|
Run a saved workflow with no agent; one run per row (Pro for
|
package/bridge-server/limits.js
CHANGED
|
@@ -28,6 +28,16 @@ const LIMITS = Object.freeze({
|
|
|
28
28
|
SETTLE_MAX_MS: 5000,
|
|
29
29
|
/** Longest per-step pacing a person may set on a saved workflow step. */
|
|
30
30
|
MAX_STEP_PACING_MS: 30000,
|
|
31
|
+
/** A tab with no agent action for this long is detached, so Chrome's "debugging" bar goes away (D-35). */
|
|
32
|
+
DEBUGGER_IDLE_MS: 60000,
|
|
33
|
+
/** Tabs a session may have while it works (main + helpers); one more closes the least recently used helper (D-38). */
|
|
34
|
+
MAX_SESSION_TABS: 5,
|
|
35
|
+
/** Tabs a session keeps once it has been idle for TAB_TRIM_IDLE_MS. */
|
|
36
|
+
MAX_IDLE_TABS: 3,
|
|
37
|
+
/** No agent action for this long counts as "the task is done": helpers are trimmed to MAX_IDLE_TABS. */
|
|
38
|
+
TAB_TRIM_IDLE_MS: 120000,
|
|
39
|
+
/** A session whose connection dropped keeps its tabs this long, in case the same session reconnects. */
|
|
40
|
+
SESSION_GRACE_MS: 30000,
|
|
31
41
|
/**
|
|
32
42
|
* How long the MCP server waits for the extension after opening Chrome itself
|
|
33
43
|
* (docs/AUTO_LAUNCH.md). Polled, never a fixed sleep.
|