diff --git a/README.md b/README.md index 022cc9f..9b6dd6b 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,12 @@ AI Agent 原本的联网能力(WebSearch、WebFetch)缺少调度策略和浏 --- -## v2.5.2 能力 +## 当前能力 | 能力 | 说明 | |------|------| | 联网工具自动选择 | WebSearch / WebFetch / curl / Jina / CDP,按场景自主判断,可任意组合 | +| CDP Extension Transport | 可选安装本地浏览器扩展,通过 `chrome.debugger` 传递 CDP 命令,日常使用减少 remote-debugging 授权弹窗 | | CDP Proxy 浏览器操作 | 直连用户日常浏览器(Chrome / Edge / Chromium 系),天然携带登录态,支持动态页面、交互操作、视频截帧 | | 三种点击方式 | `/click`(JS click)、`/clickAt`(CDP 真实鼠标事件)、`/setFiles`(文件上传) | | 本地浏览器书签/历史检索 | `find-url.mjs` 跨 Chrome / Edge 查询公网搜不到的目标(内部系统)或用户访问过的页面,支持关键词/时间窗/访问频度排序 | @@ -43,7 +44,8 @@ AI Agent 原本的联网能力(WebSearch、WebFetch)缺少调度策略和浏 | 站点经验积累 | 按域名存储操作经验(URL 模式、平台特征、已知陷阱),跨 session 复用 | | 媒体提取 | 从 DOM 直取图片/视频 URL,或对视频任意时间点截帧分析 | -**v2.5.2 更新:** +**近期更新:** +- **可选 CDP 扩展传输** — 新增 `extension/` 与 `scripts/check-cdp.mjs`,一次性 Load unpacked 后优先通过扩展连接本地 `3456` API;原 remote-debugging CDP 仍作为 fallback - **Microsoft Edge 支持** — CDP Proxy 不再绑定 Chrome,新增 Edge 适配(及 Chromium、Chrome Canary 等 Chromium 系,通过同一套自动发现机制接入)。在 `edge://inspect/#remote-debugging` 勾选 "Allow remote debugging for this browser instance" 即可 - **浏览器偏好持久化** — 新增 `config.env`(gitignored,首次运行从模板创建),通过 `WEB_ACCESS_BROWSER` 固定默认浏览器;多浏览器同时开启 toggle 时 Agent 会询问偏好。也支持单次覆盖 `--browser ` - **不擅自降级** — 偏好/指定的浏览器没启动或没开 toggle 时硬错并给出明确处理步骤,不会悄悄连到别的浏览器;proxy 首次成功连接后 pin 住浏览器 id,避免运行中漂移 @@ -111,7 +113,15 @@ git clone https://github.com/eze-is/web-access ~/.claude/skills/web-access ## 前置配置(CDP 模式) -CDP 模式需要 **Node.js 22+** 和浏览器(Chrome / Edge)开启远程调试: +CDP 模式需要 **Node.js 22+**。优先使用可选浏览器扩展传输: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/check-cdp.mjs" +``` + +如果提示扩展未连接,打开 `chrome://extensions` 或 `edge://extensions`,启用 Developer mode,Load unpacked 选择本仓库的 `extension/` 目录。安装并授权后,扩展会连接本地 `3456` API。 + +如果扩展不可用,再使用 remote-debugging fallback: 1. 在你想用的浏览器地址栏打开对应 inspect 页面: - Chrome:`chrome://inspect/#remote-debugging` @@ -144,14 +154,14 @@ pkill -f cdp-proxy.mjs && node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs" 环境检查(Agent 运行时会自动完成前置检查,无需手动执行): ```bash -node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs" +node "${CLAUDE_SKILL_DIR}/scripts/check-cdp.mjs" # $CLAUDE_SKILL_DIR 是 skill 加载时自动设置的环境变量 # 手动运行请替换为实际路径,如 ~/.claude/skills/web-access ``` ## CDP Proxy API -Proxy 通过 WebSocket 直连浏览器(兼容 `chrome://inspect` / `edge://inspect` 方式,无需命令行参数启动),提供 HTTP API: +Proxy 可通过 CDP extension transport 或 remote-debugging 直连浏览器,二者共用本地 HTTP API: ```bash # 启动(Agent 会自动管理 Proxy 生命周期,无需手动启动) diff --git a/SKILL.md b/SKILL.md index ce2cbf0..52a6abf 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,22 +14,17 @@ metadata: ## 前置检查 -在开始联网操作前,先检查 CDP 模式可用性: +在开始联网操作前,优先检查 CDP extension transport: ```bash -node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs" +node "${CLAUDE_SKILL_DIR}/scripts/check-cdp.mjs" ``` -**Node.js 22+** 必需(使用原生 WebSocket)。 - -按脚本输出处理: -- `exit 0` → 继续 -- `exit 2` → 需询问用户偏好,写入 `${CLAUDE_SKILL_DIR}/config.env` 的 `WEB_ACCESS_BROWSER` -- `exit 1` → 按 stdout 错误信息处理。若提示包含「Agent 处理顺序」,按其步骤执行(如先用系统命令打开浏览器后重跑),自动可解则不打扰用户;仍失败再向用户求助 +`cdp-extension: ready` 后使用 `http://127.0.0.1:3456`。若提示扩展未连接,按脚本提示一次性 Load unpacked `${CLAUDE_SKILL_DIR}/extension`。只有扩展不可用或能力不足时,再运行 `check-deps.mjs` 走 native CDP fallback。 -支持参数 `--browser ` 表达本次临时覆盖(不写 config.env)。 +**Node.js 22+** 必需(使用原生 WebSocket)。 -切换浏览器时,proxy 是长驻进程,需先 `pkill -f cdp-proxy.mjs` 再重跑 check-deps。 +native fallback 支持 `check-deps.mjs --browser ` 表达本次临时覆盖(不写 config.env)。切换 transport 或浏览器时,proxy 是长驻进程,需先 `pkill -f cdp-proxy.mjs` 再重跑检查。 检查通过后并必须在回复中向用户直接展示以下须知,再启动 CDP Proxy 执行操作: diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..53522fd --- /dev/null +++ b/extension/background.js @@ -0,0 +1,419 @@ +const PROXY_HTTP = 'http://127.0.0.1:3456'; +const PROXY_WS = 'ws://127.0.0.1:3456/ext'; +const RECONNECT_ALARM = 'web-access-reconnect'; +const SHORT_RECONNECT_DELAYS_MS = [1000, 2000, 5000]; +const attachedTabs = new Set(); + +let ws = null; +let connectInFlight = false; +let lastConnectedAt = null; +let reconnectTimer = null; +let reconnectAttempt = 0; + +chrome.runtime.onInstalled.addListener(() => { + chrome.alarms.create(RECONNECT_ALARM, { periodInMinutes: 0.5 }); + connect(); +}); + +chrome.runtime.onStartup.addListener(() => { + chrome.alarms.create(RECONNECT_ALARM, { periodInMinutes: 0.5 }); + connect(); +}); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === RECONNECT_ALARM) { + reconnectAttempt = 0; + connect(); + } +}); + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type === 'status') { + sendResponse({ + connected: ws?.readyState === WebSocket.OPEN, + lastConnectedAt, + }); + } + return false; +}); + +chrome.tabs.onRemoved.addListener((tabId) => { + attachedTabs.delete(tabId); +}); + +chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) attachedTabs.delete(source.tabId); +}); + +connect(); + +async function connect() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + if (connectInFlight) return; + connectInFlight = true; + + try { + const response = await fetch(`${PROXY_HTTP}/health`, { signal: AbortSignal.timeout(1000) }); + if (!response.ok) { + scheduleReconnect(); + return; + } + } catch { + scheduleReconnect(); + return; + } finally { + connectInFlight = false; + } + + try { + ws = new WebSocket(PROXY_WS); + } catch { + scheduleReconnect(); + return; + } + + ws.onopen = () => { + clearReconnectTimer(); + reconnectAttempt = 0; + lastConnectedAt = new Date().toISOString(); + safeSend({ + type: 'hello', + version: chrome.runtime.getManifest().version, + browser: detectBrowser(), + userAgent: navigator.userAgent, + }); + }; + + ws.onmessage = async (event) => { + let command; + try { + command = JSON.parse(event.data); + } catch { + return; + } + + const id = command?.id; + if (typeof id !== 'string') return; + try { + const result = await handleCommand(command); + safeSend({ id, ok: true, ...result }); + } catch (error) { + safeSend({ + id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + ws.onclose = () => { + ws = null; + scheduleReconnect(); + }; + + ws.onerror = () => { + try { ws?.close(); } catch {} + }; +} + +function scheduleReconnect() { + if (reconnectTimer || reconnectAttempt >= SHORT_RECONNECT_DELAYS_MS.length) return; + const delay = SHORT_RECONNECT_DELAYS_MS[reconnectAttempt]; + reconnectAttempt += 1; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} + +function clearReconnectTimer() { + if (!reconnectTimer) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; +} + +function safeSend(payload) { + if (!ws || ws.readyState !== WebSocket.OPEN) return false; + try { + ws.send(JSON.stringify(payload)); + return true; + } catch { + return false; + } +} + +async function handleCommand(command) { + switch (command.action) { + case 'targets': + return { data: await listTargets() }; + case 'new': + return await createTab(command.url || 'about:blank'); + case 'navigate': + return await navigate(command.target, command.url); + case 'back': + return await back(command.target); + case 'close': + return await closeTab(command.target); + case 'info': + return await info(command.target); + case 'eval': + return { value: await evaluate(command.target, command.code || 'undefined') }; + case 'click': + return { value: await click(command.target, command.selector || '') }; + case 'clickAt': + return { value: await clickAt(command.target, command.selector || '') }; + case 'setFiles': + return await setFiles(command.target, command.selector, command.files); + case 'scroll': + return { value: await scroll(command.target, command) }; + case 'screenshot': + return { data: await screenshot(command.target) }; + default: + throw new Error(`Unknown action: ${command.action}`); + } +} + +async function listTargets() { + const targets = await chrome.debugger.getTargets(); + return targets + .filter((target) => target.type === 'page' && target.tabId !== undefined) + .map((target) => ({ + id: target.id, + targetId: target.id, + tabId: target.tabId, + type: target.type, + title: target.title, + url: target.url, + attached: target.attached, + })); +} + +async function createTab(url) { + const tab = await chrome.tabs.create({ url, active: false }); + if (!tab.id) throw new Error('Chrome did not return a tab id'); + await waitForTabLoad(tab.id); + return { + targetId: await targetIdForTab(tab.id), + }; +} + +async function navigate(target, url) { + const tabId = await tabIdForTarget(target); + const tab = await chrome.tabs.update(tabId, { url }); + await waitForTabLoad(tabId); + return { + title: tab.title, + url: (await chrome.tabs.get(tabId)).url, + }; +} + +async function back(target) { + const tabId = await tabIdForTarget(target); + if (typeof chrome.tabs.goBack === 'function') { + await chrome.tabs.goBack(tabId); + } else { + await evaluate(target, 'history.back(); undefined'); + } + await waitForTabLoad(tabId).catch(() => {}); + return { ok: true }; +} + +async function closeTab(target) { + const tabId = await tabIdForTarget(target); + await detach(tabId).catch(() => {}); + await chrome.tabs.remove(tabId); + return { closed: target }; +} + +async function info(target) { + const tabId = await tabIdForTarget(target); + const tab = await chrome.tabs.get(tabId); + return { + targetId: target, + tabId, + title: tab.title, + url: tab.url, + status: tab.status, + }; +} + +async function evaluate(target, expression) { + const tabId = await tabIdForTarget(target); + await ensureAttached(tabId); + const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result.exceptionDetails) { + throw new Error( + result.exceptionDetails.exception?.description || + result.exceptionDetails.text || + 'Runtime.evaluate failed', + ); + } + return result.result?.value; +} + +async function click(target, selector) { + return await evaluate(target, `(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) throw new Error('selector not found: ' + ${JSON.stringify(selector)}); + el.click(); + return true; + })()`); +} + +async function clickAt(target, selector) { + const tabId = await tabIdForTarget(target); + await ensureAttached(tabId); + const rectResult = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression: `(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) throw new Error('selector not found: ' + ${JSON.stringify(selector)}); + const r = el.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + })()`, + returnByValue: true, + awaitPromise: true, + }); + if (rectResult.exceptionDetails) throw new Error(rectResult.exceptionDetails.text || 'clickAt failed'); + const point = rectResult.result?.value; + await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: point.x, + y: point.y, + button: 'none', + }); + await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button: 'left', + clickCount: 1, + }); + await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button: 'left', + clickCount: 1, + }); + return true; +} + +async function setFiles(target, selector = 'input[type=file]', files = []) { + if (!Array.isArray(files) || files.length === 0) throw new Error('files must be a non-empty array'); + const tabId = await tabIdForTarget(target); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, 'DOM.enable'); + const doc = await chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument'); + const match = await chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', { + nodeId: doc.root.nodeId, + selector, + }); + if (!match.nodeId) throw new Error(`selector not found: ${selector}`); + await chrome.debugger.sendCommand({ tabId }, 'DOM.setFileInputFiles', { + nodeId: match.nodeId, + files, + }); + return { count: files.length }; +} + +async function scroll(target, { y, direction }) { + const amount = Number.isFinite(y) ? y : direction === 'bottom' ? 'document.documentElement.scrollHeight' : 1000; + const expression = direction === 'bottom' + ? 'window.scrollTo(0, document.documentElement.scrollHeight); window.scrollY' + : `window.scrollBy(0, ${Number(amount)}); window.scrollY`; + return await evaluate(target, expression); +} + +async function screenshot(target) { + const tabId = await tabIdForTarget(target); + await ensureAttached(tabId); + const result = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', { + format: 'png', + }); + return result.data; +} + +async function ensureAttached(tabId) { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl(tab.url)) throw new Error(`Cannot debug URL: ${tab.url}`); + if (attachedTabs.has(tabId)) { + try { + await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }); + return; + } catch { + attachedTabs.delete(tabId); + } + } + try { + await chrome.debugger.attach({ tabId }, '1.3'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('Another debugger is already attached')) throw error; + } + attachedTabs.add(tabId); + await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable').catch(() => {}); + await chrome.debugger.sendCommand({ tabId }, 'Page.enable').catch(() => {}); +} + +async function detach(tabId) { + if (!attachedTabs.has(tabId)) return; + attachedTabs.delete(tabId); + await chrome.debugger.detach({ tabId }); +} + +async function tabIdForTarget(targetId) { + const targets = await chrome.debugger.getTargets(); + const match = targets.find((target) => target.id === targetId && target.tabId !== undefined); + if (!match) throw new Error(`target not found: ${targetId}`); + return match.tabId; +} + +async function targetIdForTab(tabId) { + const targets = await chrome.debugger.getTargets(); + const match = targets.find((target) => target.tabId === tabId && target.type === 'page'); + if (!match) throw new Error(`target id not found for tab: ${tabId}`); + return match.id; +} + +function waitForTabLoad(tabId, timeoutMs = 15000) { + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timer); + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + }; + const listener = (updatedTabId, changeInfo) => { + if (updatedTabId === tabId && changeInfo.status === 'complete') finish(); + }; + const timer = setTimeout(finish, timeoutMs); + chrome.tabs.onUpdated.addListener(listener); + chrome.tabs.get(tabId).then((tab) => { + if (tab.status === 'complete') finish(); + }).catch(finish); + }); +} + +function isDebuggableUrl(url) { + return !url || + url === 'about:blank' || + url.startsWith('http://') || + url.startsWith('https://') || + url.startsWith('data:'); +} + +function detectBrowser() { + const ua = navigator.userAgent; + if (ua.includes('Edg/')) return 'Edge'; + if (ua.includes('Chrome/')) return 'Chrome'; + return 'Chromium'; +} diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 0000000..ad514fc Binary files /dev/null and b/extension/icons/icon-128.png differ diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 0000000..97b1550 Binary files /dev/null and b/extension/icons/icon-16.png differ diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000..059287a Binary files /dev/null and b/extension/icons/icon-32.png differ diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 0000000..3129d71 Binary files /dev/null and b/extension/icons/icon-48.png differ diff --git a/extension/icons/icon.svg b/extension/icons/icon.svg new file mode 100644 index 0000000..6269dd2 --- /dev/null +++ b/extension/icons/icon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..b9089e9 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,34 @@ +{ + "manifest_version": 3, + "name": "web-access CDP Transport", + "version": "0.1.0", + "description": "CDP transport for web-access. Sends Chrome DevTools Protocol commands through chrome.debugger without repeated Chrome remote-debugging prompts.", + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "permissions": [ + "debugger", + "tabs", + "activeTab", + "alarms", + "storage" + ], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_title": "web-access", + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png" + } + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..f2abc57 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,55 @@ + + + + + web-access + + + +

