mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(serve): add --open-with-auth (#9738)
* docs(serve): propose ephemeral auth for --open Co-authored-by: Qwen-Coder <[email protected]> * docs(serve): address ephemeral auth review Co-authored-by: Qwen-Coder <[email protected]> * docs(serve): clarify asset pre-check boundary Co-authored-by: Qwen-Coder <[email protected]> * docs(serve): centralize token selection plan Co-authored-by: Qwen-Coder <[email protected]> * docs(serve): make ephemeral auth opt in Co-authored-by: Qwen-Coder <[email protected]> * docs(serve): align ineligible-browser handling with manual-URL fallback Browser-launch eligibility is a heuristic with common false negatives, so it is no longer a hard pre-listen gate: an ineligible environment warns (naming the tripped signal), starts the daemon, and prints the fragment-bearing manual URL, matching the launch-failure recovery. Also pin the generation breadcrumb with planned test assertions. Co-authored-by: Qwen-Coder <[email protected]> * feat(serve): add opt-in ephemeral auth for --open Co-authored-by: Qwen-Coder <[email protected]> * feat(serve): replace ephemeral auth with --open-with-auth Co-authored-by: Qwen-Coder <[email protected]> * docs(serve): clarify temporary token storage Co-authored-by: Qwen-Coder <[email protected]> * docs(serve): clarify ephemeral token persistence Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#9738) Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#9738) Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: Shaojin Wen <[email protected]>
This commit is contained in:
20 files changed
+1284
-25
No files matched your search
@@ -0,0 +1,318 @@
|
||||
# Authenticated Web Shell launch for `qwen serve`
|
||||
|
||||
- Status: Implemented in [#9738](https://github.com/QwenLM/qwen-code/pull/9738)
|
||||
- Baseline: `main` at `ea872a4621` (2026-08-22)
|
||||
- Revalidated: `main` at `7385b278b2` (2026-08-23)
|
||||
- Related tracking issue:
|
||||
[#4514](https://github.com/QwenLM/qwen-code/issues/4514)
|
||||
- Implementation plan: [2026-08-22-serve-open-with-auth.md](../plans/2026-08-22-serve-open-with-auth.md)
|
||||
|
||||
## Goal
|
||||
|
||||
Make the interactive Web Shell launch work without asking the operator to
|
||||
invent and copy a daemon bearer token:
|
||||
|
||||
```bash
|
||||
qwen serve --open-with-auth
|
||||
```
|
||||
|
||||
The explicit `--open-with-auth` flag should ensure that the launch uses the
|
||||
existing bearer authentication stack. It creates a strong process-lifetime
|
||||
token when no configured token exists and hands the selected token to the
|
||||
opened browser through the existing URL-fragment flow. Bare
|
||||
`qwen serve --open` keeps its current token-less loopback behavior.
|
||||
|
||||
This is an interactive convenience for a single-user loopback launch. It is
|
||||
not a general daemon credential store or a replacement for an explicitly
|
||||
managed token in multi-client and non-loopback deployments.
|
||||
|
||||
## Current behavior
|
||||
|
||||
`runQwenServe()` resolves a bearer token from `ServeOptions.token`, falling
|
||||
back to `QWEN_SERVER_TOKEN`, and trims the selected value. A configured token
|
||||
makes the global bearer middleware protect API routes. On loopback, `/health`
|
||||
remains outside that middleware unless `--require-auth` is also set. A
|
||||
non-loopback primary listener refuses to start without a token.
|
||||
|
||||
With no token, the loopback developer default leaves the primary bearer
|
||||
middleware open. Non-strict routes, including `/capabilities`, can therefore
|
||||
be called by another local process without `Authorization`. Routes using the
|
||||
strict mutation gate still return `401 token_required`.
|
||||
|
||||
`--open` currently changes none of those rules. Once the listener and runtime
|
||||
are ready, it opens the mounted Web Shell if the environment supports browser
|
||||
launch. When a token was configured separately, the resolved token is added as
|
||||
`#token=...`; otherwise the browser opens without credentials.
|
||||
|
||||
The Web Shell already implements the required handoff. It reads `#token=`,
|
||||
stores the value in per-tab `sessionStorage`, removes the fragment from the
|
||||
visible URL, and sends the token in `Authorization: Bearer ...` headers.
|
||||
Refresh therefore works, while closing the tab intentionally discards the
|
||||
credential.
|
||||
|
||||
The Desktop Shell already uses a related per-launch pattern: it creates a
|
||||
256-bit token, passes it to a child daemon through `QWEN_SERVER_TOKEN`, starts
|
||||
that daemon with `--require-auth`, and hands the credential to the Web Shell in
|
||||
a URL fragment. This proposal deliberately reuses the fragment and bearer
|
||||
semantics while differing at the CLI boundary: the token is base64url-encoded,
|
||||
is assigned to `ServeOptions.token` without mutating `process.env`, and leaves
|
||||
loopback `/health` pre-authentication unless the operator passes
|
||||
`--require-auth`. See
|
||||
[Desktop Web Shell release](./2026-07-31-desktop-web-shell-release.md).
|
||||
|
||||
## Decision
|
||||
|
||||
Add one default-off CLI flag, `--open-with-auth`. The mode requires these
|
||||
structural conditions, validated before the daemon listens:
|
||||
|
||||
1. `isLoopbackBind()` classifies the primary listener as loopback.
|
||||
2. Web Shell serving is enabled and built assets are available.
|
||||
|
||||
The flag includes the browser-opening intent; operators do not also need to
|
||||
pass `--open`. Internally, both CLI entry paths treat `--open-with-auth` as an
|
||||
effective `--open`. Passing both is harmless and has the same authenticated
|
||||
behavior.
|
||||
|
||||
These checks are deterministic, so a failure is a command error before the
|
||||
daemon listens, even when a token is already configured; the explicit request
|
||||
must not silently degrade or describe a browser-delivery mode that cannot
|
||||
run.
|
||||
|
||||
Browser-launch eligibility is deliberately not one of these hard gates.
|
||||
`shouldLaunchBrowser()` is a heuristic with common false negatives — a truthy
|
||||
`CI`, `DEBIAN_FRONTEND=noninteractive`, a Linux session without
|
||||
`DISPLAY`/`WAYLAND_DISPLAY`/`MIR_SOCKET`, a blocklisted `BROWSER` command, or
|
||||
SSH on a non-Linux host — and several of those environments still let the
|
||||
operator open a printed URL manually, for example over SSH port forwarding.
|
||||
When the heuristic reports ineligible, the CLI therefore starts the daemon and
|
||||
prints the fragment-bearing manual URL instead of auto-opening it. This matches
|
||||
the existing secure launcher's recovery after a browser command fails, so
|
||||
"the browser did not open" has exactly one actionable outcome without adding a
|
||||
second browser-eligibility API.
|
||||
|
||||
After the structural checks, the CLI creates an ephemeral bearer only when the
|
||||
existing token resolution produces no non-empty token. A non-empty configured
|
||||
token remains authoritative and suppresses generation.
|
||||
|
||||
The token is 32 cryptographically random bytes encoded with base64url. The CLI
|
||||
places it in `ServeOptions.token` before calling `runQwenServe()`. From that
|
||||
point onward, the generated token is indistinguishable from an explicitly
|
||||
configured runtime token: the existing bearer middleware, WebSocket
|
||||
authentication, strict mutation gate, internal worker handoff, redaction, and
|
||||
`RunHandle.resolvedToken` behavior remain authoritative.
|
||||
|
||||
The existing selection rule remains unchanged: choose `ServeOptions.token`
|
||||
when it is not `undefined`; otherwise choose `QWEN_SERVER_TOKEN`; then trim the
|
||||
selected value. A non-empty selected value wins and suppresses generation. A
|
||||
selected empty or whitespace-only value is treated as no configured token for
|
||||
this interactive flow.
|
||||
|
||||
No new environment variable, capability tag, protocol field, SDK option, or
|
||||
embedded `runQwenServe()` behavior is introduced. Automatic generation is a
|
||||
property of the two CLI entry paths when the new flag is present, not a daemon
|
||||
API default.
|
||||
|
||||
## Behavior matrix
|
||||
|
||||
| Invocation or environment | Automatic token | Result |
|
||||
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `qwen serve` on loopback with no configured token | No | Existing token-less developer default |
|
||||
| `qwen serve --open` on an interactive loopback host with no configured token | No | Existing browser launch and token-less loopback contract |
|
||||
| `qwen serve --open-with-auth` on an eligible loopback host | Yes, if no configured token exists | Browser receives the token; API routes require it |
|
||||
| `qwen serve --open-with-auth --require-auth` | Yes, if no configured token exists | Browser receives the token; `/health` also requires it |
|
||||
| `QWEN_SERVER_TOKEN=... qwen serve --open-with-auth` | No | Existing configured token is reused |
|
||||
| `qwen serve --token ... --open-with-auth` | No | Explicit CLI token is reused |
|
||||
| `qwen serve --open --open-with-auth` | Yes, if no configured token exists | Same as `--open-with-auth`; the extra `--open` is redundant |
|
||||
| `qwen serve --open-with-auth --no-web` | No | CLI validation error before listen |
|
||||
| `qwen serve --open-with-auth` with missing Web Shell assets | No | CLI validation error before listen |
|
||||
| `qwen serve --open-with-auth` in CI, headless SSH, or another ineligible browser environment | Yes, if no configured token exists | The daemon starts and the CLI prints the fragment-bearing manual URL |
|
||||
| `qwen serve --hostname 0.0.0.0 --open-with-auth` | No | CLI validation error; automatic credentials remain loopback-only |
|
||||
| `qwen serve --open-with-auth --local-control` | Yes for the primary listener if no configured token exists | Primary uses the selected runtime token; LAN keeps its separate pairing credential |
|
||||
| `qwen serve --open-with-auth --enable-session-shell` | Yes, if no configured token exists | The explicit shell opt-in becomes active and remains bearer-gated |
|
||||
| `qwen serve --open-with-auth --allow-origin '*'` | Yes, if no configured token exists | Selected bearer intentionally satisfies the existing wildcard-origin boot guard |
|
||||
| `qwen serve --open-with-auth --allow-origin chrome-extension://<id>` | Yes, if no configured token exists | Opened tab works; the extension does not receive or discover the generated token |
|
||||
|
||||
Static Web Shell assets remain mounted before bearer authentication because
|
||||
address-bar navigation and script loading cannot attach an Authorization
|
||||
header. Normal API routes require the selected runtime bearer. Loopback
|
||||
`/health` remains pre-authentication unless `--require-auth` is present.
|
||||
|
||||
Satisfying the `--allow-origin '*'` boot guard is intentional only when the
|
||||
operator explicitly selects `--open-with-auth`. The selected bearer prevents an
|
||||
unrelated page from calling protected API routes without the credential. It
|
||||
does not change the residual pre-authentication surfaces: static Web Shell
|
||||
assets remain readable, and loopback `/health` remains pre-authentication
|
||||
unless `--require-auth` is present. Operators who do not want those surfaces
|
||||
should combine `--require-auth` and, where appropriate, `--no-web` with an
|
||||
explicitly managed token rather than ephemeral mode.
|
||||
|
||||
## Lifecycle and recovery
|
||||
|
||||
The server does not persist an automatically generated token and does not
|
||||
export it as `QWEN_SERVER_TOKEN`. It lives for the daemon process and rotates on
|
||||
every restart. The normal internal authenticated-child path may receive it in
|
||||
the same way it receives an explicit runtime token; existing redaction and
|
||||
environment-separation rules continue to apply.
|
||||
|
||||
The browser receives the token in the launch command's URL fragment. The
|
||||
fragment is not sent in HTTP requests, access logs, or Referer headers. The Web
|
||||
Shell removes it from the URL after reading it and retains it only in that
|
||||
tab's `sessionStorage`.
|
||||
|
||||
Consequences of this intentionally short lifetime are:
|
||||
|
||||
- Refreshing the opened tab keeps working.
|
||||
- Closing the only tab loses the browser copy. Restarting
|
||||
`qwen serve --open-with-auth` creates a new token and opens a new
|
||||
authenticated tab.
|
||||
- Opening a bookmark, pasting the cleaned URL into another tab, restoring a tab
|
||||
after the browser process exits, or refreshing after `sessionStorage` was
|
||||
unavailable loads the static shell without the credential. API requests then
|
||||
receive plain `401 Unauthorized`. The current Web Shell has no global
|
||||
authentication-recovery screen; restart
|
||||
`qwen serve --open-with-auth`, or use an explicitly managed token for
|
||||
a workflow that must be reopened or shared.
|
||||
- A browser launcher failure, or an environment the eligibility heuristic
|
||||
reports as ineligible, follows the existing recovery path, which may print
|
||||
the secret-bearing fragment URL for manual opening.
|
||||
- A long-running or multi-client workflow should configure
|
||||
`QWEN_SERVER_TOKEN` explicitly so the operator can give the same credential
|
||||
to each authorized client and reopen the Web Shell without restarting.
|
||||
|
||||
The CLI emits a non-secret breadcrumb when it generates a token. Existing
|
||||
warnings continue to state that a token-bearing browser launch command can be
|
||||
visible through `ps` or `/proc`. This mode is therefore intended for a trusted,
|
||||
single-user host, not a shared workstation where process command lines or
|
||||
terminal output cross trust boundaries.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Apart from help text advertising the new option, all existing serve execution
|
||||
modes retain their current runtime behavior. This includes plain `qwen serve`,
|
||||
bare `qwen serve --open`, the Chrome extension's documented serve command,
|
||||
daemon-backed channel commands, and direct embedded callers of
|
||||
`runQwenServe()`. The new flag is the runtime compatibility boundary.
|
||||
|
||||
Inside an explicitly opted-in ephemeral launch, another local curl, SDK, or
|
||||
first-party client without the selected bearer receives `401 Unauthorized`.
|
||||
An automatically generated token is not published for discovery. Multi-client
|
||||
users should instead set `QWEN_SERVER_TOKEN` before starting
|
||||
`qwen serve --open` and configure each client with the same stable credential.
|
||||
|
||||
Daemon-backed `qwen channel set` and `qwen channel reload`, plus the
|
||||
`--daemon-url` forms of `qwen channel status` and `qwen channel stop`, cannot
|
||||
discover an ephemeral token. The Chrome extension likewise cannot receive it.
|
||||
Those clients remain compatible by omitting `--open-with-auth`, or can join an
|
||||
authenticated daemon when the operator supplies a shared stable token. No
|
||||
extension default or prompt change is needed.
|
||||
|
||||
An explicit `--enable-session-shell` has a separate posture change. Without a
|
||||
configured token today, the daemon ignores the flag with a warning. Only the
|
||||
explicit combination with `--open-with-auth` supplies that token and
|
||||
therefore enables direct session shell execution. This is a double opt-in, and
|
||||
must still be called out in user documentation and tests.
|
||||
|
||||
The generated token is not browser-scoped. Possession grants the same daemon
|
||||
authority as an explicitly configured runtime token. Per-client credentials,
|
||||
identity binding, and independent revocation require the larger credential
|
||||
store work tracked by #4514.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
- Bare `--open` keeps its existing no-op and API-only degradation behavior.
|
||||
With `--open-with-auth`, loopback binding, Web Shell enablement, and asset
|
||||
availability are validated before listen; an invalid explicit request fails
|
||||
instead of leaving an inaccessible authenticated daemon.
|
||||
- Browser-launch eligibility is a heuristic and is not a hard gate. An
|
||||
ineligible environment falls back to the printed manual URL — the same
|
||||
outcome as a launch failure after eligibility passed.
|
||||
- Non-loopback binding is never made implicitly usable by token generation; it
|
||||
continues to demand explicit operator-supplied credentials and cannot use
|
||||
`--open-with-auth`.
|
||||
- `--require-auth` keeps its existing behavior without the new flag. In an
|
||||
eligible opted-in launch, the selected credential satisfies it and also moves
|
||||
loopback `/health` behind the bearer.
|
||||
- If browser launch fails after eligibility was established, the existing
|
||||
browser launcher retains its manual-URL fallback. The heuristic-ineligible
|
||||
path prints the same form of manual URL. No persistent recovery credential
|
||||
is added.
|
||||
- Runtime startup failure keeps the existing daemon shutdown/error behavior;
|
||||
the ephemeral token does not create a second lifecycle. The ordinary yargs
|
||||
path uses the default `runQwenServe()` contract, which does not return a
|
||||
`RunHandle` before runtime readiness. The listen-first fast path already
|
||||
closes the handle and exits when `runtimeReady` rejects.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Automatically generate for bare `--open`
|
||||
|
||||
This gives the shortest complete Web Shell command, but changes the contract
|
||||
of an existing invocation. Other local curl and SDK clients, daemon-backed
|
||||
channel commands, and the Chrome extension cannot discover the generated
|
||||
credential; an explicit session-shell flag also changes from inert to active.
|
||||
The compatibility cost is larger than the one-extra-flag convenience, so bare
|
||||
`--open` remains unchanged.
|
||||
|
||||
### Make authenticated opening the default for `--open`
|
||||
|
||||
An opt-out would make the compatibility-changing behavior the default and ask
|
||||
existing users to discover a new escape hatch. A default-off opt-in flag
|
||||
preserves every existing invocation and makes the authenticated contract
|
||||
explicit, so no opt-out flag is added.
|
||||
|
||||
### Add a separate `--ephemeral-auth` modifier
|
||||
|
||||
Requiring `--open --ephemeral-auth` exposes token lifetime as a user-facing
|
||||
mechanism, creates an invalid standalone flag combination, and is inaccurate
|
||||
when a configured token is reused. `--open-with-auth` names the user intent and
|
||||
contains the opening behavior in one default-off option.
|
||||
|
||||
### Persist a generated token
|
||||
|
||||
A file-backed token would support reopening tabs and client discovery, but it
|
||||
requires secure storage permissions, stale-instance cleanup, rotation,
|
||||
revocation, and a client identity model. That work should be designed together
|
||||
with #4514's pair-token and per-client revocation scope, not introduced as a
|
||||
one-off file for `--open`.
|
||||
|
||||
### Mint a browser-scoped pairing token
|
||||
|
||||
Keeping the primary listener open while accepting a second browser credential
|
||||
would avoid breaking token-less clients. It would also require a new primary
|
||||
listener credential scope, optional authentication on an otherwise-open
|
||||
listener, separate session semantics, and revocation behavior. Reusing the
|
||||
existing runtime bearer is smaller and uses enforcement already exercised by
|
||||
all daemon transports.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Persistent token storage or discovery
|
||||
- Cross-tab or cross-browser credential sharing
|
||||
- `localStorage`, cookies, or a new browser bootstrap endpoint
|
||||
- Pair tokens, per-client identity, audit ownership, or independent revocation
|
||||
- Automatic credentials for non-loopback binds
|
||||
- Changes to SDK token discovery
|
||||
- Chrome extension token discovery or automatic credential delivery
|
||||
- A new global Web Shell 401 or credential-recovery screen
|
||||
- Changes to Local Control's listener-scoped pairing credential
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- An eligible `qwen serve --open-with-auth` opens a Web Shell that can
|
||||
call strict and non-strict API routes without manual token configuration.
|
||||
- An unauthenticated local API client receives 401 from protected routes on
|
||||
that daemon.
|
||||
- Plain `qwen serve`, bare `qwen serve --open`, direct `runQwenServe()` callers,
|
||||
headless `--open`, API-only mode, and non-loopback boot checks preserve their
|
||||
existing behavior.
|
||||
- `--open-with-auth` with an ineligible bind or disabled or missing Web Shell
|
||||
fails before listen even when another token is configured; remove the
|
||||
inapplicable flag to use the existing mode. A headless or otherwise
|
||||
browser-ineligible environment starts the daemon and prints the manual URL.
|
||||
- A non-empty token selected by the existing CLI-over-environment precedence is
|
||||
never replaced. An empty or whitespace-only selected value is treated as
|
||||
absent.
|
||||
- The generated token is not persisted by the daemon or printed during a
|
||||
successful browser launch.
|
||||
- Refresh works through the existing per-tab Web Shell storage; closing the tab
|
||||
does not create cross-tab persistence.
|
||||
@@ -61,6 +61,8 @@
|
||||
|
||||
### Boot sequence
|
||||
|
||||
Before `runQwenServe()` starts this sequence, the CLI-only `--open-with-auth` mode validates loopback/Web Shell eligibility and fills `ServeOptions.token` with the selected configured token, or with 32 random bytes encoded as base64url when that selection is empty. Direct embedders and invocations without that default-off flag do not generate a token.
|
||||
|
||||
1. **Resolve and trim token** from `opts.token` or `QWEN_SERVER_TOKEN`; this
|
||||
avoids a trailing newline from `cat token.txt` silently breaking bearer
|
||||
comparison.
|
||||
@@ -124,6 +126,7 @@ Calling `createServeApp` directly still returns only an `Application`. An embedd
|
||||
| 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. |
|
||||
| CLI flags | `--open-with-auth` | Default-off loopback Web Shell launch that reuses or generates a process-lifetime bearer before runtime. |
|
||||
| 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. |
|
||||
|
||||
@@ -273,6 +273,7 @@ sequenceDiagram
|
||||
## State & Lifecycle
|
||||
|
||||
- Bearer token is read at boot and trimmed (newlines from `cat token.txt` would otherwise silently break comparison).
|
||||
- The CLI-only `--open-with-auth` mode runs before boot: after deterministic loopback/Web Shell checks, it applies the same option-over-environment selection and fills `ServeOptions.token` with 32 random bytes encoded as base64url only when no non-empty selected token exists. The generated credential has process lifetime, is not written to `process.env` or persisted by the daemon, and reaches the browser through the existing URL fragment. The Web Shell retains its browser copy in per-tab `sessionStorage`. Bare `--open` and direct `runQwenServe()` callers never generate it.
|
||||
- Allowed-Host Set is cached per port; rebuilt on port change (ephemeral `0` → real port post-`listen`).
|
||||
- Mutation gate constructs `passthrough` and `strictDenier` once per app build; per-route call returns the cached closure (no per-request allocation).
|
||||
- Device-flow registry is disposed on `shutdown()` Phase 1 so pending flows resolve as `cancelled` before HTTP teardown.
|
||||
@@ -290,6 +291,7 @@ sequenceDiagram
|
||||
| --------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| Env | `QWEN_SERVER_TOKEN` | Bearer token (trimmed). |
|
||||
| Flag | `--token` | Bearer token (overrides env). |
|
||||
| CLI flags | `--open-with-auth` | Reuse or generate a loopback Web Shell bearer before daemon boot. |
|
||||
| Flag | `--require-auth` | Extends bearer to loopback + `/health`. Boots only with a token. |
|
||||
| Flag | `--hostname` | Non-loopback bind requires `--token` (or env). |
|
||||
| Flag | `--allow-origin <pattern>` | Switch to CORS allowlist mode. `'*'` requires a token. |
|
||||
|
||||
@@ -11,6 +11,8 @@ This page collects every setting that affects the `qwen serve` daemon and its ad
|
||||
| `--hostname <host>` | 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 <n>` | number | `4170` | Listen port; `0` means ephemeral. |
|
||||
| `--token <s>` | 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. |
|
||||
| `--open` | boolean | `false` | Open the mounted Web Shell after runtime readiness. A configured token is delivered as a URL fragment. Bare `--open` remains a silent no-op when browser launch is ineligible. |
|
||||
| `--open-with-auth` | boolean | `false` | Open the Web Shell with bearer authentication on loopback. Requires an enabled Web Shell and built assets. Reuses a selected `--token` / `QWEN_SERVER_TOKEN`, or generates a process-lifetime 256-bit bearer before listen. In a browser-ineligible environment, starts and prints the secret-bearing fragment URL. Not a `ServeOptions` or SDK setting. |
|
||||
| `--require-auth` | boolean | `false` | Extends bearer auth to loopback and `/health`; boot refuses to start without a token. |
|
||||
| `--workspace <dir>` | 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 <mode>` | `git-root` / `workspace` | `workspace` | Project-memory partitioning. `workspace` isolates by exact workspace directory; `git-root` is the legacy compatibility scope shared by workspaces at the same Git root. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE`. |
|
||||
|
||||
@@ -12,6 +12,8 @@ Authorization: Bearer <token>
|
||||
|
||||
Without a configured token (loopback dev default) the header is optional. Token comparison is constant-time. 401 responses are uniform across `missing header` / `wrong scheme` / `wrong token`.
|
||||
|
||||
**`--open-with-auth`.** This default-off CLI mode requires a loopback bind and an available Web Shell. It reuses the normal `--token`-over-`QWEN_SERVER_TOKEN` selection, or generates 32 random bytes encoded as base64url before daemon startup when that selection is empty. The browser receives the selected bearer through `#token=` and stores it per tab; the protocol and middleware see an ordinary configured token. Bare `--open`, direct embedded callers, non-loopback binds, and other clients do not receive automatic credentials. Browser-ineligible environments print the secret-bearing fragment URL for manual opening. Loopback `/health` and static Web Shell assets retain the exemptions described below; `--require-auth` still gates `/health`.
|
||||
|
||||
**`/health` exemption** (Bctum): on loopback binds (`127.0.0.1` / `localhost` / `::1` / `[::1]`) `/health` is registered BEFORE the bearer middleware, so liveness probes inside the pod don't need to carry the token even when the daemon was started with `--token`. Non-loopback binds (`--hostname 0.0.0.0` etc.) gate `/health` behind the bearer like every other route — see the [`GET /health`](#get-health) section for the rationale.
|
||||
|
||||
**`--require-auth` (#4175 PR 15).** Pass this flag at boot to extend the "must have a token" rule to loopback as well. Boot fails without a token; the `/health` exemption is dropped (so `/health` also requires `Authorization: Bearer …`).
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
# `qwen serve --open-with-auth` implementation plan
|
||||
|
||||
- Status: Implemented in [#9738](https://github.com/QwenLM/qwen-code/pull/9738)
|
||||
- Baseline: `main` at `ea872a4621` (2026-08-22)
|
||||
- Revalidated: `main` at `7385b278b2` (2026-08-23)
|
||||
- Design: [2026-08-22-serve-open-with-auth.md](../design/2026-08-22-serve-open-with-auth.md)
|
||||
- Related tracking issue:
|
||||
[#4514](https://github.com/QwenLM/qwen-code/issues/4514)
|
||||
|
||||
## Delivery rule
|
||||
|
||||
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.
|
||||
|
||||
## Phase 1: Shared temporary-token decision
|
||||
|
||||
- Extract the existing selection and normalization into a small
|
||||
`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`.
|
||||
- Make both `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.
|
||||
- Register that single computed environment access in the existing
|
||||
`process-env-guard.test.ts` allowance. Do not pass the whole `process.env`
|
||||
object across the helper boundary.
|
||||
- Add a small `packages/cli/src/serve/open-with-auth.ts` helper shared by
|
||||
the yargs handler and fast path.
|
||||
- Expose one operation that receives the materialized `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.
|
||||
- Treat browser-launch eligibility as advisory, not a hard gate. When
|
||||
`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.
|
||||
- Call the shared token selector and generate only when it returns `undefined`.
|
||||
Copy a selected configured token or the generated value into `options.token`
|
||||
before daemon startup so the decision is made once.
|
||||
- Generate `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.
|
||||
- Keep the helper outside `run-qwen-serve.ts`. Direct embedded callers do not
|
||||
have an `--open` concept and must retain their current token resolution.
|
||||
|
||||
## Phase 2: Wire both CLI entry paths
|
||||
|
||||
- Add the default-false `--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.
|
||||
- In the yargs handler, materialize the existing inline `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()`.
|
||||
- Extend the fast parser's normalized boolean mapping and parsed result with
|
||||
`open-with-auth` / `openWithAuth`. Store effective `open` as
|
||||
`open || openWithAuth`, including when `--no-open` is also present.
|
||||
- In the fast path, apply the helper after
|
||||
`bootstrapServeFastPathEnvironment()` and existing option validation, so a
|
||||
trusted workspace or home `QWEN_SERVER_TOKEN` is visible before the decision.
|
||||
- Dynamically import the helper only for `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.
|
||||
- Run the existing explicit `--token` process-list warning before applying the
|
||||
generated token, so an internally generated value is not mislabeled as a CLI
|
||||
argument.
|
||||
- When generation occurs, emit one shared non-secret message explaining that
|
||||
temporary authentication is active and that additional clients need an
|
||||
explicit shared token.
|
||||
- Leave `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.
|
||||
- Do not change `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.
|
||||
|
||||
## Phase 3: Documentation and migration guidance
|
||||
|
||||
- Document the new `--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.
|
||||
- Update `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.
|
||||
- Document the stable-token migration for multi-client use:
|
||||
`QWEN_SERVER_TOKEN=... qwen serve --open`, with the same value supplied to
|
||||
each SDK or curl client.
|
||||
- Name affected first-party clients and their migrations: daemon-backed
|
||||
`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.
|
||||
- Add the channel-command migration to `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.
|
||||
- Document process lifetime, per-tab `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.
|
||||
- Document that the authenticated-open flag deliberately activates an explicit
|
||||
`--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.
|
||||
- Cross-reference the existing Desktop Shell per-launch token design and state
|
||||
why its child-environment, hex encoding, and unconditional `--require-auth`
|
||||
choices differ from this CLI-owned flow.
|
||||
- Reference #4514 as related future work without closing it or claiming that
|
||||
auto-generated token storage, identity, or revocation is complete.
|
||||
|
||||
## Unit test matrix
|
||||
|
||||
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:
|
||||
|
||||
- An eligible opted-in generation emits the shared non-secret breadcrumb, and
|
||||
the emitted message does not contain the generated token value.
|
||||
- The yargs and fast paths parse the new flag and invoke the same decision
|
||||
after their environment bootstrap.
|
||||
- `runQwenServe()` and the generation helper both import the shared selector;
|
||||
no caller retains a duplicate precedence or trimming implementation.
|
||||
- Selector tests preserve the current choose-before-trim order, including that
|
||||
a whitespace-only option value shadows a non-empty environment value and
|
||||
then normalizes to `undefined`.
|
||||
- Workspace/home settings that supply `QWEN_SERVER_TOKEN` suppress generation
|
||||
on the fast path.
|
||||
- Generation leaves `process.env.QWEN_SERVER_TOKEN` absent or byte-for-byte
|
||||
unchanged, including when the selected value is whitespace-only.
|
||||
- Static review confirms the helper uses only the filesystem reads required by
|
||||
the existing `resolveWebShellDir()` asset pre-check, performs no filesystem
|
||||
write, and introduces no credential-file path.
|
||||
- The normal fast path, including bare `--open`, does not load the authenticated-open
|
||||
helper or full command module.
|
||||
- Each entry path captures the token at the instant it calls `runQwenServe()`;
|
||||
mutating a shared options object later must not make the ordering tests pass.
|
||||
- The fast path exits before listen when the helper rejects an invalid bind or
|
||||
Web Shell configuration.
|
||||
- `maybeOpenWebShellBrowser()` receives the daemon's `resolvedToken`, adds it as
|
||||
`#token=`, and does not put it in a query parameter or normal success logs.
|
||||
- Existing `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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## E2E test plan
|
||||
|
||||
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.
|
||||
|
||||
1. Record that bare `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`.
|
||||
2. Start `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.
|
||||
3. Verify unauthenticated `/capabilities` returns 401 and the same request with
|
||||
the captured bearer returns 200.
|
||||
4. Send an invalid empty body to strict `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.
|
||||
5. Load the real Web Shell in a browser, confirm the fragment is removed, hard
|
||||
refresh, and verify requests remain authenticated through `sessionStorage`.
|
||||
6. Verify loopback `/health` remains unauthenticated for authenticated-open
|
||||
mode, then repeat with `--require-auth` and verify an unauthenticated probe
|
||||
returns 401.
|
||||
7. Verify opted-in `--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.
|
||||
8. Verify a non-loopback opted-in launch fails before listen even when an
|
||||
explicit token is configured. Without the new flag, verify that the same
|
||||
explicit-token launch starts and a no-token non-loopback launch still
|
||||
refuses to start.
|
||||
9. Verify a successful launch prints the non-secret breadcrumb announcing
|
||||
temporary authentication, and that neither it nor any other normal stdout
|
||||
or stderr line contains the generated value. Record that the fragment
|
||||
remains visible to the browser launcher process by design.
|
||||
10. Verify `--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.
|
||||
|
||||
## Acceptance and review
|
||||
|
||||
- Include evidence showing that bare `--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.
|
||||
- State the local OS and runtime used. Mark macOS, Windows, and Linux explicitly
|
||||
as tested, not tested, or N/A in the PR template.
|
||||
- Treat effective-open derivation and fast/yargs parity as the primary
|
||||
implementation risks. Put the opted-in local-client limitation, explicit
|
||||
shared-token alternative, and unaffected bare Chrome extension and
|
||||
daemon-backed channel flows in the PR body.
|
||||
- Review the complete diff, including untracked files, for security boundaries,
|
||||
failure paths, fast-path import cost, test gaps, documentation drift,
|
||||
over-abstraction, and accidental expansion into #4514.
|
||||
- Require two consecutive clean self-audit passes after all fixes and
|
||||
verification. Any change found during an audit resets the clean-pass count.
|
||||
@@ -68,6 +68,21 @@ The default bind is `127.0.0.1:4170`. Bearer auth is **off** on loopback so loca
|
||||
|
||||
**Open the Web Shell UI.** Browse to `http://127.0.0.1:4170/` (or start the daemon with `qwen serve --open` to launch it automatically) for the full browser terminal — chat, diffs, commit history, tool calls, and permission prompts. The UI is served at the daemon root on the same origin as the API. The rest of this guide uses raw HTTP so you can script against the API directly.
|
||||
|
||||
For an authenticated single-user launch without manually creating a token, opt in explicitly:
|
||||
|
||||
```bash
|
||||
qwen serve --open-with-auth
|
||||
```
|
||||
|
||||
This loopback-only mode generates a 256-bit bearer token when neither `--token` nor `QWEN_SERVER_TOKEN` supplies one, then gives it to the opened Web Shell as a `#token=` URL fragment. The shell removes the fragment and keeps the credential in that tab's `sessionStorage`; refresh works, but closing the tab or restarting the daemon loses the credential. In CI, SSH, or another environment where auto-open is unavailable, the daemon starts and prints the fragment URL for manual opening. The printed URL is secret-bearing.
|
||||
|
||||
The flag is default-off, includes the browser-opening behavior, and requires the Web Shell, built Web Shell assets, and a loopback bind. Bare `qwen serve --open` remains token-less on loopback. In authenticated-open mode, normal API routes reject other local clients without the bearer; static Web Shell assets and loopback `/health` keep their existing pre-authentication behavior unless `--require-auth` is also set. For multiple clients or a reopenable Web Shell, use a stable shared token instead:
|
||||
|
||||
```bash
|
||||
export QWEN_SERVER_TOKEN="$(openssl rand -hex 32)"
|
||||
qwen serve --open
|
||||
```
|
||||
|
||||
### 2. Sanity-check it
|
||||
|
||||
```bash
|
||||
@@ -113,6 +128,8 @@ This mode is experimental and daemon-managed. It does not replace the standalone
|
||||
|
||||
Runtime control is exposed as `GET`, `PUT`, and `DELETE /workspace/channel`; SDK helpers are `getChannelWorkerControl()`, `setChannelWorkerSelection()`, and `stopChannelWorker()`. PUT/DELETE/reload use the strict mutation gate, so the daemon must have a bearer token configured. Runtime selections are deliberately ephemeral: PUT does not edit settings or the boot options, and a restart returns to the `qwen serve --channel` selection (or disabled when that flag is omitted). Named selections are trimmed and deduplicated in first-occurrence order; order is preserved because the first channel can affect shared model selection.
|
||||
|
||||
Daemon-backed `qwen channel set` and `qwen channel reload`, plus `status` and `stop` with `--daemon-url`, cannot discover a token generated by `--open-with-auth`. Use `QWEN_SERVER_TOKEN` and pass the same value with `--token` to those clients, or omit authenticated-open mode.
|
||||
|
||||
The daemon reads each channel's settings (tokens, `proxy`, per-channel `model`) when its worker starts. To re-read settings without changing the committed selection, call `POST /workspace/channel/reload` (SDK `client.reloadChannelWorker()`, or `qwen channel reload`). Reload re-resolves workspace ownership and restarts selected workers through the same rollback-safe reconcile path. The `channel_control` capability is present whenever runtime control is wired; `channel_reload` is present only while the manager is enabled. Persisted threads are restored from disk.
|
||||
|
||||
Each selected channel's `cwd` must resolve to a registered workspace, and channels are grouped by that owning workspace: a single-workspace daemon runs one worker (unchanged from before); a multi-workspace daemon (`--workspace` repeated) runs one worker per workspace that owns a selected channel, each bound to that workspace's cwd, `QWEN_DAEMON_WORKSPACE`, and env overlay. To host a channel in a non-primary workspace, define it in that workspace's own `.qwen/settings.json` (no `cwd` needed) or set an explicit `cwd` equal to the workspace path; a channel defined only in user/system scope with no `cwd` is ambiguous across workspaces and causes a boot error. `--channel all` stays primary-only (it hosts the primary workspace's channels) and cannot be combined with named channels.
|
||||
@@ -346,6 +363,8 @@ 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.
|
||||
|
||||
`--open-with-auth` is a CLI-owned convenience, not another daemon token source: it selects `--token` when that option is defined (even if blank), otherwise `QWEN_SERVER_TOKEN`, then trims the selected value and generates only if the result is empty. The daemon does not persist the generated value or export it as `QWEN_SERVER_TOKEN`; the existing internal authenticated-child handoff remains unchanged. The Web Shell stores its browser copy only in the receiving tab's `sessionStorage`; this mode adds no cross-tab or external-client credential discovery mechanism. The token is not independently revocable or tied to a client identity. Possession grants the same daemon authority as any other bearer token. See the [authenticated Web Shell launch design](../design/2026-08-22-serve-open-with-auth.md) and related future work in [#4514](https://github.com/QwenLM/qwen-code/issues/4514).
|
||||
|
||||
## 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:
|
||||
@@ -415,6 +434,7 @@ Notes:
|
||||
| `--allow-origin <pat>` | — | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). Cross-origin allowlist for browser webui clients. Repeatable. Each value is `*` (any origin — boot refuses if no bearer token is configured; `--require-auth` on loopback is recommended so `/health` is also bearer-gated, since it is pre-auth on loopback by default; the Web Shell static assets stay pre-auth in every mode, so pass `--no-web` to remove them) or a canonical URL origin (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo / query). **Subdomain wildcards (`https://*.example.com`) are intentionally unsupported** — list each subdomain explicitly, or use `*` with a configured token (and `--require-auth` for full hardening). Matched origins receive CORS response headers (`Access-Control-Allow-Origin`, `Vary: Origin`, methods, headers, max-age, and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as today's wall. `Origin: null` (sandboxed iframes, file:// docs) is always rejected, even under `*`. Pre-flight via `caps.features.allow_origin`. Loopback self-origin hits are unaffected. |
|
||||
| `--web` / `--no-web` | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `GET /session/<id>` document navigations). These entry points are registered **before** the bearer-auth gate — a browser can't attach a token to a `<script>` subresource or an address-bar navigation, and the shell carries no secrets. Every API route stays token-gated regardless, and the SPA deep-link fallback for all other paths sits behind the bearer gate too. On non-loopback binds a one-line stderr warning notes the UI is reachable without auth. Use `--no-web` for an API-only daemon. No effect when the build omits the Web Shell assets (the daemon logs a breadcrumb and runs API-only). |
|
||||
| `--open` | `false` | After the listener is up, open the Web Shell in your default browser at the daemon URL (with `#token=` appended as a URL fragment when a token is configured — a fragment is never sent to the server, keeping the token out of access logs and Referer headers). No-op with `--no-web`, or in headless / CI / SSH environments where no browser is available. |
|
||||
| `--open-with-auth` | `false` | Open the Web Shell with bearer authentication on loopback. Requires an enabled Web Shell and built assets. Reuses a configured token or generates a process-lifetime 256-bit bearer and delivers it in the Web Shell fragment. Browser-ineligible environments print the secret-bearing manual URL. Other clients need the same explicitly configured shared token instead. |
|
||||
|
||||
> **Memory project scope caveats.**
|
||||
>
|
||||
|
||||
@@ -40,6 +40,8 @@ Shell be framed by the extension (the `frame-ancestors` CSP) and accepts the
|
||||
extension's requests. The side panel reads the id at runtime via
|
||||
`chrome.runtime.id`, so you never have to look it up.
|
||||
|
||||
Do not replace this command with `--open-with-auth`. That mode delivers its generated bearer only to the tab it opens; the extension cannot discover it. To protect a daemon used by the extension, set `QWEN_SERVER_TOKEN` explicitly and configure the same stable token in every authorized client.
|
||||
|
||||
Once the daemon is reachable and permits framing, the side panel swaps the
|
||||
welcome screen for the chat UI automatically.
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ import { maybeOpenWebShellBrowser, serveCommand } from './serve.js';
|
||||
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
|
||||
const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true));
|
||||
const mockRunQwenServe = vi.hoisted(() => vi.fn());
|
||||
const mockApplyOpenWithAuth = vi.hoisted(() =>
|
||||
vi.fn((options: { token?: string }) => {
|
||||
options.token ??= 'generated-token';
|
||||
}),
|
||||
);
|
||||
const mockQr = vi.hoisted(() => ({
|
||||
generate: vi.fn(
|
||||
(
|
||||
@@ -38,6 +43,9 @@ vi.mock('qrcode-terminal', () => ({ default: mockQr }));
|
||||
vi.mock('../serve/run-qwen-serve.js', () => ({
|
||||
runQwenServe: mockRunQwenServe,
|
||||
}));
|
||||
vi.mock('../serve/open-with-auth.js', () => ({
|
||||
applyOpenWithAuth: mockApplyOpenWithAuth,
|
||||
}));
|
||||
|
||||
function buildParser(): Argv {
|
||||
return (serveCommand.builder as (argv: Argv) => Argv)(
|
||||
@@ -46,6 +54,23 @@ function buildParser(): Argv {
|
||||
}
|
||||
|
||||
describe('serve command args', () => {
|
||||
it('defaults authenticated open to disabled', () => {
|
||||
const parsed = buildParser().parseSync('');
|
||||
expect(parsed['open-with-auth']).toBe(false);
|
||||
});
|
||||
|
||||
it('parses standalone --open-with-auth', () => {
|
||||
const parsed = buildParser().parseSync('--open-with-auth');
|
||||
expect(parsed['open']).toBe(false);
|
||||
expect(parsed['open-with-auth']).toBe(true);
|
||||
});
|
||||
|
||||
it('allows redundant --open with --open-with-auth', () => {
|
||||
const parsed = buildParser().parseSync('--open --open-with-auth');
|
||||
expect(parsed['open']).toBe(true);
|
||||
expect(parsed['open-with-auth']).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --enable-session-shell', () => {
|
||||
const parsed = buildParser().parseSync('--enable-session-shell');
|
||||
expect(parsed['enable-session-shell']).toBe(true);
|
||||
@@ -381,6 +406,49 @@ describe('serve rate limit env parsing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('applies authenticated open before the yargs path starts the daemon', async () => {
|
||||
let tokenAtBoot: string | undefined;
|
||||
mockRunQwenServe.mockImplementationOnce(
|
||||
async (options: { token?: string }) => {
|
||||
tokenAtBoot = options.token;
|
||||
return {
|
||||
url: 'http://127.0.0.1:4170/',
|
||||
webShellMounted: true,
|
||||
runtimeReady: Promise.resolve(),
|
||||
resolvedToken: 'generated-token',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await startServeHandlerWithArgs('--open-with-auth');
|
||||
|
||||
expect(mockApplyOpenWithAuth).toHaveBeenCalledWith(expect.any(Object));
|
||||
expect(tokenAtBoot).toBe('generated-token');
|
||||
});
|
||||
|
||||
it('prints the authenticated manual URL on the yargs headless path', async () => {
|
||||
mockShouldLaunchBrowser.mockReturnValue(false);
|
||||
const stderrWrites: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
mockRunQwenServe.mockResolvedValueOnce({
|
||||
url: 'http://127.0.0.1:4170/',
|
||||
webShellMounted: true,
|
||||
runtimeReady: Promise.resolve(),
|
||||
resolvedToken: 'generated-token',
|
||||
});
|
||||
|
||||
await startServeHandlerWithArgs('--open-with-auth');
|
||||
await vi.waitFor(() =>
|
||||
expect(stderrWrites.join('')).toContain(
|
||||
'http://127.0.0.1:4170/#token=generated-token',
|
||||
),
|
||||
);
|
||||
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits the journal caps for an unpinned boot so adaptive growth stays enabled', async () => {
|
||||
mockRunQwenServe.mockResolvedValueOnce({
|
||||
url: 'http://127.0.0.1:4170/',
|
||||
@@ -496,6 +564,41 @@ describe('serve rate limit env parsing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Local Control pairing separate from the temporary primary token', async () => {
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
const enable = vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
url: 'http://192.168.1.20:4170/#token=pairing-token',
|
||||
interfaceName: 'en0',
|
||||
sleepInhibited: false,
|
||||
encrypted: false,
|
||||
});
|
||||
mockRunQwenServe.mockResolvedValueOnce({
|
||||
url: 'http://127.0.0.1:4170/',
|
||||
webShellMounted: true,
|
||||
resolvedToken: 'generated-token',
|
||||
runtimeReady: Promise.resolve(),
|
||||
getLocalControl: () => ({ enable }),
|
||||
});
|
||||
|
||||
await startServeHandlerWithArgs('--local-control --open-with-auth');
|
||||
await vi.waitFor(() => expect(mockQr.generate).toHaveBeenCalled());
|
||||
|
||||
expect(mockRunQwenServe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ token: 'generated-token' }),
|
||||
);
|
||||
expect(mockQr.generate).toHaveBeenCalledWith(
|
||||
'http://192.168.1.20:4170/#token=pairing-token',
|
||||
{ small: true },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockQr.generate).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('generated-token'),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --token and --allow-origin through to runQwenServe with --local-control', async () => {
|
||||
mockRunQwenServe.mockResolvedValueOnce({
|
||||
url: 'https://127.0.0.1/',
|
||||
@@ -827,6 +930,30 @@ describe('maybeOpenWebShellBrowser', () => {
|
||||
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints a fragment URL for authenticated open in headless environments', async () => {
|
||||
mockShouldLaunchBrowser.mockReturnValue(false);
|
||||
const stderrWrites: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await maybeOpenWebShellBrowser(
|
||||
{
|
||||
url: 'http://127.0.0.1:4170/',
|
||||
webShellMounted: true,
|
||||
resolvedToken: 'temporary-secret',
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
|
||||
expect(stderrWrites.join('')).toContain(
|
||||
'http://127.0.0.1:4170/#token=temporary-secret',
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites a wildcard bind host to loopback', async () => {
|
||||
await maybeOpenWebShellBrowser(
|
||||
{ url: 'http://0.0.0.0:4170/', webShellMounted: true },
|
||||
@@ -880,6 +1007,30 @@ describe('maybeOpenWebShellBrowser', () => {
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('prints the authenticated URL when browser launch fails', async () => {
|
||||
mockOpenBrowserSecurely.mockRejectedValueOnce(new Error('boom'));
|
||||
const stderrWrites: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await maybeOpenWebShellBrowser(
|
||||
{
|
||||
url: 'http://127.0.0.1:4170/',
|
||||
webShellMounted: true,
|
||||
resolvedToken: 'temporary-secret',
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(stderrWrites.join('')).toContain(
|
||||
'Please open this URL manually: ' +
|
||||
'http://127.0.0.1:4170/#token=temporary-secret',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serve startup import boundary', () => {
|
||||
|
||||
@@ -120,8 +120,10 @@ async function startLocalControl(
|
||||
/**
|
||||
* Open the Web Shell in a browser once the daemon is listening. Extracted from
|
||||
* the `serve` handler so it is unit-testable. Best-effort:
|
||||
* - gated on `--open`, the UI actually being mounted (`webShellMounted`), and
|
||||
* `shouldLaunchBrowser()` (false in CI / SSH / headless);
|
||||
* - gated on `--open` and the UI actually being mounted
|
||||
* (`webShellMounted`); bare `--open` remains a no-op when
|
||||
* `shouldLaunchBrowser()` is false, while `--open-with-auth` prints a
|
||||
* manual URL instead;
|
||||
* - wildcard bind hosts (`0.0.0.0` / `[::]`) are rewritten to loopback so the
|
||||
* URL is client-addressable;
|
||||
* - the token rides in the URL fragment (`#token=`), which is never sent to
|
||||
@@ -140,8 +142,11 @@ export async function maybeOpenWebShellBrowser(
|
||||
runtimeReady?: Promise<void>;
|
||||
},
|
||||
open: boolean,
|
||||
manualFallbackWhenIneligible = false,
|
||||
): Promise<void> {
|
||||
if (!open || !handle.webShellMounted || !shouldLaunchBrowser()) return;
|
||||
if (!open || !handle.webShellMounted) return;
|
||||
const shouldLaunch = shouldLaunchBrowser();
|
||||
if (!shouldLaunch && !manualFallbackWhenIneligible) return;
|
||||
try {
|
||||
await handle.runtimeReady;
|
||||
} catch (runtimeErr) {
|
||||
@@ -152,14 +157,24 @@ export async function maybeOpenWebShellBrowser(
|
||||
);
|
||||
return;
|
||||
}
|
||||
let target: URL | undefined;
|
||||
try {
|
||||
const target = new URL(handle.url);
|
||||
target = new URL(handle.url);
|
||||
// Node's URL returns the IPv6 wildcard as `[::]` (bracketed), never `::`.
|
||||
if (target.hostname === '0.0.0.0' || target.hostname === '[::]') {
|
||||
target.hostname = '127.0.0.1';
|
||||
}
|
||||
if (handle.resolvedToken) {
|
||||
target.hash = `token=${encodeURIComponent(handle.resolvedToken)}`;
|
||||
}
|
||||
if (!shouldLaunch) {
|
||||
writeStderrLine(
|
||||
'Browser launch is not available in this environment. ' +
|
||||
`Please open this URL manually: ${target.toString()}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (handle.resolvedToken) {
|
||||
writeStderrLine(
|
||||
'qwen serve: --open passes the token in the browser launch command ' +
|
||||
'(visible via `ps` / /proc); on a multi-user host open the URL manually instead.',
|
||||
@@ -167,8 +182,12 @@ export async function maybeOpenWebShellBrowser(
|
||||
}
|
||||
await openBrowserSecurely(target.toString());
|
||||
} catch (browserErr) {
|
||||
const manualUrl =
|
||||
manualFallbackWhenIneligible && target
|
||||
? `. Please open this URL manually: ${target.toString()}`
|
||||
: '';
|
||||
writeStderrLine(
|
||||
`qwen serve: failed to open browser: ${browserErr instanceof Error ? browserErr.message : String(browserErr)}`,
|
||||
`qwen serve: failed to open browser: ${browserErr instanceof Error ? browserErr.message : String(browserErr)}${manualUrl}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -193,6 +212,7 @@ interface ServeArgs {
|
||||
'tls-key'?: string;
|
||||
web: boolean;
|
||||
open: boolean;
|
||||
'open-with-auth': boolean;
|
||||
'local-control': boolean;
|
||||
'local-control-address'?: string;
|
||||
// Read from the kebab-case key only — the camelCase mirror that yargs
|
||||
@@ -362,6 +382,12 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
description:
|
||||
'Open the Web Shell in a browser once the daemon is listening. With a token configured, the launch URL (token included) is handed to the browser launcher and is visible in the process list, so prefer opening the URL manually on multi-user hosts. No-op with --no-web, when the UI assets are absent, or in headless/CI/SSH environments.',
|
||||
})
|
||||
.option('open-with-auth', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Open the Web Shell with bearer authentication on loopback. Reuse --token or QWEN_SERVER_TOKEN, or generate a temporary 256-bit token and deliver it in the URL fragment. In headless environments, print the fragment URL for manual opening.',
|
||||
})
|
||||
.option('local-control', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
@@ -811,12 +837,14 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
const externalToolGuardToken =
|
||||
process.env[EXTERNAL_TOOL_GUARD_TOKEN_ENV] ?? '';
|
||||
delete process.env[EXTERNAL_TOOL_GUARD_TOKEN_ENV];
|
||||
const openWithAuth = argv['open-with-auth'];
|
||||
const open = argv.open || openWithAuth;
|
||||
|
||||
// Lazy-load the slim serve runner so the yargs fallback path does not pull
|
||||
// the public serve barrel, which also exports REST/ACP runtime modules.
|
||||
const { runQwenServe } = await import('../serve/run-qwen-serve.js');
|
||||
try {
|
||||
const handle = await runQwenServe({
|
||||
const serveOptions = {
|
||||
port: argv.port,
|
||||
hostname: argv.hostname,
|
||||
token: argv.token,
|
||||
@@ -910,7 +938,14 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
? { restoreAskUserQuestion: true }
|
||||
: {}),
|
||||
...(channelSelection !== undefined ? { channelSelection } : {}),
|
||||
});
|
||||
} satisfies Parameters<typeof runQwenServe>[0];
|
||||
if (openWithAuth) {
|
||||
const { applyOpenWithAuth } = await import(
|
||||
'../serve/open-with-auth.js'
|
||||
);
|
||||
applyOpenWithAuth(serveOptions);
|
||||
}
|
||||
const handle = await runQwenServe(serveOptions);
|
||||
// Open the Web Shell in a browser once the listener is up (best-effort;
|
||||
// never throws — see maybeOpenWebShellBrowser).
|
||||
if (argv['local-control']) {
|
||||
@@ -924,7 +959,7 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
await maybeOpenWebShellBrowser(handle, argv.open);
|
||||
await maybeOpenWebShellBrowser(handle, open, openWithAuth);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
||||
@@ -16,6 +16,7 @@ const originalSystemDefaultsPath =
|
||||
process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'];
|
||||
const originalTrustedFoldersPath =
|
||||
process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'];
|
||||
const originalServerToken = process.env['QWEN_SERVER_TOKEN'];
|
||||
|
||||
describe('serve fast path --open import boundary', () => {
|
||||
let tempQwenHome: string | undefined;
|
||||
@@ -43,6 +44,7 @@ describe('serve fast path --open import boundary', () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock('./run-qwen-serve.js');
|
||||
vi.doUnmock('../commands/serve.js');
|
||||
vi.doUnmock('./open-with-auth.js');
|
||||
vi.resetModules();
|
||||
if (originalQwenHome === undefined) {
|
||||
delete process.env['QWEN_HOME'];
|
||||
@@ -67,6 +69,11 @@ describe('serve fast path --open import boundary', () => {
|
||||
process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] =
|
||||
originalTrustedFoldersPath;
|
||||
}
|
||||
if (originalServerToken === undefined) {
|
||||
delete process.env['QWEN_SERVER_TOKEN'];
|
||||
} else {
|
||||
process.env['QWEN_SERVER_TOKEN'] = originalServerToken;
|
||||
}
|
||||
if (tempQwenHome) {
|
||||
fs.rmSync(tempQwenHome, { recursive: true, force: true });
|
||||
tempQwenHome = undefined;
|
||||
@@ -80,7 +87,7 @@ describe('serve fast path --open import boundary', () => {
|
||||
const runtimeReady = new Promise<void>((resolve) => {
|
||||
resolveRuntime = resolve;
|
||||
});
|
||||
const runQwenServe = vi.fn(async () => ({
|
||||
const runQwenServe = vi.fn(async (_options: unknown, _deps?: unknown) => ({
|
||||
runtimeReady,
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -116,6 +123,149 @@ describe('serve fast path --open import boundary', () => {
|
||||
expect(serveCommandImported).toBe(true);
|
||||
});
|
||||
|
||||
it('does not import the authenticated-open helper for bare --open', async () => {
|
||||
useTempQwenHome();
|
||||
|
||||
const runtimeReady = new Promise<void>(() => undefined);
|
||||
const runQwenServe = vi.fn(
|
||||
async (_options: { token?: string }, _deps?: unknown) => ({
|
||||
runtimeReady,
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
let openWithAuthImported = false;
|
||||
vi.doMock('./run-qwen-serve.js', () => ({ runQwenServe }));
|
||||
vi.doMock('./open-with-auth.js', () => {
|
||||
openWithAuthImported = true;
|
||||
return { applyOpenWithAuth: vi.fn() };
|
||||
});
|
||||
|
||||
const { tryRunServeFastPath } = await import('./fast-path.js');
|
||||
void tryRunServeFastPath(['serve', '--open', '--no-web']);
|
||||
|
||||
await vi.waitFor(() => expect(runQwenServe).toHaveBeenCalledTimes(1));
|
||||
expect(openWithAuthImported).toBe(false);
|
||||
});
|
||||
|
||||
it('applies authenticated open before starting the daemon', async () => {
|
||||
useTempQwenHome();
|
||||
|
||||
const runtimeReady = new Promise<void>(() => undefined);
|
||||
let tokenAtBoot: string | undefined;
|
||||
const runQwenServe = vi.fn(
|
||||
async (options: { token?: string }, _deps?: unknown) => {
|
||||
tokenAtBoot = options.token;
|
||||
return {
|
||||
runtimeReady,
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
},
|
||||
);
|
||||
const applyOpenWithAuth = vi.fn((options: { token?: string }) => {
|
||||
options.token = 'generated-token';
|
||||
});
|
||||
vi.doMock('./run-qwen-serve.js', () => ({ runQwenServe }));
|
||||
vi.doMock('./open-with-auth.js', () => ({
|
||||
applyOpenWithAuth,
|
||||
}));
|
||||
|
||||
const { tryRunServeFastPath } = await import('./fast-path.js');
|
||||
void tryRunServeFastPath(['serve', '--open-with-auth']);
|
||||
|
||||
await vi.waitFor(() => expect(runQwenServe).toHaveBeenCalledTimes(1));
|
||||
expect(applyOpenWithAuth).toHaveBeenCalledOnce();
|
||||
expect(tokenAtBoot).toBe('generated-token');
|
||||
expect(runQwenServe).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ deferRuntimeUntilFirstHealth: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads settings environment before applying authenticated open', async () => {
|
||||
useTempQwenHome();
|
||||
fs.writeFileSync(
|
||||
path.join(tempQwenHome!, 'settings.json'),
|
||||
JSON.stringify({ env: { QWEN_SERVER_TOKEN: 'from-settings' } }),
|
||||
);
|
||||
delete process.env['QWEN_SERVER_TOKEN'];
|
||||
|
||||
const runtimeReady = new Promise<void>(() => undefined);
|
||||
const runQwenServe = vi.fn(
|
||||
async (_options: { token?: string }, _deps?: unknown) => ({
|
||||
runtimeReady,
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
const applyOpenWithAuth = vi.fn(() => {
|
||||
expect(process.env['QWEN_SERVER_TOKEN']).toBe('from-settings');
|
||||
});
|
||||
vi.doMock('./run-qwen-serve.js', () => ({ runQwenServe }));
|
||||
vi.doMock('./open-with-auth.js', () => ({
|
||||
applyOpenWithAuth,
|
||||
}));
|
||||
|
||||
const { tryRunServeFastPath } = await import('./fast-path.js');
|
||||
void tryRunServeFastPath(['serve', '--open-with-auth']);
|
||||
|
||||
await vi.waitFor(() => expect(runQwenServe).toHaveBeenCalledTimes(1));
|
||||
expect(applyOpenWithAuth).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('exits before listen when authenticated-open preparation fails', async () => {
|
||||
useTempQwenHome();
|
||||
|
||||
const runQwenServe = vi.fn();
|
||||
vi.doMock('./run-qwen-serve.js', () => ({ runQwenServe }));
|
||||
vi.doMock('./open-with-auth.js', () => ({
|
||||
applyOpenWithAuth: vi.fn(() => {
|
||||
throw new Error('--open-with-auth requires a loopback --hostname.');
|
||||
}),
|
||||
}));
|
||||
const stderrWrites: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
||||
throw new Error(`process.exit ${code}`);
|
||||
}) as typeof process.exit);
|
||||
|
||||
const { tryRunServeFastPath } = await import('./fast-path.js');
|
||||
await expect(
|
||||
tryRunServeFastPath(['serve', '--open-with-auth']),
|
||||
).rejects.toThrow('process.exit 1');
|
||||
|
||||
expect(runQwenServe).not.toHaveBeenCalled();
|
||||
expect(stderrWrites.join('')).toContain(
|
||||
'--open-with-auth requires a loopback --hostname.',
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards authenticated manual fallback to the browser opener', async () => {
|
||||
useTempQwenHome();
|
||||
|
||||
const handle = {
|
||||
runtimeReady: Promise.resolve(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const runQwenServe = vi.fn(async () => handle);
|
||||
const openBrowser = vi.fn(async () => undefined);
|
||||
vi.doMock('./run-qwen-serve.js', () => ({ runQwenServe }));
|
||||
vi.doMock('./open-with-auth.js', () => ({
|
||||
applyOpenWithAuth: vi.fn(),
|
||||
}));
|
||||
vi.doMock('../commands/serve.js', () => ({
|
||||
maybeOpenWebShellBrowser: openBrowser,
|
||||
}));
|
||||
|
||||
const { tryRunServeFastPath } = await import('./fast-path.js');
|
||||
void tryRunServeFastPath(['serve', '--open-with-auth']);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(openBrowser).toHaveBeenCalledWith(handle, true, true),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips importing the full serve command opener when runtime startup fails', async () => {
|
||||
useTempQwenHome();
|
||||
|
||||
|
||||
@@ -536,6 +536,7 @@ describe('serve fast path argument parsing', () => {
|
||||
|
||||
expect(parsed).toEqual({
|
||||
kind: 'serve',
|
||||
openWithAuth: false,
|
||||
httpBridge: true,
|
||||
open: false,
|
||||
options: {
|
||||
@@ -691,6 +692,7 @@ describe('serve fast path argument parsing', () => {
|
||||
['tls-key', ['--tls-key', '/tmp/key.pem']],
|
||||
['web', ['--no-web']],
|
||||
['open', ['--open']],
|
||||
['open-with-auth', ['--open-with-auth']],
|
||||
['local-control', ['--local-control']],
|
||||
['local-control-address', ['--local-control-address', '192.168.1.2']],
|
||||
['http-bridge', ['--no-http-bridge']],
|
||||
@@ -861,6 +863,26 @@ describe('serve fast path argument parsing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps authenticated open on the fast path', () => {
|
||||
const parsed = parseServeFastPathArgs(['serve', '--open-with-auth']);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
kind: 'serve',
|
||||
open: true,
|
||||
openWithAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets authenticated open imply --open regardless of --no-open', () => {
|
||||
expect(
|
||||
parseServeFastPathArgs(['serve', '--open-with-auth', '--no-open']),
|
||||
).toMatchObject({
|
||||
kind: 'serve',
|
||||
open: true,
|
||||
openWithAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false to let the full CLI handle fallback cases', async () => {
|
||||
await expect(tryRunServeFastPath(['serve', '--help'])).resolves.toBe(false);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ type McpBudgetMode = NonNullable<ServeOptions['mcpBudgetMode']>;
|
||||
interface ParsedServeFastPath {
|
||||
kind: 'serve';
|
||||
open: boolean;
|
||||
openWithAuth: boolean;
|
||||
httpBridge: boolean;
|
||||
options: ServeOptions;
|
||||
}
|
||||
@@ -76,12 +77,13 @@ const STRING_OPTION_BY_FLAG = new Map<string, keyof ServeOptions>([
|
||||
|
||||
const BOOLEAN_OPTION_BY_FLAG = new Map<
|
||||
string,
|
||||
keyof ServeOptions | 'open' | 'http-bridge'
|
||||
keyof ServeOptions | 'open' | 'open-with-auth' | 'http-bridge'
|
||||
>([
|
||||
['require-auth', 'requireAuth'],
|
||||
['enable-session-shell', 'enableSessionShell'],
|
||||
['web', 'serveWebShell'],
|
||||
['open', 'open'],
|
||||
['open-with-auth', 'open-with-auth'],
|
||||
['http-bridge', 'http-bridge'],
|
||||
['allow-private-auth-base-url', 'allowPrivateAuthBaseUrl'],
|
||||
['experimental-lsp', 'experimentalLsp'],
|
||||
@@ -336,6 +338,7 @@ export function parseServeFastPathArgs(
|
||||
port: 4170,
|
||||
};
|
||||
let open = false;
|
||||
let openWithAuth = false;
|
||||
let httpBridge = true;
|
||||
let mcpBudgetModeRaw: string | undefined;
|
||||
let mcpClientBudget: number | undefined;
|
||||
@@ -365,6 +368,8 @@ export function parseServeFastPathArgs(
|
||||
}
|
||||
if (booleanTarget === 'open') {
|
||||
open = value;
|
||||
} else if (booleanTarget === 'open-with-auth') {
|
||||
openWithAuth = value;
|
||||
} else if (booleanTarget === 'http-bridge') {
|
||||
httpBridge = value;
|
||||
} else {
|
||||
@@ -483,12 +488,19 @@ export function parseServeFastPathArgs(
|
||||
options.rateLimit = explicitRateLimit;
|
||||
}
|
||||
applyRateLimitEnvDefaults(options, env);
|
||||
return { kind: 'serve', open, httpBridge, options };
|
||||
return {
|
||||
kind: 'serve',
|
||||
open: open || openWithAuth,
|
||||
openWithAuth,
|
||||
httpBridge,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeOpenWebShellBrowser(
|
||||
handle: RunHandle,
|
||||
open: boolean,
|
||||
openWithAuth: boolean,
|
||||
): Promise<void> {
|
||||
if (!open) return;
|
||||
try {
|
||||
@@ -499,7 +511,7 @@ async function maybeOpenWebShellBrowser(
|
||||
const { maybeOpenWebShellBrowser: openBrowser } = await import(
|
||||
'../commands/serve.js'
|
||||
);
|
||||
await openBrowser(handle, true);
|
||||
await openBrowser(handle, true, openWithAuth);
|
||||
}
|
||||
|
||||
function emitHeadlessYoloWarning(
|
||||
@@ -583,6 +595,18 @@ export async function tryRunServeFastPath(
|
||||
|
||||
writeServeWarnings(parsed);
|
||||
|
||||
if (parsed.openWithAuth) {
|
||||
try {
|
||||
const { applyOpenWithAuth } = await import('./open-with-auth.js');
|
||||
applyOpenWithAuth(parsed.options);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const { runQwenServe } = await import('./run-qwen-serve.js');
|
||||
let handle: RunHandle;
|
||||
try {
|
||||
@@ -596,7 +620,7 @@ export async function tryRunServeFastPath(
|
||||
} catch {
|
||||
// Keep the warning best-effort, matching the yargs serve handler.
|
||||
}
|
||||
await maybeOpenWebShellBrowser(handle, parsed.open);
|
||||
await maybeOpenWebShellBrowser(handle, parsed.open, parsed.openWithAuth);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ServeOptions } from './types.js';
|
||||
import { applyOpenWithAuth } from './open-with-auth.js';
|
||||
|
||||
const mockResolveWebShellDir = vi.hoisted(() =>
|
||||
vi.fn<() => string | undefined>(() => '/tmp/web-shell'),
|
||||
);
|
||||
|
||||
vi.mock('./web-shell-resolver.js', () => ({
|
||||
resolveWebShellDir: mockResolveWebShellDir,
|
||||
}));
|
||||
|
||||
function options(overrides: Partial<ServeOptions> = {}): ServeOptions {
|
||||
return {
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
port: 4170,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const originalServerToken = process.env['QWEN_SERVER_TOKEN'];
|
||||
|
||||
describe('applyOpenWithAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveWebShellDir.mockReturnValue('/tmp/web-shell');
|
||||
delete process.env['QWEN_SERVER_TOKEN'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalServerToken === undefined) {
|
||||
delete process.env['QWEN_SERVER_TOKEN'];
|
||||
} else {
|
||||
process.env['QWEN_SERVER_TOKEN'] = originalServerToken;
|
||||
}
|
||||
});
|
||||
|
||||
it('generates a 256-bit base64url token without mutating the environment', () => {
|
||||
const serveOptions = options();
|
||||
const stderrWrites: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
applyOpenWithAuth(serveOptions);
|
||||
expect(serveOptions.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(Buffer.from(serveOptions.token!, 'base64url')).toHaveLength(32);
|
||||
expect(process.env['QWEN_SERVER_TOKEN']).toBeUndefined();
|
||||
expect(stderrWrites.join('')).toContain(
|
||||
'temporary bearer authentication enabled',
|
||||
);
|
||||
expect(stderrWrites.join('')).not.toContain(serveOptions.token);
|
||||
});
|
||||
|
||||
it('preserves an explicit option token over the environment', () => {
|
||||
process.env['QWEN_SERVER_TOKEN'] = 'env-token';
|
||||
const serveOptions = options({ token: ' option-token ' });
|
||||
|
||||
applyOpenWithAuth(serveOptions);
|
||||
|
||||
expect(serveOptions.token).toBe('option-token');
|
||||
});
|
||||
|
||||
it('preserves an environment token when no option is set', () => {
|
||||
process.env['QWEN_SERVER_TOKEN'] = ' env-token ';
|
||||
const serveOptions = options();
|
||||
|
||||
applyOpenWithAuth(serveOptions);
|
||||
|
||||
expect(serveOptions.token).toBe('env-token');
|
||||
});
|
||||
|
||||
it('treats an explicitly whitespace-only option as absent after it shadows the environment', () => {
|
||||
process.env['QWEN_SERVER_TOKEN'] = 'env-token';
|
||||
const serveOptions = options({ token: ' ' });
|
||||
|
||||
applyOpenWithAuth(serveOptions);
|
||||
|
||||
expect(serveOptions.token).not.toBe('env-token');
|
||||
expect(serveOptions.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
options({ hostname: '0.0.0.0' }),
|
||||
'--open-with-auth requires a loopback --hostname.',
|
||||
],
|
||||
[
|
||||
options({ serveWebShell: false }),
|
||||
'--open-with-auth requires the Web Shell; omit --no-web.',
|
||||
],
|
||||
])('rejects an ineligible invocation', (serveOptions, message) => {
|
||||
expect(() => applyOpenWithAuth(serveOptions)).toThrow(message);
|
||||
});
|
||||
|
||||
it('requires built Web Shell assets', () => {
|
||||
mockResolveWebShellDir.mockReturnValue(undefined);
|
||||
|
||||
expect(() => applyOpenWithAuth(options())).toThrow(
|
||||
'--open-with-auth requires built Web Shell assets.',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['localhost', 'LOCALHOST', '127.0.0.1', '127.0.0.2', '::1', '[::1]'])(
|
||||
'accepts the existing loopback bind %s',
|
||||
(hostname) => {
|
||||
const serveOptions = options({ hostname });
|
||||
applyOpenWithAuth(serveOptions);
|
||||
expect(serveOptions.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects non-loopback even when a token is already configured', () => {
|
||||
expect(() =>
|
||||
applyOpenWithAuth(
|
||||
options({ hostname: '192.168.1.2', token: 'configured' }),
|
||||
),
|
||||
).toThrow('--open-with-auth requires a loopback --hostname.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
import { isLoopbackBind } from './loopback-binds.js';
|
||||
import { resolveServeToken } from './serve-token.js';
|
||||
import type { ServeOptions } from './types.js';
|
||||
import { resolveWebShellDir } from './web-shell-resolver.js';
|
||||
|
||||
type OpenWithAuthOptions = Pick<
|
||||
ServeOptions,
|
||||
'hostname' | 'serveWebShell' | 'token'
|
||||
> & {
|
||||
requireWebShell?: boolean;
|
||||
};
|
||||
|
||||
export function applyOpenWithAuth(options: OpenWithAuthOptions): void {
|
||||
if (!isLoopbackBind(options.hostname)) {
|
||||
throw new Error('--open-with-auth requires a loopback --hostname.');
|
||||
}
|
||||
if (options.serveWebShell === false) {
|
||||
throw new Error('--open-with-auth requires the Web Shell; omit --no-web.');
|
||||
}
|
||||
if (!resolveWebShellDir()) {
|
||||
throw new Error('--open-with-auth requires built Web Shell assets.');
|
||||
}
|
||||
options.requireWebShell = true;
|
||||
|
||||
const configuredToken = resolveServeToken(options.token);
|
||||
if (configuredToken) {
|
||||
options.token = configuredToken;
|
||||
return;
|
||||
}
|
||||
|
||||
options.token = randomBytes(32).toString('base64url');
|
||||
writeStderrLine(
|
||||
'qwen serve: temporary bearer authentication enabled for this Web Shell ' +
|
||||
'launch; use an explicit shared token for additional clients.',
|
||||
);
|
||||
}
|
||||
@@ -149,7 +149,6 @@ const allowedProcessEnvAccesses = normalizeAllowances([
|
||||
'The serve entry point owns daemon bootstrap, feature flags, child-process defaults, and the launch-env loader scrub.',
|
||||
accesses: {
|
||||
'computed:EXTERNAL_TOOL_GUARD_TOKEN_ENV': 1,
|
||||
'computed:QWEN_SERVER_TOKEN_ENV': 1,
|
||||
'computed:QWEN_SERVE_CDP_TUNNEL_OVER_WS_ENV': 1,
|
||||
'computed:QWEN_SERVE_CLIENT_MCP_OVER_WS_ENV': 1,
|
||||
'computed:QWEN_SERVE_PROMPT_DEADLINE_MS_ENV': 1,
|
||||
@@ -164,6 +163,14 @@ const allowedProcessEnvAccesses = normalizeAllowances([
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'packages/cli/src/serve/serve-token.ts',
|
||||
{
|
||||
reason:
|
||||
'Daemon token selection defaults to the process-scoped QWEN_SERVER_TOKEN.',
|
||||
accesses: { 'computed:QWEN_SERVER_TOKEN_ENV': 1 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'packages/cli/src/serve/sandbox.ts',
|
||||
{
|
||||
|
||||
@@ -51,6 +51,7 @@ import * as qwenCore from '@qwen-code/qwen-code-core';
|
||||
import * as serverModule from './server.js';
|
||||
import * as webShellResolver from './web-shell-resolver.js';
|
||||
import * as webShellStatic from './web-shell-static.js';
|
||||
import { applyOpenWithAuth } from './open-with-auth.js';
|
||||
import * as settingsRuntime from '../config/settings.js';
|
||||
import * as environmentRuntime from '../config/environment.js';
|
||||
import * as trustedFoldersRuntime from '../config/trustedFolders.js';
|
||||
@@ -8406,6 +8407,35 @@ describe('runQwenServe Web Shell signals on RunHandle', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects before creating a listener when required Web Shell assets disappear after pre-check', async () => {
|
||||
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-ws-')));
|
||||
const resolveWebShellDir = vi
|
||||
.spyOn(webShellResolver, 'resolveWebShellDir')
|
||||
.mockReturnValueOnce('/tmp/web-shell')
|
||||
.mockReturnValueOnce(undefined);
|
||||
const httpServerFactory = vi.fn(() => {
|
||||
throw new Error('listener created');
|
||||
});
|
||||
const serveOptions = {
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge' as const,
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
};
|
||||
|
||||
applyOpenWithAuth(serveOptions);
|
||||
|
||||
await expect(
|
||||
runQwenServe(serveOptions, {
|
||||
bridge: makeFakeBridge(),
|
||||
httpServerFactory,
|
||||
}),
|
||||
).rejects.toThrow('--open-with-auth requires built Web Shell assets.');
|
||||
expect(resolveWebShellDir).toHaveBeenCalledTimes(2);
|
||||
expect(httpServerFactory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes the trimmed bearer token as resolvedToken', async () => {
|
||||
const handle = await bootHandle({ token: ' secret-token ' });
|
||||
try {
|
||||
|
||||
@@ -81,6 +81,7 @@ import { isDeepHealthQuery } from './health-query.js';
|
||||
import { isLoopbackBind } from './loopback-binds.js';
|
||||
import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js';
|
||||
import { resolveWebShellDir } from './web-shell-resolver.js';
|
||||
import { resolveServeToken } from './serve-token.js';
|
||||
import { acpChildExtraArgs } from './acp-child-extra-args.js';
|
||||
import {
|
||||
allowOriginCors,
|
||||
@@ -408,6 +409,7 @@ const WORKSPACE_SETTING_SCOPE =
|
||||
type RunQwenServeOptions = Omit<ServeOptions, 'token' | 'workspace'> & {
|
||||
token?: string;
|
||||
workspace?: string | string[];
|
||||
requireWebShell?: boolean;
|
||||
};
|
||||
type WorkspaceSettingsWrite =
|
||||
import('./workspace-service/types.js').WorkspaceSettingsWrite;
|
||||
@@ -2240,17 +2242,7 @@ async function runQwenServeImpl(
|
||||
};
|
||||
loggerLifecycle.scrubApplied(restoreScrubbedLoaderEnv);
|
||||
|
||||
// Trim both sources. Common gotcha: `export QWEN_SERVER_TOKEN=$(cat
|
||||
// token.txt)` keeps the file's trailing `\n` in the env value, so the
|
||||
// hashed-then-compared token never matches what well-behaved clients
|
||||
// send. Every request returns the generic 401 with no breadcrumb
|
||||
// pointing at the whitespace, and operators chase ghosts. Trim once
|
||||
// at boot so the comparison is over what humans intended to set.
|
||||
const rawToken = optsIn.token ?? process.env[QWEN_SERVER_TOKEN_ENV];
|
||||
const token =
|
||||
typeof rawToken === 'string' && rawToken.trim().length > 0
|
||||
? rawToken.trim()
|
||||
: undefined;
|
||||
const token = resolveServeToken(optsIn.token);
|
||||
const channelDeliveryDiagnosticRedaction: WorkerDiagnosticRedactionOptions = {
|
||||
workerEnv: daemonRuntimeBaseEnv,
|
||||
...(token ? { daemonToken: token } : {}),
|
||||
@@ -3126,6 +3118,9 @@ async function runQwenServeImpl(
|
||||
// with a breadcrumb rather than failing the boot.
|
||||
const webShellDir =
|
||||
opts.serveWebShell === false ? undefined : resolveWebShellDir();
|
||||
if (optsIn.requireWebShell && !webShellDir) {
|
||||
throw new Error('--open-with-auth requires built Web Shell assets.');
|
||||
}
|
||||
if (opts.serveWebShell !== false) {
|
||||
if (!webShellDir) {
|
||||
writeStderrLine(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveServeToken } from './serve-token.js';
|
||||
|
||||
describe('resolveServeToken', () => {
|
||||
it('prefers and trims the CLI option', () => {
|
||||
expect(resolveServeToken(' from-option ', 'from-env')).toBe(
|
||||
'from-option',
|
||||
);
|
||||
});
|
||||
|
||||
it('trims the environment fallback', () => {
|
||||
expect(resolveServeToken(undefined, ' from-env\n')).toBe('from-env');
|
||||
});
|
||||
|
||||
it('keeps an explicitly empty option ahead of the environment', () => {
|
||||
expect(resolveServeToken('', 'from-env')).toBeUndefined();
|
||||
expect(resolveServeToken(' ', 'from-env')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { QWEN_SERVER_TOKEN_ENV } from './channel-worker-env.js';
|
||||
|
||||
export function resolveServeToken(
|
||||
optionToken: string | undefined,
|
||||
environmentToken: string | undefined = process.env[QWEN_SERVER_TOKEN_ENV],
|
||||
): string | undefined {
|
||||
// Select before trimming so an explicitly supplied empty option continues
|
||||
// to shadow the environment, matching the existing daemon contract.
|
||||
const selected = optionToken ?? environmentToken;
|
||||
const trimmed = selected?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
Reference in new issue
Block a user