* feat(cli): serve the Web Shell UI from `qwen serve`
`qwen serve` now serves the built Web Shell SPA at its root on the same
origin as the API, so a released binary exposes the browser terminal
without the dev-only Vite server (the `npm run dev:daemon` two-process
setup is unchanged for development).
- New `webShellStatic.ts` mounts `/`, `/assets/*` and an SPA deep-link
fallback. The fallback uses the same document-navigation discriminator
as the Vite dev proxy so it never shadows API JSON 404s.
- The static shell is registered BEFORE bearerAuth (a browser can't attach
a token to a `<script>` subresource or an address-bar navigation; the
shell carries no secrets and every API route stays token-gated). HTML
responses set CSP + X-Frame-Options + Referrer-Policy + no-cache.
- `--open` launches the browser at the daemon URL (with `?token=` when set)
once the listener is up, guarded by `shouldLaunchBrowser()`.
- `--no-web` opts out for an API-only daemon.
- Bundle / npm publish / standalone packaging now ship `dist/web-shell/`.
Missing assets degrade to API-only with a breadcrumb, never a hard fail.
Tests: +6 cases in server.test.ts (root shell, assets, SPA fallback,
non-navigation 404 passthrough, security headers, --no-web off).
* fix(cli): address review on Web Shell serving
Review fixes for #5392 (qwen-code-ci-bot):
- [Critical] SPA fallback no longer shadows /health or /demo on non-loopback
binds — those paths fall through to their own routes / bearerAuth instead
of receiving index.html.
- [Critical] --open trims the bearer token before putting it in the browser
URL, matching runQwenServe's own trimming, so a trailing newline from
`$(cat token.txt)` no longer makes every API call 401.
- --open is wrapped in its own try/catch so a failed browser launch can't
take down the already-listening daemon; it normalizes wildcard binds
(0.0.0.0 / ::) to loopback, and only fires when the UI is actually mounted
(new RunHandle.webShellMounted).
- resolveWebShellDir() now requires BOTH index.html and assets/, so a partial
build degrades to API-only instead of serving a shell whose chunks 404.
- runQwenServe logs a positive "Web Shell UI served from <dir>" breadcrumb,
and warns that on a non-loopback bind without --allow-origin the shell is
read-only (same-origin POSTs are blocked by the CORS wall).
- Document the --open token-in-process-list exposure in help text + a stderr
note when a token is forwarded.
- Tests: POST method guard, sec-fetch navigation signal, /health not shadowed,
sendFile 500 path, plus isDocumentNavigation and resolveWebShellDir units.
* fix(cli): harden Web Shell asset resolution and send-error logging
Second-round review (claude /qreview on the initial commit):
- resolveWebShellDir() now walks up from this module to find a sibling
packages/web-shell/dist, covering the transpiled layouts the previous
fixed `..` depth missed — per-package `tsc` output and the integration
daemon harness (packages/cli/dist/index.js), which would otherwise resolve
to nonexistent paths and silently run API-only.
- sendFile failures are no longer silent: log the error (matching the /demo
handler — previously the only 5xx path that emitted nothing) and res.end()
a half-streamed response instead of leaving the client on a 200 with a
partial body.
The remaining comment (open-browser inside the boot try) was already fixed
in 2487c90, where the --open block gained its own try/catch.
* fix(cli): pass --open token via URL fragment + add auth-contract tests
Third-round review (qwen3.7-max /review):
- --open now puts the token in the URL fragment (#token=) instead of a query
param, and the Web Shell reads it from the fragment first (falling back to
?token= for the dev launcher / hand-built URLs). A fragment is never sent to
the server, so the token stays out of access logs and Referer headers. It is
still visible in the browser-launcher's argv, so the stderr note stays and a
one-time-code exchange remains the real fix for multi-user hosts (follow-up).
- Add a server test pinning the "shell served before bearerAuth, API still
token-gated" contract (GET / → 200 without auth, /capabilities → 401 with a
token set), plus front-end getDaemonToken fragment/query precedence tests.
The token-trim comment in this pass was already addressed in 2487c90.
* fix(cli): read --open token from RunHandle.resolvedToken; doc + test polish
Fourth-round review (qwen3.7-max /review), all suggestions:
- --open now reads the server's resolved (trimmed) token from
RunHandle.resolvedToken instead of re-deriving it from argv/env. Removes the
duplicated QWEN_SERVER_TOKEN literal + trim logic and any drift risk; the
browser token is by construction what the daemon authenticates against.
- Simplify webShellMounted to !!webShellDir (serveWebShell===false already
forces webShellDir to undefined, so the extra conjunct was dead).
- Docs: the --open row now documents the #token= fragment transport (was
?token=) and why a fragment is used.
- Tests: add removeDaemonTokenFromUrl coverage (strip from fragment / query /
both, preserve non-token hash params, no-op when absent) and the missing
afterEach import.
* fix(cli): register Web Shell SPA fallback after API routes
Fifth-round review (claude /qreview):
- The SPA fallback no longer sits before bearerAuth. It now runs after every
API route (just before the error handler), so authed routes — and their
401s — always win, and only genuine 404 misses fall through to the shell.
A navigation with an attacker-controlled `Accept: text/html` to
/capabilities (or /health on a non-loopback bind) no longer coaxes the 200
shell out of a gated endpoint, and the fragile exact-match /health,/demo
denylist (which trailing-slash variants slipped past) is gone.
registerWebShell is split into mountWebShellAssets (/, /assets — still
pre-auth so a browser can load the shell + subresources without a header)
and mountWebShellSpaFallback (post-auth). The contract test now sends
Accept: text/html to /capabilities and asserts 401 — it would have been 200
before this change (the test was passing only because it omitted Accept).
- verifyBundleArtifacts (the publish gate) now requires dist/web-shell, so a
build that skipped the web-shell workspace (e.g. npm ci --ignore-scripts
bypassing the root prepare) fails packaging loudly instead of silently
shipping an API-only CLI whose GET / 404s.
* fix(cli): return a clean 404 for missing Web Shell assets
Sixth-round review (qwen3.7-max /review):
A missing /assets/* (e.g. a stale hashed chunk after a redeploy renamed it)
now returns 404 instead of falling through to the SPA fallback and answering a
browser navigation with a 200 index.html. Implemented with an explicit /assets
404 handler after express.static rather than serve-static's `fallthrough:
false` — the latter forwards a 404 error to the catch-all error handler, which
would turn it into a 500. Test added.
* test(cli): cover --open + Web Shell signals; add shell security headers
Seventh-round review (qwen-code-ci-bot):
- [Critical] Extract the --open browser-launch logic into the exported
maybeOpenWebShellBrowser() and unit-test it: --open / webShellMounted /
shouldLaunchBrowser gating, wildcard-host -> loopback rewrite, token in the
URL fragment (not query), and the never-throws error catch.
- [Critical] Assert RunHandle.webShellMounted (false under --no-web) and
resolvedToken (trimmed / undefined) in runQwenServe.test.ts; also cover
--web/--no-web and --open arg parsing.
- Drop dead code: target.hostname === '::' is unreachable (Node's URL returns
the IPv6 wildcard as '[::]', which is already handled).
- Add defense-in-depth headers to the shell response: base-uri 'none' in the
CSP (does not fall back to default-src), X-Content-Type-Options: nosniff,
and a restrictive Permissions-Policy.
- Add serve-debug-gated logging for /assets 404s and SPA-fallback hits so a
white-screen shell / routing misconfig has a diagnostic trail.
* fix(test): satisfy the Web Shell release gate in package-assets fixture
Eighth-round review (claude /qreview) — this is the actual CI failure.
The verifyBundleArtifacts Web Shell gate (requiring dist/web-shell, added in
this PR) broke scripts/tests/package-assets.test.js, which merge-main pulled
in: its createBundleArtifacts fixture only created cli.js / vendor / bundled,
so preparePackage exited 1 at the new gate before the test's assertions ran —
red on all three Test jobs. Add the web-shell artifacts (index.html +
assets/) to the fixture. The gate itself is intentional (it stops an API-only
package from shipping).