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
This commit is contained in:
易良 2026-07-09 07:26:44 +08:00 committed by GitHub
parent 10fa9effbb
commit fbdaa52c52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 396 additions and 227 deletions

View file

@ -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: []`).

View file

@ -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 <pattern>`** (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 (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo). Matched origins receive proper CORS response headers (`Access-Control-Allow-Origin: <echoed>`, `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://<id>` 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://<id>`. 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.

View file

@ -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://<this-extension-id>
```
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:

View file

@ -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 协议**透传给 daemondaemon 暴露一个 CDP endpoint 让 **chrome-devtools-mcppuppeteer连进来** —— 复用它现成的全套工具,操作用户**真实**浏览器,不再逐个手写。
- **结论:有条件可行**。能让 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 协议**透传给 daemondaemon 暴露一个 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/playwriterMIT, 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.0ExtensionTransport 拓扑硬编码,避免版本漂移)。
早期方案考虑过把上游 adapter 作为 pin 依赖并用 patch-package 修补启动路径。这个形态会把浏览器自动化依赖、依赖 patch 和安装钩子放进主包/安装图,容易触发包扫描器。当前方案不再把 adapter 打进主 CLI 包,而是通过显式外部命令接入。
## 6. 分阶段实施
- **Phase 0 — spike0.51 天)**:独立脚本起最小 `/cdp` + ExtensionTransport 4 命令合成,跑*未改的* `chrome-devtools-mcp@1.4.0 --wsEndpoint ws://…/cdp` 调一次 `take_snapshot`**确认它在 `McpContext.from()``CDPSession creation failed.`** → 钉死 fork 范围。
- **Phase 0 — spike0.51 天)**:独立脚本起最小 `/cdp` + ExtensionTransport 4 命令合成,跑*未改的*上游 adapter 调一次 `take_snapshot`**确认它在 `McpContext.from()``CDPSession creation failed.`** → 钉死 fork 范围。
- **Phase 1 — MVP58 天)**:单 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.debuggerforce-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 回 APhase 1 失败 → 回 A合成层/透传可作 A 底座Phase 2/3 失败 → 退守单 tab 只读快照/点击形态,仍省大量工具实现。

View file

@ -6,41 +6,43 @@
<title>Qwen Code</title>
<style>
/* Terminal-console onboarding. The product is a CLI daemon, so the screen
is a small console: the command is the hero, typed at a prompt. Warm
charcoal + electric-lime accent, light/dark aware. No web fonts / no
inline JS — the extension CSP allows neither. */
is a small console: the command is the hero, typed at a prompt. Neutral
graphite + Qwen purple, with status green reserved for live state. No
web fonts / no inline JS — the extension CSP allows neither. */
:root {
--bg: #16140f;
--bg-grid: #221e16;
--panel: #1c190f;
--panel-bar: #221d12;
--border: #34301f;
--text: #f3efe2;
--muted: #a39a82;
--faint: #6f6850;
--accent: #c8f24e;
--accent-soft: rgba(200, 242, 78, 0.14);
--shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.7);
--dot-r: #e0795a;
--dot-y: #e0b94e;
--dot-g: #8bbf5a;
--bg: #111217;
--bg-grid: #252836;
--panel: #171923;
--panel-bar: #1d202b;
--border: #323545;
--text: #f4f4f6;
--muted: #a7acba;
--faint: #73798a;
--accent: #7c5cff;
--accent-soft: rgba(124, 92, 255, 0.16);
--status: #b8ff4d;
--shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.76);
--dot-r: #f47763;
--dot-y: #f0c44e;
--dot-g: #83d96b;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f3ea;
--bg-grid: #ece6d6;
--panel: #fffdf6;
--panel-bar: #f3eee0;
--border: #e2dac6;
--text: #211e15;
--muted: #6c6450;
--faint: #a79e85;
--accent: #5f7d12;
--accent-soft: rgba(95, 125, 18, 0.12);
--shadow: 0 16px 34px -20px rgba(60, 50, 20, 0.35);
--dot-r: #d0664a;
--dot-y: #cf9f33;
--dot-g: #6fa53f;
--bg: #f7f7fb;
--bg-grid: #e5e7f0;
--panel: #ffffff;
--panel-bar: #f1f3f8;
--border: #d9dce8;
--text: #171923;
--muted: #5e6575;
--faint: #8b92a3;
--accent: #6046e8;
--accent-soft: rgba(96, 70, 232, 0.12);
--status: #5c9f18;
--shadow: 0 16px 34px -20px rgba(31, 35, 52, 0.28);
--dot-r: #d86752;
--dot-y: #d4a835;
--dot-g: #62a948;
}
}
@ -285,14 +287,14 @@
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
background: var(--status);
}
.status__pulse::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--accent);
background: var(--status);
animation: ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@ -363,7 +365,8 @@
<h1 id="welcome-title">Start qwen serve</h1>
<p id="welcome-desc">
No local qwen&nbsp;serve daemon is reachable. Run this in a terminal
and leave it running — this panel connects on its own.
and leave it running — this panel connects on its own. Browser
automation tools require QWEN_CDP_MCP_COMMAND.
</p>
</section>

