docs/design/2026-08-28-trusted-loopback-full-api.md
qwen serveorigin/main at f470b122fa22 (2026-08-28)Treat the primary listener of a tokenless loopback daemon as an
operator-authorized surface. In that mode, every primary-listener operator API
is available without a bearer token, including routes currently registered
with mutate({ strict: true }), subject to its existing feature, trust,
ownership, permission, and input checks.
This is authorization by deployment boundary, not credential authentication. An open loopback request must not be stamped as bearer-authenticated. The code must retain the distinction between these two facts:
requestWasAuthenticated(req): the request presented a valid credential for
the listener on which it arrived.The mode is active only when all three conditions hold:
loopback bind AND no resolved runtime token AND requireAuth is false
Non-loopback listeners, --require-auth, the Local Control LAN listener,
channel webhook authentication, workspace trust, permission mediation,
client/session ownership, and explicit feature switches retain their current
checks.
qwen serve is primarily used as a local companion process. Its existing
loopback default is nevertheless split into two inconsistent authority tiers:
401 { code: "token_required" };--enable-session-shell;That split means a local Web Shell or SDK can create and drive sessions but cannot perform many settings, file, skill, tool, MCP, authentication, trust, workspace, or session-management operations unless the operator separately creates and distributes a bearer token.
The current strict surface is not a small list that should be edited route by
route. At the baseline above, 133 production registrations across 27 files use
mutate({ strict: true }). The central mutation gate is therefore the correct
policy seam.
runQwenServe()'s bearer requirement for non-loopback primary
listeners and for explicitly hardened loopback deployments.qwen serve, qwen serve --open, and direct tokenless local
clients simple: they do not need token discovery or browser credential
handoff.--open generate a token. --open-with-auth already serves that
explicit hardened-launch use case.mutate({ strict: true }) markers or weakening them for embedded,
non-loopback, or LAN requests.--enable-session-shell remains required.The original daemon implementation in #3889, authored by Shaojin Wen,
introduced QWEN_SERVER_TOKEN. A configured runtime token protects the
primary REST and WebSocket surfaces. runQwenServe() refuses a non-loopback
bind without a token. On loopback, no token leaves the primary listener open
for developer convenience.
Bearer values are opaque credentials. Plain HTTP exposes them to any process or network observer that can inspect the connection; the current non-loopback guidance therefore still needs TLS or a trusted terminating proxy. Removing the token from a loopback deployment avoids token distribution, not the need to define who can connect.
#4236 added createMutationGate({ tokenConfigured, requireAuth }) and the
--require-auth option. The gate deliberately allowed non-strict requests on a
tokenless loopback daemon but returned token_required for strict routes.
Subsequent APIs reused that central gate.
The marker now covers more than HTTP verbs normally described as mutations.
Some sensitive reads and control operations are strict too. The design must
therefore preserve the marker as a policy annotation rather than infer
authority from GET versus POST.
#9106 moved Local Control into the daemon. The primary listener accepts only the runtime token; the LAN listener accepts only revocable pairing tokens. The same Express app serves both listeners, and the HTTP server object is tagged so middleware can resolve the listener for every request. WebSocket upgrades repeat the same Host, Origin, and credential scoping because they bypass Express middleware.
On a tokenless daemon, the Local Control status and enable responses currently
redact the pairing URL unless requestWasAuthenticated(req) is true. That
prevents an arbitrary local process from minting and exporting LAN authority
under the current model, in which strict APIs do not trust arbitrary local
processes.
This proposal intentionally changes that local-process model. Once every local primary request is operator-authorized, hiding the pairing secret only makes a first-party tokenless Web Shell incomplete; it no longer protects a boundary the daemon claims to enforce. The LAN listener itself remains closed until a pairing credential is presented.
#9738 added loopback-only --open-with-auth. It reuses or generates a runtime
bearer, passes it to the Web Shell in a URL fragment, and the Web Shell retains
it per tab in sessionStorage. This remains useful on a shared host or whenever
the operator wants every primary API request to prove possession of a
credential. It is not the default local workflow proposed here.
The trust decision is made from the actual bind address, not from a claim in an
HTTP header. runQwenServe() resolves localhost once, pins the listener to
that address, and verifies after listen that it is ::1 or in the complete
127.0.0.0/8 range before publishing the daemon. A direct createServeApp()
embed has no socket to verify, so its caller remains responsible for ensuring
that a declared loopback hostname is bound only to loopback.
Host-header and same-origin checks also admit the exact bound loopback address,
so valid 127.0.0.0/8 binds do not drift from the trusted-mode predicate while
unrelated authorities remain rejected.
The guarantee is deliberately no stronger than that. Loopback is not user or process identity. Another local account, a compromised browser process, a desktop networking proxy, or a container that can reach a host service may be able to call the daemon. The existing Host and Origin checks reduce DNS rebinding and browser cross-origin attacks, but they do not authenticate a local process.
The daemon already accepts host.docker.internal as a primary Host and
self-origin value. Docker documents that name as a way for containers to reach
host services. Operators must therefore treat reachable local containers and
desktop proxies as part of the trusted boundary, or use a token.
The migration rule is explicit:
QWEN_SERVER_TOKEN="<secret>" qwen serve --require-auth
Use this on shared workstations, CI hosts, remote development machines,
multi-tenant containers, or whenever local processes are not mutually trusted.
--open-with-auth remains the convenient single-browser version of the same
hardened posture.
Browsers may treat correctly resolved localhost origins as potentially
trustworthy so powerful web APIs can work without public TLS. That browser
classification provides neither process identity nor authorization to modify
the daemon. The daemon's trusted-loopback decision and its Host/Origin defenses
remain separate concepts.
requireAuth is false.--require-auth never activates trusted-loopback authority. When
a token exists, the existing bearer middleware authenticates the primary
listener before route policy. runQwenServe() continues to reject
--require-auth without a token before listen.runQwenServe() and never receives trusted-loopback authority in direct
createServeApp() use.--enable-session-shell remains a separate explicit operator decision.--allow-origin values without a token
are boot errors. Remote browser trust is not implied by local-process trust;
explicit browser-extension origins retain their existing tokenless path./health retain their current
pre-authentication behavior. --require-auth continues to protect
loopback /health.Add a small pure helper next to the serve authentication policy. It accepts already-resolved facts instead of reading the environment:
interface TrustedLoopbackModeInput {
loopbackBind: boolean;
tokenConfigured: boolean;
requireAuth: boolean;
}
function isTrustedLoopbackMode(input: TrustedLoopbackModeInput): boolean {
return input.loopbackBind && !input.tokenConfigured && !input.requireAuth;
}
runQwenServe() calls it after resolveServeToken() has normalized option and
environment input. createServeApp() calls it from opts.hostname, the same
non-empty-token rule it already uses, and opts.requireAuth. Keeping the helper
pure avoids a second token-resolution path and makes direct embeds testable.
createServeApp() rejects requireAuth: true without a token before assembling
the application. That configuration is invalid independently of the socket and
otherwise leaves non-strict routes open even though the public option claims
that every normal API route requires a bearer. The exported mutation gate also
treats the same inconsistent dependency as unauthenticated and fails strict
routes closed. Channel webhook ingress remains under its independent
shared-secret contract.
The app factory does not add the runner's non-loopback/no-token boot check. It
constructs an Express application and does not own the socket on which an
embedder may host it. A direct no-token embed declaring a non-loopback hostname
therefore keeps its existing non-strict REST behavior, but it does not receive
trusted-loopback authority: strict routes, direct session shell, and Local
Control pairing material remain unavailable. runQwenServe() remains the
documented deployment entry point that rejects a non-loopback/no-token
configuration before listen.
Do not infer the mode from requestWasAuthenticated(req) === false. A wrong or
missing bearer on a protected daemon is unauthenticated too, and must have
already been rejected by bearerAuth.
Add one request policy helper:
function requestHasOperatorAuthority(
req: Request,
trustedLoopbackMode: boolean,
): boolean {
return (
requestWasAuthenticated(req) ||
(trustedLoopbackMode && listenerIdentityOf(req).kind === 'primary')
);
}
Do not set AUTHENTICATED_REQUEST for open primary requests. That symbol means
a listener-scoped credential was verified and may remain useful for audit,
telemetry, or future client identity work. Authorization by deployment mode is
a different fact.
An untagged synthetic request continues to resolve as primary, matching the
existing listener-identity test convention. It receives local authority only
when the caller explicitly constructs the app in trusted-loopback mode.
Extend CreateMutationGateDeps with optional trustedLoopbackMode, defaulting
to false. The helper is exported today, so making omission fail closed
preserves source compatibility for existing direct callers without silently
granting them local authority. Its behavior becomes:
tokenConfigured mode: all requests reaching the route already
authenticated; return the cached passthrough handler;401 token_required.Only tokenConfigured may select the unconditional passthrough. The current
gate also treats requireAuth alone as proof that global bearer authentication
already ran, relying on runQwenServe() to reject the no-token combination.
That assumption is not valid for a direct createServeApp() caller. Stop using
the flag as an authentication fact: requireAuth: true with no token must fail
closed at strict routes even in an embedded app. The effective trusted flag is
also accepted only when tokenConfigured and requireAuth are both false, so
an inconsistent injected dependency cannot broaden access.
The implementation may return the cached passthrough for every route in
trusted-loopback mode only if the Local Control listener remains protected by
the global bearerAuth middleware. That is true today. Keeping an explicit
operator-authority strict handler is slightly more defensive for embedded apps
or future listener changes and makes the primary-versus-LAN invariant visible.
Prefer that explicit handler unless measurement shows it matters.
No mutate({ strict: true }) call site changes. The markers remain valuable if
the same application is hosted under a different deployment policy later.
Change effective shell enablement from:
enableSessionShell AND tokenConfigured
to:
enableSessionShell AND (tokenConfigured OR trustedLoopbackMode)
Apply the same value in both construction paths:
runQwenServe() for bootstrap capabilities and every workspace bridge;createServeApp() for direct app construction, REST registration, daemon
status, ACP initialization, and ACP dispatch.Remove the warning that says the flag is ignored merely because no token is configured. In trusted-loopback mode, emit no ignore warning because the flag is active. In a non-trusted direct embed with no token, it remains disabled.
The existing defenses remain mandatory: the route is still marked strict, a
valid session-bound X-Qwen-Client-Id is still required, the client must belong
to the session, workspace ownership/trust still applies, and the bridge still
checks the same effective boolean before execution.
Use requestHasOperatorAuthority() instead of
requestWasAuthenticated() when deciding whether Local Control status and
enable responses may include url and qrText. Thread the mode into the route
dependencies as an optional, fail-closed boolean so isolated route mounts that
omit it retain the current redaction behavior.
Results:
When the response contains the URL, the enable route no longer needs to print
that URL solely as a fallback for this case. The existing terminal output may
remain for CLI-initiated --local-control, but HTTP responses and terminal
logging must not print duplicate secret-bearing URLs.
The enable route stays primary-only. A paired LAN client may still disable Local Control but cannot enable or move it.
The loopback Host and same-origin accept sets gain the exact bound loopback
address so every supported 127.0.0.0/8 bind behaves like the canonical
loopback names. REST, Web Shell, and WebSocket checks mirror that narrow
widening. The enforcement mechanisms and remaining defenses stay unchanged:
Without a token, explicit HTTP(S) --allow-origin <origin> entries are limited
to loopback hosts. A remotely hosted browser origin must be paired with a
configured bearer token because otherwise that page could call the full API,
including executing code as the daemon user. The wildcard form remains
forbidden without a token. Explicit browser-extension origins retain their
existing tokenless local-automation path.
The optional Chrome DevTools MCP bridge is currently advertised and registered
only when no runtime token is configured, because its local /cdp connection
does not carry that bearer. Trusted-loopback mode already satisfies this path.
Do not rewrite this inverse token check as part of the authorization change.
Do not add a capability tag. Capability tags describe implemented protocol features, while trusted-loopback is a deployment policy. The existing daemon status facts are sufficient to derive it:
security.loopbackBind
&& !security.tokenConfigured
&& !security.requireAuth
session_shell_command and multi_workspace_session_shell are advertised when
the effective shell boolean is true, including the newly supported
tokenless-loopback combination.
require_auth remains present only when --require-auth is active. The
token_required error remains part of the protocol for older daemons,
non-trusted direct embeds, and defense-in-depth tests. First-party clients may
keep their existing error handling; successful calls in trusted-loopback mode
need no client change.
Update the tokenless startup breadcrumb so operators see the actual boundary:
qwen serve: trusted loopback mode; local callers have full API access without
bearer authentication, including code execution as the daemon user. Use
--require-auth with QWEN_SERVER_TOKEN on shared or untrusted hosts.
Keep the separate warning when client-hosted MCP over WebSocket is enabled without a token.
| Primary invocation | Primary API | Strict API | /health | Explicit session shell | Local Control LAN |
|---|---|---|---|---|---|
Loopback, no token, no --require-auth | Open | Open | Open | Enabled only with --enable-session-shell | Pairing token required |
| Loopback with token | Bearer required | Bearer required | Open | Enabled only with --enable-session-shell | Pairing token required |
Loopback with token and --require-auth | Bearer required | Bearer required | Bearer required | Enabled only with --enable-session-shell | Pairing token required |
Loopback, no token, --require-auth | Boot error | Boot error | Boot error | Boot error | Not started |
| Non-loopback with token | Bearer required | Bearer required | Bearer required | Enabled only with --enable-session-shell | Unavailable; primary must bind loopback |
| Non-loopback, no token | Boot error | Boot error | Boot error | Boot error | Not started |
--open-with-auth on loopback | Generated/reused bearer required | Generated/reused bearer required | Open unless --require-auth | Enabled only with --enable-session-shell | Pairing token required |
“Open” in this table means no daemon bearer is required. It does not bypass workspace trust, client ownership, permission, feature-enable, input, or resource checks.
Rejected. It edits 133 registrations across 27 production files, invites drift as new routes land, does not fix session-shell enablement or Local Control pairing presentation, and loses useful policy annotations for other deployment modes.
--openRejected. Local SDKs, curl, channel commands, and extensions cannot discover an
ephemeral browser token. This also duplicates --open-with-auth and does not
satisfy the requirement that a tokenless local client can perform every
operator action.
--unsafe-local-full-accessRejected. The desired product contract is that local execution is the normal
case. A second flag would preserve today's partial and surprising default.
The security opt-in already exists in the safer direction as --require-auth.
Rejected. No credential was presented. Forging the authentication marker would erase the distinction needed for listener-scoped credentials, audit semantics, and future client identity work.
Rejected for the first-party trusted-loopback mode. It would leave the Web Shell dependent on terminal access even after the design explicitly grants the same local caller every other operator capability. Redaction remains correct for any request without operator authority.
Rejected. Operators need a hardened local mode on shared hosts.
QWEN_SERVER_TOKEN and --require-auth must continue to override the trusted
default.
This is an intentional security and behavior change for one existing
invocation class: loopback, no token, and no --require-auth.
Before the change, local unauthenticated callers receive token_required from
strict routes and cannot activate direct session shell. After the change, those
callers receive the normal route result and an explicit shell opt-in becomes
effective.
Operators who relied on strict routes as a partial barrier against other local processes must migrate before upgrading:
export QWEN_SERVER_TOKEN="$(openssl rand -hex 32)"
qwen serve --require-auth
The concrete secret-generation mechanism is operator choice; documentation must not encourage placing a long-lived token directly in a process command line on multi-user systems.
First-party SDK method signatures do not change. Update stale SDK comments that
promise token_required on the default loopback path. Web Shell code that maps
token_required to a friendly authentication error should remain for older
daemons and non-trusted embeds.
No stored data or wire schema migration is required. Reverting the central policy restores the previous strict behavior.
The expected production changes are narrow:
packages/cli/src/serve/auth.ts: deployment-mode and operator-authority
helpers; mutation-gate dependency and behavior; comments and matrix.packages/cli/src/serve/loopback-binds.ts: recognize the complete supported
IPv4 loopback range and format bound addresses for authority comparisons.packages/cli/src/serve/server.ts: derive trusted-loopback mode once, pass it
to the mutation gate and Local Control routes, and use it for effective
session-shell enablement.packages/cli/src/serve/server/self-origin.ts and
packages/cli/src/serve/acp-http/index.ts: align REST/Web Shell and WebSocket
Host/Origin checks with the exact bound loopback address.packages/cli/src/serve/run-qwen-serve.ts: derive the same mode from the
resolved token; align bridge/bootstrap shell enablement and startup warning.packages/cli/src/serve/capabilities.ts: update the conditional session-shell
documentation; advertisement still consumes the existing effective boolean.packages/cli/src/serve/routes/workspace-local-control.ts: authorize pairing
material by operator authority instead of credential fact alone.packages/web-shell/client/components/channels/ChannelsManagerPage.tsx:
derive operator authority from the existing daemon security status when no
bearer is present, so trusted-loopback channel controls are not left
client-side read-only.packages/cli/src/serve/types.ts and
packages/acp-bridge/src/bridgeOptions.ts: update shell-policy documentation.token_required promise.No production strict-route file should change solely to adopt this design.
Cover the complete deployment predicate and request authority matrix:
requireAuth is trusted;requireAuth is not trusted-open;requireAuth without a token does not make the strict gate passthrough for a
direct embed;token_required;Do not duplicate all 133 registrations. Test the central gate directly, then exercise representative downstream categories on a tokenless loopback app:
Each representative test must assert the downstream handler was actually called, not merely that the response stopped being 401.
Existing route-unit tests that intentionally model an untrusted embedded app
may omit trustedLoopbackMode or pass false explicitly and retain their
denial assertions. Tests describing the ordinary loopback default must change
to success assertions. At the baseline, 34 token_required references across
11 test files require classification; they must not be mechanically flipped.
runQwenServe() with loopback, no token, and --enable-session-shell
advertises session_shell_command through bootstrap and runtime
capabilities.initialize advertises _qwen/session/shell in the same mode.session_shell_disabled and advertises no
shell capability.--require-auth without token still fails before listen;--allow-origin without token fails before
listen; loopback HTTP(S) and explicit browser-extension origins retain their
tokenless behavior;--open-with-auth continues to generate/reuse and deliver its bearer;qwen serve --port 0 --open without token configuration. In the real
Web Shell, perform file/settings/skill/MCP operations that previously
returned token_required.--enable-session-shell, create a session, and run a shell command
with its real client id; repeat without the flag and confirm rejection.--require-auth;
confirm anonymous /health, REST, and WebSocket calls are rejected.Implementation verification should include the focused CLI and ACP bridge unit tests, package builds and typechecks, lint/format checks, and the repository's CLI/interactive integration suites after bundling.
Ship the change with release notes that lead with the compatibility warning, not merely the convenience benefit. The tokenless startup breadcrumb makes the active trust boundary visible in terminals, service logs, and bug reports.
Release-note copy:
qwen servenow grants the token-less loopback primary listener full operator API access, including strict routes and explicitly enabled session shell. On shared hosts, CI runners, remote development machines, or environments with untrusted local processes, configureQWEN_SERVER_TOKENand start with--require-authbefore upgrading.
No gradual runtime flag is proposed: adding another default-off flag would
defeat the requested default. The change is still operationally reversible by
setting a token and --require-auth, and code-reversible through the one
central policy dependency.
For support diagnostics, GET /daemon/status already exposes the three facts
needed to identify the mode. Do not log token values, Authorization headers,
or Local Control fragment URLs beyond existing deliberate terminal handoff
paths.
--without-connection-token mode that should be used only when another
mechanism secures the connection. This supports keeping hardened and
trusted-local modes explicit and distinct.host.docker.internal for container-to-host service access. This
is why the trusted local boundary must explicitly mention reachable
containers and proxies.