qwen-code/docs/design/local-control-cli.md
易良 04043e555d
feat: consolidate Local Control into one daemon-owned implementation (#9106)
* 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>
2026-08-17 16:44:48 +00:00

4.3 KiB

Local Control pairing

Goal

Make phone access to an existing qwen serve session a single explicit command:

qwen serve --local-control

The command keeps the primary daemon on loopback, starts one selected LAN listener, mints a revocable pairing token, prints its QR code, and inhibits system sleep until Local Control is disabled. Desktop exposes the same daemon-owned workflow from the Web Shell Settings card.

Behavior

--local-control is an opt-in shortcut over the existing daemon and Web Shell. It leaves the daemon's runtime token, configured origins, and resolved port intact, adds one LAN listener on a selected private IPv4 address, allowlists that advertised origin while the session is active, and puts the pairing token in the URL fragment before rendering the QR code.

The terminal remains the visible enabled indicator. Ctrl+C ends the whole daemon, not just Local Control: the graceful drain closes the LAN listener, invalidates the pairing token, and releases the existing cross-platform sleep inhibitor before the process exits. Turning Local Control off while the daemon keeps running is done from the Web Shell Settings card, which is also the only in-process re-enable path.

The mode rejects a non-default --hostname and --no-web instead of silently creating incomplete configurations. It composes with --token, --allow-origin, and ephemeral port 0; --local-control-address selects the LAN address when several candidates exist. Existing explicit qwen serve deployments are unchanged.

Security

  • LAN exposure requires an explicit operator action: --local-control at boot, or an enable request served only by the primary loopback listener; enable requests arriving over the LAN listener are rejected.
  • Every enable gets a new pairing token from crypto.randomBytes(32); environment tokens are not reused on the LAN listener.
  • Local Control adds the advertised LAN origin to the daemon-wide origin allowlist (--allow-origin patterns remain in effect on both listeners while the session is active); every protected LAN route still requires the pairing token.
  • The token stays in the URL fragment, so browsers do not send it in HTTP requests, access logs, or referrers before the Web Shell stores it.
  • Existing bearer authentication, timing-safe comparison, and non-loopback boot checks remain the enforcement boundary.
  • Only private/link-local IPv4 interface addresses are advertised. Multiple interfaces surface an explicit choice rather than guessing which network is correct.

Desktop behavior

Desktop keeps its bundled daemon bound to authenticated loopback. The Web Shell Settings card enables the same daemon-owned Local Control service, which starts the LAN listener, generates a separate pairing token and QR code, and acquires the platform sleep inhibitor. The listener validates its public Host and Origin and accepts only the pairing credential for LAN traffic. The Desktop PID, daemon PID, loopback address, and live sessions do not change.

Turning Local Control off from Settings closes the listener and active connections, releases sleep inhibition, and invalidates the pairing token. A later enable gets a new token. The LAN listener does not exist while the mode is off, so the normal Desktop runtime remains loopback-only.

This mode intentionally covers same-network access only. Internet remote control requires an account-authenticated outbound relay with reconnectable session state; it must not be implemented by exposing this LAN gateway through port forwarding or an unauthenticated tunnel.

Verification

  • Unit tests cover flag conflicts, generated-token handoff, LAN URL construction, QR output, and sleep inhibition.
  • Desktop Rust tests cover the gateway's Host/Origin boundary, HTTP bearer translation, WebSocket subprotocol translation, and loopback-only target requirement.
  • A real local daemon run verifies that the QR URL authenticates /capabilities, the Web Shell loads, and the sleep inhibitor lives only for the Local Control process.
  • A packaged macOS app pass verifies that enabling Local Control preserves the existing daemon/session, the QR opens that session from a second browser, and disabling it revokes the LAN listener and sleep assertion.
  • Existing serve command and sleep-inhibitor tests remain green, followed by build and typecheck.