web-access CDP Transport

+
+ + Checking... +
+ node scripts/check-cdp.mjs + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..be0cff6 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,11 @@ +chrome.runtime.sendMessage({ type: 'status' }, (status) => { + const dot = document.getElementById('dot'); + const label = document.getElementById('status'); + if (status?.connected) { + dot.classList.add('connected'); + label.textContent = 'Connected to local CDP transport'; + } else { + dot.classList.remove('connected'); + label.textContent = 'Waiting for local CDP transport'; + } +}); diff --git a/scripts/cdp-extension-transport.mjs b/scripts/cdp-extension-transport.mjs new file mode 100644 index 0000000..24ae695 --- /dev/null +++ b/scripts/cdp-extension-transport.mjs @@ -0,0 +1,419 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import { URL } from 'node:url'; + +const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; +const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const EXTENSION_ORIGIN_RE = /^(chrome|moz)-extension:\/\/[a-zA-Z0-9_-]+$/; + +export class ExtensionBridge { + constructor({ commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = {}) { + this.commandTimeoutMs = commandTimeoutMs; + this.nextId = 0; + this.pending = new Map(); + this.socket = null; + this.extension = null; + } + + isConnected() { + return this.socket !== null; + } + + attach(socket) { + this.detach(); + this.socket = socket; + } + + detach(socket = this.socket) { + if (socket && this.socket === socket) { + this.socket = null; + this.extension = null; + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(new Error(`Extension disconnected before response: ${id}`)); + } + this.pending.clear(); + } + } + + receive(raw) { + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + + if (message?.type === 'hello') { + this.extension = { + version: typeof message.version === 'string' ? message.version : 'unknown', + browser: typeof message.browser === 'string' ? message.browser : 'unknown', + userAgent: typeof message.userAgent === 'string' ? message.userAgent : undefined, + connectedAt: new Date().toISOString(), + }; + return; + } + + const id = message?.id; + if (typeof id !== 'string') return; + const pending = this.pending.get(id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(id); + pending.resolve(message); + } + + command(action, params = {}) { + if (!this.socket) { + return Promise.reject(new Error('web-access browser extension is not connected')); + } + + const id = String(++this.nextId); + const payload = { id, action, ...params }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Extension command timed out: ${action}`)); + }, this.commandTimeoutMs); + this.pending.set(id, { resolve, reject, timer }); + try { + this.socket.send(JSON.stringify(payload)); + } catch (error) { + clearTimeout(timer); + this.pending.delete(id); + reject(error); + } + }); + } +} + +export function createHttpHandler({ bridge }) { + return async (req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); + setCorsHeaders(req, res); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'content-type'); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + + if (req.method === 'OPTIONS') { + if (!isAllowedHttpOrigin(req.headers.origin)) { + res.statusCode = 403; + res.end(); + return; + } + res.statusCode = 204; + res.end(); + return; + } + + try { + if (!isAllowedHttpOrigin(req.headers.origin)) { + res.statusCode = 403; + writeJson(res, { error: 'forbidden_origin' }); + return; + } + + const pathname = url.pathname; + if (pathname === '/health') { + writeJson(res, { + status: 'ok', + backend: 'cdp-extension', + connected: bridge.isConnected(), + extension: bridge.extension, + }); + return; + } + + if (pathname === '/targets') { + writeJson(res, await unwrapBridgeResult(bridge.command('targets'))); + return; + } + + if (pathname === '/new') { + requireMethod(req, 'POST'); + const targetUrl = (await readBody(req)).trim() || 'about:blank'; + writeJson(res, await unwrapBridgeResult(bridge.command('new', { url: targetUrl }))); + return; + } + + if (pathname === '/navigate') { + requireMethod(req, 'POST'); + const target = requireTarget(url); + const targetUrl = (await readBody(req)).trim(); + writeJson(res, await unwrapBridgeResult(bridge.command('navigate', { target, url: targetUrl }))); + return; + } + + if (pathname === '/back') { + const target = requireTarget(url); + writeJson(res, await unwrapBridgeResult(bridge.command('back', { target }))); + return; + } + + if (pathname === '/close') { + const target = requireTarget(url); + writeJson(res, await unwrapBridgeResult(bridge.command('close', { target }))); + return; + } + + if (pathname === '/info') { + const target = requireTarget(url); + writeJson(res, await unwrapBridgeResult(bridge.command('info', { target }))); + return; + } + + if (pathname === '/eval') { + requireMethod(req, 'POST'); + const target = requireTarget(url); + const code = await readBody(req); + writeJson(res, await unwrapBridgeResult(bridge.command('eval', { target, code }))); + return; + } + + if (pathname === '/click') { + requireMethod(req, 'POST'); + const target = requireTarget(url); + const selector = await readBody(req); + writeJson(res, await unwrapBridgeResult(bridge.command('click', { target, selector }))); + return; + } + + if (pathname === '/clickAt') { + requireMethod(req, 'POST'); + const target = requireTarget(url); + const selector = await readBody(req); + writeJson(res, await unwrapBridgeResult(bridge.command('clickAt', { target, selector }))); + return; + } + + if (pathname === '/setFiles') { + requireMethod(req, 'POST'); + const target = requireTarget(url); + const payload = JSON.parse(await readBody(req)); + writeJson(res, await unwrapBridgeResult(bridge.command('setFiles', { target, ...payload }))); + return; + } + + if (pathname === '/scroll') { + const target = requireTarget(url); + const y = url.searchParams.has('y') ? Number(url.searchParams.get('y')) : undefined; + const direction = url.searchParams.get('direction') || undefined; + writeJson(res, await unwrapBridgeResult(bridge.command('scroll', { target, y, direction }))); + return; + } + + if (pathname === '/screenshot') { + const target = requireTarget(url); + const file = url.searchParams.get('file'); + const result = await unwrapBridgeResult(bridge.command('screenshot', { target })); + if (file && typeof result.data === 'string') { + await fs.writeFile(file, Buffer.from(result.data, 'base64')); + writeJson(res, { file, bytes: Buffer.byteLength(result.data, 'base64') }); + } else { + writeJson(res, result); + } + return; + } + + res.statusCode = 404; + writeJson(res, { + error: 'unknown_endpoint', + endpoints: { + '/health': 'GET', + '/targets': 'GET', + '/new': 'POST body=URL', + '/navigate?target=': 'POST body=URL', + '/eval?target=': 'POST body=JS', + '/click?target=': 'POST body=selector', + '/clickAt?target=': 'POST body=selector', + '/setFiles?target=': 'POST body=json', + '/scroll?target=': 'GET', + '/screenshot?target=&file=': 'GET', + '/close?target=': 'GET', + }, + }); + } catch (error) { + res.statusCode = error.statusCode || 500; + writeJson(res, { error: error.message || String(error) }); + } + }; +} + +export function handleWebSocketUpgrade(req, socket, head, bridge) { + if (req.url !== '/ext') { + socket.destroy(); + return; + } + if (!isAllowedExtensionOrigin(req.headers.origin)) { + socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + const key = req.headers['sec-websocket-key']; + if (typeof key !== 'string') { + socket.destroy(); + return; + } + + const accept = crypto.createHash('sha1').update(key + WS_MAGIC).digest('base64'); + socket.write([ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + '', + '', + ].join('\r\n')); + + const reader = new WebSocketFrameReader(); + const peer = { + label: req.headers['user-agent'] || 'web-access-extension', + send: (payload) => socket.write(encodeTextFrame(payload)), + close: () => socket.destroy(), + }; + bridge.attach(peer); + if (head?.length) { + for (const message of reader.push(head)) bridge.receive(message); + } + socket.on('data', (chunk) => { + for (const message of reader.push(chunk)) bridge.receive(message); + }); + socket.on('close', () => bridge.detach(peer)); + socket.on('error', () => bridge.detach(peer)); +} + +export class WebSocketFrameReader { + constructor() { + this.buffer = Buffer.alloc(0); + } + + push(chunk) { + this.buffer = Buffer.concat([this.buffer, Buffer.from(chunk)]); + const messages = []; + + while (this.buffer.length >= 2) { + const first = this.buffer[0]; + const second = this.buffer[1]; + const opcode = first & 0x0f; + const masked = (second & 0x80) !== 0; + let length = second & 0x7f; + let offset = 2; + + if (length === 126) { + if (this.buffer.length < offset + 2) break; + length = this.buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + if (this.buffer.length < offset + 8) break; + const big = this.buffer.readBigUInt64BE(offset); + if (big > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('WebSocket frame too large'); + length = Number(big); + offset += 8; + } + + const maskOffset = offset; + const payloadOffset = masked ? offset + 4 : offset; + const frameLength = payloadOffset + length; + if (this.buffer.length < frameLength) break; + + let payload = this.buffer.subarray(payloadOffset, frameLength); + if (masked) { + const mask = this.buffer.subarray(maskOffset, maskOffset + 4); + payload = Buffer.from(payload); + for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]; + } + this.buffer = this.buffer.subarray(frameLength); + + if (opcode === 0x8) continue; + if (opcode !== 0x1) continue; + messages.push(payload.toString('utf8')); + } + + return messages; + } +} + +export function encodeTextFrame(text, { masked = false } = {}) { + const payload = Buffer.from(String(text), 'utf8'); + const headerLength = payload.length < 126 ? 2 : payload.length <= 0xffff ? 4 : 10; + const maskLength = masked ? 4 : 0; + const frame = Buffer.alloc(headerLength + maskLength + payload.length); + + frame[0] = 0x81; + if (payload.length < 126) { + frame[1] = payload.length | (masked ? 0x80 : 0); + } else if (payload.length <= 0xffff) { + frame[1] = 126 | (masked ? 0x80 : 0); + frame.writeUInt16BE(payload.length, 2); + } else { + frame[1] = 127 | (masked ? 0x80 : 0); + frame.writeBigUInt64BE(BigInt(payload.length), 2); + } + + const payloadOffset = headerLength + maskLength; + if (masked) { + const mask = crypto.randomBytes(4); + mask.copy(frame, headerLength); + for (let i = 0; i < payload.length; i++) frame[payloadOffset + i] = payload[i] ^ mask[i % 4]; + } else { + payload.copy(frame, payloadOffset); + } + return frame; +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} + +async function unwrapBridgeResult(promise) { + const result = await promise; + if (result?.ok === false) { + const error = new Error(result.error || result.errorCode || 'extension_command_failed'); + error.statusCode = 502; + throw error; + } + if ('value' in result) return { value: result.value }; + if ('data' in result && Object.keys(result).length <= 3) return result.data; + const { id, ok, ...rest } = result || {}; + return rest; +} + +function requireMethod(req, method) { + if (req.method !== method) { + const error = new Error(`method_not_allowed: use ${method}`); + error.statusCode = 405; + throw error; + } +} + +function requireTarget(url) { + const target = url.searchParams.get('target'); + if (!target) { + const error = new Error('missing target'); + error.statusCode = 400; + throw error; + } + return target; +} + +function writeJson(res, value) { + res.end(JSON.stringify(value, null, 2)); +} + +function setCorsHeaders(req, res) { + const origin = req.headers.origin; + if (isAllowedHttpOrigin(origin) && origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } +} + +function isAllowedHttpOrigin(origin) { + return origin === undefined || isAllowedExtensionOrigin(origin); +} + +function isAllowedExtensionOrigin(origin) { + return typeof origin === 'string' && EXTENSION_ORIGIN_RE.test(origin); +} diff --git a/scripts/cdp-proxy.mjs b/scripts/cdp-proxy.mjs index 9457405..afc119a 100755 --- a/scripts/cdp-proxy.mjs +++ b/scripts/cdp-proxy.mjs @@ -10,6 +10,11 @@ import path from 'node:path'; import os from 'node:os'; import net from 'node:net'; import { selectBrowser, findFallbackPort } from './browser-discovery.mjs'; +import { + ExtensionBridge, + createHttpHandler as createExtensionHttpHandler, + handleWebSocketUpgrade, +} from './cdp-extension-transport.mjs'; // --- 解析命令行 --browser 参数(本次启动用哪个浏览器)--- function parseBrowserArg() { @@ -23,6 +28,7 @@ function parseBrowserArg() { const BROWSER_OVERRIDE = parseBrowserArg(); const PORT = parseInt(process.env.CDP_PROXY_PORT || '3456'); +const TRANSPORT = (process.env.CDP_TRANSPORT || 'extension').toLowerCase(); let ws = null; let cmdId = 0; const pending = new Map(); // id -> {resolve, timer} @@ -327,6 +333,8 @@ const server = http.createServer(async (req, res) => { const connected = ws && (ws.readyState === WS.OPEN || ws.readyState === 1); res.end(JSON.stringify({ status: 'ok', + backend: 'cdp-native', + pid: process.pid, connected, browser: connectedBrowser, sessions: sessions.size, @@ -620,6 +628,15 @@ function checkPortAvailable(port) { } async function main() { + if (TRANSPORT === 'extension') { + await mainExtensionTransport(); + return; + } + if (TRANSPORT !== 'native') { + console.error(`[CDP Proxy] unknown CDP_TRANSPORT: ${TRANSPORT}`); + process.exit(1); + } + // 检查是否已有 proxy 在运行 const available = await checkPortAvailable(PORT); if (!available) { @@ -661,6 +678,53 @@ async function main() { process.on('SIGTERM', () => shutdown('SIGTERM')); } +async function mainExtensionTransport() { + const bridge = new ExtensionBridge(); + const extensionServer = http.createServer(createExtensionHttpHandler({ bridge })); + + extensionServer.on('upgrade', (req, socket, head) => { + handleWebSocketUpgrade(req, socket, head, bridge); + }); + + const available = await checkPortAvailable(PORT); + if (!available) { + try { + const ok = await new Promise((resolve) => { + http.get(`http://127.0.0.1:${PORT}/health`, { timeout: 2000 }, (res) => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { + try { + const health = JSON.parse(d); + resolve(health.status === 'ok' && health.backend === 'cdp-extension'); + } catch { + resolve(false); + } + }); + }).on('error', () => resolve(false)); + }); + if (ok) { + console.log(`[CDP Proxy] extension transport already running on port ${PORT}`); + process.exit(0); + } + } catch { /* port occupied but not this proxy */ } + console.error(`[CDP Proxy] port ${PORT} is occupied`); + process.exit(1); + } + + extensionServer.listen(PORT, '127.0.0.1', () => { + console.log(`[CDP Proxy] extension transport listening on http://127.0.0.1:${PORT}`); + console.log(`[CDP Proxy] extension path: ${path.resolve(import.meta.dirname, '..', 'extension')}`); + }); + + const shutdown = (sig) => { + console.log(`[CDP Proxy] ${sig}, shutting down extension transport...`); + extensionServer.close(() => process.exit(0)); + }; + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + // 防止未捕获异常导致进程崩溃 process.on('uncaughtException', (e) => { console.error('[CDP Proxy] 未捕获异常:', e.message); diff --git a/scripts/check-cdp.mjs b/scripts/check-cdp.mjs new file mode 100644 index 0000000..6996d53 --- /dev/null +++ b/scripts/check-cdp.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// Ensure the web-access CDP extension transport is running and report install state. + +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PROXY_SCRIPT = path.join(ROOT, 'scripts', 'cdp-proxy.mjs'); +const EXTENSION_DIR = path.join(ROOT, 'extension'); +const PORT = Number(process.env.CDP_PROXY_PORT || 3456); +const HEALTH_URL = `http://127.0.0.1:${PORT}/health`; +const EXTENSION_SETTLE_MS = 3000; +const EXTENSION_WAIT_MS = 10000; +const EXTENSION_POLL_MS = 500; +const PROXY_STOP_WAIT_MS = 5000; +const PROXY_STOP_POLL_MS = 100; + +async function main() { + const proxyStarted = await ensureProxy(); + const health = await waitForExtension(proxyStarted); + if (isExtensionReady(health)) { + console.log(`cdp-extension: ready (${health.extension?.browser || 'browser'} ${health.extension?.version || ''})`); + process.exit(0); + } + + console.log('cdp-extension: proxy ready, extension not connected'); + console.log(' 1. 打开 chrome://extensions 或 edge://extensions,并启用 Developer mode'); + console.log(` 2. 选择 Load unpacked,目录选择:${EXTENSION_DIR}`); + console.log(' 3. 保持扩展启用;完成这一次安装授权后,web-access 可通过 chrome.debugger 传递 CDP 命令,不再需要 Chrome remote-debugging 授权弹窗'); + console.log(` 4. 重新运行:node "${path.join(ROOT, 'scripts', 'check-cdp.mjs')}"`); + process.exit(1); +} + +async function ensureProxy() { + const health = await getHealth(); + if (health?.status === 'ok') { + if (health.backend === 'cdp-extension') return false; + if (health.backend === 'cdp-native') { + await stopNativeProxy(health); + } else { + throw new Error(`Port ${PORT} is occupied by an unknown backend; refusing to stop it`); + } + } + + const logFile = path.join(os.tmpdir(), 'web-access-cdp-proxy.log'); + const logFd = fs.openSync(logFile, 'a'); + const child = spawn(process.execPath, [PROXY_SCRIPT], { + detached: true, + stdio: ['ignore', logFd, logFd], + env: { ...process.env, CDP_TRANSPORT: 'extension', CDP_PROXY_PORT: String(PORT) }, + ...(os.platform() === 'win32' ? { windowsHide: true } : {}), + }); + child.unref(); + fs.closeSync(logFd); + + for (let i = 0; i < 20; i++) { + await sleep(300); + const current = await getHealth(); + if (current?.status === 'ok' && current.backend === 'cdp-extension') return true; + } + throw new Error(`CDP extension transport did not start; see ${logFile}`); +} + +async function waitForExtension(proxyStarted) { + if (proxyStarted) await sleep(EXTENSION_SETTLE_MS); + + const deadline = Date.now() + EXTENSION_WAIT_MS; + let health = null; + do { + health = await getHealth(); + if (isExtensionReady(health)) return health; + await sleep(EXTENSION_POLL_MS); + } while (Date.now() < deadline); + + return health; +} + +async function stopNativeProxy(health) { + const pid = Number(health.pid); + if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) { + throw new Error('Native proxy did not expose a valid pid; refusing to stop it'); + } + + console.log(`cdp-extension: stopping native proxy (pid ${pid})`); + try { + process.kill(pid, 'SIGTERM'); + } catch (error) { + if (error?.code !== 'ESRCH') throw error; + } + + const deadline = Date.now() + PROXY_STOP_WAIT_MS; + while (Date.now() < deadline) { + await sleep(PROXY_STOP_POLL_MS); + const current = await getHealth(); + if (!current) return; + if (current.backend !== 'cdp-native' || Number(current.pid) !== pid) { + throw new Error(`Port ${PORT} was claimed by another backend while stopping native proxy`); + } + } + + throw new Error(`Timed out waiting for native proxy ${pid} to stop`); +} + +function isExtensionReady(health) { + return health?.status === 'ok' && + health.backend === 'cdp-extension' && + health.connected === true; +} + +function getHealth() { + return new Promise((resolve) => { + const req = http.get(HEALTH_URL, { timeout: 1000 }, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + try { resolve(JSON.parse(data)); } catch { resolve(null); } + }); + }); + req.on('timeout', () => { req.destroy(); resolve(null); }); + req.on('error', () => resolve(null)); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/check-deps.mjs b/scripts/check-deps.mjs index 87c49ba..6840ad7 100644 --- a/scripts/check-deps.mjs +++ b/scripts/check-deps.mjs @@ -69,6 +69,7 @@ function startProxyDetached(browserOverride) { const child = spawn(process.execPath, args, { detached: true, stdio: ['ignore', logFd, logFd], + env: { ...process.env, CDP_TRANSPORT: 'native' }, ...(os.platform() === 'win32' ? { windowsHide: true } : {}), }); child.unref(); @@ -81,6 +82,10 @@ async function ensureProxy(expectedBrowserId, browserOverride) { // 复用:proxy 已运行 + 已连接浏览器 → 校验 expected vs actual const health = await httpGetJson(healthUrl); + if (health?.backend === 'cdp-extension') { + console.log(`proxy: extension transport 正在占用 ${PROXY_PORT};如需 native CDP fallback,请先停止 cdp-proxy.mjs 后重跑 check-deps`); + return false; + } if (health?.status === 'ok' && health.connected) { const runningId = health.browser?.id; const runningLabel = health.browser?.label || runningId || 'unknown'; diff --git a/tests/cdp-extension-transport.test.mjs b/tests/cdp-extension-transport.test.mjs new file mode 100644 index 0000000..2935494 --- /dev/null +++ b/tests/cdp-extension-transport.test.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { once } from 'node:events'; +import test from 'node:test'; + +import { + ExtensionBridge, + createHttpHandler, + encodeTextFrame, + handleWebSocketUpgrade, + WebSocketFrameReader, +} from '../scripts/cdp-extension-transport.mjs'; + +test('websocket frame codec round-trips masked client text frames', () => { + const reader = new WebSocketFrameReader(); + const payload = JSON.stringify({ type: 'hello', version: 'test' }); + const frames = reader.push(encodeTextFrame(payload, { masked: true })); + + assert.deepEqual(frames, [payload]); +}); + +test('bridge forwards commands to the connected extension and resolves matching responses', async () => { + const bridge = new ExtensionBridge({ commandTimeoutMs: 500 }); + const sent = []; + bridge.attach({ + label: 'fake-extension', + send: (payload) => sent.push(JSON.parse(payload)), + close: () => {}, + }); + + const pending = bridge.command('eval', { target: 'target-1', code: 'document.title' }); + assert.equal(sent.length, 1); + assert.equal(sent[0].action, 'eval'); + assert.equal(sent[0].target, 'target-1'); + + bridge.receive(JSON.stringify({ id: sent[0].id, ok: true, value: 'Example' })); + + assert.deepEqual(await pending, { id: sent[0].id, ok: true, value: 'Example' }); +}); + +test('HTTP /new sends the raw POST body URL without query-string truncation', async () => { + const bridge = new ExtensionBridge({ commandTimeoutMs: 500 }); + const sent = []; + bridge.attach({ + label: 'fake-extension', + send: (payload) => { + const message = JSON.parse(payload); + sent.push(message); + bridge.receive(JSON.stringify({ id: message.id, ok: true, targetId: 'target-new' })); + }, + close: () => {}, + }); + + const server = http.createServer(createHttpHandler({ bridge })); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address(); + const url = 'https://example.test/path?a=1&b=2#frag'; + + const response = await fetch(`http://127.0.0.1:${port}/new`, { + method: 'POST', + body: url, + }); + + try { + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { targetId: 'target-new' }); + assert.equal(sent[0].action, 'new'); + assert.equal(sent[0].url, url); + } finally { + server.close(); + } +}); + +test('HTTP /health reports extension connection state', async () => { + const bridge = new ExtensionBridge({ commandTimeoutMs: 500 }); + const server = http.createServer(createHttpHandler({ bridge })); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address(); + + try { + const before = await fetch(`http://127.0.0.1:${port}/health`).then((r) => r.json()); + assert.equal(before.connected, false); + + bridge.attach({ label: 'fake-extension', send: () => {}, close: () => {} }); + bridge.receive(JSON.stringify({ type: 'hello', version: '1.0.0', browser: 'Chrome' })); + + const after = await fetch(`http://127.0.0.1:${port}/health`).then((r) => r.json()); + assert.equal(after.connected, true); + assert.equal(after.extension.version, '1.0.0'); + assert.equal(after.extension.browser, 'Chrome'); + } finally { + server.close(); + } +}); + +test('HTTP rejects browser page origins but allows extension origins', async () => { + const bridge = new ExtensionBridge({ commandTimeoutMs: 500 }); + const server = http.createServer(createHttpHandler({ bridge })); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address(); + + try { + const forbidden = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { Origin: 'https://example.test' }, + }); + assert.equal(forbidden.status, 403); + assert.deepEqual(await forbidden.json(), { error: 'forbidden_origin' }); + + const allowed = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { Origin: 'chrome-extension://abcdefghijklmnop' }, + }); + assert.equal(allowed.status, 200); + assert.equal(allowed.headers.get('access-control-allow-origin'), 'chrome-extension://abcdefghijklmnop'); + assert.equal((await allowed.json()).status, 'ok'); + } finally { + server.close(); + } +}); + +test('websocket bridge rejects non-extension origins', () => { + const bridge = new ExtensionBridge({ commandTimeoutMs: 500 }); + const writes = []; + const socket = { + write: (data) => writes.push(String(data)), + destroy: () => { socket.destroyed = true; }, + on: () => {}, + destroyed: false, + }; + + handleWebSocketUpgrade({ + url: '/ext', + headers: { + origin: 'https://example.test', + 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', + }, + }, socket, Buffer.alloc(0), bridge); + + assert.equal(socket.destroyed, true); + assert.match(writes.join(''), /403 Forbidden/); + assert.equal(bridge.isConnected(), false); +}); diff --git a/tests/cdp-transport-structure.test.mjs b/tests/cdp-transport-structure.test.mjs new file mode 100644 index 0000000..328ce9a --- /dev/null +++ b/tests/cdp-transport-structure.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('CDP mode uses unified cdp-proxy and cdp extension transport names', () => { + assert.equal(fs.existsSync(path.join(ROOT, 'scripts', 'cdp-proxy.mjs')), true); + assert.equal(fs.existsSync(path.join(ROOT, 'scripts', 'check-cdp.mjs')), true); + assert.equal(fs.existsSync(path.join(ROOT, 'scripts', 'cdp-extension-transport.mjs')), true); + + assert.equal(fs.existsSync(path.join(ROOT, 'scripts', 'check-webext.mjs')), false); + assert.equal(fs.existsSync(path.join(ROOT, 'scripts', 'webext-proxy.mjs')), false); + assert.equal(fs.existsSync(path.join(ROOT, 'scripts', 'webext-proxy-lib.mjs')), false); +}); + +test('root extension connects to the unified CDP proxy port', () => { + const background = fs.readFileSync(path.join(ROOT, 'extension', 'background.js'), 'utf8'); + assert.match(background, /http:\/\/127\.0\.0\.1:3456/); + assert.match(background, /ws:\/\/127\.0\.0\.1:3456\/ext/); + assert.doesNotMatch(background, /3457/); +}); diff --git a/tests/check-cdp.test.mjs b/tests/check-cdp.test.mjs new file mode 100644 index 0000000..45c5590 --- /dev/null +++ b/tests/check-cdp.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const CHECK_CDP = path.join(ROOT, 'scripts', 'check-cdp.mjs'); + +test('check-cdp does not report a native proxy as extension ready', async () => { + const server = http.createServer((_req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + status: 'ok', + backend: 'cdp-native', + connected: true, + chromePort: 9222, + })); + }); + server.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address(); + + try { + const result = await runCheckCdp(port); + assert.notEqual(result.code, 0); + assert.doesNotMatch(result.stdout, /cdp-extension: ready/); + assert.match(result.stderr, /native proxy/i); + } finally { + server.close(); + } +}); + +function runCheckCdp(port) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [CHECK_CDP], { + env: { ...process.env, CDP_PROXY_PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => stdout += chunk); + child.stderr.on('data', (chunk) => stderr += chunk); + child.once('error', reject); + child.once('exit', (code) => resolve({ code, stdout, stderr })); + }); +}