* chore(serve): remove the /demo debug page The daemon has shipped a real browser UI for a while: `resolveWebShellDir()` finds the bundled Web Shell assets and `mountWebShellAssets()` serves them at `/`, so `qwen serve` already opens onto a full client. `/demo` stayed behind as a 663-line inline-HTML console covering the same ground with none of the reach — nobody drives the daemon through it, and `npm run dev:daemon` starts the Web Shell dev server rather than the demo page. Keeping it around costs more than the dead code. It is the only file in the tree that pairs an event log with daemon HTTP, so work that starts as a Web Shell observation lands there instead: #8762 was found while running `/review` through the Web Shell and was fixed entirely inside the demo page's rendering, with "no Web Shell changes" in its own risk note. Deleting the page removes that decoy. Nothing is lost for protocol-level debugging: `GET /session/:id/events` streams the same raw frames the Events tab printed. `/health` shared `routes/health-demo.ts` with the demo handler, so the module is now `routes/health.ts` / `createHealthRoutes()` and drops its `getPort` dependency. The rate-limit exemption, the boot breadcrumb, and the daemon docs lose their `/demo` arms; the loopback self-origin shim regression test already asserted through `/health` and only needed its title corrected. * test(serve): pin the removed /demo contract and the pre-auth surface Review follow-up. Three of the removal hunks shipped ungated, and two doc sentences the removal rewrote were describing the pre-auth surface wrong — both before and after the edit. Deleting the `/demo` route took its assertions with it, so nothing failed if the handler came back: the Web Shell suite only exercised a generic deep link, and the rate-limit exemption could be widened again with the suite still green. `/demo` is now pinned as what it became — an ordinary unknown path: a non-navigation request 404s, a browser navigation is answered by the SPA fallback like any other deep link, and once a token is configured (with or without `--require-auth`) that navigation is refused with 401, because the fallback sits behind the bearer. The rate-limit test pins that `/health` is the only exempt GET, so re-adding a second pre-auth page to the predicate fails instead of silently escaping the limiter. Each new assertion was checked by reverting the hunk it guards and confirming it goes red. The `--allow-origin '*'` warning and both `--allow-origin` doc paragraphs enumerated `/health` as the residual tokenless surface and said nothing about the Web Shell static assets, which are mounted before the bearer in every launch mode and stay reachable even under `--require-auth` — the enumeration also claimed `/health` stays pre-auth on non-loopback binds, where it is registered behind the bearer and 401s. A probe across all three launch modes established the actual matrix; the warning and the docs now match it and name `--no-web` as the way to remove the residual browser surface. The warning text is asserted by a test for the first time. * fix(serve): correct Web Shell doc claims and re-pin the pre-auth CORS wall Review follow-up. The removal rewrote the daemon docs around the Web Shell, and three of the rewritten claims did not match what the runtime actually does: §1 never said how the bearer reaches the browser (with auth on, the plain URL loads a shell whose every API call 401s), §8 called the shell writable on any bind (on a non-loopback bind without `--allow-origin` its POSTs hit the CORS wall and 403), and §8 served `/session/:id` without the document-navigation qualifier its own code enforces. The §9 call-chain diagram also still listed the deleted `/demo` route, the developer flag references had no `--web`/`--no-web` row despite the new guidance pointing at the flag, and both design docs listed the JSON body parser ahead of post-auth `/health` while `createServeApp()` registers them the other way round. The deleted `/demo` CORS test was also the only assertion that a pre-auth page sits behind the Origin wall — every surviving Origin test targets an API path. Re-pin it for the shell root so a mount-order regression fails instead of exposing the pre-auth HTML surface cross-origin. * fix(serve): finish demo rename sweep and scope pre-auth shell claims to loopback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
23 KiB
Serve Runtime
Overview
packages/cli/src/serve/ is the boot layer for qwen serve. It translates CLI flags into ServeOptions, validates startup configuration, builds the Express app, wires middleware, registers routes, exposes daemon-host preflight/status providers, maintains the permission audit ring, and owns the two-phase graceful shutdown sequence. HTTP-facing work lives in this layer; ACP-facing work lives one layer below in @qwen-code/acp-bridge (see 03-acp-bridge.md).
Responsibilities
- Parse and validate
ServeOptions: listen address, auth, workspace, session / connection caps, MCP budget / pool, CORS, prompt / SSE / session idle timeouts, rate limit, and related toggles. - Canonicalize the primary workspace exactly once, and canonicalize every repeated
--workspacebefore registering session runtimes. The primary canonical form is shared by/capabilities.workspaceCwd, thePOST /sessionfallback, and the primary bridge. - Reject unsafe or invalid startup configurations: non-loopback bind without token,
--require-authwithout token,--allow-origin '*'without token,mcpBudgetMode='enforce'without a positivemcpClientBudget, a nonexistent or non-directory--workspace, and invalid timeout or rate-limit values. - Construct the
WorkspaceFileSystemfactory, permission audit publisher,DaemonStatusProvider, andacp-bridge. - Build the Express app, wire middleware (
denyBrowserOriginCors/allowOriginCors->hostAllowlist-> access log ->bearerAuth-> rate limit -> JSON parser -> telemetry -> per-routemutationGate), and mount session, workspace CRUD, file, device-flow auth, permission vote, and ACP HTTP routes. - Bind the listening port and register signal handlers.
- Run two-phase shutdown on SIGINT/SIGTERM; force-exit on a second signal.
Architecture
Entry: runQwenServe(opts, deps) in packages/cli/src/serve/run-qwen-serve.ts. Returns a RunHandle ({ url, port, close, ... }).
App factory: createServeApp(opts, getPort, deps) in packages/cli/src/serve/server.ts. Builds the Express Application. Direct embedders and tests call it without the bootstrap wrapper.
Capability registry: SERVE_CAPABILITY_REGISTRY in packages/cli/src/serve/capabilities.ts. Each tag has a since version and optional modes. Conditional tags are omitted when their deployment or runtime predicate is false; the registry and predicate map are the source of truth. See 11-capabilities-versioning.md.
Middleware (packages/cli/src/serve/auth.ts and server.ts):
| Middleware, in registration order | Purpose | Notes |
|---|---|---|
denyBrowserOriginCors / allowOriginCors |
Deny all Origin headers by default; switch to an allowlist when --allow-origin <pattern> is configured. |
See 12-auth-security.md. |
hostAllowlist(bind, getPort) |
On loopback, validate Host belongs to localhost, 127.0.0.1, [::1], or host.docker.internal plus the actual port. |
Defense against DNS rebinding. Comparison is case-insensitive and cached per port. |
| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to DaemonLogger when a request finishes. |
Registered before bearerAuth, so 401 denials are logged too. Skips /health and heartbeat. |
bearerAuth(token) |
SHA-256 plus timingSafeEqual constant-time bearer comparison. |
Open passthrough when no token is configured (loopback dev default). Bearer scheme is case-insensitive. |
| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after bearerAuth and before JSON parsing; returns 429 before parsing when a bucket is exhausted. |
express.json({ limit: '10mb' }) |
JSON body parsing. | Parse errors return 400. |
daemonTelemetryMiddleware |
Wraps classified daemon API requests that reach this point in an OpenTelemetry span through withDaemonRequestSpan. |
Attributes include canonical route, resolved workspace hash, sessionId, clientId, and status code. Earlier auth, rate-limit, and body-parser rejections are outside this span boundary. |
createMutationGate (per-route) |
Route-level opt-in gate for mutation routes that require token even on loopback. | Returns 401 { code: 'token_required' }. Not global app.use; routes call mutate({ strict: true }) as needed. |
Subsystems:
| Path | Role |
|---|---|
serve/fs/ |
WorkspaceFileSystem factory plus policy.ts (size/trust/binary checks), paths.ts (canonicalize, resolveWithin, symlink rejection), audit.ts, and typed FsError values. |
serve/routes/workspace-file-read.ts, workspace-file-write.ts |
HTTP handlers for GET /file, GET /file/bytes, POST /file/write, and POST /file/edit. |
serve/workspace-memory.ts |
GET/POST /workspace/memory (QWEN.md CRUD). |
serve/workspace-agents.ts |
GET/POST/DELETE /workspace/agents (subagent CRUD). |
serve/daemon-status-provider.ts |
Env snapshot plus daemon-host preflight cells: Node version, CLI entry, workspace stat, ripgrep, git, npm. |
serve/permission-audit.ts |
PermissionAuditRing (512-entry FIFO) and createPermissionAuditPublisher. |
serve/auth/device-flow.ts, qwen-device-flow-provider.ts |
Device-flow OAuth routes. See 12-auth-security.md. |
serve/daemon-logger.ts |
DaemonLogger structured file logs. See 19-observability.md. |
serve/debug-mode.ts |
Shared isServeDebugMode() predicate controlling verbose error context in HTTP responses. |
serve/acp-http/ |
ACP Streamable HTTP transport (RFD #721), mounted at /acp. Seven files implement JSON-RPC POST, SSE GET, DELETE teardown, and shared bridge usage in parallel with the REST surface. |
serve/web-shell-static.ts, serve/web-shell-resolver.ts |
Locate and mount the built Web Shell assets (the daemon's browser UI) at /, /assets, and /session/:id, plus the SPA deep-link fallback registered after all API routes. Mounted before bearerAuth in every launch mode — a browser cannot attach Authorization to a navigation or subresource — while every API route it calls stays token-gated. Degrades to API-only when the assets are absent; --no-web opts out. |
ACP bridge package imports:
- Event-bus primitives are imported from
@qwen-code/acp-bridge/eventBus. - Status primitives are imported from
@qwen-code/acp-bridge/status. serve/acp-session-bridge.tsremains as the CLI-local compatibility facade for the broader bridge surface.
Flow
Boot sequence
- Resolve and trim token from
opts.tokenorQWEN_SERVER_TOKEN; this avoids a trailing newline fromcat token.txtsilently breaking bearer comparison. - Hostname typo guard:
--hostname localhost:4170errors and suggests--port. - Auth preflight: non-loopback without token refuses;
--require-authwithout token refuses. - Workspace validation: absolute path, exists, directory.
EACCES/EPERMare wrapped to point at the flag. - Canonicalize workspace:
canonicalizeWorkspace(rawWorkspace)runsrealpathSync.nativeonce and feeds/capabilities, thePOST /sessionfallback, and the bridge. - MCP budget validation: positive integer;
enforcerequires a budget. - MCP pool toggle inference: parent env
QWEN_SERVE_NO_MCP_POOL=1makesmcpPoolActive=false, so capabilities honestly omitmcp_workspace_poolandmcp_pool_restart. - CORS / timeout / rate-limit validation:
--allow-origin '*'requires token; prompt, writer, channel idle, session idle, reaper, and rate-limit window values fail fast when invalid. - Per-handle
childEnvOverrides: passQWEN_SERVE_MCP_CLIENT_BUDGETandQWEN_SERVE_MCP_BUDGET_MODEto the ACP child throughBridgeOptions.childEnvOverridesinstead of mutatingprocess.env. - Load
settings.jsononce: readcontext.fileName,policy.permissionStrategy, andpolicy.consensusQuorum. Corrupt files fall back to defaults.validatePolicyConfig()checkspolicy.*againstSERVE_CAPABILITY_REGISTRY.permission_mediation.modes; unknown strategies or non-positiveconsensusQuorumthrowInvalidPolicyConfigError. A quorum set under a non-consensusstrategy logs a stderr warning. - Allocate
PermissionAuditRing(512 entries). - Build
fsFactory:runQwenServedefaults totrusted: true; directcreateServeAppcallers default totrusted: falseand warn once. createHttpAcpBridge, see03-acp-bridge.md.createServeAppassembles Express.server.listen(port, hostname), then resolve the actualgetPort()for host allowlist.- Register SIGINT / SIGTERM handlers for graceful shutdown.
Graceful shutdown
- Phase 1 - bridge teardown on first signal:
- Dispose the device-flow registry and cancel pending flows.
bridge.shutdown()marks each channelisDying = true, sends graceful close to each ACP child stdin, waitsKILL_HARD_DEADLINE_MS(10s) per channel, then callschannel.kill()if needed.
- Phase 2 - HTTP teardown:
server.close()stops accepting new connections and lets in-flight requests finish.SHUTDOWN_FORCE_CLOSE_MS(5s) triggersserver.closeAllConnections().- A second 2s deadline escalates again if needed.
- Second signal while exiting:
bridge.killAllSync()+process.exit(1)to avoid orphaned children blocking daemon exit.
State and lifecycle
RunHandle exposes:
url: resolved listen URL, after ephemeral port resolution.port: actual port, including0resolution.close({ timeoutMs? }): programmatic shutdown for embedders and tests.
Calling createServeApp directly returns only an Application; the embedder owns listen and shutdown.
Dependencies
Upstream used by serve/ |
Downstream using serve/ |
|---|---|
@qwen-code/acp-bridge: bridge, event bus, status types |
The qwen CLI serve subcommand handler |
packages/core: loadSettings, getCurrentGeminiMdFilename, Config, WorkspaceContext |
Direct embedders, tests |
ACP SDK (@agentclientprotocol/sdk): PROTOCOL_VERSION, ClientSideConnection through bridge |
|
Express + body-parser, node:crypto, node:fs, node:path |
Configuration
| Source | Key | Effect |
|---|---|---|
| Env | QWEN_SERVER_TOKEN |
Bearer token after trim. |
| Env | QWEN_SERVE_NO_MCP_POOL=1 |
Forces mcpPoolActive=false. |
| ACP child env | QWEN_SERVE_MCP_CLIENT_BUDGET / QWEN_SERVE_MCP_BUDGET_MODE |
Generated from --mcp-client-budget / --mcp-budget-mode and forwarded through childEnvOverrides. |
| Env | QWEN_SERVE_PROMPT_DEADLINE_MS / QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS |
Default prompt / SSE idle timeouts. |
| Env | QWEN_SERVE_RATE_LIMIT* |
Rate-limit switch, prompt / mutation / read caps, and window default. |
| Env | QWEN_SERVE_DEBUG=1 |
Verbose stderr logs. See 19-observability.md. |
| Flags | --hostname, --port |
Listen binding. |
| Flags | --token, --require-auth, --enable-session-shell |
Bearer token, loopback auth hardening, and explicit shell execution switch. |
| Flag | --workspace |
Overrides process.cwd(); repeat to register additional isolated workspace runtimes. |
| Flags | --max-sessions, --max-pending-prompts-per-session, --max-connections, --event-ring-size |
Bridge / Express caps. |
| Flags | --mcp-client-budget=N, --mcp-budget-mode={off,warn,enforce} |
Forwarded to the ACP child. |
| Flags | --allow-origin, --allow-private-auth-base-url |
Browser CORS allowlist and localhost/private auth provider installation switch. |
| Flag | --web / --no-web |
Serve or skip the Web Shell UI at the daemon root (default serves). --no-web leaves the daemon API-only. |
| Flags | --prompt-deadline-ms, --writer-idle-timeout-ms, --channel-idle-timeout-ms, --initialize-timeout-ms |
Prompt, SSE writer, ACP child idle lifecycle, and ACP child request timeout control. |
| Flags | --session-reap-interval-ms, --session-idle-timeout-ms |
Disconnected-session reaping control. |
| Flags | --rate-limit* |
Per-tier HTTP rate limit. |
settings.json |
policy.permissionStrategy, policy.consensusQuorum |
MultiClientPermissionMediator policy and quorum. |
settings.json |
context.fileName |
getCurrentGeminiMdFilename override for the bridge. |
See 17-configuration.md for the merged reference.
Caveats and known limits
- Direct
createServeAppwithoutdeps.fsFactoryordeps.bridgedefaults totrusted: false; agent-side ACPwriteTextFilerejects asuntrusted_workspace. The warning is printed once. denyBrowserOriginCorsrejects all requests carryingOrigin; the loopback Web Shell works because another middleware strips matching loopback same-origin values first — non-loopback binds require--allow-originfor the shell's XHRs.- Body-parser ordering: routes using
mutate({ strict: true })return 401 only afterexpress.json(). The worst case is--max-connections × express.json({limit: '10mb'}), up to about 2.5 GB of transient memory on a saturated loopback listener; this tradeoff is intentional. - Multiple daemons in one process must use per-handle
childEnvOverrides; mutatingprocess.envraces becausedefaultSpawnChannelFactorysnapshots env at spawn time.
References
packages/cli/src/serve/run-qwen-serve.ts(bootstrap, boot validation, graceful shutdown)packages/cli/src/serve/server.ts(createServeApp(), middleware and route assembly)packages/cli/src/serve/auth.ts(CORS, Host allowlist, bearer auth, mutation gate)packages/cli/src/serve/rate-limit.ts(per-tier HTTP rate limit)packages/cli/src/serve/capabilities.ts(capability registry and conditional advertisement)packages/cli/src/serve/types.ts(ServeOptions,CapabilitiesEnvelope)packages/cli/src/serve/daemon-status-provider.tspackages/cli/src/serve/permission-audit.ts- Issues: #3803, #4175