diff --git a/README.md b/README.md index 022cc9f..871aa7d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ AI Agent 原本的联网能力(WebSearch、WebFetch)缺少调度策略和浏 | 能力 | 说明 | |------|------| | 联网工具自动选择 | WebSearch / WebFetch / curl / Jina / CDP,按场景自主判断,可任意组合 | +| 浏览器扩展后端 | 可选的本地 Browser Bridge,一次性安装扩展后通过 `chrome.debugger` / `chrome.tabs` 操作 Chrome / Edge,避免日常使用依赖 remote-debugging 授权 | | CDP Proxy 浏览器操作 | 直连用户日常浏览器(Chrome / Edge / Chromium 系),天然携带登录态,支持动态页面、交互操作、视频截帧 | | 三种点击方式 | `/click`(JS click)、`/clickAt`(CDP 真实鼠标事件)、`/setFiles`(文件上传) | | 本地浏览器书签/历史检索 | `find-url.mjs` 跨 Chrome / Edge 查询公网搜不到的目标(内部系统)或用户访问过的页面,支持关键词/时间窗/访问频度排序 | @@ -43,6 +44,9 @@ AI Agent 原本的联网能力(WebSearch、WebFetch)缺少调度策略和浏 | 站点经验积累 | 按域名存储操作经验(URL 模式、平台特征、已知陷阱),跨 session 复用 | | 媒体提取 | 从 DOM 直取图片/视频 URL,或对视频任意时间点截帧分析 | +**未发布更新:** +- **浏览器扩展后端(可选)** — 新增 `extension/`、`scripts/webext-proxy.mjs` 和 `scripts/check-webext.mjs`。一次性 Load unpacked 并授权扩展后,默认使用 `127.0.0.1:3457`,日常浏览器操作不再需要 Chrome / Edge remote-debugging 授权弹窗。CDP Proxy 保留为兜底。 + **v2.5.2 更新:** - **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 ` @@ -109,7 +113,17 @@ claude plugin install web-access@web-access --scope user git clone https://github.com/eze-is/web-access ~/.claude/skills/web-access ``` -## 前置配置(CDP 模式) +## 前置配置(浏览器模式) + +优先使用可选浏览器扩展后端: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/check-webext.mjs" +``` + +如果扩展未连接,打开 `chrome://extensions` 或 `edge://extensions`,启用 Developer mode,Load unpacked 选择本 skill 的 `extension/` 目录。安装后扩展会连接本地 `webext-proxy.mjs`,后续浏览器操作无需再点 remote-debugging 授权弹窗。 + +### CDP 兜底 CDP 模式需要 **Node.js 22+** 和浏览器(Chrome / Edge)开启远程调试: @@ -149,28 +163,31 @@ node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs" # 手动运行请替换为实际路径,如 ~/.claude/skills/web-access ``` -## CDP Proxy API +## Proxy API -Proxy 通过 WebSocket 直连浏览器(兼容 `chrome://inspect` / `edge://inspect` 方式,无需命令行参数启动),提供 HTTP API: +扩展后端端口是 `3457`,CDP 兜底端口是 `3456`。两者都提供常用 HTTP API: ```bash -# 启动(Agent 会自动管理 Proxy 生命周期,无需手动启动) -node "${CLAUDE_SKILL_DIR}/scripts/cdp-proxy.mjs" & +# 扩展后端 +BASE=http://127.0.0.1:3457 + +# CDP 兜底时改为 +# BASE=http://127.0.0.1:3456 # 页面操作 -curl -s -X POST --data-raw 'https://example.com' http://localhost:3456/new # 新建 tab(v2.5.3 起 URL 走 POST body) -curl -s -X POST "http://localhost:3456/eval?target=ID" -d 'document.title' # 执行 JS -curl -s -X POST "http://localhost:3456/click?target=ID" -d 'button.submit' # JS 点击 -curl -s -X POST "http://localhost:3456/clickAt?target=ID" -d '.upload-btn' # 真实鼠标点击 -curl -s -X POST "http://localhost:3456/setFiles?target=ID" \ +curl -s -X POST --data-raw 'https://example.com' "$BASE/new" # 新建 tab(v2.5.3 起 URL 走 POST body) +curl -s -X POST "$BASE/eval?target=ID" -d 'document.title' # 执行 JS +curl -s -X POST "$BASE/click?target=ID" -d 'button.submit' # JS 点击 +curl -s -X POST "$BASE/clickAt?target=ID" -d '.upload-btn' # 真实鼠标点击 +curl -s -X POST "$BASE/setFiles?target=ID" \ -d '{"selector":"input[type=file]","files":["/path/to/file.png"]}' # 文件上传 -curl -s "http://localhost:3456/screenshot?target=ID&file=/tmp/shot.png" # 截图 -curl -s "http://localhost:3456/scroll?target=ID&direction=bottom" # 滚动 -curl -s "http://localhost:3456/close?target=ID" # 关闭 tab -curl -s "http://localhost:3456/health" # 查看状态(含 managedTabs 数量) +curl -s "$BASE/screenshot?target=ID&file=/tmp/shot.png" # 截图 +curl -s "$BASE/scroll?target=ID&direction=bottom" # 滚动 +curl -s "$BASE/close?target=ID" # 关闭 tab +curl -s "$BASE/health" # 查看状态 ``` -Proxy 会自动追踪通过 `/new` 创建的 tab,闲置 15 分钟后自动关闭,防止 Agent 异常退出时留下孤儿 tab。可通过环境变量 `CDP_TAB_IDLE_TIMEOUT`(单位毫秒)调整超时时间。 +CDP Proxy 会自动追踪通过 `/new` 创建的 tab,闲置 15 分钟后自动关闭,防止 Agent 异常退出时留下孤儿 tab。可通过环境变量 `CDP_TAB_IDLE_TIMEOUT`(单位毫秒)调整超时时间。 ## ⚠️ 使用前提醒 diff --git a/SKILL.md b/SKILL.md index ce2cbf0..6bad8fc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,14 +14,24 @@ metadata: ## 前置检查 -在开始联网操作前,先检查 CDP 模式可用性: +在开始联网操作前,优先检查浏览器扩展后端可用性: ```bash -node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs" +node "${CLAUDE_SKILL_DIR}/scripts/check-webext.mjs" ``` **Node.js 22+** 必需(使用原生 WebSocket)。 +按脚本输出处理: +- `exit 0` → 扩展后端已连接,后续浏览器操作使用 `http://127.0.0.1:3457` +- `exit 1` 且提示 extension not connected → 这是一次性安装步骤。引导用户打开 `chrome://extensions` 或 `edge://extensions`,启用 Developer mode,Load unpacked 选择 `${CLAUDE_SKILL_DIR}/extension`,然后重跑检查。安装并授权扩展后,日常使用不再需要浏览器 remote-debugging 授权弹窗。 + +扩展后端不可用、目标页无法 attach,或需要扩展未覆盖的底层 CDP 能力时,再检查 CDP 兜底: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs" +``` + 按脚本输出处理: - `exit 0` → 继续 - `exit 2` → 需询问用户偏好,写入 `${CLAUDE_SKILL_DIR}/config.env` 的 `WEB_ACCESS_BROWSER` @@ -31,7 +41,7 @@ node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs" 切换浏览器时,proxy 是长驻进程,需先 `pkill -f cdp-proxy.mjs` 再重跑 check-deps。 -检查通过后并必须在回复中向用户直接展示以下须知,再启动 CDP Proxy 执行操作: +检查通过后并必须在回复中向用户直接展示以下须知,再启动浏览器自动化执行操作: ``` 温馨提示:部分站点对浏览器自动化操作检测严格,存在账号封禁风险。已内置防护措施但无法完全避免,Agent 继续操作即视为接受。 @@ -96,58 +106,72 @@ node "${CLAUDE_SKILL_DIR}/scripts/find-url.mjs" [关键词...] [--only bookmarks **站点内交互产生的链接是可靠的**:通过用户视角中的可交互单元(卡片、条目、按钮)进行的站点内交互,自然到达的 URL 天然携带平台所需的完整上下文。而手动构造的 URL 可能缺失隐式必要参数,导致被拦截、返回错误页面、甚至触发反爬。 -## 浏览器 CDP 模式 +## 浏览器模式 -通过 CDP Proxy 直连用户日常浏览器(Chrome / Edge / Chromium 等 Chromium 系),天然携带登录态,无需启动独立浏览器。 +通过浏览器扩展后端或 CDP Proxy 直连用户日常浏览器(Chrome / Edge / Chromium 等 Chromium 系),天然携带登录态,无需启动独立浏览器。 若无用户明确要求,不主动操作用户已有 tab,所有操作都在自己创建的后台 tab 中进行,保持对用户环境的最小侵入。不关闭用户 tab 的前提下,完成任务后关闭自己创建的 tab,保持环境整洁。 -### 启动 +### 扩展后端(优先) + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/check-webext.mjs" +``` + +扩展后端由本地 `scripts/webext-proxy.mjs` 暴露 HTTP API,再由 `extension/` 中的 Manifest V3 扩展通过 `chrome.debugger` / `chrome.tabs` 操作浏览器。一次性安装并授权扩展后,日常使用不再依赖 `chrome://inspect/#remote-debugging` 的授权弹窗。细节见 `references/browser-extension.md`。 + +### CDP 兜底 ```bash node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs" ``` -脚本会依次检查 Node.js、浏览器调试端口,并确保 Proxy 已连接(未运行则自动启动并等待)。Proxy 启动后持续运行。 +只有扩展后端不可用、扩展无法 attach 当前页面,或确实需要扩展后端未覆盖的底层 CDP 能力时,才运行 CDP 检查。脚本会依次检查 Node.js、浏览器调试端口,并确保 Proxy 已连接(未运行则自动启动并等待)。Proxy 启动后持续运行。 ### Proxy API -所有操作通过 curl 调用 HTTP API: +所有操作通过 curl 调用 HTTP API。扩展后端端口是 `3457`,CDP 兜底端口是 `3456`: ```bash +# 扩展后端 +BASE=http://127.0.0.1:3457 + +# CDP 兜底时改为 +# BASE=http://127.0.0.1:3456 + # 列出用户已打开的 tab -curl -s http://localhost:3456/targets +curl -s "$BASE/targets" # 创建新后台 tab(自动等待加载)— URL 走 POST body,避免目标 URL 含 query 时被切分 -curl -s -X POST --data-raw 'https://example.com' http://localhost:3456/new +curl -s -X POST --data-raw 'https://example.com' "$BASE/new" # 页面信息 -curl -s "http://localhost:3456/info?target=ID" +curl -s "$BASE/info?target=ID" # 执行任意 JS:可读写 DOM、提取数据、操控元素、触发状态变更、提交表单、调用内部方法 -curl -s -X POST "http://localhost:3456/eval?target=ID" -d 'document.title' +curl -s -X POST "$BASE/eval?target=ID" -d 'document.title' # 捕获页面渲染状态(含视频当前帧) -curl -s "http://localhost:3456/screenshot?target=ID&file=/tmp/shot.png" +curl -s "$BASE/screenshot?target=ID&file=/tmp/shot.png" # 导航(URL 走 POST body,target 走 query)、后退 -curl -s -X POST --data-raw 'https://example.com' "http://localhost:3456/navigate?target=ID" -curl -s "http://localhost:3456/back?target=ID" +curl -s -X POST --data-raw 'https://example.com' "$BASE/navigate?target=ID" +curl -s "$BASE/back?target=ID" # 点击(POST body 为 CSS 选择器)— JS el.click(),简单快速,覆盖大多数场景 -curl -s -X POST "http://localhost:3456/click?target=ID" -d 'button.submit' +curl -s -X POST "$BASE/click?target=ID" -d 'button.submit' -# 真实鼠标点击 — CDP Input.dispatchMouseEvent,算用户手势,能触发文件对话框 -curl -s -X POST "http://localhost:3456/clickAt?target=ID" -d 'button.upload' +# 真实鼠标点击 — 浏览器级鼠标事件,算用户手势,能触发文件对话框 +curl -s -X POST "$BASE/clickAt?target=ID" -d 'button.upload' # 文件上传 — 直接设置 file input 的本地文件路径,绕过文件对话框 -curl -s -X POST "http://localhost:3456/setFiles?target=ID" -d '{"selector":"input[type=file]","files":["/path/to/file.png"]}' +curl -s -X POST "$BASE/setFiles?target=ID" -d '{"selector":"input[type=file]","files":["/path/to/file.png"]}' # 滚动(触发懒加载) -curl -s "http://localhost:3456/scroll?target=ID&y=3000" -curl -s "http://localhost:3456/scroll?target=ID&direction=bottom" +curl -s "$BASE/scroll?target=ID&y=3000" +curl -s "$BASE/scroll?target=ID&direction=bottom" # 关闭 tab -curl -s "http://localhost:3456/close?target=ID" +curl -s "$BASE/close?target=ID" ``` ### 页面内导航 @@ -261,5 +285,6 @@ updated: 2026-03-19 | 文件 | 何时加载 | |------|---------| +| `references/browser-extension.md` | 需要扩展后端安装、端口、能力边界或安全说明时 | | `references/cdp-api.md` | 需要 CDP API 详细参考、JS 提取模式、错误处理时 | | `references/site-patterns/{domain}.md` | 确定目标网站后,读取对应站点经验 | diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..20c2022 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,389 @@ +const PROXY_HTTP = 'http://127.0.0.1:3457'; +const PROXY_WS = 'ws://127.0.0.1:3457/ext'; +const RECONNECT_ALARM = 'web-access-reconnect'; +const attachedTabs = new Set(); + +let ws = null; +let connectInFlight = false; +let lastConnectedAt = null; + +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) 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) return; + } catch { + return; + } finally { + connectInFlight = false; + } + + try { + ws = new WebSocket(PROXY_WS); + } catch { + return; + } + + ws.onopen = () => { + 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; + }; + + ws.onerror = () => { + try { ws?.close(); } catch {} + }; +} + +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/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..26dfd2d --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,24 @@ +{ + "manifest_version": 3, + "name": "web-access Browser Bridge", + "version": "0.1.0", + "description": "Browser automation bridge for the web-access skill. Connects Chrome/Edge tabs to a local daemon without remote-debugging prompts.", + "permissions": [ + "debugger", + "tabs", + "activeTab", + "alarms", + "storage" + ], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_title": "web-access", + "default_popup": "popup.html" + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..b40454b --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,55 @@ + + + + + web-access + + + +

