diff --git a/docs/plans/2026-07-13-chrome-extension-native-tools-pr1.md b/docs/plans/2026-07-13-chrome-extension-native-tools-pr1.md new file mode 100644 index 00000000000..24fdf5abc27 --- /dev/null +++ b/docs/plans/2026-07-13-chrome-extension-native-tools-pr1.md @@ -0,0 +1,340 @@ +# Chrome Extension Native Browser Tools PR1 Implementation Plan + +**Goal:** Ship PR1 for the official Qwen Code Chrome extension so users can install the extension, run `qwen serve`, and get baseline browser debugging tools without bundling or installing `chrome-devtools-mcp`. + +**Architecture:** Keep Qwen Code's agent loop and tool orchestration in the local daemon. The Chrome extension connects to the daemon over `/acp`, hosts a small native MCP server inside the extension, and implements browser tools with `chrome.debugger` CDP access. Add a one-time daemon-to-extension pairing step so the extension only exposes browser-control tools to a trusted local daemon. + +**Tech Stack:** TypeScript, Chrome Manifest V3 service worker, `chrome.debugger`, daemon `/acp` WebSocket, existing `qwen serve` Express server, Vitest, esbuild, npm package scanner. + +## Scope + +PR1 must include: + +- Native extension-hosted browser tools: navigation, click/fill/key/scroll/wait, screenshot/snapshot, console, network, evaluate, and request sending. +- Default `qwen serve` compatibility for the official extension ID, with no `QWEN_SERVE_CLIENT_MCP_OVER_WS` or `QWEN_SERVE_CDP_TUNNEL_OVER_WS` required. +- No bundled `chrome-devtools-mcp`, Puppeteer, or external browser automation server in the main npm package or extension ZIP. +- Compatibility fallback for explicit external adapter users through `QWEN_CDP_MCP_COMMAND`. +- One-time pairing/auth so a random local process cannot impersonate the daemon and invoke extension-hosted browser tools. +- Self-contained verification: unit tests, extension packaging scan, CLI tests, typecheck/build/bundle/package scan, and real Chrome smoke test. + +Out of scope for PR1: + +- Recording/replay workflows. +- Performance timeline/profiling UI. +- Multi-tab orchestration beyond active-tab control. +- Publishing to Chrome Web Store. + +## Current Baseline + +The worktree contains the native browser-tools implementation and daemon-extension pairing. First-use mutual HMAC proof keeps the terminal code and derived credential secret off the wire; stored credentials are challenge-verified before `/acp`, pairing routes precede bearer authentication, and failed attempts are bounded. + +## Task 1: Sync Base Branch + +**Files:** + +- No direct source edits. + +**Step 1: Fetch latest main** + +Run: + +```bash +git fetch origin main +``` + +Expected: fetch succeeds. + +**Step 2: Merge latest main into the worktree** + +Run: + +```bash +git merge origin/main +``` + +Expected: merge succeeds or exposes concrete conflicts to resolve. + +**Step 3: Inspect conflicts or changed upstream serve/extension code** + +Run: + +```bash +git status --short --branch +git diff --name-only --diff-filter=U +``` + +Expected: no unresolved conflicts before continuing. + +## Task 2: Define Pairing Contract Tests + +**Files:** + +- Create: `packages/cli/src/serve/extension-pairing.test.ts` +- Create: `packages/cli/src/serve/extension-pairing.ts` +- Modify: `packages/cli/src/serve/run-qwen-serve.test.ts` +- Modify: `packages/chrome-extension/src/daemon/discovery.test.ts` +- Modify: `packages/chrome-extension/src/background/service-worker.ts` +- Modify: `packages/chrome-extension/src/daemon/discovery.ts` + +**Behavior:** + +- Daemon prints a time-limited pairing code in the terminal and exposes only + pairing status on loopback. +- Extension can store a daemon trust credential after explicit pairing. +- `/acp` browser-tools connection must carry the trust credential after pairing. +- Official extension still shows `qwen serve` as the default startup command. +- If no credential is present, the extension must not register browser tools with an untrusted daemon. + +**Step 1: Write failing CLI pairing tests** + +Add tests for: + +- Pairing challenge generation returns a high-entropy code, nonce, and expiration timestamp. +- Pairing exchange rejects wrong or expired HMAC proofs without receiving the code. +- Pairing exchange mutually authenticates the daemon and derives a persistent credential without transferring its secret. +- Stored credential verification accepts the issued credential and rejects random values. + +Run: + +```bash +cd packages/cli && npx vitest run src/serve/extension-pairing.test.ts +``` + +Expected: fails because the module does not exist or behavior is missing. + +**Step 2: Write failing extension discovery tests** + +Add tests for: + +- Reading pairing state from storage. +- Returning `unpaired` when daemon is reachable but no trust credential exists. +- Returning `ready` when daemon is reachable and trust credential is accepted. + +Run: + +```bash +npm -w packages/chrome-extension run test -- src/daemon/discovery.test.ts +``` + +Expected: fails because pairing behavior is missing. + +## Task 3: Implement Minimal Daemon Pairing + +**Files:** + +- Create: `packages/cli/src/serve/extension-pairing.ts` +- Modify: `packages/cli/src/serve/run-qwen-serve.ts` +- Modify: `packages/cli/src/serve/run-qwen-serve.test.ts` +- Modify: `packages/cli/src/serve/types.ts` only if a shared type is truly needed. + +**Implementation:** + +- Keep state in the running daemon process for PR1: current pairing code, expiration, and issued trust credential hash. +- Generate a pasteable 128-bit code and nonce with Node `crypto`. +- Add loopback-only HTTP endpoints: + - `GET /extension/pairing` returns pairing status and expiration metadata, + but never returns the terminal code. + - `POST /extension/pairing/confirm` accepts a client proof and returns a credential ID plus daemon proof; the secret is derived independently at both ends. + - `POST /extension/pairing/verify` returns a challenge proof for the public + credential id without receiving the credential secret. +- Use constant-time comparison for credential verification. +- Do not write secrets to logs. +- Do not persist credentials in the repo; extension stores its copy in Chrome storage. + +**Step 1: Implement module only** + +Run: + +```bash +cd packages/cli && npx vitest run src/serve/extension-pairing.test.ts +``` + +Expected: pairing module tests pass. + +**Step 2: Wire routes into `qwen serve`** + +Run: + +```bash +cd packages/cli && npx vitest run src/serve/run-qwen-serve.test.ts +``` + +Expected: existing serve tests plus new pairing route tests pass. + +## Task 4: Implement Extension Pairing Flow + +**Files:** + +- Modify: `packages/chrome-extension/src/daemon/discovery.ts` +- Modify: `packages/chrome-extension/src/daemon/discovery.test.ts` +- Modify: `packages/chrome-extension/src/background/service-worker.ts` +- Modify: `packages/chrome-extension/public/sidepanel.js` +- Modify: `packages/chrome-extension/public/sidepanel.html` only if the existing markup cannot support the pairing UI. + +**Implementation:** + +- Add a `paired` daemon state in discovery. +- Store the daemon credential under the existing daemon storage object. +- Side panel states: + - daemon down: show `qwen serve`; + - daemon up but unpaired: ask the user to paste the pairing code shown + in the `qwen serve` terminal; + - daemon up and paired: frame Web Shell. +- Service worker behavior: + - Do not connect/register native browser tools until pairing verifies. + - Include the trust credential in `/acp` authentication using the existing token/subprotocol mechanism only if it does not collide with `QWEN_SERVER_TOKEN`; otherwise add a small pairing-specific message before MCP registration. +- Keep external adapter fallback unchanged. + +**Step 1: Add failing extension tests** + +Run: + +```bash +npm -w packages/chrome-extension run test -- src/daemon/discovery.test.ts src/background/service-worker.test.ts +``` + +Expected: tests fail for missing pairing state or service worker gating. + +**Step 2: Implement minimal pairing UI and service worker gating** + +Run: + +```bash +npm -w packages/chrome-extension run test +``` + +Expected: all extension tests pass. + +## Task 5: Preserve Packaging and Scanner Guarantees + +**Files:** + +- Modify: `packages/chrome-extension/scripts/artifact-scan.js` only if new signatures must be added. +- Modify: `scripts/tests/chrome-extension-package.test.js` if packaging expectations change. +- Modify: `packages/chrome-extension/package.json` only if package scripts need adjustment. + +**Behavior:** + +- Extension production ZIP must not contain `chrome-devtools-mcp`, Puppeteer, or external MCP server code. +- Main npm final package must not contain those signatures either. +- Native CDP code remains extension-only. + +**Step 1: Run extension release test** + +Run: + +```bash +npm -w packages/chrome-extension run test:release +``` + +Expected: tests, typecheck, build/package, and artifact scan pass. + +**Step 2: Run root package scanner** + +Run: + +```bash +npm run build +npm run bundle +npm run prepare:package +``` + +Expected: build and final package scan pass. + +## Task 6: Real Chrome Smoke Test + +**Files:** + +- No source edits unless smoke test exposes a product bug. + +**Setup:** + +Run daemon without browser env flags: + +```bash +node packages/cli/dist/index.js serve --port 4170 --hostname 127.0.0.1 +``` + +Load the built extension ZIP or unpacked `packages/chrome-extension/dist/extension`. + +**Verify manually or with Playwright:** + +- Extension side panel detects daemon. +- First run requires pairing. +- After pairing, reload the extension and verify it stays paired. +- `/workspace/mcp` includes `qwen-browser-tools`. +- Agent can: + - navigate to `http://127.0.0.1:4170/demo`; + - inspect snapshot; + - fill and click; + - evaluate JavaScript; + - read console output; + - read network requests and response metadata. + +Expected: all checks pass without `QWEN_SERVE_CLIENT_MCP_OVER_WS`, `QWEN_SERVE_CDP_TUNNEL_OVER_WS`, or `QWEN_CDP_MCP_COMMAND`. + +## Task 7: Final Review and PR Readiness + +**Files:** + +- Modify docs as needed: + - `packages/chrome-extension/README.md` + - `packages/chrome-extension/docs/05-daemon-direct-architecture.md` + +**Step 1: Run changed-file lint** + +Run: + +```bash +npm run lint +``` + +Expected: no errors. + +**Step 2: Run full typecheck** + +Run: + +```bash +npm run typecheck +``` + +Expected: no errors. + +**Step 3: Run code review workflow** + +Use review and ponytail checks on the final diff. + +Expected: no Critical or High issues remain. Any remaining Medium/Low items are documented as PR2 follow-ups. + +**Step 4: Prepare Draft PR** + +Draft PR must state: + +- PR1 user flow. +- Pairing behavior and security model. +- Native tools included. +- Explicit statement that `chrome-devtools-mcp` is not bundled. +- Test evidence with exact commands. +- Known PR2 follow-ups: recording/replay, performance profiling, richer multi-tab workflow. + +## Validation Results + +Automated validation after syncing `origin/main` on 2026-07-16: + +- Extension `test:release`: 58 tests passed, including typecheck, ZIP creation, and artifact scan. +- Relevant CLI session authentication, ACP bridge, and Web Shell tests: 101 tests passed. +- `npm run lint`, `npm run build`, `npm run typecheck`, `npm run bundle`, and `npm run prepare:package`: passed. +- Main npm tarball: 23.4 MB and 833 files; path and content scans found no `chrome-devtools-mcp`, Puppeteer, extension ZIP/manifest, or native browser-MCP source. +- Full `npm run verify:pr` passed every deterministic stage and all other workspaces. One unrelated CLI webhook test ended with `socket hang up`; the exact test passed twice in isolation. + +The real Chrome smoke test in Task 6 remains the final manual release check. + +## Acceptance Criteria + +- Official extension + `qwen serve` gives browser tools after one-time pairing. +- No external `chrome-devtools-mcp` install is required for PR1 baseline tools. +- No browser env flags are required for the official extension path. +- Main npm package and extension ZIP scans do not flag `chrome-devtools-mcp` or Puppeteer. +- Untrusted local processes cannot silently use the extension-hosted tools. +- All listed verification commands pass in this worktree before declaring the PR ready. diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index ecc55de8e49..1c947673ac4 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -713,7 +713,7 @@ provider decision with their normal tool policy and isolation boundary. - **`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` or the exact bound loopback address and port (case-insensitive per RFC 7230 §5.4) to defend against DNS rebinding. When listening on port 80 or 443, the corresponding port-less forms are also accepted because browsers omit scheme-default ports. This supports the complete IPv4 loopback range (`127.0.0.0/8`) without admitting unrelated Hosts. **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 for normal API routes; 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 — on a **loopback** bind boot refuses if no bearer token is configured, while a non-loopback bind boots because token generation supplies one; `--require-auth` on loopback is recommended for full hardening since `/health` remains pre-auth on loopback by default — note that the Web Shell static assets (`/`, `/assets/*`, `/session/:id` document navigations) are mounted before the bearer in every mode and stay pre-auth even under `--require-auth`, so use `--no-web` when the residual browser surface matters) or a canonical URL origin (`://[:]`, no trailing slash / path / userinfo). On a **loopback** bind without a token, HTTP(S) entries must use a loopback host; a non-loopback bind always carries a token — supplied or generated — precisely because remotely hosted browser origins can execute code through the operator API as the daemon user. Explicit browser-extension origins retain their tokenless local-automation path. 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 Web Shell UI) are unaffected — a separate Origin-strip shim handles them regardless of `--allow-origin`. The built-in remote Web Shell's same-origin **HTTP** requests also work without `--allow-origin`: on a non-loopback primary listener an `Origin` equal to the direct socket scheme plus the normalized `Host` authority is bearer-authenticated and then stripped ahead of the CORS wall, while its existing public static routes stay unauthenticated. Two exclusions still need `--allow-origin` — forwarded headers never establish same-origin trust, so a TLS-terminating reverse proxy's `https` browser origin is not same-origin (and neither is a plain-HTTP intermediary that rewrites the `Host` header, such as nginx's default `proxy_set_header Host $proxy_host` or k8s Ingress; the remedy there is `--allow-origin ` for the origin your browser sees, or a proxy that forwards `Host` verbatim — port translation alone needs nothing, because only the forwarded `Host` is compared, with just the scheme-default `:80`/`:443` stripped), and the WebSocket upgrade routes (terminal, voice) keep a separate CSWSH gate that admits only loopback origins, allowlisted origins, and the Local Control listener's own origin. -- **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. +- **Chrome extension browser automation is separate from framing.** The official extension origin is pinned, so starting `qwen serve` and completing the first-use pairing enables its native console/network/screenshot/click tools without browser-related flags or an external MCP server. Custom extension builds must still pass `--allow-origin chrome-extension://`. `QWEN_CDP_MCP_COMMAND` remains an explicit compatibility path for external adapters; the main CLI package does not bundle one, and `caps.features.includes('browser_automation_mcp')` is advertised only when that legacy adapter is configured. - **A spawned `qwen --acp` child receives its owning runtime's effective environment.** The daemon freezes a process-env base, applies that workspace's settings/env-file overlay to a runtime-local snapshot, and never writes the overlay back to `process.env`; same-named keys in another runtime do not cross over. `QWEN_SERVER_TOKEN` is scrubbed before spawn because the agent does not need the daemon bearer. Loader-affecting variables (`NODE_OPTIONS`, `npm_config_node_options` and npm's config-file redirects, `NODE_PATH`, `OPENSSL_CONF`, `NODE_REPL_EXTERNAL_MODULE`, `npm_config_node_gyp`, `npm_config_init_module`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `BASH_ENV`, `ZDOTDIR`, exported bash function definitions `BASH_FUNC_*`) are likewise never passed to session subprocesses — the daemon scrubs them from its own `process.env` and from the frozen base env that session-hosting children spawn with (the base env keeps them only under the `DEV=true` harness, whose `.ts` entries still need the tsx loader), and `.env` / `settings.json` `env` sources reject them (see [settings](./configuration/settings.md)); this applies to every session the daemon hosts. Base credentials such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `QWEN_*`, and `DASHSCOPE_API_KEY` otherwise pass through unless the runtime overlay changes them. **This is intentional, not a sandbox.** The agent runs as the same UID with shell-tool access, so anything in `~/.bashrc`, `~/.aws/credentials`, or `~/.npmrc` is reachable by prompt injection regardless. Environment isolation between runtimes is not an operating-system security boundary; do not run `qwen serve` under an identity that has credentials you would not trust the agent with. - **Agent text reads are child-local and follow the regular CLI permission rules, not the workspace filesystem boundary.** Direct `read_file` can reach host text paths outside every registered workspace: external paths default to confirmation, and allow rules or approval modes may approve them automatically. Approved reads use the configurable CLI output limits rather than the workspace filesystem's returned-output, full-snapshot, and large-text scan caps. This applies to every shared text-read consumer, so the pre-reads performed by write, edit, notebook, sed, and artifact operations lose those caps together with the workspace filesystem's read audit, symlink rejection, and read-side TOCTOU protections — see [the read design](../design/daemon-local-text-reads.md) for the exact list. Because a confirmation payload is built by reading the file, an out-of-workspace diff is fanned out to **every** attached SSE subscriber before anyone approves it — in the interactive CLI that content is seen only by the person at the terminal. Treat authenticated daemon clients as the same security principal. HTTP filesystem routes remain workspace-scoped and agent discovery-tool behavior is unchanged. - **Approved final writes from built-in text tools have a narrow same-host route.** `write_file`, `edit`, `notebook_edit`, and the shell tool's simulated sed editor attach internal provenance only after the existing permission policy allows execution. Their final ACP text write can therefore target an absolute path outside the owning workspace without a second confirmation; allow rules, AUTO/AUTO_EDIT and YOLO behave like the CLI, while rejection, Plan, Hook/Guard refusal and pre-execution cancellation do not send the final write. Cancellation after a tool has already entered a non-cancellable filesystem operation keeps that tool's existing behavior. Workspace targets still use WFS. External targets use a daemon host writer with the same trust snapshot, 5 MiB encoded limit, leaf-symlink rejection, canonical path lock, atomic rename, mode preservation, `0600` new-file mode by default (configurable — see [New-file mode for agent text writes](#new-file-mode-for-agent-text-writes)), generation guard and filesystem audit. HTTP writes, generic or unmarked ACP writes, injected bridge/workspace-registry/factory integrations and arbitrary shell redirection do not receive this exception. See [the external-write design](../design/daemon-external-tool-text-writes.md). diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 7d2c29603bb..e98ffa09857 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -320,7 +320,7 @@ describe('qwen serve — capabilities envelope', () => { // Conditional tags absent under this suite's spawn flags (no // `--require-auth` / `--allow-origin` / deadline env vars / // rate-limit opt-in, no `--channel`, no configured batch ASR model): - // `require_auth`, `allow_origin`, `cdp_tunnel_over_ws`, + // `require_auth`, `cdp_tunnel_over_ws`, // `prompt_absolute_deadline`, `writer_idle_timeout`, // `workspace_voice_transcription`, `rate_limit`, `channel_reload`. // `native_directory_picker` is host-conditional (the daemon host's GUI @@ -442,6 +442,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_btw', 'mcp_workspace_pool', 'mcp_pool_restart', + 'allow_origin', 'auth_device_flow', 'permission_mediation', 'non_blocking_prompt', @@ -478,6 +479,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_session_metadata', 'session_worktree_persistence_v1', 'session_worktree_reset_v1', + 'client_mcp_over_ws', 'voice_transcribe', 'web_terminal', ]); diff --git a/package-lock.json b/package-lock.json index 802d8ec3bce..3cc2a3a860e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27448,6 +27448,7 @@ "license": "Apache-2.0", "devDependencies": { "@types/chrome": "^0.1.32", + "archiver": "^7.0.1", "esbuild": "^0.25.3", "semver": "^7.7.2", "typescript": "^5.8.3", diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 7d7b9307679..52f4097f500 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -398,6 +398,34 @@ function reportingGrade(bridge: { } describe('createAcpSessionBridge', () => { + it('restores client-hosted MCP servers when an ACP child starts', async () => { + const handle = makeChannel({ + extMethodImpl: async () => ({ toolCount: 20 }), + }); + const bridge = makeBridge({ + channelFactory: vi.fn().mockResolvedValue(handle.channel), + clientMcpRuntimeRegistrations: () => [ + { + name: 'qwen-browser-tools', + config: { type: 'sdk', __clientMcpOverWs: true }, + originatorClientId: 'extension-client', + }, + ], + }); + + await bridge.preheat(); + + expect(handle.agent.extMethodCalls).toContainEqual({ + method: SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd, + params: { + name: 'qwen-browser-tools', + config: { type: 'sdk', __clientMcpOverWs: true }, + originatorClientId: 'extension-client', + }, + }); + await bridge.shutdown(); + }); + it.each([undefined, 60_000])( 'rejects a fractional initialization timeout with restore timeout %s', (sessionRestoreTimeoutMs) => { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 0855218fb04..2531f8ce9f2 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5146,6 +5146,26 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return response; }, ); + for (const registration of opts.clientMcpRuntimeRegistrations?.() ?? + []) { + const response = await withTimeout( + connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd, + registration, + ), + MCP_RESTART_SERVER_DEADLINE_MS, + SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd, + ); + if ( + response !== null && + typeof response === 'object' && + (response as { skipped?: boolean }).skipped === true + ) { + throw new Error( + `Failed to restore client MCP server '${registration.name}'`, + ); + } + } } catch (err) { // Mark the half-initialized channel as dying/unavailable, then // kill it. Coalesced callers (`inFlightChannelSpawn` branch in diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index e4aa8c49473..2d39f68e492 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -4694,6 +4694,7 @@ describe('BridgeClient — reverse tool channel (qwen/control/client_mcp/message */ function makeClientWithRegistrar( registrar: ClientMcpRegistrar, + source?: { sourceType?: string; sourceId?: string }, ): BridgeClient { const sender: ClientMcpMessageSender = (serverName: string) => registrar.hasServer(serverName) @@ -4701,7 +4702,10 @@ describe('BridgeClient — reverse tool channel (qwen/control/client_mcp/message registrar.sendSdkMcpMessage(serverName, payload as JSONRPCMessage) : undefined; return new BridgeClient( - (() => undefined) as never, // resolveEntry: client_mcp/message is sessionless + ((sessionId: string | undefined) => + sessionId === 'extension-session' + ? ({ ...source } as never) + : undefined) as never, // resolveEntry: paired extension session carries its source (() => undefined) as never, // resolvePendingRestoreEvents { request: thrower } as never, 0, @@ -4772,6 +4776,45 @@ describe('BridgeClient — reverse tool channel (qwen/control/client_mcp/message expect((result as { payload?: unknown }).payload).toBeDefined(); }); + it('only routes browser tool calls for paired extension sessions', async () => { + const outbound: ClientMcpFrame[] = []; + const registrar = new ClientMcpRegistrar({ + sendFrame: (frame) => { + outbound.push(frame); + }, + }); + registrar.registerServer('qwen-browser-tools'); + + const denied = makeClientWithRegistrar(registrar) + .extMethod('qwen/control/client_mcp/message', { + server: 'qwen-browser-tools', + sessionId: 'extension-session', + payload: { jsonrpc: '2.0', id: 1, method: 'tools/call' }, + }) + .catch((error: unknown) => error); + await expect(denied).resolves.toMatchObject({ code: -32602 }); + expect(outbound).toHaveLength(0); + + const allowedClient = makeClientWithRegistrar(registrar, { + sourceType: 'default', + sourceId: 'chrome_extension', + }); + const allowed = allowedClient.extMethod('qwen/control/client_mcp/message', { + server: 'qwen-browser-tools', + sessionId: 'extension-session', + payload: { jsonrpc: '2.0', id: 2, method: 'tools/call' }, + }); + await vi.waitFor(() => expect(outbound).toHaveLength(1)); + registrar.resolveMessage(outbound[0]!.id, { + jsonrpc: '2.0', + id: 2, + result: { content: [] }, + }); + await expect(allowed).resolves.toMatchObject({ + payload: { result: { content: [] } }, + }); + }); + it('forwards the originating session id to the client MCP sender', async () => { const contexts: unknown[] = []; const sender: ClientMcpMessageSender = () => async (_payload, context) => { diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 7419063fa11..cb7bf1d9d46 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -1824,6 +1824,25 @@ export class BridgeClient implements Client { '`payload` must be a JSON-RPC message object', ); } + if ( + server === 'qwen-browser-tools' && + (payload as Record)['method'] === 'tools/call' + ) { + const sessionId = params['sessionId']; + const entry = + typeof sessionId === 'string' + ? this.resolveEntry(sessionId) + : undefined; + if ( + entry?.sourceType !== 'default' || + entry.sourceId !== 'chrome_extension' + ) { + throw RequestError.invalidParams( + undefined, + 'browser tools require a session created by the paired Chrome extension', + ); + } + } const send = this.clientMcpSender(server); if (!send) { // The client that hosted this server is gone (WS closed / unregistered). diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index 5cdb24abc82..eae28fb00b2 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -650,6 +650,12 @@ export interface BridgeOptions { * receives an SDK MCP runtime server, so the method is never called. */ clientMcpSender?: ClientMcpMessageSender; + /** Client-hosted MCP servers to restore after an ACP child restart. */ + clientMcpRuntimeRegistrations?: () => ReadonlyArray<{ + name: string; + config: Record; + originatorClientId: string; + }>; /** * Daemon-host seam for the `create_sub_session` tool. When a tool running * inside a child's agent turn asks (over `extMethod`) to spawn a fresh diff --git a/packages/chrome-extension/README.md b/packages/chrome-extension/README.md index 0eef3c05078..f2e85e734c8 100644 --- a/packages/chrome-extension/README.md +++ b/packages/chrome-extension/README.md @@ -6,14 +6,11 @@ Messaging host to install. It does two things: -- **Side panel** — frames the daemon's Web Shell (chat + tools), the same UI the - 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 when an external CDP MCP adapter is configured. -- **Readiness warning** — the framed Web Shell stays usable for chat while a - small status message distinguishes a disabled CDP tunnel from a missing - browser automation adapter. +- **Side panel** — handles daemon discovery and pairing, then frames the + daemon's Web Shell (chat + tools). +- **Service worker** — hosts Qwen's native browser MCP tools and executes them + through `chrome.debugger`. Tool calls travel over the daemon's reverse MCP + WebSocket. ## Build @@ -26,57 +23,62 @@ Then load it: `chrome://extensions` → enable Developer mode → **Load unpacke ## Run -The extension is a client; the daemon does the work and must be started -separately (an extension cannot spawn a local process). Open the side panel and -it will tell you exactly what to run — it generates the command with this -extension's own id: +The extension cannot spawn a local process, so start the daemon separately: ```bash -qwen serve --allow-origin chrome-extension:// +qwen serve ``` -`--allow-origin chrome-extension://` is required: it lets the daemon's Web -Shell be framed by the extension (the `frame-ancestors` CSP) and accepts the -extension's requests. The side panel reads the id at runtime via -`chrome.runtime.id`, so you never have to look it up. +The official extension id is pinned by `qwen serve`, so no browser-related +environment variables or `--allow-origin` flag are required. Custom or forked +extension builds must still pass their own origin explicitly: -Do not replace this command with `--open-with-auth`. That mode delivers its generated bearer only to the tab it opens; the extension cannot discover it. To protect a daemon used by the extension, set `QWEN_SERVER_TOKEN` explicitly and configure the same stable token in every authorized client. +```bash +qwen serve --allow-origin chrome-extension:// +``` -Once the daemon is reachable and permits framing, the side panel swaps the -welcome screen for the chat UI automatically. +Paste the pairing code printed by `qwen serve`. The credential remains in +Chrome storage across extension reloads, but a restarted daemon requires a new +pairing code because the daemon keeps trust state in memory. Once pairing +succeeds, the panel opens the chat UI and browser tools register immediately. +If Chrome storage is cleared while the daemon is still running, restart the +daemon to generate fresh pairing material. + +The first-use exchange sends only an HMAC challenge proof; the pairing code and +derived credential secret never cross HTTP. The extension verifies the daemon's +proof before storing that credential, then uses a separate challenge-response +before sending it over `/acp`. Pairing endpoints intentionally precede bearer +authentication so an unknown process never receives a stored bearer token. The +pairing code is time-limited and failed attempts are bounded. + +Do not replace this command with `--open-with-auth`. That mode delivers its +generated bearer only to the tab it opens; the extension cannot discover it. To +protect a daemon used by the extension, set `QWEN_SERVER_TOKEN` explicitly and +configure the same stable token in every authorized client. ## 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. -Install the adapter separately and point the daemon at its executable: +Browser debugging tools are implemented in and bundled with this Chrome +extension. The main `@qwen-code/qwen-code` npm package does not contain an +external Chrome DevTools MCP server. The first-release catalog covers page +snapshot/navigation/input, screenshots, JavaScript evaluation, console output, +and network request/response inspection. -The pinned adapter requires Node.js 22.12 or newer. +Tools act on the active tab. `evaluate_script` and `send_request` execute in the +page context and can access that page's authenticated session, so use a dedicated +browser profile or tab for untrusted sites and keep normal tool approval enabled. -```bash -npm install -g chrome-devtools-mcp@1.5.0 -QWEN_CDP_MCP_COMMAND=chrome-devtools-mcp \ - qwen serve --allow-origin chrome-extension://idkijaaipeeinemigojbjkmfmabokbdk -``` +An explicitly configured `QWEN_CDP_MCP_COMMAND` remains a deprecated +compatibility path targeted for removal in PR2. When present, the extension does +not register its native tool catalog and instead keeps the CDP tunnel available +to that adapter. -The separately installed adapter is not included in the Qwen Code npm package -or Chrome extension zip. -Clients can distinguish the states through `/capabilities`: +Relevant `/capabilities` tags: - `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. +- `client_mcp_over_ws` means extension-hosted tools can register over `/acp`. +- `browser_automation_mcp` means the legacy external adapter is configured. When browser automation is configured, the panel also checks `/workspace/mcp`. It warns when the adapter has not connected or when an existing user-defined @@ -86,17 +88,14 @@ It warns when the adapter has not connected or when an existing user-defined The side panel probes `GET /health` and `GET /capabilities` and shows one of: -| State | Meaning | Shown | -| ------------------------ | ----------------------------------------- | -------------------------------- | -| `down` | no daemon reachable | "Start qwen serve" + command | -| `needs-allow-origin` | daemon up but `--allow-origin` not set | "Allow this extension" + command | -| `chat-only` | Web Shell ready, CDP tunnel disabled | chat + bridge warning | -| `tunnel-only` | CDP tunnel ready, adapter missing | chat + adapter warning | -| `automation-unavailable` | adapter status could not be read | chat + status warning | -| `automation-pending` | adapter not connected | chat + connection warning | -| `automation-shadowed` | an existing MCP config takes precedence | chat + migration warning | -| `automation-configured` | adapter configured, discovery not started | the Web Shell | -| `automation-connected` | extension-backed MCP connected | the Web Shell | +| State | Meaning | Shown | +| -------------------- | ---------------------------------------- | -------------------------------- | +| `down` | no daemon reachable | "Start qwen serve" + command | +| `needs-upgrade` | daemon lacks secure extension pairing | Qwen Code update command | +| `needs-restart` | Chrome lost the active daemon credential | daemon restart guidance | +| `needs-allow-origin` | daemon up but `--allow-origin` not set | "Allow this extension" + command | +| `needs-pairing` | daemon reachable, credential not trusted | pairing-code form | +| `ready` | daemon reachable and paired | the Web Shell (chat) | ## Automated real-Chrome acceptance @@ -141,3 +140,8 @@ draw manual review and must be justified in the store listing. alpha. Chrome refuses to update an extension to a lower version, so testers upgrading from the `1.0.0` build must remove it in `chrome://extensions` before loading this package. + +Release the matching Qwen Code CLI before publishing the extension update. The +pairing handshake intentionally does not downgrade for older daemons; the side +panel detects them and shows an update command instead of sending browser tools +to an unauthenticated local process. diff --git a/packages/chrome-extension/docs/05-daemon-direct-architecture.md b/packages/chrome-extension/docs/05-daemon-direct-architecture.md index c7ca774a275..cf233914830 100644 --- a/packages/chrome-extension/docs/05-daemon-direct-architecture.md +++ b/packages/chrome-extension/docs/05-daemon-direct-architecture.md @@ -1,39 +1,99 @@ # Daemon-Direct Architecture (issue #5626) -Current Chrome extension architecture on the `qwen serve` daemon, without -Native Messaging. +Revival of the Chrome extension on the `qwen serve` daemon, dropping Native +Messaging and external browser-tool servers from the default path. ``` -Chrome side panel ── iframe/HTTP ────────────────▶ qwen serve Web Shell -Chrome service worker ── CDP frames over /acp ──▶ qwen serve /cdp tunnel -Chrome active tab ◀──── chrome.debugger ──────────┘ -External MCP adapter ── stdio MCP + /cdp WS ─────▶ qwen serve +┌─ Chrome extension (pure web client) ──────────────┐ +│ Side panel │ +│ daemon discovery + framed Web Shell ───────────┼──┐ +│ Service worker │ │ +│ browser-tools MCP server (over WS) ────────────┼─┐│ +│ chrome.debugger CDP tools + event capture ──────┼─┘│ +└───────────────────────────────────────────────────┘ ││ + ▼▼ + qwen serve daemon (localhost:4170, loopback auth-free) ``` -## Side panel chat +## Chat and pairing -The side panel probes the daemon and frames the Web Shell after the daemon -advertises `allow_origin`. The Web Shell owns sessions, streaming, permissions, -and reconnect behavior; the extension does not duplicate that React UI. +The side panel is a daemon client. `@qwen-code/webui`'s `DaemonSessionProvider` +({ baseUrl, token? }) handles connect / session-create / SSE / reconnect / +heartbeat. Loopback ⇒ `token` omitted, `workspaceCwd` omitted (daemon uses its +bound workspace). -- `src/daemon/config.ts` — `{ baseUrl, token? }`, default `http://127.0.0.1:4170`, - overridable via `chrome.storage.local`. -- `src/daemon/discovery.ts` — `GET /health` probe; gate the chat on reachability, - otherwise show a "run `qwen serve`" hint. -- `public/sidepanel.js` — probes `/health` and `/capabilities`, frames the Web - Shell, forwards its optional bearer token, and reports browser automation - readiness without blocking chat. +- `src/daemon/config.ts` stores the loopback base URL, optional daemon bearer, + and the paired extension credential in `chrome.storage.local`. +- `src/daemon/discovery.ts` probes daemon health and verifies a pairing + challenge before the panel or service worker trusts that daemon. +- The side panel frames the daemon Web Shell after discovery and pairing. +- Pairing state is process-local in PR1, so restarting `qwen serve` requires a + fresh terminal code. First-use mutual HMAC proof keeps both the code and the + derived credential secret off the wire; later discovery also uses an HMAC + challenge so stored credentials are not sent to an unknown process. -The extension has no content script or extension-local browser tool catalog. -Page inspection and automation are provided through the CDP tunnel when an -external adapter is configured. +The native-messaging transport is not part of this path. -## Browser automation +## Browser tools — extension-hosted reverse MCP -The service worker registers as `qwen-cdp-bridge` on `/acp`. The daemon's `/cdp` -endpoint translates an external adapter's browser-level CDP connection into -`cdp_*` frames, and the extension forwards page-domain commands to the active -tab through `chrome.debugger`. +A browser extension cannot be a listening MCP server. The agent runs inside the +daemon and must reach tools that execute in the extension. The mechanism already +exists in the codebase for **SDK-embedded MCP servers**, but only over the SDK's +subprocess `Query` control plane — NOT over the daemon's WS. Phase 2 makes the +daemon WS carry the same `mcp_message` frames. + +### Existing template (reuse the pattern, not the wire) + +- `core/src/tools/sdk-control-client-transport.ts` — `SdkControlClientTransport`: + the agent's MCP **client** side. Routes JSON-RPC via a + `sendMcpMessage(serverName, msg) => Promise` callback instead of stdio. + Selected when `isSdkMcpServerConfig(config)` (see `mcp-client.ts:1663`), + threaded through `createTransport(..., sendSdkMcpMessage)`. +- `sdk-typescript/src/daemon-mcp/SdkControlServerTransport.ts` — the **server** + side: an MCP `Server` connected to a transport whose `send()` → `sendToQuery()` + and inbound `handleMessage()` → `onmessage`. + +Data flow to reproduce over the daemon WS: + +``` +agent MCP client → SdkControlClientTransport.send + → daemon: sendMcpMessage('chrome-tools', jsonrpc) + → WS frame {type:'mcp_message', server:'chrome-tools', payload: jsonrpc, id} + → extension: MCP Server.handleMessage(jsonrpc) → tool executor (chrome.*) + → extension: WS frame {type:'mcp_message', id, payload: jsonrpc-result} + → daemon: resolve sendMcpMessage promise → agent gets the tool result +``` + +### Daemon side (`packages/cli/src/serve`, public-contract surface) + +1. WS message types on the serve transport: `mcp_register` (client advertises a + server name; tools are discovered through MCP), `mcp_message` (bidirectional + JSON-RPC with an `id` for request/response correlation), `mcp_unregister`. +2. On `mcp_register`, register a runtime **SDK-type** MCP server for the session + (reuse `addRuntimeMcpServer` + `isSdkMcpServerConfig`), wiring its + `sendSdkMcpMessage` callback to push `mcp_message` frames down this client's WS + and await the correlated response. +3. Tear down on WS close / `mcp_unregister`. +4. Advertise `client_mcp_over_ws`; paired extension clients work by default. + Operators can disable the channel with `QWEN_SERVE_CLIENT_MCP_OVER_WS=0` or + explicitly set it to `1` to permit legacy unpaired reverse MCP clients. + +### Extension side + +- `src/background/browser-mcp/server.ts` implements the small MCP JSON-RPC + surface needed by the daemon transport without bundling another server. +- `src/background/browser-mcp/browser-tools.ts` owns the tool catalog and the + bounded Console/Network recorders. +- `src/background/browser-mcp/debugger-session.ts` owns the active tab debugger + attachment and CDP command/event lifecycle. + +## Legacy CDP tunnel (external adapter) + +The service worker also registers as `qwen-cdp-bridge` on `/acp` when an +external adapter is configured. The daemon's `/cdp` endpoint translates an +external adapter's browser-level CDP connection into `cdp_*` frames, and the +extension forwards page-domain commands to the active tab through +`chrome.debugger`. `qwen serve --allow-origin chrome-extension://` enables the side panel and CDP tunnel. Browser tools additionally require a separately installed stdio MCP diff --git a/packages/chrome-extension/public/sidepanel.html b/packages/chrome-extension/public/sidepanel.html index e482a822a30..47edf3c80cb 100644 --- a/packages/chrome-extension/public/sidepanel.html +++ b/packages/chrome-extension/public/sidepanel.html @@ -289,6 +289,53 @@ display: inline; } + .pair { + display: grid; + gap: 9px; + animation: rise 0.55s 0.21s both; + } + .pair__row { + display: flex; + gap: 8px; + } + .pair__input { + min-width: 0; + flex: 1; + padding: 10px 11px; + color: var(--text); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + font: + 600 13px/1 ui-monospace, + SFMono-Regular, + monospace; + letter-spacing: 0; + } + .pair__input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; + } + .pair__button { + flex: none; + padding: 0 13px; + color: var(--bg); + background: var(--status); + border: 1px solid transparent; + border-radius: 8px; + font-weight: 650; + cursor: pointer; + } + .pair__button:disabled { + opacity: 0.6; + cursor: wait; + } + .pair__message { + min-height: 18px; + color: var(--muted); + font-size: 12px; + } + /* ---- live status ---- */ .status { display: flex; @@ -382,8 +429,7 @@

Start qwen serve

No local qwen serve daemon is reachable. Run this in a terminal - and leave it running — this panel connects on its own. Browser - automation tools require QWEN_CDP_MCP_COMMAND. + and leave it running — this panel connects on its own.

@@ -444,6 +490,25 @@

Start qwen serve

Copy command + +