diff --git a/docs/design/serve-server-final-split.md b/docs/design/serve-server-final-split.md index b79e42bc8cf..bf06b2b351d 100644 --- a/docs/design/serve-server-final-split.md +++ b/docs/design/serve-server-final-split.md @@ -10,13 +10,13 @@ The assembly order is part of the daemon contract and must stay visually auditab 1. same-origin `Origin` stripping 2. CORS and host allowlist -3. pre-auth `/health` and `/demo` on allowed loopback setups +3. pre-auth `/health` on allowed loopback setups 4. access logging 5. Web Shell static assets 6. bearer auth 7. rate limit -8. JSON body parser and JSON parser error mapper -9. post-auth `/health` and `/demo` when required +8. post-auth `/health` when required +9. JSON body parser and JSON parser error mapper 10. daemon telemetry 11. REST route groups 12. ACP HTTP and WebSocket routes diff --git a/docs/design/serve-server-split.md b/docs/design/serve-server-split.md index 69a055b9377..6910e8f7625 100644 --- a/docs/design/serve-server-split.md +++ b/docs/design/serve-server-split.md @@ -10,13 +10,13 @@ The app assembly order is part of the public behavior and must stay stable: 1. same-origin `Origin` stripping 2. CORS and host allowlist -3. pre-auth `/health` and `/demo` on allowed loopback setups +3. pre-auth `/health` on allowed loopback setups 4. access logging 5. Web Shell static assets 6. bearer auth 7. rate limit -8. JSON body parser and JSON parser error mapper -9. post-auth `/health` and `/demo` when required +8. post-auth `/health` when required +9. JSON body parser and JSON parser error mapper 10. daemon telemetry 11. REST route groups 12. ACP HTTP and WebSocket routes diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md index 5e81e230d43..4c9f5eefb72 100644 --- a/docs/developers/daemon/00-index.md +++ b/docs/developers/daemon/00-index.md @@ -86,7 +86,7 @@ Use these anchors when moving from the docs into the latest `main` code: | Surface | Implementation anchors | Primary docs | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| Bootstrap and HTTP assembly | `packages/cli/src/serve/run-qwen-serve.ts`, `packages/cli/src/serve/server.ts`, `packages/cli/src/serve/routes/health-demo.ts`, `/demo` | [`02`](./02-serve-runtime.md), [`20`](./20-quickstart-operations.md) | +| Bootstrap and HTTP assembly | `packages/cli/src/serve/run-qwen-serve.ts`, `packages/cli/src/serve/server.ts`, `packages/cli/src/serve/routes/health.ts`, `packages/cli/src/serve/web-shell-static.ts` | [`02`](./02-serve-runtime.md), [`20`](./20-quickstart-operations.md) | | ACP bridge and session multiplexing | `packages/acp-bridge/src/bridge.ts`, `packages/acp-bridge/src/bridgeTypes.ts`, `@qwen-code/acp-bridge` | [`03`](./03-acp-bridge.md), [`08`](./08-session-lifecycle.md) | | Permission mediation | `packages/acp-bridge/src/permissionMediator.ts`, `fromLoopback: boolean`, `policy.*` | [`04`](./04-permission-mediation.md), [`12`](./12-auth-security.md) | | MCP transport pool | `packages/core/src/tools/mcp-transport-pool.ts`, `mcp-pool-key.ts`, `pid-descendants.ts`, `session-mcp-view.ts`, `/mcp refresh`, `MCPCallInterruptedError` | [`05`](./05-mcp-transport-pool.md), [`06`](./06-mcp-budget-guardrails.md) | @@ -144,7 +144,7 @@ Use these anchors when moving from the docs into the latest `main` code: | Area | Current state | Primary docs | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | | Configuration | Full `qwen serve` flags, env vars, `settings.json`, `ServeOptions`, `BridgeOptions`, and important constants are collected in one page. | [`17`](./17-configuration.md) | -| Quickstart / operations | Shortest startup path, launch recipes, curl checks, demo page auth behavior, route split, shutdown behavior, and embedded invocation recipes are covered. | [`20`](./20-quickstart-operations.md) | +| Quickstart / operations | Shortest startup path, launch recipes, curl checks, Web Shell auth behavior, route split, shutdown behavior, and embedded invocation recipes are covered. | [`20`](./20-quickstart-operations.md) | | Errors | Boot-time explicit failures, route errors, bridge errors, EventBus errors, filesystem errors, and mediator errors are summarized with remediation. | [`18`](./18-error-taxonomy.md) | | Observability | `QWEN_SERVE_DEBUG`, curl recipes, useful events, telemetry gaps, and investigation checklists are documented. | [`19`](./19-observability.md) | diff --git a/docs/developers/daemon/02-serve-runtime.md b/docs/developers/daemon/02-serve-runtime.md index 7fbee49bc1e..34d69450e4e 100644 --- a/docs/developers/daemon/02-serve-runtime.md +++ b/docs/developers/daemon/02-serve-runtime.md @@ -37,19 +37,19 @@ **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`](./12-auth-security.md). | -| `serve/daemon-logger.ts` | `DaemonLogger` structured file logs. See [`19-observability.md`](./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/demo.ts` | Self-contained inline HTML for `GET /demo`: browser debug console with chat UI, event log, and workspace inspector. On loopback without `--require-auth`, it is registered **before** `bearerAuth`; on non-loopback or with `--require-auth`, it is registered **after** `bearerAuth`. Served with CSP `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'` plus `X-Frame-Options: DENY`. | +| 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`](./12-auth-security.md). | +| `serve/daemon-logger.ts` | `DaemonLogger` structured file logs. See [`19-observability.md`](./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**: @@ -113,32 +113,33 @@ Calling `createServeApp` directly returns only an `Application`; the embedder ow ## 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`](./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. | -| 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. | +| 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`](./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`](./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. -- `denyBrowserOriginCors` rejects **all** requests carrying `Origin`; the demo page works because another middleware strips matching same-origin values first. +- `denyBrowserOriginCors` rejects **all** requests carrying `Origin`; the **loopback** Web Shell works because another middleware strips matching loopback same-origin values first — non-loopback binds require `--allow-origin` for the shell's XHRs. - 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. diff --git a/docs/developers/daemon/12-auth-security.md b/docs/developers/daemon/12-auth-security.md index 1acb23d1db2..66e08a62a84 100644 --- a/docs/developers/daemon/12-auth-security.md +++ b/docs/developers/daemon/12-auth-security.md @@ -56,7 +56,7 @@ daemon bind beyond loopback in the open. ```mermaid flowchart LR - REQ[Request] --> SO["strip same-origin Origin
(demo page support)"] + REQ[Request] --> SO["strip same-origin Origin
(Web Shell support)"] SO --> CORS{"--allow-origin?"} CORS -->|yes| AO["allowOriginCors
(allowlist match)"] CORS -->|no| DC["denyBrowserOriginCors
(reject all Origin)"] @@ -100,7 +100,7 @@ Non-loopback binds bypass this middleware (operator chose the surface area; bear Reject any request with an `Origin` header. CLI/SDK never set Origin; only browsers do. Returns deterministic `403 { error: 'Request denied by CORS policy' }` rather than the 500 HTML the `cors` package's error-callback would produce. -Exception: the demo page's same-origin XHRs are handled by a separate middleware (in `server.ts`) that strips `Origin` when it matches the daemon's own address. +Exception: the Web Shell's same-origin XHRs on a **loopback** bind are handled by a separate middleware (in `server/self-origin.ts`) that strips `Origin` when it matches one of the loopback self-origins (`127.0.0.1`, `localhost`, `[::1]`, `host.docker.internal`). On non-loopback binds the shell's XHRs carry an unmatched `Origin` and need `--allow-origin` for the daemon origin. ### `allowOriginCors` (`--allow-origin` mode) @@ -298,7 +298,7 @@ sequenceDiagram - **`--require-auth` shadows feature preflight.** Unauthenticated clients cannot discover the `require_auth` tag; their discovery surface is the 401 body itself. - **Mutation gate body-parser ordering**: `mutationGate({strict: true})` 401 responses fire **after** `express.json()` parses the body. Worst case on a saturated loopback listener: `--max-connections × express.json({limit: '10mb'})` ≈ 2.5 GB transient. Loopback-only attack surface, intentionally accepted. -- **Same-origin Origin stripping** in `server.ts` happens _before_ `denyBrowserOriginCors`. If a future change moves the strip elsewhere, the demo page breaks. +- **Same-origin Origin stripping** in `server.ts` happens _before_ `denyBrowserOriginCors`. If a future change moves the strip elsewhere, the Web Shell breaks. - **Token comparison is over the SHA-256 digest**, not the raw token. Reduces timing leakage by collapsing variable-length token compares to a fixed-size digest compare. - The daemon does **not** carry mTLS, request signing, or pair-token proof-of-possession today. `--rate-limit` provides HTTP rate limiting by client-id / IP key; it is not client identity authentication. diff --git a/docs/developers/daemon/17-configuration.md b/docs/developers/daemon/17-configuration.md index d42ca257c3a..6cb7f512bc9 100644 --- a/docs/developers/daemon/17-configuration.md +++ b/docs/developers/daemon/17-configuration.md @@ -6,45 +6,46 @@ This page collects every setting that affects the `qwen serve` daemon and its ad ## CLI flags (`qwen serve`) -| Flag | Type | Default | Effect | -| --------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--hostname ` | string | `127.0.0.1` | Bind address. Loopback values: `127.0.0.1`, `localhost`, `::1`, `[::1]`. Non-loopback requires a bearer token at boot. `host:port` input is rejected with guidance to use `--port`. | -| `--port ` | number | `4170` | Listen port; `0` means ephemeral. | -| `--token ` | string | env | Bearer token. Overrides `QWEN_SERVER_TOKEN` and is trimmed at boot. It appears in the process command line, so prefer env in deployments. | -| `--require-auth` | boolean | `false` | Extends bearer auth to loopback and `/health`; boot refuses to start without a token. | -| `--workspace ` | absolute path / repeatable | `process.cwd()` | Startup workspace runtime; repeat to register additional isolated runtimes. The first is primary. Every value must be absolute and a directory; canonicalized at boot. | -| `--memory-project-scope ` | `git-root` / `workspace` | `git-root` | Project-memory partitioning. `git-root` shares memory among workspaces at the same Git root; `workspace` isolates by exact workspace directory. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE`. | -| `--max-sessions ` | number | `32` | Per-workspace active session cap. `0` / `Infinity` means unlimited; `NaN` / negative values throw. | -| `--max-total-sessions ` | number | derived for multiple startup/restored workspaces | Daemon-wide active session cap. When omitted, a finite default is derived once from the per-workspace cap and startup/restored workspace count. `0` / `Infinity` means unlimited. | -| `--max-pending-prompts-per-session ` | number | `5` | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited; negative or non-integer values throw. | -| `--max-connections ` | number | `256` | HTTP listener `server.maxConnections`; `0` / `Infinity` means unlimited. | -| `--enable-session-shell` | boolean | `false` | Enables direct `POST /session/:id/shell` execution. Requires bearer token, and every call must carry a session-bound `X-Qwen-Client-Id`. | -| `--event-ring-size ` | number | `8000` | Per-session SSE replay ring; soft cap is `1_000_000`. | -| `--compacted-replay-max-bytes ` | positive integer | `4194304` | Byte cap for the bounded in-memory replay snapshot returned by `POST /session/:id/load`; hard cap is `268435456`. | -| `--memory-budget-mb ` | integer in `[1024, 1048576]` | 50% of cgroup-constrained or host memory, capped at the flag maximum (1048576 MB) | Total memory budget for the daemon process tree, capped at resolved available memory. Observed and reported under `limits.memory` in daemon status; it does not size any child process. Boot rejects out-of-range values. | -| `--memory-pressure-mode ` | `off` \| `observe` | `observe` | Whether the daemon derives a memory-pressure level from its own RSS and V8 heap. Both modes report `runtime.memory.pressure`; only `observe` raises `daemon_memory_pressure`. Root process only; no remediation. | -| `--child-heap-mode ` | `off` \| `observe` | `observe` | Whether the daemon models a per-child heap partition of the budget. `observe` reports it and counts spawns past it; nothing is applied. `off` publishes no partition at all — `maxConcurrentChildren` and `perChildCeilingMb` are both `null`. | -| `--http-bridge` | boolean | `true` | Stage 1 bridge mode. `--no-http-bridge` still falls back to http-bridge and prints to stderr. | -| `--mcp-client-budget ` | positive integer | unset | Sets `WorkspaceMcpBudget.clientBudget` and forwards it to the ACP child through `childEnvOverrides`. | -| `--mcp-budget-mode ` | `off` / `warn` / `enforce` | `warn` when budget is set, otherwise `off` | Sets `WorkspaceMcpBudget.mode`; `enforce` requires `--mcp-client-budget`. | -| `--external-tool-guard-mode ` | `off` / `required` | `off` | Enables the managed ACP external pre-execution Guard. `required` fails startup unless its loopback provider completes the v1 handshake. | -| `--external-tool-guard-endpoint ` | loopback HTTP(S) origin | unset | Provider origin used only in `required` mode. It must be origin-only and use `127.0.0.1`, `localhost`, or `::1`; paths, credentials, redirects, and proxy routing are rejected. | -| `--external-tool-guard-timeout-ms ` | integer `100..30000` | `3000` | Per-handshake and per-prepare deadline. A timeout fails startup during the handshake or fails the invocation closed during a turn. | -| `--allow-origin ` | repeatable string | unset | Cross-origin allowlist that replaces the default CORS denial. `*` allows any origin but requires a token. | -| `--allow-private-auth-base-url` | boolean | `false` | Allows `/workspace/auth/provider` to install localhost / private-network auth provider `baseUrl`; use only in trusted local development. | -| `--prompt-deadline-ms ` | positive integer | unset | Server-side prompt wallclock limit in ms. Timeout aborts and returns an error. | -| `--writer-idle-timeout-ms ` | positive integer | unset | Per-SSE-connection idle timeout in ms. The daemon closes the SSE connection when no event is sent for this duration. | -| `--channel-idle-timeout-ms ` | non-negative integer | `0` | How long to keep the ACP child alive after the last session closes. `0` means reclaim immediately. | -| `--initialize-timeout-ms ` | positive integer | `10000` | ACP child request timeout, including the initialize handshake (ms). | -| `--session-restore-timeout-ms ` | positive integer | `60000` | ACP session load/resume timeout (ms). When this flag is omitted, an explicitly supplied initialize timeout raises the budget but never lowers it below the default. | -| `--session-reap-interval-ms ` | non-negative integer | `60000` | Session reaper scan interval; `0` disables it. | -| `--session-idle-timeout-ms ` | non-negative integer | `1800000` | Disconnected-session idle reaping time; `0` disables it. | -| `--rate-limit` / `--no-rate-limit` | boolean | env / off | Enables per-tier HTTP rate limiting for prompt, mutation, and read routes. | -| `--rate-limit-prompt ` | positive integer | `10` | Prompt request limit per window; requires rate limiting to be enabled. | -| `--rate-limit-mutation ` | positive integer | `30` | Mutation request limit per window; requires rate limiting to be enabled. | -| `--rate-limit-read ` | positive integer | `120` | Read request limit per window; requires rate limiting to be enabled. | -| `--rate-limit-window-ms ` | integer `>= 1000` | `60000` | Rate limit window length; requires rate limiting to be enabled. | -| no flag | - | - | `QWEN_SERVE_NO_MCP_POOL=1` fully disables the pool. | +| Flag | Type | Default | Effect | +| --------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--hostname ` | string | `127.0.0.1` | Bind address. Loopback values: `127.0.0.1`, `localhost`, `::1`, `[::1]`. Non-loopback requires a bearer token at boot. `host:port` input is rejected with guidance to use `--port`. | +| `--port ` | number | `4170` | Listen port; `0` means ephemeral. | +| `--token ` | string | env | Bearer token. Overrides `QWEN_SERVER_TOKEN` and is trimmed at boot. It appears in the process command line, so prefer env in deployments. | +| `--require-auth` | boolean | `false` | Extends bearer auth to loopback and `/health`; boot refuses to start without a token. | +| `--workspace ` | absolute path / repeatable | `process.cwd()` | Startup workspace runtime; repeat to register additional isolated runtimes. The first is primary. Every value must be absolute and a directory; canonicalized at boot. | +| `--memory-project-scope ` | `git-root` / `workspace` | `git-root` | Project-memory partitioning. `git-root` shares memory among workspaces at the same Git root; `workspace` isolates by exact workspace directory. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE`. | +| `--max-sessions ` | number | `32` | Per-workspace active session cap. `0` / `Infinity` means unlimited; `NaN` / negative values throw. | +| `--max-total-sessions ` | number | derived for multiple startup/restored workspaces | Daemon-wide active session cap. When omitted, a finite default is derived once from the per-workspace cap and startup/restored workspace count. `0` / `Infinity` means unlimited. | +| `--max-pending-prompts-per-session ` | number | `5` | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited; negative or non-integer values throw. | +| `--max-connections ` | number | `256` | HTTP listener `server.maxConnections`; `0` / `Infinity` means unlimited. | +| `--enable-session-shell` | boolean | `false` | Enables direct `POST /session/:id/shell` execution. Requires bearer token, and every call must carry a session-bound `X-Qwen-Client-Id`. | +| `--event-ring-size ` | number | `8000` | Per-session SSE replay ring; soft cap is `1_000_000`. | +| `--compacted-replay-max-bytes ` | positive integer | `4194304` | Byte cap for the bounded in-memory replay snapshot returned by `POST /session/:id/load`; hard cap is `268435456`. | +| `--memory-budget-mb ` | integer in `[1024, 1048576]` | 50% of cgroup-constrained or host memory, capped at the flag maximum (1048576 MB) | Total memory budget for the daemon process tree, capped at resolved available memory. Observed and reported under `limits.memory` in daemon status; it does not size any child process. Boot rejects out-of-range values. | +| `--memory-pressure-mode ` | `off` \| `observe` | `observe` | Whether the daemon derives a memory-pressure level from its own RSS and V8 heap. Both modes report `runtime.memory.pressure`; only `observe` raises `daemon_memory_pressure`. Root process only; no remediation. | +| `--child-heap-mode ` | `off` \| `observe` | `observe` | Whether the daemon models a per-child heap partition of the budget. `observe` reports it and counts spawns past it; nothing is applied. `off` publishes no partition at all — `maxConcurrentChildren` and `perChildCeilingMb` are both `null`. | +| `--http-bridge` | boolean | `true` | Stage 1 bridge mode. `--no-http-bridge` still falls back to http-bridge and prints to stderr. | +| `--mcp-client-budget ` | positive integer | unset | Sets `WorkspaceMcpBudget.clientBudget` and forwards it to the ACP child through `childEnvOverrides`. | +| `--mcp-budget-mode ` | `off` / `warn` / `enforce` | `warn` when budget is set, otherwise `off` | Sets `WorkspaceMcpBudget.mode`; `enforce` requires `--mcp-client-budget`. | +| `--external-tool-guard-mode ` | `off` / `required` | `off` | Enables the managed ACP external pre-execution Guard. `required` fails startup unless its loopback provider completes the v1 handshake. | +| `--external-tool-guard-endpoint ` | loopback HTTP(S) origin | unset | Provider origin used only in `required` mode. It must be origin-only and use `127.0.0.1`, `localhost`, or `::1`; paths, credentials, redirects, and proxy routing are rejected. | +| `--external-tool-guard-timeout-ms ` | integer `100..30000` | `3000` | Per-handshake and per-prepare deadline. A timeout fails startup during the handshake or fails the invocation closed during a turn. | +| `--allow-origin ` | repeatable string | unset | Cross-origin allowlist that replaces the default CORS denial. `*` allows any origin but requires a token. | +| `--allow-private-auth-base-url` | boolean | `false` | Allows `/workspace/auth/provider` to install localhost / private-network auth provider `baseUrl`; use only in trusted local development. | +| `--web` / `--no-web` | boolean | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `/session/:id` document navigations). These entry points are mounted before `bearerAuth`; every API route stays token-gated. `--no-web` leaves the daemon API-only. | +| `--prompt-deadline-ms ` | positive integer | unset | Server-side prompt wallclock limit in ms. Timeout aborts and returns an error. | +| `--writer-idle-timeout-ms ` | positive integer | unset | Per-SSE-connection idle timeout in ms. The daemon closes the SSE connection when no event is sent for this duration. | +| `--channel-idle-timeout-ms ` | non-negative integer | `0` | How long to keep the ACP child alive after the last session closes. `0` means reclaim immediately. | +| `--initialize-timeout-ms ` | positive integer | `10000` | ACP child request timeout, including the initialize handshake (ms). | +| `--session-restore-timeout-ms ` | positive integer | `60000` | ACP session load/resume timeout (ms). When this flag is omitted, an explicitly supplied initialize timeout raises the budget but never lowers it below the default. | +| `--session-reap-interval-ms ` | non-negative integer | `60000` | Session reaper scan interval; `0` disables it. | +| `--session-idle-timeout-ms ` | non-negative integer | `1800000` | Disconnected-session idle reaping time; `0` disables it. | +| `--rate-limit` / `--no-rate-limit` | boolean | env / off | Enables per-tier HTTP rate limiting for prompt, mutation, and read routes. | +| `--rate-limit-prompt ` | positive integer | `10` | Prompt request limit per window; requires rate limiting to be enabled. | +| `--rate-limit-mutation ` | positive integer | `30` | Mutation request limit per window; requires rate limiting to be enabled. | +| `--rate-limit-read ` | positive integer | `120` | Read request limit per window; requires rate limiting to be enabled. | +| `--rate-limit-window-ms ` | integer `>= 1000` | `60000` | Rate limit window length; requires rate limiting to be enabled. | +| no flag | - | - | `QWEN_SERVE_NO_MCP_POOL=1` fully disables the pool. | ## Environment variables @@ -106,24 +107,25 @@ The daemon constructs each workspace runtime from that workspace's merged settin `packages/cli/src/serve/types.ts` defines the typed options object accepted by both `runQwenServe` and `createServeApp`. It mirrors the CLI flags above and adds: -| Field | Effect | -| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `eventRingSize` | Overrides the default per-session ring size. | -| `memoryProjectScope` | `'git-root' \| 'workspace'` project-memory partitioning; falls back to `QWEN_CODE_MEMORY_PROJECT_SCOPE`. | -| `maxPendingPromptsPerSession` | Pending prompt cap per session; `0` / `Infinity` means unlimited. | -| `mcpPoolActive` | Programmatic switch, defaulting from `QWEN_SERVE_NO_MCP_POOL`. | -| `externalToolGuard` | Optional `{mode:'required', endpoint, token, timeoutMs?}`. Omission is fully off; required mode performs the provider handshake before listening. | -| `allowOrigins` | Cross-origin allowlist (`string[]`), corresponding to `--allow-origin`. | -| `allowPrivateAuthBaseUrl` | Allows private / localhost auth provider `baseUrl` installation. | -| `enableSessionShell` | Enables session shell execution; bearer token and session-bound client id are still required. | -| `promptDeadlineMs` | Prompt wallclock limit. | -| `writerIdleTimeoutMs` | SSE writer idle timeout. | -| `channelIdleTimeoutMs` | How long to keep the ACP child warm after the last session closes. | -| `initializeTimeoutMs` | ACP child request timeout, including the initialize handshake. | -| `sessionRestoreTimeoutMs` | ACP session load/resume timeout. Precedence: explicit restore value; otherwise an explicit initialize value raises the 60000 default but never lowers it; otherwise 60000. | -| `sessionReapIntervalMs` | Session reaper scan interval. | -| `sessionIdleTimeoutMs` | Disconnected-session idle reaping time. | -| `rateLimit*` | Per-tier HTTP rate limit switch, thresholds, and window. | +| Field | Effect | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `eventRingSize` | Overrides the default per-session ring size. | +| `memoryProjectScope` | `'git-root' \| 'workspace'` project-memory partitioning; falls back to `QWEN_CODE_MEMORY_PROJECT_SCOPE`. | +| `maxPendingPromptsPerSession` | Pending prompt cap per session; `0` / `Infinity` means unlimited. | +| `mcpPoolActive` | Programmatic switch, defaulting from `QWEN_SERVE_NO_MCP_POOL`. | +| `externalToolGuard` | Optional `{mode:'required', endpoint, token, timeoutMs?}`. Omission is fully off; required mode performs the provider handshake before listening. | +| `allowOrigins` | Cross-origin allowlist (`string[]`), corresponding to `--allow-origin`. | +| `allowPrivateAuthBaseUrl` | Allows private / localhost auth provider `baseUrl` installation. | +| `serveWebShell` | Serve the built Web Shell SPA at the daemon root (default `true`); `false` (the CLI's `--no-web`) leaves the daemon API-only. No effect when the build omits the shell assets. | +| `enableSessionShell` | Enables session shell execution; bearer token and session-bound client id are still required. | +| `promptDeadlineMs` | Prompt wallclock limit. | +| `writerIdleTimeoutMs` | SSE writer idle timeout. | +| `channelIdleTimeoutMs` | How long to keep the ACP child warm after the last session closes. | +| `initializeTimeoutMs` | ACP child request timeout, including the initialize handshake. | +| `sessionRestoreTimeoutMs` | ACP session load/resume timeout. Precedence: explicit restore value; otherwise an explicit initialize value raises the 60000 default but never lowers it; otherwise 60000. | +| `sessionReapIntervalMs` | Session reaper scan interval. | +| `sessionIdleTimeoutMs` | Disconnected-session idle reaping time. | +| `rateLimit*` | Per-tier HTTP rate limit switch, thresholds, and window. | ## `BridgeOptions` (programmatic bridge embedding) diff --git a/docs/developers/daemon/19-observability.md b/docs/developers/daemon/19-observability.md index 99a4f892b02..e384cce9cbd 100644 --- a/docs/developers/daemon/19-observability.md +++ b/docs/developers/daemon/19-observability.md @@ -6,23 +6,23 @@ ## What exists today -| Surface | Location | Purpose | -| ------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `QWEN_SERVE_DEBUG` stderr logs | `bridge.ts` and call sites | Env values `1` / `true` / `on` / `yes` (case-insensitive) print `qwen serve debug: ...` lines to stderr. | -| OpenTelemetry span instrumentation | `server.ts` `daemonTelemetryMiddleware` | Classified daemon API requests that reach the telemetry middleware are wrapped in `withDaemonRequestSpan`; attributes include canonical route, workspace hash when resolved, sessionId, clientId, and status code. Permission routes have dedicated spans. Prompt lifecycle is traced end-to-end. Configuration lives in `settings.json` `telemetry`. | -| OpenTelemetry daemon perf metrics | `telemetry/*event-loop-lag*`, `daemon-metrics` | Event loop lag gauges for daemon and ACP child processes, plus daemon-child pipe message byte histograms. | -| `DaemonLogger` structured file logs | `serve/daemon-logger.ts` | Appends to a stable, size-rotated `daemon.log`. File records include `runId` and PID. Boot prints the selected stable/fallback path; full status exposes health, issues, and file-copy loss counters. | -| Per-request access-log middleware | `server/access-log.ts` | Logs method/path, status, duration, session, and first raw client ID after each request. A 60-token burst / 2-per-second bucket aggregates excess traffic into five fixed status counters. Health, heartbeat, and successful SSE exclusions remain. | -| `/health` | `server.ts` route | Liveness probe; `?deep=1` returns extended details. | -| `/capabilities` | `server.ts` route | Preflight feature discovery. See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). | -| `/workspace/preflight` | Route -> `DaemonStatusProvider` | Structured readiness cells: Node version, CLI entry, ripgrep, git, npm, plus ACP-level cells once a child is alive. | -| `/workspace/env` | Route -> `DaemonStatusProvider` | Daemon process env snapshot. Secret env vars report only presence; proxy URL credentials are stripped. | -| `/workspace/mcp` | Route -> bridge extMethod | Pool, budget, and refusal snapshot. | -| `/workspace/skills`, `/workspace/providers` | Routes | ACP-side live snapshots; return empty idle data when no session exists. | -| Per-session SSE | `GET /session/:id/events` | Real-time event stream. | -| `/demo` debug console | `GET /demo` (`packages/cli/src/serve/demo.ts`) | Browser-accessible single-page console: chat, event log, workspace inspector, and permission UX. On loopback, `http://127.0.0.1:4170/demo` is the quickest end-to-end validation path without writing SDK code. Registration rules are in [`02-serve-runtime.md`](./02-serve-runtime.md). | -| `PermissionAuditRing` | `permission-audit.ts` | In-memory FIFO of 512 permission decisions. | -| Mediator `decisionReason` audit | `permissionMediator.ts` | Internal structured record explaining why a permission request resolved the way it did. | +| Surface | Location | Purpose | +| ------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QWEN_SERVE_DEBUG` stderr logs | `bridge.ts` and call sites | Env values `1` / `true` / `on` / `yes` (case-insensitive) print `qwen serve debug: ...` lines to stderr. | +| OpenTelemetry span instrumentation | `server.ts` `daemonTelemetryMiddleware` | Classified daemon API requests that reach the telemetry middleware are wrapped in `withDaemonRequestSpan`; attributes include canonical route, workspace hash when resolved, sessionId, clientId, and status code. Permission routes have dedicated spans. Prompt lifecycle is traced end-to-end. Configuration lives in `settings.json` `telemetry`. | +| OpenTelemetry daemon perf metrics | `telemetry/*event-loop-lag*`, `daemon-metrics` | Event loop lag gauges for daemon and ACP child processes, plus daemon-child pipe message byte histograms. | +| `DaemonLogger` structured file logs | `serve/daemon-logger.ts` | Appends to a stable, size-rotated `daemon.log`. File records include `runId` and PID. Boot prints the selected stable/fallback path; full status exposes health, issues, and file-copy loss counters. | +| Per-request access-log middleware | `server/access-log.ts` | Logs method/path, status, duration, session, and first raw client ID after each request. A 60-token burst / 2-per-second bucket aggregates excess traffic into five fixed status counters. Health, heartbeat, and successful SSE exclusions remain. | +| `/health` | `server.ts` route | Liveness probe; `?deep=1` returns extended details. | +| `/capabilities` | `server.ts` route | Preflight feature discovery. See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). | +| `/workspace/preflight` | Route -> `DaemonStatusProvider` | Structured readiness cells: Node version, CLI entry, ripgrep, git, npm, plus ACP-level cells once a child is alive. | +| `/workspace/env` | Route -> `DaemonStatusProvider` | Daemon process env snapshot. Secret env vars report only presence; proxy URL credentials are stripped. | +| `/workspace/mcp` | Route -> bridge extMethod | Pool, budget, and refusal snapshot. | +| `/workspace/skills`, `/workspace/providers` | Routes | ACP-side live snapshots; return empty idle data when no session exists. | +| Per-session SSE | `GET /session/:id/events` | Real-time event stream. | +| Web Shell UI | `GET /` (`packages/cli/src/serve/web-shell-static.ts`) | Browser UI served from the bundled Web Shell assets: chat, session list, workspace inspector, and permission UX. On loopback, `http://127.0.0.1:4170/` is the quickest end-to-end validation path without writing SDK code. Registration rules are in [`02-serve-runtime.md`](./02-serve-runtime.md). | +| `PermissionAuditRing` | `permission-audit.ts` | In-memory FIFO of 512 permission decisions. | +| Mediator `decisionReason` audit | `permissionMediator.ts` | Internal structured record explaining why a permission request resolved the way it did. | ## What does not exist today diff --git a/docs/developers/daemon/20-quickstart-operations.md b/docs/developers/daemon/20-quickstart-operations.md index 2db30e65b59..a8c935e8447 100644 --- a/docs/developers/daemon/20-quickstart-operations.md +++ b/docs/developers/daemon/20-quickstart-operations.md @@ -16,7 +16,7 @@ qwen serve: bound to workspace "/your/cwd" qwen serve: bearer auth disabled (loopback default). Set QWEN_SERVER_TOKEN to enable. ``` -Open `http://127.0.0.1:4170/demo` in a browser to see the debug console: chat UI, event stream, and workspace inspection. In the default loopback dev mode, `createServeApp()` mounts the `/demo` route from `packages/cli/src/serve/routes/health-demo.ts` **before** `bearerAuth`, so no token is required. +Open `http://127.0.0.1:4170/` in a browser to get the Web Shell UI: chat, session list, and workspace inspection. `createServeApp()` mounts the bundled Web Shell assets (`packages/cli/src/serve/web-shell-static.ts`) **before** `bearerAuth`, so the shell itself loads without a token; its own API calls carry the bearer when one is configured — start the daemon with `--open` (which puts the token in the URL fragment, never sent to the server) or append `#token=…` manually when auth is enabled. `--no-web` opts out and leaves the daemon API-only. ## 2. Launch recipes @@ -67,7 +67,7 @@ qwen serve --channel-idle-timeout-ms 60000 QWEN_SERVE_RATE_LIMIT=1 qwen serve ``` -With the hardened loopback recipe (3), `/demo` is registered after `bearerAuth`. A normal browser navigation needs an auth header, so use curl or an SDK script instead. +With the hardened loopback recipe (3), `/health` is registered after `bearerAuth`, so probes must carry the token like every other API route (the Web Shell static surface stays pre-auth by design; pass `--no-web` for an API-only daemon). ## 3. Full startup flags @@ -198,23 +198,21 @@ curl -N \ -H 'Last-Event-ID: 0' \ 'http://127.0.0.1:4170/session//events' -# 8. Demo page -open http://127.0.0.1:4170/demo +# 8. Web Shell UI +open http://127.0.0.1:4170/ ``` When bearer auth is enabled, add `-H "Authorization: Bearer $QWEN_SERVER_TOKEN"` to every request. -## 8. Can the demo page be used? +## 8. Is there a browser UI? -**Yes.** It is implemented by `getDemoHtml(port)` in `packages/cli/src/serve/demo.ts` as self-contained HTML with no external dependency. +**Yes — the Web Shell.** `resolveWebShellDir()` finds the built assets (bundled next to the CLI bundle in a release, `packages/web-shell/dist` in a checkout) and `mountWebShellAssets()` serves them at `/`, `/assets`, and `/session/:id` document navigations (browser deep links — a plain `curl /session/` gets the API's 401/404, not the shell). When the assets are missing the daemon degrades to API-only instead of crashing; `--no-web` opts out explicitly. -| Launch mode | Where `/demo` is registered | Direct browser navigation | -| --------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------ | -| Loopback without `--require-auth` | `routes/health-demo.ts`, mounted by `createServeApp()` **before** `bearerAuth` | Works without token | -| Loopback with `--require-auth` | `routes/health-demo.ts`, mounted by `createServeApp()` **after** `bearerAuth` | Difficult to use from a plain browser; use curl or SDK | -| Non-loopback bind | `routes/health-demo.ts`, mounted by `createServeApp()` **after** `bearerAuth` | Same as above | +The static shell is mounted **before** `bearerAuth` in every launch mode — a browser cannot attach an `Authorization` header to an address-bar navigation or a ` - -`; -} diff --git a/packages/cli/src/serve/rate-limit.test.ts b/packages/cli/src/serve/rate-limit.test.ts index 72e778e46cf..a51c9b00398 100644 --- a/packages/cli/src/serve/rate-limit.test.ts +++ b/packages/cli/src/serve/rate-limit.test.ts @@ -296,16 +296,32 @@ describe('rateLimit', () => { expect(next).toHaveBeenCalledTimes(5); }); - it('exempts GET /demo', () => { - const next = vi.fn(); - for (let i = 0; i < 5; i++) { - limiter.middleware( - mockReq({ method: 'GET', path: '/demo' }), - mockRes(), - next, - ); + it('exempts no GET path other than /health', () => { + // Pins the shape of the GET/HEAD exemption after `/demo` was removed + // from it. Any future widening of the predicate — a second pre-auth + // page, a prefix match — flips one of these to `next()` and fails here + // instead of silently letting an unauthenticated route escape the + // limiter. + for (const path of ['/demo', '/health/deep', '/healthz', '/']) { + // A fresh limiter per path: the read bucket is keyed by caller, not by + // path, so one shared limiter would exhaust after the first entry. + const scoped = createRateLimiter({ + tiers: { + prompt: { windowMs: 60_000, max: 1 }, + mutation: { windowMs: 60_000, max: 1 }, + read: { windowMs: 60_000, max: 1 }, + }, + hostname: '127.0.0.1', + }); + const next = vi.fn(); + const res = mockRes(); + scoped.middleware(mockReq({ method: 'GET', path }), mockRes(), next); + scoped.middleware(mockReq({ method: 'GET', path }), res, vi.fn()); + expect(next).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBe(429); + expect(res.body).toMatchObject({ tier: 'read' }); + scoped.dispose(); } - expect(next).toHaveBeenCalledTimes(5); }); it('exempts POST .../heartbeat', () => { diff --git a/packages/cli/src/serve/rate-limit.ts b/packages/cli/src/serve/rate-limit.ts index 42c080fc0d1..000b6ed7e3b 100644 --- a/packages/cli/src/serve/rate-limit.ts +++ b/packages/cli/src/serve/rate-limit.ts @@ -70,13 +70,9 @@ function resolveTier( // Strip trailing slash for consistent matching const p = path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path; - // Exempt: OPTIONS, health, demo, heartbeat, SSE events, ACP transport + // Exempt: OPTIONS, health, heartbeat, SSE events, ACP transport if (method === 'OPTIONS') return null; - if ( - (method === 'GET' || method === 'HEAD') && - (p === '/health' || p === '/demo') - ) - return null; + if ((method === 'GET' || method === 'HEAD') && p === '/health') return null; if ( method === 'POST' && p.startsWith('/session/') && diff --git a/packages/cli/src/serve/routes/health-demo.test.ts b/packages/cli/src/serve/routes/health.test.ts similarity index 91% rename from packages/cli/src/serve/routes/health-demo.test.ts rename to packages/cli/src/serve/routes/health.test.ts index 61a166548ac..5b2e8127267 100644 --- a/packages/cli/src/serve/routes/health-demo.test.ts +++ b/packages/cli/src/serve/routes/health.test.ts @@ -12,7 +12,7 @@ import { type WorkspaceRegistry, type WorkspaceRuntime, } from '../workspace-registry.js'; -import { createHealthDemoRoutes } from './health-demo.js'; +import { createHealthRoutes } from './health.js'; function makeRuntime(): WorkspaceRuntime { return { @@ -30,9 +30,8 @@ function makeRuntime(): WorkspaceRuntime { function makeApp(workspaceRegistry: WorkspaceRegistry) { const app = express(); - const { register } = createHealthDemoRoutes({ + const { register } = createHealthRoutes({ opts: { hostname: '127.0.0.1', requireAuth: false }, - getPort: () => 4321, workspaceRegistry, getActiveSseCount: () => 0, getRateLimiter: () => undefined, @@ -41,7 +40,7 @@ function makeApp(workspaceRegistry: WorkspaceRegistry) { return app; } -describe('createHealthDemoRoutes /health', () => { +describe('createHealthRoutes /health', () => { it('returns 503 degraded when a workspace entry is blocked', async () => { const registry = createSingleWorkspaceRegistry(makeRuntime()); registry.beginReplacement(registry.primaryEntry, 'policy-2'); diff --git a/packages/cli/src/serve/routes/health-demo.ts b/packages/cli/src/serve/routes/health.ts similarity index 79% rename from packages/cli/src/serve/routes/health-demo.ts rename to packages/cli/src/serve/routes/health.ts index 1ad2d802979..1db099b04ef 100644 --- a/packages/cli/src/serve/routes/health-demo.ts +++ b/packages/cli/src/serve/routes/health.ts @@ -7,65 +7,26 @@ import { gradeActiveWorkCoverage } from '@qwen-code/acp-bridge/bridgeTypes'; import type { Application, Request, Response } from 'express'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; -import { getDemoHtml } from '../demo.js'; import { isDeepHealthQuery } from '../health-query.js'; import { isLoopbackBind } from '../loopback-binds.js'; import type { RateLimiterInstance } from '../rate-limit.js'; import type { ServeOptions } from '../types.js'; import type { WorkspaceRegistry } from '../workspace-registry.js'; -interface CreateHealthDemoRoutesDeps { +interface CreateHealthRoutesDeps { opts: Pick; - getPort: () => number; workspaceRegistry: WorkspaceRegistry; getActiveSseCount: () => number; getRateLimiter: () => RateLimiterInstance | undefined; } -interface HealthDemoRoutes { +interface HealthRoutes { exposeHealthPreAuth: boolean; register(app: Application): void; } -export function createHealthDemoRoutes( - deps: CreateHealthDemoRoutesDeps, -): HealthDemoRoutes { - const { - opts, - getPort, - workspaceRegistry, - getActiveSseCount, - getRateLimiter, - } = deps; - - // --- Demo page: mirrors the `/health` loopback-gating pattern. - // On loopback binds, registered BEFORE bearerAuth so browsers can - // reach the page via address-bar navigation (which cannot attach - // Authorization headers). On non-loopback binds, registered AFTER - // bearerAuth — an unauthenticated `/demo` on a public interface - // would leak the full API surface (route enumeration + interactive - // console), far more than `/health`'s `{"status":"ok"}`. - // X-Frame-Options: DENY + CSP frame-ancestors 'none' prevent - // clickjacking — a malicious site embedding the demo in an iframe - // could trick a user into performing daemon actions via transparent - // overlay (the iframe's same-origin fetches bypass CORS). - const demoHandler = (_req: Request, res: Response) => { - try { - res - .type('html') - .set('X-Frame-Options', 'DENY') - .set( - 'Content-Security-Policy', - "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'", - ) - .send(getDemoHtml(getPort())); - } catch (err) { - writeStderrLine( - `qwen serve: /demo render failed: ${err instanceof Error ? err.message : String(err)}`, - ); - res.status(500).json({ error: 'Failed to render demo page' }); - } - }; +export function createHealthRoutes(deps: CreateHealthRoutesDeps): HealthRoutes { + const { opts, workspaceRegistry, getActiveSseCount, getRateLimiter } = deps; // `/health` is exempted from `bearerAuth` ONLY on loopback binds — // the canonical liveness-probe case (k8s/Compose probes don't @@ -202,7 +163,6 @@ export function createHealthDemoRoutes( exposeHealthPreAuth, register(app: Application): void { app.get('/health', healthHandler); - app.get('/demo', demoHandler); }, }; } diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 4e6fbfc0d22..cc91496f066 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -10777,6 +10777,55 @@ describe('runQwenServe channel worker supervisor', () => { }); describe('runQwenServe startup observability', () => { + it("names every pre-auth surface in the --allow-origin '*' warning", async () => { + // This warning is the operator's only notice of what a wildcard origin + // exposes without a token, so it must enumerate the actual pre-auth + // surface: the Web Shell static assets (mounted before bearerAuth in + // every mode) and, on loopback without --require-auth, /health. If the + // pre-auth set drifts again, this assertion is what catches the stale + // message. + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-allow-origin-')), + ); + const stderrWrites: string[] = []; + const spy = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk) => { + stderrWrites.push(String(chunk)); + return true; + }); + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + token: 'secret', + allowOrigins: ['*'], + }, + { resolveOnListen: true }, + ); + try { + await handle.runtimeReady; + const warning = stderrWrites + .join('') + .split('\n') + .find((line) => line.includes('--allow-origin:')); + expect(warning).toBeDefined(); + expect(warning).toContain('Web Shell static assets'); + expect(warning).toContain('--no-web'); + expect(warning).toContain('/health'); + expect(warning).toContain('--require-auth'); + // The retired debug page must not resurface in the enumeration. + expect(warning).not.toContain('/demo'); + } finally { + spy.mockRestore(); + await handle.close(); + } + }); + let tmpDir: string; afterEach(() => { diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 8dd0b731907..fcaed7099cc 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -2441,8 +2441,9 @@ async function runQwenServeImpl( `qwen serve: --allow-origin: ${opts.allowOrigins.join(', ')}` + (parsed.allowAny ? ' (WARNING: `*` admits any cross-origin browser — bearer ' + - 'token gates API routes; /health and /demo remain pre-auth ' + - 'on loopback unless --require-auth is set)' + 'token gates API routes; the Web Shell static assets stay ' + + 'pre-auth in every mode unless --no-web, and /health stays ' + + 'pre-auth on loopback unless --require-auth is set)' : ''), ); } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 758593730cf..0ab220e1028 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -3142,6 +3142,20 @@ describe('createServeApp', () => { expect(res.headers['cache-control']).toContain('no-cache'); }); + it('rejects cross-origin requests for the pre-auth shell page (CORS wall runs first)', async () => { + // Re-pins the contract the deleted `/demo` CORS test carried: the + // pre-auth page surface sits behind the Origin wall, so a future + // mount-order regression that exposes the shell to cross-origin + // browsers fails here instead of silently shipping. + const app = createServeApp(baseOpts, undefined, { webShellDir }); + const res = await request(app) + .get('/') + .set('Host', host) + .set('Origin', 'https://evil.example.com'); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'Request denied by CORS policy' }); + }); + it('serves the shell for a // root request pre-auth (non-strict routing)', async () => { // Express non-strict routing matches a raw `//` against `app.get('/')` // too; the deferred gate's isPreAuthWebShellRequest mirrors this shape. @@ -3347,6 +3361,67 @@ describe('createServeApp', () => { expect(res.text).toContain('
'); }); + it('no longer serves a demo page: /demo is an ordinary unknown path', async () => { + // `/demo` used to be its own pre-auth route. Now it is nothing: a + // non-navigation request must 404 (never a debug console), while a + // browser navigation is indistinguishable from any other SPA deep link + // and gets the shell. Reintroducing the old handler flips the first + // assertion. + const app = createServeApp(baseOpts, undefined, { webShellDir }); + + const api = await request(app) + .get('/demo') + .set('Host', host) + .set('Accept', 'application/json'); + expect(api.status).toBe(404); + expect(api.text).not.toContain('
'); + + const nav = await request(app) + .get('/demo') + .set('Host', host) + .set('Accept', 'text/html'); + expect(nav.status).toBe(200); + expect(nav.text).toContain('
'); + }); + + it('gates a /demo navigation behind the bearer once a token is configured', async () => { + // The SPA fallback is mounted AFTER bearerAuth, so unlike `/` and + // `/session/:id` an unauthenticated navigation to a leftover `/demo` + // bookmark is refused rather than answered with the shell — the old + // route's loopback pre-auth exposure is gone in every launch mode. + for (const opts of [ + { ...baseOpts, token: 'secret' }, + { ...baseOpts, token: 'secret', requireAuth: true }, + ]) { + const app = createServeApp(opts, undefined, { webShellDir }); + const res = await request(app) + .get('/demo') + .set('Host', host) + .set('Accept', 'text/html'); + expect(res.status).toBe(401); + expect(res.text).not.toContain('
'); + } + }); + + it('serves the shell pre-auth on a non-loopback bind (every launch mode)', async () => { + // Re-pins the non-loopback half of the deleted `/demo` suite: the + // shell is pre-auth in every launch mode. The old `/demo` registered + // after bearerAuth on non-loopback, so a future change re-gating + // mountWebShellAssets on the bind address would 401 browser + // navigations to a --hostname 0.0.0.0 deployment; this fails first. + const app = createServeApp( + { ...baseOpts, hostname: '0.0.0.0', token: 'secret' }, + undefined, + { webShellDir }, + ); + const res = await request(app) + .get('/') + .set('Host', `0.0.0.0:${baseOpts.port}`) + .set('Accept', 'text/html'); + expect(res.status).toBe(200); + expect(res.text).toContain('
'); + }); + it('does not shadow /health on a browser navigation (Critical #1)', async () => { // Non-loopback + requireAuth registers /health POST-auth. A browser // navigation (Accept text/html) must fall THROUGH the SPA fallback to @@ -25120,85 +25195,6 @@ describe('GET /session/:id/events (SSE)', () => { }); }); -describe('GET /demo', () => { - it('returns 200 with text/html content type on loopback', async () => { - const app = createServeApp(baseOpts, () => 4170, { - bridge: fakeBridge(), - }); - const res = await request(app) - .get('/demo') - .set('Host', `127.0.0.1:${baseOpts.port}`); - expect(res.status).toBe(200); - expect(res.headers['content-type']).toMatch(/text\/html/); - expect(res.text).toContain('Qwen Serve'); - expect(res.text).toContain(''); - }); - - it('is accessible without bearer token on loopback even when --token is set', async () => { - // Loopback: /demo is registered BEFORE bearerAuth so browsers can - // reach the page via address-bar navigation (no Authorization header). - const app = createServeApp({ ...baseOpts, token: 'secret' }, () => 4170, { - bridge: fakeBridge(), - }); - const res = await request(app) - .get('/demo') - .set('Host', `127.0.0.1:${baseOpts.port}`); - expect(res.status).toBe(200); - expect(res.headers['content-type']).toMatch(/text\/html/); - }); - - it('requires bearer token on non-loopback (401 without token)', async () => { - // Non-loopback: /demo is registered AFTER bearerAuth to prevent - // unauthenticated access on public interfaces. - const app = createServeApp( - { ...baseOpts, hostname: '0.0.0.0', token: 'secret' }, - () => 4170, - { bridge: fakeBridge() }, - ); - const res = await request(app).get('/demo').set('Host', '0.0.0.0:4170'); - expect(res.status).toBe(401); - }); - - it('is accessible on non-loopback with valid bearer token', async () => { - const app = createServeApp( - { ...baseOpts, hostname: '0.0.0.0', token: 'secret' }, - () => 4170, - { bridge: fakeBridge() }, - ); - const res = await request(app) - .get('/demo') - .set('Host', '0.0.0.0:4170') - .set('Authorization', 'Bearer secret'); - expect(res.status).toBe(200); - expect(res.headers['content-type']).toMatch(/text\/html/); - }); - - it('is guarded by CORS (rejects cross-origin requests)', async () => { - const app = createServeApp(baseOpts, () => 4170, { - bridge: fakeBridge(), - }); - const res = await request(app) - .get('/demo') - .set('Host', `127.0.0.1:${baseOpts.port}`) - .set('Origin', 'https://evil.example.com'); - expect(res.status).toBe(403); - }); - - it('sets anti-clickjacking headers (X-Frame-Options + CSP)', async () => { - const app = createServeApp(baseOpts, () => 4170, { - bridge: fakeBridge(), - }); - const res = await request(app) - .get('/demo') - .set('Host', `127.0.0.1:${baseOpts.port}`); - expect(res.status).toBe(200); - expect(res.headers['x-frame-options']).toBe('DENY'); - expect(res.headers['content-security-policy']).toContain( - "frame-ancestors 'none'", - ); - }); -}); - describe('same-origin Origin-stripping middleware', () => { it('strips loopback Origin header matching daemon port', async () => { const app = createServeApp(baseOpts, () => 4170, { @@ -25398,7 +25394,7 @@ describe('--allow-origin CORS allowlist (T2.4 #4514)', () => { ); }); - it('demo self-origin shim still works when `--allow-origin` is set (loopback strip runs first)', async () => { + it('loopback self-origin shim still works when `--allow-origin` is set (loopback strip runs first)', async () => { // Regression anchor: the loopback-self-origin shim that strips the // Origin header for matching addresses must continue working even // when the new allowlist middleware is installed. Without this, diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index f0a1dd63495..dc15041d1fc 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -89,7 +89,7 @@ import { } from './workspace-agents.js'; import { mountWorkspaceGenerationRoutes } from './workspace-generation.js'; import { registerDaemonStatusRoutes } from './routes/daemon-status.js'; -import { createHealthDemoRoutes } from './routes/health-demo.js'; +import { createHealthRoutes } from './routes/health.js'; import { registerWorkspaceAuthRoutes } from './routes/workspace-auth.js'; import { registerWorkspaceExtensionRoutes } from './routes/workspace-extensions.js'; import type { WorkspaceFileSystemFactory } from './fs/index.js'; @@ -1601,15 +1601,14 @@ export function createServeApp( workspaceQualifiedAcpEnabled, }); - const healthDemoRoutes = createHealthDemoRoutes({ + const healthRoutes = createHealthRoutes({ opts, - getPort, workspaceRegistry, getActiveSseCount, getRateLimiter: () => rateLimiter, }); - if (healthDemoRoutes.exposeHealthPreAuth) { - healthDemoRoutes.register(app); + if (healthRoutes.exposeHealthPreAuth) { + healthRoutes.register(app); } installAccessLogMiddleware(app, daemonLog); @@ -1698,13 +1697,12 @@ export function createServeApp( app.use(rateLimiter.middleware); } - if (!healthDemoRoutes.exposeHealthPreAuth) { + if (!healthRoutes.exposeHealthPreAuth) { // Non-loopback OR loopback with `--require-auth`: register - // `/health` and `/demo` AFTER `bearerAuth` so probes must carry - // the token. Otherwise unauthenticated callers can ping any - // reachable address:port to confirm a daemon exists (and `/demo` - // leaks the full API surface). - healthDemoRoutes.register(app); + // `/health` AFTER `bearerAuth` so probes must carry the token. + // Otherwise unauthenticated callers can ping any reachable + // address:port to confirm a daemon exists. + healthRoutes.register(app); } installJsonBodyParser(app); diff --git a/packages/cli/src/serve/server/self-origin.ts b/packages/cli/src/serve/server/self-origin.ts index 0d1ba74b3f7..7341c39817c 100644 --- a/packages/cli/src/serve/server/self-origin.ts +++ b/packages/cli/src/serve/server/self-origin.ts @@ -7,7 +7,7 @@ import type { Application, Request } from 'express'; /** - * Allow same-origin requests from the demo page. Browsers send an `Origin` + * Allow same-origin requests from the Web Shell. Browsers send an `Origin` * header on same-origin POST/fetch calls; the browser-origin wall would reject * them. Only loopback origins are matched. */ diff --git a/packages/cli/src/serve/web-shell-static.ts b/packages/cli/src/serve/web-shell-static.ts index f65ee9ddf73..7a88ec67d03 100644 --- a/packages/cli/src/serve/web-shell-static.ts +++ b/packages/cli/src/serve/web-shell-static.ts @@ -14,7 +14,7 @@ export { resolveWebShellDir } from './web-shell-resolver.js'; /** * Content-Security-Policy for the Web Shell HTML shell. * - * Deliberately looser than the `/demo` page's `default-src 'none'`: the real + * Deliberately looser than a `default-src 'none'` static page: the real * UI loads same-origin module scripts plus the inline performance.measure * patch baked into `index.html`, runs shiki/mermaid (eval + wasm + blob * workers), pulls katex fonts/images as `data:`, and streams SSE @@ -158,9 +158,9 @@ function createSendIndex( (err) => { if (!err) return; // Only 5xx path in the serve app that would otherwise emit nothing — - // log it like the sibling /demo handler so an operator can see why the - // shell stopped loading (EACCES/ESTALE on a network mount, a perms - // change, a partial deploy). + // log it so an operator can see why the shell stopped loading + // (EACCES/ESTALE on a network mount, a perms change, a partial + // deploy). writeStderrLine( `qwen serve: Web Shell index send failed: ${err instanceof Error ? err.message : String(err)}`, );