browserforce 1.0.9 → 1.0.10
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/README.md +80 -0
- package/bin.js +128 -1
- package/mcp/src/exec-engine.js +13 -1
- package/mcp/src/index.js +71 -31
- package/mcp/src/plugin-installer.js +71 -0
- package/mcp/src/plugin-loader.js +85 -0
- package/mcp/src/update-check.js +65 -0
- package/package.json +3 -3
- package/relay/src/index.js +107 -17
- package/relay/src/plugin-installer.cjs +55 -0
package/README.md
CHANGED
|
@@ -23,6 +23,20 @@ Works with [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP-
|
|
|
23
23
|
| Agent support | Any MCP client | OpenClaw only | Any MCP client | Claude only | **Any MCP client** |
|
|
24
24
|
| Playwright API | Partial | No | Full | No | **Full** |
|
|
25
25
|
|
|
26
|
+
## Your Credentials Stay Yours
|
|
27
|
+
|
|
28
|
+
Every other approach asks you to hand over something: an API key, an OAuth token, stored passwords, session cookies in a config file. BrowserForce asks for none of it.
|
|
29
|
+
|
|
30
|
+
**Why?** Because you're already logged in. BrowserForce talks to your running Chrome — it doesn't extract credentials, store cookies, or replay tokens. The browser handles auth exactly as it always has. Your agent inherits your sessions the same way a new Chrome tab does.
|
|
31
|
+
|
|
32
|
+
What you never need to provide:
|
|
33
|
+
- No passwords
|
|
34
|
+
- No API keys
|
|
35
|
+
- No OAuth tokens
|
|
36
|
+
- No session cookies in env vars or config files
|
|
37
|
+
|
|
38
|
+
It's a security win *and* a setup win — there are no secrets to rotate, leak, or manage. Your logins live in Chrome. They stay in Chrome.
|
|
39
|
+
|
|
26
40
|
## Setup
|
|
27
41
|
|
|
28
42
|
### 1. Install
|
|
@@ -153,10 +167,76 @@ browserforce snapshot [n] # Accessibility tree of tab n
|
|
|
153
167
|
browserforce screenshot [n] # Screenshot tab n (PNG to stdout)
|
|
154
168
|
browserforce navigate <url> # Open URL in a new tab
|
|
155
169
|
browserforce -e "<code>" # Run Playwright JavaScript (one-shot)
|
|
170
|
+
browserforce plugin list # List installed plugins
|
|
171
|
+
browserforce plugin install <n> # Install a plugin from the registry
|
|
172
|
+
browserforce plugin remove <n> # Remove an installed plugin
|
|
156
173
|
```
|
|
157
174
|
|
|
158
175
|
Each `-e` command is one-shot — state does not persist between calls. For persistent state, use the MCP server.
|
|
159
176
|
|
|
177
|
+
## Plugins
|
|
178
|
+
|
|
179
|
+
Plugins add custom helpers directly into the `execute` tool scope. Install once — your agent calls them like built-in functions.
|
|
180
|
+
|
|
181
|
+
### Install a plugin
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
browserforce plugin install highlight
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
That's it. Restart MCP (or Claude Desktop) and `highlight()` is available in every `execute` call.
|
|
188
|
+
|
|
189
|
+
### Official plugins
|
|
190
|
+
|
|
191
|
+
| Plugin | What it adds | Install |
|
|
192
|
+
|--------|-------------|---------|
|
|
193
|
+
| `highlight` | `highlight(selector, color?)` — outlines matching elements; `clearHighlights()` — removes them | `browserforce plugin install highlight` |
|
|
194
|
+
|
|
195
|
+
### Use an installed plugin
|
|
196
|
+
|
|
197
|
+
After installing `highlight`, your agent can call it directly:
|
|
198
|
+
|
|
199
|
+
```javascript
|
|
200
|
+
// Outline all buttons in blue
|
|
201
|
+
await highlight('button', 'blue');
|
|
202
|
+
|
|
203
|
+
// Highlight the specific element you're about to click
|
|
204
|
+
await highlight('[data-testid="submit"]', 'red');
|
|
205
|
+
return await screenshotWithAccessibilityLabels();
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The helper receives the active page, context, and state automatically — no plumbing needed.
|
|
209
|
+
|
|
210
|
+
### Manage plugins
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
browserforce plugin list # See what's installed
|
|
214
|
+
browserforce plugin remove highlight # Uninstall
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Plugins are stored at `~/.browserforce/plugins/`. Each one is a folder with an `index.js`.
|
|
218
|
+
|
|
219
|
+
### Write your own
|
|
220
|
+
|
|
221
|
+
```javascript
|
|
222
|
+
// ~/.browserforce/plugins/my-plugin/index.js
|
|
223
|
+
export default {
|
|
224
|
+
name: 'my-plugin',
|
|
225
|
+
helpers: {
|
|
226
|
+
async scrollToBottom(page, ctx, state) {
|
|
227
|
+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
228
|
+
},
|
|
229
|
+
async countLinks(page, ctx, state) {
|
|
230
|
+
return page.evaluate(() => document.querySelectorAll('a').length);
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Drop it in `~/.browserforce/plugins/my-plugin/`, restart MCP, and call `await scrollToBottom()` or `await countLinks()` from any `execute` call.
|
|
237
|
+
|
|
238
|
+
Add a `SKILL.md` file alongside `index.js` and its content is automatically appended to the `execute` tool's description — so your agent knows the helpers exist without you having to explain them every time.
|
|
239
|
+
|
|
160
240
|
### Any Playwright Script
|
|
161
241
|
|
|
162
242
|
```javascript
|
package/bin.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import http from 'node:http';
|
|
6
|
+
import { checkForUpdate } from './mcp/src/update-check.js';
|
|
6
7
|
|
|
7
8
|
const { values, positionals } = parseArgs({
|
|
8
9
|
options: {
|
|
@@ -42,6 +43,32 @@ function httpGet(url) {
|
|
|
42
43
|
});
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function httpFetch(method, url, body, authToken) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const parsed = new URL(url);
|
|
49
|
+
const payload = body ? JSON.stringify(body) : undefined;
|
|
50
|
+
const req = http.request({
|
|
51
|
+
hostname: parsed.hostname, port: parsed.port,
|
|
52
|
+
path: parsed.pathname, method,
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
|
|
56
|
+
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
|
57
|
+
},
|
|
58
|
+
}, (res) => {
|
|
59
|
+
let data = '';
|
|
60
|
+
res.on('data', (d) => (data += d));
|
|
61
|
+
res.on('end', () => {
|
|
62
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
|
|
63
|
+
catch { resolve({ status: res.statusCode, body: data }); }
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
req.on('error', reject);
|
|
67
|
+
if (payload) req.write(payload);
|
|
68
|
+
req.end();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
async function connectBrowser() {
|
|
46
73
|
const { getCdpUrl, ensureRelay } = await import('./mcp/src/exec-engine.js');
|
|
47
74
|
await ensureRelay();
|
|
@@ -215,6 +242,86 @@ async function cmdMcp() {
|
|
|
215
242
|
await import('./mcp/src/index.js');
|
|
216
243
|
}
|
|
217
244
|
|
|
245
|
+
async function cmdPlugin() {
|
|
246
|
+
const sub = positionals[1];
|
|
247
|
+
if (!sub) {
|
|
248
|
+
console.error('Usage: browserforce plugin <list|install|remove> [name]');
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const { getRelayHttpUrl } = await import('./mcp/src/exec-engine.js');
|
|
253
|
+
let baseUrl;
|
|
254
|
+
try { baseUrl = getRelayHttpUrl(); } catch { baseUrl = 'http://127.0.0.1:19222'; }
|
|
255
|
+
|
|
256
|
+
// Auth token for write endpoints — read from token file
|
|
257
|
+
const { readFileSync } = await import('node:fs');
|
|
258
|
+
const { join } = await import('node:path');
|
|
259
|
+
const { homedir } = await import('node:os');
|
|
260
|
+
const pluginsDir = process.env.BF_PLUGINS_DIR || join(homedir(), '.browserforce', 'plugins');
|
|
261
|
+
const tokenFile = join(homedir(), '.browserforce', 'auth-token');
|
|
262
|
+
let authToken = '';
|
|
263
|
+
try { authToken = readFileSync(tokenFile, 'utf8').trim(); } catch { /* no token file */ }
|
|
264
|
+
|
|
265
|
+
if (sub === 'list') {
|
|
266
|
+
const data = await httpGet(`${baseUrl}/plugins`);
|
|
267
|
+
if (values.json) {
|
|
268
|
+
output(data, true);
|
|
269
|
+
} else {
|
|
270
|
+
const list = (data && data.plugins) ? data.plugins : [];
|
|
271
|
+
if (list.length === 0) {
|
|
272
|
+
console.log('No plugins installed');
|
|
273
|
+
} else {
|
|
274
|
+
for (const name of list) console.log(` \u2022 ${name}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (sub === 'install') {
|
|
281
|
+
const name = positionals[2];
|
|
282
|
+
if (!name) { console.error('Usage: browserforce plugin install <name>'); process.exit(1); }
|
|
283
|
+
const { status, body } = await httpFetch('POST', `${baseUrl}/plugins/install`, { name }, authToken);
|
|
284
|
+
if (status >= 400) {
|
|
285
|
+
console.error(`Error: ${body.error || JSON.stringify(body)}`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
output(body, values.json);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (sub === 'remove') {
|
|
293
|
+
const name = positionals[2];
|
|
294
|
+
if (!name) { console.error('Usage: browserforce plugin remove <name>'); process.exit(1); }
|
|
295
|
+
const { status, body } = await httpFetch('DELETE', `${baseUrl}/plugins/${encodeURIComponent(name)}`, null, authToken);
|
|
296
|
+
if (status >= 400) {
|
|
297
|
+
console.error(`Error: ${body.error || JSON.stringify(body)}`);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
output(body, values.json);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
console.error(`Unknown plugin subcommand: ${sub}`);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function cmdUpdate() {
|
|
309
|
+
const { spawnSync } = await import('node:child_process');
|
|
310
|
+
console.log('Checking for updates...');
|
|
311
|
+
const update = await checkForUpdate();
|
|
312
|
+
if (!update) {
|
|
313
|
+
console.log('Already up to date.');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
console.log(`Updating ${update.current} → ${update.latest}...`);
|
|
317
|
+
const result = spawnSync('npm', ['install', '-g', 'browserforce'], { stdio: 'inherit' });
|
|
318
|
+
if (result.status !== 0) {
|
|
319
|
+
console.error('Update failed. Run manually: npm install -g browserforce');
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
console.log(`Updated to ${update.latest}.`);
|
|
323
|
+
}
|
|
324
|
+
|
|
218
325
|
function cmdHelp() {
|
|
219
326
|
console.log(`
|
|
220
327
|
BrowserForce — Give AI agents your real Chrome browser
|
|
@@ -227,6 +334,10 @@ function cmdHelp() {
|
|
|
227
334
|
browserforce screenshot [n] Screenshot tab n (default: 0)
|
|
228
335
|
browserforce snapshot [n] Accessibility tree of tab n (default: 0)
|
|
229
336
|
browserforce navigate <url> Open URL in a new tab
|
|
337
|
+
browserforce plugin list List installed plugins
|
|
338
|
+
browserforce plugin install <n> Install a plugin from the registry
|
|
339
|
+
browserforce plugin remove <n> Remove an installed plugin
|
|
340
|
+
browserforce update Update to the latest version
|
|
230
341
|
browserforce -e "<code>" Execute Playwright JavaScript (one-shot)
|
|
231
342
|
|
|
232
343
|
Options:
|
|
@@ -237,6 +348,9 @@ function cmdHelp() {
|
|
|
237
348
|
Examples:
|
|
238
349
|
browserforce serve
|
|
239
350
|
browserforce tabs
|
|
351
|
+
browserforce plugin list
|
|
352
|
+
browserforce plugin install highlight
|
|
353
|
+
browserforce update
|
|
240
354
|
browserforce -e "return await snapshot()"
|
|
241
355
|
browserforce -e "await page.goto('https://github.com'); return await snapshot()"
|
|
242
356
|
browserforce screenshot 0 > page.png
|
|
@@ -252,7 +366,7 @@ function cmdHelp() {
|
|
|
252
366
|
const commands = {
|
|
253
367
|
serve: cmdServe, mcp: cmdMcp, status: cmdStatus, tabs: cmdTabs,
|
|
254
368
|
screenshot: cmdScreenshot, snapshot: cmdSnapshot, navigate: cmdNavigate,
|
|
255
|
-
execute: cmdExecute, help: cmdHelp,
|
|
369
|
+
execute: cmdExecute, plugin: cmdPlugin, update: cmdUpdate, help: cmdHelp,
|
|
256
370
|
};
|
|
257
371
|
|
|
258
372
|
const handler = commands[command];
|
|
@@ -262,9 +376,22 @@ if (!handler) {
|
|
|
262
376
|
process.exit(1);
|
|
263
377
|
}
|
|
264
378
|
|
|
379
|
+
// Start update check in background — skipped for long-running or self-update commands
|
|
380
|
+
const updatePromise = (command !== 'serve' && command !== 'mcp' && command !== 'update')
|
|
381
|
+
? checkForUpdate()
|
|
382
|
+
: null;
|
|
383
|
+
|
|
265
384
|
try {
|
|
266
385
|
await handler();
|
|
267
386
|
} catch (err) {
|
|
268
387
|
console.error(`Error: ${err.message}`);
|
|
269
388
|
process.exit(1);
|
|
270
389
|
}
|
|
390
|
+
|
|
391
|
+
// Show update notice after command finishes (wait at most 500 ms)
|
|
392
|
+
if (updatePromise) {
|
|
393
|
+
const update = await Promise.race([updatePromise, new Promise(r => setTimeout(r, 500, null))]);
|
|
394
|
+
if (update) {
|
|
395
|
+
process.stderr.write(`\n Update available: ${update.current} → ${update.latest}\n Run: npm install -g browserforce\n\n`);
|
|
396
|
+
}
|
|
397
|
+
}
|
package/mcp/src/exec-engine.js
CHANGED
|
@@ -198,6 +198,7 @@ export async function smartWaitForPageLoad(page, timeout, pollInterval = 100, mi
|
|
|
198
198
|
// computed accessible names. Supports Shadow DOM (open roots).
|
|
199
199
|
|
|
200
200
|
export async function getAccessibilityTree(page, rootSelector) {
|
|
201
|
+
if (!page || typeof page.evaluate !== 'function') return null;
|
|
201
202
|
return page.evaluate((sel) => {
|
|
202
203
|
function getRole(el) {
|
|
203
204
|
if (el.nodeType !== 1) return null;
|
|
@@ -403,7 +404,7 @@ export class CodeExecutionTimeoutError extends Error {
|
|
|
403
404
|
|
|
404
405
|
// buildExecContext takes userState and optional console helpers as params
|
|
405
406
|
// instead of referencing module-level singletons.
|
|
406
|
-
export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = {}) {
|
|
407
|
+
export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = {}, pluginHelpers = {}) {
|
|
407
408
|
const { consoleLogs, setupConsoleCapture } = consoleHelpers;
|
|
408
409
|
|
|
409
410
|
const activePage = () => {
|
|
@@ -443,7 +444,18 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = {
|
|
|
443
444
|
if (consoleLogs) consoleLogs.set(activePage(), []);
|
|
444
445
|
};
|
|
445
446
|
|
|
447
|
+
// Wrap plugin helpers to auto-inject (page, ctx, state) as first three args
|
|
448
|
+
const wrappedPluginHelpers = {};
|
|
449
|
+
for (const [name, fn] of Object.entries(pluginHelpers)) {
|
|
450
|
+
wrappedPluginHelpers[name] = (...args) => {
|
|
451
|
+
let pg = null;
|
|
452
|
+
try { pg = activePage(); } catch { /* no active page */ }
|
|
453
|
+
return fn(pg, ctx, userState, ...args);
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
446
457
|
return {
|
|
458
|
+
...wrappedPluginHelpers, // plugin helpers spread first — built-ins always win
|
|
447
459
|
page: defaultPage, context: ctx, state: userState,
|
|
448
460
|
snapshot, waitForPageLoad, getLogs, clearLogs,
|
|
449
461
|
fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout,
|
package/mcp/src/index.js
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
getCdpUrl, ensureRelay, CodeExecutionTimeoutError, buildExecContext, runCode, formatResult,
|
|
11
11
|
} from './exec-engine.js';
|
|
12
12
|
import { screenshotWithLabels } from './a11y-labels.js';
|
|
13
|
+
import { loadPlugins, buildPluginHelpers, buildPluginSkillAppendix } from './plugin-loader.js';
|
|
14
|
+
import { checkForUpdate } from './update-check.js';
|
|
13
15
|
|
|
14
16
|
// ─── Console Log Capture ─────────────────────────────────────────────────────
|
|
15
17
|
|
|
@@ -101,6 +103,17 @@ function getPages() {
|
|
|
101
103
|
|
|
102
104
|
let userState = {};
|
|
103
105
|
|
|
106
|
+
// ─── Plugin State ────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
let plugins = [];
|
|
109
|
+
let pluginHelpers = {};
|
|
110
|
+
|
|
111
|
+
// ─── Update State ────────────────────────────────────────────────────────────
|
|
112
|
+
// Checked once at startup; notice injected into first execute response only.
|
|
113
|
+
|
|
114
|
+
let pendingUpdate = null; // { current, latest } or null
|
|
115
|
+
let updateNoticeSent = false;
|
|
116
|
+
|
|
104
117
|
// ─── MCP Server ──────────────────────────────────────────────────────────────
|
|
105
118
|
|
|
106
119
|
const server = new McpServer({
|
|
@@ -291,38 +304,45 @@ state
|
|
|
291
304
|
Persistent object — survives across execute calls. Cleared on reset.
|
|
292
305
|
Use state.page, state.data, state.anything to preserve working state.`;
|
|
293
306
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
307
|
+
function registerExecuteTool(skillAppendix = '') {
|
|
308
|
+
server.tool(
|
|
309
|
+
'execute',
|
|
310
|
+
EXECUTE_PROMPT + skillAppendix,
|
|
311
|
+
{
|
|
312
|
+
code: z.string().describe('JavaScript to run — page/context/state/snapshot/waitForPageLoad/getLogs in scope'),
|
|
313
|
+
timeout: z.number().optional().describe('Max execution time in ms (default: 30000)'),
|
|
314
|
+
},
|
|
315
|
+
async ({ code, timeout = 30000 }) => {
|
|
316
|
+
await ensureBrowser();
|
|
317
|
+
ensureAllPagesCapture();
|
|
318
|
+
const ctx = getContext();
|
|
319
|
+
const pages = ctx.pages();
|
|
320
|
+
const page = pages[0] || null;
|
|
321
|
+
|
|
322
|
+
if (page) setupConsoleCapture(page);
|
|
323
|
+
const execCtx = buildExecContext(page, ctx, userState, {
|
|
324
|
+
consoleLogs, setupConsoleCapture,
|
|
325
|
+
}, pluginHelpers);
|
|
326
|
+
try {
|
|
327
|
+
const result = await runCode(code, execCtx, timeout);
|
|
328
|
+
const formatted = formatResult(result);
|
|
329
|
+
// Inject update notice into the first text response of the session (once only)
|
|
330
|
+
if (pendingUpdate && !updateNoticeSent && formatted.type === 'text') {
|
|
331
|
+
updateNoticeSent = true;
|
|
332
|
+
formatted.text += `\n\n[BrowserForce update available: ${pendingUpdate.current} → ${pendingUpdate.latest}]\n[Run: browserforce update or: npm install -g browserforce]`;
|
|
333
|
+
}
|
|
334
|
+
return { content: [formatted] };
|
|
335
|
+
} catch (err) {
|
|
336
|
+
const isTimeout = err instanceof CodeExecutionTimeoutError;
|
|
337
|
+
const hint = isTimeout ? '' : '\n\n[If connection lost, call reset tool to reconnect]';
|
|
338
|
+
return {
|
|
339
|
+
content: [{ type: 'text', text: `Error: ${err.message}${hint}` }],
|
|
340
|
+
isError: true,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
323
343
|
}
|
|
324
|
-
|
|
325
|
-
|
|
344
|
+
);
|
|
345
|
+
}
|
|
326
346
|
|
|
327
347
|
server.tool(
|
|
328
348
|
'reset',
|
|
@@ -425,9 +445,29 @@ server.tool(
|
|
|
425
445
|
}
|
|
426
446
|
);
|
|
427
447
|
|
|
448
|
+
// ─── Plugin Init ─────────────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
async function initPlugins() {
|
|
451
|
+
try {
|
|
452
|
+
plugins = await loadPlugins();
|
|
453
|
+
pluginHelpers = buildPluginHelpers(plugins);
|
|
454
|
+
if (plugins.length > 0) {
|
|
455
|
+
process.stderr.write(`[bf-mcp] Loaded ${plugins.length} plugin(s): ${plugins.map(p => p.name).join(', ')}\n`);
|
|
456
|
+
}
|
|
457
|
+
} catch (err) {
|
|
458
|
+
process.stderr.write(`[bf-mcp] Plugin load error: ${err.message}\n`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
428
462
|
// ─── Start Server ────────────────────────────────────────────────────────────
|
|
429
463
|
|
|
430
464
|
async function main() {
|
|
465
|
+
await initPlugins();
|
|
466
|
+
registerExecuteTool(buildPluginSkillAppendix(plugins));
|
|
467
|
+
|
|
468
|
+
// Fire update check in background — result stored in pendingUpdate for execute handler
|
|
469
|
+
checkForUpdate().then(info => { pendingUpdate = info; }).catch(() => {});
|
|
470
|
+
|
|
431
471
|
try {
|
|
432
472
|
await ensureBrowser();
|
|
433
473
|
process.stderr.write('[bf-mcp] Connected to relay\n');
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdir, writeFile, rename } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
|
|
6
|
+
const REGISTRY_URL = 'https://raw.githubusercontent.com/ivalsaraj/browserforce/main/plugins/registry.json';
|
|
7
|
+
|
|
8
|
+
function httpsGetRaw(url) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
https.get(url, { headers: { 'User-Agent': 'browserforce' } }, (res) => {
|
|
11
|
+
if (res.statusCode !== 200) {
|
|
12
|
+
return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
|
|
13
|
+
}
|
|
14
|
+
let data = '';
|
|
15
|
+
res.on('data', d => { data += d; });
|
|
16
|
+
res.on('end', () => resolve(data));
|
|
17
|
+
}).on('error', reject);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function fetchRegistry() {
|
|
22
|
+
// Test override
|
|
23
|
+
if (process.env.BF_TEST_REGISTRY) {
|
|
24
|
+
return JSON.parse(process.env.BF_TEST_REGISTRY);
|
|
25
|
+
}
|
|
26
|
+
const raw = await httpsGetRaw(REGISTRY_URL);
|
|
27
|
+
return JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function fetchPluginFile(url, testEnvKey) {
|
|
31
|
+
if (process.env[testEnvKey]) return process.env[testEnvKey];
|
|
32
|
+
return httpsGetRaw(url);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Install a plugin from the registry into destDir/<name>/.
|
|
37
|
+
* @param {string} name
|
|
38
|
+
* @param {string} pluginsDir — e.g. ~/.browserforce/plugins
|
|
39
|
+
*/
|
|
40
|
+
export async function installPlugin(name, pluginsDir) {
|
|
41
|
+
const registry = await fetchRegistry();
|
|
42
|
+
const entry = registry.plugins?.find(p => p.name === name);
|
|
43
|
+
if (!entry) throw new Error(`Plugin "${name}" not found in registry`);
|
|
44
|
+
|
|
45
|
+
if (!entry.url) throw new Error(`Plugin "${name}" registry entry missing required field: url`);
|
|
46
|
+
|
|
47
|
+
const js = await fetchPluginFile(entry.url, 'BF_TEST_PLUGIN_JS');
|
|
48
|
+
|
|
49
|
+
// SHA-256 integrity check (skip if registry entry omits sha256 — dev/test mode)
|
|
50
|
+
if (entry.sha256) {
|
|
51
|
+
const actual = createHash('sha256').update(js).digest('hex');
|
|
52
|
+
if (actual !== entry.sha256) {
|
|
53
|
+
throw new Error(`Plugin "${name}" integrity check failed — sha256 mismatch`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const destDir = join(pluginsDir, name);
|
|
58
|
+
await mkdir(destDir, { recursive: true });
|
|
59
|
+
|
|
60
|
+
// Write atomically: write to .tmp, then rename — prevents partial installs
|
|
61
|
+
const tmpJs = join(destDir, 'index.js.tmp');
|
|
62
|
+
await writeFile(tmpJs, js);
|
|
63
|
+
await rename(tmpJs, join(destDir, 'index.js'));
|
|
64
|
+
|
|
65
|
+
if (entry.skill_url) {
|
|
66
|
+
try {
|
|
67
|
+
const skill = await fetchPluginFile(entry.skill_url, 'BF_TEST_PLUGIN_SKILL');
|
|
68
|
+
await writeFile(join(destDir, 'SKILL.md'), skill);
|
|
69
|
+
} catch { /* SKILL.md optional */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
export const PLUGINS_DIR = join(homedir(), '.browserforce', 'plugins');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Scan pluginsDir for subfolders with index.js. Loads each as an ESM module.
|
|
10
|
+
* @param {string} [pluginsDir]
|
|
11
|
+
* @returns {Promise<Array>}
|
|
12
|
+
*/
|
|
13
|
+
export async function loadPlugins(pluginsDir = PLUGINS_DIR) {
|
|
14
|
+
const plugins = [];
|
|
15
|
+
|
|
16
|
+
let entries;
|
|
17
|
+
try {
|
|
18
|
+
entries = await readdir(pluginsDir, { withFileTypes: true });
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (err.code !== 'ENOENT') {
|
|
21
|
+
// Permission or IO error — log but don't crash
|
|
22
|
+
process.stderr.write(`[bf-plugins] Cannot read plugins dir: ${err.message}\n`);
|
|
23
|
+
}
|
|
24
|
+
return plugins; // missing directory is fine — no plugins installed
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (!entry.isDirectory()) continue;
|
|
29
|
+
const pluginDir = join(pluginsDir, entry.name);
|
|
30
|
+
const indexPath = join(pluginDir, 'index.js');
|
|
31
|
+
|
|
32
|
+
let mod;
|
|
33
|
+
try {
|
|
34
|
+
mod = await import(pathToFileURL(indexPath).href);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
process.stderr.write(`[bf-plugins] Failed to load ${entry.name}: ${err.message}\n`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const plugin = mod.default;
|
|
41
|
+
if (!plugin?.name) {
|
|
42
|
+
process.stderr.write(`[bf-plugins] Skipping ${entry.name}: export missing 'name' field\n`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let skill = '';
|
|
47
|
+
try {
|
|
48
|
+
skill = await readFile(join(pluginDir, 'SKILL.md'), 'utf8');
|
|
49
|
+
} catch { /* SKILL.md is optional */ }
|
|
50
|
+
|
|
51
|
+
plugins.push({ ...plugin, _skill: skill, _dir: pluginDir });
|
|
52
|
+
process.stderr.write(`[bf-plugins] Loaded plugin: ${plugin.name}\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return plugins;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Merge helpers from all plugins into a flat object.
|
|
60
|
+
* Last plugin wins on name collision (with a warning).
|
|
61
|
+
*/
|
|
62
|
+
export function buildPluginHelpers(plugins) {
|
|
63
|
+
const helpers = {};
|
|
64
|
+
for (const plugin of plugins) {
|
|
65
|
+
if (!plugin.helpers) continue;
|
|
66
|
+
for (const [name, fn] of Object.entries(plugin.helpers)) {
|
|
67
|
+
if (helpers[name]) {
|
|
68
|
+
process.stderr.write(`[bf-plugins] Helper name conflict: "${name}" — ${plugin.name} overwrites previous\n`);
|
|
69
|
+
}
|
|
70
|
+
helpers[name] = fn;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return helpers;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the SKILL.md appendix to append to the execute tool prompt.
|
|
78
|
+
* Only includes plugins that have non-empty SKILL.md content.
|
|
79
|
+
*/
|
|
80
|
+
export function buildPluginSkillAppendix(plugins) {
|
|
81
|
+
const sections = plugins
|
|
82
|
+
.filter(p => p._skill && p._skill.trim())
|
|
83
|
+
.map(p => `\n\n═══ PLUGIN: ${p.name} ═══\n\n${p._skill.trim()}`);
|
|
84
|
+
return sections.join('');
|
|
85
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
export function semverGt(a, b) {
|
|
7
|
+
const pa = a.split('.').map(Number);
|
|
8
|
+
const pb = b.split('.').map(Number);
|
|
9
|
+
for (let i = 0; i < 3; i++) {
|
|
10
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
11
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check npm registry for a newer version of browserforce.
|
|
18
|
+
* Result is cached for 24 h in ~/.browserforce/update-check.json.
|
|
19
|
+
* Returns { current, latest } if an update is available, otherwise null.
|
|
20
|
+
* Never throws — all errors resolve to null.
|
|
21
|
+
*/
|
|
22
|
+
export async function checkForUpdate() {
|
|
23
|
+
try {
|
|
24
|
+
// package.json is two levels up from mcp/src/
|
|
25
|
+
const pkgPath = new URL('../../package.json', import.meta.url).pathname;
|
|
26
|
+
const current = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
27
|
+
|
|
28
|
+
const cacheDir = join(homedir(), '.browserforce');
|
|
29
|
+
const cacheFile = join(cacheDir, 'update-check.json');
|
|
30
|
+
|
|
31
|
+
// Return cached result if still fresh (< 24 h)
|
|
32
|
+
try {
|
|
33
|
+
const cached = JSON.parse(readFileSync(cacheFile, 'utf8'));
|
|
34
|
+
if (Date.now() - cached.checkedAt < 86_400_000) {
|
|
35
|
+
return semverGt(cached.latest, current) ? { current, latest: cached.latest } : null;
|
|
36
|
+
}
|
|
37
|
+
} catch { /* no cache yet, or invalid */ }
|
|
38
|
+
|
|
39
|
+
// Fetch latest from npm registry
|
|
40
|
+
const latest = await new Promise((resolve, reject) => {
|
|
41
|
+
const req = https.get(
|
|
42
|
+
'https://registry.npmjs.org/browserforce/latest',
|
|
43
|
+
{ headers: { 'User-Agent': 'browserforce-cli' } },
|
|
44
|
+
(res) => {
|
|
45
|
+
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); }
|
|
46
|
+
let data = '';
|
|
47
|
+
res.on('data', d => (data += d));
|
|
48
|
+
res.on('end', () => { try { resolve(JSON.parse(data).version); } catch { reject(new Error('parse error')); } });
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
req.on('error', reject);
|
|
52
|
+
req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Persist to cache
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
58
|
+
writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest }));
|
|
59
|
+
} catch { /* ignore cache write errors */ }
|
|
60
|
+
|
|
61
|
+
return semverGt(latest, current) ? { current, latest } : null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browserforce",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Give AI agents your real Chrome browser with progressive examples: simple reads, form interactions, multi-tab workflows, and state persistence. Search X and GitHub, extract ProductHunt data, test forms, compare A/B variants, monitor status pages. Works with OpenClaw, Claude, and any MCP agent.",
|
|
6
6
|
"homepage": "https://github.com/ivalsaraj/browserforce",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"relay": "lsof -ti tcp:19222 | xargs kill -9 2>/dev/null; sleep 0.3; node relay/src/index.js",
|
|
47
47
|
"relay:dev": "lsof -ti tcp:19222 | xargs kill -9 2>/dev/null; sleep 0.3; node --watch relay/src/index.js",
|
|
48
48
|
"mcp": "node mcp/src/index.js",
|
|
49
|
-
"test": "node --test relay/test/relay-server.test.js && node --test mcp/test/mcp-tools.test.js && node --test test/cli.test.js",
|
|
49
|
+
"test": "node --test relay/test/relay-server.test.js && node --test mcp/test/mcp-tools.test.js && node --test mcp/test/plugin-loader.test.js && node --test mcp/test/plugin-installer.test.js && node --test mcp/test/exec-engine-plugins.test.js && node --test mcp/test/mcp-plugin-integration.test.js && node --test test/cli.test.js",
|
|
50
50
|
"test:relay": "node --test relay/test/relay-server.test.js",
|
|
51
|
-
"test:mcp": "node --test mcp/test/mcp-tools.test.js"
|
|
51
|
+
"test:mcp": "node --test mcp/test/mcp-tools.test.js && node --test mcp/test/plugin-loader.test.js && node --test mcp/test/plugin-installer.test.js && node --test mcp/test/exec-engine-plugins.test.js && node --test mcp/test/mcp-plugin-integration.test.js"
|
|
52
52
|
}
|
|
53
53
|
}
|
package/relay/src/index.js
CHANGED
|
@@ -14,6 +14,7 @@ const PING_INTERVAL_MS = 5000;
|
|
|
14
14
|
const BF_DIR = path.join(os.homedir(), '.browserforce');
|
|
15
15
|
const TOKEN_FILE = path.join(BF_DIR, 'auth-token');
|
|
16
16
|
const CDP_URL_FILE = path.join(BF_DIR, 'cdp-url');
|
|
17
|
+
const BF_PLUGINS_DIR = path.join(BF_DIR, 'plugins');
|
|
17
18
|
|
|
18
19
|
// ─── Logging ─────────────────────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -124,8 +125,9 @@ function syntheticInitResponse(method, target) {
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
class RelayServer {
|
|
127
|
-
constructor(port = DEFAULT_PORT) {
|
|
128
|
+
constructor(port = DEFAULT_PORT, pluginsDir = BF_PLUGINS_DIR) {
|
|
128
129
|
this.port = port;
|
|
130
|
+
this.pluginsDir = pluginsDir;
|
|
129
131
|
this.authToken = getOrCreateAuthToken();
|
|
130
132
|
|
|
131
133
|
// Extension connection (single slot)
|
|
@@ -158,23 +160,26 @@ class RelayServer {
|
|
|
158
160
|
this.extWss.on('connection', (ws) => this._onExtConnect(ws));
|
|
159
161
|
this.cdpWss.on('connection', (ws) => this._onCdpConnect(ws));
|
|
160
162
|
|
|
161
|
-
server.listen(this.port, '127.0.0.1', () => {
|
|
162
|
-
const cdpUrl = `ws://127.0.0.1:${this.port}/cdp?token=${this.authToken}`;
|
|
163
|
-
if (writeCdpUrl) writeCdpUrlFile(cdpUrl);
|
|
164
|
-
console.log('');
|
|
165
|
-
console.log(' BrowserForce');
|
|
166
|
-
console.log(' ────────────────────────────────────────');
|
|
167
|
-
console.log(` Status: http://127.0.0.1:${this.port}/`);
|
|
168
|
-
console.log(` CDP: ${cdpUrl}`);
|
|
169
|
-
console.log(` Config: ${BF_DIR}/`);
|
|
170
|
-
console.log(' ────────────────────────────────────────');
|
|
171
|
-
console.log('');
|
|
172
|
-
console.log(' Waiting for extension to connect...');
|
|
173
|
-
console.log('');
|
|
174
|
-
});
|
|
175
|
-
|
|
176
163
|
this.server = server;
|
|
177
|
-
|
|
164
|
+
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
server.listen(this.port, '127.0.0.1', () => {
|
|
167
|
+
this.port = server.address().port;
|
|
168
|
+
const cdpUrl = `ws://127.0.0.1:${this.port}/cdp?token=${this.authToken}`;
|
|
169
|
+
if (writeCdpUrl) writeCdpUrlFile(cdpUrl);
|
|
170
|
+
console.log('');
|
|
171
|
+
console.log(' BrowserForce');
|
|
172
|
+
console.log(' ────────────────────────────────────────');
|
|
173
|
+
console.log(` Status: http://127.0.0.1:${this.port}/`);
|
|
174
|
+
console.log(` CDP: ${cdpUrl}`);
|
|
175
|
+
console.log(` Config: ${BF_DIR}/`);
|
|
176
|
+
console.log(' ────────────────────────────────────────');
|
|
177
|
+
console.log('');
|
|
178
|
+
console.log(' Waiting for extension to connect...');
|
|
179
|
+
console.log('');
|
|
180
|
+
resolve({ port: this.port, authToken: this.authToken });
|
|
181
|
+
});
|
|
182
|
+
});
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
// ─── HTTP ────────────────────────────────────────────────────────────────
|
|
@@ -230,10 +235,95 @@ class RelayServer {
|
|
|
230
235
|
return;
|
|
231
236
|
}
|
|
232
237
|
|
|
238
|
+
// ─── Plugin Routes ───────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
if (url.pathname === '/plugins' && req.method === 'GET') {
|
|
241
|
+
try {
|
|
242
|
+
const entries = fs.existsSync(this.pluginsDir)
|
|
243
|
+
? fs.readdirSync(this.pluginsDir, { withFileTypes: true })
|
|
244
|
+
.filter(d => d.isDirectory())
|
|
245
|
+
.map(d => d.name)
|
|
246
|
+
: [];
|
|
247
|
+
res.end(JSON.stringify({ plugins: entries }));
|
|
248
|
+
} catch (err) {
|
|
249
|
+
res.statusCode = 500;
|
|
250
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (url.pathname === '/plugins/install' && req.method === 'POST') {
|
|
256
|
+
if (!this._requireAuth(req, res)) return;
|
|
257
|
+
let body = '';
|
|
258
|
+
req.on('data', chunk => { body += chunk; });
|
|
259
|
+
req.on('end', async () => {
|
|
260
|
+
try {
|
|
261
|
+
const { name } = JSON.parse(body);
|
|
262
|
+
if (!name) {
|
|
263
|
+
res.statusCode = 400;
|
|
264
|
+
res.end(JSON.stringify({ error: 'name required' }));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const { installPlugin } = require('./plugin-installer.cjs');
|
|
268
|
+
await installPlugin(name, this.pluginsDir);
|
|
269
|
+
res.end(JSON.stringify({ ok: true, plugin: name }));
|
|
270
|
+
} catch (err) {
|
|
271
|
+
res.statusCode = 422;
|
|
272
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const deleteMatch = url.pathname.match(/^\/plugins\/([a-z0-9_-]+)$/);
|
|
279
|
+
if (deleteMatch && req.method === 'DELETE') {
|
|
280
|
+
if (!this._requireAuth(req, res)) return;
|
|
281
|
+
const name = deleteMatch[1];
|
|
282
|
+
try {
|
|
283
|
+
const pluginPath = path.join(this.pluginsDir, name);
|
|
284
|
+
if (!fs.existsSync(pluginPath)) {
|
|
285
|
+
res.statusCode = 404;
|
|
286
|
+
res.end(JSON.stringify({ error: `Plugin "${name}" not installed` }));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
fs.rmSync(pluginPath, { recursive: true });
|
|
290
|
+
res.end(JSON.stringify({ ok: true, plugin: name }));
|
|
291
|
+
} catch (err) {
|
|
292
|
+
res.statusCode = 500;
|
|
293
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
233
298
|
res.statusCode = 404;
|
|
234
299
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
235
300
|
}
|
|
236
301
|
|
|
302
|
+
// ─── Auth Helper ─────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
_requireAuth(req, res) {
|
|
305
|
+
// Double gate: Bearer token + Origin restriction.
|
|
306
|
+
// The relay's /json/version exposes the auth token unauthenticated (required
|
|
307
|
+
// by Playwright for CDP discovery), so Bearer alone isn't sufficient —
|
|
308
|
+
// any local browser tab could read the token and call write endpoints.
|
|
309
|
+
// Restricting Origin to chrome-extension:// closes that vector.
|
|
310
|
+
const origin = req.headers['origin'] || '';
|
|
311
|
+
if (origin && !origin.startsWith('chrome-extension://')) {
|
|
312
|
+
// Origin present but not the extension — reject (CSRF / browser tab attack)
|
|
313
|
+
res.statusCode = 403;
|
|
314
|
+
res.end(JSON.stringify({ error: 'Forbidden — invalid origin' }));
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
const authHeader = req.headers['authorization'] || '';
|
|
318
|
+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
319
|
+
if (!token || token !== this.authToken) {
|
|
320
|
+
res.statusCode = 401;
|
|
321
|
+
res.end(JSON.stringify({ error: 'Unauthorized — Bearer token required' }));
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
237
327
|
// ─── WebSocket Upgrade ───────────────────────────────────────────────────
|
|
238
328
|
|
|
239
329
|
_handleUpgrade(req, socket, head) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// CJS version of mcp/src/plugin-installer.js — keep in sync
|
|
2
|
+
const { mkdir, writeFile, rename } = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const https = require('node:https');
|
|
6
|
+
|
|
7
|
+
const REGISTRY_URL = 'https://raw.githubusercontent.com/ivalsaraj/browserforce/main/plugins/registry.json';
|
|
8
|
+
|
|
9
|
+
function httpsGetRaw(url) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
https.get(url, { headers: { 'User-Agent': 'browserforce' } }, (res) => {
|
|
12
|
+
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
|
|
13
|
+
let data = '';
|
|
14
|
+
res.on('data', d => { data += d; });
|
|
15
|
+
res.on('end', () => resolve(data));
|
|
16
|
+
}).on('error', reject);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function fetchRegistry() {
|
|
21
|
+
if (process.env.BF_TEST_REGISTRY) return JSON.parse(process.env.BF_TEST_REGISTRY);
|
|
22
|
+
const raw = await httpsGetRaw(REGISTRY_URL);
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function installPlugin(name, pluginsDir) {
|
|
27
|
+
const registry = await fetchRegistry();
|
|
28
|
+
const entry = registry.plugins?.find(p => p.name === name);
|
|
29
|
+
if (!entry) throw new Error(`Plugin "${name}" not found in registry`);
|
|
30
|
+
|
|
31
|
+
if (!entry.url) throw new Error(`Plugin "${name}" registry entry missing required field: url`);
|
|
32
|
+
|
|
33
|
+
const js = await httpsGetRaw(entry.url);
|
|
34
|
+
|
|
35
|
+
if (entry.sha256) {
|
|
36
|
+
const actual = crypto.createHash('sha256').update(js).digest('hex');
|
|
37
|
+
if (actual !== entry.sha256) throw new Error(`Plugin "${name}" integrity check failed`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const destDir = path.join(pluginsDir, name);
|
|
41
|
+
await mkdir(destDir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
const tmpJs = path.join(destDir, 'index.js.tmp');
|
|
44
|
+
await writeFile(tmpJs, js);
|
|
45
|
+
await rename(tmpJs, path.join(destDir, 'index.js'));
|
|
46
|
+
|
|
47
|
+
if (entry.skill_url) {
|
|
48
|
+
try {
|
|
49
|
+
const skill = await httpsGetRaw(entry.skill_url);
|
|
50
|
+
await writeFile(path.join(destDir, 'SKILL.md'), skill);
|
|
51
|
+
} catch { /* optional */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { installPlugin };
|