From 25d080bcb346146e3c8d37a8543e144287b7275b Mon Sep 17 00:00:00 2001 From: "jifeng.zjd" Date: Wed, 13 May 2026 21:09:42 +0800 Subject: [PATCH 1/4] feat(serve): add /demo debug page for qwen serve daemon Add a self-contained HTML debug page at GET /demo that provides a browser-based UI for exercising all daemon routes: session create/attach, prompt send/cancel, SSE event streaming, model switching, permission voting, and health/capabilities checks. Also add a same-origin exemption middleware (before the CORS deny layer) so browser fetch calls from the demo page pass through while external Origins remain blocked. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/serve/demo.ts | 519 +++++++++++++++++++++++++++++++ packages/cli/src/serve/server.ts | 31 ++ 2 files changed, 550 insertions(+) create mode 100644 packages/cli/src/serve/demo.ts diff --git a/packages/cli/src/serve/demo.ts b/packages/cli/src/serve/demo.ts new file mode 100644 index 0000000000..feb3a7a3b7 --- /dev/null +++ b/packages/cli/src/serve/demo.ts @@ -0,0 +1,519 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Inline HTML for the `/demo` debug page. Served as a single self-contained + * page with no external dependencies so it works without a build step or + * static-file serving. + */ +export function getDemoHtml(_port: number): string { + return /* html */ ` + + + + +Qwen Serve Demo + + + + +
+

Qwen Serve

+ Demo +
+ + Connecting... +
+
+ +
+ + +
+
+
Chat
+
Events
+
API Log
+
+ +
+
+
Create a session to start chatting
+
+
+ + +
+
+ +
+
+
+ +
+
+
+
+
+ + + +`; +} diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 456b2ffe8a..d71db0aa32 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -24,6 +24,7 @@ import { type CapabilitiesEnvelope, type ServeOptions, } from './types.js'; +import { getDemoHtml } from './demo.js'; export interface ServeAppDeps { /** Bridge instance; tests inject a fake. Defaults to a fresh real one. */ @@ -64,6 +65,36 @@ export function createServeApp( const bridge = deps.bridge ?? createHttpAcpBridge({ maxSessions: opts.maxSessions }); + // --- Demo page: registered BEFORE CORS/auth so the browser can load + // the page and make same-origin API calls. The page is a self-contained + // HTML debug UI for exercising all daemon routes. + app.get('/demo', (_req, res) => { + res.type('html').send(getDemoHtml(getPort())); + }); + + // Allow same-origin requests from the demo page. Browsers send an + // `Origin` header on same-origin POST/fetch calls; `denyBrowserOriginCors` + // below would reject them. This middleware strips `Origin` when it + // matches the daemon's own address so the demo page's API calls pass + // through. Only loopback origins are matched — non-loopback deployments + // require the operator to front the daemon with a reverse proxy for + // browser access anyway (per the threat-model docs). + app.use((req: import('express').Request, _res, next) => { + const origin = req.headers.origin; + if (origin) { + const port = getPort(); + const selfOrigins = new Set([ + `http://127.0.0.1:${port}`, + `http://localhost:${port}`, + `http://[::1]:${port}`, + ]); + if (selfOrigins.has(origin)) { + delete req.headers.origin; + } + } + next(); + }); + // Order matters: rejection guards (CORS / Host allowlist / bearer auth) // run BEFORE the JSON body parser. Otherwise an unauthenticated POST // gets a full 10MB `JSON.parse` before the 401 fires — a trivially From 85c9d42c9fd1dcabccb2443d618c4147ea45b59c Mon Sep 17 00:00:00 2001 From: "jifeng.zjd" Date: Thu, 14 May 2026 15:12:09 +0800 Subject: [PATCH 2/4] fix(serve): address CR feedback for /demo page security and robustness - Fix XSS: build permission buttons with DOM APIs instead of innerHTML - Fix SSE: move currentEvent outside read loop for cross-chunk frames - Fix SSE: handle stream end (flush trailing buffer, update UI status) - Security: move /demo route behind hostAllowlist and bearerAuth guards - Security: add host.docker.internal to same-origin Origin allowlist - Add Auth Token input and include Authorization header in API/SSE calls - Add try/catch to /demo route handler with writeStderrLine logging - Check API result before removing permission card from UI - Add 7 tests for /demo route and Origin-stripping middleware --- packages/cli/src/serve/demo.ts | 87 ++++++++++++++++++++------- packages/cli/src/serve/server.test.ts | 87 +++++++++++++++++++++++++++ packages/cli/src/serve/server.ts | 24 +++++--- 3 files changed, 169 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/serve/demo.ts b/packages/cli/src/serve/demo.ts index feb3a7a3b7..6948c17581 100644 --- a/packages/cli/src/serve/demo.ts +++ b/packages/cli/src/serve/demo.ts @@ -114,6 +114,12 @@ export function getDemoHtml(_port: number): string { +
+

Auth Token

+ + +
+

Quick Actions

@@ -171,6 +177,7 @@ export function getDemoHtml(_port: number): string { const cwdInput = $('#cwdInput'); const promptInput = $('#promptInput'); const modelInput = $('#modelInput'); + const tokenInput = $('#tokenInput'); const chatArea = $('#chatArea'); const chatInput = $('#chatInput'); const eventLog = $('#eventLog'); @@ -214,8 +221,13 @@ export function getDemoHtml(_port: number): string { } // --- API helpers --- + function authHeaders() { + const token = tokenInput.value.trim(); + return token ? { 'Authorization': 'Bearer ' + token } : {}; + } + async function api(method, path, body) { - const opts = { method, headers: {} }; + const opts = { method, headers: { ...authHeaders() } }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); @@ -290,7 +302,8 @@ export function getDemoHtml(_port: number): string { (async function readSSE() { try { - const res = await fetch(url, { signal: abort.signal }); + const hdrs = authHeaders(); + const res = await fetch(url, { signal: abort.signal, headers: hdrs }); if (!res.ok) { logEvent('SSE-ERR', 'HTTP ' + res.status); return; @@ -298,6 +311,7 @@ export function getDemoHtml(_port: number): string { const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; + let currentEvent = {}; while (true) { const { done, value } = await reader.read(); @@ -307,7 +321,6 @@ export function getDemoHtml(_port: number): string { const lines = buffer.split('\\n'); buffer = lines.pop(); - let currentEvent = {}; for (const line of lines) { if (line.startsWith('data: ')) { currentEvent.data = line.slice(6); @@ -323,9 +336,17 @@ export function getDemoHtml(_port: number): string { } } } + // Process any remaining buffered data + if (currentEvent.data) handleSSEMessage(currentEvent); + statusDot.classList.remove('ok'); + statusText.textContent = 'SSE stream ended'; + enablePrompt(false); + logEvent('SSE', 'Stream ended by server'); } catch (err) { if (err.name !== 'AbortError') { logEvent('SSE-ERR', err.message); + statusDot.classList.remove('ok'); + statusText.textContent = 'SSE error'; } } })(); @@ -454,31 +475,53 @@ export function getDemoHtml(_port: number): string { for (const [id, req] of pendingPerms) { const card = document.createElement('div'); card.className = 'permission-card'; - let html = '

' + escHtml(req.tool?.name || 'Permission') + '

'; - html += '
' + escHtml(id) + '
'; + + const h4 = document.createElement('h4'); + h4.textContent = req.tool?.name || 'Permission'; + card.appendChild(h4); + + const idDiv = document.createElement('div'); + idDiv.style.cssText = 'font-size:11px;color:var(--text2);margin-bottom:6px'; + idDiv.textContent = id; + card.appendChild(idDiv); + + function makePermBtn(reqId, optId, label, isCancel) { + const btn = document.createElement('button'); + btn.className = 'btn opt-btn'; + btn.textContent = label; + btn.dataset.req = reqId; + if (isCancel) { + btn.dataset.cancel = '1'; + btn.style.borderColor = 'var(--err)'; + btn.style.color = 'var(--err)'; + } else { + btn.dataset.opt = optId; + } + btn.addEventListener('click', async () => { + let outcome; + if (isCancel) { + outcome = { outcome: 'cancelled' }; + } else { + outcome = { outcome: 'selected', optionId: optId }; + } + const result = await api('POST', '/permission/' + reqId, { outcome }); + if (result.ok) { + removePermission(reqId); + } else { + logError('PERM-ERR', 'Failed to resolve permission ' + reqId + ': ' + JSON.stringify(result.data)); + } + }); + return btn; + } + if (req.options && Array.isArray(req.options)) { for (const opt of req.options) { - html += ''; + card.appendChild(makePermBtn(id, opt.optionId, opt.name || opt.optionId, false)); } } - html += ''; - card.innerHTML = html; + card.appendChild(makePermBtn(id, null, 'Cancel', true)); permissionList.appendChild(card); } - - permissionList.querySelectorAll('.opt-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const reqId = btn.dataset.req; - let outcome; - if (btn.dataset.cancel === '1') { - outcome = { outcome: 'cancelled' }; - } else { - outcome = { outcome: 'selected', optionId: btn.dataset.opt }; - } - await api('POST', '/permission/' + reqId, { outcome }); - removePermission(reqId); - }); - }); } // --- Button bindings --- diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index c9ec984b3d..e865541350 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -1425,6 +1425,93 @@ describe('GET /session/:id/events (SSE)', () => { }); }); +describe('GET /demo', () => { + it('returns 200 with text/html content type', 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 protected by bearer auth (401 without 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'); + expect(res.status).toBe(401); + }); + + it('is accessible 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/); + }); +}); + +describe('same-origin Origin-stripping middleware', () => { + it('strips loopback Origin header matching daemon port', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + // A request with matching same-origin should pass CORS check + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'http://127.0.0.1:4170'); + // Should NOT be rejected by denyBrowserOriginCors (status != 403) + expect(res.status).not.toBe(403); + }); + + it('does not strip non-loopback Origin', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'http://evil.com:4170'); + expect(res.status).toBe(403); + }); + + it('does not strip Origin with wrong port', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'http://127.0.0.1:9999'); + expect(res.status).toBe(403); + }); + + it('strips host.docker.internal Origin', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + const res = await request(app) + .get('/health') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'http://host.docker.internal:4170'); + expect(res.status).not.toBe(403); + }); +}); + describe('runQwenServe SIGINT handler', () => { it('does not register signal handlers until the listener is up', () => { // Sanity: we register `once` so we don't leak across test runs. diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index d71db0aa32..15c4cbc187 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -65,13 +65,6 @@ export function createServeApp( const bridge = deps.bridge ?? createHttpAcpBridge({ maxSessions: opts.maxSessions }); - // --- Demo page: registered BEFORE CORS/auth so the browser can load - // the page and make same-origin API calls. The page is a self-contained - // HTML debug UI for exercising all daemon routes. - app.get('/demo', (_req, res) => { - res.type('html').send(getDemoHtml(getPort())); - }); - // Allow same-origin requests from the demo page. Browsers send an // `Origin` header on same-origin POST/fetch calls; `denyBrowserOriginCors` // below would reject them. This middleware strips `Origin` when it @@ -87,6 +80,7 @@ export function createServeApp( `http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`, + `http://host.docker.internal:${port}`, ]); if (selfOrigins.has(origin)) { delete req.headers.origin; @@ -158,6 +152,22 @@ export function createServeApp( } app.use(bearerAuth(opts.token)); + + // --- Demo page: registered AFTER CORS, Host allowlist, and bearer auth + // so the debug UI is protected by the same guards as all other routes. + // The same-origin Origin-stripping middleware above ensures the demo + // page's own API calls pass through denyBrowserOriginCors. + app.get('/demo', (_req, res) => { + try { + res.type('html').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' }); + } + }); + app.use(express.json({ limit: '10mb' })); if (!loopback) { From 92a77b94a7d626810d3ab2cf286c0933d2f3f9b6 Mon Sep 17 00:00:00 2001 From: "jifeng.zjd" Date: Fri, 15 May 2026 10:52:46 +0800 Subject: [PATCH 3/4] fix(serve): move /demo before bearerAuth so browsers can reach it Browsers cannot attach Authorization headers on address-bar navigation, so /demo behind bearerAuth was unreachable when --token was set. Move the /demo route after CORS + Host allowlist but before bearerAuth. The static HTML shell contains no secrets; all API/SSE routes remain bearer-protected and the in-page token input authenticates them. --- packages/cli/src/serve/server.test.ts | 26 +++++++++++---------- packages/cli/src/serve/server.ts | 33 +++++++++++++++------------ 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index e865541350..7fa6b85fa0 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -1439,28 +1439,30 @@ describe('GET /demo', () => { expect(res.text).toContain(''); }); - it('is protected by bearer auth (401 without token)', async () => { + it('is accessible without bearer token even when --token is set (before bearerAuth)', async () => { + // /demo is registered BEFORE bearerAuth so browsers can reach the + // page via address-bar navigation (which cannot attach Authorization + // headers). The in-page token input authenticates subsequent API calls. 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); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); }); - it('is accessible with valid bearer token', async () => { - const app = createServeApp( - { ...baseOpts, hostname: '0.0.0.0', token: 'secret' }, - () => 4170, - { bridge: fakeBridge() }, - ); + it('is still guarded by CORS and Host allowlist', async () => { + const app = createServeApp(baseOpts, () => 4170, { + bridge: fakeBridge(), + }); + // Cross-origin request should be rejected by denyBrowserOriginCors 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/); + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Origin', 'https://evil.example.com'); + expect(res.status).toBe(403); }); }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 15c4cbc187..c842b2a87d 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -96,6 +96,24 @@ export function createServeApp( app.use(denyBrowserOriginCors); app.use(hostAllowlist(opts.hostname, getPort)); + // --- Demo page: registered AFTER CORS and Host allowlist but BEFORE + // bearerAuth. Browsers cannot attach an Authorization header on + // address-bar navigation, so a bearer-protected /demo would be + // unreachable — the in-page token input field would never load. + // Serving only the static HTML shell here is safe: the page itself + // contains no secrets, and all daemon API/SSE routes remain behind + // bearerAuth so the token field authenticates subsequent calls. + app.get('/demo', (_req, res) => { + try { + res.type('html').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' }); + } + }); + // `/health` is exempted from `bearerAuth` ONLY on loopback binds — // the canonical liveness-probe case (k8s/Compose probes don't // carry the daemon's bearer; round-tripping a 401 just to know @@ -153,21 +171,6 @@ export function createServeApp( app.use(bearerAuth(opts.token)); - // --- Demo page: registered AFTER CORS, Host allowlist, and bearer auth - // so the debug UI is protected by the same guards as all other routes. - // The same-origin Origin-stripping middleware above ensures the demo - // page's own API calls pass through denyBrowserOriginCors. - app.get('/demo', (_req, res) => { - try { - res.type('html').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' }); - } - }); - app.use(express.json({ limit: '10mb' })); if (!loopback) { From f3dbe9661d22943f6a299cd2ccb4e8c91de7e0bf Mon Sep 17 00:00:00 2001 From: "jifeng.zjd" Date: Fri, 15 May 2026 14:19:57 +0800 Subject: [PATCH 4/4] feat(serve): show 401 token hint on demo page When an API call returns 401 Unauthorized, highlight the Auth Token input field with a yellow border and display a hint message guiding the user to enter their bearer token. Applies to both API calls and SSE connections. The hint auto-dismisses after 6 seconds. --- packages/cli/src/serve/demo.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/cli/src/serve/demo.ts b/packages/cli/src/serve/demo.ts index 6948c17581..ea92329972 100644 --- a/packages/cli/src/serve/demo.ts +++ b/packages/cli/src/serve/demo.ts @@ -220,6 +220,27 @@ export function getDemoHtml(_port: number): string { return d.innerHTML; } + // --- 401 token hint --- + let tokenHintTimer = null; + function highlightTokenInput(msg) { + tokenInput.style.borderColor = 'var(--warn)'; + tokenInput.focus(); + // Show hint text below the input + let hint = document.getElementById('tokenHint'); + if (!hint) { + hint = document.createElement('div'); + hint.id = 'tokenHint'; + hint.style.cssText = 'font-size:11px;color:var(--warn);margin-top:-4px;margin-bottom:4px;'; + tokenInput.parentNode.insertBefore(hint, tokenInput.nextSibling); + } + hint.textContent = msg; + if (tokenHintTimer) clearTimeout(tokenHintTimer); + tokenHintTimer = setTimeout(() => { + tokenInput.style.borderColor = ''; + hint.textContent = ''; + }, 6000); + } + // --- API helpers --- function authHeaders() { const token = tokenInput.value.trim(); @@ -240,6 +261,9 @@ export function getDemoHtml(_port: number): string { try { data = JSON.parse(text); } catch { data = text; } if (!res.ok) { logError(res.status, JSON.stringify(data)); + if (res.status === 401) { + highlightTokenInput('API returned 401 — enter your bearer token below'); + } return { ok: false, status: res.status, data }; } logResponse(res.status, JSON.stringify(data)); @@ -306,6 +330,9 @@ export function getDemoHtml(_port: number): string { const res = await fetch(url, { signal: abort.signal, headers: hdrs }); if (!res.ok) { logEvent('SSE-ERR', 'HTTP ' + res.status); + if (res.status === 401) { + highlightTokenInput('SSE returned 401 — enter your bearer token and recreate the session'); + } return; } const reader = res.body.getReader();