qwen-code/docs/design/remote-web-shell-daemon.md
易良 dbe14695ab
feat(web-shell): connect saved daemons simultaneously, no-flash host switching (#13727) (#13739)
* feat(web-shell): connect saved daemons simultaneously, no-flash host switching (#13727)

The Web Shell no longer lives behind a single-daemon connection with
full-page switches. Saved hosts stay connected alongside the focused
daemon: the sidebar renders every host's live workspaces and sessions
(workspaces and session rows polled per host, offline/unauthorized hosts
degrade to their last-known snapshot with a muted badge), and the
composer workspace picker offers every connected host's directories in
host groups.

Focused-host switches inside the CSP-covered set are in-app: a
DaemonTargetProvider owns the focused origin as state, the interactive
provider remounts on it instead of reloading the document, and the URL
is rewritten in place ('?daemon=' + repeated '?fanout=' params, which
the server and the dev server now widen connect-src for). Intents that
cross a switch — a composer pick for another host, a remote session row
— travel as one-shot handoffs the remounted app drains once connected,
carrying the typed draft text and the target workspace/session. A newly
connected host still navigates once: only a fresh document can widen its
own CSP, and steady-state URLs already carry the full host set.

Security invariants kept: per-origin bearer tokens never cross hosts
(fan-out clients are read-only, one stable client per origin), remote
daemons still must --allow-origin the page, session/terminals stay on
the focused host (multi-provider streaming is phase 2 and explicitly
out of scope), and the interactive provider's deferred-dispose slot is
now a pool so rapid sequential remounts cannot leak a client. The
terminal WebSocket token lookup and no-arg auth header helpers remain
focused-host-only and are flagged as such.

Refs #13677 (restored aggregated sidebar) and #11475 (parent feature);
revises docs/design/remote-web-shell-daemon §非目标 in a follow-up.

Fixes #13727.

* fix(web-shell): reflect connection removal live in the fan-out view (#13727)

Removing a host from Settings > Connections cleared only the remote
connections catalog (and its token), so the sidebar's fan-out group for
that host survived until the next tab load: remote-connections never
emitted a change event for same-tab listeners, and the union feed
(useFanoutOrigins) re-read the still-present workspace-hosts entry.

Removal now clears the host's saved workspace catalog and its per-origin
token, both catalogs emit their change events, and the fan-out origin
feed, host client pool pruning, and the URL fanout-param sync all react
in the same tab. Deleting a workspace on a fan-out host stays as
designed: focus the host in-app (no reload) and use the ordinary
removal dialog there — fan-out clients are read-only by contract.

* feat(web-shell): mark remote workspace options with their host (#13727)

Host group headers alone left rows unattributable when a label appeared
under more than one host (e.g. two qwen-code checkouts on one daemon):
remote options now carry a truncated host suffix, and the tooltip names
origin plus full path, so every row in the composer picker identifies
its owning daemon. Page-origin rows stay unadorned.

* feat(web-shell): globe icon on remote picker rows (#13727)

Group headers alone did not register as remote marks: every remote
workspace option now leads with a globe icon beside the label, matching
the sidebar's remote badge semantics, and keeps the host suffix.

* fix(web-shell): use the sidebar's folder-and-globe badge in the picker (#13727)

My earlier outline-globe icon diverged from the established remote
badge: remote picker rows now render the same folder icon with the
blue Globe2 overlay the sidebar WorkspaceSection uses, sharing its
remote-workspace-folder-icon marker.

* fix(web-shell): disambiguate colliding workspace labels in the picker (#13727)

Two daemons registering the same basename left identical rows in the
composer picker (e.g. qwen-code from two checkouts on one host); labels
now gain a parent-directory suffix only when they collide in the list.

* feat(web-shell): offer workspace removal on fan-out host rows (#13727)

Watch the user hunt for a delete affordance and it is clear read-only
catalog rows fail the feature: remote workspace rows now carry the same
removal entrance the focused project's menu owns - the confirm dialog
runs against that host's own daemon REST (workspaceById().remove) under
its own per-origin token, and the group's capabilities refresh lands the
row out immediately. Chat/terminal/name-trust mutations stay
focused-host-only; removal is catalog management, which the open-groups
view exists to operate.

* fix(web-shell): use the workspace three-dot menu for remote row removal (#13727)

A bare trash icon invented a second affordance language; the remote rows
now use the same hover WorkspaceMenu the focused project's rows do,
with just the removal entry enabled. Trash affordance removed; dialog
flow and daemon-REST removal unchanged.

* fix(web-shell): stop polling storms in the fan-out sidebar, show pins (#13727)

Every successful capabilities poll bumped a generation token that fed
WorkspaceSection's reloadToken, so all fan-out sections ran their full
session catalog reload every 30 seconds - the visible sidebar flash and
order churn the click-through showed. Generation is now content-
addressed: unchanged snapshots keep the token; a 70 s steady-state shows
zero DOM churn. Fan-out session rows also render the pin marker (pins
live on each daemon, they were just invisible in the groups).

* feat(web-shell): fixed host rank and in-app deep-link switching (#13727)

The sidebar host groups now occupy a fixed flex order per origin - the
focused host stops grabbing the top slot on every switch, so opening a
session on another daemon repaints the same ranks and only moves the
'connected' badge to the focused heading. A global anchor interceptor
turns same-origin session links whose daemon is already CSP-covered
into in-app focused switches with an open handoff, instead of the hard
document reload that reshuffled all composer and sidebar state; anything
off-origin, uncovered, or same-daemon still follows the default browser
navigation.

* fix(web-shell): resolve focused origin without the URL constructor (#13727)

The removal/export sidebar suites run WebShellSidebar in an environment
an SDK import chain had already polluted, so new URL() died mid-render
in seven of them. Origins reaching the sidebar are plain origins
already, so resolve the focused origin directly instead of parsing one.

* fix(web-shell): reset the session surface on focused switches (#13727)

A focused-host swap could carry the previous host's /session/<id> (or
workspace params) into the new daemon, where that session does not
exist - rendering 'workspace not found' with 400s. Focus switches now
drop the session pathname and workspace/context params when they arrive
without a session of their own. The interactive provider keeps its key
remount: pet alternatives leak the stale workspace context since the
App connection state does not reset on client swap; a short connecting
transition is the correct surface until multi-provider switching
(phase 2) brings true smooth swaps.

* fix(web-shell): make same-host focus clicks fully inert (#13727)

Clicking the already-focused host (the 'connected' badge row or the
heading of the focused group) still rewrote the URL with workspace and
context deleted - harmless-looking param churn that the app read as a
session-surface reset and visibly refreshed the shell with. focusHost is
now a strict no-op when the target is already the active origin and
carries no workspace preselection; a covered covered-host switch is
unchanged.

* test(web-shell): tolerate the persistent ?fanout= param in URL assertions

Multi-daemon fan-out keeps `?fanout=` in the URL via replaceState so the next
document load's CSP still covers the saved hosts. Two pre-existing
remote-workspace-add specs asserted an exactly-clean URL and now fail
deterministically (3/3 attempts each):

  - :192 expected `/settings`, got `/settings?fanout=http%3A%2F%2F127.0.0.1%3A5199`
  - :322 expected `search === ''`, got `?fanout=http%3A%2F%2F127.0.0.1%3A5199`

Compare with the `fanout` param removed. Both assertions still catch a
leftover resume marker or credential, which is what they were written for.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmv1bewpnhw

* test(web-shell): scope the added-connection assertion to the saved chip

The multi-daemon workspaces surface now renders the remote host label in the
host group as well, so
`page.getByText('127.0.0.1:5199', { exact: true })` resolves to two elements and
web-shell E2E Smoke fails deterministically (3/3 attempts) with a Playwright
strict mode violation at
web-shell.remote-workspace-add.spec.ts:234 in "Settings adds a verified
computer and returns to Connections @smoke".

Scope the assertion to the saved-connection chip the test just created
(`button[title="<origin>"]`, rendered by DaemonStatusDialog). That is the
element this assertion matched before the workspaces surface started rendering
the same host label, so the check keeps its original meaning instead of being
weakened. Test-only change, no product code touched.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmv1fp89li2

* fix(web-shell): one shared fan-out poll per host, sync design doc (#13727)

Review follow-ups on #13739:

- Each fan-out host was polled by up to three independent 30 s pollers
  (App's useHostFanout, OtherHosts' useHostFanout whose return value was
  discarded, and every FanoutHostGroup's useHostCapabilities). Polling now
  lives in one module-level store keyed by origin: N hosts cost N
  capabilities() rounds per interval no matter how many components
  subscribe, the hook-called-for-its-side-effect call is gone, and hosts
  that lose their last subscriber drop their client. A regression test
  pins the one-round-per-host property (red with the old hooks).
- The activeToken doc comment promised "read per render so token edits
  re-key it", but the memo that computes it does not depend on the token.
  The comment now states what the code does: refreshed on focus/dependency
  changes, not on token edits.
- docs/design/remote-web-shell-daemon{,.zh-CN.md} still said switching
  performs a full page navigation and only the connected daemon is live.
  Both language versions now describe the shipped model (every saved host
  live in the sidebar, in-app focused switching, one navigation only for
  hosts outside the CSP-covered set) and document the ?fanout= URL/CSP
  contract, which was previously described nowhere.

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-10-10 09:00:56 +00:00

15 KiB

Remote Daemon Connections from Web Shell

English | 简体中文

Status: Web Shell milestone for #11475. Revised 2026-10-09: simultaneous live fan-out of every saved host, in-app focused switching, and the ?fanout= CSP contract land with #13727; supersedes the "one live host, navigation on selection" wording kept until then. Revised 2026-10-08: restores the aggregated workspace sidebar and supersedes the 2026-09-12 narrowing (e879557a), which had removed the multi-host project catalog and declared aggregation a non-goal.

Problem

Web Shell already sends workspace, session, file, SSE, and WebSocket requests through one daemon baseUrl, but its standalone entry point rejects an explicitly selected daemon on another origin. A Web Shell page therefore cannot connect directly to an already-running remote daemon.

Connecting to a remote daemon also hides every local project today: the sidebar lists only the connected daemon's workspaces, each row carries the same "remote" badge keyed on the connection, and nothing names the host. Users who added a remote directory expect the sidebar to keep their local folders visible next to it; instead every visible row reads "remote" and the way back to the local daemon is undiscoverable.

Goals

  • Let a Web Shell URL select one remote daemon with ?daemon=<origin>.
  • Let users enter or replace the daemon address and optional bearer token in Web Shell.
  • Remember successfully verified remote computers so workspace creation can reuse them.
  • Keep the remote daemon as the sole owner of its workspaces, sessions, files, terminals, and execution.
  • Preserve reconnect and session navigation on the selected daemon.
  • Keep bearer credentials isolated by daemon origin.
  • Keep local and remote project identities together in one sidebar: entries are keyed by daemon origin + workspace ID, the connected host's live project list comes first, and saved projects of other hosts are grouped underneath by host. Selecting a saved project navigates the page to its host and workspace.
  • Mark locality per row and per host: the connected host is named above the live list when any other host is known, saved remote entries use a server icon, local entries use a laptop icon, and the chat header chip names the host the conversation runs on.

Non-goals

  • Desktop integration, managed SSH, daemon installation, discovery, relay, federation, or virtual filesystems.
  • Live session streaming or execution from more than one daemon at once in a single Web Shell instance. Every saved host is live in the sidebar (workspaces and session catalogs poll each daemon), but chat streaming, terminals, and execution stay bound to the one focused daemon.
  • Starting or stopping an externally managed daemon.

Design

The connection address is an HTTP origin such as https://daemon.example.com, an internal-network endpoint such as http://10.0.0.8:4170, or, for a user-managed SSH tunnel, http://127.0.0.1:4170. Credentials, paths, query strings, and fragments are rejected so one address always identifies one daemon origin. HTTPS should be used outside trusted networks because HTTP exposes daemon traffic and bearer tokens in transit.

The standalone Web Shell reads the daemon query parameter and passes that origin to the existing DaemonWorkspaceProvider. Its existing SDK clients then send REST, SSE, file, session, and terminal WebSocket traffic directly to that daemon. Session navigation preserves the daemon parameter.

The pre-connection gate always exposes a daemon address and optional token form, including when the URL contains an invalid target. Once connected, the existing Daemon Status overview shows the current target and connection state and provides the same switch controls. Switching between hosts the current document already covers is in-app: the focused host is controller state, the interactive provider remounts on the new origin without reloading the document, and ?daemon= is rewritten in place. Connecting a host outside the covered set still performs one full page navigation, because only a freshly served document can widen its own CSP. Reconnecting to the target already in use reloads in place instead, so the selected session, workspace, and context survive it exactly as they survive a plain refresh. It does not probe or fall back to another runtime. The connection gate lists saved hosts so a user stranded on an unreachable or wrong daemon always has a way back to a known one.

The existing sidebar remains the workspace and session management UI, and it keeps project identities from the page's own daemon together with the remote hosts this browser has used. Identities are keyed by daemon origin + workspace ID. localStorage persists only identity fields — origin, workspace id, cwd, and displayName — never tokens or project contents. While more than one host is known, a heading above the live project list names the focused host: a laptop icon for the page's own daemon, a server icon plus hostname for a remote one. Every other known host renders below as its own live group, polled from that host's daemon with its own per-origin credential; a host that drops offline or rejects its credential keeps its last known rows behind a muted badge instead of falling back to its saved snapshot, and an unreachable host never deletes its saved projects. The page's own daemon group is always synthesized while focused cross-origin, so the local projects stay visible and selectable. Selecting any row on a covered host focuses that host in place; a host the document cannot reach still navigates once. Remote workspace rows in the live list keep the folder icon with the small blue globe when the host is cross-origin, and saved remote entries use the plain server icon, so local and remote folders stay visually distinct in both sections.

Every capabilities refresh of the connected daemon re-records that host's project identities into the saved-host catalog. While connected cross-origin, the shell additionally probes the page's own daemon with its origin-scoped token and refreshes its saved project list, so the local group is populated on first use and kept current thereafter; a failed probe keeps the previously saved list.

Settings includes a Connections category where remote computers are added, reviewed, forgotten, or selected. Adding a cross-origin computer temporarily navigates to that daemon so the existing connection gate can verify its capabilities and credential without weakening CSP; success or cancellation returns to the source shell with Settings > Connections reopened. Submitting an explicit connection form, from either Settings > Connections or Daemon Status, records the validated origin in a browser-local connection catalog; bearer tokens remain tab-scoped, and forgetting a connection removes its tab-scoped credential too. The normal Add workspace action opens the directory browser directly. Its Folder source selector lists this computer and the connected remote computers, matching the source-selection pattern used by Codex project creation without adding a separate Local/Remote step. Selecting a computer this tab is not already connected to navigates to that daemon and resumes the same directory browser; selecting the daemon already in use keeps it open in place without reloading the shell. The browser uses daemon-provided directory suggestions, supports parent-directory navigation and manual absolute paths, and registers the selected directory through the existing workspace mutation. Native folder selection remains hidden for remote daemons. Session discovery, transcript loading, file references, terminal traffic, and execution require no parallel remote-specific implementations because they already use the selected SDK client.

The chat header names the host the conversation runs on with a compact chip — laptop icon plus "Local", or server icon plus "Remote · hostname" — with the working directory in its tooltip. The chip appears once at least one other host is known; a purely local shell stays unlabeled.

The add operation remains a one-shot flow. The source URL is kept only in the current tab while navigation is in progress. Cancel returns to that URL, changing Folder source continues the same browser on the selected computer, and a successful registration stays on the selected daemon and clears the continuation state. The connection catalog stores origins only; the saved-host project catalog stores identity fields only, never tokens, and never merges one daemon's workspaces into another daemon's live list. Ordinary daemon switches do not resume the flow.

Bearer tokens remain in per-tab sessionStorage, but are keyed by daemon origin. The legacy unqualified key is used only for same-origin connections. Selecting a remote daemon never reuses a token stored for the page's own daemon or another remote daemon.

When the HTML shell is served by qwen serve, its CSP adds the validated selected daemon origin — plus every validated ?fanout=<origin> parameter — and the corresponding ws: or wss: origins to connect-src. Fanout origins go through the same strict validator as ?daemon= (credentials, paths, query strings, and fragments rejected), may be repeated, and are dropped when they equal the page origin. The shell keeps the fanout parameters in sync with the saved-host set through history.replaceState, which cannot widen the current document's policy; a host newly saved outside the covered set still needs its one navigation before it can be focused in place. The remote daemon must independently allow the Web Shell page origin with --allow-origin; existing Origin, Host, and bearer checks remain authoritative. Probing the page's own daemon for the saved-project refresh stays same-origin and needs no CSP change.

Disconnecting or closing the browser only disposes the client connection. It does not stop the externally managed daemon; existing daemon-side client-detach and session-retention policies remain unchanged.

Failure and Security Boundaries

  • An unfamiliar ?daemon= target waits for explicit confirmation before any probe. Explicit connection forms write the target to the persistent catalog only after its capabilities probe succeeds; opening an ordinary daemon URL remains tab-scoped.
  • The browser-local file bridge is offered only when the connected daemon is the page's own origin, in the standalone and embedded shells alike: a cross-origin target never mounts it, so a client directory cannot be handed to a remote daemon whose panel copy promises files stay on the computer. Remote workspace files remain available through the selected daemon. The same-origin SSH-tunnel deployment keeps its behavior and origin-scoped grants.
  • The remote-add continuation is explicit, tab-scoped, and one-shot. It reuses the origin-only connection catalog and does not alter ordinary daemon switching.
  • The saved-host project catalog persists project identities (origin, id, cwd, displayName) only. It never stores bearer tokens, file contents, or session data, and a host removed from the connection catalog keeps its saved projects only until its next successful capabilities refresh.
  • Invalid remote addresses are reported by the connection gate and are not contacted.
  • Authentication, Origin, Host, and network failures stay explicit in the existing connection gate; there is no fallback from a valid selected remote daemon to a local runtime.
  • A URL selecting an attacker-controlled daemon cannot cause a token for another daemon to be sent to it.
  • A loopback URL selected through ?daemon= may be an SSH tunnel and is not treated as proof that the daemon host is the browser host.
  • HTTP and HTTPS targets are accepted. HTTPS is recommended outside trusted networks. SSH transport, if desired, is supplied by the user as a loopback tunnel outside Qwen Code.

Validation

  • Unit-test address validation, token isolation, query preservation, and CSP sources.
  • Unit-test the saved-host catalog: identity-only persistence, update-on-refresh, page-origin synthesis while cross-origin, and unreadable-storage degradation.
  • Start local Web Shell and a token-configured daemon on a remote host, then connect by entering the address and token in the browser.
  • Add a remote computer in Settings > Connections, verify that the flow returns to the same settings category, choose the normal Add workspace action, select that computer from Folder source, browse its directories, register one, and verify that the new workspace becomes active on that daemon.
  • With the shell connected to a remote daemon, verify the sidebar names the connected host above the live list, lists the page's own projects in a local group underneath, marks the live rows with the globe badge only as long as the host is cross-origin, and shows the header chip; then select a saved local project and verify the page navigates to the page's own daemon with that workspace active.
  • Verify that changing Folder source keeps the directory browser open, Cancel returns to the exact source page without a token or continuation marker, and an ordinary daemon switch never opens the directory browser.
  • Verify the local page lists the remote workspace, obtains remote directory suggestions, lists and references remote files, and loads a remote session transcript.
  • Verify the target, saved host groups, and selected session remain after refresh without re-entering the token, including while a saved host is unreachable.

Acceptance Criteria

  • A Web Shell page can connect directly to a configured remote daemon origin.
  • An invalid or unavailable target can be replaced from the connection gate, and a connected target can be switched from Daemon Status.
  • The standalone Settings panel exposes a Connections category for managing verified remote computer origins.
  • The sidebar exposes one Add workspace action that opens the directory browser directly; its Folder source selector can switch between this computer and a verified remote connection, continue after navigation, browse daemon directories, and register the selected absolute path.
  • After adding a remote directory the local projects stay listed: the connected host's live projects and the page's own saved projects are both selectable from the same sidebar, grouped and labeled by host.
  • Locality is marked per row and per host: server icons for remote entries, a laptop icon for the local group, the globe badge on live rows only while the connected host is cross-origin, and the chat header chip names the current host.
  • Cancel returns to the source shell predictably, while changing Folder source keeps the browser flow active and a completed add stays on the selected daemon; persistent catalogs contain connection origins and project identity fields, never tokens or project contents.
  • Workspace and session discovery and file/terminal operations use the selected daemon through the existing SDK.
  • Credentials are never reused across daemon origins.
  • Remote selection survives navigation and refresh.
  • Invalid addresses and daemon policy/authentication failures are explicit and do not fall back to another runtime.