venombrowser 4.1.1 → 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.
@@ -37,6 +37,8 @@ Commands:
37
37
  setup Run the interactive setup wizard
38
38
  start Start the bridge daemon
39
39
  status Check daemon & extension connection status
40
+ stop Stop the bridge daemon (releases port)
41
+ restart Restart the bridge daemon
40
42
  connect Auto-register MCP server with known agents
41
43
  uninstall Remove MCP entries, stop daemon (does NOT npm uninstall)
42
44
  agent "<task>" Run a one-shot browser automation task
@@ -153,6 +155,35 @@ function runChat() {
153
155
  child.on('exit', (code) => process.exit(code || 0));
154
156
  }
155
157
 
158
+ function runStop() {
159
+ const { BrowserBridge } = require('../src/bridge-client');
160
+ const bridge = new BrowserBridge({ port: BRIDGE_PORT, session: 'cli', apiKey: process.env.REST_API_KEY });
161
+ bridge.stopServer()
162
+ .then(() => console.log('✅ Sent stop signal to bridge daemon. It will shut down gracefully.'))
163
+ .catch(err => {
164
+ if (err.message.includes('fetch failed') || err.message.includes('ECONNREFUSED')) {
165
+ console.log('✅ Daemon is already stopped.');
166
+ } else {
167
+ console.error('❌ Failed to stop daemon:', err.message);
168
+ }
169
+ });
170
+ }
171
+
172
+ function runRestart() {
173
+ console.log('🔄 Restarting VenomBrowser daemon...');
174
+ const { BrowserBridge } = require('../src/bridge-client');
175
+ const bridge = new BrowserBridge({ port: BRIDGE_PORT, session: 'cli', apiKey: process.env.REST_API_KEY });
176
+ bridge.stopServer()
177
+ .then(() => {
178
+ console.log('✅ Daemon stopped.');
179
+ setTimeout(() => runStart(), 1000);
180
+ })
181
+ .catch(err => {
182
+ console.log('✅ Daemon was not running.');
183
+ runStart();
184
+ });
185
+ }
186
+
156
187
  // ─── Dispatch ───────────────────────────────────────────────────────────────
157
188
 
158
189
  const command = process.argv[2];
