Skip to content
31 changes: 31 additions & 0 deletions docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,35 @@ curl -H "Authorization: Bearer $QWEN_SERVER_TOKEN" http://your-host:4170/capabil

The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401 responses are uniform across "missing header", "wrong scheme", and "wrong token" so a side-channel can't distinguish.

## HTTPS / TLS (for mobile / cross-device access)

By default the daemon serves plain HTTP. That's fine on `localhost`, but a phone or tablet hitting a LAN IP (`https://192.168.x.x:4170`) is **not** a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) over `http://` — so browsers block `getUserMedia` (voice input), WebRTC, and other secure-context-only APIs. Pass `--tls-cert` + `--tls-key` to serve the Web Shell over HTTPS and unlock them:

```bash
# 1. Install a local CA and trust it (one-time). The mobile device must
# also trust this CA — mkcert prints where the root cert lives.
mkcert -install

# 2. Generate a cert for your machine's LAN IP.
mkcert 192.168.1.100

# 3. Start the daemon over HTTPS. Non-loopback binds still require a token,
# and the browser Origin must be allowed through CORS.
qwen serve \
--hostname 0.0.0.0 \
--token "$(openssl rand -hex 32)" \
--tls-cert ./192.168.1.100.pem \
--tls-key ./192.168.1.100-key.pem \
--allow-origin "https://192.168.1.100:4170"
# → qwen serve listening on https://0.0.0.0:4170
```

Notes:

