# CDP WebSocket 代理(多 Agent 共享浏览器) > Chrome DevTools Protocol WebSocket 代理,支持多个 AI Agent 共享同一浏览器实例。依赖 ws 包:npm install ws --prefix ~/scripts ## Install Save as a script file and run: #!/usr/bin/env node /** * CDP Background Proxy v3 — 持久连接 + 多 Agent 隔离 * * 1. 持久 WebSocket 连接到 Chrome(弹窗只出现一次) * 2. 拦截 Target.activateTarget / Page.bringToFront(防抢焦点) * 3. 强制 createTarget background=true * 4. 请求 ID 重映射(防多客户端 ID 冲突) * 5. 事件按 sessionId 路由到对应客户端(防多 Agent 互扰) * 6. 自动重连 * * 依赖: npm install ws --prefix "$HOME/scripts" (ws 包需与本文件同目录层级) * 用法: node cdp-proxy.mjs [--port 9401] [--chrome-port 9222] */ import http from 'http'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { WebSocketServer, WebSocket } from 'ws'; const args = process.argv.slice(2); const getArg = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; }; const PROXY_PORT = parseInt(getArg('--port', '9401')); const CHROME_PORT = parseInt(getArg('--chrome-port', '9222')); const CHROME_HOST = getArg('--chrome-host', '127.0.0.1'); const DEVTOOLS_PORT_FILE = getArg('--devtools-port-file', path.join(os.homedir(), 'Library/Application Support/Google/Chrome/DevToolsActivePort')); // ═══════════════════════════════════════════ // 持久 Chrome 连接 // ═══════════════════════════════════════════ let chromeWs = null; let chromeReady = false; let reconnecting = false; const BLOCKED_METHODS = new Set([ 'Target.activateTarget', 'Page.bringToFront', ]); function safeSend(ws, data) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(typeof data === 'string' ? data : JSON.stringify(data)); } } function readDevToolsActivePort() { try { const content = fs.readFileSync(DEVTOOLS_PORT_FILE, 'utf-8').trim(); const lines = content.split('\n'); if (lines.length >= 2) { return { port: parseInt(lines[0]), wsPath: lines[1] }; } } catch (e) { /* ignore */ } return null; } function getChromeWsUrl() { const portInfo = readDevToolsActivePort(); if (portInfo) { return `ws://${CHROME_HOST}:${portInfo.port}${portInfo.wsPath}`; } return null; } // ═══════════════════════════════════════════ // 多客户端隔离 // ═══════════════════════════════════════════ // 全局自增 ID,保证唯一 let globalIdCounter = 1; // proxyId → { clientWs, originalId, method } const pendingRequests = new Map(); // sessionId → clientWs(哪个客户端 attach 了这个 session) const sessionOwners = new Map(); // clientWs → { tabs: Set, sessions: Set, proxyIds: Set } const clientState = new Map(); function getOrCreateState(clientWs) { if (!clientState.has(clientWs)) { clientState.set(clientWs, { tabs: new Set(), sessions: new Set(), proxyIds: new Set() }); } return clientState.get(clientWs); } // ═════════════════════���═════════════════════ // Chrome 连接管理 // ═══════════════════════════════════════════ function connectToChrome() { if (reconnecting) return; reconnecting = true; const wsUrl = getChromeWsUrl(); if (!wsUrl) { console.error('[Proxy] Chrome 未运行,5秒后重试...'); setTimeout(() => { reconnecting = false; connectToChrome(); }, 5000); return; } console.log(`[Proxy] 连接 Chrome: ${wsUrl}`); chromeWs = new WebSocket(wsUrl); chromeWs.on('open', () => { console.log('[Proxy] ✓ Chrome 已连接'); chromeReady = true; reconnecting = false; }); chromeWs.on('message', (rawData) => { try { const msg = JSON.parse(rawData.toString()); // ── 响应消息(有 id)→ 路由到发起者 ── if (msg.id !== undefined && pendingRequests.has(msg.id)) { const { clientWs, originalId, method } = pendingRequests.get(msg.id); pendingRequests.delete(msg.id); const state = clientState.get(clientWs); // 追踪 createTarget if (method === 'Target.createTarget' && msg.result?.targetId) { if (state) state.tabs.add(msg.result.targetId); console.log(`[Proxy] 新 tab: ${msg.result.targetId}`); } // 追踪 attachToTarget → 记录 session 归属 if (method === 'Target.attachToTarget' && msg.result?.sessionId) { const sid = msg.result.sessionId; sessionOwners.set(sid, clientWs); if (state) state.sessions.add(sid); console.log(`[Proxy] session ${sid.slice(0, 8)}... → 客户端`); } // 还原原始 ID msg.id = originalId; safeSend(clientWs, msg); return; } // ── 事件消息(无 id)→ 按 sessionId 路由 ── if (msg.method) { // 带 sessionId 的事件:发给对应客户端 if (msg.sessionId && sessionOwners.has(msg.sessionId)) { safeSend(sessionOwners.get(msg.sessionId), msg); return; } // Target 域的全局事件:按 targetId 路由或广播 if (msg.method.startsWith('Target.')) { const targetId = msg.params?.targetInfo?.targetId || msg.params?.targetId; if (targetId) { // 找到拥有这个 tab 的客户端 for (const [ws, state] of clientState) { if (state.tabs.has(targetId)) { safeSend(ws, msg); return; } } } // 找不到归属,广播 for (const [ws] of clientState) { safeSend(ws, msg); } return; } // 其他无 sessionId 的事件,广播 for (const [ws] of clientState) { safeSend(ws, msg); } return; } // 其他消息,广播 for (const [ws] of clientState) { safeSend(ws, rawData); } } catch (e) { for (const [ws] of clientState) { safeSend(ws, rawData); } } }); chromeWs.on('close', () => { console.log('[Proxy] Chrome 断开,5秒后重连...'); chromeReady = false; chromeWs = null; reconnecting = false; // 清理所有 session 归属(Chrome 重启后 session 失效) sessionOwners.clear(); setTimeout(() => connectToChrome(), 5000); }); chromeWs.on('error', (err) => { console.error('[Proxy] Chrome 错误:', err.message); chromeReady = false; chromeWs = null; reconnecting = false; setTimeout(() => connectToChrome(), 5000); }); } // ═══════════════════════════════════════════ // HTTP 端点 // ═══════════════════════════════════════════ function cdpRequest(method, params = {}) { return new Promise((resolve, reject) => { if (!chromeReady) return reject(new Error('Chrome not connected')); const proxyId = globalIdCounter++; const timeout = setTimeout(() => { pendingRequests.delete(proxyId); reject(new Error('timeout')); }, 5000); const fakeClient = { send: (data) => { clearTimeout(timeout); resolve(typeof data === 'string' ? JSON.parse(data) : data); }, readyState: WebSocket.OPEN, }; pendingRequests.set(proxyId, { clientWs: fakeClient, originalId: proxyId, method }); chromeWs.send(JSON.stringify({ id: proxyId, method, params })); }); } const server = http.createServer(async (req, res) => { try { if (req.url === '/json/version') { const portInfo = readDevToolsActivePort(); const wsUrl = portInfo ? `ws://${CHROME_HOST}:${PROXY_PORT}${portInfo.wsPath}` : `ws://${CHROME_HOST}:${PROXY_PORT}/devtools/browser/proxy`; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ Browser: 'Chrome (via CDP Proxy v3)', webSocketDebuggerUrl: wsUrl })); return; } if (req.url === '/json/list' || req.url === '/json') { if (!chromeReady) { res.writeHead(503); res.end('Chrome not connected'); return; } try { const result = await cdpRequest('Target.getTargets'); const targets = (result.result?.targetInfos || []).map(t => ({ ...t, webSocketDebuggerUrl: `ws://${CHROME_HOST}:${PROXY_PORT}/devtools/page/${t.targetId}`, })); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(targets)); } catch (e) { res.writeHead(500); res.end(e.message); } return; } if (req.url === '/json/new') { if (!chromeReady) { res.writeHead(503); res.end('Chrome not connected'); return; } try { const result = await cdpRequest('Target.createTarget', { url: 'about:blank', background: true }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result.result || {})); } catch (e) { res.writeHead(500); res.end(e.message); } return; } if (req.url === '/proxy/status') { const clientList = []; for (const [, state] of clientState) { clientList.push({ tabs: state.tabs.size, sessions: state.sessions.size }); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ chromeConnected: chromeReady, clients: clientState.size, sessions: sessionOwners.size, pendingRequests: pendingRequests.size, clientDetails: clientList, }, null, 2)); return; } res.writeHead(404); res.end('Not found'); } catch (e) { res.writeHead(500); res.end(e.message); } }); // ═══════════════════���═══════════════════════ // WebSocket 客户端处理 // ═══════════════════════════════════════════ const wss = new WebSocketServer({ server }); wss.on('connection', (clientWs, req) => { const state = getOrCreateState(clientWs); console.log(`[Proxy] 客户端连接 (共 ${clientState.size} 个)`); clientWs.on('message', (data) => { if (!chromeReady) { try { const msg = JSON.parse(data.toString()); if (msg.id !== undefined) { safeSend(clientWs, { id: msg.id, error: { code: -1, message: 'Chrome not connected' } }); } } catch (e) { /* ignore */ } return; } try { const msg = JSON.parse(data.toString()); // 拦截抢焦点命令 if (BLOCKED_METHODS.has(msg.method)) { console.log(`[Proxy] 拦截: ${msg.method}`); const reply = { id: msg.id, result: {} }; if (msg.sessionId) reply.sessionId = msg.sessionId; safeSend(clientWs, reply); return; } // 强制后台创建 tab if (msg.method === 'Target.createTarget') { if (!msg.params) msg.params = {}; msg.params.background = true; console.log(`[Proxy] 后台创建 tab: ${msg.params.url || 'about:blank'}`); } // ── ID 重映射 ── if (msg.id !== undefined) { const proxyId = globalIdCounter++; pendingRequests.set(proxyId, { clientWs, originalId: msg.id, method: msg.method, }); state.proxyIds.add(proxyId); msg.id = proxyId; } safeSend(chromeWs, msg); } catch (e) { safeSend(chromeWs, data); } }); const cleanup = () => { // 清理这个客户端的 session 归属 for (const sid of state.sessions) { sessionOwners.delete(sid); } // 清理未完成的请求 for (const pid of state.proxyIds) { pendingRequests.delete(pid); } clientState.delete(clientWs); console.log(`[Proxy] 客户端断开 (剩 ${clientState.size} 个, 释放 ${state.tabs.size} tab, ${state.sessions.size} session)`); }; clientWs.on('close', cleanup); clientWs.on('error', cleanup); }); // ═══════════════════════════════════════════ // 启动 // ═══════════════════════════════════════════ process.on('uncaughtException', (err) => { console.error('[Proxy] 异常(已恢复):', err.message); }); process.on('unhandledRejection', (reason) => { console.error('[Proxy] Promise 拒绝(已恢复):', reason); }); server.listen(PROXY_PORT, () => { console.log(` ╔══════════════════════════════════════════════════════╗ ║ CDP Background Proxy v3 — 持久连接 + 多Agent隔离 ║ ║ ║ ║ Proxy: http://127.0.0.1:${PROXY_PORT} ║ ║ ║ ║ ✓ 持久连接(弹窗只出现一次) ║ ║ ✓ 拦截 activateTarget / bringToFront ║ ║ ✓ 强制 createTarget background=true ║ ║ ✓ 请求 ID 重映射(防多客户端冲突) ║ ║ ✓ 事件按 sessionId 路由(防多 Agent 互扰) ║ ║ ✓ ���动重连 ║ ║ ║ ║ 状态: http://127.0.0.1:${PROXY_PORT}/proxy/status ║ ╚══════════════════════════════════════════════════════╝ `); connectToChrome(); }); --- Source: https://tokrepo.com/en/workflows/cdp-websocket-agent-e0f83e28 Author: henuwangkai