From fbdaa52c528b4253ad34d605e1de1ec6dcaa2240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Thu, 9 Jul 2026 07:26:44 +0800 Subject: [PATCH] Gate browser automation MCP on external adapter (#6472) * feat(cli): gate browser automation adapter * fix(cli): close browser automation review gaps * test(cli): cover browser automation gates * fix(cli): close browser automation review gaps * fix(cli): close browser automation review gaps --- docs/developers/qwen-serve-protocol.md | 6 +- docs/users/qwen-serve.md | 1 + packages/chrome-extension/README.md | 24 ++- .../docs/06-plan-c-cdp-tunnel.md | 29 ++- .../chrome-extension/public/sidepanel.html | 71 ++++---- packages/chrome-extension/public/sidepanel.js | 6 +- .../src/background/cdp-bridge.ts | 4 +- packages/cli/src/serve/acp-http/index.ts | 10 +- .../cli/src/serve/acp-http/transport.test.ts | 4 +- packages/cli/src/serve/capabilities.ts | 15 ++ packages/cli/src/serve/cdp-mcp-command.ts | 27 +++ packages/cli/src/serve/run-qwen-serve.test.ts | 45 +++++ packages/cli/src/serve/run-qwen-serve.ts | 2 + packages/cli/src/serve/server.test.ts | 52 ++++++ .../cli/src/serve/server/serve-features.ts | 2 + packages/core/src/hooks/urlValidator.test.ts | 6 +- scripts/create-standalone-package.js | 8 +- scripts/prepare-package.js | 167 +++++++++++------- scripts/tests/package-assets.test.js | 144 ++++++--------- 19 files changed, 396 insertions(+), 227 deletions(-) create mode 100644 packages/cli/src/serve/cdp-mcp-command.ts diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 9c8077852c..d8c337ab8d 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -186,7 +186,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'permission_mediation', 'prompt_absolute_deadline', 'writer_idle_timeout', 'non_blocking_prompt', 'session_language', 'session_rewind', 'workspace_hooks', 'session_hooks', 'workspace_extensions', - 'session_branch', 'rate_limit', 'workspace_reload'] + 'session_branch', 'rate_limit', 'workspace_reload', + 'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp'] ``` > Conditional tags appear only when their matching deployment toggle is on (see the table below). F3's `permission_mediation` tag is always-on and carries `modes: ['first-responder', 'designated', 'consensus', 'local-only']` so SDK clients can introspect the build-supported set; the runtime-active strategy is at `body.policy.permission`. @@ -243,6 +244,9 @@ operator diagnostic snapshot documented below. | `session_shell_command` | session shell execution is explicitly enabled. | | `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. | | `workspace_reload` | workspace reload support is available in the embedded route configuration. | +| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. | +| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. | +| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. | `mcp_guardrails` is **not** in this conditional table — it's an always-on tag, advertised whenever the binary supports the new `/workspace/mcp` budget fields, regardless of whether the operator configured a budget. Operators who haven't set `--mcp-client-budget` still get the new fields (with `budgetMode: 'off'`, `budgets: []`). diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index a8d42092de..240cad3fbc 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -366,6 +366,7 @@ Notes: - **`LOOPBACK_BINDS` includes IPv6** — `::1` and `[::1]` count as loopback for the no-token rule. - **Host header allowlist** — on **loopback** binds the daemon checks `Host:` matches `localhost:port` / `127.0.0.1:port` / `[::1]:port` / `host.docker.internal:port` (case-insensitive per RFC 7230 §5.4) to defend against DNS rebinding. **Non-loopback binds (`--hostname 0.0.0.0`) intentionally bypass the Host allowlist** — the operator has chosen the surface area, so the bearer-token gate is the sole authentication layer; reverse proxies / SNI / client cert pinning are the operator's responsibility, not the daemon's. If you need Host-based isolation on a non-loopback bind, terminate TLS + check Host at a front proxy. - **CORS denies any browser Origin by default** — returns `403` JSON. Pass **`--allow-origin `** (repeatable, T2.4 #4514) to opt specific browser origins through. Each value is either the literal `*` (any origin — boot refuses if no bearer token is configured; `--require-auth` on loopback is recommended for full hardening since `/health` and `/demo` remain pre-auth on loopback by default) or a canonical URL origin (`://[:]`, no trailing slash / path / userinfo). Matched origins receive proper CORS response headers (`Access-Control-Allow-Origin: `, `Vary: Origin`, plus standard methods / headers / max-age and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as the default wall. `caps.features.allow_origin` is advertised conditionally so SDK / webui clients can pre-flight whether the daemon honors cross-origin hits before issuing them. Example: `qwen serve --allow-origin http://localhost:3000 --allow-origin http://localhost:5173`. Loopback self-origin hits (e.g. the `/demo` page) are unaffected — a separate Origin-strip shim handles them regardless of `--allow-origin`. **Browser webuis without `--allow-origin` configured** still fall back to the same Stage 1 options as before: package as a native shell (Electron/Tauri) so no `Origin` header is sent, or front the daemon with a same-origin reverse proxy. +- **Chrome extension browser automation is separate from framing.** `qwen serve --allow-origin chrome-extension://` lets the extension frame the Web Shell and connect to the daemon. Console/network/screenshot/click tools require an external CDP MCP adapter command: `QWEN_CDP_MCP_COMMAND=/path/to/cdp-mcp-adapter qwen serve --allow-origin chrome-extension://`. The main CLI package does not bundle a browser automation adapter; clients can check `caps.features.includes('browser_automation_mcp')` before presenting those tools as available. - **Spawned `qwen --acp` child inherits the daemon's environment** with one explicit scrub: `QWEN_SERVER_TOKEN` is removed before the child starts (the daemon's own bearer; the agent doesn't need it). Everything else — `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `QWEN_*` / `DASHSCOPE_API_KEY` / your custom `modelProviders[].envKey` / etc. — passes through, because the agent legitimately needs those to authenticate to the LLM. **This is intentional, not a sandbox.** The agent runs as the same UID with shell-tool access, so anything in `~/.bashrc` / `~/.aws/credentials` / `~/.npmrc` is reachable by prompt injection regardless. The env passthrough is not the security boundary; the user-as-trust-root is. Don't run `qwen serve` under an identity that has env-resident credentials you wouldn't trust the agent with. - **Per-subscriber bounded SSE queues** — a slow client that overflows its queue gets a `client_evicted` terminal frame and is closed; one stuck consumer can't pin the daemon. - **Per-session prompt admission cap** — defaults to 5 accepted-but-unsettled prompts per session. A buggy client cannot enqueue unbounded prompt promises or temporary SSE waits for one session. diff --git a/packages/chrome-extension/README.md b/packages/chrome-extension/README.md index 4ff781f7ee..05f3f221de 100644 --- a/packages/chrome-extension/README.md +++ b/packages/chrome-extension/README.md @@ -10,8 +10,7 @@ It does two things: daemon serves to the browser. The panel has no UI of its own. - **Service worker** — a CDP-tunnel pipe. It connects to the daemon's `/acp` WebSocket and bridges `cdp_*` frames into `chrome.debugger`, so the agent can - drive the real browser (read page, screenshot, click, …) via - chrome-devtools-mcp over the tunnel. + drive the real browser when an external CDP MCP adapter is configured. ## Build @@ -41,6 +40,27 @@ extension's requests. The side panel reads the id at runtime via Once the daemon is reachable and permits framing, the side panel swaps the welcome screen for the chat UI automatically. +## Browser Automation Tools + +The command above only makes the side panel and Web Shell available. Browser +automation tools such as console/network inspection, screenshots, and page +clicking require an explicit external MCP adapter command: + +```bash +QWEN_CDP_MCP_COMMAND=/path/to/cdp-mcp-adapter \ +qwen serve --allow-origin chrome-extension:// +``` + +No browser automation adapter is bundled with the main `@qwen-code/qwen-code` +package. When `QWEN_CDP_MCP_COMMAND` is unset, the extension can still open the +Web Shell, but the daemon will not register browser automation MCP tools. +Clients can distinguish the states through `/capabilities`: + +- `allow_origin` means the extension may frame and call the daemon. +- `cdp_tunnel_over_ws` means the daemon exposes the reverse CDP tunnel. +- `browser_automation_mcp` means the external adapter command is configured and + browser automation MCP tools can be registered when the CDP bridge connects. + ## Onboarding states The side panel probes `GET /health` and `GET /capabilities` and shows one of: diff --git a/packages/chrome-extension/docs/06-plan-c-cdp-tunnel.md b/packages/chrome-extension/docs/06-plan-c-cdp-tunnel.md index b228c62b43..99106dd8ce 100644 --- a/packages/chrome-extension/docs/06-plan-c-cdp-tunnel.md +++ b/packages/chrome-extension/docs/06-plan-c-cdp-tunnel.md @@ -1,14 +1,14 @@ -# Plan C — CDP 隧道:复用 chrome-devtools-mcp 全套工具驱动真实浏览器 +# Plan C — CDP 隧道:复用 DevTools MCP 工具驱动真实浏览器 > 设计文档(可行性 + 实施方案)。配套:[`05-daemon-direct-architecture.md`](./05-daemon-direct-architecture.md)(Phase 1/2 已落地:side panel + 逆向工具通道 `chrome-tools`)。 > 关联 issue #5626 / PR #5777。 ## 0. TL;DR -- **问题**:当前 `chrome-tools` 逆向通道(Plan A)在扩展端用 `chrome.*` **重新实现** chrome-devtools-mcp 已有的能力(console / network / screenshot…),每加一个新能力都要手写 executor。 -- **Plan C**:扩展只用 `chrome.debugger` 把真实标签页的 **CDP 协议**透传给 daemon,daemon 暴露一个 CDP endpoint 让 **chrome-devtools-mcp(puppeteer)连进来** —— 复用它现成的全套工具,操作用户**真实**浏览器,不再逐个手写。 -- **结论:有条件可行**。能让 puppeteer 连上单 tab(命题 A,可行);但「把*未改的* chrome-devtools-mcp 直接接上」(命题 B)**不可行 —— 必须给它打一个小补丁**(patch-package,~2 处 / 十几行 diff)。 -- **形态**:不扣代码、不 submodule。`chrome-devtools-mcp` 从 `npx @latest` 改成仓库 pin 依赖 `1.4.0` + 一个 `patches/chrome-devtools-mcp+1.4.0.patch`(与现有 `ink+7.0.3.patch` 同形态)。 +- **问题**:当前 `chrome-tools` 逆向通道(Plan A)在扩展端用 `chrome.*` **重新实现**上游 DevTools MCP server 已有的能力(console / network / screenshot…),每加一个新能力都要手写 executor。 +- **Plan C**:扩展只用 `chrome.debugger` 把真实标签页的 **CDP 协议**透传给 daemon,daemon 暴露一个 CDP endpoint 让 **外部 DevTools MCP adapter 连进来** —— 复用它现成的全套工具,操作用户**真实**浏览器,不再逐个手写。 +- **结论:有条件可行**。能让浏览器自动化运行时连上单 tab(命题 A,可行);但「把*未改的*上游 adapter 直接接上」(命题 B)**不可行 —— 必须给它打一个小补丁**(patch-package,~2 处 / 十几行 diff)。 +- **形态(历史方案,已不作为主包方案)**:早期评估过 pin 上游 adapter + patch-package。0.19.4 的包扫描问题证明这类依赖、patch 和安装钩子不应进入主 CLI 包;后续应走显式外部 adapter 命令。 - **建议**:先做 Phase 0 半天 spike 钉死那堵墙,再决定是否全量上。 ## 1. 决定性发现:为什么「零改造复用」不行(已逐行核实 1.4.0 源码) @@ -41,7 +41,7 @@ ## 2. 架构 ``` -chrome-devtools-mcp(fork) qwen serve daemon 扩展(MV3) 真实 tab +DevTools MCP adapter qwen serve daemon 扩展(MV3) 真实 tab puppeteer.connect browser级 ┌ /cdp WS endpoint ┐ reverse cdp-bridge chrome. ({browserWS}) ───CDP───────▶ │ CdpBrowserEmulator│ WS /acp chrome. debugger │ ①本地应答browser域│ cdp_command .debugger ──▶ Page/DOM @@ -54,7 +54,7 @@ chrome-devtools-mcp(fork) qwen serve daemon 扩展(MV3) 组件: -- **chrome-devtools-mcp (patched) + puppeteer-core**:`puppeteer.connect({ browserWSEndpoint: 'ws://127.0.0.1:PORT/cdp' })`。 +- **DevTools MCP adapter + browser automation runtime**:通过 `browserWSEndpoint: 'ws://127.0.0.1:PORT/cdp'` 连接 daemon 暴露的 CDP endpoint。 - **daemon `/cdp` WS endpoint(新增)**:raw CDP 帧 + `CdpBrowserEmulator`(browser-level 合成 + sessionId 打/解标签)。 - **daemon 现有 `/acp` reverse WS**:扩展已建立、过了 ACP-initialize 鉴权的那条 socket,新增 `cdp_*` 帧。 - **cdp-reverse-link**:把 `/cdp` puppeteer socket 与扩展 `/acp` 连接配对(单 daemon = 单扩展 = 单 browser)。 @@ -85,20 +85,13 @@ chrome-devtools-mcp(fork) qwen serve daemon 扩展(MV3) | remorses/playwriter(MIT, 3.6k★, 活跃) | 扩展 + WS CDP relay,外部 client `connectOverCDP` 驱动真实已登录浏览器。 | | 本仓库 `/acp` reverse 通道 | `WebSocketServer({noServer:true})` + pathname 分支 + `clientMcpOverWs` 式 feature-flag + `mcp_*` 帧拦截 —— `cdp_*` 帧照搬,鉴权/CSRF/origin/maxPayload 全复用。 | -## 5. patch-package 形态(不扣代码、不 submodule) +## 5. 历史 patch-package 形态(不再作为主包方案) -本仓库已用 patch-package(`postinstall: patch-package` + `patches/ink+7.0.3.patch` 样例)。 - -```jsonc -// package.json -"dependencies": { "chrome-devtools-mcp": "1.4.0" } // pin,不再 npx @latest -``` - -流程:改 `node_modules/chrome-devtools-mcp/build/src/McpContext.js`(try-catch 包 #init:81-82)→ `npx patch-package chrome-devtools-mcp` → 生成 `patches/chrome-devtools-mcp+1.4.0.patch`(进 git)→ `npm install` 自动应用。puppeteer-core pin 25.2.0(ExtensionTransport 拓扑硬编码,避免版本漂移)。 +早期方案考虑过把上游 adapter 作为 pin 依赖并用 patch-package 修补启动路径。这个形态会把浏览器自动化依赖、依赖 patch 和安装钩子放进主包/安装图,容易触发包扫描器。当前方案不再把 adapter 打进主 CLI 包,而是通过显式外部命令接入。 ## 6. 分阶段实施 -- **Phase 0 — spike(0.5–1 天)**:独立脚本起最小 `/cdp` + ExtensionTransport 4 命令合成,跑*未改的* `chrome-devtools-mcp@1.4.0 --wsEndpoint ws://…/cdp` 调一次 `take_snapshot`,**确认它在 `McpContext.from()` 抛 `CDPSession creation failed.`** → 钉死 fork 范围。 +- **Phase 0 — spike(0.5–1 天)**:独立脚本起最小 `/cdp` + ExtensionTransport 4 命令合成,跑*未改的*上游 adapter 调一次 `take_snapshot`,**确认它在 `McpContext.from()` 抛 `CDPSession creation failed.`** → 钉死 fork 范围。 - **Phase 1 — MVP(5–8 天)**:单 tab `take_snapshot`/`click` 跑通。 - daemon 新增 `packages/cli/src/serve/cdp-tunnel/{cdp-ws,cdp-browser-emulator,cdp-reverse-link}.ts`。 - daemon 改 `acp-http/index.ts`(upgrade 加 `/cdp` 分支,复用 auth/CSRF/origin)+ reverse WS 加 `isCdpFrameType` 守卫;`run-qwen-serve.ts`/`server.ts`/`serve/types.ts`/`serve/capabilities.ts` 加 `cdpTunnelOverWs` flag(仿 `clientMcpOverWs`,默认 OFF)。 @@ -122,7 +115,7 @@ chrome-devtools-mcp(fork) qwen serve daemon 扩展(MV3) | 调试 banner / DevTools 互斥 | 中(UX) | onDetach 重连 + 用户提示 | | 受管 Chrome 策略 `DeveloperToolsAvailability` | 中(部署) | 部署前核查策略允许 chrome.debugger(force-installed 扩展 114+ 默认禁,需值 1) | | 大 payload(截图/getResponseBody) vs maxPayload | 中 | `/cdp` 抬高 maxPayload 或分块 | -| puppeteer 版本漂移 | 低 | pin cdp-mcp 1.4.0 + puppeteer-core 25.2.0 | +| 浏览器自动化运行时版本漂移 | 低 | 外部 adapter 自己 pin 版本;主 CLI 包不携带该依赖 | **回退**:Phase 0 失败 → 放弃 C 回 A;Phase 1 失败 → 回 A(合成层/透传可作 A 底座);Phase 2/3 失败 → 退守单 tab 只读快照/点击形态,仍省大量工具实现。 diff --git a/packages/chrome-extension/public/sidepanel.html b/packages/chrome-extension/public/sidepanel.html index c9a372d1e0..b4b7ea9f73 100644 --- a/packages/chrome-extension/public/sidepanel.html +++ b/packages/chrome-extension/public/sidepanel.html @@ -6,41 +6,43 @@ Qwen Code