Skip to Content
Developer GuideDaemon Mode (Developer Deep Dive)02 · Serve Runtime

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 --workspace before registering session runtimes. The primary canonical form is shared by /capabilities.workspaceCwd, the POST /session fallback, and the primary bridge.
  • Resolve the bearer: --token, then QWEN_SERVER_TOKEN, then — where neither source is present and the requested --hostname is non-loopback (the literal localhost resolved 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-auth is set. Generation keys on the spelling while the boot refusal reads the resolved address, which leaves two carve-outs: a localhost that 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-auth on a token-less loopback bind, wildcard or non-loopback HTTP(S) --allow-origin on a token-less loopback bind, mcpBudgetMode='enforce' without a positive mcpClientBudget, a nonexistent or non-directory --workspace, and invalid timeout or rate-limit values.
  • Construct the WorkspaceFileSystem factory, permission audit publisher, DaemonStatusProvider, and acp-bridge.
  • Build the Express app, wire middleware (loopback Origin strip -> access log -> inbound trace-id capture -> hostAllowlist -> remote same-origin Origin strip -> allowOriginCors over the mutable origin allowlist -> pre-auth /health -> pre-auth Web Shell assets -> channel webhooks -> bearerAuth -> rate limit -> JSON parser -> telemetry -> per-route mutationGate), and mount session, workspace CRUD, file, device-flow auth, permission vote, and ACP HTTP routes. (The unconditional denyBrowserOriginCors wall 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 orderPurposeNotes
installSelfOriginStripMiddlewareDeletes 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 middlewareRecords 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 captureCaptures 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.
installRemoteSelfOriginMiddlewareOn 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.
allowOriginCorsAlways 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 /healthLiveness 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 routesPOST /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 middlewareOptional 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.
daemonTelemetryMiddlewareWraps 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:

PathRole
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.tsHTTP handlers for GET /file, GET /file/bytes, POST /file/write, and POST /file/edit.
serve/workspace-memory.tsGET/POST /workspace/memory (QWEN.md CRUD).
serve/workspace-agents.tsGET/POST/DELETE /workspace/agents (subagent CRUD).
serve/daemon-status-provider.tsEnv snapshot plus daemon-host preflight cells: Node version, CLI entry, workspace stat, ripgrep, git, npm.
serve/permission-audit.tsPermissionAuditRing (512-entry FIFO) and createPermissionAuditPublisher.
serve/auth/device-flow.ts, qwen-device-flow-provider.tsDevice-flow OAuth routes. See 12-auth-security.md.
serve/daemon-logger.tsDaemonLogger structured file logs. See 19-observability.md.
serve/debug-mode.tsShared 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.tsLocate 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.ts remains 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.

  1. Resolve the token from opts.token or QWEN_SERVER_TOKEN, trimmed so a trailing newline from cat token.txt cannot silently break bearer comparison. When the requested --hostname is non-loopback (the literal localhost resolved 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 after listen() and rotates on every restart. Loopback spellings never generate, so they keep the trusted token-less mode. An explicitly blank source (--token '', or QWEN_SERVER_TOKEN set 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 --token shadows a set env value and resolves to no token, whereas a blank env resolves to no token only when --token is not passed (a non-blank --token still wins); in both shapes a non-loopback bind with no resolved token still fails the guards below.
  2. Hostname typo guard: --hostname localhost:4170 errors and suggests --port.
  3. Auth preflight: a non-loopback bind with no resolved token refuses — reachable through an explicitly empty source, or through a localhost bind whose one-time resolution lands off-loopback (generation keys on the spelling, so nothing was generated there); --require-auth refuses on a token-less bind, which after step 1 means a loopback bind with no configured source. Wildcard and non-loopback HTTP(S) --allow-origin guards read the same resolved token, so on a non-loopback bind the generated bearer satisfies them and those refusals are loopback-only too.
  4. Workspace validation: absolute path, exists, directory. EACCES / EPERM are wrapped to point at the flag.
  5. Canonicalize workspace: canonicalizeWorkspace(rawWorkspace) runs realpathSync.native once and feeds /capabilities, the POST /session fallback, and the bridge.
  6. MCP budget validation: positive integer; enforce requires a budget.
  7. MCP pool toggle inference: parent env QWEN_SERVE_NO_MCP_POOL=1 makes mcpPoolActive=false, so capabilities honestly omit mcp_workspace_pool and mcp_pool_restart.
  8. CORS / timeout / rate-limit validation: wildcard and non-loopback HTTP(S) --allow-origin values 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.
  9. Per-handle childEnvOverrides: pass QWEN_SERVE_MCP_CLIENT_BUDGET and QWEN_SERVE_MCP_BUDGET_MODE to the ACP child through BridgeOptions.childEnvOverrides instead of mutating process.env.
  10. Load settings.json once: read context.fileName, policy.permissionStrategy, and policy.consensusQuorum. Corrupt files fall back to defaults. validatePolicyConfig() checks policy.* against SERVE_CAPABILITY_REGISTRY.permission_mediation.modes; unknown strategies or non-positive consensusQuorum throw InvalidPolicyConfigError. A quorum set under a non-consensus strategy logs a stderr warning.
  11. Allocate PermissionAuditRing (512 entries).
  12. Build fsFactory: runQwenServe defaults to trusted: true; direct createServeApp callers default to trusted: false and warn once.
  13. createHttpAcpBridge, see 03-acp-bridge.md.
  14. createServeApp assembles Express.
  15. Create and lifecycle-bind the HTTP(S) server before listening, then call server.listen(port, hostname) and resolve the actual getPort() for host allowlist. Conversations ownership cannot start until this listener and the remaining host startup gates are ready.
  16. Register SIGINT / SIGTERM handlers for graceful shutdown through the shared app lifecycle.

Graceful shutdown

  1. Seal admission and begin all drains on the first signal:
    • Dispose the device-flow registry and cancel pending flows.
    • bridge.shutdown() marks each channel isDying = true, sends graceful close to each ACP child stdin, waits KILL_HARD_DEADLINE_MS (10s) per channel, then calls channel.kill() if needed.
  2. 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) triggers server.closeAllConnections().
    • A second 2s deadline escalates again if needed.
  3. 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.
  4. 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, including 0 resolution.
  • 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 typesThe qwen CLI serve subcommand handler
packages/core: getAllMemoryFilenames, Config, WorkspaceContextDirect embedders, tests
ACP SDK (@agentclientprotocol/sdk): PROTOCOL_VERSION, ClientSideConnection through bridge
Express + body-parser, node:crypto, node:fs, node:path

Configuration

SourceKeyEffect
EnvQWEN_SERVER_TOKENBearer token after trim.
EnvQWEN_SERVE_NO_MCP_POOL=1Forces mcpPoolActive=false.
ACP child envQWEN_SERVE_MCP_CLIENT_BUDGET / QWEN_SERVE_MCP_BUDGET_MODEGenerated from --mcp-client-budget / --mcp-budget-mode and forwarded through childEnvOverrides.
EnvQWEN_SERVE_PROMPT_DEADLINE_MS / QWEN_SERVE_WRITER_IDLE_TIMEOUT_MSDefault prompt / SSE idle timeouts.
EnvQWEN_SERVE_RATE_LIMIT*Rate-limit switch, prompt / mutation / read caps, and window default.
EnvQWEN_SERVE_DEBUG=1Verbose stderr logs. See 19-observability.md.
Flags--hostname, --portListen binding.
Flags--token, --require-auth, --enable-session-shellBearer token, loopback auth hardening, and explicit shell execution switch.
CLI flags--open-with-authDefault-off loopback Web Shell launch that reuses or generates a process-lifetime bearer before runtime.
Flag--workspaceOverrides process.cwd(); repeat to register additional isolated workspace runtimes.
Flags--max-sessions, --max-pending-prompts-per-session, --max-connections, --event-ring-sizeBridge / 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-urlBrowser CORS allowlist and localhost/private auth provider installation switch.
Flag--web / --no-webServe 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-msPrompt, SSE writer, ACP child idle lifecycle, and ACP child request timeout control.
Flags--session-reap-interval-ms, --session-idle-timeout-msDisconnected-session reaping control.
Flags--rate-limit*Per-tier HTTP rate limit.
settings.jsonpolicy.permissionStrategy, policy.consensusQuorumMultiClientPermissionMediator policy and quorum.
settings.jsoncontext.fileNameWorkspace memory filename passed to /workspace/init through the workspace-service contextFilename.

See 17-configuration.md for the merged reference.

Caveats and known limits

  • Direct createServeApp without deps.fsFactory or deps.bridge defaults to trusted: false; agent-side ACP writeTextFile rejects as untrusted_workspace. The warning is printed once.
  • The runtime app runs allowOriginCors over the mutable allowlist; unmatched Origin values get the 403 deny envelope (the unconditional denyBrowserOriginCors wall 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 their Origin stripped 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 whose https origin never matches the plain socket, and any plain-HTTP intermediary that rewrites the Host header — nginx’s default proxy_set_header Host $proxy_host and k8s Ingress both do. Port translation alone needs nothing on a non-loopback bind (docker -p 8080:4170): the check compares Origin against the normalized forwarded Host only and never consults the listening port (just the scheme-default :80/:443 are 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 with 403 Invalid Host header for every request including the shell document, and --allow-origin cannot 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 forward Host verbatim — 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 after express.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; mutating process.env races because defaultSpawnChannelFactory snapshots 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.ts
  • packages/cli/src/serve/permission-audit.ts
  • Issues: #3803 , #4175 
Last updated on