browserforce 1.0.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/README.md +293 -0
- package/bin.js +269 -0
- package/mcp/package.json +14 -0
- package/mcp/src/exec-engine.js +424 -0
- package/mcp/src/index.js +372 -0
- package/mcp/src/snapshot.js +197 -0
- package/package.json +52 -0
- package/relay/package.json +1 -0
- package/relay/src/index.js +847 -0
- package/skills/browserforce/SKILL.md +123 -0
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
const http = require('node:http');
|
|
2
|
+
const crypto = require('node:crypto');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
7
|
+
|
|
8
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PORT = 19222;
|
|
11
|
+
const COMMAND_TIMEOUT_MS = 30000;
|
|
12
|
+
const PING_INTERVAL_MS = 5000;
|
|
13
|
+
|
|
14
|
+
const BF_DIR = path.join(os.homedir(), '.browserforce');
|
|
15
|
+
const TOKEN_FILE = path.join(BF_DIR, 'auth-token');
|
|
16
|
+
const CDP_URL_FILE = path.join(BF_DIR, 'cdp-url');
|
|
17
|
+
|
|
18
|
+
// ─── Logging ─────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function ts() { return new Date().toTimeString().slice(0, 8); }
|
|
21
|
+
function log(...args) { console.log(`[${ts()}]`, ...args); }
|
|
22
|
+
function logErr(...args) { console.error(`[${ts()}]`, ...args); }
|
|
23
|
+
|
|
24
|
+
// ─── Token Persistence ──────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function getOrCreateAuthToken() {
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(BF_DIR, { recursive: true });
|
|
29
|
+
if (fs.existsSync(TOKEN_FILE)) {
|
|
30
|
+
const token = fs.readFileSync(TOKEN_FILE, 'utf8').trim();
|
|
31
|
+
if (token.length > 0) return token;
|
|
32
|
+
}
|
|
33
|
+
} catch { /* fall through to generate */ }
|
|
34
|
+
|
|
35
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
36
|
+
try { fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 }); } catch {}
|
|
37
|
+
return token;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeCdpUrlFile(cdpUrl) {
|
|
41
|
+
try {
|
|
42
|
+
fs.mkdirSync(BF_DIR, { recursive: true });
|
|
43
|
+
fs.writeFileSync(CDP_URL_FILE, cdpUrl, { mode: 0o600 });
|
|
44
|
+
} catch (e) {
|
|
45
|
+
logErr('[relay] Failed to write CDP URL file:', e.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── RelayServer ─────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const DEFAULT_BROWSER_CONTEXT_ID = 'bf-default-context';
|
|
52
|
+
|
|
53
|
+
// Commands Playwright sends automatically to every page during initialization.
|
|
54
|
+
// We intercept these on unattached tabs and return synthetic responses so
|
|
55
|
+
// chrome.debugger.attach() is never called until the AI actually uses the tab.
|
|
56
|
+
// This preserves dark mode and avoids the "controlled by automated software"
|
|
57
|
+
// bar appearing on every open tab.
|
|
58
|
+
const INIT_ONLY_METHODS = new Set([
|
|
59
|
+
// Runtime
|
|
60
|
+
'Runtime.enable', 'Runtime.disable', 'Runtime.runIfWaitingForDebugger',
|
|
61
|
+
'Runtime.addBinding',
|
|
62
|
+
// Page lifecycle + scripting
|
|
63
|
+
'Page.enable', 'Page.disable',
|
|
64
|
+
'Page.getFrameTree', // needs shaped response — see syntheticInitResponse()
|
|
65
|
+
'Page.setLifecycleEventsEnabled',
|
|
66
|
+
'Page.setInterceptFileChooserDialog', 'Page.setPrerenderingAllowed',
|
|
67
|
+
'Page.setBypassCSP',
|
|
68
|
+
'Page.addScriptToEvaluateOnNewDocument', // needs shaped response — returns { identifier }
|
|
69
|
+
'Page.removeScriptToEvaluateOnNewDocument',
|
|
70
|
+
'Page.createIsolatedWorld', // Playwright uses _sendMayFail — response ignored
|
|
71
|
+
'Page.setFontFamilies',
|
|
72
|
+
// Network / Fetch
|
|
73
|
+
'Fetch.enable', 'Fetch.disable',
|
|
74
|
+
'Network.enable', 'Network.disable', 'Network.setBypassServiceWorker',
|
|
75
|
+
'Network.setExtraHTTPHeaders', 'Network.setCacheDisabled',
|
|
76
|
+
// Target
|
|
77
|
+
'Target.setAutoAttach', 'Target.setDiscoverTargets',
|
|
78
|
+
// Logging
|
|
79
|
+
'Log.enable', 'Log.disable',
|
|
80
|
+
'Console.enable', 'Console.disable',
|
|
81
|
+
// CSS / DOM
|
|
82
|
+
'CSS.enable', 'CSS.disable',
|
|
83
|
+
'DOM.enable', 'DOM.disable',
|
|
84
|
+
'Inspector.enable',
|
|
85
|
+
// Workers
|
|
86
|
+
'ServiceWorker.enable', 'ServiceWorker.disable',
|
|
87
|
+
// Debugger
|
|
88
|
+
'Debugger.enable', 'Debugger.disable',
|
|
89
|
+
// Security
|
|
90
|
+
'Security.enable', 'Security.disable',
|
|
91
|
+
'Security.setIgnoreCertificateErrors',
|
|
92
|
+
// Performance
|
|
93
|
+
'Performance.enable', 'Performance.disable',
|
|
94
|
+
// Emulation — init and optional overrides Playwright sets up front
|
|
95
|
+
'Emulation.setEmulatedMedia', 'Emulation.setDeviceMetricsOverride',
|
|
96
|
+
'Emulation.setTouchEmulationEnabled', 'Emulation.setDefaultBackgroundColorOverride',
|
|
97
|
+
'Emulation.setAutomationOverride', 'Emulation.setFocusEmulationEnabled',
|
|
98
|
+
'Emulation.setScriptExecutionDisabled',
|
|
99
|
+
'Emulation.setLocaleOverride', 'Emulation.setTimezoneOverride',
|
|
100
|
+
'Emulation.setUserAgentOverride', 'Emulation.setGeolocationOverride',
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
// Return a well-shaped synthetic response for init commands that need more than {}.
|
|
104
|
+
function syntheticInitResponse(method, target) {
|
|
105
|
+
switch (method) {
|
|
106
|
+
case 'Page.getFrameTree':
|
|
107
|
+
// Playwright reads frameTree.frame.id and frameTree.frame.url
|
|
108
|
+
return {
|
|
109
|
+
frameTree: {
|
|
110
|
+
frame: {
|
|
111
|
+
id: target.targetId || `frame-${target.tabId}`,
|
|
112
|
+
url: target.targetInfo?.url || 'about:blank',
|
|
113
|
+
securityOrigin: '',
|
|
114
|
+
mimeType: 'text/html',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
case 'Page.addScriptToEvaluateOnNewDocument':
|
|
119
|
+
// Playwright stores the identifier to remove the script later
|
|
120
|
+
return { identifier: `bf-stub-${target.tabId}-${Date.now()}` };
|
|
121
|
+
default:
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
class RelayServer {
|
|
127
|
+
constructor(port = DEFAULT_PORT) {
|
|
128
|
+
this.port = port;
|
|
129
|
+
this.authToken = getOrCreateAuthToken();
|
|
130
|
+
|
|
131
|
+
// Extension connection (single slot)
|
|
132
|
+
this.ext = null;
|
|
133
|
+
this.extMsgId = 0;
|
|
134
|
+
this.extPending = new Map(); // id -> { resolve, reject, timer }
|
|
135
|
+
this.pingTimer = null;
|
|
136
|
+
|
|
137
|
+
// CDP clients
|
|
138
|
+
this.clients = new Set();
|
|
139
|
+
|
|
140
|
+
// Target tracking
|
|
141
|
+
this.targets = new Map(); // sessionId -> { tabId, targetId, targetInfo }
|
|
142
|
+
this.tabToSession = new Map(); // tabId -> sessionId
|
|
143
|
+
this.childSessions = new Map(); // childSessionId -> { tabId, parentSessionId }
|
|
144
|
+
this.sessionCounter = 0;
|
|
145
|
+
|
|
146
|
+
// State
|
|
147
|
+
this.autoAttachEnabled = false;
|
|
148
|
+
this.autoAttachParams = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
start({ writeCdpUrl = true } = {}) {
|
|
152
|
+
const server = http.createServer((req, res) => this._handleHttp(req, res));
|
|
153
|
+
|
|
154
|
+
this.extWss = new WebSocketServer({ noServer: true });
|
|
155
|
+
this.cdpWss = new WebSocketServer({ noServer: true });
|
|
156
|
+
|
|
157
|
+
server.on('upgrade', (req, socket, head) => this._handleUpgrade(req, socket, head));
|
|
158
|
+
this.extWss.on('connection', (ws) => this._onExtConnect(ws));
|
|
159
|
+
this.cdpWss.on('connection', (ws) => this._onCdpConnect(ws));
|
|
160
|
+
|
|
161
|
+
server.listen(this.port, '127.0.0.1', () => {
|
|
162
|
+
const cdpUrl = `ws://127.0.0.1:${this.port}/cdp?token=${this.authToken}`;
|
|
163
|
+
if (writeCdpUrl) writeCdpUrlFile(cdpUrl);
|
|
164
|
+
console.log('');
|
|
165
|
+
console.log(' BrowserForce');
|
|
166
|
+
console.log(' ────────────────────────────────────────');
|
|
167
|
+
console.log(` Status: http://127.0.0.1:${this.port}/`);
|
|
168
|
+
console.log(` CDP: ${cdpUrl}`);
|
|
169
|
+
console.log(` Config: ${BF_DIR}/`);
|
|
170
|
+
console.log(' ────────────────────────────────────────');
|
|
171
|
+
console.log('');
|
|
172
|
+
console.log(' Waiting for extension to connect...');
|
|
173
|
+
console.log('');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
this.server = server;
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── HTTP ────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
async _handleHttp(req, res) {
|
|
183
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
184
|
+
res.setHeader('Content-Type', 'application/json');
|
|
185
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
186
|
+
|
|
187
|
+
if (url.pathname === '/') {
|
|
188
|
+
res.end(JSON.stringify({
|
|
189
|
+
status: 'ok',
|
|
190
|
+
extension: !!this.ext,
|
|
191
|
+
targets: this.targets.size,
|
|
192
|
+
clients: this.clients.size,
|
|
193
|
+
}));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (url.pathname === '/json/version') {
|
|
198
|
+
res.end(JSON.stringify({
|
|
199
|
+
Browser: 'BrowserForce/1.0',
|
|
200
|
+
'Protocol-Version': '1.3',
|
|
201
|
+
webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}/cdp?token=${this.authToken}`,
|
|
202
|
+
}));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (url.pathname === '/json/list' || url.pathname === '/json') {
|
|
207
|
+
const list = [...this.targets.values()].map((t) => ({
|
|
208
|
+
id: t.targetId,
|
|
209
|
+
title: t.targetInfo?.title || '',
|
|
210
|
+
url: t.targetInfo?.url || '',
|
|
211
|
+
type: 'page',
|
|
212
|
+
webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}/cdp?token=${this.authToken}`,
|
|
213
|
+
}));
|
|
214
|
+
res.end(JSON.stringify(list));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (url.pathname === '/restrictions') {
|
|
219
|
+
if (!this.ext) {
|
|
220
|
+
res.end(JSON.stringify({ mode: 'auto', lockUrl: false, noNewTabs: false, readOnly: false, instructions: '' }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const restrictions = await this._sendToExt('getRestrictions');
|
|
225
|
+
res.end(JSON.stringify(restrictions));
|
|
226
|
+
} catch (err) {
|
|
227
|
+
res.statusCode = 502;
|
|
228
|
+
res.end(JSON.stringify({ error: 'Extension not responding' }));
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
res.statusCode = 404;
|
|
234
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── WebSocket Upgrade ───────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
_handleUpgrade(req, socket, head) {
|
|
240
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
241
|
+
|
|
242
|
+
if (url.pathname === '/extension') {
|
|
243
|
+
// Validate origin
|
|
244
|
+
const origin = req.headers.origin || '';
|
|
245
|
+
if (!origin.startsWith('chrome-extension://')) {
|
|
246
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
247
|
+
socket.destroy();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Single slot
|
|
251
|
+
if (this.ext) {
|
|
252
|
+
socket.write('HTTP/1.1 409 Conflict\r\n\r\n');
|
|
253
|
+
socket.destroy();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
this.extWss.handleUpgrade(req, socket, head, (ws) => {
|
|
257
|
+
this.extWss.emit('connection', ws, req);
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (url.pathname === '/cdp') {
|
|
263
|
+
const token = url.searchParams.get('token');
|
|
264
|
+
if (token !== this.authToken) {
|
|
265
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
266
|
+
socket.destroy();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.cdpWss.handleUpgrade(req, socket, head, (ws) => {
|
|
270
|
+
this.cdpWss.emit('connection', ws, req);
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
276
|
+
socket.destroy();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── Extension Connection ────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
_onExtConnect(ws) {
|
|
282
|
+
log('[relay] Extension connected');
|
|
283
|
+
this.ext = { ws };
|
|
284
|
+
|
|
285
|
+
ws.on('message', (data) => {
|
|
286
|
+
try {
|
|
287
|
+
this._handleExtMessage(JSON.parse(data.toString()));
|
|
288
|
+
} catch (e) {
|
|
289
|
+
logErr('[relay] Extension message parse error:', e.message);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
ws.on('close', () => {
|
|
294
|
+
log('[relay] Extension disconnected');
|
|
295
|
+
this._cleanupExtension();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
ws.on('error', (err) => {
|
|
299
|
+
logErr('[relay] Extension WS error:', err.message);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Ping keepalive
|
|
303
|
+
this.pingTimer = setInterval(() => {
|
|
304
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
305
|
+
ws.send(JSON.stringify({ method: 'ping' }));
|
|
306
|
+
}
|
|
307
|
+
}, PING_INTERVAL_MS);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
_cleanupExtension() {
|
|
311
|
+
this.ext = null;
|
|
312
|
+
clearInterval(this.pingTimer);
|
|
313
|
+
this.pingTimer = null;
|
|
314
|
+
|
|
315
|
+
// Reject all pending extension commands
|
|
316
|
+
for (const [id, pending] of this.extPending) {
|
|
317
|
+
clearTimeout(pending.timer);
|
|
318
|
+
pending.reject(new Error('Extension disconnected'));
|
|
319
|
+
}
|
|
320
|
+
this.extPending.clear();
|
|
321
|
+
|
|
322
|
+
// Notify CDP clients: all targets gone
|
|
323
|
+
for (const [sessionId, target] of this.targets) {
|
|
324
|
+
this._broadcastCdp({
|
|
325
|
+
method: 'Target.detachedFromTarget',
|
|
326
|
+
params: { sessionId, targetId: target.targetId },
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
this.targets.clear();
|
|
330
|
+
this.tabToSession.clear();
|
|
331
|
+
this.childSessions.clear();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_handleExtMessage(msg) {
|
|
335
|
+
// Response to a command we sent
|
|
336
|
+
if (msg.id !== undefined && this.extPending.has(msg.id)) {
|
|
337
|
+
const pending = this.extPending.get(msg.id);
|
|
338
|
+
this.extPending.delete(msg.id);
|
|
339
|
+
clearTimeout(pending.timer);
|
|
340
|
+
|
|
341
|
+
if (msg.error) {
|
|
342
|
+
pending.reject(new Error(msg.error));
|
|
343
|
+
} else {
|
|
344
|
+
pending.resolve(msg.result);
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Events from extension
|
|
350
|
+
if (msg.method === 'pong') return;
|
|
351
|
+
|
|
352
|
+
if (msg.method === 'cdpEvent') {
|
|
353
|
+
this._handleCdpEventFromExt(msg.params);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (msg.method === 'tabDetached') {
|
|
358
|
+
this._handleTabDetached(msg.params);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (msg.method === 'tabUpdated') {
|
|
363
|
+
this._handleTabUpdated(msg.params);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (msg.method === 'manualTabAttached') {
|
|
368
|
+
const { tabId, sessionId, targetId, targetInfo } = msg.params;
|
|
369
|
+
const relaySessionId = `bf-session-${++this.sessionCounter}`;
|
|
370
|
+
this.targets.set(relaySessionId, {
|
|
371
|
+
tabId,
|
|
372
|
+
targetId: targetId || `bf-target-${tabId}`,
|
|
373
|
+
targetInfo: targetInfo || { url: '', title: '' },
|
|
374
|
+
debuggerAttached: true,
|
|
375
|
+
});
|
|
376
|
+
this.tabToSession.set(tabId, relaySessionId);
|
|
377
|
+
|
|
378
|
+
// Notify connected CDP clients
|
|
379
|
+
for (const client of this.clients) {
|
|
380
|
+
if (client.readyState === 1) { // WebSocket.OPEN
|
|
381
|
+
this._sendAttachedEvent(client, relaySessionId, this.targets.get(relaySessionId));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Send command to extension, returns promise */
|
|
389
|
+
_sendToExt(method, params = {}) {
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
391
|
+
if (!this.ext || this.ext.ws.readyState !== WebSocket.OPEN) {
|
|
392
|
+
reject(new Error('Extension not connected'));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const id = ++this.extMsgId;
|
|
397
|
+
const timer = setTimeout(() => {
|
|
398
|
+
this.extPending.delete(id);
|
|
399
|
+
reject(new Error(`Extension command '${method}' timed out after ${COMMAND_TIMEOUT_MS}ms`));
|
|
400
|
+
}, COMMAND_TIMEOUT_MS);
|
|
401
|
+
|
|
402
|
+
this.extPending.set(id, { resolve, reject, timer });
|
|
403
|
+
this.ext.ws.send(JSON.stringify({ id, method, params }));
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ─── CDP Events from Extension ──────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
_handleCdpEventFromExt({ tabId, method, params, childSessionId }) {
|
|
410
|
+
const sessionId = this.tabToSession.get(tabId);
|
|
411
|
+
if (!sessionId) return;
|
|
412
|
+
|
|
413
|
+
// Track child sessions (iframes / OOPIFs)
|
|
414
|
+
if (method === 'Target.attachedToTarget' && params?.sessionId) {
|
|
415
|
+
this.childSessions.set(params.sessionId, { tabId, parentSessionId: sessionId });
|
|
416
|
+
}
|
|
417
|
+
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
|
|
418
|
+
this.childSessions.delete(params.sessionId);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Route: child session events go under the parent's sessionId
|
|
422
|
+
const outerSessionId = childSessionId
|
|
423
|
+
? (this.childSessions.get(childSessionId)?.parentSessionId || sessionId)
|
|
424
|
+
: sessionId;
|
|
425
|
+
|
|
426
|
+
this._broadcastCdp({ method, params, sessionId: outerSessionId });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
_handleTabDetached({ tabId, reason }) {
|
|
430
|
+
const sessionId = this.tabToSession.get(tabId);
|
|
431
|
+
if (!sessionId) return;
|
|
432
|
+
|
|
433
|
+
const target = this.targets.get(sessionId);
|
|
434
|
+
|
|
435
|
+
// Clean up child sessions for this tab
|
|
436
|
+
for (const [childId, child] of this.childSessions) {
|
|
437
|
+
if (child.tabId === tabId) this.childSessions.delete(childId);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this.targets.delete(sessionId);
|
|
441
|
+
this.tabToSession.delete(tabId);
|
|
442
|
+
|
|
443
|
+
this._broadcastCdp({
|
|
444
|
+
method: 'Target.detachedFromTarget',
|
|
445
|
+
params: { sessionId, targetId: target?.targetId },
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
log(`[relay] Tab ${tabId} detached (${reason})`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
_handleTabUpdated({ tabId, url, title }) {
|
|
452
|
+
const sessionId = this.tabToSession.get(tabId);
|
|
453
|
+
if (!sessionId) return;
|
|
454
|
+
|
|
455
|
+
const target = this.targets.get(sessionId);
|
|
456
|
+
if (!target) return;
|
|
457
|
+
|
|
458
|
+
if (url) target.targetInfo.url = url;
|
|
459
|
+
if (title) target.targetInfo.title = title;
|
|
460
|
+
|
|
461
|
+
this._broadcastCdp({
|
|
462
|
+
method: 'Target.targetInfoChanged',
|
|
463
|
+
params: {
|
|
464
|
+
targetInfo: {
|
|
465
|
+
targetId: target.targetId,
|
|
466
|
+
type: 'page',
|
|
467
|
+
title: target.targetInfo.title || '',
|
|
468
|
+
url: target.targetInfo.url || '',
|
|
469
|
+
attached: true,
|
|
470
|
+
browserContextId: DEFAULT_BROWSER_CONTEXT_ID,
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ─── CDP Client Connection ──────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
_onCdpConnect(ws) {
|
|
479
|
+
log('[relay] CDP client connected');
|
|
480
|
+
this.clients.add(ws);
|
|
481
|
+
|
|
482
|
+
ws.on('message', (data) => {
|
|
483
|
+
try {
|
|
484
|
+
const msg = JSON.parse(data.toString());
|
|
485
|
+
this._handleCdpClientMessage(ws, msg);
|
|
486
|
+
} catch (e) {
|
|
487
|
+
logErr('[relay] CDP client message error:', e.message);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
ws.on('close', () => {
|
|
492
|
+
log('[relay] CDP client disconnected');
|
|
493
|
+
this.clients.delete(ws);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
ws.on('error', (err) => {
|
|
497
|
+
logErr('[relay] CDP client WS error:', err.message);
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async _handleCdpClientMessage(ws, msg) {
|
|
502
|
+
const { id, method, params, sessionId } = msg;
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
let result;
|
|
506
|
+
if (sessionId) {
|
|
507
|
+
result = await this._forwardToTab(sessionId, method, params);
|
|
508
|
+
} else {
|
|
509
|
+
result = await this._handleBrowserCommand(ws, id, method, params);
|
|
510
|
+
}
|
|
511
|
+
if (result !== undefined) {
|
|
512
|
+
const response = { id, result };
|
|
513
|
+
if (sessionId) response.sessionId = sessionId;
|
|
514
|
+
ws.send(JSON.stringify(response));
|
|
515
|
+
}
|
|
516
|
+
} catch (err) {
|
|
517
|
+
const response = {
|
|
518
|
+
id,
|
|
519
|
+
error: { code: -32000, message: err.message },
|
|
520
|
+
};
|
|
521
|
+
if (sessionId) response.sessionId = sessionId;
|
|
522
|
+
ws.send(JSON.stringify(response));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async _handleBrowserCommand(ws, msgId, method, params) {
|
|
527
|
+
switch (method) {
|
|
528
|
+
case 'Browser.getVersion':
|
|
529
|
+
return {
|
|
530
|
+
protocolVersion: '1.3',
|
|
531
|
+
product: 'BrowserForce/1.0',
|
|
532
|
+
userAgent: 'BrowserForce',
|
|
533
|
+
jsVersion: '',
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
case 'Target.setDiscoverTargets':
|
|
537
|
+
// Emit targetCreated for all known targets
|
|
538
|
+
for (const [, target] of this.targets) {
|
|
539
|
+
ws.send(JSON.stringify({
|
|
540
|
+
method: 'Target.targetCreated',
|
|
541
|
+
params: {
|
|
542
|
+
targetInfo: {
|
|
543
|
+
targetId: target.targetId,
|
|
544
|
+
type: 'page',
|
|
545
|
+
title: target.targetInfo?.title || '',
|
|
546
|
+
url: target.targetInfo?.url || '',
|
|
547
|
+
attached: true,
|
|
548
|
+
browserContextId: DEFAULT_BROWSER_CONTEXT_ID,
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
return {};
|
|
554
|
+
|
|
555
|
+
case 'Target.setAutoAttach':
|
|
556
|
+
this.autoAttachEnabled = true;
|
|
557
|
+
this.autoAttachParams = params;
|
|
558
|
+
// Respond immediately, then attach tabs asynchronously
|
|
559
|
+
ws.send(JSON.stringify({ id: msgId, result: {} }));
|
|
560
|
+
this._autoAttachAllTabs(ws).catch((e) => {
|
|
561
|
+
logErr('[relay] Auto-attach error:', e.message);
|
|
562
|
+
});
|
|
563
|
+
return undefined; // Already sent response
|
|
564
|
+
|
|
565
|
+
case 'Target.getTargets':
|
|
566
|
+
return {
|
|
567
|
+
targetInfos: [...this.targets.values()].map((t) => ({
|
|
568
|
+
targetId: t.targetId,
|
|
569
|
+
type: 'page',
|
|
570
|
+
title: t.targetInfo?.title || '',
|
|
571
|
+
url: t.targetInfo?.url || '',
|
|
572
|
+
attached: true,
|
|
573
|
+
browserContextId: DEFAULT_BROWSER_CONTEXT_ID,
|
|
574
|
+
})),
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
case 'Target.getTargetInfo': {
|
|
578
|
+
if (params?.targetId) {
|
|
579
|
+
for (const target of this.targets.values()) {
|
|
580
|
+
if (target.targetId === params.targetId) {
|
|
581
|
+
return {
|
|
582
|
+
targetInfo: {
|
|
583
|
+
targetId: target.targetId,
|
|
584
|
+
type: 'page',
|
|
585
|
+
title: target.targetInfo?.title || '',
|
|
586
|
+
url: target.targetInfo?.url || '',
|
|
587
|
+
attached: true,
|
|
588
|
+
browserContextId: DEFAULT_BROWSER_CONTEXT_ID,
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// No targetId or unrecognized targetId → return browser target
|
|
595
|
+
return {
|
|
596
|
+
targetInfo: {
|
|
597
|
+
targetId: params?.targetId || 'browser',
|
|
598
|
+
type: 'browser',
|
|
599
|
+
title: '',
|
|
600
|
+
url: '',
|
|
601
|
+
attached: true,
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
case 'Target.attachToTarget': {
|
|
607
|
+
// Find already-attached target by targetId
|
|
608
|
+
for (const [sessionId, target] of this.targets) {
|
|
609
|
+
if (target.targetId === params.targetId) {
|
|
610
|
+
return { sessionId };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
throw new Error(`Target ${params.targetId} not found or not attached`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
case 'Target.createTarget':
|
|
617
|
+
return this._createTarget(ws, params);
|
|
618
|
+
|
|
619
|
+
case 'Target.closeTarget':
|
|
620
|
+
return this._closeTarget(params);
|
|
621
|
+
|
|
622
|
+
case 'Browser.setDownloadBehavior':
|
|
623
|
+
return {};
|
|
624
|
+
|
|
625
|
+
case 'Target.getBrowserContexts':
|
|
626
|
+
return { browserContextIds: [DEFAULT_BROWSER_CONTEXT_ID] };
|
|
627
|
+
|
|
628
|
+
default:
|
|
629
|
+
// Unknown browser-level commands get a no-op response
|
|
630
|
+
return {};
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ─── Tab Management ─────────────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
async _autoAttachAllTabs(ws) {
|
|
637
|
+
if (!this.ext) return;
|
|
638
|
+
|
|
639
|
+
const { tabs } = await this._sendToExt('listTabs');
|
|
640
|
+
log(`[relay] Browser has ${tabs.length} tab(s) — agent creates own tabs via context.newPage()`);
|
|
641
|
+
|
|
642
|
+
// Re-emit attachedToTarget for already-tracked targets (reconnection case)
|
|
643
|
+
for (const [sessionId, target] of this.targets) {
|
|
644
|
+
this._sendAttachedEvent(ws, sessionId, target);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Do NOT auto-attach existing browser tabs. Lazy attachment creates broken
|
|
648
|
+
// Playwright Page objects because INIT_ONLY_METHODS fakes Runtime.enable,
|
|
649
|
+
// so Playwright never gets executionContextCreated events → page.evaluate()
|
|
650
|
+
// deadlocks. Instead, the agent creates tabs via context.newPage() which
|
|
651
|
+
// eagerly attaches the debugger via _createTarget.
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Attach debugger to a tab on demand (lazy). Race-safe via attachPromise. */
|
|
655
|
+
async _ensureDebuggerAttached(target, sessionId) {
|
|
656
|
+
if (target.debuggerAttached) return;
|
|
657
|
+
|
|
658
|
+
// If another command already triggered attachment, wait for it
|
|
659
|
+
if (target.attachPromise) {
|
|
660
|
+
await target.attachPromise;
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
target.attachPromise = (async () => {
|
|
665
|
+
log(`[relay] Lazy-attaching debugger to tab ${target.tabId} (triggered by: ${target._triggerMethod || '?'}) ${target.targetInfo?.url}`);
|
|
666
|
+
const result = await this._sendToExt('attachTab', {
|
|
667
|
+
tabId: target.tabId,
|
|
668
|
+
sessionId,
|
|
669
|
+
});
|
|
670
|
+
if (result.targetId) target.targetId = result.targetId;
|
|
671
|
+
if (result.targetInfo) target.targetInfo = result.targetInfo;
|
|
672
|
+
target.debuggerAttached = true;
|
|
673
|
+
target.attachPromise = null;
|
|
674
|
+
})();
|
|
675
|
+
|
|
676
|
+
await target.attachPromise;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
_sendAttachedEvent(ws, sessionId, target) {
|
|
680
|
+
ws.send(JSON.stringify({
|
|
681
|
+
method: 'Target.attachedToTarget',
|
|
682
|
+
params: {
|
|
683
|
+
sessionId,
|
|
684
|
+
targetInfo: {
|
|
685
|
+
targetId: target.targetId,
|
|
686
|
+
type: 'page',
|
|
687
|
+
title: target.targetInfo?.title || '',
|
|
688
|
+
url: target.targetInfo?.url || '',
|
|
689
|
+
attached: true,
|
|
690
|
+
browserContextId: DEFAULT_BROWSER_CONTEXT_ID,
|
|
691
|
+
},
|
|
692
|
+
waitingForDebugger: false,
|
|
693
|
+
},
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async _createTarget(ws, params) {
|
|
698
|
+
const sessionId = `s${++this.sessionCounter}`;
|
|
699
|
+
const result = await this._sendToExt('createTab', {
|
|
700
|
+
url: params.url || 'about:blank',
|
|
701
|
+
sessionId,
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
this.targets.set(sessionId, {
|
|
705
|
+
tabId: result.tabId,
|
|
706
|
+
targetId: result.targetId,
|
|
707
|
+
targetInfo: result.targetInfo,
|
|
708
|
+
debuggerAttached: true, // createTab attaches debugger immediately
|
|
709
|
+
attachPromise: null,
|
|
710
|
+
});
|
|
711
|
+
this.tabToSession.set(result.tabId, sessionId);
|
|
712
|
+
|
|
713
|
+
// Broadcast attachedToTarget to ALL clients
|
|
714
|
+
this._broadcastCdp({
|
|
715
|
+
method: 'Target.attachedToTarget',
|
|
716
|
+
params: {
|
|
717
|
+
sessionId,
|
|
718
|
+
targetInfo: {
|
|
719
|
+
targetId: result.targetId,
|
|
720
|
+
type: 'page',
|
|
721
|
+
title: result.targetInfo?.title || '',
|
|
722
|
+
url: result.targetInfo?.url || params.url || 'about:blank',
|
|
723
|
+
attached: true,
|
|
724
|
+
browserContextId: DEFAULT_BROWSER_CONTEXT_ID,
|
|
725
|
+
},
|
|
726
|
+
waitingForDebugger: false,
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
return { targetId: result.targetId };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async _closeTarget(params) {
|
|
734
|
+
let tabId;
|
|
735
|
+
let sessionId;
|
|
736
|
+
|
|
737
|
+
for (const [sid, target] of this.targets) {
|
|
738
|
+
if (target.targetId === params.targetId) {
|
|
739
|
+
tabId = target.tabId;
|
|
740
|
+
sessionId = sid;
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (!tabId) throw new Error('Target not found');
|
|
746
|
+
|
|
747
|
+
await this._sendToExt('closeTab', { tabId });
|
|
748
|
+
|
|
749
|
+
// Clean up child sessions
|
|
750
|
+
for (const [childId, child] of this.childSessions) {
|
|
751
|
+
if (child.tabId === tabId) this.childSessions.delete(childId);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
this.targets.delete(sessionId);
|
|
755
|
+
this.tabToSession.delete(tabId);
|
|
756
|
+
|
|
757
|
+
this._broadcastCdp({
|
|
758
|
+
method: 'Target.detachedFromTarget',
|
|
759
|
+
params: { sessionId, targetId: params.targetId },
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
return { success: true };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ─── CDP Command Forwarding ─────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
async _forwardToTab(sessionId, method, params) {
|
|
768
|
+
// Main session
|
|
769
|
+
const target = this.targets.get(sessionId);
|
|
770
|
+
if (target) {
|
|
771
|
+
if (!target.debuggerAttached) {
|
|
772
|
+
// Playwright sends init-only commands to every page it learns about.
|
|
773
|
+
// Return synthetic {} so we never attach the debugger until the AI
|
|
774
|
+
// actually uses the tab — preserves dark mode and avoids the automation
|
|
775
|
+
// info bar on every open tab.
|
|
776
|
+
if (INIT_ONLY_METHODS.has(method)) {
|
|
777
|
+
return syntheticInitResponse(method, target);
|
|
778
|
+
}
|
|
779
|
+
target._triggerMethod = method;
|
|
780
|
+
await this._ensureDebuggerAttached(target, sessionId);
|
|
781
|
+
}
|
|
782
|
+
return this._sendToExt('cdpCommand', {
|
|
783
|
+
tabId: target.tabId,
|
|
784
|
+
method,
|
|
785
|
+
params: params || {},
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Child session (iframe / OOPIF)
|
|
790
|
+
const child = this.childSessions.get(sessionId);
|
|
791
|
+
if (child) {
|
|
792
|
+
// Ensure parent tab's debugger is attached
|
|
793
|
+
const parentSessionId = this.tabToSession.get(child.tabId);
|
|
794
|
+
const parentTarget = parentSessionId && this.targets.get(parentSessionId);
|
|
795
|
+
if (parentTarget && !parentTarget.debuggerAttached) {
|
|
796
|
+
await this._ensureDebuggerAttached(parentTarget, parentSessionId);
|
|
797
|
+
}
|
|
798
|
+
return this._sendToExt('cdpCommand', {
|
|
799
|
+
tabId: child.tabId,
|
|
800
|
+
method,
|
|
801
|
+
params: params || {},
|
|
802
|
+
childSessionId: sessionId,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
throw new Error(`Session '${sessionId}' not found`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ─── Broadcast ──────────────────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
_broadcastCdp(msg) {
|
|
812
|
+
const data = JSON.stringify(msg);
|
|
813
|
+
for (const client of this.clients) {
|
|
814
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
815
|
+
client.send(data);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
stop() {
|
|
821
|
+
clearInterval(this.pingTimer);
|
|
822
|
+
this.server?.close();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
827
|
+
|
|
828
|
+
module.exports = { RelayServer, DEFAULT_PORT, BF_DIR, TOKEN_FILE, CDP_URL_FILE };
|
|
829
|
+
|
|
830
|
+
// ─── CLI Entry ───────────────────────────────────────────────────────────────
|
|
831
|
+
|
|
832
|
+
if (require.main === module) {
|
|
833
|
+
const port = parseInt(process.env.RELAY_PORT || process.argv[2] || DEFAULT_PORT, 10);
|
|
834
|
+
const relay = new RelayServer(port);
|
|
835
|
+
relay.start();
|
|
836
|
+
|
|
837
|
+
process.on('SIGINT', () => {
|
|
838
|
+
log('\n[relay] Shutting down...');
|
|
839
|
+
relay.stop();
|
|
840
|
+
process.exit(0);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
process.on('SIGTERM', () => {
|
|
844
|
+
relay.stop();
|
|
845
|
+
process.exit(0);
|
|
846
|
+
});
|
|
847
|
+
}
|