mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-21 22:55:16 +00:00
* 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> |
||
|---|---|---|
| .. | ||
| prepare-runtime.js | ||
| resolve-log-root.js | ||
| smoke-packaged.js | ||
| smoke-runtime.js | ||
| test-release.js | ||
| version.js | ||