* feat(cli): add daemon-owned Local Control service Local Control is implemented twice today — once in the CLI, once as an 830-line Rust TCP proxy in the Tauri shell — with two divergent security models. This adds the daemon-side service both can collapse onto. The Rust proxy exists only because `qwen serve` fixes its bind address at startup and cannot add a listener later; everything it does (Host/Origin rewriting, CRLF rejection, connection caps) is compensation for that one fact. `LocalControlService` attaches a second `http.Server` over the same Express app at runtime, so there is no hop to rewrite. Phases 1-3 of docs/plans/2026-08-13-local-control-consolidation.md: - Listener identity tagged on the `http.Server`, resolved per request, so credentials scope to the listener a request arrived on. - `CredentialStore` replaces `bearerAuth`'s single pre-hashed token. The runtime token is rejected on the LAN listener and the pairing token on loopback — the invariant the Rust proxy enforced by rejecting requests carrying the runtime token. Fixes the CLI path handing the LAN the full-strength daemon token with no revocation short of restart. - `hostAllowlist` now gates the LAN listener against its advertised authority. Previously it opted out entirely off loopback, leaving the CLI path with no DNS-rebinding defense. - `MutableOriginAllowlist` lets the LAN origin be added and removed at runtime; the middleware is still installed once. Empty-allowlist behavior is identical to the `denyBrowserOriginCors` wall it replaces. - ACP WS upgrade tracks a set of servers instead of one, and scopes the subprotocol credential the same way as the REST gate. - LAN selection advertises private/link-local IPv4 only, and surfaces ambiguity to the caller instead of failing (Rust) or emitting a QR per interface (CLI). Refs #9075 * feat(cli): wire Local Control into the daemon boot sequence Constructs the service in `createServeApp`, where the credential store and the CORS allowlist it mutates already live, and publishes it on `app.locals` alongside `acpHandle` — the channel `runQwenServe` already uses to reach into the built app for lifecycle work. - `bearerAuth` now takes the listener-scoped `CredentialStore`, and the ACP WS mount takes the same one so the `qwen-bearer.*` subprotocol cannot sidestep the scoping the REST gate enforces. - The CORS middleware is installed unconditionally over a `MutableOriginAllowlist`. With no `--allow-origin` this returns the same 403 envelope as the `denyBrowserOriginCors` wall it replaces, so the default posture is unchanged. - Daemon teardown disables Local Control before disposing the ACP handle, since detaching the LAN listener's upgrade registration goes through it. Token revocation and origin removal are synchronous, so they complete even though the enclosing dispose scope cannot await the socket close. - The LAN listener honors `--tls-cert` / `--tls-key`, reading them at enable time so a renewed certificate is picked up. Serving plaintext off a daemon deliberately put behind TLS would downgrade the more exposed of the two surfaces; `status.encrypted` reports which it is. Refs #9075 * feat(cli): repoint --local-control at the daemon service The flag stops being a second implementation and becomes a caller. Previously `--local-control` commandeered the daemon: it bound to `0.0.0.0`, generated a token that WAS the daemon token, and rewrote the origin allowlist — which is why it conflicted with `--token`, `--hostname`, `--allow-origin`, and an ephemeral port. The daemon now owns a separate LAN listener with a separate revocable credential, so none of those are in tension. A daemon can serve authenticated loopback and run a Local Control session at the same time, and `--no-web` is the only remaining conflict. - `localControlUrls` is deleted. Its "every non-internal IPv4" policy is the bug the service's private/link-local selection replaces; it would put a VPN or public address in a QR code. - Ambiguous multi-network hosts get `--local-control-address <ip>` instead of a QR per interface. - Sleep inhibition moves into the service, so it is held while the LAN listener is up and released when it goes down rather than for the lifetime of the process. - The pairing line now reports actual sleep-inhibition and encryption state instead of asserting the common case. - `RunHandle.getLocalControl()` reaches the service; a getter because the runtime app is mounted after the listener is up. Refs #9075 * fix(cli): harden daemon-owned Local Control * fix(cli): flush Local Control disable response * feat(desktop): move Local Control into Settings * fix(local-control): close listener lifecycle gaps * fix(cli): resolve local control review comments * fix(local-control): align route lifecycle * fix(serve): close local control review gaps * fix(serve): close Local Control QR and bridge-filter review blockers QR rendering in the Local Control routes is now best-effort: an over-capacity pairing URL (the target deep-link is caller-influenced) no longer turns enable/status into a 500 while the LAN listener stays live, which wedged the Web Shell card with no disable path. The interface denylist also stops rejecting physical LAN bridges (br0, Windows "Network Bridge") and only filters the virtual bridge shapes (Docker br-<hex>, macOS bridge<N>), matching the deleted Rust filter's per-platform behavior. Adds regression tests for both. * fix(serve): close round-5 Local Control review findings - Card: reconcile the selected LAN address on every status update, so a stale selection cannot survive a network change when only one candidate remains (the selector is hidden in that case and gave no affordance). - Interface filter: fold the hex run into the Docker bridge token (br-[0-9a-f]+) so bridge IDs starting with a letter stop escaping the shared boundary check. - LAN listener: drop the whole-request timeout budget; Node never resets it on body chunks, so it 408'd phones trickling large uploads through the shared Express app. Header and keep-alive timeouts stay. - Copy: Ctrl+C ends the whole daemon, not just Local Control (design doc, terminal banner, --local-control description). - Accessibility: aria-live on the card, role=alert on its error line. - Tests: QR happy path, listen-error handler cleanup, strict error-handler count after enable, letter-starting Docker bridge. * fix(web-shell): preserve local control base paths * fix(local-control): close round-7 review findings - card: keep the 409 candidate list on the error path — requestLocalControl attaches the parsed payload to the thrown error and toggle reconciles status/selection from it, so a stale address after a DHCP change recovers without a page remount (R7-3) - lan-interfaces: match `vpn` as a substring and add a `wintun` token, closing the OpenVPN Wintun escape (boundary semantics let `vpn` sit inside "openvpn" unmatched) plus mid-word names like vpnkit; regression test covers the adapter family (R7-1 demonstrated entrance; structural per-platform classification stays a follow-up) - drop the orphaned strictPort ServeOptions field, the EADDRINUSE-bump condition reading it, and its test — no production entry point sets it anymore (R7-5) - docs: refresh 12-auth-security.md / 02-serve-runtime.md for the new middleware topology — unconditional allowOriginCors over the mutable allowlist on the runtime app, deny wall only in the bootstrap app, listener-scoped pairing credential on the LAN listener, and the new mutation-gate row (R7-2) - remove the dead selectLanAddress barrel re-export * fix(local-control): enforce the loopback-bind precondition on runtime enable The LAN listener binds the primary listener's port on the selected LAN address, so a wildcard or LAN primary bind already owns it — the `--local-control` CLI flag refuses that configuration at boot, but the runtime enable route (driven by the Web Shell Settings card) skipped the check and surfaced a 500 `listen EADDRINUSE` with no remediation, silently unusable for the whole class of non-loopback deployments. Return 409 `local_control_non_loopback_bind` with the actionable restart hint instead; loopback binds (127.0.0.1/localhost/::1) stay enabled via the shared `isLoopbackBind` helper. * fix(local-control): close round-8 demonstrated escapes + doc/test gaps R7-1 (demonstrated false negatives): Docker veth peer IDs are veth<hex> and may be letter-led (vethd4a1b2c), which the bare token's digit boundary let escape — the token takes the same shape as br-<hex>. Corporate SSL-VPN adapters (Cisco AnyConnect, GlobalProtect, Pulse Secure, FortiClient, Cloudflare WARP) carry no `vpn` substring, so their vendor names are listed explicitly; a sole-candidate VPN address is no longer silently auto-advertised in the QR. Regression tests cover all six shapes. The vEthernet-external false positive and the class fix (structural classification instead of name matching) remain under #9158. Also: the settings card's status-fetch effect clears a stale error on re-run and ignores superseded responses; the detach test now connects a primary-listener client and asserts it survives detachServer (the per-server filter previously survived a mutation probe); the flags table gains the --local-control-address row; the design doc states that --allow-origin origins stay admitted alongside the LAN origin; the three Host-gate doc surfaces note that the LAN listener always enforces its advertised-authority Host check. * fix(local-control): bound slow-body slots + close round-7 adapter escapes (#9106) - service: replace requestTimeout=0 with a bounded 30-minute whole-request budget; an unlimited budget let an unauthenticated LAN client trickle bodies and hold every pre-auth connection slot open indefinitely (headersTimeout covers only headers, keepAliveTimeout only idle sockets) - lan-interfaces: add interim vendor tokens for post-rename SSL-VPN successors (ivanti, cisco secure, citrix, sonicwall); Ivanti Connect Secure (Pulse Secure renamed) escaped the enumerated list and was auto-advertised in the QR. Class fix stays tracked in #9158 - auth: document the MutationGateOptions caveat that on a no-token daemon the Local Control pairing credential admits loopback callers to the strict surface (round-7 design decision still open) * fix(local-control): stop serving the pairing secret to unauthenticated callers (#9106) Probe-verified hole: on a tokenless daemon any local process could POST /workspace/local-control/enable (or GET the unguarded status route) and read status.url — the pairing token in the fragment — then present it on the LAN listener, where the strictDenier passthrough admitted it to the whole strict mutation surface (file writes, memory CRUD, git push/pull, extension/MCP control) without the operator ever scanning anything. Close the acquisition step: - GET status / POST enable / POST disable now return url + qrText only to requests bearerAuth actually authenticated (requestWasAuthenticated); unauthenticated callers get the status with the secret stripped and urlRedacted: true while active - on an unauthenticated enable the pairing URL is printed to the daemon's own terminal instead — the one channel a local attacker cannot read over HTTP - web-shell Settings card renders a terminal hint when urlRedacted (en/zh) - MutationGateOptions caveat rewritten to the resolved state Authenticated callers (daemon token) are unchanged. Route tests: redaction for unauthenticated GET/enable, full payload for authenticated callers, terminal print on enable; suites 42/42, eslint/prettier clean, Codex security review CLEAN. * fix(local-control): close round-9/10/11 review findings (#9106) - write the pairing URL with writeStdoutLineSafe so a dead/full stdout cannot wedge enable into a false 500 - reject an empty --local-control-address instead of silently dropping it - pin --token/--allow-origin composition through to runQwenServe - drop stale serve.ts file:line references in credentials/lan-interfaces - correct the CORS caveat and the design doc's flag/origin claims * test(serve): drop stale strict-port assertion * fix(local-control): close round-12 review findings (#9106) - give the composition test a full enable payload and a handle.close so the detached handler cannot leak a real process.exit(1) - wrap the QR dynamic import + setErrorLevel in withUiData's fault isolation so a broken qrcode-terminal degrades to the raw URL instead of 500ing every status/enable - fix the ZH urlRedacted copy (it prints a URL, not a QR) - finish the denyBrowserOriginCors -> allowOriginCors doc sweep in 01-architecture.md and 18-error-taxonomy.md --------- Co-authored-by: yiliang114 <yiliang114@users.noreply.github.com> |
||
|---|---|---|
| .. | ||
| bootstrap | ||
| runtime/qwen-code | ||
| scripts | ||
| src-tauri | ||
| .gitignore | ||
| .npmrc | ||
| NOTICE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
Qwen Code desktop shell
This package is an isolated Tauri 2 shell around the existing Web Shell. It does not contain a second UI.
Runtime layout
npm run build:runtime prepares runtime/qwen-code/ with:
- the current platform's Node.js runtime,
- the bundled
qwenCLI, - the built Web Shell under
lib/web-shell/.
The Tauri app starts qwen serve on an ephemeral loopback port with a per-launch bearer token, waits for /health, and then opens that same daemon-served Web Shell in the native window.
Use Settings → Daemon → Local Control to temporarily share the live daemon with a phone on the same Wi-Fi. The Web Shell displays a QR code, keeps the computer awake while sharing is enabled, and closes the LAN listener when the user turns it off.
Local development
From this directory:
npm install --workspaces=false
npm run build:runtime --workspaces=false
npm run dev --workspaces=false
The first two steps are one-time setup. After that, npm run dev is all you need.
build:runtime bundles the current platform's Node.js, the qwen CLI, and the built Web Shell into runtime/qwen-code/. Re-run it only when you change the CLI or Web Shell source.
Use QWEN_DESKTOP_WORKSPACE=/absolute/path to override the initial workspace. The app otherwise restores its saved primary workspace or creates ~/Documents/Qwen on first launch. QWEN_DEFAULT_WORKSPACE_DIR=/absolute/path relocates that first-launch default, matching the Electron shell. Add and switch project workspaces from the Web Shell after startup.
Debugging
Runtime log
The daemon log is written to ~/Library/Logs/com.alibaba.qwen-code/desktop-runtime.log on macOS. Tail it to see qwen serve output:
tail -f ~/Library/Logs/com.alibaba.qwen-code/desktop-runtime.log
The desktop state (saved workspace, window position) is stored in ~/Library/Application Support/com.alibaba.qwen-code/desktop-state.json.
WebView DevTools
Open the Web Shell's DevTools from the running window with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows/Linux). This lets you inspect network requests, console output, and the React component tree.
Environment variables
| Variable | Purpose |
|---|---|
QWEN_DESKTOP_WORKSPACE |
Override the initial workspace path |
QWEN_DEFAULT_WORKSPACE_DIR |
Relocate the first-launch default workspace directory |
QWEN_DESKTOP_SKIP_BUILD |
Set to 1 to skip the CLI/Web Shell rebuild during build:runtime |
QWEN_CODE_ROOT |
Point to a local qwen-code checkout for the runtime bundle |
Rust tests
cargo test --manifest-path src-tauri/Cargo.toml
Releases
The Desktop Release workflow builds signed updater artifacts when dry_run is disabled. Published releases require the Tauri updater private key. macOS releases also require Apple signing and notarization credentials.
The first stable Tauri release may set electron_bridge=true to publish the macOS ZIPs and DMGs, Windows NSIS installer, Linux AppImage, and their Electron 0.0.5 manifests. Leave the input disabled for later releases; the fixed desktop-latest release retains the bridge assets while desktop-latest.json advances independently.
The macOS workflow accepts either the Tauri-era APPLE_* certificate and notarization secrets or the existing MAC_CSC_* and APPLE_NOTARY_* secrets. TAURI_SIGNING_PRIVATE_KEY must match the public key in src-tauri/tauri.conf.json.