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 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
+ }
@@ -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
- server.tool(
295
- 'execute',
296
- EXECUTE_PROMPT,
297
- {
298
- code: z.string().describe('JavaScript to run — page/context/state/snapshot/waitForPageLoad/getLogs in scope'),
299
- timeout: z.number().optional().describe('Max execution time in ms (default: 30000)'),
300
- },
301
- async ({ code, timeout = 30000 }) => {
302
- await ensureBrowser();
303
- ensureAllPagesCapture();
304
- const ctx = getContext();
305
- const pages = ctx.pages();
306
- const page = pages[0] || null;
307
-
308
- if (page) setupConsoleCapture(page);
309
- const execCtx = buildExecContext(page, ctx, userState, {
310
- consoleLogs, setupConsoleCapture,
311
- });
312
- try {
313
- const result = await runCode(code, execCtx, timeout);
314
- const formatted = formatResult(result);
315
- return { content: [formatted] };
316
- } catch (err) {
317
- const isTimeout = err instanceof CodeExecutionTimeoutError;
318
- const hint = isTimeout ? '' : '\n\n[If connection lost, call reset tool to reconnect]';
319
- return {
320
- content: [{ type: 'text', text: `Error: ${err.message}${hint}` }],
321
- isError: true,
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.9",
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
  }
@@ -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
- return this;
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 };