- **Both flags or neither** — boot fails if only one is given (a cert with no key can't start an HTTPS listener).
- **TLS is orthogonal to auth** — HTTPS encrypts the transport; the bearer token still gates every API route. Non-loopback binds require a token with or without TLS.
- **Scope is TLS termination only** — no auto-generation, no ACME / Let's Encrypt. This is a LAN / dev convenience; for internet-facing deployments terminate TLS at a reverse proxy (see the threat model below).

## CLI flags

| Flag | Default | Purpose |
Expand All @@ -250,6 +279,8 @@ The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401
| `--hostname <addr>` | `127.0.0.1` | Bind interface. Anything beyond loopback requires a token. |
| `--token <str>` | — | Bearer token. Falls back to `QWEN_SERVER_TOKEN` env var (with leading/trailing whitespace stripped — handy for `$(cat token.txt)`). |
| `--require-auth` | `false` | Refuse to start without a bearer token, even on loopback. Hardens the `127.0.0.1` developer default for shared dev hosts / CI runners / multi-tenant workstations where any local user can hit the listener. Boots only with `--token` or `QWEN_SERVER_TOKEN` set; gates `/health` behind the bearer too. |
| `--tls-cert <path>` | — | Path to a PEM certificate file. Serve over **HTTPS** instead of HTTP. Must be paired with `--tls-key` (boot fails if only one is given). Unlocks secure-context browser APIs — voice input (`getUserMedia`), WebRTC — over a LAN IP, which browsers otherwise block on plain `http://`. TLS termination only; no auto-generation / ACME. See [HTTPS / TLS](#https--tls-for-mobile--cross-device-access) below. |
| `--tls-key <path>` | — | Path to a PEM private key file. Must be paired with `--tls-cert`. |
| `--max-sessions <n>` | `20` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~30–50 MB per session). |
| `--max-pending-prompts-per-session <n>` | `5` | Per-session cap on prompts accepted by `POST /session/:id/prompt` but not yet settled, including queued prompts and the active prompt. The bridge rejects overflow synchronously with `503`, `Retry-After: 5`, and `code: "prompt_queue_full"` before returning a `promptId`. Set to `0` to disable. `branchSession` serializes on the same FIFO but does not count against this prompt cap. |
| `--workspace <path>` | `process.cwd()` | Absolute workspace path this daemon binds to (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02 — 1 daemon = 1 workspace). `POST /session` requests with a mismatched `cwd` return `400 workspace_mismatch`. For multi-workspace deployments, run one `qwen serve` per workspace on separate ports. |
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ interface ServeArgs {
workspace?: string;
'require-auth': boolean;
'enable-session-shell': boolean;
'tls-cert'?: string;
'tls-key'?: string;
web: boolean;
open: boolean;
// Read from the kebab-case key only — the camelCase mirror that yargs
Expand Down Expand Up @@ -195,6 +197,19 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
description:
'Enable direct POST /session/:id/shell execution. Requires a bearer token and a session-bound client id on each call.',
})
.option('tls-cert', {
type: 'string',
description:
'Path to a PEM certificate file. Serve over HTTPS instead of HTTP. ' +
'Required for secure-context browser APIs (voice input/getUserMedia, ' +
'WebRTC) when accessed over a LAN IP. Must be used together with ' +
'--tls-key. Generate a local cert with mkcert.',
})
.option('tls-key', {
type: 'string',
description:
'Path to a PEM private key file. Must be used together with --tls-cert.',
})
.option('experimental-lsp', {
type: 'boolean',
default: false,
Expand Down Expand Up @@ -506,6 +521,10 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
requireAuth: argv['require-auth'],
enableSessionShell: argv['enable-session-shell'],
serveWebShell: argv.web,
...(argv['tls-cert'] !== undefined
Comment thread
pomelo-nwu marked this conversation as resolved.
? { tlsCert: argv['tls-cert'] }
: {}),
...(argv['tls-key'] !== undefined ? { tlsKey: argv['tls-key'] } : {}),
allowPrivateAuthBaseUrl: argv['allow-private-auth-base-url'],
mcpClientBudget,
mcpBudgetMode: resolvedMcpMode,
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/serve/acp-http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,15 @@ const WS_READ_METHODS = new Set([
function isSameLoopbackOrigin(origin: string, localPort?: number): boolean {
if (!localPort) return false;
const parsed = new URL(origin);
// Both schemes: under `--tls-cert/--tls-key` the loopback ACP client
// speaks https, so its Origin header carries `https://`.
const allowed = new Set([
Comment thread
pomelo-nwu marked this conversation as resolved.
`http://localhost:${localPort}`,
`http://127.0.0.1:${localPort}`,
`http://[::1]:${localPort}`,
`https://localhost:${localPort}`,
`https://127.0.0.1:${localPort}`,
`https://[::1]:${localPort}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] isSameLoopbackOrigin omits host.docker.internal — REST/WS asymmetry for Docker HTTPS clients

The WS Host header allowlist later in this same file (line ~674) includes host.docker.internal for both port-qualified and port-less forms. Both REST self-origin strip middlewares (self-origin.ts and run-qwen-serve.ts) also include it. But isSameLoopbackOrigin omits it entirely.

Concrete effect: a Docker-hosted browser connecting over HTTPS can make REST requests (the self-origin strip removes the Origin header before CORS runs) and passes the WS Host check — but its WS upgrade is rejected by this Origin check with a 403. The operator has no obvious workaround path without --allow-origin.

Suggested change
`https://[::1]:${localPort}`,
`https://[::1]:${localPort}`,
`https://host.docker.internal:${localPort}`,

And in the port-443 block, add host.docker.internal to the host list:

for (const host of ['localhost', '127.0.0.1', '[::1]', 'host.docker.internal']) {

— qwen3.7-max via Qwen Code /review

]);
return allowed.has(parsed.origin.toLowerCase());
}
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/serve/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,11 @@ export function hostAllowlist(
`host.docker.internal:${port}`,
]);
// RFC 7230 §5.4: clients may omit the port suffix when it matches
// the URI scheme's default. http → 80, https → 443. The qwen
// serve daemon is plain HTTP, so accept the no-port forms when
// we're listening on port 80 (uncommon but valid for an operator
// who points at a privileged port for clean URLs).
if (port === 80) {
// the URI scheme's default. http → 80, https → 443. Accept the
// no-port forms when we're listening on either default port
// (uncommon but valid for an operator who points at a privileged
// port for clean URLs, or who enables TLS on 443).
if (port === 80 || port === 443) {
cachedAllowed.add('localhost');
cachedAllowed.add('127.0.0.1');
cachedAllowed.add('[::1]');
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/serve/fast-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,24 @@ describe('serve fast path argument parsing', () => {
});
});

it('parses --tls-cert and --tls-key on the fast path', () => {
const parsed = parseServeFastPathArgs([
'serve',
'--tls-cert',
'/tmp/cert.pem',
'--tls-key',
'/tmp/key.pem',
]);

expect(parsed).toMatchObject({
kind: 'serve',
options: {
tlsCert: '/tmp/cert.pem',
tlsKey: '/tmp/key.pem',
},
});
});

it('parses bundled entrypoint argv before serve', () => {
const parsed = parseServeFastPathArgs([
'/repo/dist/cli.js',
Expand Down Expand Up @@ -553,6 +571,8 @@ describe('serve fast path argument parsing', () => {
['workspace', ['--workspace', process.cwd()]],
['require-auth', ['--require-auth']],
['enable-session-shell', ['--enable-session-shell']],
['tls-cert', ['--tls-cert', '/tmp/cert.pem']],
['tls-key', ['--tls-key', '/tmp/key.pem']],
['web', ['--no-web']],
['open', ['--open']],
['http-bridge', ['--no-http-bridge']],
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/serve/fast-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const STRING_OPTION_BY_FLAG = new Map<string, keyof ServeOptions>([
['hostname', 'hostname'],
['token', 'token'],
['workspace', 'workspace'],
['tls-cert', 'tlsCert'],
['tls-key', 'tlsKey'],
]);

const BOOLEAN_OPTION_BY_FLAG = new Map<
Expand Down
Loading
Loading