@@ -167,6 +198,12 @@ switch (command) {
167
198
  case 'status':
168
199
  runStatus();
169
200
  break;
201
+ case 'stop':
202
+ runStop();
203
+ break;
204
+ case 'restart':
205
+ runRestart();
206
+ break;
170
207
  case 'connect':
171
208
  runConnect();
172
209
  break;
@@ -136,9 +136,14 @@ async function main() {
136
136
  // Step 1: Ensure the bridge daemon is running (Kimi-style auto-start)
137
137
  await ensureBridgeRunning();
138
138
 
139
- // Step 2: Start the MCP server (stdio transport for agent communication)
140
139
  const VenomBrowserMcpServer = require('../src/mcp-server');
141
- const server = new VenomBrowserMcpServer();
140
+ const server = new VenomBrowserMcpServer({
141
+ onBeforeCommand: async (toolName) => {
142
+ // Don't auto-start if the agent is explicitly trying to stop the server
143
+ if (toolName === 'stop_server') return;
144
+ await ensureBridgeRunning();
145
+ }
146
+ });
142
147
  await server.start();
143
148
  }
144
149
 
@@ -1,183 +1,145 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * VenomBrowser Setup — Interactive setup wizard
4
- *
5
- * Helps users (and agents) configure VenomBrowser by:
6
- * 1. Creating/updating .env from .env.example
7
- * 2. Generating a REST_API_KEY if missing
8
- * 3. Prompting for WS_AUTH_TOKEN (from extension popup)
9
- * 4. Configuring the LLM provider
10
- *
11
- * Usage:
12
- * node bin/venombrowser-setup.js
13
- * node bin/venombrowser-setup.js --auto # Non-interactive: generate keys, use defaults
14
- * node bin/venombrowser-setup.js --token=<tok> # Set the WS_AUTH_TOKEN directly
15
- */
16
-
17
- const fs = require('fs');
18
- const path = require('path');
19
- const crypto = require('crypto');
20
- const readline = require('readline');
21
-
22
- const ENV_PATH = path.join(__dirname, '..', '.env');
23
- const EXAMPLE_PATH = path.join(__dirname, '..', '.env.example');
24
-
25
- function generateKey(length = 32) {
26
- return crypto.randomBytes(length).toString('hex');
27
- }
28
-
29
- function parseEnv(content) {
30
- const vars = {};
31
- for (const line of content.split('\n')) {
32
- const trimmed = line.trim();
33
- if (!trimmed || trimmed.startsWith('#')) continue;
34
- const eqIndex = trimmed.indexOf('=');
35
- if (eqIndex === -1) continue;
36
- const key = trimmed.slice(0, eqIndex).trim();
37
- const val = trimmed.slice(eqIndex + 1).trim();
38
- vars[key] = val;
39
- }
40
- return vars;
41
- }
42
-
43
- function updateEnvFile(updates) {
44
- let content;
45
- if (fs.existsSync(ENV_PATH)) {
46
- content = fs.readFileSync(ENV_PATH, 'utf8');
47
- } else if (fs.existsSync(EXAMPLE_PATH)) {
48
- content = fs.readFileSync(EXAMPLE_PATH, 'utf8');
49
- } else {
50
- // Build minimal .env
51
- content = Object.entries(updates).map(([k, v]) => `${k}=${v}`).join('\n') + '\n';
52
- fs.writeFileSync(ENV_PATH, content);
53
- return;
54
- }
55
-
56
- for (const [key, value] of Object.entries(updates)) {
57
- // Replace existing key= lines, or append
58
- const regex = new RegExp(`^${key}=.*$`, 'm');
59
- if (regex.test(content)) {
60
- content = content.replace(regex, `${key}=${value}`);
61
- } else {
62
- content += `\n${key}=${value}\n`;
63
- }
64
- }
65
-
66
- fs.writeFileSync(ENV_PATH, content);
67
- }
68
-
69
- function ask(rl, question) {
70
- return new Promise(resolve => rl.question(question, resolve));
71
- }
72
-
73
- async function main() {
74
- const args = process.argv.slice(2);
75
- const isAuto = args.includes('--auto');
76
- const tokenArg = args.find(a => a.startsWith('--token='));
77
- const tokenValue = tokenArg ? tokenArg.split('=').slice(1).join('=') : null;
78
-
79
- console.log('🕷️ VenomBrowser Bridge Setup');
80
- console.log('═'.repeat(50));
81
-
82
- // Load existing env
83
- let existing = {};
84
- if (fs.existsSync(ENV_PATH)) {
85
- existing = parseEnv(fs.readFileSync(ENV_PATH, 'utf8'));
86
- console.log('📄 Found existing .env file');
87
- } else {
88
- console.log('📄 No .env file found — will create one');
89
- }
90
-
91
- const updates = {};
92
-
93
- // ── REST_API_KEY ─────────────────────────────────────────────────────────
94
- if (!existing.REST_API_KEY || existing.REST_API_KEY.includes('pick-any') || existing.REST_API_KEY.includes('your')) {
95
- const key = generateKey();
96
- updates.REST_API_KEY = key;
97
- console.log(`🔑 Generated REST_API_KEY: ${key}`);
98
- } else {
99
- console.log(`🔑 REST_API_KEY: ${existing.REST_API_KEY.slice(0, 8)}...`);
100
- }
101
-
102
- // ── WS_AUTH_TOKEN ────────────────────────────────────────────────────────
103
- if (tokenValue) {
104
- updates.WS_AUTH_TOKEN = tokenValue;
105
- console.log(`🔐 WS_AUTH_TOKEN set from --token flag`);
106
- } else if (isAuto) {
107
- if (!existing.WS_AUTH_TOKEN || existing.WS_AUTH_TOKEN.includes('paste')) {
108
- console.log('⚠️ WS_AUTH_TOKEN not set. After loading the extension, run:');
109
- console.log(' node bin/venombrowser-setup.js --token=<paste-token-from-extension-popup>');
110
- }
111
- } else {
112
- // Interactive
113
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
114
-
115
- if (!existing.WS_AUTH_TOKEN || existing.WS_AUTH_TOKEN.includes('paste')) {
116
- console.log();
117
- console.log('📋 To get your WS_AUTH_TOKEN:');
118
- console.log(' 1. Load the extension/ folder in chrome://extensions/');
119
- console.log(' 2. Click the VenomBrowser Bridge icon in your toolbar');
120
- console.log(' 3. Click the auth token to copy it');
121
- console.log();
122
- const token = await ask(rl, '🔐 Paste your Auth Token (or press Enter to skip): ');
123
- if (token.trim()) {
124
- updates.WS_AUTH_TOKEN = token.trim();
125
- console.log('✅ WS_AUTH_TOKEN saved!');
126
- } else {
127
- console.log('⏭️ Skipped you can set it later with: node bin/venombrowser-setup.js --token=<token>');
128
- }
129
- } else {
130
- console.log(`🔐 WS_AUTH_TOKEN: ${existing.WS_AUTH_TOKEN.slice(0, 8)}...`);
131
- }
132
-
133
- rl.close();
134
- }
135
-
136
- // ── Save ──────────────────────────────────────────────────────────────────
137
- if (Object.keys(updates).length > 0) {
138
- updateEnvFile(updates);
139
- console.log();
140
- console.log('💾 Configuration saved to server/.env');
141
- } else {
142
- console.log();
143
- console.log('✅ Configuration is already up to date');
144
- }
145
-
146
- // ── Final summary ─────────────────────────────────────────────────────────
147
- const final = { ...existing, ...updates };
148
- console.log();
149
- console.log('🚀 Next steps:');
150
- if (!final.WS_AUTH_TOKEN || final.WS_AUTH_TOKEN.includes('paste')) {
151
- console.log(' 1. Set your WS_AUTH_TOKEN (see instructions above)');
152
- console.log(' 2. Start using your AI agent (the bridge daemon will auto-start)');
153
- } else {
154
- console.log(' 1. Start using your AI agent (the bridge daemon will auto-start)');
155
- }
156
- console.log(' 2. venombrowser chat — start interactive chat');
157
- console.log(' 3. venombrowser agent "task" — run a one-shot task');
158
- console.log();
159
-
160
- // ── Auto-register with agents ────────────────────────────────────────────
161
- try {
162
- const { connect } = require('../src/connect');
163
- connect();
164
- } catch (err) {
165
- console.log('ℹ️ Auto-registration skipped:', err.message);
166
- // Still print manual instructions as fallback
167
- const absPath = path.resolve(__dirname, 'venombrowser-mcp.js').replace(/\\/g, '/');
168
- console.log('🔌 To connect Claude Code or other MCP agents, add this to your MCP config:');
169
- console.log(JSON.stringify({
170
- "venombrowser": {
171
- command: "node",
172
- args: [absPath],
173
- env: {
174
- REST_API_KEY: final.REST_API_KEY || updates.REST_API_KEY || 'your-key'
175
- }
176
- }
177
- }, null, 2));
178
- console.log();
179
- }
180
- }
181
-
182
- main().catch(console.error);
183
-
2
+ /**
3
+ * VenomBrowser Setup — Interactive setup wizard
4
+ *
5
+ * Helps users (and agents) configure VenomBrowser by:
6
+ * 1. Creating/updating .env from .env.example
7
+ * 2. Configuring the LLM provider (optional)
8
+ *
9
+ * Usage:
10
+ * node bin/venombrowser-setup.js
11
+ * node bin/venombrowser-setup.js --auto # Non-interactive: use defaults
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const readline = require('readline');
17
+
18
+ const ENV_PATH = path.join(__dirname, '..', '.env');
19
+ const EXAMPLE_PATH = path.join(__dirname, '..', '.env.example');
20
+
21
+ function parseEnv(content) {
22
+ const vars = {};
23
+ for (const line of content.split('\n')) {
24
+ const trimmed = line.trim();
25
+ if (!trimmed || trimmed.startsWith('#')) continue;
26
+ const eqIndex = trimmed.indexOf('=');
27
+ if (eqIndex === -1) continue;
28
+ const key = trimmed.slice(0, eqIndex).trim();
29
+ const val = trimmed.slice(eqIndex + 1).trim();
30
+ vars[key] = val;
31
+ }
32
+ return vars;
33
+ }
34
+
35
+ function updateEnvFile(updates) {
36
+ let content;
37
+ if (fs.existsSync(ENV_PATH)) {
38
+ content = fs.readFileSync(ENV_PATH, 'utf8');
39
+ } else if (fs.existsSync(EXAMPLE_PATH)) {
40
+ content = fs.readFileSync(EXAMPLE_PATH, 'utf8');
41
+ } else {
42
+ // Build minimal .env
43
+ content = Object.entries(updates).map(([k, v]) => `${k}=${v}`).join('\n') + '\n';
44
+ fs.writeFileSync(ENV_PATH, content);
45
+ return;
46
+ }
47
+
48
+ for (const [key, value] of Object.entries(updates)) {
49
+ // Replace existing key= lines, or append
50
+ const regex = new RegExp(`^${key}=.*$`, 'm');
51
+ if (regex.test(content)) {
52
+ content = content.replace(regex, `${key}=${value}`);
53
+ } else {
54
+ content += `\n${key}=${value}\n`;
55
+ }
56
+ }
57
+
58
+ fs.writeFileSync(ENV_PATH, content);
59
+ }
60
+
61
+ function ask(rl, question) {
62
+ return new Promise(resolve => rl.question(question, resolve));
63
+ }
64
+
65
+ async function main() {
66
+ console.log('🕷️ VenomBrowser Bridge Setup');
67
+ console.log('═'.repeat(50));
68
+
69
+ // Load existing env
70
+ let existing = {};
71
+ if (fs.existsSync(ENV_PATH)) {
72
+ existing = parseEnv(fs.readFileSync(ENV_PATH, 'utf8'));
73
+ console.log('📄 Found existing .env file');
74
+ } else {
75
+ console.log('📄 No .env file found — will create one');
76
+ }
77
+
78
+ const crypto = require('crypto');
79
+ const updates = {};
80
+
81
+ // ── Auto-generate security tokens if missing ──────────────────────────────
82
+ if (!existing.REST_API_KEY) {
83
+ updates.REST_API_KEY = crypto.randomBytes(32).toString('hex');
84
+ console.log('🔑 Generated new REST_API_KEY');
85
+ }
86
+ if (!existing.WS_AUTH_TOKEN) {
87
+ updates.WS_AUTH_TOKEN = crypto.randomUUID();
88
+ console.log('🔑 Generated new WS_AUTH_TOKEN');
89
+ }
90
+
91
+ const finalWsToken = updates.WS_AUTH_TOKEN || existing.WS_AUTH_TOKEN;
92
+
93
+ // ── Inject token into extension ───────────────────────────────────────────
94
+ const bgPath = path.join(__dirname, '..', '..', 'extension', 'background.js');
95
+ if (fs.existsSync(bgPath)) {
96
+ let bgContent = fs.readFileSync(bgPath, 'utf8');
97
+ // Look for INJECTED_TOKEN = '__WS_AUTH_TOKEN__' or an already injected token
98
+ bgContent = bgContent.replace(/const INJECTED_TOKEN = '.*?';/, `const INJECTED_TOKEN = '${finalWsToken}';`);
99
+ fs.writeFileSync(bgPath, bgContent);
100
+ console.log('💉 Injected WS_AUTH_TOKEN into extension/background.js');
101
+ }
102
+
103
+ // ── Save ──────────────────────────────────────────────────────────────────
104
+ if (Object.keys(updates).length > 0) {
105
+ updateEnvFile(updates);
106
+ console.log();
107
+ console.log('💾 Configuration saved to server/.env');
108
+ } else {
109
+ console.log();
110
+ console.log('✅ Configuration is already up to date');
111
+ }
112
+
113
+ // ── Final summary ─────────────────────────────────────────────────────────
114
+ console.log();
115
+ console.log('🚀 Next steps:');
116
+ console.log(' 1. IMPORTANT: Go to chrome://extensions and click the "Reload" icon on the VenomBrowser extension');
117
+ console.log(' (This is required to apply the newly generated security token)');
118
+ console.log(' 2. Start using your AI agent (the bridge daemon will auto-start)');
119
+ console.log(' 3. venombrowser chat — start interactive chat');
120
+ console.log(' 4. venombrowser agent "task" run a one-shot task');
121
+ console.log();
122
+
123
+ // ── Auto-register with agents ────────────────────────────────────────────
124
+ try {
125
+ const { connect } = require('../src/connect');
126
+ connect();
127
+ } catch (err) {
128
+ console.log('ℹ️ Auto-registration skipped:', err.message);
129
+ // Still print manual instructions as fallback
130
+ const absPath = path.resolve(__dirname, 'venombrowser-mcp.js').replace(/\\/g, '/');
131
+ console.log('🔌 To connect Claude Code or other MCP agents, add this to your MCP config:');
132
+ console.log(JSON.stringify({
133
+ "venombrowser": {
134
+ command: "node",
135
+ args: [absPath],
136
+ env: {
137
+ BRIDGE_PORT: "10086"
138
+ }
139
+ }
140
+ }, null, 2));
141
+ console.log();
142
+ }
143
+ }
144
+
145
+ main().catch(console.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "venombrowser",
3
- "version": "4.1.1",
3
+ "version": "4.1.2",
4
4
  "description": "Agent-agnostic browser automation platform — give any AI agent eyes and hands in your real Chrome browser",
5
5
  "author": "TechVenom",
6
6
  "main": "src/bridge-server.js",
@@ -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
@@ -188,8 +188,9 @@ const TOOL_DEFINITIONS = [
188
188
  parameters: {
189
189
  type: 'object',
190
190
  properties: {
191
- selector: { type: 'string', description: 'CSS selector of the file input' },
192
- files: { type: 'array', items: { type: 'string' }, description: 'File paths to upload' }
191
+ selector: { type: 'string', description: '@e ref or CSS selector' },
192
+ files: { type: 'array', items: { type: 'string' }, description: 'Absolute paths to files' },
193
+ confirm: { type: 'boolean', description: 'Required: set to true to confirm this action' }
193
194
  },
194
195
  required: ['selector', 'files']
195
196
  }
@@ -233,8 +234,188 @@ const TOOL_DEFINITIONS = [
233
234
  function: {
234
235
  name: 'close_session',
235
236
  description: 'Close all tabs in the session.',
237
+ parameters: {
238
+ type: 'object',
239
+ properties: {
240
+ confirm: { type: 'boolean', description: 'Required: set to true to confirm closing all tabs' }
241
+ }
242
+ }
243
+ }
244
+ },
245
+ // ── Phase 1: Cheap Wins ──
246
+ {
247
+ type: 'function',
248
+ function: {
249
+ name: 'scroll_to',
250
+ description: 'Scroll an element into view. Use before clicking elements that may be off-screen.',
251
+ parameters: {
252
+ type: 'object',
253
+ properties: {
254
+ selector: { type: 'string', description: '@e ref or CSS selector of element to scroll to' }
255
+ },
256
+ required: ['selector']
257
+ }
258
+ }
259
+ },
260
+ {
261
+ type: 'function',
262
+ function: {
263
+ name: 'check_visibility',
264
+ description: 'Check if an element is visible, in viewport, and not obscured by overlays (cookie banners, modals, etc).',
265
+ parameters: {
266
+ type: 'object',
267
+ properties: {
268
+ selector: { type: 'string', description: '@e ref or CSS selector' }
269
+ },
270
+ required: ['selector']
271
+ }
272
+ }
273
+ },
274
+ {
275
+ type: 'function',
276
+ function: {
277
+ name: 'check_stale',
278
+ description: 'Check if an @e ref from a previous snapshot is still valid (element still in DOM).',
279
+ parameters: {
280
+ type: 'object',
281
+ properties: {
282
+ selector: { type: 'string', description: '@e ref (e.g. "@e5")' }
283
+ },
284
+ required: ['selector']
285
+ }
286
+ }
287
+ },
288
+ {
289
+ type: 'function',
290
+ function: {
291
+ name: 'double_click',
292
+ description: 'Double-click an element. Useful for text selection or opening items.',
293
+ parameters: {
294
+ type: 'object',
295
+ properties: {
296
+ selector: { type: 'string', description: '@e ref or CSS selector' }
297
+ },
298
+ required: ['selector']
299
+ }
300
+ }
301
+ },
302
+ {
303
+ type: 'function',
304
+ function: {
305
+ name: 'right_click',
306
+ description: 'Right-click an element to trigger context menu.',
307
+ parameters: {
308
+ type: 'object',
309
+ properties: {
310
+ selector: { type: 'string', description: '@e ref or CSS selector' }
311
+ },
312
+ required: ['selector']
313
+ }
314
+ }
315
+ },
316
+ {
317
+ type: 'function',
318
+ function: {
319
+ name: 'keyboard',
320
+ description: 'Press a key or key combination. Uses real hardware-level key events via CDP. Examples: key="Enter", key="a" modifiers=["Control"] for Ctrl+A.',
321
+ parameters: {
322
+ type: 'object',
323
+ properties: {
324
+ key: { type: 'string', description: 'Key name: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Space, F1-F12, or any letter/number.' },
325
+ modifiers: { type: 'array', items: { type: 'string', enum: ['Control', 'Alt', 'Shift', 'Meta'] }, description: 'Modifier keys to hold. Default none.' }
326
+ },
327
+ required: ['key']
328
+ }
329
+ }
330
+ },
331
+ {
332
+ type: 'function',
333
+ function: {
334
+ name: 'go_back',
335
+ description: 'Navigate back in browser history (like clicking the back button).',
236
336
  parameters: { type: 'object', properties: {} }
237
337
  }
338
+ },
339
+ {
340
+ type: 'function',
341
+ function: {
342
+ name: 'go_forward',
343
+ description: 'Navigate forward in browser history.',
344
+ parameters: { type: 'object', properties: {} }
345
+ }
346
+ },
347
+ {
348
+ type: 'function',
349
+ function: {
350
+ name: 'manage_cookies',
351
+ description: 'Read, set, or delete cookies for the current page domain.',
352
+ parameters: {
353
+ type: 'object',
354
+ properties: {
355
+ cmd: { type: 'string', enum: ['get', 'get_all', 'set', 'delete', 'clear'], description: 'Cookie operation' },
356
+ name: { type: 'string', description: 'Cookie name (required for get/set/delete)' },
357
+ value: { type: 'string', description: 'Cookie value (for set)' },
358
+ domain: { type: 'string', description: 'Cookie domain (optional, defaults to current domain)' },
359
+ confirm: { type: 'boolean', description: 'Required: true if cmd is delete or clear' }
360
+ },
361
+ required: ['cmd']
362
+ }
363
+ }
364
+ },
365
+ {
366
+ type: 'function',
367
+ function: {
368
+ name: 'manage_storage',
369
+ description: 'Read, set, or delete localStorage/sessionStorage entries for the current page.',
370
+ parameters: {
371
+ type: 'object',
372
+ properties: {
373
+ cmd: { type: 'string', enum: ['get', 'get_all', 'set', 'delete', 'clear', 'keys'], description: 'Storage operation' },
374
+ storage_type: { type: 'string', enum: ['local', 'session'], description: 'Which storage to use. Default "local".' },
375
+ key: { type: 'string', description: 'Storage key (required for get/set/delete)' },
376
+ value: { type: 'string', description: 'Value to store (for set). Objects will be JSON-stringified.' },
377
+ confirm: { type: 'boolean', description: 'Required: true if cmd is delete or clear' }
378
+ },
379
+ required: ['cmd']
380
+ }
381
+ }
382
+ },
383
+ // ── Lifecycle Management ──
384
+ {
385
+ type: 'function',
386
+ function: {
387
+ name: 'bridge_status',
388
+ description: 'Check the VenomBrowser bridge daemon status: whether the server is running, the extension is connected, uptime, and command count.',
389
+ parameters: { type: 'object', properties: {} }
390
+ }
391
+ },
392
+ {
393
+ type: 'function',
394
+ function: {
395
+ name: 'stop_server',
396
+ description: 'Stop the VenomBrowser bridge daemon completely. This releases the port and disconnects the extension. Use when you are completely done with browser automation and want to free resources.',
397
+ parameters: {
398
+ type: 'object',
399
+ properties: {
400
+ confirm: { type: 'boolean', description: 'Required: set to true to confirm stopping the server' }
401
+ },
402
+ required: ['confirm']
403
+ }
404
+ }
405
+ },
406
+ {
407
+ type: 'function',
408
+ function: {
409
+ name: 'restart_server',
410
+ description: 'Restart the VenomBrowser bridge daemon. Use this to fix connection issues — the daemon will shut down and auto-restart on your next browser command.',
411
+ parameters: {
412
+ type: 'object',
413
+ properties: {
414
+ confirm: { type: 'boolean', description: 'Required: set to true to confirm restarting the server' }
415
+ },
416
+ required: ['confirm']
417
+ }
418
+ }
238
419
  }
239
420
  ];
240
421
 
@@ -340,6 +521,88 @@ async function executeTool(bridge, name, args = {}) {
340
521
  return { closed: r.closed };
341
522
  }
342
523
 
524
+ // ── Phase 1: Cheap Wins ──
525
+ case 'scroll_to': {
526
+ const r = await bridge.scrollTo(args.selector);
527
+ return r;
528
+ }
529
+
530
+ case 'check_visibility': {
531
+ const r = await bridge.checkVisibility(args.selector);
532
+ return r;
533
+ }
534
+
535
+ case 'check_stale': {
536
+ const r = await bridge.checkStale(args.selector);
537
+ return r;
538
+ }
539
+
540
+ case 'double_click': {
541
+ const r = await bridge.doubleClick(args.selector);
542
+ return { success: true, tag: r.tag, text: r.text };
543
+ }
544
+
545
+ case 'right_click': {
546
+ const r = await bridge.rightClick(args.selector);
547
+ return { success: true, tag: r.tag, text: r.text };
548
+ }
549
+
550
+ case 'keyboard': {
551
+ const r = await bridge.keyboard(args.key, { modifiers: args.modifiers });
552
+ return r;
553
+ }
554
+
555
+ case 'go_back': {
556
+ const r = await bridge.goBack();
557
+ return r;
558
+ }
559
+
560
+ case 'go_forward': {
561
+ const r = await bridge.goForward();
562
+ return r;
563
+ }
564
+
565
+ case 'manage_cookies': {
566
+ const r = await bridge.manageCookies(args.cmd, { name: args.name, value: args.value, domain: args.domain });
567
+ return r;
568
+ }
569
+
570
+ case 'manage_storage': {
571
+ const r = await bridge.manageStorage(args.cmd, { storageType: args.storage_type, key: args.key, value: args.value });
572
+ return r;
573
+ }
574
+
575
+ // ── Lifecycle Management ──
576
+ case 'bridge_status': {
577
+ const r = await bridge.bridgeStatus();
578
+ return r;
579
+ }
580
+
581
+ case 'stop_server': {
582
+ try {
583
+ const r = await bridge.stopServer();
584
+ return { success: true, message: r.message || 'Server is shutting down.' };
585
+ } catch (e) {
586
+ // Server may close connection before responding — that's expected
587
+ if (e.message?.includes('ECONNRESET') || e.message?.includes('ECONNREFUSED') || e.message?.includes('socket hang up')) {
588
+ return { success: true, message: 'Server has shut down.' };
589
+ }
590
+ throw e;
591
+ }
592
+ }
593
+
594
+ case 'restart_server': {
595
+ try {
596
+ const r = await bridge.restartServer();
597
+ return { success: true, message: r.message || 'Server is restarting. Your next browser command will auto-spawn a new daemon.' };
598
+ } catch (e) {
599
+ if (e.message?.includes('ECONNRESET') || e.message?.includes('ECONNREFUSED') || e.message?.includes('socket hang up')) {
600
+ return { success: true, message: 'Server has shut down for restart. Your next browser command will auto-spawn a new daemon.' };
601
+ }
602
+ throw e;
603
+ }
604
+ }
605
+
343
606
  default:
344
607
  throw new Error(`Unknown tool: ${name}`);
345
608
  }