docs/plans/2026-08-22-serve-open-with-auth.md
qwen serve --open-with-auth implementation planmain at ea872a4621 (2026-08-22)main at 7385b278b2 (2026-08-23)Implement this as one bounded, default-off CLI feature after the design is
approved. Add only the --open-with-auth CLI flag; do not add persistent
storage, a new authentication protocol, a public ServeOptions switch, or an
SDK discovery mechanism. The implementation must reuse the existing runtime
bearer and Web Shell fragment flow end to end.
The implementation PR starts from fresh main. It may touch the two CLI serve
entry paths, one authenticated-open helper, one token-selection leaf, targeted
tests, and the existing user/developer documentation. It must not change Core
browser eligibility, daemon middleware, or broaden into the pair-token and
revocation work in #4514.
packages/cli/src/serve/serve-token.ts leaf. Its operation accepts the
option token and an optional environment-token string, defaulting to the
single process.env[QWEN_SERVER_TOKEN_ENV] key. It owns the
optionToken ?? environmentToken precedence, trims the selected string, and
maps an empty result to undefined.runQwenServe() and the authenticated-open helper call that operation.
Neither caller may re-derive the precedence or trimming rule. This extraction
changes no token source or runtime behavior; it makes future precedence
changes authoritative in one place.process-env-guard.test.ts allowance. Do not pass the whole process.env
object across the helper boundary.packages/cli/src/serve/open-with-auth.ts helper shared by
the yargs handler and fast path.ServeOptions object.
The callers invoke it only when --open-with-auth is true. It requires
isLoopbackBind(options.hostname), serveWebShell !== false, and resolvable
Web Shell assets. Reuse the existing predicates and fail before listen even
if an explicit token is configured.shouldLaunchBrowser() is false, continue startup and print the
fragment-bearing manual URL once the runtime is ready. Keep the existing
Core browser heuristic and blocklist unchanged; the secure launcher already
provides the matching manual-URL fallback after a launch attempt fails.undefined.
Copy a selected configured token or the generated value into options.token
before daemon startup so the decision is made once.randomBytes(32).toString('base64url') and assign it only to
options.token. Do not set process.env, write a credential file, add a
token store, or expose the value in a diagnostic.run-qwen-serve.ts. Direct embedded callers do not
have an --open concept and must retain their current token resolution.--open-with-auth yargs option with help text that says
it opens an authenticated local Web Shell, reusing or generating the bearer.
Derive effective opening as argv.open || argv['open-with-auth']; do not add
an invalid flag-combination check.ServeOptions object
as a local value checked with satisfies Parameters<typeof runQwenServe>[0]
after settings/environment loading. This retains repeatable startup
workspaces while restoring excess-property checks. Apply the shared helper
immediately before runQwenServe().open-with-auth / openWithAuth. Store effective open as
open || openWithAuth, including when --no-open is also present.bootstrapServeFastPathEnvironment() and existing option validation, so a
trusted workspace or home QWEN_SERVER_TOKEN is visible before the decision.parsed.openWithAuth. Bare --open
must preserve the fast path's current import and startup boundary, and the
full serve command opener remains deferred until runtime readiness.--token process-list warning before applying the
generated token, so an internally generated value is not mislabeled as a CLI
argument.maybeOpenWebShellBrowser() responsible for waiting for runtime
readiness, rewriting wildcard browser targets, adding resolvedToken as a
fragment, and invoking the secure browser launcher. Do not re-derive or pass
the generated token separately. When an opted-in launch skipped auto-open
because the environment was ineligible, print the same fragment-bearing
manual URL through the launcher's existing manual-URL wording once readiness
resolves. Pin both entry points' fallback boolean with wiring tests.RunHandle, ServeOptions, CredentialStore, bearer
middleware, mutation gates, Local Control credentials, channel-worker token
separation, WebSocket authentication, or the Web Shell token reader. A new
global Web Shell 401 recovery screen is explicitly deferred; document the
missing-fragment recovery path instead of expanding this bounded CLI change.--open-with-auth option beside --open and update
docs/users/qwen-serve.md with its prerequisites, default-off behavior,
pre-authentication surfaces, and the fact that another no-token client
receives 401 only inside the explicitly opted-in launch.docs/developers/daemon/02-serve-runtime.md,
docs/developers/daemon/12-auth-security.md,
docs/developers/daemon/17-configuration.md, and the authentication section
of docs/developers/qwen-serve-protocol.md so token precedence includes the
CLI-owned --open-with-auth generation step without implying that
runQwenServe() itself gained a new source.QWEN_SERVER_TOKEN=... qwen serve --open, with the same value supplied to
each SDK or curl client.qwen channel set/reload and remote status/stop need the explicit shared
token only when connecting to an opted-in temporary-token daemon; the Chrome
extension keeps its documented plain
qwen serve --allow-origin chrome-extension://<id> flow without the new flag
because it cannot discover the generated credential.docs/users/qwen-serve.md, and add a
scoped note to packages/chrome-extension/README.md that the extension
cannot discover an opted-in generated credential. The extension command and
onboarding prompt already omit --open, so they do not need a behavior
change.sessionStorage, restart rotation,
tab-close loss, missing-fragment and storage-unavailable 401 behavior,
browser-launch command visibility, and the existing secret-bearing manual-URL
fallback on launch failure.--enable-session-shell, deliberately satisfies the --allow-origin '*'
bearer guard, and leaves the documented static-asset and loopback /health
surfaces unchanged. Bare --open does none of these things.--require-auth
choices differ from this CLI-owned flow.Add collocated tests for the shared selector and helper, and extend the existing command and fast-path suites.
| Scenario | Expected result |
|---|---|
Plain qwen serve | No token is generated |
Bare interactive loopback --open | No token is generated; existing behavior is unchanged |
Eligible --open-with-auth with Web Shell assets | Opens the browser and assigns a selected or generated token |
--open --open-with-auth | Same authenticated behavior; the extra --open is harmless |
Non-empty --token on an eligible opted-in launch | Explicit token is retained; no token is generated |
Non-empty QWEN_SERVER_TOKEN with no CLI token on an eligible opted-in launch | Environment token remains authoritative |
| Whitespace-only selected token on an eligible opted-in launch | Treated as absent and replaced |
--open-with-auth --no-web | CLI validation error before listen |
| Opted-in launch with missing Web Shell assets | CLI validation error before listen |
| Opted-in launch in CI, headless Linux, or ineligible SSH | Starts and prints the fragment-bearing manual URL |
localhost, uppercase LOCALHOST, 127.0.0.1, 127.0.0.2, ::1, and [::1] | Eligible forms accepted through isLoopbackBind() |
0.0.0.0, [::], or a LAN address | CLI validation error even when another token is configured |
--open-with-auth --require-auth with no configured token | Generated token reaches runQwenServe() |
--open-with-auth --enable-session-shell with no configured token | Generated token activates the explicit shell opt-in |
--open-with-auth --allow-origin '*' with no configured token | Token is generated before the wildcard-origin boot guard runs |
--open-with-auth --local-control with no configured token | Primary gets the runtime token; LAN retains only its pairing token |
Also verify:
runQwenServe() and the generation helper both import the shared selector;
no caller retains a duplicate precedence or trimming implementation.undefined.QWEN_SERVER_TOKEN suppress generation
on the fast path.process.env.QWEN_SERVER_TOKEN absent or byte-for-byte
unchanged, including when the selected value is whitespace-only.resolveWebShellDir() asset pre-check, performs no filesystem
write, and introduces no credential-file path.--open, does not load the authenticated-open
helper or full command module.runQwenServe();
mutating a shared options object later must not make the ordering tests pass.maybeOpenWebShellBrowser() receives the daemon's resolvedToken, adds it as
#token=, and does not put it in a query parameter or normal success logs.runQwenServe() token trimming, non-loopback refusal,
--require-auth, /health, strict mutation, and worker-redaction tests stay
unchanged and green.Run targeted tests from the CLI package:
cd packages/cli
npx vitest run \
src/serve/serve-token.test.ts \
src/serve/open-with-auth.test.ts \
src/serve/process-env-guard.test.ts \
src/commands/serve.test.ts \
src/serve/fast-path.test.ts \
src/serve/fast-path-open.test.ts
Then run repository verification:
npm run build
npm run typecheck
npm run lint
Before implementation, record the current behavior with the globally installed
qwen CLI as required by the repository workflow. Store the plan and results
under .qwen/e2e-tests/; do not commit that ignored artifact.
Build and bundle the candidate. Use a temporary executable as BROWSER so the
test captures the browser URL without exposing a real credential to another
application.
qwen serve --port 0 --open still launches without an
automatic token: unauthenticated /capabilities returns 200 and a strict
mutation without a token returns token_required.qwen serve --port 0 --open-with-auth on loopback with no
configured token. Capture the launch fragment, decode it, and verify it
represents 32 bytes./capabilities returns 401 and the same request with
the captured bearer returns 200.POST /workspace/memory in a temporary
trusted workspace. Against the generated-token daemon, verify the
unauthenticated request is stopped by global bearer middleware with plain
401 {"error":"Unauthorized"}, while the authenticated request reaches body
validation and returns invalid_scope without changing workspace data.
Record token_required separately against a plain token-less loopback
baseline, where the strict mutation gate remains authoritative.sessionStorage./health remains unauthenticated for authenticated-open
mode, then repeat with --require-auth and verify an unauthenticated probe
returns 401.--no-web and missing Web Shell assets fail before listen.
Verify a headless opted-in launch starts and prints the manual URL. Verify
redundant --open and an accompanying --no-open do not disable the
authenticated-open intent.
Verify the corresponding invocations without the new flag retain existing
behavior.--open-with-auth --allow-origin '*' starts with the
generated bearer, leaves loopback /health pre-authentication, and returns
401 for an unauthenticated protected request. Verify
--open-with-auth --local-control keeps the LAN pairing credential
separate from the primary runtime token.--open preserves its existing daemon,
API, and browser-launch behavior, while adding --open-with-auth changes
unauthenticated /capabilities from 200 to 401 and keeps the automatically
opened browser authenticated.