web-access Browser Bridge

+
+ + Checking... +
+ node scripts/webext-proxy.mjs + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..bf2cd40 --- /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 web-access proxy'; + } else { + dot.classList.remove('connected'); + label.textContent = 'Waiting for local web-access proxy'; + } +}); diff --git a/references/browser-extension.md b/references/browser-extension.md new file mode 100644 index 0000000..53a07dc --- /dev/null +++ b/references/browser-extension.md @@ -0,0 +1,85 @@ +# Browser Extension Backend + +`web-access` now includes an optional browser-extension backend for Chrome / Edge. +It is meant to avoid Chrome remote-debugging authorization prompts during normal +use. + +## Model + +- The extension is a Manifest V3 service worker under `extension/`. +- The local daemon is `scripts/webext-proxy.mjs`. +- The extension connects outward to `ws://127.0.0.1:3457/ext`. +- The daemon exposes a CDP-proxy-like HTTP API on `http://127.0.0.1:3457`. +- Browser control is performed through extension APIs, mainly `chrome.debugger` + and `chrome.tabs`. + +This follows the same broad architecture as OpenCLI's Browser Bridge: one-time +extension installation, then local daemon communication. It does not require +Chrome's `chrome://inspect/#remote-debugging` toggle. + +## One-Time Setup + +Start the daemon and check extension connectivity: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/check-webext.mjs" +``` + +If the extension is not connected, load it once: + +1. Open `chrome://extensions` or `edge://extensions`. +2. Enable Developer mode. +3. Choose "Load unpacked". +4. Select `${CLAUDE_SKILL_DIR}/extension`. +5. Re-run `node "${CLAUDE_SKILL_DIR}/scripts/check-webext.mjs"`. + +After this one-time browser permission, future `web-access` browser operations +can use the extension backend without the remote-debugging authorization prompt. + +## API + +The extension backend mirrors the common CDP proxy endpoints: + +```bash +curl -s http://127.0.0.1:3457/health +curl -s http://127.0.0.1:3457/targets +curl -s -X POST --data-raw 'https://example.com' http://127.0.0.1:3457/new +curl -s -X POST --data-raw 'https://example.com' "http://127.0.0.1:3457/navigate?target=ID" +curl -s -X POST "http://127.0.0.1:3457/eval?target=ID" -d 'document.title' +curl -s -X POST "http://127.0.0.1:3457/click?target=ID" -d 'button.submit' +curl -s -X POST "http://127.0.0.1:3457/clickAt?target=ID" -d 'button.upload' +curl -s -X POST "http://127.0.0.1:3457/setFiles?target=ID" -d '{"selector":"input[type=file]","files":["C:\\path\\file.png"]}' +curl -s "http://127.0.0.1:3457/scroll?target=ID&direction=bottom" +curl -s "http://127.0.0.1:3457/screenshot?target=ID&file=C:\\temp\\shot.png" +curl -s "http://127.0.0.1:3457/close?target=ID" +``` + +For URL-carrying endpoints, use POST body. This avoids truncating URLs that +contain `?`, `&`, or `#`. + +## Capability Notes + +Extension backend supports the common web-access browser actions: + +- create / navigate / close tabs +- evaluate JavaScript +- click by selector +- dispatch mouse clicks at element centers +- set files on file inputs +- scroll +- screenshot + +It is not a complete replacement for raw CDP in every edge case. Keep CDP proxy +as fallback when the extension backend cannot attach to a tab, a page is not +debuggable, or a workflow needs an unsupported CDP method. + +## Security Notes + +- The daemon binds only to `127.0.0.1`. +- The extension connects only to `127.0.0.1:3457`. +- The daemon rejects HTTP requests with ordinary web page `Origin` headers and + only accepts WebSocket bridge connections from extension origins such as + `chrome-extension://...`. +- The extension asks for `debugger` permission, which is powerful. Install it + only from this local skill directory you control. +- Do not expose the daemon port beyond localhost. diff --git a/scripts/check-webext.mjs b/scripts/check-webext.mjs new file mode 100644 index 0000000..9f312f2 --- /dev/null +++ b/scripts/check-webext.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// Ensure the web-access browser-extension proxy 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', 'webext-proxy.mjs'); +const EXTENSION_DIR = path.join(ROOT, 'extension'); +const PORT = Number(process.env.WEB_ACCESS_EXT_PROXY_PORT || 3457); +const HEALTH_URL = `http://127.0.0.1:${PORT}/health`; + +async function main() { + await ensureProxy(); + const health = await getHealth(); + if (health?.connected) { + console.log(`webext: ready (${health.extension?.browser || 'browser'} ${health.extension?.version || ''})`); + process.exit(0); + } + + console.log('webext: 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 remote-debugging 授权弹窗'); + console.log(` 4. 重新运行:node "${path.join(ROOT, 'scripts', 'check-webext.mjs')}"`); + process.exit(1); +} + +async function ensureProxy() { + const health = await getHealth(); + if (health?.status === 'ok') return; + + const logFile = path.join(os.tmpdir(), 'web-access-webext-proxy.log'); + const logFd = fs.openSync(logFile, 'a'); + const child = spawn(process.execPath, [PROXY_SCRIPT], { + detached: true, + stdio: ['ignore', logFd, logFd], + ...(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; + } + throw new Error(`webext proxy did not start; see ${logFile}`); +} + +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/webext-proxy-lib.mjs b/scripts/webext-proxy-lib.mjs new file mode 100644 index 0000000..8bc0ab0 --- /dev/null +++ b/scripts/webext-proxy-lib.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: 'webext', + 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/webext-proxy.mjs b/scripts/webext-proxy.mjs new file mode 100644 index 0000000..11ef498 --- /dev/null +++ b/scripts/webext-proxy.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Browser-extension proxy for web-access. +// Exposes a CDP-proxy-compatible HTTP API and talks to the extension over WebSocket. + +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +import { + ExtensionBridge, + createHttpHandler, + handleWebSocketUpgrade, +} from './webext-proxy-lib.mjs'; + +const PORT = Number(process.env.WEB_ACCESS_EXT_PROXY_PORT || 3457); +const bridge = new ExtensionBridge(); +const server = http.createServer(createHttpHandler({ bridge })); + +server.on('upgrade', (req, socket, head) => { + handleWebSocketUpgrade(req, socket, head, bridge); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`[WebExt Proxy] listening on http://127.0.0.1:${PORT}`); + console.log(`[WebExt Proxy] extension path: ${path.resolve(import.meta.dirname, '..', 'extension')}`); + console.log(`[WebExt Proxy] log: ${path.join(os.tmpdir(), 'web-access-webext-proxy.log')}`); +}); diff --git a/tests/webext-proxy.test.mjs b/tests/webext-proxy.test.mjs new file mode 100644 index 0000000..ae2ea98 --- /dev/null +++ b/tests/webext-proxy.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/webext-proxy-lib.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); +});