venombrowser 4.1.0 → 4.1.2

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.
@@ -18,22 +18,29 @@ class BridgeTimeoutError extends BridgeError {}
18
18
  class BridgeCommandError extends BridgeError {}
19
19
 
20
20
  class BrowserBridge {
21
- constructor({ host = '127.0.0.1', port = 10086, session = 'default' } = {}) {
21
+ constructor({ host = '127.0.0.1', port = 10086, session = 'default', apiKey } = {}) {
22
+ if (!apiKey) {
23
+ throw new Error('apiKey is required to connect to the VenomBrowser bridge.');
24
+ }
22
25
  this.baseUrl = `http://${host}:${port}`;
23
26
  this.session = session;
24
27
  this.http = axios.create({
25
28
  baseURL: this.baseUrl,
26
- timeout: 60000
29
+ timeout: 60000,
30
+ headers: {
31
+ 'x-bridge-key': apiKey
32
+ }
27
33
  });
28
34
  }
29
35
 
30
36
  // ─── Core command method ─────────────────────────────────────────────────
31
- async command(action, args = {}, timeout = 30000) {
37
+ async command(action, args = {}, timeout = 30000, extra = {}) {
32
38
  try {
33
39
  const response = await this.http.post('/command', {
34
40
  action,
35
41
  args,
36
- session: this.session
42
+ session: this.session,
43
+ ...extra
37
44
  }, { timeout });
38
45
  return response.data;
39
46
  } catch (e) {
@@ -73,7 +80,19 @@ class BrowserBridge {
73
80
  }
74
81
 
75
82
  async closeSession() {
76
- return this.command('close_session');
83
+ return this.command('close_session', {}, 30000, { confirm: true });
84
+ }
85
+
86
+ async stopServer() {
87
+ return this.command('stop_server', {}, 5000, { confirm: true });
88
+ }
89
+
90
+ async restartServer() {
91
+ return this.command('restart_server', {}, 5000, { confirm: true });
92
+ }
93
+
94
+ async bridgeStatus() {
95
+ return this.command('status');
77
96
  }
78
97
 
79
98
  // ─── Content interaction (Kimi-style) ────────────────────────────────────
@@ -130,6 +149,47 @@ class BrowserBridge {
130
149
  return this.command('scroll', { direction, amount });
131
150
  }
132
151
 
152
+ // ─── Phase 1: Cheap Wins ─────────────────────────────────────────────────
153
+ async scrollTo(selector) {
154
+ return this.command('scroll_to', { selector });
155
+ }
156
+
157
+ async checkVisibility(selector) {
158
+ return this.command('check_visibility', { selector });
159
+ }
160
+
161
+ async checkStale(selector) {
162
+ return this.command('check_stale', { selector });
163
+ }
164
+
165
+ async doubleClick(selector) {
166
+ return this.command('double_click', { selector });
167
+ }
168
+
169
+ async rightClick(selector) {
170
+ return this.command('right_click', { selector });
171
+ }
172
+
173
+ async keyboard(key, { modifiers = [] } = {}) {
174
+ return this.command('keyboard', { key, modifiers });
175
+ }
176
+
177
+ async goBack() {
178
+ return this.command('go_back');
179
+ }
180
+
181
+ async goForward() {
182
+ return this.command('go_forward');
183
+ }
184
+
185
+ async manageCookies(cmd, { name, value, domain } = {}) {
186
+ return this.command('manage_cookies', { cmd, name, value, domain });
187
+ }
188
+
189
+ async manageStorage(cmd, { storageType = 'local', key, value } = {}) {
190
+ return this.command('manage_storage', { cmd, storage_type: storageType, key, value });
191
+ }
192
+
133
193
  // ─── Legacy compatibility (maps old names to new) ─────────────────────────
134
194
  async extract(selector = null) {
135
195
  const r = await this.evaluate(`
@@ -16,13 +16,37 @@ const fs = require('fs');
16
16
  const path = require('path');
17
17
  const WebSocket = require('ws');
18
18
  const { v4: uuidv4 } = require('uuid');
19
+ const crypto = require('crypto');
19
20
 
20
21
  const PORT = parseInt(process.env.BRIDGE_PORT || '10086', 10);
21
22
  const WS_PORT = parseInt(process.env.WS_PORT || String(PORT + 1), 10);
23
+ const WS_AUTH_TOKEN = process.env.WS_AUTH_TOKEN;
24
+ const REST_API_KEY = process.env.REST_API_KEY;
25
+ const CORS_ORIGIN = process.env.CORS_ORIGIN;
26
+
27
+ if (!WS_AUTH_TOKEN || !REST_API_KEY) {
28
+ console.error('[Bridge] FATAL: WS_AUTH_TOKEN and REST_API_KEY must be set in the environment.');
29
+ process.exit(1);
30
+ }
31
+
22
32
  const WS_PING_INTERVAL = 15000;
23
33
  const WS_PING_TIMEOUT = 10000;
24
34
  const CMD_TIMEOUT = 30000;
35
+ const IDLE_TIMEOUT = 15 * 60 * 1000; // 15 minutes of inactivity shuts down the daemon
25
36
  const SCREENSHOT_DIR = path.join(__dirname, '..', '..', 'screenshots');
37
+ const LOG_DIR = path.join(__dirname, '..', 'logs');
38
+
39
+ if (!fs.existsSync(LOG_DIR)) {
40
+ fs.mkdirSync(LOG_DIR, { recursive: true });
41
+ }
42
+
43
+ function safeCompare(a, b) {
44
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
45
+ const aBuf = Buffer.from(a);
46
+ const bBuf = Buffer.from(b);
47
+ if (aBuf.length !== bBuf.length) return false;
48
+ return crypto.timingSafeEqual(aBuf, bBuf);
49
+ }
26
50
 
27
51
  class VenomBrowserBridgeServer {
28
52
  constructor() {
@@ -37,13 +61,57 @@ class VenomBrowserBridgeServer {
37
61
  this.currentTab = null;
38
62
  this.pingTimer = null;
39
63
  this.lastPong = Date.now();
64
+ this.idleTimer = null;
65
+
66
+ this.lastRestActivity = Date.now();
67
+ this.EXTENSION_IDLE_TIMEOUT = parseInt(process.env.EXTENSION_IDLE_TIMEOUT || String(120 * 1000), 10);
68
+ this.DAEMON_IDLE_TIMEOUT = parseInt(process.env.DAEMON_IDLE_TIMEOUT || String(30 * 60 * 1000), 10);
69
+
70
+ // Setup uncaught exception handlers
71
+ process.on('uncaughtException', (err) => {
72
+ console.error('[Bridge] UNCAUGHT EXCEPTION:', err);
73
+ });
40
74
 
75
+ process.on('unhandledRejection', (reason, promise) => {
76
+ console.error('[Bridge] UNHANDLED REJECTION:', reason);
77
+ });
78
+ }
79
+
80
+ start() {
81
+ console.log(`\n🚀 Starting VenomBrowser Bridge Server v4.1.0...`);
41
82
  this.startHTTP();
42
83
  this.startWS();
43
84
  this.startPingLoop();
85
+ this.startIdleMonitor();
86
+ }
87
+
88
+ startIdleMonitor() {
89
+ setInterval(() => {
90
+ const now = Date.now();
91
+ const idleTime = now - this.lastRestActivity;
92
+
93
+ // 1. If idle longer than DAEMON_IDLE_TIMEOUT, shut down completely
94
+ if (idleTime > this.DAEMON_IDLE_TIMEOUT) {
95
+ console.log(`[Bridge] Daemon idle for > ${this.DAEMON_IDLE_TIMEOUT}ms. Shutting down.`);
96
+ process.exit(0);
97
+ }
98
+
99
+ // 2. If idle longer than EXTENSION_IDLE_TIMEOUT, disconnect extension
100
+ if (idleTime > this.EXTENSION_IDLE_TIMEOUT && this.extensionWs && this.extensionWs.readyState === WebSocket.OPEN) {
101
+ console.log(`[Bridge] No REST activity for > ${this.EXTENSION_IDLE_TIMEOUT}ms. Disconnecting extension.`);
102
+ this.safeSend(this.extensionWs, { type: 'idle_disconnect' });
103
+
104
+ // Wait a tiny bit for message to send before closing
105
+ setTimeout(() => {
106
+ if (this.extensionWs) {
107
+ this.extensionWs.close(1000, 'Idle Disconnect');
108
+ }
109
+ }, 100);
110
+ }
111
+ }, 10000); // Check every 10 seconds
44
112
  }
45
113
 
46
- // ─── Safe WebSocket send ─────────────────────────────────────────────────
114
+ // ─── HTTP Server ─────────────────────────────────────────────────────────
47
115
  safeSend(ws, data) {
48
116
  try {
49
117
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -59,9 +127,16 @@ class VenomBrowserBridgeServer {
59
127
  // ─── HTTP Server ─────────────────────────────────────────────────────────
60
128
  startHTTP() {
61
129
  this.httpServer = http.createServer((req, res) => {
62
- res.setHeader('Access-Control-Allow-Origin', '*');
63
- res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
64
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
130
+ // CORS handling
131
+ if (CORS_ORIGIN && CORS_ORIGIN !== '*') {
132
+ if (req.headers.origin === CORS_ORIGIN) {
133
+ res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
134
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
135
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-bridge-key');
136
+ }
137
+ } else {
138
+ // No CORS by default. Only allow localhost processes.
139
+ }
65
140
 
66
141
  if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
67
142
 
@@ -80,6 +155,12 @@ class VenomBrowserBridgeServer {
80
155
  });
81
156
  }
82
157
 
158
+ // API Key Auth for all other endpoints
159
+ const apiKey = req.headers['x-bridge-key'];
160
+ if (!safeCompare(apiKey, REST_API_KEY)) {
161
+ return send(401, { success: false, error: 'Unauthorized: Invalid x-bridge-key' });
162
+ }
163
+
83
164
  if (req.method === 'GET' && req.url === '/status') {
84
165
  return send(200, {
85
166
  success: true,
@@ -139,10 +220,19 @@ class VenomBrowserBridgeServer {
139
220
 
140
221
  if (!action) throw new Error('action is required');
141
222
 
223
+ const destructiveActions = ['upload', 'close_session', 'stop_server', 'restart_server'];
224
+ const destructiveArgs = ['delete', 'clear'];
225
+ if (destructiveActions.includes(action) || (['manage_cookies', 'manage_storage'].includes(action) && destructiveArgs.includes(args.cmd))) {
226
+ if (request.confirm !== true) {
227
+ throw new Error(`Confirmation required: The action '${action}' is destructive. You must include "confirm": true in your request to proceed.`);
228
+ }
229
+ }
230
+
142
231
  if (!this.sessions.has(session)) {
143
232
  this.sessions.set(session, { tabs: new Set(), startTime: Date.now(), commandCount: 0 });
144
233
  }
145
234
  this.sessions.get(session).commandCount++;
235
+ this.lastRestActivity = Date.now();
146
236
 
147
237
  if (action === 'health') return { service: 'venombrowser-bridge' };
148
238
 
@@ -156,8 +246,6 @@ class VenomBrowserBridgeServer {
156
246
  };
157
247
  }
158
248
 
159
- if (!this.isConnected()) throw new Error('Extension not connected');
160
-
161
249
  return this.executeAction(action, args, session, tabId);
162
250
  }
163
251
 
@@ -218,6 +306,19 @@ class VenomBrowserBridgeServer {
218
306
  return { closed };
219
307
  }
220
308
 
309
+ case 'stop_server': {
310
+ console.log('[Bridge] Received stop_server command from agent/CLI. Shutting down gracefully.');
311
+ setTimeout(() => this.shutdown(), 100);
312
+ return { success: true, message: 'Server is shutting down.' };
313
+ }
314
+
315
+ case 'restart_server': {
316
+ console.log('[Bridge] Received restart_server command. Shutting down for restart...');
317
+ // The MCP server / agent will auto-spawn a new daemon on next command
318
+ setTimeout(() => this.shutdown(), 100);
319
+ return { success: true, message: 'Server is restarting. Next command will auto-spawn a new daemon.' };
320
+ }
321
+
221
322
  case 'snapshot': {
222
323
  const r = await send('SNAPSHOT', {}, tabId || this.currentTab);
223
324
  const res = r.result || {};
@@ -245,12 +346,18 @@ class VenomBrowserBridgeServer {
245
346
  }
246
347
 
247
348
  case 'evaluate': {
248
- const r = await send('EVALUATE', { code: args.code }, tabId || this.currentTab, 30000);
349
+ const code = args.code;
350
+ fs.appendFileSync(path.join(LOG_DIR, 'audit.log'), `[${new Date().toISOString()}] evaluate: ${code}\n`);
351
+ const r = await send('EVALUATE', { code }, tabId || this.currentTab, 30000);
249
352
  return { type: r.result?.type, value: r.result?.result };
250
353
  }
251
354
 
252
355
  case 'cdp': {
253
- const r = await send('CDP', { method: args.method, params: args.params }, tabId || this.currentTab, 30000);
356
+ const { method, params = {} } = args;
357
+ if (method === 'Runtime.evaluate' || method === 'Runtime.evaluateOnNewDocument') {
358
+ fs.appendFileSync(path.join(LOG_DIR, 'audit.log'), `[${new Date().toISOString()}] CDP ${method}: ${JSON.stringify(params)}\n`);
359
+ }
360
+ const r = await send('CDP', { method, params }, tabId || this.currentTab, 30000);
254
361
  return r.result;
255
362
  }
256
363
 
@@ -294,6 +401,57 @@ class VenomBrowserBridgeServer {
294
401
  return r.result;
295
402
  }
296
403
 
404
+ // ── Phase 1: Cheap Wins ──
405
+ case 'scroll_to': {
406
+ const r = await send('SCROLL_TO', { selector: args.selector }, tabId || this.currentTab);
407
+ return r.result;
408
+ }
409
+
410
+ case 'check_visibility': {
411
+ const r = await send('CHECK_VISIBILITY', { selector: args.selector }, tabId || this.currentTab);
412
+ return r.result;
413
+ }
414
+
415
+ case 'check_stale': {
416
+ const r = await send('CHECK_STALE', { selector: args.selector }, tabId || this.currentTab);
417
+ return r.result;
418
+ }
419
+
420
+ case 'double_click': {
421
+ const r = await send('DOUBLE_CLICK', { selector: args.selector }, tabId || this.currentTab);
422
+ return { success: true, tag: r.result?.tag, text: r.result?.text };
423
+ }
424
+
425
+ case 'right_click': {
426
+ const r = await send('RIGHT_CLICK', { selector: args.selector }, tabId || this.currentTab);
427
+ return { success: true, tag: r.result?.tag, text: r.result?.text };
428
+ }
429
+
430
+ case 'keyboard': {
431
+ const r = await send('KEYBOARD', { key: args.key, modifiers: args.modifiers || [] }, tabId || this.currentTab);
432
+ return r.result;
433
+ }
434
+
435
+ case 'go_back': {
436
+ const r = await send('GO_BACK', {}, tabId || this.currentTab);
437
+ return r.result;
438
+ }
439
+
440
+ case 'go_forward': {
441
+ const r = await send('GO_FORWARD', {}, tabId || this.currentTab);
442
+ return r.result;
443
+ }
444
+
445
+ case 'manage_cookies': {
446
+ const r = await send('COOKIES', { cmd: args.cmd, name: args.name, value: args.value, domain: args.domain }, tabId || this.currentTab);
447
+ return r.result;
448
+ }
449
+
450
+ case 'manage_storage': {
451
+ const r = await send('STORAGE', { cmd: args.cmd, storageType: args.storage_type || args.storageType || 'local', key: args.key, value: args.value }, tabId || this.currentTab);
452
+ return r.result;
453
+ }
454
+
297
455
  default:
298
456
  throw new Error(`Unknown action: ${action}`);
299
457
  }
@@ -301,8 +459,8 @@ class VenomBrowserBridgeServer {
301
459
 
302
460
  // ─── WebSocket server ────────────────────────────────────────────────────
303
461
  startWS() {
304
- this.wss = new WebSocket.Server({ port: WS_PORT }, () => {
305
- console.log(`[Bridge] WebSocket on ws://localhost:${WS_PORT}`);
462
+ this.wss = new WebSocket.Server({ host: '127.0.0.1', port: WS_PORT }, () => {
463
+ console.log(`[Bridge] WebSocket on ws://127.0.0.1:${WS_PORT}`);
306
464
  });
307
465
 
308
466
  this.wss.on('error', (e) => {
@@ -316,6 +474,16 @@ class VenomBrowserBridgeServer {
316
474
  const addr = req.socket.remoteAddress || 'unknown';
317
475
  console.log(`[Bridge] Extension connected from ${addr}`);
318
476
 
477
+ // Handshake timeout
478
+ ws.authTimeout = setTimeout(() => {
479
+ if (!ws.authenticated) {
480
+ console.log('[Bridge] Extension handshake timed out.');
481
+ ws.close(4001, 'Auth timeout');
482
+ }
483
+ }, 5000);
484
+
485
+ ws.authenticated = false;
486
+
319
487
  // Replace previous connection
320
488
  if (this.extensionWs && this.extensionWs.readyState === WebSocket.OPEN) {
321
489
  console.log('[Bridge] Replacing previous extension');
@@ -364,9 +532,19 @@ class VenomBrowserBridgeServer {
364
532
  }
365
533
  if (msg.type === 'hello') {
366
534
  console.log(`[Bridge] Extension handshake: ${msg.client} v${msg.version}`);
535
+ if (safeCompare(msg.token, WS_AUTH_TOKEN)) {
536
+ ws.authenticated = true;
537
+ clearTimeout(ws.authTimeout);
538
+ this.safeSend(ws, { type: 'auth_ok' });
539
+ } else {
540
+ this.safeSend(ws, { type: 'auth_fail' });
541
+ setTimeout(() => ws.close(4001, 'Unauthorized'), 100);
542
+ }
367
543
  return;
368
544
  }
369
545
 
546
+ if (!ws.authenticated) return; // Drop messages from unauthenticated sockets
547
+
370
548
  // Command response
371
549
  if (msg.id && this.pending.has(msg.id)) {
372
550
  const entry = this.pending.get(msg.id);
@@ -400,9 +578,14 @@ class VenomBrowserBridgeServer {
400
578
  }
401
579
 
402
580
  // ─── Send command to extension ───────────────────────────────────────────
403
- sendToExtension(command, params = {}, tabId = null, timeoutMs = CMD_TIMEOUT) {
404
- if (!this.isConnected()) {
405
- return Promise.reject(new Error('Extension not connected'));
581
+ async sendToExtension(command, params = {}, tabId = null, timeoutMs = CMD_TIMEOUT) {
582
+ if (!this.isConnected() || !this.extensionWs.authenticated) {
583
+ console.log(`[Bridge] Extension not ready. Waiting for reconnection (max 10s)...`);
584
+ try {
585
+ await this.waitForConnection(10000);
586
+ } catch (err) {
587
+ throw err;
588
+ }
406
589
  }
407
590
 
408
591
  const id = uuidv4();
@@ -425,6 +608,25 @@ class VenomBrowserBridgeServer {
425
608
  });
426
609
  }
427
610
 
611
+ waitForConnection(timeoutMs) {
612
+ return new Promise((resolve, reject) => {
613
+ if (this.isConnected() && this.extensionWs.authenticated) return resolve();
614
+
615
+ const checkInterval = setInterval(() => {
616
+ if (this.isConnected() && this.extensionWs.authenticated) {
617
+ clearInterval(checkInterval);
618
+ clearTimeout(timeoutTimer);
619
+ resolve();
620
+ }
621
+ }, 500);
622
+
623
+ const timeoutTimer = setTimeout(() => {
624
+ clearInterval(checkInterval);
625
+ reject(new Error('Timed out waiting for extension to connect'));
626
+ }, timeoutMs);
627
+ });
628
+ }
629
+
428
630
  rejectAll(reason) {
429
631
  for (const { reject, timer } of this.pending.values()) {
430
632
  clearTimeout(timer);
@@ -466,6 +668,7 @@ process.on('unhandledRejection', (reason) => {
466
668
  // ─── Start ────────────────────────────────────────────────────────────────
467
669
  console.log('🚀 Starting VenomBrowser Bridge Server v4.1.0...');
468
670
  const server = new VenomBrowserBridgeServer();
671
+ server.start();
469
672
  console.log(`📡 WebSocket : ws://localhost:${WS_PORT}`);
470
673
  console.log(`🌐 HTTP API : http://127.0.0.1:${PORT}/command`);
471
674
  console.log(`📋 Status : http://127.0.0.1:${PORT}/status`);
package/src/connect.js CHANGED
@@ -185,9 +185,17 @@ function connect() {
185
185
  const registered = [];
186
186
 
187
187
  for (const { name, path: configPath, format } of configs) {
188
+ // If the file doesn't exist, create it so we can auto-configure
188
189
  if (!fs.existsSync(configPath)) {
189
- console.log(` ⏭️ ${name}: config not found, skipping`);
190
- continue;
190
+ try {
191
+ const dir = path.dirname(configPath);
192
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
193
+ fs.writeFileSync(configPath, '{}', 'utf8');
194
+ console.log(` ✨ ${name}: created missing config file`);
195
+ } catch (err) {
196
+ console.log(` ⏭️ ${name}: could not create config (${err.message})`);
197
+ continue;
198
+ }
191
199
  }
192
200
 
193
201
  const result = format === 'mcp'
package/src/mcp-server.js CHANGED
@@ -20,12 +20,15 @@ const { BrowserBridge } = require('./bridge-client');
20
20
  const { getMcpToolDefinitions, executeTool } = require('./tools/browser-tools');
21
21
 
22
22
  class VenomBrowserMcpServer {
23
- constructor() {
23
+ constructor(options = {}) {
24
+ this.onBeforeCommand = options.onBeforeCommand || null;
24
25
  const host = process.env.BRIDGE_HOST || '127.0.0.1';
25
26
  const port = parseInt(process.env.BRIDGE_PORT || '10086', 10);
26
27
  const session = process.env.BRIDGE_SESSION || 'mcp-session';
27
28
 
28
- this.bridge = new BrowserBridge({ host, port, session });
29
+ const apiKey = process.env.REST_API_KEY;
30
+
31
+ this.bridge = new BrowserBridge({ host, port, session, apiKey });
29
32
 
30
33
  this.server = new Server(
31
34
  { name: 'venombrowser', version: '4.0.0' },
@@ -45,6 +48,10 @@ class VenomBrowserMcpServer {
45
48
  const { name, arguments: args } = request.params;
46
49
 
47
50
  try {
51
+ if (this.onBeforeCommand) {
52
+ await this.onBeforeCommand(name);
53
+ }
54
+
48
55
  const result = await executeTool(this.bridge, name, args || {});
49
56
 
50
57
  // Special handling for screenshots — return as MCP image content