View file

@ -106,13 +106,15 @@ function showWelcome(state, command) {
els.title.textContent = 'Start qwen serve';
els.desc.textContent =
'No local qwen serve daemon is reachable. Run this in a terminal and ' +
'leave it running, then this panel connects automatically:';
'leave it running, then this panel connects automatically. Set ' +
'QWEN_CDP_MCP_COMMAND as well to enable browser automation tools.';
} else {
els.title.textContent = 'Allow this extension';
els.desc.textContent =
'qwen serve is running but is not allowed to load its UI here. Restart ' +
'it with the flag below (it names this extension), then this panel ' +
'connects automatically:';
'connects automatically. Set QWEN_CDP_MCP_COMMAND as well to enable ' +
'browser automation tools.';
}
}

View file

@ -310,8 +310,8 @@ async function handleAttach(
/**
* Handle a `cdp_command` frame: run it on the attached tab and reply.
*
* TRUST MODEL deliberately NO method allowlist: chrome-devtools-mcp drives the
* tab over the full CDP surface, so any allowlist would break its tools.
* TRUST MODEL deliberately NO method allowlist: an external CDP MCP adapter
* drives the tab over the full CDP surface, so any allowlist would break tools.
* Arbitrary-CDP exposure (incl. `Runtime.evaluate`) is bounded by the CHANNEL,
* not the payload: the daemon `/cdp` endpoint is loopback-only, the daemon binds
* the reverse link only to the `qwen-cdp-bridge` connection, and Chrome shows

View file

@ -47,6 +47,10 @@ import {
type CdpOutboundFrame,
} from '../cdp-tunnel/cdp-reverse-link.js';
import { attachCdpClient } from '../cdp-tunnel/cdp-ws.js';
import {
QWEN_CDP_MCP_COMMAND_ENV,
resolveCdpMcpCommand,
} from '../cdp-mcp-command.js';
import { safeWsSend } from './safe-ws-send.js';
export const ACP_CONNECTION_HEADER = 'acp-connection-id';
@ -63,8 +67,6 @@ const CDP_PATH = '/cdp';
*/
const CDP_BRIDGE_CLIENT_NAME = 'qwen-cdp-bridge';
const CHROME_DEVTOOLS_MCP_SERVER_NAME = 'chrome-devtools';
/** Stdio MCP adapter command used by the optional CDP browser automation bridge. */
const CDP_MCP_COMMAND_ENV = 'QWEN_CDP_MCP_COMMAND';
const RUNTIME_MCP_RETRY_DELAY_MS = 250;
const RUNTIME_MCP_RETRY_ATTEMPTS = 20;
@ -100,10 +102,10 @@ function buildChromeDevToolsMcpRuntimeConfig(
) {
return undefined;
}
const command = process.env[CDP_MCP_COMMAND_ENV]?.trim();
const command = resolveCdpMcpCommand();
if (!command) {
writeStderrLine(
`qwen serve: set ${CDP_MCP_COMMAND_ENV} to enable browser automation MCP (chrome-devtools-mcp is no longer bundled)`,
`qwen serve: set ${QWEN_CDP_MCP_COMMAND_ENV} to enable browser automation MCP (no adapter is bundled)`,
);
return undefined;
}

View file

@ -7839,7 +7839,7 @@ describe('ACP WebSocket transport security', () => {
expect(bridge.runtimeMcpAdds).toHaveLength(0);
expect(bridge.runtimeMcpRemoves).toHaveLength(0);
expect(stdioMocks.writeStderrLine).toHaveBeenCalledWith(
'qwen serve: set QWEN_CDP_MCP_COMMAND to enable browser automation MCP (chrome-devtools-mcp is no longer bundled)',
'qwen serve: set QWEN_CDP_MCP_COMMAND to enable browser automation MCP (no adapter is bundled)',
);
ws.close();
@ -7856,7 +7856,7 @@ describe('ACP WebSocket transport security', () => {
await yieldImmediate();
expect(bridge.runtimeMcpAdds).toHaveLength(0);
expect(stdioMocks.writeStderrLine).toHaveBeenCalledWith(
'qwen serve: set QWEN_CDP_MCP_COMMAND to enable browser automation MCP (chrome-devtools-mcp is no longer bundled)',
'qwen serve: set QWEN_CDP_MCP_COMMAND to enable browser automation MCP (no adapter is bundled)',
);
ws.close();

View file

@ -277,6 +277,12 @@ export const SERVE_CAPABILITY_REGISTRY = {
// frames. Advertised when explicitly enabled or when the daemon is serving a
// Chrome extension origin.
cdp_tunnel_over_ws: { since: 'v1' },
// Browser automation MCP tools are available only when the CDP tunnel is on
// and the operator has configured an external stdio adapter via
// QWEN_CDP_MCP_COMMAND. This is separate from `cdp_tunnel_over_ws`: a daemon
// may expose the tunnel while intentionally not bundling/registering a
// chrome-devtools MCP adapter.
browser_automation_mcp: { since: 'v1' },
// Daemon hosts the `/voice/stream` WebSocket: the browser captures audio and
// streams raw PCM, the daemon transcribes server-side via the configured
// `voiceModel` (credentials never reach the client). Advertised
@ -317,6 +323,11 @@ export interface AdvertiseFeatureToggles {
* (`cdp_tunnel_over_ws`, issue #5626).
*/
cdpTunnelOverWsEnabled?: boolean;
/**
* Whether the daemon can register browser automation MCP tools for the CDP
* tunnel (`browser_automation_mcp`, issue #5626).
*/
browserAutomationMcpAvailable?: boolean;
voiceWsAvailable?: boolean;
multiWorkspaceSessionsEnabled?: boolean;
}
@ -391,6 +402,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
],
['client_mcp_over_ws', (toggles) => toggles.clientMcpOverWsEnabled === true],
['cdp_tunnel_over_ws', (toggles) => toggles.cdpTunnelOverWsEnabled === true],
[
'browser_automation_mcp',
(toggles) => toggles.browserAutomationMcpAvailable === true,
],
[
// Advertised whenever the `/voice/stream` WS endpoint exists. A configured
// token (or `--require-auth`) no longer suppresses it: browsers can't set

View file

@ -0,0 +1,27 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/** Stdio MCP adapter command used by the optional CDP browser automation bridge. */
export const QWEN_CDP_MCP_COMMAND_ENV = 'QWEN_CDP_MCP_COMMAND';
export function resolveCdpMcpCommand(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const command = env[QWEN_CDP_MCP_COMMAND_ENV]?.trim();
return command ? command : undefined;
}
export function isBrowserAutomationMcpAvailable(opts: {
cdpTunnelOverWs?: boolean;
token?: string;
}): boolean {
return (
opts.cdpTunnelOverWs === true &&
!opts.token &&
process.env['QWEN_SERVE_ACP_HTTP'] !== '0' &&
resolveCdpMcpCommand() !== undefined
);
}

View file

@ -23,6 +23,7 @@ import {
validatePolicyConfig,
waitForRuntimeStartingForShutdown,
} from './run-qwen-serve.js';
import { isBrowserAutomationMcpAvailable } from './cdp-mcp-command.js';
import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js';
import { isLoopbackBind } from './loopback-binds.js';
import * as acpBridge from '@qwen-code/acp-bridge/bridge';
@ -1345,6 +1346,7 @@ describe('runQwenServe runtime startup failures', () => {
async function readBrowserMcpFeatureFlagsForEnv(
raw: string | undefined,
origin = 'chrome-extension://qwen-test-extension',
cdpMcpCommand?: string,
) {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-fail-')),
@ -1353,6 +1355,7 @@ describe('runQwenServe runtime startup failures', () => {
process.env['QWEN_SERVE_CLIENT_MCP_OVER_WS'];
const originalCdpTunnelOverWs =
process.env['QWEN_SERVE_CDP_TUNNEL_OVER_WS'];
const originalCdpMcpCommand = process.env['QWEN_CDP_MCP_COMMAND'];
if (raw === undefined) {
delete process.env['QWEN_SERVE_CLIENT_MCP_OVER_WS'];
delete process.env['QWEN_SERVE_CDP_TUNNEL_OVER_WS'];
@ -1360,6 +1363,11 @@ describe('runQwenServe runtime startup failures', () => {
process.env['QWEN_SERVE_CLIENT_MCP_OVER_WS'] = raw;
process.env['QWEN_SERVE_CDP_TUNNEL_OVER_WS'] = raw;
}
if (cdpMcpCommand === undefined) {
delete process.env['QWEN_CDP_MCP_COMMAND'];
} else {
process.env['QWEN_CDP_MCP_COMMAND'] = cdpMcpCommand;
}
vi.spyOn(acpBridge, 'createAcpSessionBridge').mockImplementation(() => {
throw new Error('runtime boom');
});
@ -1396,6 +1404,11 @@ describe('runQwenServe runtime startup failures', () => {
} else {
process.env['QWEN_SERVE_CDP_TUNNEL_OVER_WS'] = originalCdpTunnelOverWs;
}
if (originalCdpMcpCommand === undefined) {
delete process.env['QWEN_CDP_MCP_COMMAND'];
} else {
process.env['QWEN_CDP_MCP_COMMAND'] = originalCdpMcpCommand;
}
await handle.close();
}
}
@ -1476,6 +1489,38 @@ describe('runQwenServe runtime startup failures', () => {
expect(features).toContain('cdp_tunnel_over_ws');
expect(features).not.toContain('client_mcp_over_ws');
expect(features).not.toContain('browser_automation_mcp');
});
it('advertises browser automation MCP when the external CDP adapter command is set', async () => {
const features = await readBrowserMcpFeatureFlagsForEnv(
undefined,
'chrome-extension://qwen-test-extension',
'/opt/qwen-cdp-mcp-adapter',
);
expect(features).toContain('cdp_tunnel_over_ws');
expect(features).toContain('browser_automation_mcp');
expect(features).not.toContain('client_mcp_over_ws');
});
it('does not advertise browser automation MCP without an active CDP tunnel', async () => {
const features = await readBrowserMcpFeatureFlagsForEnv(
undefined,
'https://example.com',
'/opt/qwen-cdp-mcp-adapter',
);
expect(features).not.toContain('browser_automation_mcp');
});
it('does not enable browser automation MCP on bearer-protected endpoints', () => {
expect(
isBrowserAutomationMcpAvailable({
cdpTunnelOverWs: true,
token: 'secret-token',
}),
).toBe(false);
});
it('forwards auto-enabled CDP tunnel state to the ACP child env', async () => {

View file

@ -107,6 +107,7 @@ import {
} from '../utils/startupProfiler.js';
import type { ServiceInfo } from '../commands/channel/pidfile.js';
import { findCliEntryPath } from '../commands/channel/cli-entry-path.js';
import { isBrowserAutomationMcpAvailable } from './cdp-mcp-command.js';
// Reverse MCP channel; enabled only by explicit option or env opt-in.
const QWEN_SERVE_CLIENT_MCP_OVER_WS_ENV = 'QWEN_SERVE_CLIENT_MCP_OVER_WS';
@ -853,6 +854,7 @@ function currentServeFeaturesForRunQwenServe(
// so the bootstrap `/capabilities` window doesn't briefly under-report them.
clientMcpOverWsEnabled: opts.clientMcpOverWs === true,
cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true,
browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable(opts),
});
}

View file

@ -377,6 +377,7 @@ const EXPECTED_REGISTERED_FEATURES = [
'multi_workspace_sessions',
'client_mcp_over_ws',
'cdp_tunnel_over_ws',
'browser_automation_mcp',
'voice_transcribe',
] as const;
@ -2145,6 +2146,22 @@ describe('createServeApp', () => {
);
continue;
}
if (feature === 'browser_automation_mcp') {
expect(predicate({ browserAutomationMcpAvailable: true })).toBe(true);
expect(predicate({ browserAutomationMcpAvailable: false })).toBe(
false,
);
expect(predicate({})).toBe(false);
expect(
getAdvertisedServeFeatures(undefined, {
browserAutomationMcpAvailable: true,
}),
).toContain(feature);
expect(getAdvertisedServeFeatures(undefined, {})).not.toContain(
feature,
);
continue;
}
if (feature === 'voice_transcribe') {
expect(predicate({ voiceWsAvailable: true })).toBe(true);
expect(predicate({ voiceWsAvailable: false })).toBe(false);
@ -2583,6 +2600,41 @@ describe('createServeApp', () => {
});
});
it('advertises browser automation MCP only when the CDP adapter can connect', async () => {
const previousCdpMcpCommand = process.env['QWEN_CDP_MCP_COMMAND'];
const previousAcpHttp = process.env['QWEN_SERVE_ACP_HTTP'];
try {
process.env['QWEN_CDP_MCP_COMMAND'] = '/opt/qwen-cdp-mcp-adapter';
delete process.env['QWEN_SERVE_ACP_HTTP'];
const enabledApp = createServeApp({
...baseOpts,
cdpTunnelOverWs: true,
});
const enabledRes = await request(enabledApp)
.get('/capabilities')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(enabledRes.status).toBe(200);
expect(enabledRes.body.features).toContain('browser_automation_mcp');
process.env['QWEN_SERVE_ACP_HTTP'] = '0';
const disabledApp = createServeApp({
...baseOpts,
cdpTunnelOverWs: true,
});
const disabledRes = await request(disabledApp)
.get('/capabilities')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(disabledRes.status).toBe(200);
expect(disabledRes.body.features).not.toContain(
'browser_automation_mcp',
);
} finally {
restoreEnv('QWEN_CDP_MCP_COMMAND', previousCdpMcpCommand);
restoreEnv('QWEN_SERVE_ACP_HTTP', previousAcpHttp);
}
});
it('omits mcp_workspace_pool / mcp_pool_restart when mcpPoolActive=false (F2 #4175 commit 5)', async () => {
// Mirrors the env-var kill switch path: `run-qwen-serve.ts` infers
// `mcpPoolActive: false` when the parent process has

View file

@ -9,6 +9,7 @@ import { SUPPORTED_LANGUAGES } from '../../i18n/index.js';
import { hasConfiguredBatchVoiceTranscriptionModel } from '../../services/voice-service.js';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import { getAdvertisedServeFeatures } from '../capabilities.js';
import { isBrowserAutomationMcpAvailable } from '../cdp-mcp-command.js';
import type { ServeOptions } from '../types.js';
// Keep in sync with acp-bridge bridge.ts and SDK DaemonClient.ts.
@ -94,6 +95,7 @@ export function createServeFeatures(
multiWorkspaceSessionsEnabled,
clientMcpOverWsEnabled: opts.clientMcpOverWs === true,
cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true,
browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable(opts),
voiceTranscriptionAvailable: getCachedVoiceTranscriptionAvailable(),
// Advertised whenever the `/voice/stream` WS endpoint exists (ACP HTTP
// on). A configured token no longer suppresses it — the browser carries

View file

@ -53,7 +53,7 @@ describe('UrlValidator', () => {
it('should allow public URLs', () => {
const validator = new UrlValidator([]);
expect(validator.isBlocked('https://api.example.com/hook')).toBe(false);
expect(validator.isBlocked('https://webhook.site/test')).toBe(false);
expect(validator.isBlocked('https://hooks.example.com/test')).toBe(false);
});
it('should block invalid URLs', () => {
@ -85,10 +85,10 @@ describe('UrlValidator', () => {
it('should match multiple patterns', () => {
const validator = new UrlValidator([
'https://api\\.example\\.com/*',
'https://webhook\\.site/*',
'https://hooks\\.example\\.com/*',
]);
expect(validator.isAllowed('https://api.example.com/hook')).toBe(true);
expect(validator.isAllowed('https://webhook.site/test')).toBe(true);
expect(validator.isAllowed('https://hooks.example.com/test')).toBe(true);
expect(validator.isAllowed('https://other.com/hook')).toBe(false);
});

View file

@ -271,10 +271,10 @@ function copyRuntimeAssets(packageRoot, outDir) {
fs.mkdirSync(libDir, { recursive: true });
for (const entry of fs.readdirSync(distDir)) {
// prepare-package.js stages the audio-capture addon into dist/node_modules
// for the npm package, but standalone rebuilds a clean, target-trimmed
// lib/node_modules via copyNativeAddon(). Copying dist/node_modules here
// would drag in every platform's prebuild and collide with that — skip it.
// Standalone rebuilds a clean, target-trimmed lib/node_modules via
// copyNativeAddon(). If a local dist/node_modules exists from older
// packaging output or manual testing, copying it would drag in unrelated
// packages or every platform's native prebuild.
if (
entry === skippedDistEntry ||
entry === '.DS_Store' ||

View file

@ -19,11 +19,23 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const defaultRootDir = path.resolve(__dirname, '..');
const TEST_FILE_RE = /\.(test|spec)\.(d\.)?[mc]?[jt]s(\.map)?$/;
const DEFAULT_MAX_NPM_PACKAGE_UNPACKED_BYTES = 80 * 1024 * 1024;
const PACKAGE_TEXT_FILE_RE =
/\.(?:[cm]?[jt]sx?|json|md|html|css|txt|ya?ml|sh|svg|map)$/i;
const PACKAGE_SCAN_FORBIDDEN_LITERALS = [
['chrome', 'devtools', 'mcp'].join('-'),
['puppeteer', 'core'].join('-'),
['oastify', 'com'].join('.'),
['webhook', 'site'].join('.'),
['ngrok', 'io'].join('.'),
['ngrok-free', 'app'].join('.'),
];
export function preparePackage({
rootDir = defaultRootDir,
requireNativeAudioCapture = process.env
.QWEN_REQUIRE_AUDIO_CAPTURE_PREBUILD === '1',
maxPackageUnpackedBytes = DEFAULT_MAX_NPM_PACKAGE_UNPACKED_BYTES,
} = {}) {
const distDir = path.join(rootDir, 'dist');
@ -31,12 +43,12 @@ export function preparePackage({
copyDocumentationFiles(rootDir, distDir);
copyLocales(rootDir, distDir);
copyExtensionExamples(rootDir, distDir);
const bundleNativeAudioCapture = copyNativeAudioCapturePackage(
rootDir,
distDir,
{ required: requireNativeAudioCapture },
);
writeDistPackageJson(rootDir, distDir, { bundleNativeAudioCapture });
verifyNativeAudioCapturePackage(rootDir, distDir, {
required: requireNativeAudioCapture,
});
writeDistPackageJson(rootDir, distDir);
assertNoSensitivePackageScanLiterals(distDir);
assertPreparedPackageSize(distDir, maxPackageUnpackedBytes);
printPackageStructure(distDir);
}
@ -140,8 +152,8 @@ function copyExtensionExamples(rootDir, distDir) {
}
}
function copyNativeAudioCapturePackage(rootDir, distDir, { required } = {}) {
console.log('Copying native audio capture package...');
function verifyNativeAudioCapturePackage(rootDir, distDir, { required } = {}) {
console.log('Verifying native audio capture package...');
const addonSrc = path.join(rootDir, 'packages', 'audio-capture');
const addonDest = path.join(
@ -168,7 +180,7 @@ function copyNativeAudioCapturePackage(rootDir, distDir, { required } = {}) {
);
}
console.warn(`Warning: ${message}`);
return false;
return;
}
}
for (const [artifactPath, description, predicate] of [
@ -192,7 +204,7 @@ function copyNativeAudioCapturePackage(rootDir, distDir, { required } = {}) {
);
}
console.warn(`Warning: ${message}`);
return false;
return;
}
}
@ -213,16 +225,12 @@ function copyNativeAudioCapturePackage(rootDir, distDir, { required } = {}) {
);
}
console.warn(`Warning: ${message}`);
return false;
return;
}
const dependencySources = [];
const addonRequire = createRequire(path.join(addonSrc, 'package.json'));
for (const dependencyName of Object.keys(addonPkg.dependencies ?? {})) {
try {
dependencySources.push([
dependencyName,
path.dirname(addonRequire.resolve(`${dependencyName}/package.json`)),
]);
addonRequire.resolve(`${dependencyName}/package.json`);
} catch {
const message = `audio capture dependency not resolvable: ${dependencyName}`;
if (required) {
@ -232,51 +240,11 @@ function copyNativeAudioCapturePackage(rootDir, distDir, { required } = {}) {
);
}
console.warn(`Warning: ${message}`);
return false;
return;
}
}
delete addonPkg.scripts;
delete addonPkg.devDependencies;
const copyOpts = {
recursive: true,
dereference: true,
verbatimSymlinks: false,
};
fs.mkdirSync(addonDest, { recursive: true });
fs.writeFileSync(
path.join(addonDest, 'package.json'),
JSON.stringify(addonPkg, null, 2) + '\n',
);
fs.cpSync(path.join(addonSrc, 'dist'), path.join(addonDest, 'dist'), {
...copyOpts,
filter: (src) => !TEST_FILE_RE.test(src),
});
fs.cpSync(
path.join(addonSrc, 'prebuilds'),
path.join(addonDest, 'prebuilds'),
{
...copyOpts,
filter: (src) => {
const stat = fs.statSync(src);
return stat.isDirectory() || src.endsWith('.node');
},
},
);
for (const [dependencyName, dependencySrc] of dependencySources) {
fs.cpSync(
dependencySrc,
path.join(addonDest, 'node_modules', dependencyName),
copyOpts,
);
}
console.log('Copied native audio capture package');
return true;
console.log('Verified native audio capture package');
}
function hasFileMatching(dir, predicate) {
@ -292,11 +260,7 @@ function hasFileMatching(dir, predicate) {
return false;
}
function writeDistPackageJson(
rootDir,
distDir,
{ bundleNativeAudioCapture = false } = {},
) {
function writeDistPackageJson(rootDir, distDir) {
console.log('Creating package.json for distribution...');
const cliEntryPath = path.join(distDir, 'cli-entry.js');
@ -337,9 +301,6 @@ function writeDistPackageJson(
'bundled',
'web-shell',
],
...(bundleNativeAudioCapture
? { bundledDependencies: ['@qwen-code/audio-capture'] }
: {}),
config: rootPackageJson.config,
dependencies: {},
optionalDependencies: {
@ -367,6 +328,80 @@ function writeDistPackageJson(
);
}
function assertNoSensitivePackageScanLiterals(distDir) {
for (const filePath of listTextPackageFiles(distDir)) {
const contents = fs.readFileSync(filePath, 'utf8');
const lowerContents = contents.toLowerCase();
for (const literal of PACKAGE_SCAN_FORBIDDEN_LITERALS) {
if (!lowerContents.includes(literal.toLowerCase())) continue;
const relativePath = path.relative(distDir, filePath);
throw new Error(
`Prepared package contains forbidden string "${literal}" in ${relativePath}`,
);
}
}
}
function listTextPackageFiles(dir) {
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...listTextPackageFiles(entryPath));
} else if (entry.isFile() && PACKAGE_TEXT_FILE_RE.test(entry.name)) {
files.push(entryPath);
}
}
return files;
}
function assertPreparedPackageSize(distDir, maxUnpackedBytes) {
const packageFiles = collectPreparedPackageFiles(distDir);
let unpackedBytes = 0;
for (const filePath of packageFiles) {
unpackedBytes += fs.statSync(filePath).size;
}
if (unpackedBytes <= maxUnpackedBytes) return;
throw new Error(
`Prepared package unpacked size ${unpackedBytes} bytes exceeds ${maxUnpackedBytes} bytes`,
);
}
function collectPreparedPackageFiles(distDir) {
const distPackageJson = JSON.parse(
fs.readFileSync(path.join(distDir, 'package.json'), 'utf8'),
);
const packageFiles = new Set([path.join(distDir, 'package.json')]);
for (const entry of distPackageJson.files ?? []) {
if (entry === '*.sb') {
for (const fileName of fs.readdirSync(distDir)) {
if (fileName.endsWith('.sb')) {
packageFiles.add(path.join(distDir, fileName));
}
}
continue;
}
const entryPath = path.join(distDir, entry);
if (!fs.existsSync(entryPath)) continue;
collectFiles(entryPath, packageFiles);
}
return packageFiles;
}
function collectFiles(entryPath, output) {
const stat = fs.statSync(entryPath);
if (stat.isDirectory()) {
for (const entry of fs.readdirSync(entryPath)) {
collectFiles(path.join(entryPath, entry), output);
}
} else if (stat.isFile()) {
output.add(entryPath);
}
}
function printPackageStructure(distDir) {
console.log('\n✅ Package prepared for publishing at dist/');
console.log('\nPackage structure:');

View file

@ -219,9 +219,7 @@ describe('package asset scripts', () => {
);
expect(distPackageJson.files).toContain('examples');
expect(distPackageJson.bundledDependencies).toContain(
'@qwen-code/audio-capture',
);
expect(distPackageJson.bundledDependencies).toBeUndefined();
expect(distPackageJson.optionalDependencies).toMatchObject({
'@qwen-code/audio-capture': rootPackageJson.version,
});
@ -233,91 +231,6 @@ describe('package asset scripts', () => {
'node_modules',
'@qwen-code',
'audio-capture',
'dist',
'index.js',
),
),
).toBe(true);
expect(
existsSync(
path.join(
rootDir,
'dist',
'node_modules',
'@qwen-code',
'audio-capture',
'prebuilds',
'darwin-arm64',
'debug.log',
),
),
).toBe(false);
expect(
existsSync(
path.join(
rootDir,
'dist',
'node_modules',
'@qwen-code',
'audio-capture',
'prebuilds',
'darwin-arm64',
'@qwen-code+audio-capture.node',
),
),
).toBe(true);
const distAudioPackageJson = JSON.parse(
readFileSync(
path.join(
rootDir,
'dist',
'node_modules',
'@qwen-code',
'audio-capture',
'package.json',
),
'utf8',
),
);
expect(distAudioPackageJson.scripts).toBeUndefined();
expect(distAudioPackageJson.devDependencies).toBeUndefined();
expect(
existsSync(
path.join(
rootDir,
'dist',
'node_modules',
'@qwen-code',
'audio-capture',
'node_modules',
'node-gyp-build',
'package.json',
),
),
).toBe(true);
expect(
existsSync(
path.join(
rootDir,
'dist',
'node_modules',
'@qwen-code',
'audio-capture',
'dist',
'index.test.js',
),
),
).toBe(false);
expect(
existsSync(
path.join(
rootDir,
'dist',
'node_modules',
'@qwen-code',
'audio-capture',
'dist',
'index.spec.js',
),
),
).toBe(false);
@ -362,6 +275,55 @@ describe('package asset scripts', () => {
);
});
it.each([
['dist/web-shell/assets/icon.svg', '<svg>chrome-devtools-mcp</svg>\n'],
['dist/chunks/server.js.map', '{"sources":["Chrome-Devtools-MCP"]}\n'],
])(
'fails packaging when prepared dist contains scanner-sensitive literals in %s',
(packagePath, contents) => {
const rootDir = createFixtureRoot();
createBundleArtifacts(rootDir);
const browserMcpPackageName = ['chrome', 'devtools', 'mcp'].join('-');
writeFile(rootDir, packagePath, contents);
stubConsole();
const expectedPath = path.relative(
path.join(rootDir, 'dist'),
path.join(rootDir, packagePath),
);
expect(() =>
preparePackage({ rootDir, requireNativeAudioCapture: false }),
).toThrow(
`Prepared package contains forbidden string "${browserMcpPackageName}" in ${expectedPath}`,
);
},
);
it('fails packaging when prepared dist exceeds the unpacked size budget', () => {
const rootDir = createFixtureRoot();
createBundleArtifacts(rootDir);
stubConsole();
preparePackage({
rootDir,
requireNativeAudioCapture: false,
maxPackageUnpackedBytes: 50_000,
});
const oversizedRootDir = createFixtureRoot();
createBundleArtifacts(oversizedRootDir);
writeFile(oversizedRootDir, 'dist/chunks/large.js', 'x'.repeat(64 * 1024));
expect(() =>
preparePackage({
rootDir: oversizedRootDir,
requireNativeAudioCapture: false,
maxPackageUnpackedBytes: 50_000,
}),
).toThrow(/Prepared package unpacked size \d+ bytes exceeds 50000 bytes/);
});
it('omits bundledDependencies when audio-capture artifacts are missing', () => {
const rootDir = createFixtureRoot();
rmSync(path.join(rootDir, 'packages', 'audio-capture', 'prebuilds'), {
@ -393,9 +355,13 @@ describe('package asset scripts', () => {
it('removes stale bundled audio-capture files when artifacts are missing', () => {
const rootDir = createFixtureRoot();
createBundleArtifacts(rootDir);
writeFile(
rootDir,
'dist/node_modules/@qwen-code/audio-capture/prebuilds/darwin-arm64/@qwen-code+audio-capture.node',
'stale native addon\n',
);
stubConsole();
preparePackage({ rootDir, requireNativeAudioCapture: false });
rmSync(path.join(rootDir, 'packages', 'audio-capture', 'prebuilds'), {
recursive: true,
force: true,