mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
10fa9effbb
commit
fbdaa52c52
19 changed files with 396 additions and 227 deletions
|
|
@ -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: []`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 只读快照/点击形态,仍省大量工具实现。
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
packages/cli/src/serve/cdp-mcp-command.ts
Normal file
27
packages/cli/src/serve/cdp-mcp-command.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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:');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue