venombrowser 4.1.0
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/.env.example +26 -0
- package/bin/venombrowser-agent.js +182 -0
- package/bin/venombrowser-chat.js +300 -0
- package/bin/venombrowser-cli.js +194 -0
- package/bin/venombrowser-mcp.js +30 -0
- package/bin/venombrowser-server.js +11 -0
- package/bin/venombrowser-setup.js +183 -0
- package/package.json +65 -0
- package/src/bridge-client.js +207 -0
- package/src/bridge-server.js +476 -0
- package/src/connect.js +220 -0
- package/src/mcp-server.js +101 -0
- package/src/providers/index.js +47 -0
- package/src/providers/ollama.js +108 -0
- package/src/providers/openrouter.js +158 -0
- package/src/tools/browser-tools.js +364 -0
- package/src/uninstall.js +246 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VenomBrowser Bridge Server — Kimi-style single-port daemon
|
|
3
|
+
*
|
|
4
|
+
* v4.1.0 — Stability rewrite:
|
|
5
|
+
* - Global error handlers (no crashes)
|
|
6
|
+
* - Protected WebSocket send (no crash on disconnect mid-command)
|
|
7
|
+
* - Proper keepalive with timeout detection
|
|
8
|
+
* - HTTP server error handling
|
|
9
|
+
* - Graceful degradation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
require('dotenv').config();
|
|
13
|
+
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const WebSocket = require('ws');
|
|
18
|
+
const { v4: uuidv4 } = require('uuid');
|
|
19
|
+
|
|
20
|
+
const PORT = parseInt(process.env.BRIDGE_PORT || '10086', 10);
|
|
21
|
+
const WS_PORT = parseInt(process.env.WS_PORT || String(PORT + 1), 10);
|
|
22
|
+
const WS_PING_INTERVAL = 15000;
|
|
23
|
+
const WS_PING_TIMEOUT = 10000;
|
|
24
|
+
const CMD_TIMEOUT = 30000;
|
|
25
|
+
const SCREENSHOT_DIR = path.join(__dirname, '..', '..', 'screenshots');
|
|
26
|
+
|
|
27
|
+
class VenomBrowserBridgeServer {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.extensionWs = null;
|
|
30
|
+
this.pending = new Map();
|
|
31
|
+
this.sessions = new Map();
|
|
32
|
+
this.startTime = Date.now();
|
|
33
|
+
this.commandCount = 0;
|
|
34
|
+
this.lastCommand = null;
|
|
35
|
+
this.lastCommandAt = null;
|
|
36
|
+
this.extensionInfo = null;
|
|
37
|
+
this.currentTab = null;
|
|
38
|
+
this.pingTimer = null;
|
|
39
|
+
this.lastPong = Date.now();
|
|
40
|
+
|
|
41
|
+
this.startHTTP();
|
|
42
|
+
this.startWS();
|
|
43
|
+
this.startPingLoop();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Safe WebSocket send ─────────────────────────────────────────────────
|
|
47
|
+
safeSend(ws, data) {
|
|
48
|
+
try {
|
|
49
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
50
|
+
ws.send(typeof data === 'string' ? data : JSON.stringify(data));
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error('[Bridge] safeSend error:', e.message);
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── HTTP Server ─────────────────────────────────────────────────────────
|
|
60
|
+
startHTTP() {
|
|
61
|
+
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');
|
|
65
|
+
|
|
66
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
67
|
+
|
|
68
|
+
const send = (status, data) => {
|
|
69
|
+
try {
|
|
70
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
71
|
+
res.end(JSON.stringify(data));
|
|
72
|
+
} catch (_) {}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
76
|
+
return send(200, {
|
|
77
|
+
server: 'ok',
|
|
78
|
+
extension: this.isConnected() ? 'connected' : 'disconnected',
|
|
79
|
+
port: PORT
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (req.method === 'GET' && req.url === '/status') {
|
|
84
|
+
return send(200, {
|
|
85
|
+
success: true,
|
|
86
|
+
connected: this.isConnected(),
|
|
87
|
+
uptime_seconds: ((Date.now() - this.startTime) / 1000).toFixed(1),
|
|
88
|
+
commands_executed: this.commandCount,
|
|
89
|
+
pending_commands: this.pending.size,
|
|
90
|
+
active_sessions: this.sessions.size,
|
|
91
|
+
last_command: this.lastCommand,
|
|
92
|
+
last_command_at: this.lastCommandAt,
|
|
93
|
+
extension: this.extensionInfo
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (req.method === 'POST' && req.url === '/command') {
|
|
98
|
+
let body = '';
|
|
99
|
+
req.on('data', chunk => { body += chunk; });
|
|
100
|
+
req.on('end', async () => {
|
|
101
|
+
let request;
|
|
102
|
+
try {
|
|
103
|
+
request = JSON.parse(body);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return send(400, { success: false, error: 'Invalid JSON' });
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const result = await this.handleCommand(request);
|
|
109
|
+
send(200, { success: true, ...result });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
const code = e.message.includes('not connected') ? 503
|
|
112
|
+
: e.message.includes('timed out') ? 408
|
|
113
|
+
: 500;
|
|
114
|
+
send(code, { success: false, error: e.message });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
send(404, { success: false, error: 'Not found. Use POST /command' });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
this.httpServer.on('error', (e) => {
|
|
124
|
+
console.error('[Bridge] HTTP server error:', e.message);
|
|
125
|
+
if (e.code === 'EADDRINUSE') {
|
|
126
|
+
console.error(`[Bridge] Port ${PORT} is already in use. Kill the other process or change BRIDGE_PORT in .env`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
this.httpServer.listen(PORT, '127.0.0.1', () => {
|
|
132
|
+
console.log(`[Bridge] HTTP on http://127.0.0.1:${PORT}`);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Command handler ─────────────────────────────────────────────────────
|
|
137
|
+
async handleCommand(request) {
|
|
138
|
+
const { action, args = {}, session = 'default', tabId } = request;
|
|
139
|
+
|
|
140
|
+
if (!action) throw new Error('action is required');
|
|
141
|
+
|
|
142
|
+
if (!this.sessions.has(session)) {
|
|
143
|
+
this.sessions.set(session, { tabs: new Set(), startTime: Date.now(), commandCount: 0 });
|
|
144
|
+
}
|
|
145
|
+
this.sessions.get(session).commandCount++;
|
|
146
|
+
|
|
147
|
+
if (action === 'health') return { service: 'venombrowser-bridge' };
|
|
148
|
+
|
|
149
|
+
if (action === 'status') {
|
|
150
|
+
return {
|
|
151
|
+
connected: this.isConnected(),
|
|
152
|
+
uptime_seconds: ((Date.now() - this.startTime) / 1000).toFixed(1),
|
|
153
|
+
commands_executed: this.commandCount,
|
|
154
|
+
pending_commands: this.pending.size,
|
|
155
|
+
active_sessions: this.sessions.size
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!this.isConnected()) throw new Error('Extension not connected');
|
|
160
|
+
|
|
161
|
+
return this.executeAction(action, args, session, tabId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Action router ───────────────────────────────────────────────────────
|
|
165
|
+
async executeAction(action, args, session, tabId) {
|
|
166
|
+
const send = (cmd, params = {}, tid = null, timeout = CMD_TIMEOUT) =>
|
|
167
|
+
this.sendToExtension(cmd, params, tid, timeout);
|
|
168
|
+
|
|
169
|
+
switch (action) {
|
|
170
|
+
case 'navigate': {
|
|
171
|
+
const { url, newTab = false } = args;
|
|
172
|
+
if (!url) throw new Error('url is required');
|
|
173
|
+
if (newTab) {
|
|
174
|
+
const r = await send('NEW_TAB', { url, active: true });
|
|
175
|
+
if (r.result?.tabId) this.sessions.get(session)?.tabs.add(r.result.tabId);
|
|
176
|
+
return { url: r.result?.url ?? url, tabId: r.result?.tabId };
|
|
177
|
+
}
|
|
178
|
+
const r = await send('NAVIGATE', { url, waitForLoad: true }, tabId);
|
|
179
|
+
if (r.result?.tabId) {
|
|
180
|
+
this.currentTab = r.result.tabId;
|
|
181
|
+
this.sessions.get(session)?.tabs.add(r.result.tabId);
|
|
182
|
+
}
|
|
183
|
+
return { url: r.result?.url ?? url, tabId: r.result?.tabId, title: r.result?.title };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'find_tab': {
|
|
187
|
+
const { url, active = false } = args;
|
|
188
|
+
if (active) {
|
|
189
|
+
const r = await send('GET_ACTIVE_TAB');
|
|
190
|
+
return { success: true, url: r.result?.url, tabId: r.result?.tabId, borrowed: true };
|
|
191
|
+
}
|
|
192
|
+
const r = await send('LIST_TABS');
|
|
193
|
+
const match = (r.result || []).find(t => t.url?.includes(url));
|
|
194
|
+
if (match) { this.currentTab = match.id; return { success: true, url: match.url, tabId: match.id }; }
|
|
195
|
+
throw new Error(`No tab matching ${url}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case 'list_tabs': {
|
|
199
|
+
const r = await send('LIST_TABS');
|
|
200
|
+
return { tabs: (r.result || []).map(t => ({ tabId: t.id, url: t.url, title: t.title, active: t.active })) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case 'close_tab': {
|
|
204
|
+
const tid = tabId || this.currentTab;
|
|
205
|
+
if (!tid) throw new Error('No tab to close');
|
|
206
|
+
await send('CLOSE_TAB', {}, tid);
|
|
207
|
+
this.sessions.get(session)?.tabs.delete(tid);
|
|
208
|
+
if (this.currentTab === tid) this.currentTab = null;
|
|
209
|
+
return { closed: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case 'close_session': {
|
|
213
|
+
const tabs = this.sessions.get(session)?.tabs || new Set();
|
|
214
|
+
let closed = 0;
|
|
215
|
+
for (const t of tabs) { try { await send('CLOSE_TAB', {}, t); closed++; } catch (_) {} }
|
|
216
|
+
tabs.clear();
|
|
217
|
+
this.sessions.delete(session);
|
|
218
|
+
return { closed };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
case 'snapshot': {
|
|
222
|
+
const r = await send('SNAPSHOT', {}, tabId || this.currentTab);
|
|
223
|
+
const res = r.result || {};
|
|
224
|
+
return { url: res.url, title: res.title, refCount: res.refCount, elements: res.elements };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
case 'click': {
|
|
228
|
+
const r = await send('CLICK', { selector: args.selector }, tabId || this.currentTab);
|
|
229
|
+
return { success: true, tag: r.result?.tag, text: r.result?.text };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case 'fill': {
|
|
233
|
+
const r = await send('TYPE', { selector: args.selector, text: args.value, clear: true }, tabId || this.currentTab);
|
|
234
|
+
return { success: true, typed: r.result?.typed, length: r.result?.length };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case 'type': {
|
|
238
|
+
const r = await send('TYPE', { selector: args.selector, text: args.text, clear: args.clear, pressEnter: args.pressEnter }, tabId || this.currentTab);
|
|
239
|
+
return { success: true, typed: r.result?.typed, length: r.result?.length };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case 'submit_form': {
|
|
243
|
+
const r = await send('SUBMIT_FORM', { selector: args.selector }, tabId || this.currentTab);
|
|
244
|
+
return { success: true, submitted: r.result?.submitted, action: r.result?.action, method: r.result?.method };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case 'evaluate': {
|
|
248
|
+
const r = await send('EVALUATE', { code: args.code }, tabId || this.currentTab, 30000);
|
|
249
|
+
return { type: r.result?.type, value: r.result?.result };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case 'cdp': {
|
|
253
|
+
const r = await send('CDP', { method: args.method, params: args.params }, tabId || this.currentTab, 30000);
|
|
254
|
+
return r.result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
case 'screenshot': {
|
|
258
|
+
const r = await send('SCREENSHOT', { format: args.format, quality: args.quality }, tabId || this.currentTab);
|
|
259
|
+
// Save base64 to disk, return file path
|
|
260
|
+
if (r.result?.base64) {
|
|
261
|
+
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
262
|
+
const filename = `screenshot-${Date.now()}.${r.result.format || 'png'}`;
|
|
263
|
+
const filepath = path.join(SCREENSHOT_DIR, filename);
|
|
264
|
+
fs.writeFileSync(filepath, Buffer.from(r.result.base64, 'base64'));
|
|
265
|
+
return { format: r.result.format, path: filepath, filename, sizeBytes: r.result.sizeBytes, mimeType: r.result.mimeType };
|
|
266
|
+
}
|
|
267
|
+
return { format: r.result?.format, path: r.result?.path, sizeBytes: r.result?.sizeBytes, mimeType: r.result?.mimeType };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
case 'network': {
|
|
271
|
+
const r = await send('NETWORK', { cmd: args.cmd, filter: args.filter, requestId: args.requestId }, tabId || this.currentTab);
|
|
272
|
+
return r.result;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case 'upload': {
|
|
276
|
+
const r = await send('UPLOAD', { selector: args.selector, files: args.files }, tabId || this.currentTab);
|
|
277
|
+
return { success: true, fileCount: r.result?.fileCount };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case 'save_as_pdf': {
|
|
281
|
+
const r = await send('SAVE_AS_PDF', args, tabId || this.currentTab, 60000);
|
|
282
|
+
if (r.result?.base64) {
|
|
283
|
+
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
284
|
+
const filename = `page-${Date.now()}.pdf`;
|
|
285
|
+
const filepath = path.join(SCREENSHOT_DIR, filename);
|
|
286
|
+
fs.writeFileSync(filepath, Buffer.from(r.result.base64, 'base64'));
|
|
287
|
+
return { path: filepath, sizeBytes: r.result.sizeBytes, mimeType: r.result.mimeType, pageTitle: r.result.pageTitle };
|
|
288
|
+
}
|
|
289
|
+
return { path: r.result?.path, sizeBytes: r.result?.sizeBytes, mimeType: r.result?.mimeType, pageTitle: r.result?.pageTitle };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
case 'scroll': {
|
|
293
|
+
const r = await send('SCROLL', { direction: args.direction || 'down', amount: args.amount || 500 }, tabId || this.currentTab);
|
|
294
|
+
return r.result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
default:
|
|
298
|
+
throw new Error(`Unknown action: ${action}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── WebSocket server ────────────────────────────────────────────────────
|
|
303
|
+
startWS() {
|
|
304
|
+
this.wss = new WebSocket.Server({ port: WS_PORT }, () => {
|
|
305
|
+
console.log(`[Bridge] WebSocket on ws://localhost:${WS_PORT}`);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
this.wss.on('error', (e) => {
|
|
309
|
+
console.error('[Bridge] WS server error:', e.message);
|
|
310
|
+
if (e.code === 'EADDRINUSE') {
|
|
311
|
+
console.error(`[Bridge] WS port ${WS_PORT} in use. Change WS_PORT in .env`);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
this.wss.on('connection', (ws, req) => {
|
|
316
|
+
const addr = req.socket.remoteAddress || 'unknown';
|
|
317
|
+
console.log(`[Bridge] Extension connected from ${addr}`);
|
|
318
|
+
|
|
319
|
+
// Replace previous connection
|
|
320
|
+
if (this.extensionWs && this.extensionWs.readyState === WebSocket.OPEN) {
|
|
321
|
+
console.log('[Bridge] Replacing previous extension');
|
|
322
|
+
try { this.extensionWs.close(1000); } catch (_) {}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
this.extensionWs = ws;
|
|
326
|
+
this.extensionInfo = { connectedAt: Date.now(), address: addr };
|
|
327
|
+
this.lastPong = Date.now();
|
|
328
|
+
|
|
329
|
+
ws.on('message', (data) => {
|
|
330
|
+
try {
|
|
331
|
+
const msg = JSON.parse(data.toString());
|
|
332
|
+
this.onExtMessage(ws, msg);
|
|
333
|
+
} catch (_) {}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
ws.on('close', (code, reason) => {
|
|
337
|
+
console.log(`[Bridge] Extension disconnected (code=${code})`);
|
|
338
|
+
if (this.extensionWs === ws) {
|
|
339
|
+
this.extensionWs = null;
|
|
340
|
+
this.extensionInfo = null;
|
|
341
|
+
this.rejectAll('Extension disconnected');
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
ws.on('error', (err) => {
|
|
346
|
+
console.error('[Bridge] Extension socket error:', err.message);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Welcome
|
|
350
|
+
this.safeSend(ws, { type: 'welcome', daemon: 'venombrowser-bridge', version: '4.1.0' });
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
onExtMessage(ws, msg) {
|
|
355
|
+
// Keepalive
|
|
356
|
+
if (msg.type === 'ping') {
|
|
357
|
+
this.lastPong = Date.now();
|
|
358
|
+
this.safeSend(ws, { type: 'pong', timestamp: Date.now() });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (msg.type === 'pong') {
|
|
362
|
+
this.lastPong = Date.now();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (msg.type === 'hello') {
|
|
366
|
+
console.log(`[Bridge] Extension handshake: ${msg.client} v${msg.version}`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Command response
|
|
371
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
372
|
+
const entry = this.pending.get(msg.id);
|
|
373
|
+
clearTimeout(entry.timer);
|
|
374
|
+
this.pending.delete(msg.id);
|
|
375
|
+
this.commandCount++;
|
|
376
|
+
this.lastCommand = msg.id;
|
|
377
|
+
this.lastCommandAt = new Date().toISOString();
|
|
378
|
+
if (msg.status === 'success') {
|
|
379
|
+
entry.resolve(msg);
|
|
380
|
+
} else {
|
|
381
|
+
entry.reject(new Error(msg.error || 'Command failed'));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ─── Keepalive ping loop ─────────────────────────────────────────────────
|
|
387
|
+
startPingLoop() {
|
|
388
|
+
this.pingTimer = setInterval(() => {
|
|
389
|
+
if (!this.extensionWs || this.extensionWs.readyState !== WebSocket.OPEN) return;
|
|
390
|
+
|
|
391
|
+
// Check if extension is dead (no pong in timeout period)
|
|
392
|
+
if (Date.now() - this.lastPong > WS_PING_TIMEOUT + WS_PING_INTERVAL) {
|
|
393
|
+
console.warn('[Bridge] Extension timed out — closing');
|
|
394
|
+
try { this.extensionWs.close(4001, 'Ping timeout'); } catch (_) {}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
this.safeSend(this.extensionWs, { type: 'ping', timestamp: Date.now() });
|
|
399
|
+
}, WS_PING_INTERVAL);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ─── 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'));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const id = uuidv4();
|
|
409
|
+
const normalizedTabId = tabId == null ? null : Number(tabId) || null;
|
|
410
|
+
const msg = { id, command, params, tabId: normalizedTabId, timeout: timeoutMs };
|
|
411
|
+
|
|
412
|
+
return new Promise((resolve, reject) => {
|
|
413
|
+
const timer = setTimeout(() => {
|
|
414
|
+
this.pending.delete(id);
|
|
415
|
+
reject(new Error(`Command ${command} timed out after ${timeoutMs}ms`));
|
|
416
|
+
}, timeoutMs);
|
|
417
|
+
|
|
418
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
419
|
+
|
|
420
|
+
if (!this.safeSend(this.extensionWs, msg)) {
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
this.pending.delete(id);
|
|
423
|
+
reject(new Error('Failed to send command to extension'));
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
rejectAll(reason) {
|
|
429
|
+
for (const { reject, timer } of this.pending.values()) {
|
|
430
|
+
clearTimeout(timer);
|
|
431
|
+
reject(new Error(reason));
|
|
432
|
+
}
|
|
433
|
+
this.pending.clear();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
isConnected() {
|
|
437
|
+
return this.extensionWs?.readyState === WebSocket.OPEN;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
shutdown() {
|
|
441
|
+
console.log('\n[Bridge] Shutting down...');
|
|
442
|
+
clearInterval(this.pingTimer);
|
|
443
|
+
this.rejectAll('Server shutting down');
|
|
444
|
+
try { this.extensionWs?.close(1000); } catch (_) {}
|
|
445
|
+
try { this.wss?.close(); } catch (_) {}
|
|
446
|
+
try {
|
|
447
|
+
this.httpServer?.close(() => {
|
|
448
|
+
console.log('[Bridge] Stopped.');
|
|
449
|
+
process.exit(0);
|
|
450
|
+
});
|
|
451
|
+
} catch (_) {}
|
|
452
|
+
setTimeout(() => process.exit(0), 2000);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ─── Global error handlers (prevent crashes) ─────────────────────────────
|
|
457
|
+
process.on('uncaughtException', (e) => {
|
|
458
|
+
console.error('[Bridge] UNCAUGHT EXCEPTION:', e.message);
|
|
459
|
+
// Don't exit — keep running
|
|
460
|
+
});
|
|
461
|
+
process.on('unhandledRejection', (reason) => {
|
|
462
|
+
console.error('[Bridge] UNHANDLED REJECTION:', reason);
|
|
463
|
+
// Don't exit — keep running
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ─── Start ────────────────────────────────────────────────────────────────
|
|
467
|
+
console.log('🚀 Starting VenomBrowser Bridge Server v4.1.0...');
|
|
468
|
+
const server = new VenomBrowserBridgeServer();
|
|
469
|
+
console.log(`📡 WebSocket : ws://localhost:${WS_PORT}`);
|
|
470
|
+
console.log(`🌐 HTTP API : http://127.0.0.1:${PORT}/command`);
|
|
471
|
+
console.log(`📋 Status : http://127.0.0.1:${PORT}/status`);
|
|
472
|
+
console.log('\nLoad extension from: ./extension/');
|
|
473
|
+
console.log('Press Ctrl+C to stop.\n');
|
|
474
|
+
|
|
475
|
+
process.on('SIGINT', () => server.shutdown());
|
|
476
|
+
process.on('SIGTERM', () => server.shutdown());
|
package/src/connect.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VenomBrowser Connect — Auto-register MCP server with known agents
|
|
3
|
+
*
|
|
4
|
+
* Detects existing agent config files (Claude Desktop, OpenCode, Cursor)
|
|
5
|
+
* and merges a "venombrowser" MCP server entry into each without
|
|
6
|
+
* clobbering any existing entries.
|
|
7
|
+
*
|
|
8
|
+
* Uses jsonc-parser for reading/writing JSONC files, preserving comments
|
|
9
|
+
* and formatting in the user's config.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const { connect } = require('./connect');
|
|
13
|
+
* await connect(); // auto-detect and register
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const jsonc = require('jsonc-parser');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the MCP server entry object for Claude Desktop / Cursor format.
|
|
23
|
+
*/
|
|
24
|
+
function buildMcpEntry() {
|
|
25
|
+
const mcpPath = path.resolve(__dirname, '..', 'bin', 'venombrowser-mcp.js');
|
|
26
|
+
return {
|
|
27
|
+
command: 'node',
|
|
28
|
+
args: [mcpPath.replace(/\\/g, '/')],
|
|
29
|
+
env: { BRIDGE_PORT: '10086' }
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the MCP server entry object for OpenCode format.
|
|
35
|
+
* OpenCode uses: { type: "local", command: [...], environment: {...} }
|
|
36
|
+
*/
|
|
37
|
+
function buildOpenCodeMcpEntry() {
|
|
38
|
+
const mcpPath = path.resolve(__dirname, '..', 'bin', 'venombrowser-mcp.js');
|
|
39
|
+
return {
|
|
40
|
+
type: 'local',
|
|
41
|
+
command: ['node', mcpPath.replace(/\\/g, '/')],
|
|
42
|
+
environment: { BRIDGE_PORT: '10086' }
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get the list of known agent config file paths to check.
|
|
48
|
+
* Returns an array of { name, path, format } objects.
|
|
49
|
+
* format is "mcpServers" for Claude Desktop/Cursor, "mcp" for OpenCode.
|
|
50
|
+
*/
|
|
51
|
+
function getAgentConfigPaths() {
|
|
52
|
+
const home = os.homedir();
|
|
53
|
+
const configs = [];
|
|
54
|
+
|
|
55
|
+
// Claude Desktop
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
const appdata = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
58
|
+
configs.push({
|
|
59
|
+
name: 'Claude Desktop',
|
|
60
|
+
path: path.join(appdata, 'Claude', 'claude_desktop_config.json'),
|
|
61
|
+
format: 'mcpServers'
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
configs.push({
|
|
65
|
+
name: 'Claude Desktop',
|
|
66
|
+
path: path.join(home, '.config', 'claude', 'claude_desktop_config.json'),
|
|
67
|
+
format: 'mcpServers'
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Cursor
|
|
72
|
+
configs.push({
|
|
73
|
+
name: 'Cursor',
|
|
74
|
+
path: path.join(home, '.cursor', 'mcp.json'),
|
|
75
|
+
format: 'mcpServers'
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// OpenCode
|
|
79
|
+
configs.push({
|
|
80
|
+
name: 'OpenCode',
|
|
81
|
+
path: path.join(home, '.config', 'opencode', 'opencode.jsonc'),
|
|
82
|
+
format: 'mcp'
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return configs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Merge the venombrowser entry into a config file's mcpServers (Claude Desktop / Cursor).
|
|
90
|
+
* Uses jsonc-parser to preserve comments and formatting.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} filePath - Absolute path to the config file
|
|
93
|
+
* @returns {{ merged: boolean, existed: boolean }} result
|
|
94
|
+
*/
|
|
95
|
+
function mergeIntoConfig(filePath) {
|
|
96
|
+
let content;
|
|
97
|
+
try {
|
|
98
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
99
|
+
} catch {
|
|
100
|
+
return { merged: false, existed: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let config;
|
|
104
|
+
try {
|
|
105
|
+
config = jsonc.parse(content);
|
|
106
|
+
} catch {
|
|
107
|
+
console.warn(` ⚠️ Skipping ${filePath} — invalid JSON`);
|
|
108
|
+
return { merged: false, existed: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Ensure mcpServers key exists
|
|
112
|
+
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
|
|
113
|
+
config.mcpServers = {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check if already registered
|
|
117
|
+
if (config.mcpServers['venombrowser']) {
|
|
118
|
+
return { merged: false, existed: true, alreadyRegistered: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Merge in the new entry — does NOT clobber existing entries
|
|
122
|
+
config.mcpServers['venombrowser'] = buildMcpEntry();
|
|
123
|
+
|
|
124
|
+
// Use jsonc-parser to write back, preserving comments and formatting
|
|
125
|
+
const edits = jsonc.modify(content, ['mcpServers', 'venombrowser'], buildMcpEntry(), { formattingOptions: { insertSpaces: true, tabSize: 2 } });
|
|
126
|
+
const updated = jsonc.applyEdits(content, edits);
|
|
127
|
+
fs.writeFileSync(filePath, updated, 'utf8');
|
|
128
|
+
return { merged: true, existed: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Merge the venombrowser entry into OpenCode's config file.
|
|
133
|
+
* OpenCode uses "mcp" key (not "mcpServers") and a different entry format.
|
|
134
|
+
* Preserves "$schema" and any existing "mcp" entries.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} filePath - Absolute path to the config file
|
|
137
|
+
* @returns {{ merged: boolean, existed: boolean }} result
|
|
138
|
+
*/
|
|
139
|
+
function mergeIntoOpenCodeConfig(filePath) {
|
|
140
|
+
let content;
|
|
141
|
+
try {
|
|
142
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
143
|
+
} catch {
|
|
144
|
+
return { merged: false, existed: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let config;
|
|
148
|
+
try {
|
|
149
|
+
config = jsonc.parse(content);
|
|
150
|
+
} catch {
|
|
151
|
+
console.warn(` ⚠️ Skipping ${filePath} — invalid JSON`);
|
|
152
|
+
return { merged: false, existed: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Ensure mcp key exists
|
|
156
|
+
if (!config.mcp || typeof config.mcp !== 'object') {
|
|
157
|
+
config.mcp = {};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check if already registered
|
|
161
|
+
if (config.mcp['venombrowser']) {
|
|
162
|
+
return { merged: false, existed: true, alreadyRegistered: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Use jsonc-parser to write back, preserving comments and formatting
|
|
166
|
+
const entry = buildOpenCodeMcpEntry();
|
|
167
|
+
const edits = jsonc.modify(content, ['mcp', 'venombrowser'], entry, { formattingOptions: { insertSpaces: true, tabSize: 2 } });
|
|
168
|
+
const updated = jsonc.applyEdits(content, edits);
|
|
169
|
+
fs.writeFileSync(filePath, updated, 'utf8');
|
|
170
|
+
return { merged: true, existed: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run the auto-registration across all known agent configs.
|
|
175
|
+
* @returns {string[]} List of agent names that were successfully registered.
|
|
176
|
+
*/
|
|
177
|
+
function connect() {
|
|
178
|
+
const entry = buildMcpEntry();
|
|
179
|
+
console.log('🔌 VenomBrowser Connect — Auto-registering MCP server with agents');
|
|
180
|
+
console.log('═'.repeat(55));
|
|
181
|
+
console.log(` MCP entry: node ${entry.args[0]}`);
|
|
182
|
+
console.log();
|
|
183
|
+
|
|
184
|
+
const configs = getAgentConfigPaths();
|
|
185
|
+
const registered = [];
|
|
186
|
+
|
|
187
|
+
for (const { name, path: configPath, format } of configs) {
|
|
188
|
+
if (!fs.existsSync(configPath)) {
|
|
189
|
+
console.log(` ⏭️ ${name}: config not found, skipping`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = format === 'mcp'
|
|
194
|
+
? mergeIntoOpenCodeConfig(configPath)
|
|
195
|
+
: mergeIntoConfig(configPath);
|
|
196
|
+
|
|
197
|
+
if (result.alreadyRegistered) {
|
|
198
|
+
console.log(` ✅ ${name}: already registered`);
|
|
199
|
+
registered.push(name);
|
|
200
|
+
} else if (result.merged) {
|
|
201
|
+
console.log(` ✅ ${name}: registered successfully`);
|
|
202
|
+
registered.push(name);
|
|
203
|
+
} else if (result.existed) {
|
|
204
|
+
console.log(` ⚠️ ${name}: skipped (could not merge)`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log();
|
|
209
|
+
if (registered.length > 0) {
|
|
210
|
+
console.log(`🎉 Registered with: ${registered.join(', ')}`);
|
|
211
|
+
} else {
|
|
212
|
+
console.log('ℹ️ No agent configs found. You can manually add VenomBrowser to your agent\'s MCP config:');
|
|
213
|
+
console.log(JSON.stringify({ 'venombrowser': entry }, null, 2));
|
|
214
|
+
}
|
|
215
|
+
console.log();
|
|
216
|
+
|
|
217
|
+
return registered;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { connect, buildMcpEntry, buildOpenCodeMcpEntry, mergeIntoConfig, mergeIntoOpenCodeConfig, getAgentConfigPaths };
|