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. - Resolve the bearer:
--token, thenQWEN_SERVER_TOKEN, then — where neither source is present and the requested--hostnameis non-loopback (the literallocalhostresolved once first) — a generated ephemeral 128-bit base64url bearer (22 characters) printed once at startup. Loopback spellings never generate and keep the trusted token-less mode unless--require-authis set. Generation keys on the spelling while the boot refusal reads the resolved address, which leaves two carve-outs: alocalhostthat resolves off-loopback never generates, and boots only when a token source resolved (Refusing to bind …otherwise); and a non-literal name that resolves to loopback does generate, losing trusted token-less mode so its bearer prints token-only. - Reject unsafe or invalid startup configurations: a non-loopback bind whose token source is explicitly empty,
--require-authon a token-less loopback bind, wildcard or non-loopback HTTP(S)--allow-originon a token-less loopback bind,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 (loopback
Originstrip -> access log -> inbound trace-id capture ->hostAllowlist-> remote same-originOriginstrip ->allowOriginCorsover the mutable origin allowlist -> pre-auth/health-> pre-auth Web Shell assets -> channel webhooks ->bearerAuth-> rate limit -> JSON parser -> telemetry -> per-routemutationGate), and mount session, workspace CRUD, file, device-flow auth, permission vote, and ACP HTTP routes. (The unconditionaldenyBrowserOriginCorswall remains only in the bootstrap app,run-qwen-serve.ts.) - 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, server.ts, server/self-origin.ts, and server/access-log.ts):
| Middleware, in registration order | Purpose | Notes |
|---|---|---|
installSelfOriginStripMiddleware | Deletes an Origin header that matches a loopback same-origin value for the bound port, so the loopback Web Shell’s own POST/fetch calls are never treated as cross-origin. | First middleware on the runtime app. Matches both schemes and the bound loopback host, and omits scheme-default ports per RFC 7230 §5.4. |
| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to DaemonLogger when a request finishes. | Registered ahead of every gate, so 401 / 403 / 429 short-circuits are logged too. Exempt by exact path: GET /health and POST */heartbeat, so those liveness probes are never logged — including when a gate below rejects them (HEAD /health and GET /health/ are logged like any request). Successful GET */events streams are dropped as well. Burst-limited to 60 lines refilling at 2/s; overflow coalesces into an access logs suppressed warning. Pre-authentication gate rejects (Host allowlist, the CORS wall, the remote same-origin credential check) draw from a separate 30/1-s budget, so a credential-less flood of those rejects cannot starve the operator’s own lines behind that warning; bearerAuth 401s (a no-Origin flood) are not marked and still charge the operator budget — unchanged from before the reorder. |
| Inbound trace-id capture | Captures the caller’s traceparent trace id before any gate can short-circuit. | Lets the access log join a 401 / 429 / 400 / 404 line to the caller’s trace in telemetry-off deployments, where it is the only such link. |
hostAllowlist(bind, getPort) | On loopback, validate Host belongs to localhost, 127.0.0.1, [::1], host.docker.internal, or the exact bound loopback address, plus the actual port; port-less forms are accepted on ports 80 and 443. | Defense against DNS rebinding; it therefore also covers the pre-auth /health routes below it. Comparison is case-insensitive and cached per port. A deliberate no-op on non-loopback binds, where the bearer is the authentication layer. The Local Control LAN listener always enforces its advertised-authority Host check, whatever the primary bind is. |
installRemoteSelfOriginMiddleware | On a non-loopback primary listener that has a token, bearer-authenticates a request whose Origin equals the direct socket scheme plus the normalized Host, then deletes that Origin. | This is what lets the built-in Web Shell’s same-origin HTTP mutations through without --allow-origin. No-op on loopback binds and when no token is configured. Pre-auth Web Shell routes (/, //, /assets*, /mcp-app-sandbox, exact /session/:id document navigations) skip the credential check. Forwarded headers are never consulted. |
allowOriginCors | Always installed on the runtime app over a MutableOriginAllowlist: --allow-origin <pattern> entries seed it, Local Control adds the LAN origin while enabled; unmatched origins get the 403 deny envelope. | See 12-auth-security.md. Its rejects are logged by the access log above, except on the health/heartbeat exemptions. |
Pre-auth /health | Liveness route registered ahead of bearerAuth on an ordinary loopback bind. | Dropped under --require-auth, and never registered pre-auth on a non-loopback bind; those cases register /health after bearerAuth instead. A Local Control listener authenticates its own /health even in the pre-auth position. |
| Web Shell static assets and MCP App sandbox | /, /assets*, /mcp-app-sandbox, and exact /session/:id document navigations, mounted ahead of bearerAuth. | A browser cannot attach Authorization to a navigation or a <script src> subresource, and the static shell carries no secrets. The SPA deep-link fallback is registered after all API routes instead. --no-web opts out. |
| Channel webhook routes | POST /channels/:channelName/webhooks/:source, registered ahead of bearerAuth. | Authenticates with its own x-qwen-webhook-secret; rotating the daemon bearer does not rotate webhook secrets. |
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, so only authenticated requests are counted; returns 429 before parsing when a bucket is exhausted. Webhook routes use their own shared-secret gate instead. |
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 mutations that require operator authority. Trusted primary-listener requests, bearer-authenticated requests, and paired Local Control requests qualify. | A token-less primary request that reaches the strict gate without trusted-loopback authority returns 401 { code: 'token_required' }. Missing or invalid configured credentials are rejected earlier by bearer middleware with plain 401 Unauthorized. Not global app.use; routes call mutate({ strict: true }) as needed. |
The bootstrap app that answers requests during the cold window (createBootstrapServeApp in run-qwen-serve.ts) runs a shorter chain in this order: loopback Origin strip -> hostAllowlist -> remote same-origin Origin strip -> the CORS wall (allowOriginCors when --allow-origin is set, otherwise the unconditional denyBrowserOriginCors) -> pre-auth /health on an ordinary loopback bind -> bearerAuth -> the gated /health, /capabilities, and /daemon/status routes. It installs no access log, so only the requests it answers itself — /health, /capabilities, and /daemon/status — are not logged. The delegating wrapper (createDelegatingServeApp) sits in front with its own bearer gate: any other cold-window path starts the runtime and is dispatched to the runtime app, which does log it, with durationMs measured from that hand-off rather than the client-visible start. With --open the runtime app answers directly, so there is no cold window at all.
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 because a browser cannot attach Authorization to a navigation or subresource. API calls follow the normal authority policy: configured tokens gate normal API routes except loopback /health unless --require-auth is set, while channel webhook ingress always uses its own shared secret and the token-less trusted-loopback primary listener has full operator access. 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
Before runQwenServe() starts this sequence, the CLI-only --open-with-auth mode validates loopback/Web Shell eligibility and fills ServeOptions.token with the selected configured token, or with 32 random bytes (a 256-bit bearer) encoded as base64url when that selection is empty. That generated value is an ordinary configured token as far as every step below is concerned — which is why --require-auth --open-with-auth boots — and it is a separate generator from the non-loopback ephemeral bearer in step 1. Direct embedders that call createServeApp themselves never generate a token.
- Resolve the token from
opts.tokenorQWEN_SERVER_TOKEN, trimmed so a trailing newline fromcat token.txtcannot silently break bearer comparison. When the requested--hostnameis non-loopback (the literallocalhostresolved once first) and neither source is present, generate an ephemeral 128-bit (16-byte) bearer as 22 base64url characters instead of refusing; it is printed once by the remote quickstart afterlisten()and rotates on every restart. Loopback spellings never generate, so they keep the trusted token-less mode. An explicitly blank source (--token '', orQWEN_SERVER_TOKENset to an empty or whitespace-only value) is not “absent”, so it always suppresses generation — but blankness decides the resolved token in one direction only: a blank--tokenshadows a set env value and resolves to no token, whereas a blank env resolves to no token only when--tokenis not passed (a non-blank--tokenstill wins); in both shapes a non-loopback bind with no resolved token still fails the guards below. - Hostname typo guard:
--hostname localhost:4170errors and suggests--port. - Auth preflight: a non-loopback bind with no resolved token refuses — reachable through an explicitly empty source, or through a
localhostbind whose one-time resolution lands off-loopback (generation keys on the spelling, so nothing was generated there);--require-authrefuses on a token-less bind, which after step 1 means a loopback bind with no configured source. Wildcard and non-loopback HTTP(S)--allow-originguards read the same resolved token, so on a non-loopback bind the generated bearer satisfies them and those refusals are loopback-only too. - 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: wildcard and non-loopback HTTP(S)
--allow-originvalues require a resolved token (see step 3 for why those refusals are loopback-only); 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.- Create and lifecycle-bind the HTTP(S) server before listening, then call
server.listen(port, hostname)and resolve the actualgetPort()for host allowlist. Conversations ownership cannot start until this listener and the remaining host startup gates are ready. - Register SIGINT / SIGTERM handlers for graceful shutdown through the shared app lifecycle.
Graceful shutdown
- Seal admission and begin all drains on the 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.
- Close the listener while app and host drains run:
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.
- Release Conversations ownership only after positive shutdown proof from the listener, app-local work, host-owned work, Live discovery cleanup, and runtime drains. Any incomplete proof rejects shutdown instead of allowing an unsafe handoff.
- 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(): programmatic shutdown for embedders and tests.
Calling createServeApp directly still returns only an Application. An embedder that needs Live/Conversations must create the actual Node server, call getServeAppLifecycle(app).bindServer(server) before its first listen(), and await lifecycle.close() during shutdown. Without binding, ordinary routes remain available but Live/Conversations fail closed. Calling raw server.close() triggers event-driven cleanup, but the embedder must still await lifecycle.close() to observe drain or ownership-release failures.
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: getAllMemoryFilenames, 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. |
| CLI flags | --open-with-auth | Default-off loopback Web Shell launch that reuses or generates a process-lifetime bearer before runtime. |
| 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 | Workspace memory filename passed to /workspace/init through the workspace-service contextFilename. |
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. - The runtime app runs
allowOriginCorsover the mutable allowlist; unmatchedOriginvalues get the 403 deny envelope (the unconditionaldenyBrowserOriginCorswall survives only in the bootstrap app). The loopback Web Shell works because another middleware strips matching loopback same-origin values first; on a non-loopback bind with a token the shell’s same-origin XHRs are bearer-authenticated and theirOriginstripped ahead of the wall, so they need no--allow-origin. Three cases still require an allowlist entry: WebSocket upgrades (terminal, voice), a TLS-terminating front proxy whosehttpsorigin never matches the plain socket, and any plain-HTTP intermediary that rewrites theHostheader — nginx’s defaultproxy_set_header Host $proxy_hostand k8s Ingress both do. Port translation alone needs nothing on a non-loopback bind (docker -p 8080:4170): the check comparesOriginagainst the normalized forwardedHostonly and never consults the listening port (just the scheme-default:80/:443are stripped; a non-default port must survive verbatim in it). On the default loopback bind it does not: the DNS-rebinding Host allowlist only accepts the daemon’s own port, so a port-translating tunnel (ssh -L 8080:localhost:4170) is rejected with403 Invalid Host headerfor every request including the shell document, and--allow-origincannot override it — forward the same port or bind non-loopback. The remedy for the WebSocket and TLS-terminating cases is--allow-origin <origin>; a Host-rewriting intermediary can instead be configured to forwardHostverbatim — which cannot help once TLS terminates at the proxy, because the scheme is read from the daemon’s own socket. - 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