From 6dc161c59096c09b04df112565c71dbf6f08a142 Mon Sep 17 00:00:00 2001 From: sleepyy-dog Date: Wed, 8 Jul 2026 22:52:57 +0800 Subject: [PATCH 1/2] feat: add CDP extension transport --- README.md | 20 +- SKILL.md | 15 +- extension/background.js | 419 +++++++++++++++++++++++++ extension/icons/icon-128.png | Bin 0 -> 8125 bytes extension/icons/icon-16.png | Bin 0 -> 861 bytes extension/icons/icon-32.png | Bin 0 -> 1881 bytes extension/icons/icon-48.png | Bin 0 -> 3034 bytes extension/icons/icon.svg | 29 ++ extension/manifest.json | 34 ++ extension/popup.html | 55 ++++ extension/popup.js | 11 + scripts/cdp-extension-transport.mjs | 419 +++++++++++++++++++++++++ scripts/cdp-proxy.mjs | 62 ++++ scripts/check-cdp.mjs | 93 ++++++ scripts/check-deps.mjs | 5 + tests/cdp-extension-transport.test.mjs | 144 +++++++++ tests/cdp-transport-structure.test.mjs | 24 ++ 17 files changed, 1315 insertions(+), 15 deletions(-) create mode 100644 extension/background.js create mode 100644 extension/icons/icon-128.png create mode 100644 extension/icons/icon-16.png create mode 100644 extension/icons/icon-32.png create mode 100644 extension/icons/icon-48.png create mode 100644 extension/icons/icon.svg create mode 100644 extension/manifest.json create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 scripts/cdp-extension-transport.mjs create mode 100644 scripts/check-cdp.mjs create mode 100644 tests/cdp-extension-transport.test.mjs create mode 100644 tests/cdp-transport-structure.test.mjs 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 0000000000000000000000000000000000000000..ad514fce19c46af1e8766175bf8dbcfbb2fc0a33 GIT binary patch literal 8125 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 E00tF5KL7v# literal 0 HcmV?d00001 diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..97b1550a7068d32d1adcbd817b7dcb84d76eed5b GIT binary patch literal 861 zcmV-j1ETziP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGxhX4Q_hXIe}@nrx20`f^jK~y+TZPR;5 zRCyG@@!#KWIQP!Ij(;uvlhs5T+r~~0ve!hJgw8s5T$^#mai)3P8Bu4nnjZENNei}3 z*%qXgw#Py*B6?~$1{D!e1Vd4=!BSR&Sh3JQTYbYjiZ1Jahr{`t!#Qw-5JD*0f_&9k z^t;9Cd|@f7B=7qw@)o&Ki#@1zFKUSorOc1w3}CJbzL31(lX5U(8?|CurER+EEE>tT zYFOq~lDA+2&uY7X^-y{e-iYiCBL^c$x=wC=Y?&}u6rTJaUY4C)adD8Y_o4*sDCcc2O8Z%cWi{04Px*6&(%r@xlA^r9*)+7=H&mJdw`3$A0W+T^k`%k(C z+L@8|V%}1XtXCq`dY&?S?5HG>6jYh*A9nEJB6F7F5JHzi8{sJHV{meg~z8LYH;dgx7 z96>wKK=CGzft9L-2zf*3qwJ3$hoVSv9d~y-sY~7HCw`{;$~lf+yMXJL6u#fKv+evL z^dq~Oxv|8+ifcYX_DA}qa2A^q*`0sb*@)J;gYeOI5~mIl?rFvMYYJO~hT>UjU{#w> zenG`+A%*lcld+TtBh~wn;%?-qh8!xx94JBYtwM1xMfqtA&vc^B)ixU+nVX3Fa+CVV nl(=>>wxMd@VVULpIF7#po6-xHaf*)f00000NkvXXu0mjfx@ep- literal 0 HcmV?d00001 diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..059287a7eb4ce99a9c9442bd5f25255a1bfc775e GIT binary patch literal 1881 zcmV-f2d4OmP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGxhX4Q_hXIe}@nrx22JT5jK~z{ro!5O( z6L%U1@cplUNXTY)foppy)0;uYj+9EVaBWf1QtJzXkQYNhB@7Ry74rg+fW!Yo=- zaD>t1BWtb()8fBuam4V8t1%H{#-@O(wN-MKP6O<$DFXbY|-;rTz0=e*jD+^z59 zVSPA;S51$roF0&z7o?ZJtqORGqyUVzppVf!nHn#Q!=I1gdF=xz{@YJtJ#}DEj_c!a zHUZ~(kbWLykpGHkwmd}8ngx0h&(+Qv7nXhD`&Xm@&wP060dXIcAwLu$ek zzBullFK3badao2<{+?5*al3$fV|c(P;mUeH(!;(ZL{_fmbmkMf2f|Bszk~9_CoppP za}14siHeUt#?pO<9tc;4d5~exHA03LOoK0t;@Ork^aF$7ez+sn&9TqKntE+KGlF?d zk(9Wy)}MfTL54UGnR$9SUX&Vtq{<7+zb;8wssKVczZ=8+mhH2^-5_fnAZEnNqRTEU z`4r+BxrOI<@5lA;`@|}GqXU+6qtfU4Q|IM)I1s}_9tkIdRuD4$StI)2DZ$-S85rzc zh@x_HwBbA(;lA`g3D2v41#=d!!Rdj67&`kN<}9m#+U$XC=&BSVf9EaFyGl+P6_siuXK2_rv_hRC|C#AYO_A%N&If?XTRY)&4fHFrLE?v)JPFW>h zJ@hVq9Q#!Eox#x_5ud7u|7ZibK8lmR8W2-BQ({D;Un2|f!`TiD5BB1_kz=@c<1D_t zaSn}d{Q+r9E0N;~ptPk0rI83qw?^=|(E@XGGyZ*JP&{|!Kc8Uu>%;i$+@ECuvf5Y) zSMhofQ~1<9-?x1=hK>}X@9i=aX{?|t?Wp?48EI)3Hf+Jl$jhid@d5b1c7s0}EnP<& z7Hw*R^V>1;Jj0j6ASdtnuiEW+>@O@HIlzLfi{UDd1_V=L0<$Ep;a+0Ftg?bGw~07i z?SSE*7sMZRT)2*cKp0OrJy1D3P`SJyJzkJL9tsPK$8BEm9Pw!w`W}xq`asrA-t)L+ zpTbq#S`ahgl-3Ey`Dlj=Kdv@%P^_(y;pBQB6jm$LEGGr9oxcSBqb}rZ6h0T0-IRn= zt~DT-WeNBiId1LT5Bp&B`IiDz8)dk%&JTs%2DLR7z;I?5CEJ?7f7pqf2A3RnM{&xr z3dGOhRd4Qs`QTm{J|B(+FmX_5jWS$#)D#Z+3K-5@5U;#A(tzTYdZ@g~IAwncgbWu3 z2?5tRQL?!i{NXOBonCnWGY5s<7{luWP=p?+c%-|^#HORD?zi%QsRuZ0F!%=enQE5g0scRIw#1W4P?Lq z(r<){Ul)DUxXEsJ3Jtdcl3^1eK)QGnzQFC*ifJaP1^s0m`{L z88=RkXU@~y&dAqii#wxqpKq}o7yjR8gICRfKbwHZc3(`MmW|sT5(F9Gmn-Z1qrV8Z z&x9|C;<;M!%9HS^BP|$Irib}fGVJY_zxzO5UZ TD12T100000NkvXXu0mjf7Wj_= literal 0 HcmV?d00001 diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..3129d7104a41720338447034d3df2b028aadcf22 GIT binary patch literal 3034 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..5d4c960 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} @@ -620,6 +626,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 +676,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..63af7b0 --- /dev/null +++ b/scripts/check-cdp.mjs @@ -0,0 +1,93 @@ +#!/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; + +async function main() { + const proxyStarted = await ensureProxy(); + const health = await waitForExtension(proxyStarted); + if (health?.connected) { + 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') return false; + + 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); + if ((await getHealth())?.status === 'ok') 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 (health?.connected) return health; + await sleep(EXTENSION_POLL_MS); + } while (Date.now() < deadline); + + return health; +} + +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/); +}); From fb5dfc6c0fef08039a4fc0d67ef6465522b1b7b4 Mon Sep 17 00:00:00 2001 From: sleepyy-dog Date: Tue, 14 Jul 2026 21:11:00 +0800 Subject: [PATCH 2/2] fix: distinguish CDP proxy transports --- scripts/cdp-proxy.mjs | 2 ++ scripts/check-cdp.mjs | 50 ++++++++++++++++++++++++++++++++++++---- tests/check-cdp.test.mjs | 49 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 tests/check-cdp.test.mjs diff --git a/scripts/cdp-proxy.mjs b/scripts/cdp-proxy.mjs index 5d4c960..afc119a 100755 --- a/scripts/cdp-proxy.mjs +++ b/scripts/cdp-proxy.mjs @@ -333,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, diff --git a/scripts/check-cdp.mjs b/scripts/check-cdp.mjs index 63af7b0..6996d53 100644 --- a/scripts/check-cdp.mjs +++ b/scripts/check-cdp.mjs @@ -16,11 +16,13 @@ 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 (health?.connected) { + if (isExtensionReady(health)) { console.log(`cdp-extension: ready (${health.extension?.browser || 'browser'} ${health.extension?.version || ''})`); process.exit(0); } @@ -35,7 +37,14 @@ async function main() { async function ensureProxy() { const health = await getHealth(); - if (health?.status === 'ok') return false; + 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'); @@ -50,7 +59,8 @@ async function ensureProxy() { for (let i = 0; i < 20; i++) { await sleep(300); - if ((await getHealth())?.status === 'ok') return true; + 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}`); } @@ -62,13 +72,45 @@ async function waitForExtension(proxyStarted) { let health = null; do { health = await getHealth(); - if (health?.connected) return health; + 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) => { 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 })); + }); +}