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.
@@ -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
+ }