diff --git a/docs/INDEX.md b/docs/INDEX.md
index c57735c5..2091cab1 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -90,6 +90,7 @@ If the docs themselves feel stale or scattered, also read `docs/reference/DOCS_R
- Auto-update: `docs/features/auto-update.md`
- Windows cross-platform work: `docs/plans/windows-support.md`
- Dev mock runtime (dual-guarded `src/dev/` Tauri shim that boots the real UI in a plain browser under `npm run dev` with seed data, for visual/screenshot work): `docs/features/dev-mock-runtime.md`
+- Web remote access (embedded default-off HTTP+WS server in the desktop app; a browser on another device loads the same UI bundle and drives the same running instance via a `__TAURI_INTERNALS__` WebSocket shim — pairing-token auth, mirror-mode multi-client stream fan-out, web fallbacks incl. remote project creation): `docs/features/web-remote-access.md`; locked design contract + remaining work at `docs/plans/web-remote-access.md`
- Repo boundaries: `docs/reference/ARCHITECTURE.md`
- Design system (color tokens, theming layers, `.theme-warm`/density scales, the no-hardcoded-colors rule — shadcn stone base `b1HYEHloH`): `docs/reference/DESIGN-SYSTEM.md`
- Keyboard shortcuts: `docs/reference/SHORTCUTS.md`
diff --git a/docs/core/STATUS.md b/docs/core/STATUS.md
index 977e8ee1..71a1d6f5 100644
--- a/docs/core/STATUS.md
+++ b/docs/core/STATUS.md
@@ -20,6 +20,8 @@ Landed on `main` after the `v0.13.1` tag (unreleased): **structural sharing in `
Landed on `main` after the `v0.13.1` tag (unreleased): **agent-browser sessions are reaped during the app's lifetime instead of only on restart** (issue #126). Agent-browser daemons (headless Chromium behind each `AgentBrowserSession`) previously only got torn down by the restart-time PID-file reconciliation, so a long-running app session leaked one Chromium process per closed workspace that had ever opened a browser. `AppStateStore::close_workspace` now returns `CloseWorkspaceResult.removed_agent_browser_sessions` (collected under the same lock that removes the workspace, so no TOCTOU race), and a shared `reap_agent_browser_sessions` helper in `commands/workspace.rs` fire-and-forgets `AgentBrowserManager::close()` for each on BOTH close paths — `close_workspace` and `close_workspace_with_worktree_impl` (the worktree/MCP `workspace_close` path had zero agent-browser teardown before this). A new 5-minute backstop sweep in `lib.rs` closes any tracked `ws-*` session whose owning workspace no longer exists, via the pure `agent_browser::orphaned_ws_sessions` helper and `AppStateStore::live_agent_browser_session_names` — in-memory-keys-only (never shells out to `agent-browser session list`, so it can't touch another codemux instance's daemon) and `ws-*`-scoped (never touches user browser-pane sessions). See `docs/features/browser.md` § "Session reaping during app lifetime (issue #126)".
+In progress on the `web-remote-access` branch (unmerged): **web remote access** — the desktop app grows an embedded, **default-off** HTTP + WebSocket server (new `src-tauri/src/web_remote/`, axum, in the desktop process) so a browser on another device (laptop, phone on the LAN or the user's mesh VPN) loads the *same* UI bundle and drives the *same* running instance — same projects, workspaces, terminals, agent chats. It is a **UI transport** (a second frontend for one backend), the sibling of the remote-hosts **compute transport** (same frontend, remote backend), and the two compose: a paired web client transparently sees and drives the desktop's remote hosts with no web-specific host code. The seam is `window.__TAURI_INTERNALS__` — the production twin of the dev-mock shim (`src/remote/shim.ts`) backed by a real WebSocket instead of fixtures, so **zero app components change**; `main.tsx` selects real-IPC / dev-mock / web-shim three ways and both shims stay dormant under the desktop WebView. Invokes dispatch through the main webview's own `on_message` (reusing arg deserialization + `State`/`AppHandle` extraction + ACL + error formatting for all ~300 commands with no per-command wiring); a `Builder`-registered channel **interceptor** + `ChannelRouter` re-route each command's `Channel` frames to the owning browser (JSON `chan` frames, or the compact `[0x01][u32 cb][u64 idx][payload]` binary frame for PTY bytes), and a `listen_any` **event hub** multiplexes all subscribers onto one desktop listener per event name. Auth is **pairing-token → persistent revocable session**: a 32-byte, 10-min, single-use pairing token (QR / `#pair=` deep link) is traded at `POST /api/pair` for a SQLite session (SHA-256 at rest, constant-time compare, no early-return), and the session is traded for a 30s single-use **WS ticket** so the session secret never lands in a `/ws` URL; credentials ride a `Bearer` header or an `HttpOnly; SameSite=Strict` cookie through one `authenticate`; optional **approval mode** holds a new pairing pending until the desktop approves; **revocation severs live sockets** immediately (frame `Close` + trip a `watch`). Origin checks guard every state-touching route, pairing is rate-limited 5/min/IP keyed on the real TCP peer, and the browser-pane proxy validates the loopback port range + rejects control chars. Multi-client is **mirror mode** (all clients share `AppStateSnapshot` incl. focus); the terminal + agent-chat streams now **fan out** to N subscribers (per-generation attach/detach, replay only to the new attacher, pause only when ALL subscribers pause), and the **desktop window owns scrollback serialization** (web clients no-op it). Web fallbacks are gated on `isRemoteClient()` so desktop stays byte-identical: an in-app **path browser** replaces the native file dialog (unlocking **opening projects / creating workspaces from the browser**), **Web Notifications** (toast fallback) mirror desktop notifications, `convertFileSrc` → an auth-gated `/api/assets` streamer, the loopback `agent-browser` daemons are reached through an authed WS/HTTP **browser-pane proxy**, window chrome is hidden, and the desktop stays the single updater — it publishes availability to paired browsers, defers its auto-update restart while remote devices are attached, and a browser can trigger the desktop's download-and-restart. `web_remote_list_endpoints` enumerates loopback / LAN / tailnet / MagicDNS with per-endpoint **secure-context** notes (only loopback is secure over plain HTTP; TLS is delegated to the mesh VPN's HTTPS serve or a user proxy in v1). Account (OAuth) sign-in stays desktop-only by design; the localStorage session is a documented shared-machine trade-off (revoke severs instantly). Debug-only e2e affordances (`web_remote::e2e_autostart`, `auth::seed_dev_offline_login`, dev `?remote=1`) are all `#[cfg(debug_assertions)]` **and** env-gated, so no release path is weakened. Verified with the full matrix (cargo check/test, `npm run check`/`test`/`build`) plus a live end-to-end drive through the Codemux browser pane (pair via link + QR, live terminal replay/input, agent-chat turn, open project, create workspace via the web path-browser, desktop+web mirroring, revoke-mid-session socket drop, reconnect). See `docs/features/web-remote-access.md`; locked design contract + remaining work (relay/account tier, per-client views, per-command ACLs) at `docs/plans/web-remote-access.md`.
+
Shipped in `v0.13.1`: **first-class non-git projects + chat image attachments + first-send polish.** (1) **Non-git projects** (PR #147) — a plain folder can be opened as a workspace (`is_git` snapshot flag) with an opt-in "Initialize Git" affordance in the context bar / Context Row / Changes panel; worktree controls hide in Thread Scope and there's no add-path git gate. "Initialize Git" is a *bare* `git init` — it never stages or commits the user's files (PR #148). See `docs/features/workspace-creation.md` § "Non-Git Projects". (2) **Chat image attachments rendered in sent messages** (PR #151) — an image attached to a chat message now renders inline in the sent bubble (≤160px, broken-image fallback) with a click-to-open lightbox. See `docs/features/agent-chat.md` § "Attachments". (3) **Deferred-worktree auto-naming fix** (PR #146) — first-send worktree creation stops silently falling back to random names when a real name can be generated; PR #144's deferred-worktree chat sessions now launch *in* the worktree and restore the Context Row issue chip (PR #148). (4) **Transcript smooth-scroll fixes** (PRs #148, #149) — `scrollbar-gutter: stable` restored on the viewport and native scroll anchoring allowed to stabilize the unpinned transcript.
Shipped in `v0.13.0` (PR #144): **Context Row** — the permanent bottom `WorkspaceContextBar` now hides itself (`useAgentChatPaneActive()`, `src/hooks/use-gui-chrome.ts`) while an Agent Chat pane is the active pane of the active surface in GUI chrome, and its git/PR detail relocates into the composer's scope row instead. `AgentChatPane`'s `belowComposerSlot` now renders a new read-only Context Row (static project/branch labels + ` `) once a thread has messages, and passes the same `WorkspaceStatusCluster` as `ThreadScopeRow`'s new `trailing` prop for the empty-thread state — so the behind chip, PR chip, the GUI-mode background-browser indicator (extracted to the shared `BackgroundBrowserIndicator` + `useBackgroundBrowserSession` in `src/components/browser/background-browser-indicator.tsx`, still consumed by the bar for terminal-pane cases), and a "workspace details" popover (branch/base/behind/ahead/uncommitted/PR/location + View PR / Sync quick actions) read identically on both sides of the first send. `PR_CHIP_TONE` moved to `src/components/github/pr-status-icon.tsx` as the single shared home. Frontend + docs only; no Rust changes. See `docs/features/agent-chat.md` § "Context Row (running-thread status)" and `docs/features/workspace-context-bar.md`.
diff --git a/docs/features/agent-chat.md b/docs/features/agent-chat.md
index f46b52a6..e6fb3035 100644
--- a/docs/features/agent-chat.md
+++ b/docs/features/agent-chat.md
@@ -966,10 +966,20 @@ to `codemux.exe` under `binaries/` on Windows. (It originally shipped
as an `externalBin` under `usr/bin/`; moved to a resource because
linuxdeploy's patchelf step corrupts the ~100 MB bun-compiled binary
during AppImage bundling — see commit 025fa19.) At runtime, the
-`setup()` hook in `src-tauri/src/lib.rs` resolves the resource via
-`AppHandle::path().resource_dir()` and pins the resolved path into
-the `CODEMUX_CLAUDE_SIDECAR_PATH` env var so the adapter (which has
-no `AppHandle` access at construction time) can find it. The release
+`setup()` hook in `src-tauri/src/lib.rs` calls
+`resolve_sidecar(resource_dir)`
+(`agent_provider::claude::sidecar_path`), which prefers an existing
+`CODEMUX_CLAUDE_SIDECAR_PATH` override, then the
+`/binaries/` copy, and — **in debug builds only** —
+falls back to the dev-tree `/binaries/` copy so
+`npm run tauri:dev` (whose resource dir does not carry the sidecar)
+still resolves it and the agent-chat GUI can spawn. The winner is
+pinned into the `CODEMUX_CLAUDE_SIDECAR_PATH` env var so the adapter
+(which has no `AppHandle` access at construction time) can find the
+same binary. The debug fallback is compiled out of release via
+`debug_assertions`, so the release resolution stays resource-dir-only;
+when the binary exists nowhere the hook leaves the env var unset and
+the Claude provider stays unconfigured rather than panicking. The release
workflow (`.github/workflows/release.yml`) installs Bun and
pre-stages the binary so tauri-action finds it before bundling. CI
(`.github/workflows/ci.yml`) does the same, with a zero-byte
diff --git a/docs/features/web-remote-access.md b/docs/features/web-remote-access.md
new file mode 100644
index 00000000..931d9990
--- /dev/null
+++ b/docs/features/web-remote-access.md
@@ -0,0 +1,247 @@
+# Web Remote Access
+
+- Purpose: Describe the embedded, default-off HTTP + WebSocket server that turns the desktop app into a second frontend for its own backend — a browser on another device loads the same UI bundle and drives the same running instance.
+- Audience: Anyone touching the `web_remote` server, the `src/remote/` WebSocket shim, the Remote Access settings pane, the stream fan-out in terminal/agent-chat, or the web fallbacks (path picker, notifications, assets, browser-pane proxy, updater).
+- Authority: Canonical feature-level reality doc for web remote access.
+- Update when: The WS protocol contract, the auth model, the fan-out semantics, the endpoint enumeration, or any web fallback changes.
+- Read next: `docs/plans/web-remote-access.md` (locked design contract + remaining work), `docs/features/dev-mock-runtime.md`, `docs/features/remote-hosts.md`, `docs/features/terminal.md`, `docs/features/agent-chat.md`.
+
+## What This Feature Is
+
+The desktop app grows an embedded HTTP + WebSocket server. When the user turns it on, a browser on another machine — a laptop, a phone on the same LAN or the user's mesh VPN — loads the *same* UI bundle the desktop runs and drives the *same* running desktop instance: the same projects, workspaces, terminals, agent chats, git state. It is a **second frontend for one backend**, not a second app.
+
+This is a **UI transport**. It is the sibling of remote-hosts, which is a **compute transport** (same frontend, a remote backend reached over SSH). The two compose: a paired web client automatically sees and drives the desktop's remote hosts, because all of that logic already lives behind the invoke surface the web client speaks to. See "Relationship to remote hosts" below.
+
+Default off. Nothing binds until the user enables it in Settings → Remote Access. Work continues when the browser disconnects — the persistent PTY daemon already guarantees a shell survives the app losing its UI, and the web client is just another UI.
+
+## Current Model
+
+### The seam: `window.__TAURI_INTERNALS__`
+
+The entire React UI talks to its backend through exactly one object, `window.__TAURI_INTERNALS__`. The dev mock runtime (`src/dev/tauri-mock.ts`) already proved the UI runs unmodified in a plain browser against a shim of that object. Web remote access installs a **production** twin of that shim (`src/remote/shim.ts`) backed by a real WebSocket to the desktop instead of in-process fixtures. **Zero app components change** — every `invoke()`, `listen`/`emit`/`once`, `Channel`, `convertFileSrc`, and plugin call routes through the shim.
+
+`src/main.tsx` selects the runtime before React mounts, three-way:
+
+1. The Tauri desktop WebView injects the real internals — nothing installed, real IPC.
+2. `npm run dev` in a plain browser with no real internals → dev mock.
+3. A plain browser served by the desktop's web-remote server → the WebSocket shim (`src/remote/bootstrap.tsx` handles pairing, then installs the shim and connects before mounting).
+
+Both shims stay dormant under the desktop WebView, so real-IPC behavior is byte-identical to before this feature existed. `import.meta.env.DEV` statically tree-shakes the dev-mock branch out of the production bundle. `?remote=1` under `npm run dev` forces the WebSocket shim so it can be exercised against a live server instead of the mock.
+
+### The embedded server
+
+New module `src-tauri/src/web_remote/` runs an axum server (axum is already a dependency) inside the desktop process. When enabled it binds `` (default **4377**, configurable) on the interfaces the **bind scope** selects (default `0.0.0.0`, every interface — see below); disabling severs every live socket (`ConnectionRegistry::close_all`, the same mechanism revocation uses) and then flips a graceful-shutdown `watch` that unbinds every listener — turning the master switch off is a security action, so already-connected devices lose control immediately rather than lingering until their next reload (graceful shutdown alone only stops *new* connections; an open WebSocket never closes on its own). A port **or bind-scope** change rebinds through the same stop→start path, so it also drops existing connections (they cannot follow to the new port/interface). Config (`enabled`, `port`, `require_approval`, `bind_scope`) persists in the existing settings storage under `web_remote.config` (`bind_scope` is `#[serde(default)]` → a config written before the field existed loads as `all`), and `restore_on_boot` re-binds on app start if the feature was left enabled — a bind failure (port taken, or `tailscale` scope with no tailnet address) is logged, never fatal.
+
+### Bind scope (interface exposure)
+
+`bind_scope` narrows which interfaces the server listens on, so on a hostile network the port simply isn't open on the untrusted segment:
+
+- **`all`** (default) → `0.0.0.0:` — every interface. Historical behavior; any LAN or tailnet peer with the port can reach it.
+- **`tailscale`** → the tailnet address(es) `endpoints::tailnet_ips()` reports (the same CGNAT-range + `tailscale status` discovery the endpoint list uses) **plus loopback**, each bound as its own listener sharing one router. The port is reachable over the mesh VPN and locally, but never on the LAN. If **no** tailnet address is present the enable/rebind **fails** with `No Tailscale address found — connect Tailscale or choose a different access scope` and the server stays off — it never silently falls back to `0.0.0.0` (that would re-expose the port the scope exists to hide).
+- **`loopback`** → `127.0.0.1:` only — reachable just from this machine (e.g. tunnelled in over SSH).
+
+`bind_addrs(scope, port)` resolves a scope to the concrete `SocketAddr`s; `start_server` binds one listener per address up front (a partial bind failure drops the already-bound listeners, never leaking a half-open server) and serves the same router on all of them behind a shared shutdown signal. `web_remote_enable` rolls `enabled` back to `false` if the bind fails, so the UI never shows "enabled but not running"; `web_remote_set_config` restores the previous scope/port and rebinds to it if a scope/port change can't bind, so a bad change leaves the server on its last-good address rather than off. The scope is surfaced on `WebRemoteStatus.bind_scope` and chosen in Settings → Remote Access ("Who can connect").
+
+Module layout:
+
+- `mod.rs` — managed `WebRemoteState`, config persistence, server lifecycle, the Tauri command surface, the boot-restore + e2e-autostart hooks.
+- `server.rs` — the axum router, the connection registry, the WebSocket lifecycle (writer task, keepalive pinger, reader loop), and the shared session-admission gate.
+- `auth.rs` — pairing tokens, sessions, WS tickets, the rate limiter, origin checks.
+- `dispatch.rs` — invoke dispatch via synthesized `on_message`, plus the channel interceptor/router.
+- `events.rs` — the `listen_any` fan-out hub with per-event refcounting.
+- `endpoints.rs` — reachable-endpoint enumeration (loopback / LAN / tailnet / MagicDNS), de-noised of Docker/virtual interfaces and grouped for the UI (This device / Local network / Tailscale / Other) with one `recommended` hint.
+- `assets.rs` — the authenticated `/api/assets` file route (`convertFileSrc` replacement).
+- `proxy.rs` — the auth-gated browser-pane WS + HTTP proxy to loopback `agent-browser` daemons.
+- `snapshot.rs` — the authenticated `/api/snapshot` bulk state-bootstrap route (a small versioned client API).
+
+The frontend served is the app's own bundle, resolved through Tauri's `AssetResolver` (assets are embedded in the release binary — there is no `dist/` on disk in production; a debug-only `dist/` fallback keeps `npm run tauri:dev` serviceable). A path with no file extension falls back to `index.html` for client-side routing.
+
+### WS protocol contract
+
+The transport is `GET /ws?ticket=`, upgraded after ticket validation. Both sides (`src-tauri/src/web_remote/{server,dispatch,events}.rs` and `src/remote/{transport,shim}.ts`) match this exactly:
+
+Text frames (JSON):
+
+- C→S `{"t":"invoke","id":,"cmd":"","args":{...}}`. `Channel` instances serialize (via `@tauri-apps/api`'s `toJSON`) to `"__CHANNEL__:"` strings, minted by the shim's own `transformCallback` registry.
+- S→C `{"t":"ok","id":,"data":}` / `{"t":"err","id":,"error":}` — id-matched. The server spawns one task per invoke, so responses may return out of order (allowed by design). **Terminal input is the exception:** `write_to_pty` frames are drained through a per-connection serial lane (`is_ordered_invoke`) instead of a per-frame task, so a burst of keystrokes reaches the PTY in the exact order the client sent it — the task-per-invoke concurrency would otherwise let two rapid writes race and scramble the bytes, which the desktop's in-process IPC never does.
+- C→S `{"t":"listen","event":""}` / `{"t":"unlisten","event":""}`; S→C `{"t":"event","event":"","payload":}`.
+- S→C JSON channel body: `{"t":"chan","ch":,"idx":,"data":}`.
+
+Binary frames (raw channel bodies, e.g. PTY bytes): `[0x01][u32 BE callbackId][u64 BE idx][payload]`. The shim delivers `{index, message}` to the stored callback exactly as the dev mock does — the mock is the reference for what `@tauri-apps/api`'s `Channel` expects.
+
+Keepalive: the server pings every 30s; two unanswered pings closes the socket. On close the shim reconnects with exponential backoff (1s → 16s cap), re-sends its `listen` subscriptions, and re-issues a WS ticket first. In-flight invokes reject on disconnect; the UI's own `get_app_state`-on-mount plus snapshot events self-heal the view.
+
+### Invoke dispatch (Rust ladder, Option 1)
+
+A browser's `invoke` frame is turned into a real `tauri::webview::InvokeRequest` and driven through the main window's webview via `WebviewWindow::on_message` — the same entry point the desktop's own IPC uses (`dispatch::dispatch_invoke`). This reuses argument deserialization, `State`/`AppHandle`/`Window` extraction, ACL resolution, and error formatting for **every one of the app's ~300 commands with zero per-command wiring**. The request reuses the main window's own URL so ACL resolves identically to a desktop-initiated invoke. `on_message` runs on `spawn_blocking` (a sync command runs inline, an async command is handed to Tauri's runtime); a oneshot carries the `InvokeResponse` back to the WS task, which emits the id-matched `ok`/`err` frame. `InvokeResponseBody::Json` → `data`; a panicking command resolves to an `err` frame via the dropped oneshot, never crashing the server.
+
+### Channels and binary PTY frames
+
+A `tauri::ipc::Channel` deserialized inside a synthesized invoke would, by default, post its frames to the *desktop* webview's JS — the wrong client. Tauri exposes a **channel interceptor**, registered on the `Builder` in `lib.rs`, that fires for every channel send with `(callback_id, index, body)`. Before dispatching an invoke, the dispatcher walks the args and rewrites each `__CHANNEL__:` marker to a fresh **server-side** channel id, registering a route `server_id → (this WS, clientId)` in `ChannelRouter`. When the command later sends on that channel, the interceptor calls `ChannelRouter::route`, which serializes the body to the owning WS — a `chan` text frame for JSON, the compact `[0x01]…` binary frame for raw bodies — and returns `true` to suppress the desktop delivery.
+
+This is uniform across every channel-taking command (`attach_pty_output`, `attach_agent_chat_output`, …) and needs no knowledge of their signatures, so it is unaffected by concurrent changes to those commands' fan-out internals. A fast-path atomic (`active` route count) lets the desktop-only hot path — local PTY output with no web channels open — skip the router lock entirely and pay only one relaxed load. Routes die with their WS connection (`remove_conn` on socket close).
+
+### Event hub
+
+Browsers subscribe to app events over the WS; `events::EventHub` multiplexes all subscribers onto a single `AppHandle::listen_any` registration **per event name**, reference-counted by subscriber. The first subscriber registers the desktop-side listener; the last to leave (or disconnect) unregisters it. So there is exactly one desktop listener per active event no matter how many browsers watch it, and none once no one is — the desktop's own event bus sees no extra churn. Payloads forward verbatim (re-parsed to a value so they nest rather than double-encode).
+
+### HTTP snapshot bootstrap (`GET /api/snapshot`)
+
+Without help, a freshly-paired (or reconnecting) web client renders blank until a full chain completes: `POST /api/ws-ticket` → `GET /ws` upgrade → React mount → the app's own `get_app_state` invoke over the socket → the reply. `snapshot.rs` collapses the tail of that chain. It exposes one authenticated route, `GET /api/snapshot`, that returns the whole initial state in a single HTTP GET the client fires **in parallel** with the WS handshake, so the UI paints real state as soon as it mounts instead of paying a post-mount round-trip.
+
+The payload is a stable, versioned envelope:
+
+```json
+{ "api_version": 1, "app_state": , "status": }
+```
+
+- `app_state` is byte-for-byte what the `get_app_state` command returns — it is produced by the **same** `AppStateStore::snapshot()` call, so there is no second serialization path to keep in sync.
+- `status` is the same `WebRemoteStatus` the `web_remote_status` command and the `web-remote-state-changed` event carry (built by the shared `build_status`).
+- The response is `Cache-Control: no-store` (a live snapshot must never be served from a cache) and `application/json`.
+
+Admission is the same gate the other authenticated `/api/*` routes use (`server::require_session`): same-origin, an approved non-revoked session by bearer or the HttpOnly cookie. Unauth → 401, cross-origin → 403.
+
+**Seeding is WS-subordinate and stale-safe.** The frontend seam is entirely inside the WebSocket shim — zero app-component changes, so desktop/Tauri and dev-mock paths are byte-identical (they never install the shim, and `fetchSnapshot` is a shim option they never pass). The shim (`src/remote/{snapshot-seed,shim}.ts`) fires the prefetch at install time — concurrently with `transport.connect()` — and answers the app's **first** `get_app_state` from it, but only when the prefetch has already settled and **no** WS-delivered app-state has superseded it. The guard is a monotonic counter (`wsAppStateSeq`), bumped on every `app-state-changed` event the socket delivers: the initial seed is served only while that counter is still `0`, so a WS snapshot that arrived first always wins. The prefetch is never awaited on the hot path (a slow/hung fetch can't delay the first render — the WS invoke covers that), and any failure resolves to `null` and falls back to the WS path silently. On every **reconnect** the shim refetches and pushes the result through the same `app-state-changed` delivery the app already consumes — catching up on state that changed while the socket was down (those events had no subscriber) — again discarded if a fresher WS snapshot lands first. Full-snapshot last-writer-wins makes this safe; the counter guard makes it stale-safe.
+
+**Native-client API seed.** This endpoint is deliberately the first piece of a small, versioned API intended for future **non-web** clients (a native mobile client is the obvious next consumer), not a web-only shortcut. A native client with no WebView and no `@tauri-apps/api` shim can still bootstrap its whole view from one authenticated GET, and `api_version` lets it detect a breaking envelope change (bump it on any incompatible shape change). The web transport happens to be the first consumer; the shape is designed to outlive it.
+
+## Auth model
+
+The trust model is **pairing token → persistent revocable session**, with short-lived tickets keeping the session secret out of WS URLs and logs.
+
+1. **Pairing token** — 32 random bytes, 10-minute TTL, single use, in memory only (`auth::PairingStore`). Minted by the desktop (`web_remote_create_pairing`) and handed to a browser out-of-band as a QR code or a `/#pair=` deep link.
+2. **Session** — created when a browser presents a valid pairing token to `POST /api/pair`. Persisted in SQLite (`web_remote_sessions`: id, name, user_agent, `token_hash`, timestamps, `approved`, `revoked`). The session token is SHA-256 hashed at rest; the plaintext is returned to the browser once and never stored. `authenticate` resolves a presented token by constant-time comparing its SHA-256 against **every** active row's hash without early-return, so timing leaks neither which row matched nor whether one did.
+3. **WS ticket** — 30-second TTL, single use, in memory (`auth::TicketStore`). A browser trades its session (bearer or cookie) at `POST /api/ws-ticket` for a ticket, then opens `/ws?ticket=…`. Ticket redemption is atomic (remove-then-TTL-check under one lock), so two simultaneous `GET /ws` with the same ticket resolve to exactly one winner. `ws_upgrade` **re-validates** `!revoked && approved` after redeeming the ticket, in case the session was revoked during the ticket's 30s window.
+
+Credentials travel two ways, both funneled through the same `authenticate`: an `Authorization: Bearer ` header (the shim's `fetch`), and an `HttpOnly; SameSite=Strict; Path=/` cookie (`cmux_web_session`) for ` `/asset GETs that can't set a header. There is no confusion/privilege gap between them.
+
+**Approval mode** (`require_approval`, off by default): a new pairing is inserted `approved = 0` and gets no tickets until the desktop approves it. The desktop's Remote Access pane surfaces pending devices with approve/reject; reject closes any live sockets and deletes the row.
+
+### Pairing from the terminal — `codemux remote pair`
+
+When you're away from home you can SSH into the desktop and mint a pairing code from the shell, without opening the GUI. The control socket is same-machine + unauthenticated by design — over SSH you **are** on the machine — so it is the right transport. `codemux remote pair [--name ]` sends a `web_remote_pair` control command (`control.rs::dispatch_request`), which calls `web_remote::control_pair`:
+
+- It errors clearly if remote access is off (`Remote access is not enabled — enable it in Settings first`) — the server must be bound for the URL to be reachable.
+- Otherwise it mints a one-time token through the **same** shared path the GUI uses (`web_remote::mint_pairing` → `PairingStore::issue_named`; there is no duplicate token logic), and pairs the URL to the endpoint enumeration's single `recommended` pick (MagicDNS → tailnet → local network, falling back to loopback), so the printed URL matches what the Settings pane would show.
+- The optional `--name ` rides along on the token as a **suggested device name**: the pair handler uses it only as a fallback when the connecting client sends no `device_name` of its own (the bundled web client always derives one), so a device paired from the terminal still shows a friendly name in the desktop list.
+
+The CLI prints a scannable **terminal QR** of the pairing URL (pure-Rust `qrcode` crate, unicode `Dense1x2` renderer — no image deps added), plus the link, raw token, endpoint (kind + secure-context note), and expiry. Scan it with a phone camera or open the link in any browser that can reach the machine. The token is single-use and 10-minute-TTL like any GUI-minted pairing.
+
+**Revocation severs live sockets immediately.** `web_remote_revoke_session` marks the row revoked and calls `ConnectionRegistry::close_session`, which frames a `Close` and trips a per-connection `watch` that unwinds the read loop (releasing its channels + event subscriptions). `web_remote_active_session_hashes` filters `WHERE revoked = 0`, so a revoked token can never re-authenticate.
+
+**Rate limiting**: `POST /api/pair` is capped at 5 attempts per minute per IP, keyed on the real TCP peer (`ConnectInfo.ip()`) — not `X-Forwarded-For`, so a spoofed header can't bypass it. A rejected attempt is not recorded, so a backing-off client recovers.
+
+**Origin checks** run on every state-touching route — the `/ws` upgrade, `/api/pair`, `/api/ws-ticket`, `/api/assets`, and both `/proxy/browser/*` routes. A present `Origin` must match the request `Host`; a missing `Origin` is allowed (native clients and same-origin asset GETs don't send one, and CSRF requires a browser that always does). A present-but-mismatched Origin is the cross-site case and is rejected.
+
+## Endpoint enumeration
+
+`web_remote_list_endpoints` (`endpoints::list`) enumerates every URL a browser could use to reach the bound server, so the settings UI can show copy-ready links with an accurate per-endpoint security note. Each entry carries a `kind`, a coarse UI `group`, a `secure` flag, and a single `recommended` hint:
+
+- **loopback** (`127.0.0.1`) — always first, and the one origin a browser treats as a **secure context** over plain HTTP (so clipboard/notifications keep working here). Group: `this_device`.
+- **lan** — non-loopback **RFC 1918** private IPv4 on a real local interface. Group: `local_network`. (Marginal addresses — `169.254` link-local IPv4 and non-link-local IPv6 that aren't on the tailnet — are still surfaced, but grouped as `other`.)
+- **tailnet** — interface IPs inside the `100.64.0.0/10` CGNAT range, plus every address (IPv4 **and** IPv6) that `tailscale status --json` reports for this node (when the mesh CLI is present). Group: `tailscale`.
+- **magicdns** — the node's MagicDNS name, labeled with a hint to enable the mesh's HTTPS serve for a trusted certificate. Group: `tailscale`.
+
+**Virtual interfaces are filtered by name.** Docker bridges (`docker*`, `br-*`), `veth` pairs, libvirt (`virbr*`), Kubernetes CNI plumbing (`cni`/`flannel`/`cali`/`kube`), VirtualBox/VMware host-only nets, ZeroTier (`zt*`), and the Tailscale tunnel (`tailscale*`) are skipped by `is_virtual_iface`, so their `172.x`/`169.254.x`/etc. addresses never masquerade as a reachable endpoint. (The `br-` prefix is hyphenated so a real bridge `br0` survives.) Tailnet addresses come from the CLI rather than the skipped `tailscale0` interface — which is why a Tailscale IPv6 lands under `tailscale`, not `other`.
+
+**Groups drive the UI.** The four groups — `this_device`, `local_network`, `tailscale`, `other` — render as labelled sections in both the "Reachable at" list and the pairing-card device picker; empty groups are dropped and `other` sits behind a default-closed disclosure. Exactly one endpoint is marked `recommended` (the best "reach from anywhere" option: MagicDNS, else a tailnet IP, else a local-network IP; loopback-only leaves nothing marked), surfaced with a "Recommended" chip. The frontend grouping helper (`remote-access-utils.ts`) degrades any unrecognised `group` value into `other`, so a future backend group can't render blank.
+
+Only loopback reports `secure: true`. LAN/tailnet are plain HTTP from the server's point of view — it can't know whether a mesh proxy is terminating TLS in front of it — so they report `secure: false` and the UI surfaces the consequence. Everything degrades gracefully: no interfaces, no `tailscale` binary, or a malformed status blob just yields fewer entries, never an error.
+
+## Settings & device management UX
+
+Settings → Remote Access (`src/components/settings/remote-access-section.tsx`) is the desktop control surface:
+
+- **Master toggle** with a plain-language exposure warning (turning it on lets other devices drive this one).
+- **Access scope** ("Who can connect") — a segmented control over `all` / `tailscale` / `loopback` with a one-line explanation for each, wired through `web_remote_set_config({ bindScope })`. Changing it rebinds (the same drop-connections stop→start path a port change uses); switching to `tailscale` with no tailnet address is rejected with a toast and the control snaps back to the last-good scope.
+- **Port field** with validation; a port change while running rebinds the listener.
+- **Grouped endpoint list** — copy buttons under group headers (This device / Local network / Tailscale, with an `other` disclosure), a per-endpoint security note (secure loopback vs plain-HTTP LAN vs mesh HTTPS), and a **Recommended** marker on the best from-anywhere endpoint, from `web_remote_list_endpoints`.
+- **Pairing generator** — a link plus a client-side-rendered **QR code** (`use-qr-svg.ts`) encoding the `/#pair=` URL, with a live countdown to the token's 10-minute expiry.
+- **Paired/connected devices list** — name, platform (derived from the user agent), last-seen relative time, and a live-connection dot, with per-device **revoke** and **revoke-all**.
+- **Approval-mode toggle** and the **pending-approval flow** — newly pending devices raise a desktop notification + badge and show approve/reject controls.
+
+Everything live-updates: every server/session state change emits `web-remote-state-changed` on the global bus, which both the desktop pane and any paired web client listen to. Clipboard copy is secure-context aware (it degrades where the browser blocks the Clipboard API on an insecure origin).
+
+Once paired, the web client shows a persistent "Remote — connected to ``" indicator (`src/remote/status-banner.ts`) that degrades to a pulsing amber "Reconnecting…" on a socket drop and to an offline state on revocation (then reloads back to the pairing screen).
+
+## Multi-client semantics
+
+**Mirror mode (v1).** All clients — the desktop window and every paired browser — see the same `AppStateSnapshot`, including `active_workspace_id` and focus. Switching a workspace on the phone switches it on the desktop. Per-client (non-mirrored) views are explicitly out of scope for v1 (a deep state-model refactor; deferred).
+
+**Stream fan-out.** The live streams that used to target a single desktop channel now fan out to N subscribers so the desktop and one or more browsers can watch the same terminal or agent chat at once:
+
+- **Terminal** (`src-tauri/src/terminal/mod.rs`): `SessionRuntime` replaced its single `output_channel` with a subscriber list keyed by generation. `attach_pty_output` returns its generation and replays the `pending_output` ring **only to the new channel**; `detach_pty_output` takes a required generation arg and removes only that subscriber (never clobbering a newer attach). Flow control pauses the PTY reader **only when ALL subscribers request pause** (a per-generation pause set), and an attach clears only its own generation's pause — so a paused-then-detached browser can't park the reader forever, backed by the `FLOW_MAX_PARK` backstop. `dropped_chunks` counts only when there are zero subscribers.
+- **Agent chat** (`src-tauri/src/commands/agent_chat.rs`): `AgentChatChannelRegistry` became list-per-thread; attach pushes, detach removes by generation with empty-bucket cleanup, and `forward_event` iterates all subscribers of a thread. No replay is needed — SQLite history covers a late joiner.
+
+The frontend threads the generation token from `attachPtyOutput` through to `detachPtyOutput` on cleanup (`TerminalPane.tsx`). Desktop single-client behavior is byte-identical to before fan-out.
+
+**The desktop window owns scrollback serialization.** Web clients never ack `serialize-terminal-buffers` and never write scrollback files — `use-scrollback-serializer.ts` no-ops when `isRemoteClient()` is true. There is exactly one serialization owner regardless of how many browsers are attached.
+
+## Web fallbacks & parity
+
+The desktop UI assumes affordances a plain browser lacks. Each is bridged behind `isRemoteClient()` (`src/components/remote/is-remote-client.ts`), the single source of truth set by the shim before mount, so desktop behavior is never touched:
+
+- **File/folder picking → in-app path browser.** `pickFolder`/`pickFiles` (`src/lib/file-dialog.ts`) route to a path-browser modal (`src/components/remote/remote-path-picker.tsx`) backed by the existing directory-listing commands, returning the same `absolute path | null` / `path[]` shape the native dialog does. This unlocks **opening new projects and creating workspaces from the browser** — a first-class goal, not an afterthought.
+- **Notifications → Web Notifications with toast fallback.** The backend emits a global `notification` event alongside every native desktop notification. On the web client, `use-web-notifications.ts` raises a real OS notification via the Web Notifications API when permission is granted and the tab is hidden, and an in-app toast otherwise. Permission is requested lazily on the first event, never on mount. The desktop ignores the event (the OS notification already fired).
+- **`convertFileSrc` → `/api/assets`.** The shim maps a local file path to `GET /api/assets?path=` on the server origin; `assets.rs` streams the file back with a guessed MIME type so ` ` and editor previews resolve exactly as the desktop `asset:` protocol does. Auth-gated (bearer or cookie + same-origin); directories and non-regular files collapse to a bare 404 (no path leak, no directory listing).
+- **Browser-pane proxy.** A remote client can't reach the loopback `agent-browser` daemons (ports 9223–9299), so `proxy.rs` bridges them through the authenticated origin: `GET /proxy/browser/:port/ws` upgrades and pipes the screencast socket frame-for-frame (binary-safe, backpressure-aware), and `ANY /proxy/browser/:port/api/*rest` forwards the daemon's HTTP endpoints. The port is validated strictly against the agent-browser range before any connection, keeping the proxy from becoming a general-purpose loopback forwarder. The HTTP forwarder rebuilds a **minimal** request (request line + `Host` + body headers only, single `write_all`) to respect the daemon's single-segment ~1.4 KB read constraint and to never leak the session's cookies/bearer to the daemon; it rejects any path segment containing an ASCII control character so a percent-decoded `%0d%0a` can't smuggle a header or a second request line into the daemon.
+- **Hidden window chrome.** The custom title-bar window controls and drag regions (`window-chrome.tsx`, `title-bar.tsx`) render nothing on the web — there is no OS window to minimize/maximize/close. The shim answers the window/webview plugin calls as no-ops so nothing crashes.
+- **Update defer + web-triggered desktop update.** The web client has no updater plugin, so the desktop's `useUpdateChecker` hook is the single updater. When a desktop update is ready it publishes availability to the server (`web_remote_publish_update_available`), which rides `web-remote-state-changed` (and the `web_remote_status` snapshot, so late joiners see it) to paired browsers. A browser can ask the desktop to run its normal download-and-restart flow (`web_remote_request_update` → the `web-remote-update-requested` event the desktop updater listens for; the desktop confirmation UX still applies). Conversely, updates never restart the app on their own — download and restart are explicit user actions — and while any remote device is attached (`connected_sessions > 0`) the desktop's update toast additionally surfaces a "remote devices are connected" hint before the user restarts. The PTY daemon keeps agents alive across a restart; the web client reconnects via the shim's backoff loop.
+
+## Security model and constraints
+
+- **Default off.** Nothing binds until the user enables it; disabling tears the listener down.
+- **A paired session has desktop-level control by design.** It *is* the desktop (decision 5 in the plan). The `/api/assets` route is arbitrary-file-read on purpose — the same capability the desktop `asset:` protocol already has. The security boundary is pairing + revocation + the network layer, **not** per-command ACLs. Per-command scopes for paired devices are future work.
+- **Plain-HTTP endpoints are not secure contexts.** Only loopback qualifies. On a LAN/tailnet plain-HTTP origin a browser blocks the Clipboard API and can restrict notifications; the settings UI surfaces this per endpoint. For trusted HTTPS the recommended path is the user's **mesh VPN serve feature** (which fronts the plain-HTTP server with a valid certificate) or the user's own reverse proxy — TLS is delegated, not embedded, in v1.
+- **No PTY bytes through any cloud service.** Browser ⇄ desktop traffic is direct. There is no relay in v1.
+- **Account (OAuth) sign-in stays desktop-only by design.** The web client does not carry the account login; it authenticates to a *device* (the desktop) via pairing, and inherits whatever account the desktop is already signed into. Signing into a Codemux account happens on the desktop.
+- **localStorage session trade-off.** The paired session token lives in the browser's `localStorage`, so pairing survives a refresh without re-scanning a QR. On a shared machine that is a documented trade-off; revoke-from-desktop is the mitigation and severs the socket instantly.
+- **DoS hygiene (accepted v1 trade-offs):** the per-connection outbound queue is unbounded and each invoke spawns a task, but both are reachable only by an authed full-control session and the PTY path is flow-controlled; axum caps inbound WS messages at 64 MiB by default. The pairing rate-limiter's per-IP map is not aged-out, but entries are tiny and the population is a LAN/tailnet device set.
+
+## Dev/test affordances
+
+All three are compiled only into debug builds and, even there, dormant unless an env var is set — no release path is weakened:
+
+- **`web_remote::e2e_autostart`** (`#[cfg(debug_assertions)]` + `CODEMUX_WEB_REMOTE_E2E=1`): on boot, enables the server on `CODEMUX_WEB_REMOTE_PORT` (default 4377) with approval off, mints a one-time pairing token, and writes the full pairing URL to the file named by `CODEMUX_WEB_REMOTE_E2E_PAIRING_FILE` (created `0600` so only the launching user can read the token). `restore_on_boot` yields to this hook under the same gate so they never race on the bind. Lets an automated harness drive the served web client without a human clicking inside the native window.
+- **`auth::seed_dev_offline_login`** (`#[cfg(debug_assertions)]` + `CODEMUX_DEV_OFFLINE_LOGIN=1`): seeds a cached offline auth user via the normal `save_auth` path so a harness can boot the app without a real account or network. No-ops when a real session is already stored, so it never clobbers a genuine login.
+- **`?remote=1` under `npm run dev`**: forces `main.tsx` to install the real WebSocket shim (against a running server) instead of the in-process dev mock, so the production transport can be exercised in a plain browser during development.
+
+## Verification
+
+- **Rust unit/integration** (`cargo test`, `src-tauri`): the `web_remote` suite covers the pairing lifecycle (single-use, TTL, unknown/expired rejection), ticket single-use + binding + expiry, the rate limiter, `authenticate` round-trip + revocation + pending-approval, the origin check matrix, the connection registry (targeted `close_session` frame + signal), the session-admission gate (bearer + cookie, missing/unknown/pending/cross-origin), the channel router (marker rewrite incl. nested, JSON `chan` frame + binary frame shape, per-connection route teardown), the event fan-out, the endpoint enumeration (tailnet/LAN range detection, loopback-first-and-secure, IPv6 bracketing), the asset route (regular-file MIME + length, directory/missing → 404, MIME guessing), the snapshot route (`api_version`, the `{api_version, app_state, status}` envelope shape built from the real `AppStateStore::snapshot`, and its admission: unauth → 401, cross-origin → 403, approved session admitted), the proxy path guards (port range, control-char rejection), the **bind-scope** address resolution (`all`→0.0.0.0, `loopback`→127.0.0.1 only, `tailscale`→tailnet+loopback, no-tailnet→clear error with no silent 0.0.0.0 fallback, unknown→all), the **config bind-scope** serde default (legacy config without the field loads as `all`; snake-case wire contract), the **control-pair** command (errors when disabled and mints no token; when enabled mints a URL embedding a single-use token that pairs via the same `PairingStore` path and carries the `--name` label as a fallback), the pairing suggested-name round-trip, and the CLI's `render_qr` / `print_pairing` formatter. The terminal and agent-chat suites cover the fan-out invariants (two subscribers interleave, detach(A) leaves B streaming, pause(A alone) doesn't stall B, replay reaches only the attacher, registry detach removes only the matching subscriber).
+- **Frontend** (`npm run test`): shim contract tests against a fake WS (invoke round-trip, channel ordering, listen refcounting), the transport (reconnect/ticket flow), the snapshot seeding (`fetchSnapshot` envelope validation + failure → `null`; the first `get_app_state` served from the seed with no wire frame; the seed discarded once a newer WS snapshot arrived; clean fall-back when the prefetch fails; reconnect refetch pushed via `app-state-changed`, and discarded if a fresher WS event wins the race), the path-browser utils, the web-notifications delivery matrix, the remote-access settings utils + section (including the bind-scope segmented control defaulting to `all` and switching to `tailscale only`), the `web_remote_set_config` wrapper carrying `bindScope`, and the update-defer decision.
+- **Live end-to-end** (Stage 4): the real desktop app (`npm run tauri:dev`) with the server enabled, driven through the Codemux browser pane — pair via link + QR, watch a live terminal (replay + live bytes + input), run an agent-chat turn, open an existing project, create a new workspace via the web path-browser, second-client mirroring (desktop + web at once), revoke mid-session (socket drops), and reconnect behavior.
+
+## Relationship to remote hosts
+
+Web remote access is a **UI transport**: a second frontend for the same backend. Remote hosts (`docs/features/remote-hosts.md`) is a **compute transport**: the same frontend driving a backend on another machine over SSH. They are orthogonal and compose cleanly:
+
+- A paired **web client** issues the same invokes the desktop does, so it transparently sees and drives whatever remote hosts the desktop has configured — pushing a workspace to a host, watching a host-side agent, adopting a sibling workspace — with no web-specific host code.
+- The two never share a byte path: web traffic is browser ⇄ desktop (direct), host traffic is desktop ⇄ host (SSH). A future relay/account tier for web access is a strictly additive phase and does not touch the remote-hosts SSH stack.
+
+The clean mental model: remote hosts move *where the work runs*; web remote access moves *where you watch and steer it from*.
+
+## Important Touch Points
+
+- `src-tauri/src/web_remote/` — the embedded server: `mod.rs` (state/lifecycle/commands/boot hooks; `bind_addrs` bind-scope resolution; `mint_pairing` shared token path; `control_pair` for the CLI), `server.rs` (router, `bind(addr)`, connection registry, WS lifecycle, session gate, pair handler's suggested-name fallback), `auth.rs` (pairing/session/ticket/rate-limit/origin; `PairingStore::issue_named`/`consume_named` carry the CLI `--name` label), `dispatch.rs` (invoke via `on_message` + channel router), `events.rs` (`listen_any` hub), `endpoints.rs` (endpoint enumeration + `tailnet_ips()` for the tailscale bind scope), `assets.rs` (`/api/assets`), `proxy.rs` (browser-pane proxy), `snapshot.rs` (`/api/snapshot` bulk state bootstrap).
+- `src-tauri/src/control.rs` — the `web_remote_pair` control-socket command (same-machine, drives `web_remote::control_pair`); `src-tauri/src/cli.rs` — the `codemux remote pair [--name ]` subcommand + `render_qr` terminal QR (`qrcode` dep in `Cargo.toml`).
+- `src-tauri/src/lib.rs` — registers `WebRemoteState`, wires the channel interceptor to `channel_router().route`, calls `restore_on_boot` + `e2e_autostart` in `setup`, and lists the twelve `web_remote_*` commands in `generate_handler!`.
+- `src-tauri/src/database.rs` — the `web_remote_sessions` table + its accessors (insert/get/list/active-hashes/touch/approve/revoke/delete).
+- `src-tauri/src/terminal/mod.rs`, `src-tauri/src/commands/agent_chat.rs` — the multi-subscriber stream fan-out.
+- `src-tauri/src/notifications.rs` — emits the global `notification` event the web bridge consumes.
+- `src/main.tsx` — three-way runtime shim selection (real / dev mock / web shim; `?remote=1` dev override).
+- `src/remote/` — `bootstrap.tsx` (pairing screen + connect loop), `shim.ts` (installs `__TAURI_INTERNALS__`; seeds the first `get_app_state` + reconnect re-seed), `transport.ts` (WS + reconnect + ticket flow), `snapshot-seed.ts` (the `/api/snapshot` prefetch feeding the seed), `session.ts` (localStorage session), `status-banner.ts` (remote indicator), `web-remote-events.ts` (`web-remote-state-changed` helper), `bootstrap-entry.ts`.
+- `src/components/remote/` — `is-remote-client.ts` (the `isRemoteClient()` source of truth), `remote-path-picker.tsx` + `-store.ts` + `path-utils.ts` (web file/folder picker).
+- `src/components/settings/remote-access-section.tsx` (+ `remote-access-utils.ts`, `use-qr-svg.ts`) — the Remote Access settings pane.
+- `src/hooks/use-web-notifications.ts` — Web Notifications / toast bridge.
+- `src/hooks/use-update-checker.ts` — desktop updater as the single updater; publishes availability + defers restart while remote devices attach.
+- `src/hooks/use-scrollback-serializer.ts` — no-ops on the web client (desktop owns serialization).
+- `src/lib/file-dialog.ts` — routes to the web path picker when remote.
+- `src/components/layout/{window-chrome,title-bar}.tsx`, `src/components/browser/BrowserPane.tsx` — remote-gated chrome + browser-pane proxy wiring.
+- `docs/plans/web-remote-access.md` — the locked design contract, WS protocol reference, and remaining work.
+
+## Notes
+
+- The dev mock (`src/dev/tauri-mock.ts`) is the reference for the `Channel` `{index, message}` delivery shape; keep the production shim's channel dispatch aligned with it.
+- Keep the WS protocol frame shapes in `dispatch.rs`/`events.rs` and `transport.ts`/`shim.ts` in lockstep — they are two ends of one contract.
+- Current-truth lives here; active next steps (relay/account tier, per-client views, per-command ACL scopes, browser-pane screencast confirmation on a real display, updater UX against a published release) live in `docs/plans/web-remote-access.md`.
diff --git a/docs/plans/web-remote-access.md b/docs/plans/web-remote-access.md
new file mode 100644
index 00000000..3c876de6
--- /dev/null
+++ b/docs/plans/web-remote-access.md
@@ -0,0 +1,92 @@
+# Web Remote Access — Implementation Plan (locked spec)
+
+- Purpose: Track the remaining work on web remote access and preserve the locked design contract. Stages 1–4 have landed; current-truth lives in `docs/features/web-remote-access.md`.
+- Audience: Anyone changing this area. The design decisions + WS protocol contract below are the canonical wire spec both ends must match.
+- Authority: Locked design contract + remaining-work tracker. Current behavior is canonical in `docs/features/web-remote-access.md`.
+- Update when: A contract changes, a deferred item is picked up, or an open question is resolved.
+- Read next: `docs/features/web-remote-access.md`, `docs/features/remote-hosts.md`, `docs/features/dev-mock-runtime.md`, `docs/features/terminal.md`, `docs/features/agent-chat.md`
+
+## Goal
+
+The desktop app grows an embedded, default-off HTTP+WebSocket server. A browser on another machine (laptop, phone) loads the same UI bundle and drives the same running instance — same projects, sessions, terminals, agent chats. Work continues when the browser disconnects (PTY daemon already guarantees this). Reachability v1: LAN + the user's own tailnet (mesh VPN detected, not embedded), with trusted HTTPS via the mesh's serve feature. A relay/account tier is a later, strictly additive phase.
+
+This is a **UI transport** (second frontend for the same backend). The existing remote-hosts system is a **compute transport** (same frontend, remote backend). They compose: a web client automatically sees and drives remote hosts because all of that logic lives behind the invoke surface.
+
+## Design decisions (locked)
+
+1. **The seam is `window.__TAURI_INTERNALS__`.** The dev mock (`src/dev/tauri-mock.ts`) proves the entire UI runs in a plain browser through a 5-member internals object + `window.__TAURI_EVENT_PLUGIN_INTERNALS__`. The web client installs a *real* shim backed by a WebSocket instead of fixtures. Zero changes to app components.
+2. **Server lives in the desktop app process** (module `src-tauri/src/web_remote/`), axum. It serves the frontend bundle via Tauri's `AssetResolver` (assets are embedded in the binary in production — there is no `dist/` on disk) and exposes `/api/*` + `/ws`.
+3. **Default off. Nothing binds until the user enables it.** When enabled, bind `` (default port 4377, configurable) on the interfaces the **bind scope** selects — `all` (`0.0.0.0`, default) / `tailscale` (tailnet + loopback) / `loopback` (127.0.0.1 only). Disable tears the listener(s) down. `tailscale` with no tailnet address fails the enable rather than falling back to `0.0.0.0`.
+4. **Auth: one-time pairing token → persistent revocable session.** Sessions in SQLite, token hashed (SHA-256) at rest, constant-time compare. WS connects via short-lived single-use tickets so session tokens never appear in WS URLs. Optional approval mode: new pairings sit pending until approved on the desktop.
+5. **Full command surface for authenticated sessions.** A paired device has desktop-level control by design (it IS the desktop). Security boundary is pairing + revocation + network layer, not per-command ACLs (future work).
+6. **Multi-client is mirror-mode v1.** All clients see the same `AppStateSnapshot` including `active_workspace_id`/focus. Per-client views are explicitly out of scope for v1.
+7. **Desktop window is the scrollback-serialization owner.** Web clients never ack `serialize-terminal-buffers` and never write scrollback files.
+8. **TLS is delegated to the mesh serve feature or the user's own proxy in v1.** The settings UI surfaces the secure-context consequence (clipboard etc. degrade on plain HTTP).
+9. **No PTY bytes through any cloud service, ever.** Browser ⇄ desktop traffic is direct.
+
+## WS protocol contract (both sides MUST match this exactly)
+
+Endpoint: `GET /ws?ticket=` → upgrade after ticket validation.
+
+Text frames (JSON):
+
+- C→S `{"t":"invoke","id":,"cmd":"","args":{...}}` — args are JSON-serialized by the shim; `Channel` instances serialize (via `@tauri-apps/api` `toJSON`) to `"__CHANNEL__:"` strings; the shim's own `transformCallback` registry issues the ids.
+- S→C `{"t":"ok","id":,"data":}` and `{"t":"err","id":,"error":}` — id-matched, out-of-order responses allowed (server spawns a task per invoke).
+- C→S `{"t":"listen","event":""}` / `{"t":"unlisten","event":""}`; S→C `{"t":"event","event":"","payload":}`. Server implements this with `app.listen_any(name)` registered on first subscriber, unregistered on last.
+- S→C channel data (JSON bodies): `{"t":"chan","ch":,"idx":,"data":}`.
+
+Binary frames (raw channel bodies, e.g. PTY bytes): `[0x01][u32 BE callbackId][u64 BE idx][payload]`. The shim delivers `{index, message}` to the stored callback exactly as `src/dev/tauri-mock.ts` does (that mock IS the reference for what `@tauri-apps/api` `Channel` expects).
+
+Keepalive: server pings every 30s; missed pong ×2 closes. On close, the shim reconnects with exponential backoff (1s → 16s cap), re-sends `listen` subscriptions, and re-issues a ticket first (`POST /api/ws-ticket`). In-flight invokes reject on disconnect; the UI's own `get_app_state`-on-mount + snapshot events self-heal state.
+
+## HTTP surface
+
+- `GET /*` — static UI bundle via `AssetResolver` (public; the bundle is not a secret; all state is behind auth).
+- `POST /api/pair` `{token, device_name}` → `{session_id, session_token, approved}` + `Set-Cookie` (HttpOnly, SameSite=Strict, for ` `/asset GETs). Rate-limited (5/min/IP). One-time tokens: 32 random bytes, 10 min TTL, single use, in-memory.
+- `POST /api/ws-ticket` (Bearer or cookie) → `{ticket}` (30s TTL, single-use).
+- `GET /api/snapshot` (auth required) → `{api_version, app_state, status}`, `Cache-Control: no-store`, `application/json`. One-shot bulk state bootstrap: `app_state` is the exact value `get_app_state` returns (same `AppStateStore::snapshot()` path), `status` is the `WebRemoteStatus` payload. The web client fetches it **in parallel** with the WS handshake to seed the first render; also the first piece of a versioned API for future native (non-web) clients (bump `api_version` on a breaking envelope change).
+- `GET /api/assets?path=` (auth required) — replacement for `convertFileSrc`; streams a regular file, non-files → 404.
+- `GET /proxy/browser/:port/ws` + `ANY /proxy/browser/:port/api/*rest` (auth required) — browser-pane proxy to the loopback agent-browser daemons; port validated against the 9223–9299 range, control chars rejected, minimal request rebuilt for the daemon's single-segment read.
+- `GET /api/health` (public, version only).
+- Origin checks on WS upgrade and every state-touching `/api/*` + `/proxy/*` route: Origin host must match request Host (same-origin client only); missing Origin allowed (non-browser clients).
+
+## Rust dispatch strategy (Lane A — shipped as Option 1)
+
+The ~300 commands are registered once in `src-tauri/src/lib.rs` (`generate_handler!`). The web dispatcher builds a real `tauri::webview::InvokeRequest` and drives it through the main window's webview via `WebviewWindow::on_message` — reusing arg deserialization, `State`/`AppHandle`/`Window` extraction, ACL resolution, and error formatting for every command with zero per-command wiring. A `Builder`-registered channel **interceptor** + `ChannelRouter` re-route each command's `Channel` frames to the owning browser (JSON → `chan` frame, raw → binary frame); this is uniform across every channel-taking command and needs no knowledge of their signatures.
+
+## Already landed
+
+Stages 1–4 plus an adversarial review pass have shipped on this branch (all verified: `cargo check`/`test`, `npm run check`/`test`/`build`, plus a live end-to-end drive). Canonical behavior is in `docs/features/web-remote-access.md`.
+
+- **Stage 1 — Foundation.**
+ - *Rust server* (`src-tauri/src/web_remote/`): `mod.rs` (state/config/lifecycle/commands/boot-restore), `server.rs` (axum router + connection registry + WS lifecycle + session gate), `auth.rs` (pairing/sessions/tickets/rate-limit/origin), `dispatch.rs` (invoke via `on_message` + channel router), `events.rs` (`listen_any` hub), `endpoints.rs` (loopback/LAN/tailnet/MagicDNS enumeration). SQLite `web_remote_sessions` migration + accessors. Twelve `web_remote_*` commands registered in `generate_handler!`; config persists and `restore_on_boot` re-binds on boot.
+ - *Frontend shim* (`src/remote/`): `transport.ts` (WS + reconnect + ticket flow), `shim.ts` (installs `__TAURI_INTERNALS__` + `__TAURI_EVENT_PLUGIN_INTERNALS__`), `bootstrap.tsx` (pairing screen + connect loop), `session.ts`, `status-banner.ts`. `main.tsx` selects real-IPC / dev-mock / web-shim three ways; `?remote=1` dev override.
+ - *Stream fan-out*: terminal `SessionRuntime` moved to a per-generation subscriber list (targeted replay, ALL-subscribers-pause flow control, generation-scoped detach); `AgentChatChannelRegistry` moved to list-per-thread. Desktop single-client behavior byte-identical.
+- **Stage 2 — Settings & device management UI** (`src/components/settings/remote-access-section.tsx` + `remote-access-utils.ts` + `use-qr-svg.ts`): master toggle + exposure warning, port field, endpoint list with copy + per-endpoint security note, pairing generator with client-side QR + expiry countdown, paired-devices list with revoke/revoke-all, approval-mode toggle + pending-approval flow, live `web-remote-state-changed` updates, secure-context-aware clipboard, and the "Remote — connected to ``" indicator.
+- **Stage 3 — Web fallbacks & parity**: web path browser (`src/components/remote/remote-path-picker.tsx`) unlocking remote project/workspace creation; Web Notifications + toast bridge (`use-web-notifications.ts`); `convertFileSrc` → `/api/assets` (`assets.rs`); browser-pane proxy (`proxy.rs`); hidden window chrome; desktop-as-single-updater with defer-while-remote + web-triggered restart (`use-update-checker.ts`, `web_remote_publish_update_available` / `web_remote_request_update`). All gated on `isRemoteClient()`.
+- **Stage 4 — End-to-end verification**: live drive through the Codemux browser pane against `npm run tauri:dev` — pair (link + QR), live terminal (replay + input), agent-chat turn, open project, create workspace via the web path-browser, desktop+web mirroring, revoke-mid-session socket drop, reconnect.
+- **Adversarial review pass**: hardened the browser-pane proxy against HTTP request/header (CRLF) injection on the percent-decoded catch-all path (`has_control_char` + 400 guard + regression test); deduped three inline `isRemoteClient()` copies to `src/components/remote/is-remote-client.ts`.
+- **HTTP snapshot bootstrapping.** Added `GET /api/snapshot` (`src-tauri/src/web_remote/snapshot.rs`) — an authed, versioned `{api_version, app_state, status}` envelope built from the **same** `AppStateStore::snapshot()` the `get_app_state` command uses (no duplicated serialization) with `Cache-Control: no-store`. The web shim (`src/remote/{snapshot-seed,shim}.ts`) prefetches it **concurrently** with the WS ticket + upgrade and answers the app's first `get_app_state` from it, so the UI paints without a post-mount socket round-trip. WS stays the source of truth: the seed is served only while a monotonic `wsAppStateSeq` (bumped on each `app-state-changed` event) is still `0`, so a WS-delivered snapshot always wins and no stale seed can overwrite newer state; the prefetch is never awaited on the hot path and any failure falls back to the WS path silently. On every reconnect the shim refetches and re-applies via the `app-state-changed` path (discarded if a fresher WS event lands first) to catch up on state missed during the outage. Desktop/Tauri and dev-mock paths are byte-identical (the `fetchSnapshot` shim option is only supplied by the web bootstrap). Deliberately shaped as the first piece of a versioned API for future native (non-web) clients. Tests: Rust snapshot route (`api_version` + envelope shape + 401/403/200 admission); TS seeding (`fetchSnapshot` validation/failure, seed-when-pending with no wire frame, discard-when-superseded, clean fall-back, reconnect re-seed + stale-race discard). Verified: `cargo check`/`test`, `npm run check`/`test`/`build` all green.
+
+### Hardening — CLI pairing over SSH + Tailscale-only bind scope (landed)
+
+Two related hardening features shipped on this branch; canonical behavior is in `docs/features/web-remote-access.md` (§ "Bind scope", § "Pairing from the terminal").
+
+- **`codemux remote pair [--name ]`.** Mint a web-remote pairing code from a shell — the away-from-home SSH flow, no GUI needed. The control socket is same-machine + unauthenticated by design, so over SSH it is the right transport. A new `web_remote_pair` control command (`control.rs`) drives `web_remote::control_pair`: it errors if remote access is off (`Remote access is not enabled — enable it in Settings first`), else mints a one-time token through the **shared** `mint_pairing` path (no duplicate token logic) and pairs it to the endpoint enumeration's `recommended` pick. The CLI prints a scannable **terminal QR** (pure-Rust `qrcode`, unicode renderer — no image deps) plus link/token/endpoint/expiry. `--name` rides on the token as a fallback device label (`PairingStore::issue_named`/`consume_named`), used by the pair handler only when the client sends no name. Tests: control-pair disabled→error / enabled→usable single-use token that pairs via the existing path; suggested-name round-trip; `render_qr`/`print_pairing`.
+- **Bind scope (`bind_scope`).** New persisted config field (`all` default | `tailscale` | `loopback`), `#[serde(default)]` so old configs load as `all`. `bind_addrs(scope, port)` resolves it to concrete addresses; `start_server` binds one listener per address (partial-failure-safe) sharing one router + shutdown signal. `tailscale` binds the tailnet IP(s) from `endpoints::tailnet_ips()` **plus** loopback; with no tailnet address it fails the enable with `No Tailscale address found — …` and keeps the server off (never a silent `0.0.0.0` fallback). `web_remote_enable` rolls `enabled` back on bind failure; `web_remote_set_config` accepts `bind_scope`, validates it, rebinds on change (same drop-connections path as a port change), and restores the last-good scope/port if the new one can't bind. Surfaced on `WebRemoteStatus.bind_scope` and chosen via a Settings segmented control ("Who can connect"). Tests: bind-scope resolution matrix, serde default, status wire contract, settings control + wrapper.
+
+## Remaining work (deferred, not blocking v1)
+
+- **Relay / account tier (post-v1).** Optional cloud relay so a browser can drive the desktop without being on the same LAN/tailnet. Strictly additive — no PTY bytes through the relay (decision 9). Protocol substrate candidates researched (Rust-native E2E QUIC w/ hole punching vs a managed tunnel); decision deferred. Account (OAuth) sign-in stays desktop-only; the relay forwards a *verified device*, not an account login.
+- **Per-client (non-mirrored) views.** v1 is mirror-mode (decision 6): all clients share `active_workspace_id`/focus. Independent per-client focus/view state is a deep state-model refactor, explicitly deferred.
+- **Per-command ACL scopes for paired devices.** v1 is all-or-nothing by design (decision 5) — a paired device has full desktop control. Scoped/read-only pairings are future work.
+- **Browser-pane screencast confirmation on a real display.** The browser-pane proxy is wired and unit-tested, but the end-to-end screencast + input round-trip through the proxy still wants a confirmation pass against a real `agent-browser` daemon on a real display (the Stage-4 drive exercised terminals + chat, not the browser pane live).
+- **Updater UX against a published release.** The defer-while-remote + web-triggered-restart flow is implemented and unit-tested, but the full "web client asks the desktop to update, desktop downloads a *published* release + restarts, web client reconnects via backoff" loop still wants a live pass once there is a release to update *to*. Residual risk (a new build fails to boot while the user is remote) is documented; an optional boot watchdog is future work.
+
+## Update-while-remote policy (shipped)
+
+Remote sessions active → auto-update restart is deferred by default; the web client can explicitly trigger "update and restart" (PTY daemon keeps agents alive across the restart; web client reconnects via backoff). Residual risk (new build fails to boot while abroad) is documented; an optional watchdog is future work. See "Updater UX against a published release" above for the remaining live pass.
+
+## Likely touch points
+
+See `docs/features/web-remote-access.md` § "Important Touch Points" for the full map. Reference evidence for future work: `src/dev/tauri-mock.ts` (shim/channel contract), `src-tauri/src/web_remote/*` (server + auth + dispatch + proxy), `src-tauri/src/remote/{server,auth,manifest}.rs` (the remote-hosts daemon's axum + bearer patterns, and the reserved `Identity::Cloud` variant the relay tier would reuse).
diff --git a/docs/reference/CONTROL.md b/docs/reference/CONTROL.md
index e1a873ec..b5b46cc3 100644
--- a/docs/reference/CONTROL.md
+++ b/docs/reference/CONTROL.md
@@ -45,6 +45,7 @@
- browser control: `create_browser_pane`, `open_url`, `browser_automation`
- memory and handoff: `get_project_memory`, `update_project_memory`, `add_project_memory_entry`, `generate_handoff`
- indexing: `rebuild_index`, `index_status`, `search_index`
+- web remote access: `web_remote_pair` (mint a one-time pairing code; errors if remote access is disabled). Same-machine only — this is the SSH-in-and-pair path, no GUI needed.
## Boundary Notes
@@ -65,10 +66,17 @@ codemux browser snapshot
codemux memory show
codemux handoff
codemux index build
+codemux remote pair
+codemux remote pair --name "Kitchen iPad"
codemux logs --tail 200
codemux doctor
```
+`codemux remote pair` prints a scannable terminal QR of the pairing URL plus
+the raw link, token, and expiry — pair a phone/laptop over SSH without
+opening the desktop GUI. Requires remote access to be enabled in Settings
+first (`Settings → Remote Access`).
+
## Local Diagnostics
`codemux logs` and `codemux doctor` run entirely locally — no running
diff --git a/package-lock.json b/package-lock.json
index af1c49b7..939d2de4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -56,6 +56,7 @@
"motion": "^12.42.2",
"nanoid": "^5.1.16",
"next-themes": "^0.4.6",
+ "qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@@ -78,6 +79,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.0",
@@ -1745,72 +1747,6 @@
}
}
},
- "node_modules/@inquirer/core/node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/@inquirer/core/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@inquirer/core/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@inquirer/core/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@inquirer/core/node_modules/wrap-ansi": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
- "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/@inquirer/figures": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz",
@@ -5929,6 +5865,26 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
+ "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -6240,7 +6196,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6511,6 +6466,15 @@
"node": ">=6"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001781",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
@@ -6704,28 +6668,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/cliui/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/cliui/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -6793,7 +6735,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -6806,7 +6747,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
"license": "MIT"
},
"node_modules/comma-separated-tokens": {
@@ -7552,6 +7492,15 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -7717,6 +7666,12 @@
"node": ">=0.3.1"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
@@ -7793,6 +7748,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -8320,6 +8281,19 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -8441,7 +8415,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -9126,7 +9099,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -9776,6 +9748,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
@@ -11520,6 +11504,42 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/package-manager-detector": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
@@ -11631,6 +11651,15 @@
"integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
"license": "MIT"
},
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -11695,6 +11724,15 @@
"node": ">=16.20.0"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/points-on-curve": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
@@ -11876,6 +11914,87 @@
"node": ">=6"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/qrcode/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -12468,7 +12587,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -12484,6 +12602,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -12752,6 +12876,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -13084,6 +13214,32 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -13518,6 +13674,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
@@ -14099,6 +14262,12 @@
"node": ">=18"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -14116,6 +14285,47 @@
"node": ">=8"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -14225,41 +14435,6 @@
"node": ">=12"
}
},
- "node_modules/yargs/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/yargs/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yargs/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/yoctocolors": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
diff --git a/package.json b/package.json
index a06632fd..f3793ac9 100644
--- a/package.json
+++ b/package.json
@@ -66,6 +66,7 @@
"motion": "^12.42.2",
"nanoid": "^5.1.16",
"next-themes": "^0.4.6",
+ "qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@@ -88,6 +89,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.0",
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 8cfbb37a..851d2e8d 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -425,6 +425,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
+ "base64 0.22.1",
"bytes",
"futures-util",
"http",
@@ -443,8 +444,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
+ "sha1",
"sync_wrapper",
"tokio",
+ "tokio-tungstenite 0.24.0",
"tower",
"tower-layer",
"tower-service",
@@ -938,6 +941,7 @@ dependencies = [
"glob",
"hkdf",
"hostname",
+ "if-addrs",
"ignore",
"libc",
"log",
@@ -947,6 +951,7 @@ dependencies = [
"nucleo-matcher",
"png 0.17.16",
"portable-pty",
+ "qrcode",
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
@@ -969,7 +974,8 @@ dependencies = [
"tauri-plugin-updater",
"tempfile",
"tokio",
- "tokio-tungstenite",
+ "tokio-tungstenite 0.26.2",
+ "tokio-util",
"toml 1.1.2+spec-1.1.0",
"tower",
"url",
@@ -2639,6 +2645,16 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "if-addrs"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "ignore"
version = "0.4.25"
@@ -4225,6 +4241,12 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
+[[package]]
+name = "qrcode"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
+
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -6281,6 +6303,18 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite 0.24.0",
+]
+
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
@@ -6290,7 +6324,7 @@ dependencies = [
"futures-util",
"log",
"tokio",
- "tungstenite",
+ "tungstenite 0.26.2",
]
[[package]]
@@ -6543,6 +6577,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.8.5",
+ "sha1",
+ "thiserror 1.0.69",
+ "utf-8",
+]
+
[[package]]
name = "tungstenite"
version = "0.26.2"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 54e7fb91..6bb38af6 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -94,8 +94,30 @@ rrule = "0.14"
# tunnelled via SSH when remote) is the v1 design constraint that lets
# us drop in an optional cloud relay later without changing the daemon.
# See docs/plans/mcp-on-remote.md.
-axum = "0.7"
+#
+# The `ws` feature is required by the embedded web-remote-access server
+# (`src/web_remote/`), which upgrades `/ws` connections to drive the same
+# frontend bundle from a browser on another device. It pulls in
+# tokio-tungstenite + sha1 + base64 (all already in the tree).
+axum = { version = "0.7", features = ["ws"] }
tower = "0.5"
+# Streaming body for the authenticated `/api/assets` route: `ReaderStream`
+# wraps a `tokio::fs::File` so large files (images referenced from the web
+# client) are piped chunk-by-chunk instead of buffered whole into memory.
+# The `io` feature adds no new transitive crates (bytes/futures-core are
+# already in the tree).
+tokio-util = { version = "0.7", features = ["io"] }
+# Local network-interface enumeration for the web-remote endpoint list
+# (loopback + LAN IPs). Thin wrapper over `getifaddrs`/`GetAdaptersAddresses`;
+# `libc` (already a dependency) backs it on Unix. Tailnet IPs + MagicDNS are
+# discovered separately by shelling out to `tailscale status --json`.
+if-addrs = "0.13"
+# Pure-Rust QR encoder for `codemux remote pair`, which prints a scannable
+# terminal QR of the pairing URL so a phone can pair over SSH without the
+# desktop GUI. `default-features = false` drops the optional image/svg/pic
+# renderers — we only use the built-in unicode (`Dense1x2`) renderer, so this
+# adds no image-processing crates to the tree.
+qrcode = { version = "0.14", default-features = false }
[target.'cfg(unix)'.dependencies]
tauri-plugin-dialog = { version = "2", default-features = false, features = ["xdg-portal"] }
diff --git a/src-tauri/src/agent_provider/claude/sidecar_path.rs b/src-tauri/src/agent_provider/claude/sidecar_path.rs
index dc7374f1..6fdca8b4 100644
--- a/src-tauri/src/agent_provider/claude/sidecar_path.rs
+++ b/src-tauri/src/agent_provider/claude/sidecar_path.rs
@@ -16,11 +16,15 @@
//! 3. `/binaries/codemux-claude-sidecar` — an optional
//! triple-less fallback for environments that flatten names.
//!
-//! Tauri resource-dir resolution is intentionally NOT attempted here.
-//! That requires a live `tauri::AppHandle`, which adapter code does
-//! not have at construction time. When the main runtime loads the
-//! provider from a Tauri command path, it should set
-//! `CODEMUX_CLAUDE_SIDECAR_PATH` before calling us.
+//! Tauri resource-dir resolution is intentionally NOT attempted by
+//! [`resolve_sidecar_path`]. That requires a live `tauri::AppHandle`,
+//! which adapter code does not have at construction time. Instead the
+//! main runtime calls [`resolve_sidecar`] at startup — where the
+//! resource dir IS available — and pins the winner into
+//! `CODEMUX_CLAUDE_SIDECAR_PATH` before the adapter is constructed.
+//! [`resolve_sidecar`] also carries a debug-only dev-tree fallback so
+//! `npm run tauri:dev` (whose resource dir does not contain the
+//! sidecar) resolves the `src-tauri/binaries/` copy.
use std::path::{Path, PathBuf};
@@ -60,23 +64,35 @@ fn exe_suffix() -> &'static str {
}
}
+/// Triple-qualified file name of the staged sidecar binary, e.g.
+/// `codemux-claude-sidecar-x86_64-unknown-linux-gnu[.exe]`. This is
+/// the name `scripts/build-claude-sidecar.sh` writes into
+/// `src-tauri/binaries/` and the name Tauri copies into the resource
+/// dir for release builds.
+fn sidecar_binary_name() -> String {
+ format!("codemux-claude-sidecar-{}{}", target_triple(), exe_suffix())
+}
+
+/// The `CODEMUX_CLAUDE_SIDECAR_PATH` override, if set to a non-empty
+/// value. Highest-priority source everywhere the sidecar is resolved.
+fn env_override() -> Option {
+ match std::env::var(SIDECAR_PATH_ENV) {
+ Ok(value) if !value.is_empty() => Some(PathBuf::from(value)),
+ _ => None,
+ }
+}
+
/// Candidate locations to probe, in priority order.
fn candidate_paths() -> Vec {
let mut candidates = Vec::new();
- if let Ok(explicit) = std::env::var(SIDECAR_PATH_ENV) {
- if !explicit.is_empty() {
- candidates.push(PathBuf::from(explicit));
- }
+ if let Some(explicit) = env_override() {
+ candidates.push(explicit);
}
- let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
- let binaries = manifest_dir.join("binaries");
- let triple = target_triple();
- let ext = exe_suffix();
-
- candidates.push(binaries.join(format!("codemux-claude-sidecar-{triple}{ext}")));
- candidates.push(binaries.join(format!("codemux-claude-sidecar{ext}")));
+ let binaries = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("binaries");
+ candidates.push(binaries.join(sidecar_binary_name()));
+ candidates.push(binaries.join(format!("codemux-claude-sidecar{}", exe_suffix())));
candidates
}
@@ -105,6 +121,56 @@ pub fn resolve_sidecar_path() -> Result {
})
}
+/// Resolve the sidecar path for the main runtime at startup, where a
+/// Tauri `resource_dir` IS available (unlike the adapter-side
+/// [`resolve_sidecar_path`]). Returns the path that should be pinned
+/// into [`SIDECAR_PATH_ENV`] so downstream adapter code — which has no
+/// `AppHandle` — finds the same binary.
+///
+/// Priority:
+/// 1. An already-set [`SIDECAR_PATH_ENV`] override — returned
+/// verbatim and never overridden, because the e2e suite and unit
+/// tests pin it explicitly.
+/// 2. `/binaries/codemux-claude-sidecar-` —
+/// the packaged/release location Tauri copies from the
+/// `resources` glob in `tauri.conf.json`.
+/// 3. Debug builds only: `/binaries/…` — the
+/// dev-tree copy staged by `scripts/build-claude-sidecar.sh`.
+/// `resource_dir` does NOT contain it under `npm run tauri:dev`,
+/// so without this fallback the chat GUI fails to spawn the
+/// sidecar in run-from-source builds. Compiled out of release
+/// builds via `debug_assertions`, so release resolution stays
+/// resource-dir only.
+///
+/// Returns `None` when the binary exists nowhere (e.g. a fresh
+/// checkout that never ran the build script); callers should leave the
+/// provider slot empty rather than panic — resolution then falls to
+/// `provider_not_configured` semantics.
+pub fn resolve_sidecar(resource_dir: Option<&Path>) -> Option {
+ if let Some(explicit) = env_override() {
+ return Some(explicit);
+ }
+
+ if let Some(dir) = resource_dir {
+ let candidate = dir.join("binaries").join(sidecar_binary_name());
+ if candidate.exists() {
+ return Some(candidate);
+ }
+ }
+
+ #[cfg(debug_assertions)]
+ {
+ let candidate = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("binaries")
+ .join(sidecar_binary_name());
+ if candidate.exists() {
+ return Some(candidate);
+ }
+ }
+
+ None
+}
+
/// Assert the sidecar binary exists — used by the provider's health
/// check before a session is started.
pub fn probe_sidecar_binary_exists() -> Result {
@@ -131,6 +197,25 @@ pub fn is_executable(path: &Path) -> bool {
mod tests {
use super::*;
+ /// Serializes every test that reads or writes `SIDECAR_PATH_ENV`.
+ /// `env::set_var`/`remove_var` mutate process-global state that
+ /// leaks across the parallel test threads, so these tests must not
+ /// run concurrently or they clobber each other's view of the
+ /// override.
+ static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+ /// A fresh, empty temp directory unique to this call. Removed first
+ /// in case a prior run left it behind.
+ fn unique_tmp_dir() -> PathBuf {
+ use std::sync::atomic::{AtomicU64, Ordering};
+ static N: AtomicU64 = AtomicU64::new(0);
+ let n = N.fetch_add(1, Ordering::Relaxed);
+ let dir = std::env::temp_dir()
+ .join(format!("codemux-sidecar-test-{}-{n}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ dir
+ }
+
#[test]
fn target_triple_matches_cfg() {
let t = target_triple();
@@ -157,6 +242,7 @@ mod tests {
#[test]
fn env_var_override_is_first_candidate() {
+ let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// `env::set_var` leaks into sibling threads running in
// parallel; gate on a marker to avoid clobbering the real
// env in tests that actually spawn the sidecar.
@@ -167,4 +253,70 @@ mod tests {
Some(marker.to_string()));
std::env::remove_var(SIDECAR_PATH_ENV);
}
+
+ #[test]
+ fn resolve_sidecar_honors_env_override_verbatim() {
+ let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ // The override wins even when a valid resource-dir copy is
+ // present, and is returned verbatim without an existence check
+ // — the e2e suite pins a path the harness owns.
+ let marker = "/tmp/nonexistent-sidecar-override-marker-resolve";
+ std::env::set_var(SIDECAR_PATH_ENV, marker);
+
+ let tmp = unique_tmp_dir();
+ let binaries = tmp.join("binaries");
+ std::fs::create_dir_all(&binaries).unwrap();
+ std::fs::write(binaries.join(sidecar_binary_name()), b"real").unwrap();
+
+ let resolved = resolve_sidecar(Some(&tmp));
+
+ std::env::remove_var(SIDECAR_PATH_ENV);
+ let _ = std::fs::remove_dir_all(&tmp);
+ assert_eq!(resolved, Some(PathBuf::from(marker)));
+ }
+
+ #[test]
+ fn resolve_sidecar_prefers_resource_dir_when_present() {
+ let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ std::env::remove_var(SIDECAR_PATH_ENV);
+
+ let tmp = unique_tmp_dir();
+ let binaries = tmp.join("binaries");
+ std::fs::create_dir_all(&binaries).unwrap();
+ let staged = binaries.join(sidecar_binary_name());
+ std::fs::write(&staged, b"real").unwrap();
+
+ let resolved = resolve_sidecar(Some(&tmp));
+
+ let _ = std::fs::remove_dir_all(&tmp);
+ assert_eq!(resolved, Some(staged));
+ }
+
+ #[test]
+ fn resolve_sidecar_falls_back_when_resource_dir_missing_binary() {
+ let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ std::env::remove_var(SIDECAR_PATH_ENV);
+
+ // An empty resource dir forces step 2 to miss. In debug builds
+ // the dev-tree fallback then resolves the staged
+ // `src-tauri/binaries/` copy when it exists on this machine; in
+ // release builds (fallback compiled out) or a checkout that
+ // never ran the build script the result is `None`. Assert the
+ // exact branch this build+machine actually takes so the test is
+ // deterministic everywhere.
+ let empty_resource = unique_tmp_dir();
+ std::fs::create_dir_all(&empty_resource).unwrap();
+ let manifest_copy = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("binaries")
+ .join(sidecar_binary_name());
+
+ let resolved = resolve_sidecar(Some(&empty_resource));
+
+ let _ = std::fs::remove_dir_all(&empty_resource);
+ if cfg!(debug_assertions) && manifest_copy.exists() {
+ assert_eq!(resolved, Some(manifest_copy));
+ } else {
+ assert_eq!(resolved, None);
+ }
+ }
}
diff --git a/src-tauri/src/auth/mod.rs b/src-tauri/src/auth/mod.rs
index 2154fb76..151310c2 100644
--- a/src-tauri/src/auth/mod.rs
+++ b/src-tauri/src/auth/mod.rs
@@ -302,6 +302,38 @@ pub fn save_auth(
db.save_auth_token(&encrypted)
}
+/// Dev/test-only: seed an offline auth session so an automated end-to-end
+/// harness can drive the app without a real Codemux account or any network
+/// access.
+///
+/// Compiled **only into debug builds** (the `debug_assertions` gate) and, even
+/// there, dormant unless `CODEMUX_DEV_OFFLINE_LOGIN=1` is set — so it can never
+/// affect a shipped release. It writes a cached local user via the normal
+/// [`save_auth`] path; combined with pointing `CODEMUX_API_URL` at an
+/// unreachable endpoint, `check_auth` returns this user through its existing
+/// offline/network-error fallback branch (`Ok(load_cached_user(..))`). No-ops
+/// when a real session is already stored so it never clobbers a genuine login.
+#[cfg(debug_assertions)]
+pub fn seed_dev_offline_login(db: &DatabaseStore) {
+ if std::env::var("CODEMUX_DEV_OFFLINE_LOGIN").ok().as_deref() != Some("1") {
+ return;
+ }
+ if load_token(db).is_some() {
+ return;
+ }
+ let user = AuthUser {
+ id: "dev-offline".to_string(),
+ email: "dev@localhost".to_string(),
+ name: Some("Dev Mode".to_string()),
+ image: None,
+ };
+ let expires_at = (chrono::Utc::now() + chrono::Duration::days(3650)).to_rfc3339();
+ match save_auth(db, "dev-offline-token", &expires_at, Some(&user)) {
+ Ok(()) => eprintln!("[codemux::auth] seeded offline dev login (debug/e2e affordance)"),
+ Err(e) => eprintln!("[codemux::auth] seed_dev_offline_login failed: {e}"),
+ }
+}
+
/// Update only the `auth_method` field of the stored auth record,
/// preserving token/expires_at/user. Called by the signin paths
/// (email, github) and by `setup_sync_password` so the value
diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs
index 2e6315da..9fbac2d0 100644
--- a/src-tauri/src/cli.rs
+++ b/src-tauri/src/cli.rs
@@ -38,6 +38,11 @@ pub enum CommandSet {
#[command(subcommand)]
command: WorkspaceCommand,
},
+ /// Web remote-access operations
+ Remote {
+ #[command(subcommand)]
+ command: RemoteCommand,
+ },
/// Print recent lines from the desktop app's log file
Logs {
/// Number of lines from the end of the log to print
@@ -71,6 +76,20 @@ pub enum WorkspaceCommand {
},
}
+#[derive(Subcommand)]
+pub enum RemoteCommand {
+ /// Mint a one-time pairing code for the web remote server and print a
+ /// scannable link + terminal QR. Requires remote access to be enabled in
+ /// Settings first. Handy over SSH: pair a phone/laptop without opening
+ /// the desktop GUI.
+ Pair {
+ /// Optional label for the paired device. Used as a fallback name if
+ /// the connecting browser doesn't provide one of its own.
+ #[arg(long)]
+ name: Option,
+ },
+}
+
#[derive(Subcommand)]
pub enum BrowserCommand {
Create,
@@ -593,6 +612,31 @@ pub async fn maybe_run_cli() -> Result {
println!("{}", serde_json::to_string_pretty(&response).map_err(|error| error.to_string())?);
Ok(true)
}
+ Some(CommandSet::Remote { command }) => {
+ match command {
+ RemoteCommand::Pair { name } => {
+ let mut params = json!({});
+ if let Some(ref label) = name {
+ params["name"] = json!(label);
+ }
+ let response = send_control_request(ControlRequest {
+ command: "web_remote_pair".into(),
+ params,
+ })
+ .await?;
+ if !response.ok {
+ // Surface the handler's message (e.g. "Remote access is
+ // not enabled — enable it in Settings first") instead of
+ // a bare `null`.
+ return Err(response
+ .error
+ .unwrap_or_else(|| "Unknown error from control endpoint".to_string()));
+ }
+ print_pairing(&response.data.unwrap_or(json!(null)));
+ }
+ }
+ Ok(true)
+ }
Some(CommandSet::Logs { tail }) => {
// Purely local — reads the file tauri-plugin-log writes, so
// it works even when (especially when) the app won't start.
@@ -693,6 +737,12 @@ pub async fn maybe_run_cli() -> Result {
"rerun-setup": { "args": "[workspace-id]", "description": "Re-run setup (.codemuxinclude + scripts) for a workspace" }
}
},
+ "remote": {
+ "description": "Web remote-access operations",
+ "subcommands": {
+ "pair": { "args": "[--name ]", "description": "Mint a one-time web-remote pairing code + QR (requires remote access enabled)" }
+ }
+ },
"status": { "description": "Show Codemux app status" },
"notify": { "args": "", "description": "Send a notification to the user" },
"handoff": { "description": "Generate project handoff summary" },
@@ -714,6 +764,59 @@ pub async fn maybe_run_cli() -> Result {
}
}
+/// Pretty-print the `web_remote_pair` result for a human at a terminal: a
+/// scannable QR of the pairing URL, then the link, token, endpoint, and
+/// expiry. The QR encodes the same `http://host:port/#pair=` URL the
+/// link shows, so a phone camera can pair without any typing.
+fn print_pairing(data: &Value) {
+ let url = data["pairing_url"].as_str().unwrap_or_default();
+ let token = data["token"].as_str().unwrap_or_default();
+ let expires_at = data["expires_at"].as_str().unwrap_or_default();
+ let host = data["host"].as_str().unwrap_or_default();
+ let kind = data["endpoint_kind"].as_str().unwrap_or_default();
+ let secure = data["secure"].as_bool().unwrap_or(false);
+
+ println!();
+ match render_qr(url) {
+ Some(qr) => println!("{qr}"),
+ None => println!("(QR unavailable — use the link below)\n"),
+ }
+ println!("Pairing link: {url}");
+ println!("Token: {token}");
+ if !host.is_empty() {
+ let scope = if secure {
+ "secure context"
+ } else {
+ "plain HTTP — not a browser secure context"
+ };
+ println!("Endpoint: {host} ({kind}, {scope})");
+ }
+ if !expires_at.is_empty() {
+ println!("Expires: {expires_at}");
+ }
+ println!();
+ println!("Scan the QR with your phone — or open the link in any browser on a");
+ println!("device that can reach this machine — to pair it. One-time use.");
+}
+
+/// Render `text` to a compact terminal QR using the pure-Rust `qrcode`
+/// crate's built-in unicode renderer (two rows of modules per text line).
+/// Returns `None` if the text is too large to encode, in which case the
+/// caller falls back to printing just the link.
+fn render_qr(text: &str) -> Option {
+ use qrcode::render::unicode;
+ use qrcode::{EcLevel, QrCode};
+
+ // Medium EC keeps the code compact while tolerating a smudged terminal /
+ // camera glare; the pairing URL is short so this always fits.
+ let code = QrCode::with_error_correction_level(text, EcLevel::M).ok()?;
+ Some(
+ code.render::()
+ .quiet_zone(true)
+ .build(),
+ )
+}
+
fn normalize_memory_kind(kind: &str) -> &'static str {
match kind {
"pinned" | "pinned_context" => "pinned_context",
@@ -724,3 +827,37 @@ fn normalize_memory_kind(kind: &str) -> &'static str {
}
}
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn render_qr_encodes_the_pairing_url() {
+ // The pairing URL is short, so it always encodes; the unicode
+ // renderer yields a non-trivial block of half-height module rows.
+ let url = "http://100.101.102.103:4377/#pair=deadbeefdeadbeefdeadbeefdeadbeef";
+ let qr = render_qr(url).expect("pairing URL encodes to a QR");
+ assert!(qr.lines().count() > 8, "renders multiple module rows");
+ assert!(
+ qr.chars().any(|c| c == '█' || c == '▀' || c == '▄' || c == ' '),
+ "uses the unicode Dense1x2 block glyphs"
+ );
+ }
+
+ #[test]
+ fn print_pairing_handles_a_full_control_result_without_panicking() {
+ // Mirrors the `ControlPairing` JSON the `web_remote_pair` control
+ // command returns, so the CLI's formatter is exercised end-to-end.
+ let data = json!({
+ "pairing_url": "http://100.101.102.103:4377/#pair=abc123",
+ "host": "100.101.102.103",
+ "port": 4377,
+ "token": "abc123",
+ "expires_at": "2026-07-12T10:00:00Z",
+ "secure": false,
+ "endpoint_kind": "tailnet",
+ });
+ print_pairing(&data);
+ }
+}
+
diff --git a/src-tauri/src/commands/agent_chat.rs b/src-tauri/src/commands/agent_chat.rs
index aac33167..ec2c5ae3 100644
--- a/src-tauri/src/commands/agent_chat.rs
+++ b/src-tauri/src/commands/agent_chat.rs
@@ -160,41 +160,47 @@ struct AgentChatChannelEntry {
/// pane that already lost the thread (unmount racing a remount, or
/// a second pane re-attaching the same thread) can never tear down
/// the channel a newer subscriber just installed. Same guard idea
- /// as `SessionRuntime::output_channel_generation` on the PTY path.
+ /// as `OutputSubscriber::generation` on the PTY path.
generation: u64,
}
/// Registry of per-thread frontend event channels (Tauri-managed
/// state).
///
-/// Analogous to `SessionRuntime.output_channel` in `terminal/mod.rs`:
+/// Analogous to `SessionRuntime.output_subscribers` in `terminal/mod.rs`:
/// when a chat pane mounts with a bound thread it invokes
/// [`attach_agent_chat_output`] with a fresh `Channel`; the event
-/// bridge ([`forward_event`]) then routes that thread's live events —
-/// crucially the high-frequency `content_delta` token stream — to
-/// exactly that channel instead of broadcasting them app-wide.
+/// bridge ([`forward_event`]) then fans that thread's live events —
+/// crucially the high-frequency `content_delta` token stream — out to
+/// every attached channel instead of broadcasting them app-wide.
+///
+/// Each thread maps to a **list** of subscribers, not a single one, so
+/// the same thread can be mirrored to several consumers at once (the
+/// desktop window plus any browser client rendering the same thread).
+/// Every subscriber receives an identical event stream.
///
/// Replay split (decided in issue #75): the channel carries **live
/// events only**. Transcript-affecting events are persisted to SQLite
/// by `forward_event` regardless of whether a channel is attached, and
/// a late-attaching / resumed pane rebuilds history through the
/// existing DB hydrate (`agent_chat_list_messages` +
-/// `hydrateThread`). Events that fire while no channel is attached and
-/// that are not persisted (lifecycle notices like
+/// `hydrateThread`) — so no replay-on-attach machinery is needed here
+/// even for a fresh mirror client. Events that fire while no channel is
+/// attached and that are not persisted (lifecycle notices like
/// `session_state_changed`) are dropped — exactly the behaviour the
/// event-bus path had for unmounted panes, where no listener was
/// registered to hear them.
#[derive(Default)]
pub struct AgentChatChannelRegistry {
- channels: Mutex>,
+ channels: Mutex>>,
next_generation: AtomicU64,
}
impl AgentChatChannelRegistry {
- /// Install `channel` as the live subscriber for `thread_id`,
- /// replacing any previous subscriber (newest pane wins, like a PTY
- /// reattach). Returns the generation token the caller must hand
- /// back to [`detach`](Self::detach).
+ /// Add `channel` as a live subscriber for `thread_id`, alongside any
+ /// existing subscribers (fan-out — every attached consumer receives
+ /// the thread's events). Returns the generation token the caller
+ /// must hand back to [`detach`](Self::detach).
pub fn attach(
&self,
thread_id: &str,
@@ -202,45 +208,56 @@ impl AgentChatChannelRegistry {
) -> u64 {
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed) + 1;
let mut channels = self.channels.lock().expect("channel registry poisoned");
- channels.insert(
- thread_id.to_string(),
- AgentChatChannelEntry {
+ channels
+ .entry(thread_id.to_string())
+ .or_default()
+ .push(AgentChatChannelEntry {
channel,
generation,
- },
- );
+ });
generation
}
- /// Remove the subscriber for `thread_id`, but only when
- /// `generation` still matches the installed entry. Returns whether
- /// an entry was actually removed. Idempotent: detaching an unknown
- /// thread or a superseded generation is a no-op.
+ /// Remove the subscriber for `thread_id` whose generation matches.
+ /// Returns whether an entry was actually removed. Idempotent:
+ /// detaching an unknown thread or a superseded generation is a
+ /// no-op, and other subscribers on the same thread are untouched.
pub fn detach(&self, thread_id: &str, generation: u64) -> bool {
let mut channels = self.channels.lock().expect("channel registry poisoned");
- match channels.get(thread_id) {
- Some(entry) if entry.generation == generation => {
- channels.remove(thread_id);
- true
- }
- _ => false,
+ let Some(entries) = channels.get_mut(thread_id) else {
+ return false;
+ };
+ let before = entries.len();
+ entries.retain(|entry| entry.generation != generation);
+ let removed = entries.len() != before;
+ // Drop the now-empty bucket so the map doesn't accumulate keys
+ // for threads with no subscribers.
+ if entries.is_empty() {
+ channels.remove(thread_id);
}
+ removed
}
- /// Clone the live channel for `thread_id`, if any. Cloning is
- /// cheap (the `Channel` is internally ref-counted) and keeps the
- /// lock scope tight on the hot streaming path.
- pub fn channel_for(&self, thread_id: &str) -> Option> {
+ /// Clone every live channel for `thread_id`. Cloning is cheap (the
+ /// `Channel` is internally ref-counted) and keeps the lock scope
+ /// tight on the hot streaming path — the caller sends outside the
+ /// lock. Returns an empty vec when no consumer is attached.
+ pub fn channels_for(&self, thread_id: &str) -> Vec> {
let channels = self.channels.lock().expect("channel registry poisoned");
- channels.get(thread_id).map(|entry| entry.channel.clone())
+ channels
+ .get(thread_id)
+ .map(|entries| entries.iter().map(|entry| entry.channel.clone()).collect())
+ .unwrap_or_default()
}
}
/// Register a per-thread `Channel` that will receive every live
/// runtime event for `thread_id` (see [`AgentChatChannelRegistry`]).
-/// Returns the attach generation; the frontend passes it back to
-/// [`detach_agent_chat_output`] on unmount so a stale detach cannot
-/// clobber a newer attach.
+/// Multiple consumers can attach to the same thread at once (mirror
+/// mode); each gets an identical event stream. Returns the attach
+/// generation; the frontend passes it back to
+/// [`detach_agent_chat_output`] on unmount so a stale detach only ever
+/// removes its own subscriber, never a sibling's.
///
/// Not gated on `enable_agent_chat`, same rationale as
/// `agent_chat_list_messages`: a pane bound to a session that predates
@@ -260,7 +277,8 @@ pub fn attach_agent_chat_output(
/// Tear down the per-thread channel installed by
/// [`attach_agent_chat_output`]. Idempotent; a mismatched generation
-/// (a newer pane re-attached first) is a silent no-op.
+/// (the subscriber already went away, or belongs to a sibling consumer)
+/// is a silent no-op that leaves other subscribers on the thread intact.
#[tauri::command]
pub fn detach_agent_chat_output(
channels: State<'_, AgentChatChannelRegistry>,
@@ -1918,13 +1936,14 @@ pub fn forward_event(app: &AppHandle, event: ProviderRuntimeEvent
return;
}
// Thread-scoped events (incl. the content_delta token stream) go
- // over the per-thread Channel only — never the global bus. A
- // missing channel means no pane is currently attached to this
- // thread; transcript events were already persisted above, so the
- // DB hydrate replays them on (re)attach.
+ // over the per-thread Channels only — never the global bus. An empty
+ // subscriber list means no pane is currently attached to this thread;
+ // transcript events were already persisted above, so the DB hydrate
+ // replays them on (re)attach. When several consumers are attached
+ // (mirror mode), each receives an identical copy.
let channels: State<'_, AgentChatChannelRegistry> = app.state();
- if let Some(channel) = channels.channel_for(&thread_id.0) {
- if let Err(error) = channel.send(payload) {
+ for channel in channels.channels_for(&thread_id.0) {
+ if let Err(error) = channel.send(payload.clone()) {
eprintln!(
"[codemux::agent_chat] Failed to send event on thread channel {}: {error}",
thread_id.0
@@ -3135,14 +3154,25 @@ mod tests {
}
}
+ /// Fan a payload out to every channel the registry holds for a thread,
+ /// exactly as `forward_event` does for thread-scoped events.
+ fn fan_out(
+ registry: &AgentChatChannelRegistry,
+ thread: &str,
+ payload: AgentChatEventPayload,
+ ) {
+ for channel in registry.channels_for(thread) {
+ channel.send(payload.clone()).expect("send ok");
+ }
+ }
+
#[test]
fn registry_attach_routes_sends_through_channel() {
let registry = AgentChatChannelRegistry::default();
let (channel, captured) = capture_channel();
registry.attach("t1", channel);
- let live = registry.channel_for("t1").expect("channel installed");
- live.send(delta_payload("t1", "hello")).expect("send ok");
+ fan_out(®istry, "t1", delta_payload("t1", "hello"));
let received = captured.lock().unwrap();
assert_eq!(received.len(), 1);
@@ -3157,13 +3187,15 @@ mod tests {
}
#[test]
- fn registry_channel_for_unknown_thread_is_none() {
+ fn registry_channels_for_unknown_thread_is_empty() {
let registry = AgentChatChannelRegistry::default();
- assert!(registry.channel_for("nope").is_none());
+ assert!(registry.channels_for("nope").is_empty());
}
+ /// Two panes attached to the SAME thread (mirror mode) BOTH receive every
+ /// event — the multi-client fan-out guarantee.
#[test]
- fn registry_newest_attach_wins() {
+ fn registry_fans_out_to_all_subscribers() {
let registry = AgentChatChannelRegistry::default();
let (first, first_captured) = capture_channel();
let (second, second_captured) = capture_channel();
@@ -3171,36 +3203,59 @@ mod tests {
let g2 = registry.attach("t1", second);
assert_ne!(g1, g2, "each attach mints a fresh generation");
- registry
- .channel_for("t1")
- .expect("channel installed")
- .send(delta_payload("t1", "x"))
- .expect("send ok");
+ fan_out(®istry, "t1", delta_payload("t1", "x"));
+
+ assert_eq!(
+ first_captured.lock().unwrap().len(),
+ 1,
+ "first subscriber receives its copy"
+ );
+ assert_eq!(
+ second_captured.lock().unwrap().len(),
+ 1,
+ "second subscriber receives its copy"
+ );
+ }
+ /// Detaching one subscriber removes exactly that one and leaves the other
+ /// streaming (the per-generation teardown).
+ #[test]
+ fn registry_detach_removes_only_matching_subscriber() {
+ let registry = AgentChatChannelRegistry::default();
+ let (first, first_captured) = capture_channel();
+ let (second, second_captured) = capture_channel();
+ let g1 = registry.attach("t1", first);
+ let _g2 = registry.attach("t1", second);
+
+ assert!(registry.detach("t1", g1), "matching detach removes the entry");
+
+ fan_out(®istry, "t1", delta_payload("t1", "after"));
assert!(
first_captured.lock().unwrap().is_empty(),
- "superseded channel must not receive events"
+ "detached subscriber must receive nothing further"
+ );
+ assert_eq!(
+ second_captured.lock().unwrap().len(),
+ 1,
+ "the surviving subscriber keeps streaming"
);
- assert_eq!(second_captured.lock().unwrap().len(), 1);
}
#[test]
fn registry_detach_with_stale_generation_is_noop() {
let registry = AgentChatChannelRegistry::default();
- let (first, _) = capture_channel();
- let (second, second_captured) = capture_channel();
- let stale = registry.attach("t1", first);
- let _current = registry.attach("t1", second);
+ let (channel, captured) = capture_channel();
+ let generation = registry.attach("t1", channel);
- // The unmounting pane that owned `first` detaches late — the
- // newer subscriber must survive.
- assert!(!registry.detach("t1", stale));
- registry
- .channel_for("t1")
- .expect("newer channel still installed")
- .send(delta_payload("t1", "still-live"))
- .expect("send ok");
- assert_eq!(second_captured.lock().unwrap().len(), 1);
+ // A late detach carrying a generation that was never issued (or already
+ // removed) must not tear down the live subscriber.
+ assert!(!registry.detach("t1", generation + 999));
+ fan_out(®istry, "t1", delta_payload("t1", "still-live"));
+ assert_eq!(
+ captured.lock().unwrap().len(),
+ 1,
+ "live subscriber survives a stale detach"
+ );
}
#[test]
@@ -3209,7 +3264,7 @@ mod tests {
let (channel, _) = capture_channel();
let generation = registry.attach("t1", channel);
assert!(registry.detach("t1", generation));
- assert!(registry.channel_for("t1").is_none());
+ assert!(registry.channels_for("t1").is_empty());
// Idempotent on repeat.
assert!(!registry.detach("t1", generation));
}
@@ -3222,11 +3277,7 @@ mod tests {
registry.attach("thread-a", a);
registry.attach("thread-b", b);
- registry
- .channel_for("thread-a")
- .unwrap()
- .send(delta_payload("thread-a", "for-a"))
- .unwrap();
+ fan_out(®istry, "thread-a", delta_payload("thread-a", "for-a"));
assert_eq!(a_captured.lock().unwrap().len(), 1);
assert!(
diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs
index a87e5f42..378728ef 100644
--- a/src-tauri/src/control.rs
+++ b/src-tauri/src/control.rs
@@ -1452,6 +1452,25 @@ async fn dispatch_request(app: &AppHandle, request: ControlRequest) -> ControlRe
serde_json::to_value(serde_json::json!({ "runs": runs }))
.map_err(|error| error.to_string())
})(),
+ // ── Web remote access ──
+ //
+ // Mint a one-time web-remote pairing code from the terminal (the
+ // `codemux remote pair` CLI, typically over SSH). The control socket
+ // is same-machine + unauthenticated by design — over SSH you ARE on
+ // the machine — so this is the right transport for pairing without
+ // opening the desktop GUI. Reuses the exact token path + endpoint
+ // enumeration the Settings pane uses (`web_remote::control_pair`).
+ "web_remote_pair" => {
+ let name = request
+ .params
+ .get("name")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(str::to_string);
+ crate::web_remote::control_pair(app, name)
+ .and_then(|res| serde_json::to_value(res).map_err(|error| error.to_string()))
+ }
_ => Err(format!("Unknown control command: {}", request.command)),
};
diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs
index 60235e04..d0a1dedc 100644
--- a/src-tauri/src/database.rs
+++ b/src-tauri/src/database.rs
@@ -151,6 +151,22 @@ pub struct ProjectScripts {
pub worktree_includes: Vec,
}
+/// A paired web-remote browser device. One row in `web_remote_sessions`.
+/// `token_hash` is the SHA-256 (hex) of the bearer token handed to the
+/// browser at pair time; the plaintext token is never persisted.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WebRemoteSessionRecord {
+ pub id: String,
+ pub name: Option,
+ pub user_agent: Option,
+ #[serde(skip_serializing)]
+ pub token_hash: String,
+ pub created_at: String,
+ pub last_seen_at: Option,
+ pub approved: bool,
+ pub revoked: bool,
+}
+
fn database_path() -> Option {
let config = dirs::config_dir()?;
Some(config.join(crate::APP_DIR_NAME).join("codemux.db"))
@@ -462,6 +478,24 @@ fn create_schema(conn: &Connection) -> Result<(), String> {
WHERE dirty = 1;
CREATE INDEX IF NOT EXISTS idx_workspaces_sync_host
ON workspaces_sync(user_id, host_server_id);
+
+ -- Web remote access: one row per paired browser device (schema v8).
+ -- The session token is never stored in the clear — `token_hash` holds
+ -- its SHA-256 and the auth layer does a constant-time compare. A
+ -- device stays in this table (revoked = 1) after revocation so the
+ -- desktop UI can still show a history entry; the auth layer treats any
+ -- revoked = 1 row as dead. `approved` gates ws-ticket issuance when the
+ -- server runs in require-approval mode. See web_remote::auth.
+ CREATE TABLE IF NOT EXISTS web_remote_sessions (
+ id TEXT PRIMARY KEY,
+ name TEXT,
+ user_agent TEXT,
+ token_hash TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ last_seen_at TEXT,
+ approved INTEGER NOT NULL DEFAULT 0,
+ revoked INTEGER NOT NULL DEFAULT 0
+ );
",
)
.map_err(|e| format!("Failed to create database schema: {e}"))?;
@@ -2992,6 +3026,150 @@ impl DatabaseStore {
}
}
+// ── Web remote access sessions ──
+//
+// Persistence for paired browser devices. The auth layer
+// (`web_remote::auth`) owns token generation, hashing and the
+// constant-time compare; these methods are the storage primitives it
+// builds on. All rows are `user_id`-agnostic (single local user, like
+// the rest of the local desktop state).
+impl DatabaseStore {
+ fn row_to_web_remote_session(
+ row: &rusqlite::Row<'_>,
+ ) -> rusqlite::Result {
+ Ok(WebRemoteSessionRecord {
+ id: row.get(0)?,
+ name: row.get(1)?,
+ user_agent: row.get(2)?,
+ token_hash: row.get(3)?,
+ created_at: row.get(4)?,
+ last_seen_at: row.get(5)?,
+ approved: row.get::<_, i64>(6)? != 0,
+ revoked: row.get::<_, i64>(7)? != 0,
+ })
+ }
+
+ /// Insert a freshly paired device. `token_hash` is the SHA-256 hex of
+ /// the plaintext bearer token (which the caller keeps only long enough
+ /// to hand back to the browser once).
+ pub fn web_remote_insert_session(
+ &self,
+ id: &str,
+ name: Option<&str>,
+ user_agent: Option<&str>,
+ token_hash: &str,
+ approved: bool,
+ ) -> Result<(), String> {
+ let conn = self.conn.lock().unwrap();
+ conn.execute(
+ "INSERT INTO web_remote_sessions
+ (id, name, user_agent, token_hash, created_at, last_seen_at, approved, revoked)
+ VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'), ?5, 0)",
+ params![id, name, user_agent, token_hash, approved as i64],
+ )
+ .map_err(|e| format!("Failed to insert web_remote session: {e}"))?;
+ Ok(())
+ }
+
+ /// Fetch a single session by id, regardless of revoked/approved state.
+ pub fn web_remote_get_session(&self, id: &str) -> Option {
+ let conn = self.conn.lock().unwrap();
+ conn.query_row(
+ "SELECT id, name, user_agent, token_hash, created_at, last_seen_at, approved, revoked
+ FROM web_remote_sessions WHERE id = ?1",
+ params![id],
+ Self::row_to_web_remote_session,
+ )
+ .ok()
+ }
+
+ /// All non-revoked sessions, newest first. This is what the desktop
+ /// device-management UI lists (pending devices included — the UI keys
+ /// off `approved`).
+ pub fn web_remote_list_sessions(&self) -> Vec {
+ let conn = self.conn.lock().unwrap();
+ let mut stmt = match conn.prepare(
+ "SELECT id, name, user_agent, token_hash, created_at, last_seen_at, approved, revoked
+ FROM web_remote_sessions WHERE revoked = 0 ORDER BY created_at DESC",
+ ) {
+ Ok(stmt) => stmt,
+ Err(_) => return Vec::new(),
+ };
+ let rows = match stmt.query_map([], Self::row_to_web_remote_session) {
+ Ok(rows) => rows,
+ Err(_) => return Vec::new(),
+ };
+ rows.filter_map(|r| r.ok()).collect()
+ }
+
+ /// Every non-revoked session, used by the auth layer to resolve a
+ /// presented bearer token via a constant-time hash compare. Returns
+ /// `(id, token_hash, approved)` tuples so the auth layer never has to
+ /// materialise the whole record just to authenticate.
+ pub fn web_remote_active_session_hashes(&self) -> Vec<(String, String, bool)> {
+ let conn = self.conn.lock().unwrap();
+ let mut stmt = match conn
+ .prepare("SELECT id, token_hash, approved FROM web_remote_sessions WHERE revoked = 0")
+ {
+ Ok(stmt) => stmt,
+ Err(_) => return Vec::new(),
+ };
+ let rows = match stmt.query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, i64>(2)? != 0,
+ ))
+ }) {
+ Ok(rows) => rows,
+ Err(_) => return Vec::new(),
+ };
+ rows.filter_map(|r| r.ok()).collect()
+ }
+
+ /// Bump `last_seen_at` to now. Best-effort; a failed touch is not fatal
+ /// to a live connection.
+ pub fn web_remote_touch_session(&self, id: &str) {
+ let conn = self.conn.lock().unwrap();
+ let _ = conn.execute(
+ "UPDATE web_remote_sessions SET last_seen_at = datetime('now') WHERE id = ?1",
+ params![id],
+ );
+ }
+
+ /// Flip a pending session to approved (approval-mode flow).
+ pub fn web_remote_set_session_approved(&self, id: &str, approved: bool) -> Result<(), String> {
+ let conn = self.conn.lock().unwrap();
+ conn.execute(
+ "UPDATE web_remote_sessions SET approved = ?2 WHERE id = ?1",
+ params![id, approved as i64],
+ )
+ .map_err(|e| format!("Failed to update web_remote session approval: {e}"))?;
+ Ok(())
+ }
+
+ /// Revoke a session. The row is kept (revoked = 1) for UI history; the
+ /// auth layer treats it as dead from this point on.
+ pub fn web_remote_revoke_session(&self, id: &str) -> Result<(), String> {
+ let conn = self.conn.lock().unwrap();
+ conn.execute(
+ "UPDATE web_remote_sessions SET revoked = 1 WHERE id = ?1",
+ params![id],
+ )
+ .map_err(|e| format!("Failed to revoke web_remote session: {e}"))?;
+ Ok(())
+ }
+
+ /// Hard-delete a session row. Used by the reject flow — a rejected
+ /// pending device leaves no trace.
+ pub fn web_remote_delete_session(&self, id: &str) -> Result<(), String> {
+ let conn = self.conn.lock().unwrap();
+ conn.execute("DELETE FROM web_remote_sessions WHERE id = ?1", params![id])
+ .map_err(|e| format!("Failed to delete web_remote session: {e}"))?;
+ Ok(())
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 8ee8f205..5c5fe318 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -79,6 +79,10 @@ pub mod terminal;
// Opt-in cloud-push diagnostic tracing. Defines the crate-wide
// `trace_cloud_push!` macro (gated on `CODEMUX_TRACE_CLOUD_PUSH`).
pub mod trace;
+// Embedded web remote-access server (default-off HTTP+WebSocket). Lets a
+// browser on another device drive this running instance as a second
+// frontend. See docs/plans/web-remote-access.md.
+pub mod web_remote;
/// Save window dimensions and position to SQLite before close.
fn save_window_state(handle: &tauri::AppHandle) {
@@ -153,6 +157,14 @@ pub fn run() {
));
}
+ // Web-remote state must exist before the builder so the channel
+ // interceptor (registered below) and the WS dispatcher share one
+ // channel routing table. The interceptor reroutes a synthesized
+ // invoke's channel frames to the owning browser instead of the
+ // desktop webview.
+ let web_remote_state = web_remote::WebRemoteState::default();
+ let web_remote_channel_router = web_remote_state.channel_router();
+
tauri::Builder::default()
// This plugin should run before the rest of the app setup so duplicate
// launches are intercepted before a second GUI is created.
@@ -239,6 +251,14 @@ pub fn run() {
eprintln!("[codemux] WARNING: Database init failed: {e}. Using in-memory fallback.");
database::DatabaseStore::new_in_memory()
}))
+ .manage(web_remote_state)
+ // Reroute channel frames opened by a web-remote invoke to the
+ // browser that opened them. Returns true only for server-allocated
+ // channel ids (the router owns them); genuine desktop-webview
+ // channels fall through to Tauri's default delivery.
+ .channel_interceptor(move |_webview, callback, index, body| {
+ web_remote_channel_router.route(callback.0, index, body)
+ })
.plugin(tauri_plugin_opener::init())
// Persistent native logging: app log dir + stderr. Warn-level
// globally so dependency chatter stays out, which still
@@ -682,12 +702,16 @@ pub fn run() {
#[cfg(unix)]
crate::hosts_inventory::spawn(app.handle().clone());
- // Resolve the bundled claude-agent sidecar from Tauri's resource
- // dir and pin the path via env var so the adapter (which has no
- // AppHandle access at construction time) can find it. Only one
- // file matches the `codemux-claude-sidecar-*` glob in resources/
- // — the per-target binary staged by scripts/build-claude-sidecar.sh
- // for whichever triple this build was compiled against.
+ // Resolve the bundled claude-agent sidecar and pin the path via
+ // env var so the adapter (which has no AppHandle access at
+ // construction time) can find it. `resolve_sidecar` honors an
+ // already-set override, then Tauri's resource dir (the
+ // per-target binary staged into the `codemux-claude-sidecar-*`
+ // resources glob for release builds), then — debug builds only —
+ // the `src-tauri/binaries/` dev-tree copy that `npm run
+ // tauri:dev` does not stage into the resource dir. Returns `None`
+ // when the binary exists nowhere, leaving the provider slot empty
+ // (provider_not_configured) rather than pinning a bad path.
//
// The bun --compile binary is huge (~100 MB) with the dynamic
// section at offset 96 MB, which patchelf corrupts when run by
@@ -695,23 +719,14 @@ pub fn run() {
// (usr/share/...) instead of an externalBin (usr/bin/) keeps it
// out of linuxdeploy's scan path. See `tauri.conf.json` for the
// mirror change.
- if let Ok(resource_dir) = app.handle().path().resource_dir() {
- let triple = agent_provider::claude::sidecar_path::target_triple();
- let ext = if cfg!(windows) { ".exe" } else { "" };
- let sidecar = resource_dir
- .join("binaries")
- .join(format!("codemux-claude-sidecar-{triple}{ext}"));
- if sidecar.exists()
- && std::env::var(
- agent_provider::claude::sidecar_path::SIDECAR_PATH_ENV,
- )
- .is_err()
- {
- std::env::set_var(
- agent_provider::claude::sidecar_path::SIDECAR_PATH_ENV,
- sidecar,
- );
- }
+ let resource_dir = app.handle().path().resource_dir().ok();
+ if let Some(sidecar) = agent_provider::claude::sidecar_path::resolve_sidecar(
+ resource_dir.as_deref(),
+ ) {
+ std::env::set_var(
+ agent_provider::claude::sidecar_path::SIDECAR_PATH_ENV,
+ sidecar,
+ );
}
// Agent-chat provider registry initialisation.
@@ -1463,6 +1478,25 @@ pub fn run() {
}
}
+ // Dev/test-only: an automated harness can set
+ // `CODEMUX_DEV_OFFLINE_LOGIN=1` (paired with an unreachable
+ // `CODEMUX_API_URL`) to seed an offline dev login so the served
+ // app is usable without a real account. No-op in release builds
+ // and dormant without the env var.
+ #[cfg(debug_assertions)]
+ crate::auth::seed_dev_offline_login(&app.state::());
+
+ // Restore web remote-access: if the user left it enabled, this
+ // re-binds the server on boot (non-fatal if the port is taken).
+ crate::web_remote::restore_on_boot(app.handle());
+
+ // Dev/test-only: an automated end-to-end harness can set
+ // `CODEMUX_WEB_REMOTE_E2E=1` to have the server come up and a
+ // pairing URL written to disk on boot. No-op in release builds
+ // (gated on `debug_assertions`) and dormant without the env var.
+ #[cfg(debug_assertions)]
+ crate::web_remote::e2e_autostart(app.handle());
+
#[cfg(debug_assertions)]
{
let pid = std::process::id();
@@ -1804,6 +1838,19 @@ pub fn run() {
session_adapters::validate_resume,
session_adapters::get_adapter_info,
session_adapters::get_scanner_captures,
+ // Web remote access (docs/plans/web-remote-access.md).
+ web_remote::web_remote_status,
+ web_remote::web_remote_enable,
+ web_remote::web_remote_disable,
+ web_remote::web_remote_set_config,
+ web_remote::web_remote_create_pairing,
+ web_remote::web_remote_list_endpoints,
+ web_remote::web_remote_list_sessions,
+ web_remote::web_remote_revoke_session,
+ web_remote::web_remote_approve_session,
+ web_remote::web_remote_reject_session,
+ web_remote::web_remote_publish_update_available,
+ web_remote::web_remote_request_update,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs
index 68b90a71..f9c791a1 100644
--- a/src-tauri/src/notifications.rs
+++ b/src-tauri/src/notifications.rs
@@ -1,10 +1,26 @@
use std::path::Path;
use std::process::{Command, Stdio};
-use tauri::{AppHandle, Manager};
+use serde::Serialize;
+use tauri::{AppHandle, Emitter, Manager};
use crate::state::AppStateStore;
+/// Global event mirrored to web clients alongside the native desktop
+/// notification. Stage 3b's frontend surfaces it via the Web Notifications API
+/// (with a toast fallback); the desktop path is unchanged.
+const NOTIFICATION_EVENT: &str = "notification";
+
+/// Payload for the global [`NOTIFICATION_EVENT`]. Serialized snake_case so web
+/// clients see stable field names. `title`/`body` carry exactly what the
+/// native notification shows; `workspace_title` is the originating workspace.
+#[derive(Debug, Clone, Serialize)]
+pub struct NotificationPayload {
+ pub title: String,
+ pub body: String,
+ pub workspace_title: String,
+}
+
#[cfg(target_os = "linux")]
const FREEDESKTOP_COMPLETE: &str = "/usr/share/sounds/freedesktop/stereo/complete.oga";
#[cfg(target_os = "macos")]
@@ -26,11 +42,31 @@ pub fn should_suppress(app: &AppHandle, pane_in_active_workspace: bool) -> bool
/// Clicking the notification focuses the Codemux window (and on Hyprland,
/// jumps to whichever workspace it lives on).
pub fn dispatch_agent_complete(app: &AppHandle, workspace_title: &str) {
- let summary = format!("Agent finished — {}", workspace_title);
- let body = "Codemux is waiting for your review.".to_string();
+ let payload = agent_complete_payload(workspace_title);
- show_desktop_notification(app, &summary, &body);
+ // Native desktop path — byte-identical to before: same summary/body
+ // strings, same show + sound calls in the same order.
+ show_desktop_notification(app, &payload.title, &payload.body);
play_completion_sound(app);
+
+ // Web-remote bridge: mirror the same notification onto the global event
+ // bus so a paired browser can raise an OS notification client-side (Stage
+ // 3b). Purely additive — emitting is independent of the native path and a
+ // no-op when nobody is listening. Web-side suppression (e.g. the tab is
+ // focused) is the frontend's policy, not ours.
+ let _ = app.emit(NOTIFICATION_EVENT, payload);
+}
+
+/// Build the payload for an "agent finished" notification. Split out so the
+/// title/body/serialization contract is unit-testable without firing a real
+/// desktop notification. The strings must match the native summary/body so the
+/// web and desktop notifications read identically.
+fn agent_complete_payload(workspace_title: &str) -> NotificationPayload {
+ NotificationPayload {
+ title: format!("Agent finished — {}", workspace_title),
+ body: "Codemux is waiting for your review.".to_string(),
+ workspace_title: workspace_title.to_string(),
+ }
}
fn show_desktop_notification(app: &AppHandle, summary: &str, body: &str) {
@@ -145,3 +181,24 @@ fn play_completion_sound(app: &AppHandle) {
.spawn();
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn agent_complete_payload_matches_native_strings_and_serializes_snake_case() {
+ let payload = agent_complete_payload("My Project");
+ // Must equal the strings the native notification renders.
+ assert_eq!(payload.title, "Agent finished — My Project");
+ assert_eq!(payload.body, "Codemux is waiting for your review.");
+ assert_eq!(payload.workspace_title, "My Project");
+
+ // The web bridge event carries snake_case keys web clients rely on.
+ let v = serde_json::to_value(&payload).unwrap();
+ assert_eq!(v["title"], "Agent finished — My Project");
+ assert_eq!(v["body"], "Codemux is waiting for your review.");
+ assert_eq!(v["workspace_title"], "My Project");
+ assert!(v.get("workspaceTitle").is_none(), "no camelCase leakage");
+ }
+}
diff --git a/src-tauri/src/terminal/mod.rs b/src-tauri/src/terminal/mod.rs
index 9a39d53e..84143635 100644
--- a/src-tauri/src/terminal/mod.rs
+++ b/src-tauri/src/terminal/mod.rs
@@ -217,14 +217,41 @@ pub struct TerminalStatusPayload {
pub exit_code: Option,
}
+/// One live consumer of a session's PTY output stream.
+///
+/// A single PTY reader fans its output out to every subscriber in
+/// `SessionRuntime::output_subscribers`. In the common case there is
+/// exactly one (the desktop window's xterm), but the stream can be
+/// mirrored to additional consumers (e.g. a browser client rendering the
+/// same session) — each attaches its own `Channel` and gets an identical
+/// byte stream.
+pub struct OutputSubscriber {
+ /// Monotonic id minted by `attach_pty_output`. A detach (or a failed
+ /// send) only removes the subscriber whose generation matches, so a
+ /// stale teardown from a consumer that already went away can never
+ /// evict a newer subscriber that attached concurrently.
+ pub generation: u64,
+ pub channel: Channel>,
+ /// This subscriber's flow-control request. The PTY reader only parks
+ /// (applies back-pressure to the child) when EVERY subscriber has set
+ /// this — see [`recompute_flow_paused`]. A caught-up subscriber keeps
+ /// bytes flowing for all of them.
+ pub flow_paused: bool,
+}
+
pub struct SessionRuntime {
pub writer: Option>,
pub master: Option>,
- pub output_channel: Option>>,
- /// Monotonic generation for the currently installed output channel.
- /// Incremented on attach/detach so a failed send from an old cloned
- /// channel cannot clear a newer channel that attached concurrently.
- pub output_channel_generation: u64,
+ /// Live consumers of this session's PTY output. Empty when nothing is
+ /// attached (output then accumulates in `pending_output` for replay on
+ /// the next attach). `queue_or_send_output` fans each chunk out to all
+ /// entries and prunes any whose channel send fails.
+ pub output_subscribers: Vec,
+ /// Monotonic source of subscriber generations. Bumped on every attach so
+ /// each subscriber gets a unique id; a stale detach/failed-send can then
+ /// target exactly one subscriber and never clobber a newer one that
+ /// attached concurrently.
+ pub next_output_generation: u64,
pub pending_output: VecDeque>,
/// Running total of `chunk.len()` across every entry in `pending_output`.
/// Maintained alongside the deque so the eviction loop can cap by bytes
@@ -248,23 +275,30 @@ pub struct SessionRuntime {
/// across attach/detach cycles so the cumulative loss across a session
/// is observable, not just the most recent overflow.
pub dropped_chunks: u64,
- /// PTY producer back-pressure flag for the **in-process** read loop. When
- /// set, `batched_reader_loop` stops draining the PTY master fd; the kernel
- /// PTY buffer then fills and the child's next `write()` blocks — real
- /// back-pressure straight to the producer instead of an unbounded renderer
- /// queue (or, worse, `pending_output` eviction once the 256 MiB ring caps).
+ /// Effective PTY producer back-pressure flag for the **in-process** read
+ /// loop. When set, `batched_reader_loop` stops draining the PTY master fd;
+ /// the kernel PTY buffer then fills and the child's next `write()` blocks —
+ /// real back-pressure straight to the producer instead of an unbounded
+ /// renderer queue (or, worse, `pending_output` eviction once the 256 MiB
+ /// ring caps).
///
- /// Set/cleared by `set_pty_flow_paused` (the `pause_pty_output` /
+ /// This is a *derived* flag: `recompute_flow_paused` sets it true only when
+ /// there is at least one subscriber and EVERY subscriber has requested a
+ /// pause (see [`OutputSubscriber::flow_paused`]). A single caught-up
+ /// subscriber keeps bytes flowing for the whole fan-out. The per-subscriber
+ /// requests are driven by `set_pty_flow_paused` (the `pause_pty_output` /
/// `resume_pty_output` commands the renderer calls at its HIGH/LOW
- /// watermarks). Self-heals three ways so a dropped resume can never wedge a
- /// PTY: cleared on `attach_pty_output` (a reattaching pane starts live),
- /// cleared on `detach_pty_output` (a backgrounded agent isn't left
- /// blocked), and a `FLOW_MAX_PARK` backstop inside the read loop.
+ /// watermarks), and the derived flag is recomputed on every attach, detach,
+ /// and pause/resume. Self-heals so a dropped resume can never wedge a PTY:
+ /// a fresh attach adds an unpaused subscriber (clearing the derived flag), a
+ /// detach drops that subscriber's request with it, and a `FLOW_MAX_PARK`
+ /// backstop inside the read loop force-resumes as a last resort.
///
/// Shared (`Arc`) with the reader thread so toggling it needs no lock on
/// the hot read path. Daemon-backed (persistent) sessions don't run the
/// in-process loop — their back-pressure routes through the daemon's own
- /// `flow_paused` flag — so this stays unread for them (harmless).
+ /// `flow_paused` flag (the recomputed verdict is forwarded there) — so this
+ /// stays unread for them (harmless).
pub flow_paused: Arc,
pub last_status: TerminalStatusPayload,
pub child_pid: Option,
@@ -315,8 +349,8 @@ impl SessionRuntime {
Self {
writer: None,
master: None,
- output_channel: None,
- output_channel_generation: 0,
+ output_subscribers: Vec::new(),
+ next_output_generation: 0,
pending_output: VecDeque::new(),
pending_output_bytes: 0,
dropped_chunks: 0,
@@ -639,12 +673,35 @@ fn emit_terminal_status(
}
}
+/// Recompute a session's derived `flow_paused` back-pressure flag from its
+/// current subscriber set and store it into the shared atomic the in-process
+/// reader polls. Returns the new effective value.
+///
+/// Fan-out flow-control policy: a single PTY reader feeds N live subscribers
+/// (the desktop window plus any mirror consumers). It must only stop draining
+/// the PTY — applying real back-pressure to the child — when EVERY current
+/// subscriber has asked to pause; one caught-up subscriber keeps bytes flowing
+/// for all of them. With zero subscribers the reader keeps running and its
+/// output accumulates in the bounded replay ring. A subscriber's pause request
+/// is released automatically when it detaches, because it stops counting toward
+/// the "all paused" verdict.
+///
+/// Pure w.r.t. side channels (no daemon I/O, no task spawn) so it can run under
+/// the sessions lock and be unit-tested directly; the daemon forward lives in
+/// `apply_flow_control`.
+fn recompute_flow_paused(runtime: &SessionRuntime) -> bool {
+ let effective = !runtime.output_subscribers.is_empty()
+ && runtime.output_subscribers.iter().all(|s| s.flow_paused);
+ runtime.flow_paused.store(effective, Ordering::Relaxed);
+ effective
+}
+
fn queue_or_send_output(
sessions: &Arc>>,
session_id: &str,
chunk: Vec,
) {
- let channel_to_send = with_session_runtime(
+ let subscribers_to_send = with_session_runtime(
sessions,
session_id,
|| SessionRuntime::new(session_id),
@@ -671,7 +728,7 @@ fn queue_or_send_output(
runtime.pending_output_bytes = runtime
.pending_output_bytes
.saturating_sub(evicted.len());
- if runtime.output_channel.is_none() {
+ if runtime.output_subscribers.is_empty() {
let dropped = runtime.dropped_chunks.saturating_add(1);
runtime.dropped_chunks = dropped;
// Log on the first drop and then on each power-of-two boundary
@@ -681,37 +738,58 @@ fn queue_or_send_output(
if dropped == 1 || dropped.is_power_of_two() {
eprintln!(
"[codemux::terminal] session={session_id} dropped pending_output \
- chunk (cumulative={dropped}). Indicates the frontend was \
- detached when the buffer overflowed."
+ chunk (cumulative={dropped}). Indicates no consumer was \
+ attached when the buffer overflowed."
);
}
}
}
+ // Snapshot (generation, channel) for every subscriber so the sends
+ // happen OUTSIDE the lock. Cloning a `Channel` is cheap (it's
+ // internally ref-counted).
runtime
- .output_channel
- .clone()
- .map(|channel| (channel, runtime.output_channel_generation))
+ .output_subscribers
+ .iter()
+ .map(|s| (s.generation, s.channel.clone()))
+ .collect::>()
},
);
- let Some((channel, generation)) = channel_to_send else {
+ if subscribers_to_send.is_empty() {
return;
- };
+ }
// Tauri IPC delivery can be slower than the in-memory bookkeeping above,
// especially when the WebView is busy parsing terminal output. Never hold
// the global sessions mutex while sending, otherwise active keystrokes
// (`write_to_pty`) block behind unrelated PTY output from other sessions.
- if let Err(error) = channel.send(chunk) {
- eprintln!("[codemux::terminal] Failed to send terminal output: {error}");
+ //
+ // Fan out to every subscriber; collect the generations whose send failed so
+ // they can be pruned. A failure means that consumer's channel is dead (the
+ // webview navigated away, a mirror client dropped) — evicting it by
+ // generation can never touch a newer subscriber that attached concurrently.
+ let mut failed: Vec = Vec::new();
+ for (generation, channel) in subscribers_to_send {
+ if let Err(error) = channel.send(chunk.clone()) {
+ eprintln!("[codemux::terminal] Failed to send terminal output: {error}");
+ failed.push(generation);
+ }
+ }
+
+ if !failed.is_empty() {
with_existing_session_runtime(sessions, session_id, |runtime| {
- if runtime.output_channel_generation == generation {
- runtime.output_channel = None;
- runtime.output_channel_generation =
- runtime.output_channel_generation.saturating_add(1);
- }
+ runtime
+ .output_subscribers
+ .retain(|s| !failed.contains(&s.generation));
+ // Losing a subscriber can flip the "all paused" verdict.
+ recompute_flow_paused(runtime);
});
+ // Keep a daemon-backed session's flow gate in step with the pruned set.
+ // For daemon sessions this runs on the daemon read task (a tokio
+ // context); for in-process sessions there is no daemon client, so it is
+ // a no-op that never spawns. Cheap: only reached when a send failed.
+ forward_flow_control_to_daemon(sessions, session_id);
}
}
@@ -1989,7 +2067,7 @@ pub(crate) fn terminate_pty_session(
// client.
let daemon_client = runtime.daemon_client.take();
if was_persistent {
- runtime.output_channel = None;
+ runtime.output_subscribers.clear();
runtime.pending_output.clear();
runtime.pending_output_bytes = 0;
// Drop runtime first so any held Arcs (writer, etc.) release before
@@ -2033,7 +2111,7 @@ pub(crate) fn terminate_pty_session(
// Clear child_pid to None *first* so the `Drop for SessionRuntime`
// safety-net impl stays silent on the happy path. Any non-None value
// printed by Drop means something skipped this function.
- runtime.output_channel = None;
+ runtime.output_subscribers.clear();
runtime.pending_output.clear();
runtime.pending_output_bytes = 0;
if let Some(master) = runtime.master.as_mut() {
@@ -2088,7 +2166,7 @@ pub fn close_terminal_session(
Ok(fallback_session.0)
}
-/// Like `terminate_pty_session` but preserves `output_channel` +
+/// Like `terminate_pty_session` but preserves `output_subscribers` +
/// `pending_output` for daemon-backed (persistent) sessions, so the
/// frontend's xterm stays connected across the kill-and-respawn that
/// happens on workspace push/pull.
@@ -2127,7 +2205,7 @@ pub(crate) fn terminate_pty_session_keep_channel(
rt.is_spawning = false;
rt.skip_preset_launch = false;
rt.resume_command = None;
- // PRESERVED (the whole point): output_channel,
+ // PRESERVED (the whole point): output_subscribers,
// pending_output, pending_output_bytes, last_status.
Some(daemon_client)
})
@@ -2216,6 +2294,17 @@ pub fn get_terminal_status(
Ok(status)
}
+/// Register `channel` as a live subscriber to a session's PTY output and
+/// return the subscriber's generation token. The caller passes that token to
+/// [`detach_pty_output`] (and `pause_pty_output`/`resume_pty_output`) so a
+/// stale teardown from a superseded consumer can never touch a newer one.
+///
+/// Multiple subscribers can attach to the same session simultaneously (the
+/// desktop window plus any mirror clients); each receives an identical byte
+/// stream via the fan-out in `queue_or_send_output`. The `pending_output`
+/// replay ring is flushed **only to the newly attached channel**, so a late
+/// joiner catches up without re-delivering history to the existing
+/// subscribers.
#[tauri::command]
pub fn attach_pty_output(
terminal_state: State<'_, PtyState>,
@@ -2223,7 +2312,7 @@ pub fn attach_pty_output(
channel: Channel>,
session_id: Option,
skip_pending: Option,
-) -> Result<(), String> {
+) -> Result {
let session_id = session_id
.or_else(|| {
app_state
@@ -2232,42 +2321,65 @@ pub fn attach_pty_output(
})
.ok_or_else(|| "No active terminal session found".to_string())?;
- let pending_chunks = with_session_runtime(
+ let (generation, pending_chunks) = with_session_runtime(
&terminal_state.sessions,
&session_id,
|| SessionRuntime::new(&session_id),
|runtime| {
- runtime.output_channel = Some(channel.clone());
- runtime.output_channel_generation =
- runtime.output_channel_generation.saturating_add(1);
- // Self-heal: a freshly attached renderer is, by definition, caught
- // up. Clear any stale flow-control pause so the in-process read
- // loop resumes — guards against a prior pane that paused and was
- // torn down before its resume landed. (The daemon clears its own
- // flag on `Attach` for the same reason.)
- runtime.flow_paused.store(false, Ordering::Relaxed);
- if skip_pending.unwrap_or(false) {
+ runtime.next_output_generation =
+ runtime.next_output_generation.saturating_add(1);
+ let generation = runtime.next_output_generation;
+ runtime.output_subscribers.push(OutputSubscriber {
+ generation,
+ channel: channel.clone(),
+ // A freshly attached consumer is, by definition, caught up, so
+ // it starts unpaused.
+ flow_paused: false,
+ });
+ // Self-heal: adding an unpaused subscriber means "all subscribers
+ // paused" can no longer hold, so the in-process read loop resumes.
+ // Guards against a prior consumer that paused and was torn down
+ // before its resume landed. (The daemon clears its own flag on
+ // `Attach` for the same reason; the recomputed verdict is also
+ // forwarded to it below.)
+ recompute_flow_paused(runtime);
+ let pending = if skip_pending.unwrap_or(false) {
vec![]
} else {
runtime.pending_output.iter().cloned().collect::>()
- }
+ };
+ (generation, pending)
},
);
+ // Forward the recomputed back-pressure verdict to the daemon (no-op for
+ // in-process sessions). Done outside the sessions lock.
+ forward_flow_control_to_daemon(&terminal_state.sessions, &session_id);
+
+ // Replay history to the NEW subscriber only — the existing subscribers
+ // already have it. Sent outside the lock like all other channel I/O.
for chunk in pending_chunks {
channel
.send(chunk)
.map_err(|error| format!("Failed to flush buffered PTY output: {error}"))?;
}
- Ok(())
+ Ok(generation)
}
+/// Tear down a subscriber installed by [`attach_pty_output`]. A
+/// `Some(generation)` removes only that subscriber, so a late detach from a
+/// consumer that already lost the session (unmount racing a remount, or a
+/// second consumer attaching the same session) can never evict a newer
+/// subscriber. `None` removes every subscriber for the session (legacy
+/// whole-session detach). Idempotent: an unknown session or a superseded
+/// generation is a no-op.
#[tauri::command]
pub fn detach_pty_output(
terminal_state: State<'_, PtyState>,
app_state: State<'_, AppStateStore>,
session_id: Option,
+ generation: Option,
) -> Result<(), String> {
let session_id = session_id
.or_else(|| {
@@ -2277,90 +2389,133 @@ pub fn detach_pty_output(
})
.ok_or_else(|| "No active terminal session found".to_string())?;
- with_session_runtime(
+ with_existing_session_runtime(
&terminal_state.sessions,
&session_id,
- || SessionRuntime::new(&session_id),
|runtime| {
- runtime.output_channel = None;
- runtime.output_channel_generation =
- runtime.output_channel_generation.saturating_add(1);
- // Self-heal: the renderer that requested back-pressure is going
- // away. Resume the in-process read loop so a backgrounded child
- // (e.g. a daemon-less agent left running on tab switch) is not left
- // blocked on `write()` indefinitely. The renderer also resumes
- // explicitly on unmount; this is the backend belt-and-suspenders.
- runtime.flow_paused.store(false, Ordering::Relaxed);
+ match generation {
+ Some(generation) => runtime
+ .output_subscribers
+ .retain(|s| s.generation != generation),
+ None => runtime.output_subscribers.clear(),
+ }
+ // Self-heal: the removed consumer's pause request dies with it, so
+ // recomputing may resume the reader — a backgrounded child (e.g. a
+ // daemon-less agent left running on tab switch) is not left blocked
+ // on `write()`. Removing the last subscriber also resumes (an empty
+ // set never parks).
+ recompute_flow_paused(runtime);
},
);
+ // Push the recomputed verdict to the daemon (no-op for in-process sessions).
+ forward_flow_control_to_daemon(&terminal_state.sessions, &session_id);
+
Ok(())
}
-/// Pause a session's PTY read loop (terminal output flow control). The renderer
-/// calls this when a fast producer outruns its xterm write queue; pausing makes
-/// the child block on `write()` once the kernel PTY buffer fills — real
-/// back-pressure instead of an ever-growing renderer queue (or, worse,
-/// `pending_output` eviction once the 256 MiB ring caps).
+/// Pause one subscriber's view of a session's PTY output (terminal flow
+/// control). The renderer calls this when a fast producer outruns its xterm
+/// write queue. Pausing does not stop the reader outright: the child only
+/// blocks on `write()` (real back-pressure) once EVERY subscriber has paused —
+/// a caught-up mirror consumer keeps the stream flowing. `Some(generation)` is
+/// the token returned by [`attach_pty_output`], identifying the calling
+/// subscriber; `None` pauses every subscriber for the session (legacy).
///
-/// Works on BOTH spawn paths: daemon-backed sessions route to the daemon's
-/// read loop; in-process sessions flip the shared `flow_paused` flag the
-/// in-process `batched_reader_loop` polls. Both paths self-heal (resume on
-/// attach/detach + a max-park backstop) so a paused PTY can never wedge even
+/// Works on BOTH spawn paths: the recomputed "all paused" verdict drives the
+/// in-process `batched_reader_loop`'s shared flag and is forwarded to the
+/// daemon's read loop for daemon-backed sessions. Both paths self-heal (resume
+/// on attach/detach + a max-park backstop) so a paused PTY can never wedge even
/// if the matching resume is never delivered.
#[tauri::command]
pub fn pause_pty_output(
terminal_state: State<'_, PtyState>,
session_id: String,
+ generation: Option,
) -> Result<(), String> {
- set_pty_flow_paused(&terminal_state.sessions, &session_id, true);
+ set_pty_flow_paused(&terminal_state.sessions, &session_id, generation, true);
Ok(())
}
-/// Resume a session's PTY read loop. See `pause_pty_output`. Idempotent.
+/// Resume one subscriber's view of a session's PTY output. `None` resumes every
+/// subscriber for the session. See `pause_pty_output`. Idempotent.
#[tauri::command]
pub fn resume_pty_output(
terminal_state: State<'_, PtyState>,
session_id: String,
+ generation: Option,
) -> Result<(), String> {
- set_pty_flow_paused(&terminal_state.sessions, &session_id, false);
+ set_pty_flow_paused(&terminal_state.sessions, &session_id, generation, false);
Ok(())
}
-/// Apply a flow-control pause/resume to whichever read loop owns `session_id`.
+/// Record a flow-control request (`paused`), recompute the session's derived
+/// "all paused" verdict, and apply it to whichever read loop owns the session.
///
-/// In-process path: flip the session's shared `flow_paused` atomic — the
-/// `batched_reader_loop` polls it and stops/starts draining the master fd.
-/// Daemon path: ALSO route to the daemon (fire-and-forget, mirroring
-/// `DaemonWriter`); flow control is advisory and a failure (e.g. the session
-/// just exited) is benign. Setting the in-process flag for a daemon-backed
-/// session is harmless — no in-process loop reads it for that session.
+/// `Some(generation)` sets just that subscriber's request; a generation that no
+/// longer matches any subscriber is a benign no-op (the consumer detached
+/// between the renderer's watermark check and this IPC). `None` sets the
+/// request on every current subscriber (legacy whole-session pause/resume).
fn set_pty_flow_paused(
sessions: &Arc>>,
session_id: &str,
+ generation: Option,
paused: bool,
) {
- // Flip the in-process flag under the lock and grab the daemon client (if
- // any) in the same pass, so we touch the session map exactly once.
- let daemon_client = with_existing_session_runtime(sessions, session_id, |runtime| {
- runtime.flow_paused.store(paused, Ordering::Relaxed);
+ with_existing_session_runtime(sessions, session_id, |runtime| {
+ match generation {
+ Some(generation) => {
+ if let Some(sub) = runtime
+ .output_subscribers
+ .iter_mut()
+ .find(|s| s.generation == generation)
+ {
+ sub.flow_paused = paused;
+ }
+ }
+ None => {
+ for sub in runtime.output_subscribers.iter_mut() {
+ sub.flow_paused = paused;
+ }
+ }
+ }
+ recompute_flow_paused(runtime);
+ });
+
+ // Push the recomputed verdict to the daemon (no-op for in-process sessions).
+ forward_flow_control_to_daemon(sessions, session_id);
+}
+
+/// Forward a session's current derived `flow_paused` verdict to the daemon's
+/// own flow gate for daemon-backed sessions. Fire-and-forget (mirroring
+/// `DaemonWriter`): flow control is advisory and a failure (e.g. the session
+/// just exited) is benign. A no-op for in-process sessions — no daemon client
+/// exists, and the in-process reader already reads the shared atomic directly.
+fn forward_flow_control_to_daemon(
+ sessions: &Arc>>,
+ session_id: &str,
+) {
+ // Read the already-recomputed verdict and grab the daemon client (if any)
+ // in one lock pass.
+ let snapshot = with_existing_session_runtime(sessions, session_id, |runtime| {
+ let effective = runtime.flow_paused.load(Ordering::Relaxed);
#[cfg(unix)]
{
- runtime.daemon_client.clone()
+ (effective, runtime.daemon_client.clone())
}
#[cfg(not(unix))]
{
- Option::<()>::None
+ (effective, Option::<()>::None)
}
});
#[cfg(unix)]
- if let Some(client) = daemon_client.flatten() {
+ if let Some((effective, Some(client))) = snapshot {
let session_id = session_id.to_string();
tauri::async_runtime::spawn(async move {
- if let Err(error) = client.set_flow_paused(session_id.clone(), paused).await {
+ if let Err(error) = client.set_flow_paused(session_id.clone(), effective).await {
eprintln!(
- "[codemux::terminal] flow-control set_flow_paused(paused={paused}) \
+ "[codemux::terminal] flow-control set_flow_paused(paused={effective}) \
for {session_id} failed (benign if the session just exited): {error}"
);
}
@@ -2368,7 +2523,7 @@ fn set_pty_flow_paused(
}
#[cfg(not(unix))]
{
- let _ = daemon_client;
+ let _ = snapshot;
}
}
@@ -3332,6 +3487,48 @@ mod tests {
Arc::new(Mutex::new(HashMap::new()))
}
+ /// Install a fresh output subscriber exactly the way `attach_pty_output`
+ /// does (mint a generation, push an unpaused entry, recompute flow) and
+ /// return its generation. Test-only mirror of the command body so the
+ /// unit tests don't need a real Tauri `State`/`AppHandle`.
+ fn attach_subscriber(
+ sessions: &Arc>>,
+ session_id: &str,
+ channel: Channel>,
+ ) -> u64 {
+ with_session_runtime(
+ sessions,
+ session_id,
+ || SessionRuntime::new(session_id),
+ |runtime| {
+ runtime.next_output_generation =
+ runtime.next_output_generation.saturating_add(1);
+ let generation = runtime.next_output_generation;
+ runtime.output_subscribers.push(OutputSubscriber {
+ generation,
+ channel,
+ flow_paused: false,
+ });
+ recompute_flow_paused(runtime);
+ generation
+ },
+ )
+ }
+
+ /// Remove a subscriber by generation the way `detach_pty_output` does.
+ fn detach_subscriber(
+ sessions: &Arc>>,
+ session_id: &str,
+ generation: u64,
+ ) {
+ with_existing_session_runtime(sessions, session_id, |runtime| {
+ runtime
+ .output_subscribers
+ .retain(|s| s.generation != generation);
+ recompute_flow_paused(runtime);
+ });
+ }
+
#[test]
fn comm_log_entry_formats_and_filters() {
// Real agent output → a tagged, timestamped line.
@@ -3489,7 +3686,7 @@ mod tests {
}
/// `terminate_pty_session_keep_channel` for a daemon-backed
- /// (persistent) session must PRESERVE `output_channel` and
+ /// (persistent) session must PRESERVE `output_subscribers` and
/// `pending_output` so the frontend's xterm stays attached
/// across the kill-and-respawn that happens on workspace push.
/// Without this, the respawned PTY's output buffers in
@@ -3982,29 +4179,17 @@ mod tests {
assert_eq!(after_first_overflow, 7);
// Simulate attach (mirrors attach_pty_output body): install a
- // channel, drain pending_output, then detach again. Reset the
+ // subscriber, drain pending_output, then detach again. Reset the
// byte counter alongside the deque to keep the invariant —
// production code does this in `close_terminal_session`; tests
// touching the deque directly must do the same.
let channel: Channel> = Channel::new(|_| Ok(()));
- with_session_runtime(
- &sessions,
- "persist",
- || SessionRuntime::new("persist"),
- |runtime| {
- runtime.output_channel = Some(channel);
- runtime.pending_output.clear();
- runtime.pending_output_bytes = 0;
- },
- );
- with_session_runtime(
- &sessions,
- "persist",
- || SessionRuntime::new("persist"),
- |runtime| {
- runtime.output_channel = None;
- },
- );
+ let generation = attach_subscriber(&sessions, "persist", channel);
+ with_existing_session_runtime(&sessions, "persist", |runtime| {
+ runtime.pending_output.clear();
+ runtime.pending_output_bytes = 0;
+ });
+ detach_subscriber(&sessions, "persist", generation);
// Counter should still reflect the prior overflow.
let guard = sessions.lock().unwrap();
@@ -4077,15 +4262,20 @@ mod tests {
});
// Mirror the attach_pty_output Tauri command body directly: install
- // the channel, snapshot pending_output, then forward each chunk.
+ // the subscriber, snapshot pending_output, then forward each chunk.
let pending_chunks = with_session_runtime(
&sessions,
"replay",
|| SessionRuntime::new("replay"),
|runtime| {
- runtime.output_channel = Some(channel.clone());
- runtime.output_channel_generation =
- runtime.output_channel_generation.saturating_add(1);
+ runtime.next_output_generation =
+ runtime.next_output_generation.saturating_add(1);
+ let generation = runtime.next_output_generation;
+ runtime.output_subscribers.push(OutputSubscriber {
+ generation,
+ channel: channel.clone(),
+ flow_paused: false,
+ });
runtime.pending_output.iter().cloned().collect::>()
},
);
@@ -4129,16 +4319,7 @@ mod tests {
captured_handler.lock().unwrap().push(bytes);
Ok(())
});
- with_session_runtime(
- &sessions,
- "live",
- || SessionRuntime::new("live"),
- |runtime| {
- runtime.output_channel = Some(channel);
- runtime.output_channel_generation =
- runtime.output_channel_generation.saturating_add(1);
- },
- );
+ attach_subscriber(&sessions, "live", channel);
// Drive the same path the reader thread uses for each batched flush.
for i in 0..OUTPUT_BUFFER_BYTE_LIMIT + 5 {
@@ -4191,16 +4372,7 @@ mod tests {
Ok(())
});
- with_session_runtime(
- &sessions,
- "live-lock",
- || SessionRuntime::new("live-lock"),
- |runtime| {
- runtime.output_channel = Some(channel);
- runtime.output_channel_generation =
- runtime.output_channel_generation.saturating_add(1);
- },
- );
+ attach_subscriber(&sessions, "live-lock", channel);
queue_or_send_output(&sessions, "live-lock", vec![42]);
@@ -4210,6 +4382,168 @@ mod tests {
);
}
+ /// Real `tauri::ipc::Channel` whose handler records every decoded chunk, in
+ /// order. Shared shape used by the fan-out tests below.
+ fn recording_channel() -> (Channel>, Arc>>>) {
+ let captured: Arc>>> = Arc::new(Mutex::new(Vec::new()));
+ let handler = captured.clone();
+ let channel: Channel> = Channel::new(move |body| {
+ let bytes = body.deserialize::>().expect("decode body");
+ handler.lock().unwrap().push(bytes);
+ Ok(())
+ });
+ (channel, captured)
+ }
+
+ /// Multi-client fan-out: two subscribers attached to the same session must
+ /// BOTH receive every chunk, in the same interleaved order the reader
+ /// produced them. This is the core mirror-mode delivery guarantee.
+ #[test]
+ fn test_fan_out_delivers_to_all_subscribers() {
+ let sessions = make_sessions();
+ let (ch_a, cap_a) = recording_channel();
+ let (ch_b, cap_b) = recording_channel();
+ attach_subscriber(&sessions, "fan", ch_a);
+ attach_subscriber(&sessions, "fan", ch_b);
+
+ for i in 0..8u8 {
+ queue_or_send_output(&sessions, "fan", vec![i, i + 200]);
+ }
+
+ let a = cap_a.lock().unwrap().clone();
+ let b = cap_b.lock().unwrap().clone();
+ assert_eq!(a.len(), 8, "subscriber A must see every chunk");
+ assert_eq!(b.len(), 8, "subscriber B must see every chunk");
+ for i in 0..8u8 {
+ assert_eq!(a[i as usize], vec![i, i + 200], "A order preserved");
+ assert_eq!(b[i as usize], vec![i, i + 200], "B order preserved");
+ }
+ }
+
+ /// Detaching one subscriber must leave the other streaming uninterrupted —
+ /// removing A never touches B's delivery.
+ #[test]
+ fn test_detach_one_leaves_other_streaming() {
+ let sessions = make_sessions();
+ let (ch_a, cap_a) = recording_channel();
+ let (ch_b, cap_b) = recording_channel();
+ let gen_a = attach_subscriber(&sessions, "detach", ch_a);
+ attach_subscriber(&sessions, "detach", ch_b);
+
+ queue_or_send_output(&sessions, "detach", vec![1]);
+ // Both saw the first chunk.
+ assert_eq!(cap_a.lock().unwrap().len(), 1);
+ assert_eq!(cap_b.lock().unwrap().len(), 1);
+
+ detach_subscriber(&sessions, "detach", gen_a);
+ // Only B remains a subscriber.
+ with_existing_session_runtime(&sessions, "detach", |runtime| {
+ assert_eq!(runtime.output_subscribers.len(), 1, "A removed");
+ });
+
+ queue_or_send_output(&sessions, "detach", vec![2]);
+ queue_or_send_output(&sessions, "detach", vec![3]);
+
+ assert_eq!(
+ cap_a.lock().unwrap().clone(),
+ vec![vec![1u8]],
+ "detached A must receive nothing further"
+ );
+ assert_eq!(
+ cap_b.lock().unwrap().clone(),
+ vec![vec![1u8], vec![2u8], vec![3u8]],
+ "B keeps streaming across A's detach"
+ );
+ }
+
+ /// Replay-on-attach targets the NEW subscriber only: a late joiner catches
+ /// up from the `pending_output` ring while the existing subscriber does not
+ /// re-receive history it already streamed live.
+ #[test]
+ fn test_replay_on_attach_reaches_only_the_attacher() {
+ let sessions = make_sessions();
+ let (ch_a, cap_a) = recording_channel();
+ attach_subscriber(&sessions, "join", ch_a);
+
+ // Produce output while only A is attached — A gets it live, and it also
+ // lands in the replay ring.
+ for i in 0..5u8 {
+ queue_or_send_output(&sessions, "join", vec![i]);
+ }
+ assert_eq!(cap_a.lock().unwrap().len(), 5, "A streamed 5 chunks live");
+
+ // B attaches late. Mirror attach_pty_output: install B, snapshot
+ // pending_output, replay to B's channel only.
+ let (ch_b, cap_b) = recording_channel();
+ let pending = with_session_runtime(
+ &sessions,
+ "join",
+ || SessionRuntime::new("join"),
+ |runtime| {
+ runtime.next_output_generation =
+ runtime.next_output_generation.saturating_add(1);
+ let generation = runtime.next_output_generation;
+ runtime.output_subscribers.push(OutputSubscriber {
+ generation,
+ channel: ch_b.clone(),
+ flow_paused: false,
+ });
+ runtime.pending_output.iter().cloned().collect::>()
+ },
+ );
+ for chunk in pending {
+ ch_b.send(chunk).expect("replay send");
+ }
+
+ // A must NOT have received the replay again (still 5 live chunks); B
+ // catches up with exactly the ring's history.
+ assert_eq!(
+ cap_a.lock().unwrap().len(),
+ 5,
+ "existing subscriber must not re-receive replayed history"
+ );
+ assert_eq!(
+ cap_b.lock().unwrap().clone(),
+ (0..5u8).map(|i| vec![i]).collect::>(),
+ "late joiner replays the full ring in order"
+ );
+
+ // A subsequent live chunk now reaches BOTH.
+ queue_or_send_output(&sessions, "join", vec![99]);
+ assert_eq!(*cap_a.lock().unwrap().last().unwrap(), vec![99u8]);
+ assert_eq!(*cap_b.lock().unwrap().last().unwrap(), vec![99u8]);
+ }
+
+ /// A detach carrying a stale/superseded generation must not remove the
+ /// current subscriber — the guard that keeps an unmount race from tearing
+ /// down a newer attach.
+ #[test]
+ fn test_detach_with_stale_generation_is_noop() {
+ let sessions = make_sessions();
+ let (channel, captured) = recording_channel();
+ let generation = attach_subscriber(&sessions, "stale-detach", channel);
+
+ // Detach a generation that was never issued → nothing removed.
+ detach_subscriber(&sessions, "stale-detach", generation + 1);
+ with_existing_session_runtime(&sessions, "stale-detach", |runtime| {
+ assert_eq!(
+ runtime.output_subscribers.len(),
+ 1,
+ "stale detach must not remove the live subscriber"
+ );
+ });
+
+ // The live subscriber still streams.
+ queue_or_send_output(&sessions, "stale-detach", vec![7]);
+ assert_eq!(captured.lock().unwrap().clone(), vec![vec![7u8]]);
+
+ // The matching generation does remove it.
+ detach_subscriber(&sessions, "stale-detach", generation);
+ with_existing_session_runtime(&sessions, "stale-detach", |runtime| {
+ assert!(runtime.output_subscribers.is_empty(), "matching detach removes");
+ });
+ }
+
#[test]
fn test_flush_pty_batch_noop_on_empty() {
let sessions = make_sessions();
@@ -4533,18 +4867,13 @@ mod tests {
Ok(())
});
- // Start unpaused; grab the shared flow flag (mirrors the spawn path).
- let flow_paused = with_session_runtime(
- &sessions,
- "fire",
- || SessionRuntime::new("fire"),
- |runtime| {
- runtime.output_channel = Some(channel);
- runtime.output_channel_generation += 1;
- runtime.flow_paused.store(false, Ordering::Relaxed);
- runtime.flow_paused.clone()
- },
- );
+ // Start unpaused; install the subscriber and grab the shared flow flag
+ // (mirrors the spawn + attach paths).
+ attach_subscriber(&sessions, "fire", channel);
+ let flow_paused = with_existing_session_runtime(&sessions, "fire", |runtime| {
+ runtime.flow_paused.clone()
+ })
+ .expect("runtime exists");
let read_sessions = sessions.clone();
let read_flow_paused = flow_paused.clone();
@@ -4630,49 +4959,99 @@ mod tests {
}
/// `set_pty_flow_paused` must flip the in-process `flow_paused` flag for a
- /// session with no daemon client — i.e. pause/resume is no longer a no-op on
- /// the in-process path (issue #73 acceptance criterion). Attach then clears
- /// it (self-heal).
+ /// single-subscriber session with no daemon client — i.e. pause/resume is
+ /// not a no-op on the in-process path (issue #73 acceptance criterion). A
+ /// fresh reattach then clears it (self-heal: a new unpaused subscriber can't
+ /// leave "all subscribers paused" true).
#[test]
fn test_set_pty_flow_paused_toggles_in_process_flag() {
let sessions = make_sessions();
- let flag = with_session_runtime(
- &sessions,
- "inproc",
- || SessionRuntime::new("inproc"),
- |runtime| {
- // No daemon_client set → this is an in-process session.
- runtime.flow_paused.clone()
- },
- );
+ // No daemon_client set → this is an in-process session.
+ let channel: Channel> = Channel::new(|_| Ok(()));
+ let generation = attach_subscriber(&sessions, "inproc", channel);
+ let flag = with_existing_session_runtime(&sessions, "inproc", |runtime| {
+ runtime.flow_paused.clone()
+ })
+ .expect("runtime exists");
assert!(
!flag.load(Ordering::Relaxed),
- "a fresh session starts unpaused"
+ "a fresh subscriber starts unpaused"
);
- set_pty_flow_paused(&sessions, "inproc", true);
+ set_pty_flow_paused(&sessions, "inproc", Some(generation), true);
assert!(
flag.load(Ordering::Relaxed),
- "pause must set the in-process flow_paused flag (not a no-op)"
+ "pausing the only subscriber must set the in-process flow_paused flag"
);
- set_pty_flow_paused(&sessions, "inproc", false);
+ set_pty_flow_paused(&sessions, "inproc", Some(generation), false);
assert!(
!flag.load(Ordering::Relaxed),
"resume must clear the in-process flow_paused flag"
);
- // Re-pause, then simulate a reattach: attach_pty_output clears the flag
- // as a self-heal so a reattaching pane always starts live.
- set_pty_flow_paused(&sessions, "inproc", true);
+ // Re-pause, then simulate a reattach: a fresh unpaused subscriber makes
+ // "all subscribers paused" false, so the derived flag clears.
+ set_pty_flow_paused(&sessions, "inproc", Some(generation), true);
assert!(flag.load(Ordering::Relaxed));
- with_existing_session_runtime(&sessions, "inproc", |runtime| {
- // Mirror the self-heal line in attach_pty_output.
- runtime.flow_paused.store(false, Ordering::Relaxed);
- });
+ let channel2: Channel> = Channel::new(|_| Ok(()));
+ attach_subscriber(&sessions, "inproc", channel2);
+ assert!(
+ !flag.load(Ordering::Relaxed),
+ "a fresh attach must clear a stale pause (new subscriber is unpaused)"
+ );
+ }
+
+ /// A subscriber pausing alone must NOT park the reader while another
+ /// subscriber is still caught up — the fan-out only parks when EVERY
+ /// subscriber has paused. This is the core multi-client flow-control rule.
+ #[test]
+ fn test_flow_parks_only_when_all_subscribers_paused() {
+ let sessions = make_sessions();
+ let ch_a: Channel> = Channel::new(|_| Ok(()));
+ let ch_b: Channel> = Channel::new(|_| Ok(()));
+ let gen_a = attach_subscriber(&sessions, "multi", ch_a);
+ let gen_b = attach_subscriber(&sessions, "multi", ch_b);
+ let flag = with_existing_session_runtime(&sessions, "multi", |runtime| {
+ runtime.flow_paused.clone()
+ })
+ .expect("runtime exists");
+
+ // A pauses alone → B still unpaused → reader must NOT park.
+ set_pty_flow_paused(&sessions, "multi", Some(gen_a), true);
+ assert!(
+ !flag.load(Ordering::Relaxed),
+ "one paused subscriber must not park the reader while another is live"
+ );
+
+ // B also pauses → all paused → reader parks.
+ set_pty_flow_paused(&sessions, "multi", Some(gen_b), true);
+ assert!(
+ flag.load(Ordering::Relaxed),
+ "reader must park once every subscriber has paused"
+ );
+
+ // A resumes → no longer all paused → reader resumes.
+ set_pty_flow_paused(&sessions, "multi", Some(gen_a), false);
+ assert!(
+ !flag.load(Ordering::Relaxed),
+ "reader must resume as soon as any subscriber resumes"
+ );
+
+ // Detaching the still-paused B (with A unpaused) leaves the reader
+ // running; detaching A too (leaving only paused... none) → empty set
+ // never parks.
+ set_pty_flow_paused(&sessions, "multi", Some(gen_a), true);
+ assert!(flag.load(Ordering::Relaxed), "both paused → parked");
+ detach_subscriber(&sessions, "multi", gen_b);
+ assert!(
+ flag.load(Ordering::Relaxed),
+ "only the paused subscriber remains → still parked"
+ );
+ detach_subscriber(&sessions, "multi", gen_a);
assert!(
!flag.load(Ordering::Relaxed),
- "attach self-heal must clear a stale pause"
+ "no subscribers left → reader must run (empty set never parks)"
);
}
@@ -4682,7 +5061,7 @@ mod tests {
fn test_set_pty_flow_paused_missing_session_is_noop() {
let sessions = make_sessions();
// Must not panic / must not create a phantom runtime.
- set_pty_flow_paused(&sessions, "ghost", true);
+ set_pty_flow_paused(&sessions, "ghost", Some(1), true);
let guard = sessions.lock().unwrap();
assert!(
guard.get("ghost").is_none(),
@@ -4690,6 +5069,27 @@ mod tests {
);
}
+ /// A stale `generation` (a subscriber that already detached) must not toggle
+ /// the flag for the surviving subscribers.
+ #[test]
+ fn test_set_pty_flow_paused_stale_generation_is_noop() {
+ let sessions = make_sessions();
+ let channel: Channel> = Channel::new(|_| Ok(()));
+ let generation = attach_subscriber(&sessions, "stale", channel);
+ let flag = with_existing_session_runtime(&sessions, "stale", |runtime| {
+ runtime.flow_paused.clone()
+ })
+ .expect("runtime exists");
+
+ // Pause a generation that was never installed → no subscriber matches →
+ // recompute leaves the live (unpaused) subscriber flowing.
+ set_pty_flow_paused(&sessions, "stale", Some(generation + 999), true);
+ assert!(
+ !flag.load(Ordering::Relaxed),
+ "a pause aimed at a non-existent generation must not park the reader"
+ );
+ }
+
/// The `FLOW_MAX_PARK` backstop must force-resume a reader that was paused
/// and never resumed, so a wedged/crashed renderer can't block the child
/// forever. We can't wait the full 10s in a unit test, so this asserts the
diff --git a/src-tauri/src/web_remote/assets.rs b/src-tauri/src/web_remote/assets.rs
new file mode 100644
index 00000000..76a9c792
--- /dev/null
+++ b/src-tauri/src/web_remote/assets.rs
@@ -0,0 +1,196 @@
+//! Authenticated file-asset route for the web-remote server.
+//!
+//! Backs the browser shim's `convertFileSrc` replacement: the shim maps a
+//! local file path to `/api/assets?path=` (see `src/remote/shim.ts`),
+//! and this route streams that file back with a guessed MIME type so
+//! ` ` / editor previews resolve on the web client exactly as the
+//! desktop `asset:` protocol resolves them.
+//!
+//! ## Security model — full-file read is by design
+//!
+//! This route serves ANY file the desktop user can read. That is intentional:
+//! a paired web session has desktop-level control — it *is* the desktop (see
+//! decision 5 in `docs/plans/web-remote-access.md`). The security boundary is
+//! pairing + revocation + the network layer, not per-path ACLs. The desktop
+//! webview's own `asset:`/`convertFileSrc` protocol reads arbitrary local
+//! files the same way; this is the web-transport equivalent. Access is gated
+//! on an approved, non-revoked session (bearer or HttpOnly cookie) plus a
+//! same-origin check via [`super::server::require_session`]. Directories and
+//! non-regular files return 404 — the route never produces a directory
+//! listing.
+
+use std::path::Path;
+
+use axum::{
+ extract::{Query, State},
+ http::{header, StatusCode},
+ response::{IntoResponse, Response},
+};
+use axum::http::HeaderMap;
+use serde::Deserialize;
+use tauri::AppHandle;
+
+#[derive(Deserialize)]
+pub struct AssetQuery {
+ path: String,
+}
+
+/// `GET /api/assets?path=` — auth-gated file streamer.
+pub async fn serve(
+ State(app): State,
+ headers: HeaderMap,
+ Query(query): Query,
+) -> Response {
+ if let Err(resp) = super::server::require_session(&app, &headers) {
+ return resp;
+ }
+
+ match open_asset(Path::new(&query.path)).await {
+ Ok(asset) => {
+ // Stream the file rather than buffering it whole — a web client
+ // may request a large image/media file.
+ let stream = tokio_util::io::ReaderStream::new(asset.file);
+ let body = axum::body::Body::from_stream(stream);
+ axum::response::Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, asset.mime)
+ .header(header::CONTENT_LENGTH, asset.len)
+ .body(body)
+ .unwrap_or_else(|_| {
+ (StatusCode::INTERNAL_SERVER_ERROR, "asset build failed").into_response()
+ })
+ }
+ Err(AssetError::NotFound) => (StatusCode::NOT_FOUND, "not found").into_response(),
+ }
+}
+
+/// An openable regular file plus the metadata needed to serve it.
+struct OpenAsset {
+ file: tokio::fs::File,
+ mime: &'static str,
+ len: u64,
+}
+
+enum AssetError {
+ /// Missing path, a directory, or any non-regular file.
+ NotFound,
+}
+
+/// Open a regular file for streaming. Non-files (directories, missing paths,
+/// sockets/fifos) map to [`AssetError::NotFound`] so the route never lists a
+/// directory or leaks a special-file read.
+async fn open_asset(path: &Path) -> Result {
+ let meta = tokio::fs::metadata(path)
+ .await
+ .map_err(|_| AssetError::NotFound)?;
+ if !meta.is_file() {
+ return Err(AssetError::NotFound);
+ }
+ let file = tokio::fs::File::open(path)
+ .await
+ .map_err(|_| AssetError::NotFound)?;
+ Ok(OpenAsset {
+ file,
+ mime: guess_mime(path),
+ len: meta.len(),
+ })
+}
+
+/// Guess a MIME type from a path's extension. Covers the media/text/font
+/// types the app's `convertFileSrc` call sites actually reference (images,
+/// video/audio previews, PDFs, markdown/text). Unknown extensions fall back
+/// to `application/octet-stream`, which browsers download rather than
+/// mis-render. A small hand-rolled table keeps this dependency-free.
+fn guess_mime(path: &Path) -> &'static str {
+ let ext = path
+ .extension()
+ .and_then(|e| e.to_str())
+ .map(str::to_ascii_lowercase)
+ .unwrap_or_default();
+ match ext.as_str() {
+ // Images
+ "png" => "image/png",
+ "jpg" | "jpeg" => "image/jpeg",
+ "gif" => "image/gif",
+ "webp" => "image/webp",
+ "svg" => "image/svg+xml",
+ "avif" => "image/avif",
+ "bmp" => "image/bmp",
+ "ico" => "image/x-icon",
+ "tif" | "tiff" => "image/tiff",
+ "heic" => "image/heic",
+ // Video
+ "mp4" | "m4v" => "video/mp4",
+ "webm" => "video/webm",
+ "mov" => "video/quicktime",
+ "mkv" => "video/x-matroska",
+ "avi" => "video/x-msvideo",
+ // Audio
+ "mp3" => "audio/mpeg",
+ "wav" => "audio/wav",
+ "ogg" | "oga" => "audio/ogg",
+ "m4a" => "audio/mp4",
+ "flac" => "audio/flac",
+ // Documents / text
+ "pdf" => "application/pdf",
+ "json" => "application/json",
+ "txt" | "log" => "text/plain; charset=utf-8",
+ "md" | "markdown" => "text/markdown; charset=utf-8",
+ "csv" => "text/csv; charset=utf-8",
+ "html" | "htm" => "text/html; charset=utf-8",
+ "css" => "text/css; charset=utf-8",
+ "js" | "mjs" => "text/javascript; charset=utf-8",
+ "xml" => "application/xml",
+ // Fonts
+ "woff" => "font/woff",
+ "woff2" => "font/woff2",
+ "ttf" => "font/ttf",
+ "otf" => "font/otf",
+ _ => "application/octet-stream",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn serves_regular_file_with_guessed_mime_and_length() {
+ let dir = tempfile::tempdir().unwrap();
+ let p = dir.path().join("shot.png");
+ tokio::fs::write(&p, b"\x89PNG\r\n\x1a\n").await.unwrap();
+
+ let asset = open_asset(&p).await.map_err(|_| ()).expect("file opens");
+ assert_eq!(asset.mime, "image/png");
+ assert_eq!(asset.len, 8);
+ }
+
+ #[tokio::test]
+ async fn directory_is_not_found() {
+ let dir = tempfile::tempdir().unwrap();
+ assert!(matches!(
+ open_asset(dir.path()).await,
+ Err(AssetError::NotFound)
+ ));
+ }
+
+ #[tokio::test]
+ async fn missing_path_is_not_found() {
+ assert!(matches!(
+ open_asset(Path::new("/no/such/file/should/exist/xyz.png")).await,
+ Err(AssetError::NotFound)
+ ));
+ }
+
+ #[test]
+ fn mime_guessing_covers_common_types_and_falls_back() {
+ assert_eq!(guess_mime(Path::new("/a/b.PNG")), "image/png");
+ assert_eq!(guess_mime(Path::new("/a/b.jpeg")), "image/jpeg");
+ assert_eq!(guess_mime(Path::new("/a/b.svg")), "image/svg+xml");
+ assert_eq!(guess_mime(Path::new("/a/b.pdf")), "application/pdf");
+ assert_eq!(guess_mime(Path::new("/a/b.md")), "text/markdown; charset=utf-8");
+ // Unknown / no extension → generic download type.
+ assert_eq!(guess_mime(Path::new("/a/b.unknownext")), "application/octet-stream");
+ assert_eq!(guess_mime(Path::new("/a/noext")), "application/octet-stream");
+ }
+}
diff --git a/src-tauri/src/web_remote/auth.rs b/src-tauri/src/web_remote/auth.rs
new file mode 100644
index 00000000..203f8710
--- /dev/null
+++ b/src-tauri/src/web_remote/auth.rs
@@ -0,0 +1,463 @@
+//! Authentication primitives for the web-remote server.
+//!
+//! The trust model (see `docs/plans/web-remote-access.md`):
+//!
+//! 1. **Pairing token** — 32 random bytes, 10-minute TTL, single use,
+//! in memory only. Handed to a browser out-of-band (QR / link).
+//! 2. **Session** — created when a valid pairing token is presented.
+//! Persisted in SQLite as a SHA-256 `token_hash`; the plaintext
+//! session token is returned to the browser once and never stored.
+//! Authentication is a constant-time hash compare.
+//! 3. **WS ticket** — 30-second TTL, single use, in memory. A browser
+//! trades its session token for a ticket, then opens `/ws?ticket=…`
+//! so the long-lived session secret never lands in a WS URL or a
+//! server log.
+//!
+//! Pairing attempts are rate-limited to 5/min/IP. Origin is checked on
+//! every state-touching request so a page on another site cannot ride a
+//! browser's stored cookie.
+
+use std::collections::HashMap;
+use std::net::IpAddr;
+use std::sync::Mutex;
+use std::time::{Duration, Instant};
+
+use axum::http::HeaderMap;
+use rand::RngCore;
+use sha2::{Digest, Sha256};
+
+/// Pairing tokens live 10 minutes.
+pub const PAIRING_TTL: Duration = Duration::from_secs(10 * 60);
+/// WS tickets live 30 seconds.
+pub const TICKET_TTL: Duration = Duration::from_secs(30);
+/// Pairing attempts allowed per IP per window.
+pub const RATE_LIMIT_MAX: usize = 5;
+/// Rate-limit sliding window.
+pub const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60);
+/// Entropy for pairing / session / ticket secrets.
+pub const TOKEN_BYTES: usize = 32;
+
+/// Hex-encode a byte slice (lowercase). Small enough to avoid pulling in
+/// a hex crate for a handful of call sites.
+fn to_hex(bytes: &[u8]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut s = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ s.push(HEX[(b >> 4) as usize] as char);
+ s.push(HEX[(b & 0x0f) as usize] as char);
+ }
+ s
+}
+
+/// A fresh random secret as `TOKEN_BYTES * 2` lowercase hex chars.
+pub fn random_token() -> String {
+ let mut buf = [0u8; TOKEN_BYTES];
+ rand::thread_rng().fill_bytes(&mut buf);
+ to_hex(&buf)
+}
+
+/// SHA-256 of `input`, hex-encoded. Used to hash session tokens at rest.
+pub fn sha256_hex(input: &str) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(input.as_bytes());
+ to_hex(&hasher.finalize())
+}
+
+/// Constant-time byte-slice equality (length-independent branch on the
+/// content). Returns false on length mismatch.
+pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
+ if a.len() != b.len() {
+ return false;
+ }
+ let mut diff: u8 = 0;
+ for (x, y) in a.iter().zip(b.iter()) {
+ diff |= x ^ y;
+ }
+ diff == 0
+}
+
+// ── Pairing tokens ──────────────────────────────────────────────────
+
+/// A live pairing token: when it was issued, plus an optional device name
+/// suggested by whoever minted it (the `codemux remote pair --name `
+/// CLI). The suggested name is used by the pair handler only as a fallback
+/// when the connecting client presents no `device_name` of its own.
+struct PairingEntry {
+ created: Instant,
+ suggested_name: Option,
+}
+
+/// Outcome of consuming a pairing token: whether it was valid (single-use,
+/// within TTL) plus any suggested device name it carried.
+pub struct PairingConsume {
+ pub consumed: bool,
+ pub suggested_name: Option,
+}
+
+#[derive(Default)]
+pub struct PairingStore {
+ inner: Mutex>,
+}
+
+impl PairingStore {
+ /// Issue a new single-use pairing token with no suggested device name.
+ /// Returns `(token, expires_in)`.
+ pub fn issue(&self) -> (String, Duration) {
+ self.issue_named(None)
+ }
+
+ /// Issue a single-use pairing token carrying an optional suggested
+ /// device name. Returns `(token, expires_in)`. The name is surfaced by
+ /// [`PairingStore::consume_named`] so the pair handler can label a
+ /// device that connects without naming itself (e.g. a QR minted from
+ /// the CLI with `--name`).
+ pub fn issue_named(&self, suggested_name: Option) -> (String, Duration) {
+ let token = random_token();
+ let mut map = self.inner.lock().unwrap();
+ Self::prune(&mut map);
+ map.insert(
+ token.clone(),
+ PairingEntry {
+ created: Instant::now(),
+ suggested_name,
+ },
+ );
+ (token, PAIRING_TTL)
+ }
+
+ /// Consume a pairing token: succeeds exactly once, and only within
+ /// the TTL. Removes it whether or not it was still fresh so a
+ /// captured-but-expired token can't be retried.
+ pub fn consume(&self, token: &str) -> bool {
+ self.consume_named(token).consumed
+ }
+
+ /// Consume a pairing token, also returning any suggested device name it
+ /// carried so the pair handler can use it as a fallback label. Same
+ /// single-use / TTL semantics as [`PairingStore::consume`].
+ pub fn consume_named(&self, token: &str) -> PairingConsume {
+ let mut map = self.inner.lock().unwrap();
+ Self::prune(&mut map);
+ match map.remove(token) {
+ Some(entry) => PairingConsume {
+ consumed: entry.created.elapsed() < PAIRING_TTL,
+ suggested_name: entry.suggested_name,
+ },
+ None => PairingConsume {
+ consumed: false,
+ suggested_name: None,
+ },
+ }
+ }
+
+ /// Number of live (unexpired) pairing tokens. Test/introspection use.
+ pub fn live_count(&self) -> usize {
+ let mut map = self.inner.lock().unwrap();
+ Self::prune(&mut map);
+ map.len()
+ }
+
+ fn prune(map: &mut HashMap) {
+ map.retain(|_, entry| entry.created.elapsed() < PAIRING_TTL);
+ }
+}
+
+// ── WS tickets ──────────────────────────────────────────────────────
+
+struct TicketEntry {
+ session_id: String,
+ created: Instant,
+}
+
+#[derive(Default)]
+pub struct TicketStore {
+ inner: Mutex>,
+}
+
+impl TicketStore {
+ /// Mint a single-use ticket bound to `session_id`.
+ pub fn issue(&self, session_id: &str) -> String {
+ let ticket = random_token();
+ let mut map = self.inner.lock().unwrap();
+ Self::prune(&mut map);
+ map.insert(
+ ticket.clone(),
+ TicketEntry {
+ session_id: session_id.to_string(),
+ created: Instant::now(),
+ },
+ );
+ ticket
+ }
+
+ /// Redeem a ticket, returning the bound `session_id` if it is valid
+ /// and unexpired. Single-use: the ticket is removed regardless.
+ pub fn consume(&self, ticket: &str) -> Option {
+ let mut map = self.inner.lock().unwrap();
+ Self::prune(&mut map);
+ match map.remove(ticket) {
+ Some(entry) if entry.created.elapsed() < TICKET_TTL => Some(entry.session_id),
+ _ => None,
+ }
+ }
+
+ fn prune(map: &mut HashMap) {
+ map.retain(|_, entry| entry.created.elapsed() < TICKET_TTL);
+ }
+}
+
+// ── Pairing rate limiter ────────────────────────────────────────────
+
+#[derive(Default)]
+pub struct RateLimiter {
+ inner: Mutex>>,
+}
+
+impl RateLimiter {
+ /// Record an attempt from `ip` and report whether it is allowed
+ /// (≤ `RATE_LIMIT_MAX` within `RATE_LIMIT_WINDOW`). A rejected attempt
+ /// is *not* recorded, so a blocked client that backs off recovers.
+ pub fn check_and_record(&self, ip: IpAddr) -> bool {
+ let mut map = self.inner.lock().unwrap();
+ let now = Instant::now();
+ let hits = map.entry(ip).or_default();
+ hits.retain(|t| now.duration_since(*t) < RATE_LIMIT_WINDOW);
+ if hits.len() >= RATE_LIMIT_MAX {
+ return false;
+ }
+ hits.push(now);
+ true
+ }
+}
+
+// ── Session authentication ──────────────────────────────────────────
+
+/// The result of resolving a presented bearer/cookie token to a session.
+#[derive(Debug, Clone)]
+pub struct AuthedSession {
+ pub id: String,
+ /// Whether this session has been approved (approval-mode gate).
+ pub approved: bool,
+}
+
+/// Resolve a plaintext session token to a live (non-revoked) session by
+/// constant-time comparing its SHA-256 against every active row's hash.
+/// Returns `None` if nothing matches.
+pub fn authenticate(db: &crate::database::DatabaseStore, token: &str) -> Option {
+ if token.is_empty() {
+ return None;
+ }
+ let presented = sha256_hex(token);
+ let mut matched: Option = None;
+ // Scan every candidate (don't early-return) so timing does not leak
+ // which/whether a row matched.
+ for (id, stored_hash, approved) in db.web_remote_active_session_hashes() {
+ if constant_time_eq(presented.as_bytes(), stored_hash.as_bytes()) {
+ matched = Some(AuthedSession { id, approved });
+ }
+ }
+ matched
+}
+
+// ── Origin checks ───────────────────────────────────────────────────
+
+/// Enforce same-origin for state-touching requests. A request passes when
+/// it has no `Origin` header (native clients, same-origin asset GETs) or
+/// when the Origin's host authority matches the request `Host`. A present
+/// but mismatched Origin is rejected — that is the cross-site case.
+pub fn origin_ok(headers: &HeaderMap) -> bool {
+ let origin = match headers.get(axum::http::header::ORIGIN).and_then(|v| v.to_str().ok()) {
+ Some(o) => o,
+ // No Origin header: not a cross-site browser request. Allow.
+ None => return true,
+ };
+ let host = match headers.get(axum::http::header::HOST).and_then(|v| v.to_str().ok()) {
+ Some(h) => h,
+ None => return false,
+ };
+ // Compare host authorities: strip the scheme from Origin, then match
+ // the `host[:port]` portion against the Host header verbatim.
+ let origin_authority = origin
+ .split_once("://")
+ .map(|(_, rest)| rest)
+ .unwrap_or(origin);
+ origin_authority == host
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::database::init_test_database;
+
+ #[test]
+ fn sha256_known_vector() {
+ // SHA-256("abc")
+ assert_eq!(
+ sha256_hex("abc"),
+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
+ );
+ }
+
+ #[test]
+ fn constant_time_eq_behaviour() {
+ assert!(constant_time_eq(b"abc", b"abc"));
+ assert!(!constant_time_eq(b"abc", b"abd"));
+ assert!(!constant_time_eq(b"abc", b"abcd"));
+ assert!(constant_time_eq(b"", b""));
+ }
+
+ #[test]
+ fn random_token_is_64_hex_chars_and_unique() {
+ let a = random_token();
+ let b = random_token();
+ assert_eq!(a.len(), TOKEN_BYTES * 2);
+ assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
+ assert_ne!(a, b);
+ }
+
+ #[test]
+ fn pairing_token_single_use() {
+ let store = PairingStore::default();
+ let (token, ttl) = store.issue();
+ assert_eq!(ttl, PAIRING_TTL);
+ assert!(store.consume(&token), "first consume succeeds");
+ assert!(!store.consume(&token), "second consume fails (single-use)");
+ }
+
+ #[test]
+ fn pairing_unknown_token_rejected() {
+ let store = PairingStore::default();
+ assert!(!store.consume("deadbeef"));
+ }
+
+ #[test]
+ fn pairing_expired_token_rejected() {
+ let store = PairingStore::default();
+ let token = random_token();
+ // Simulate an already-expired issue by inserting an old timestamp.
+ {
+ let mut map = store.inner.lock().unwrap();
+ map.insert(
+ token.clone(),
+ PairingEntry {
+ created: Instant::now() - (PAIRING_TTL + Duration::from_secs(1)),
+ suggested_name: None,
+ },
+ );
+ }
+ assert!(!store.consume(&token), "expired token must be rejected");
+ }
+
+ #[test]
+ fn pairing_suggested_name_survives_consume_and_is_single_use() {
+ // A token minted with a suggested name (the CLI's `--name` label)
+ // returns that name once, on the single successful consume. The
+ // second consume is empty — proving the name isn't leaked twice and
+ // the single-use invariant holds for the named path too.
+ let store = PairingStore::default();
+ let (token, _ttl) = store.issue_named(Some("Kitchen iPad".to_string()));
+
+ let first = store.consume_named(&token);
+ assert!(first.consumed, "first named consume succeeds");
+ assert_eq!(first.suggested_name.as_deref(), Some("Kitchen iPad"));
+
+ let second = store.consume_named(&token);
+ assert!(!second.consumed, "second named consume fails (single-use)");
+ assert_eq!(second.suggested_name, None);
+ }
+
+ #[test]
+ fn ticket_single_use_and_binding() {
+ let store = TicketStore::default();
+ let ticket = store.issue("session-1");
+ assert_eq!(store.consume(&ticket).as_deref(), Some("session-1"));
+ assert!(store.consume(&ticket).is_none(), "ticket is single-use");
+ }
+
+ #[test]
+ fn ticket_expiry_rejected() {
+ let store = TicketStore::default();
+ let ticket = random_token();
+ {
+ let mut map = store.inner.lock().unwrap();
+ map.insert(
+ ticket.clone(),
+ TicketEntry {
+ session_id: "s".into(),
+ created: Instant::now() - (TICKET_TTL + Duration::from_secs(1)),
+ },
+ );
+ }
+ assert!(store.consume(&ticket).is_none(), "expired ticket must be rejected");
+ }
+
+ #[test]
+ fn rate_limiter_blocks_sixth_attempt() {
+ let rl = RateLimiter::default();
+ let ip: IpAddr = "203.0.113.7".parse().unwrap();
+ for i in 0..RATE_LIMIT_MAX {
+ assert!(rl.check_and_record(ip), "attempt {i} within limit");
+ }
+ assert!(!rl.check_and_record(ip), "attempt beyond limit blocked");
+ // A different IP is unaffected.
+ assert!(rl.check_and_record("203.0.113.8".parse().unwrap()));
+ }
+
+ #[test]
+ fn authenticate_roundtrip_and_revocation() {
+ let db = init_test_database();
+ let token = random_token();
+ let hash = sha256_hex(&token);
+ db.web_remote_insert_session("sess-a", Some("Phone"), None, &hash, true)
+ .unwrap();
+
+ // A valid token resolves to the approved session.
+ let authed = authenticate(&db, &token).expect("token authenticates");
+ assert_eq!(authed.id, "sess-a");
+ assert!(authed.approved);
+
+ // A wrong token does not.
+ assert!(authenticate(&db, &random_token()).is_none());
+
+ // After revocation the token is dead.
+ db.web_remote_revoke_session("sess-a").unwrap();
+ assert!(
+ authenticate(&db, &token).is_none(),
+ "revoked session must not authenticate"
+ );
+ }
+
+ #[test]
+ fn authenticate_reflects_pending_approval() {
+ let db = init_test_database();
+ let token = random_token();
+ db.web_remote_insert_session("sess-p", None, None, &sha256_hex(&token), false)
+ .unwrap();
+ let authed = authenticate(&db, &token).expect("authenticates");
+ assert!(!authed.approved, "pending session reports approved=false");
+ }
+
+ fn headers_with(origin: Option<&str>, host: &str) -> HeaderMap {
+ let mut h = HeaderMap::new();
+ if let Some(o) = origin {
+ h.insert(axum::http::header::ORIGIN, o.parse().unwrap());
+ }
+ h.insert(axum::http::header::HOST, host.parse().unwrap());
+ h
+ }
+
+ #[test]
+ fn origin_check() {
+ // Same origin passes.
+ assert!(origin_ok(&headers_with(Some("http://192.168.1.5:4377"), "192.168.1.5:4377")));
+ // Cross-site is rejected.
+ assert!(!origin_ok(&headers_with(Some("http://evil.example"), "192.168.1.5:4377")));
+ // Missing Origin (native client / same-origin GET) passes.
+ assert!(origin_ok(&headers_with(None, "192.168.1.5:4377")));
+ // HTTPS origin with matching authority passes.
+ assert!(origin_ok(&headers_with(
+ Some("https://host.tailnet.ts.net"),
+ "host.tailnet.ts.net"
+ )));
+ }
+}
diff --git a/src-tauri/src/web_remote/dispatch.rs b/src-tauri/src/web_remote/dispatch.rs
new file mode 100644
index 00000000..5e910277
--- /dev/null
+++ b/src-tauri/src/web_remote/dispatch.rs
@@ -0,0 +1,375 @@
+//! Invoke dispatch for the web-remote server.
+//!
+//! ## Strategy (Rust dispatch ladder, Option 1)
+//!
+//! A browser's `invoke(cmd, args)` frame is turned into a real
+//! [`tauri::webview::InvokeRequest`] and driven through the main
+//! window's webview via `WebviewWindow::on_message` — the same entry
+//! point the desktop's own IPC uses. This reuses argument
+//! deserialization, `State`/`AppHandle`/`Window` extraction, ACL
+//! resolution, and error formatting for every one of the app's ~300
+//! commands with zero per-command wiring. The `on_message` responder
+//! forwards the [`InvokeResponse`] back over a oneshot; the WS task
+//! awaits it and emits the id-matched `ok`/`err` frame. One tokio task
+//! per invoke means responses may return out of order — exactly what
+//! the protocol allows.
+//!
+//! ## Channels (the one thing on_message can't do for us)
+//!
+//! A `tauri::ipc::Channel` deserialized inside a synthesized invoke
+//! would, by default, post its frames to the *desktop* webview's JS —
+//! the wrong client. Tauri exposes a **channel interceptor**
+//! (registered on the `Builder` in `lib.rs`) that fires for every
+//! channel send with `(callback_id, index, body)`. Before dispatching,
+//! we walk the invoke args and rewrite each `__CHANNEL__:`
+//! marker to a fresh **server-side** channel id, registering a route
+//! `server_id → (this WS, clientId)` in [`ChannelRouter`]. When the
+//! command later sends on that channel, the interceptor calls
+//! [`ChannelRouter::route`], which serialises the body to the owning
+//! WS as a `chan`/binary frame and returns `true` to suppress the
+//! desktop delivery. This is uniform across every channel-taking
+//! command (`attach_pty_output`, `attach_agent_chat_output`, …) and
+//! needs no knowledge of their signatures — so it is unaffected by
+//! concurrent changes to those commands' fan-out internals.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
+use std::sync::{Arc, Mutex};
+
+use axum::extract::ws::Message;
+use serde_json::{json, Value};
+use tauri::ipc::{CallbackFn, InvokeBody, InvokeResponse, InvokeResponseBody};
+use tauri::webview::InvokeRequest;
+use tauri::{AppHandle, Manager};
+
+use super::server::OutboundTx;
+
+/// The `@tauri-apps/api` `Channel` serialisation marker.
+const CHANNEL_PREFIX: &str = "__CHANNEL__:";
+
+/// Where the interceptor sends a given server-side channel's frames.
+struct ChannelRoute {
+ /// The callback id the browser's shim expects on its frames.
+ client_cb: u32,
+ /// Writer for the owning WS connection.
+ out: OutboundTx,
+ /// Owning connection — used to drop all of a socket's routes on close.
+ conn_id: u64,
+}
+
+/// Maps server-allocated channel ids to their destination WS. Shared
+/// between the dispatcher (which registers routes) and the channel
+/// interceptor (which consumes them). One instance lives for the whole
+/// app; the interceptor closure in `lib.rs` holds a clone.
+#[derive(Default)]
+pub struct ChannelRouter {
+ routes: Mutex>,
+ next: AtomicU32,
+ /// Live route count, kept in sync with `routes`. Lets the interceptor
+ /// short-circuit without taking the lock when no web-remote channels
+ /// are open — the desktop-only hot path (e.g. local PTY output) then
+ /// pays nothing beyond one relaxed atomic load.
+ active: AtomicUsize,
+}
+
+impl ChannelRouter {
+ /// Allocate a fresh server-side channel id bound to a browser
+ /// callback id on a specific connection.
+ fn alloc(&self, client_cb: u32, conn_id: u64, out: OutboundTx) -> u32 {
+ // Start high to keep server ids clustered away from the low ids a
+ // freshly booted desktop webview tends to mint first. (Collisions
+ // are still only possible against a *currently live* server id and
+ // are astronomically unlikely — see the module deviation note.)
+ let server_cb = 0x4000_0000u32.wrapping_add(self.next.fetch_add(1, Ordering::SeqCst));
+ let mut routes = self.routes.lock().unwrap();
+ routes.insert(
+ server_cb,
+ ChannelRoute {
+ client_cb,
+ out,
+ conn_id,
+ },
+ );
+ self.active.store(routes.len(), Ordering::Relaxed);
+ server_cb
+ }
+
+ /// Interceptor entry point. Returns `true` when `server_cb` is one of
+ /// ours (frame consumed and forwarded to its WS), `false` otherwise
+ /// (a genuine desktop-webview channel — let Tauri deliver it).
+ pub fn route(&self, server_cb: u32, idx: usize, body: &InvokeResponseBody) -> bool {
+ // Fast path: no web channels open → never ours, don't touch the lock.
+ if self.active.load(Ordering::Relaxed) == 0 {
+ return false;
+ }
+ let routes = self.routes.lock().unwrap();
+ let Some(route) = routes.get(&server_cb) else {
+ return false;
+ };
+ let msg = match body {
+ // Raw channel bodies (e.g. a command that sends
+ // `tauri::ipc::Response` bytes) → compact binary frame:
+ // [0x01][u32 BE callbackId][u64 BE idx][payload]
+ InvokeResponseBody::Raw(bytes) => {
+ let mut framed = Vec::with_capacity(1 + 4 + 8 + bytes.len());
+ framed.push(0x01u8);
+ framed.extend_from_slice(&route.client_cb.to_be_bytes());
+ framed.extend_from_slice(&(idx as u64).to_be_bytes());
+ framed.extend_from_slice(bytes);
+ Message::Binary(framed)
+ }
+ // JSON channel bodies (the common case, incl. PTY bytes which
+ // a `Channel>` serialises to a JSON number array) →
+ // {"t":"chan","ch":,"idx":,"data":}
+ InvokeResponseBody::Json(s) => {
+ let data: Value = serde_json::from_str(s).unwrap_or(Value::Null);
+ let frame = json!({
+ "t": "chan",
+ "ch": route.client_cb,
+ "idx": idx,
+ "data": data,
+ });
+ Message::Text(frame.to_string())
+ }
+ };
+ let _ = route.out.send(msg);
+ true
+ }
+
+ /// Drop every route owned by a connection. Called when its WS closes —
+ /// "channels die with their WS connection".
+ pub fn remove_conn(&self, conn_id: u64) {
+ let mut routes = self.routes.lock().unwrap();
+ routes.retain(|_, r| r.conn_id != conn_id);
+ self.active.store(routes.len(), Ordering::Relaxed);
+ }
+}
+
+/// Recursively rewrite every `__CHANNEL__:` string in `value` to a
+/// server-side id and register its route. Handles top-level channel args
+/// and channels nested inside object/array args alike.
+fn rewrite_channel_markers(
+ value: &mut Value,
+ router: &Arc,
+ conn_id: u64,
+ out: &OutboundTx,
+) {
+ match value {
+ Value::String(s) => {
+ if let Some(rest) = s.strip_prefix(CHANNEL_PREFIX) {
+ if let Ok(client_cb) = rest.parse::() {
+ let server_cb = router.alloc(client_cb, conn_id, out.clone());
+ *s = format!("{CHANNEL_PREFIX}{server_cb}");
+ }
+ }
+ }
+ Value::Array(items) => {
+ for item in items {
+ rewrite_channel_markers(item, router, conn_id, out);
+ }
+ }
+ Value::Object(map) => {
+ for (_, v) in map.iter_mut() {
+ rewrite_channel_markers(v, router, conn_id, out);
+ }
+ }
+ _ => {}
+ }
+}
+
+/// Drive one invoke to completion and emit its response frame on `out`.
+pub async fn dispatch_invoke(
+ app: &AppHandle,
+ router: &Arc,
+ conn_id: u64,
+ out: &OutboundTx,
+ id: u64,
+ cmd: String,
+ mut args: Value,
+) {
+ // Route any channels this command opens back to *this* browser.
+ rewrite_channel_markers(&mut args, router, conn_id, out);
+
+ let webview = match app.get_webview_window("main") {
+ Some(w) => w,
+ None => {
+ let _ = out.send(err_text(id, "web-remote: main window unavailable"));
+ return;
+ }
+ };
+ // Reuse the main window's own origin so ACL resolves identically to a
+ // desktop-initiated invoke of the same command.
+ let url = match webview.url() {
+ Ok(u) => u,
+ Err(_) => {
+ let _ = out.send(err_text(id, "web-remote: main window url unavailable"));
+ return;
+ }
+ };
+
+ let request = InvokeRequest {
+ cmd,
+ callback: CallbackFn(0),
+ error: CallbackFn(1),
+ url,
+ body: InvokeBody::Json(args),
+ headers: Default::default(),
+ invoke_key: app.invoke_key().to_string(),
+ };
+
+ let (tx, rx) = tokio::sync::oneshot::channel::();
+ // Drive on_message off the async workers: a synchronous command runs
+ // inline on this blocking thread, an async command is handed to
+ // Tauri's runtime and resolves the responder later. Either way the
+ // oneshot carries the result back.
+ let _ = tokio::task::spawn_blocking(move || {
+ webview.on_message(
+ request,
+ Box::new(move |_webview, _cmd, response, _callback, _error| {
+ let _ = tx.send(response);
+ }),
+ );
+ });
+
+ let frame = match rx.await {
+ Ok(InvokeResponse::Ok(body)) => ok_frame(id, body),
+ Ok(InvokeResponse::Err(err)) => err_value(id, err.0),
+ Err(_) => err_text(id, "web-remote: command dropped without responding"),
+ };
+ let _ = out.send(frame);
+}
+
+/// `{"t":"ok","id":…,"data":…}` from a resolved [`InvokeResponseBody`].
+fn ok_frame(id: u64, body: InvokeResponseBody) -> Message {
+ let data = match body {
+ InvokeResponseBody::Json(s) => serde_json::from_str(&s).unwrap_or(Value::Null),
+ // No app command returns a raw invoke body today; forward the bytes
+ // as a JSON array so the shim can still reconstruct them if one ever
+ // does. (Channel payloads — the real raw path — never come through
+ // here; they go via the interceptor above.)
+ InvokeResponseBody::Raw(bytes) => Value::Array(bytes.into_iter().map(|b| json!(b)).collect()),
+ };
+ Message::Text(json!({ "t": "ok", "id": id, "data": data }).to_string())
+}
+
+/// `{"t":"err","id":…,"error":}` from a command rejection.
+fn err_value(id: u64, error: Value) -> Message {
+ Message::Text(json!({ "t": "err", "id": id, "error": error }).to_string())
+}
+
+/// `{"t":"err","id":…,"error":""}` for transport-level failures.
+fn err_text(id: u64, message: &str) -> Message {
+ Message::Text(json!({ "t": "err", "id": id, "error": message }).to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tokio::sync::mpsc;
+
+ fn drain_text(rx: &mut mpsc::UnboundedReceiver) -> Value {
+ match rx.try_recv().expect("a frame was sent") {
+ Message::Text(s) => serde_json::from_str(&s).unwrap(),
+ other => panic!("expected text frame, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn rewrite_allocates_and_registers_top_level_channel() {
+ let router = Arc::new(ChannelRouter::default());
+ let (tx, _rx) = mpsc::unbounded_channel();
+ let mut args = json!({ "channel": "__CHANNEL__:7", "sessionId": "s1" });
+ rewrite_channel_markers(&mut args, &router, 1, &tx);
+
+ // The marker was rewritten to a server id (not the original 7).
+ let rewritten = args["channel"].as_str().unwrap();
+ assert!(rewritten.starts_with(CHANNEL_PREFIX));
+ assert_ne!(rewritten, "__CHANNEL__:7");
+ // Non-channel args are untouched.
+ assert_eq!(args["sessionId"], json!("s1"));
+ assert_eq!(router.routes.lock().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn rewrite_handles_nested_channels() {
+ let router = Arc::new(ChannelRouter::default());
+ let (tx, _rx) = mpsc::unbounded_channel();
+ let mut args = json!({ "opts": { "cb": "__CHANNEL__:3" }, "list": ["__CHANNEL__:4"] });
+ rewrite_channel_markers(&mut args, &router, 9, &tx);
+ assert_eq!(router.routes.lock().unwrap().len(), 2);
+ }
+
+ #[test]
+ fn route_json_body_emits_chan_frame_with_client_id_and_idx() {
+ let router = Arc::new(ChannelRouter::default());
+ let (tx, mut rx) = mpsc::unbounded_channel();
+ let mut args = json!({ "channel": "__CHANNEL__:42" });
+ rewrite_channel_markers(&mut args, &router, 1, &tx);
+ let server_cb: u32 = args["channel"]
+ .as_str()
+ .unwrap()
+ .strip_prefix(CHANNEL_PREFIX)
+ .unwrap()
+ .parse()
+ .unwrap();
+
+ // Simulate a channel send (as the interceptor would).
+ let body = InvokeResponseBody::Json("[27,91,65]".to_string());
+ assert!(router.route(server_cb, 5, &body));
+
+ let frame = drain_text(&mut rx);
+ assert_eq!(frame["t"], json!("chan"));
+ assert_eq!(frame["ch"], json!(42), "client callback id preserved");
+ assert_eq!(frame["idx"], json!(5));
+ assert_eq!(frame["data"], json!([27, 91, 65]));
+ }
+
+ #[test]
+ fn route_raw_body_emits_binary_frame() {
+ let router = Arc::new(ChannelRouter::default());
+ let (tx, mut rx) = mpsc::unbounded_channel();
+ let mut args = json!({ "channel": "__CHANNEL__:1" });
+ rewrite_channel_markers(&mut args, &router, 1, &tx);
+ let server_cb: u32 = args["channel"]
+ .as_str()
+ .unwrap()
+ .strip_prefix(CHANNEL_PREFIX)
+ .unwrap()
+ .parse()
+ .unwrap();
+
+ let body = InvokeResponseBody::Raw(vec![0xde, 0xad]);
+ assert!(router.route(server_cb, 2, &body));
+
+ match rx.try_recv().expect("a frame") {
+ Message::Binary(bytes) => {
+ assert_eq!(bytes[0], 0x01);
+ assert_eq!(&bytes[1..5], &1u32.to_be_bytes()); // client cb id
+ assert_eq!(&bytes[5..13], &2u64.to_be_bytes()); // idx
+ assert_eq!(&bytes[13..], &[0xde, 0xad]);
+ }
+ other => panic!("expected binary frame, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn route_unknown_id_is_ignored() {
+ let router = Arc::new(ChannelRouter::default());
+ let body = InvokeResponseBody::Json("null".to_string());
+ assert!(!router.route(999, 0, &body), "unknown id must not be consumed");
+ }
+
+ #[test]
+ fn remove_conn_drops_only_that_connections_routes() {
+ let router = Arc::new(ChannelRouter::default());
+ let (tx_a, _ra) = mpsc::unbounded_channel();
+ let (tx_b, _rb) = mpsc::unbounded_channel();
+ router.alloc(1, 100, tx_a);
+ router.alloc(2, 200, tx_b);
+ assert_eq!(router.routes.lock().unwrap().len(), 2);
+ router.remove_conn(100);
+ let routes = router.routes.lock().unwrap();
+ assert_eq!(routes.len(), 1);
+ assert!(routes.values().all(|r| r.conn_id == 200));
+ }
+}
diff --git a/src-tauri/src/web_remote/endpoints.rs b/src-tauri/src/web_remote/endpoints.rs
new file mode 100644
index 00000000..a0b64290
--- /dev/null
+++ b/src-tauri/src/web_remote/endpoints.rs
@@ -0,0 +1,523 @@
+//! Reachable-endpoint enumeration for the web-remote server.
+//!
+//! Produces the list of URLs a browser on another device could use to
+//! reach this desktop once the server is bound on `0.0.0.0:`:
+//!
+//! - **loopback** — `127.0.0.1` (a browser secure-context origin, so
+//! clipboard etc. keep working here).
+//! - **lan** — every non-loopback IPv4 on a *real* local interface that is
+//! not inside the tailnet CGNAT range.
+//! - **tailnet** — interface IPs inside `100.64.0.0/10` plus whatever
+//! `tailscale status --json` reports for this node.
+//! - **magicdns** — the node's MagicDNS name (`Self.DNSName`) when
+//! Tailscale is present.
+//!
+//! Docker bridges, `veth` pairs, libvirt/VM host-only nets, Kubernetes CNI
+//! plumbing, ZeroTier, and the Tailscale tunnel are skipped by interface
+//! name — their `172.x`/`169.254.x`/etc. addresses are noise, not a place a
+//! human wants to point a browser.
+//!
+//! Each endpoint also carries a coarse `group` (`this_device` |
+//! `local_network` | `tailscale` | `other`) that the settings UI renders as
+//! labelled sections, and a single `recommended` hint pointing at the best
+//! "from anywhere" option.
+//!
+//! Everything degrades gracefully: no interfaces, no `tailscale` binary,
+//! or a malformed status blob just yields fewer entries, never an error.
+
+use serde::Serialize;
+use std::net::{IpAddr, Ipv4Addr};
+
+/// A single place a browser could reach this desktop.
+#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
+pub struct Endpoint {
+ /// `loopback` | `lan` | `tailnet` | `magicdns`.
+ pub kind: String,
+ /// Coarse UI grouping: `this_device` | `local_network` | `tailscale` |
+ /// `other`. Drives the labelled sections in the settings panel.
+ pub group: String,
+ /// IP literal or DNS hostname (no scheme, no port).
+ pub host: String,
+ pub port: u16,
+ /// Ready-to-copy `http://host:port` URL.
+ pub url: String,
+ /// Whether a browser treats this origin as a secure context. Only
+ /// loopback qualifies over plain HTTP; LAN/tailnet need Tailscale's
+ /// HTTPS serve (or a user proxy) to become secure, which we cannot
+ /// detect here — so they report `false` and the UI surfaces the note.
+ pub secure: bool,
+ /// The single best "reach from anywhere" endpoint, surfaced with a
+ /// "Recommended" chip. At most one endpoint carries this.
+ pub recommended: bool,
+ /// Short human hint for the settings UI.
+ pub label: String,
+}
+
+const GROUP_THIS_DEVICE: &str = "this_device";
+const GROUP_LOCAL_NETWORK: &str = "local_network";
+const GROUP_TAILSCALE: &str = "tailscale";
+const GROUP_OTHER: &str = "other";
+
+/// `true` if `name` looks like a virtual / container / VM / mesh interface
+/// whose addresses are not useful "reach this machine" endpoints: Docker
+/// bridges (`docker0`, `br-`), `veth` pairs, libvirt (`virbr`),
+/// Kubernetes CNI plumbing (`cni`, `flannel`, `cali`, `kube`), VirtualBox /
+/// VMware host-only nets, ZeroTier (`zt`), and the Tailscale tunnel
+/// (`tailscale0` — tailnet addresses come from the CLI instead).
+///
+/// The `br-` prefix is intentionally hyphenated so a legitimate bridge like
+/// `br0` is *not* swept up with Docker's `br-` networks.
+fn is_virtual_iface(name: &str) -> bool {
+ const PREFIXES: &[&str] = &[
+ "docker", "br-", "veth", "virbr", "cni", "flannel", "cali", "kube", "vboxnet", "vmnet",
+ "zt", "tailscale",
+ ];
+ let n = name.to_ascii_lowercase();
+ PREFIXES.iter().any(|p| n.starts_with(p))
+}
+
+/// `true` if `addr` is inside the tailnet CGNAT range `100.64.0.0/10`.
+fn is_tailnet_v4(addr: Ipv4Addr) -> bool {
+ let o = addr.octets();
+ // /10 → first octet 100, second octet in 64..=127.
+ o[0] == 100 && (0x40..=0x7f).contains(&o[1])
+}
+
+/// RFC 1918 private IPv4 ranges (plus 169.254/16 link-local, which is still
+/// LAN-reachable on the local segment). Excludes the tailnet range (checked
+/// earlier). Used to decide *whether* a v4 address is surfaced at all; its
+/// display group is refined by `v4.is_private()` vs link-local.
+fn is_lan_v4(addr: Ipv4Addr) -> bool {
+ addr.is_private() || addr.is_link_local()
+}
+
+/// Build the URL host portion, bracketing IPv6 literals.
+fn url_host(host: &str, is_v6: bool) -> String {
+ if is_v6 {
+ format!("[{host}]")
+ } else {
+ host.to_string()
+ }
+}
+
+fn make_endpoint(
+ kind: &str,
+ group: &str,
+ host: String,
+ port: u16,
+ secure: bool,
+ label: &str,
+ is_v6: bool,
+) -> Endpoint {
+ let url = format!("http://{}:{}", url_host(&host, is_v6), port);
+ Endpoint {
+ kind: kind.to_string(),
+ group: group.to_string(),
+ host,
+ port,
+ url,
+ secure,
+ recommended: false,
+ label: label.to_string(),
+ }
+}
+
+/// Enumerate every endpoint at which a bound server on `port` is reachable.
+pub fn list(port: u16) -> Vec {
+ let mut out: Vec = Vec::new();
+
+ // Loopback is always first — the one secure-context origin.
+ out.push(make_endpoint(
+ "loopback",
+ GROUP_THIS_DEVICE,
+ "127.0.0.1".to_string(),
+ port,
+ true,
+ "This device only (secure context)",
+ false,
+ ));
+
+ // Interface IPs — skip virtual bridges by name so Docker's 172.x et al.
+ // never masquerade as a LAN endpoint.
+ if let Ok(ifaces) = if_addrs::get_if_addrs() {
+ let pairs: Vec<(String, IpAddr)> = ifaces
+ .into_iter()
+ .map(|i| {
+ let ip = i.ip();
+ (i.name, ip)
+ })
+ .collect();
+ push_interface_endpoints(&pairs, port, &mut out);
+ }
+
+ // Tailnet IPs + MagicDNS from the Tailscale CLI, if present.
+ if let Some(status) = tailscale_status() {
+ for ip in status.ips {
+ if let Ok(IpAddr::V4(v4)) = ip.parse::() {
+ push_unique(
+ &mut out,
+ make_endpoint(
+ "tailnet",
+ GROUP_TAILSCALE,
+ v4.to_string(),
+ port,
+ false,
+ "Over your tailnet",
+ false,
+ ),
+ );
+ } else if let Ok(IpAddr::V6(v6)) = ip.parse::() {
+ push_unique(
+ &mut out,
+ make_endpoint(
+ "tailnet",
+ GROUP_TAILSCALE,
+ v6.to_string(),
+ port,
+ false,
+ "Over your tailnet",
+ true,
+ ),
+ );
+ }
+ }
+ if let Some(dns) = status.dns_name {
+ let host = dns.trim_end_matches('.').to_string();
+ if !host.is_empty() {
+ push_unique(
+ &mut out,
+ make_endpoint(
+ "magicdns",
+ GROUP_TAILSCALE,
+ host,
+ port,
+ false,
+ "MagicDNS name (enable Tailscale's HTTPS serve for a trusted certificate)",
+ false,
+ ),
+ );
+ }
+ }
+ }
+
+ mark_recommended(&mut out);
+ out
+}
+
+/// The tailnet IP addresses this node is reachable at: CGNAT-range
+/// (`100.64.0.0/10`) interface IPs plus every address `tailscale status
+/// --json` reports for this node. Reuses the exact discovery [`list`] uses,
+/// then keeps only `tailnet` entries (dropping loopback/LAN/MagicDNS) and
+/// parses their host back to an [`IpAddr`], so the addresses the server
+/// binds for the "Tailscale only" scope are precisely the "Tailscale"
+/// endpoints the Settings pane advertises. Empty when Tailscale is absent —
+/// the caller must NOT silently fall back to all interfaces.
+pub fn tailnet_ips() -> Vec {
+ // The port is irrelevant here — we only read each endpoint's host — so
+ // pass a placeholder.
+ list(0)
+ .into_iter()
+ .filter(|e| e.kind == "tailnet")
+ .filter_map(|e| e.host.parse::().ok())
+ .collect()
+}
+
+/// Classify each `(interface-name, ip)` pair into an endpoint, appending to
+/// `out`. Loopback, IPv6 link-local, and virtual/bridge interfaces are
+/// skipped. Pulled out of `list` so the name-skipping and group-assignment
+/// logic is unit-testable without touching the host's real interfaces.
+fn push_interface_endpoints(ifaces: &[(String, IpAddr)], port: u16, out: &mut Vec) {
+ for (name, ip) in ifaces {
+ if is_virtual_iface(name) {
+ continue;
+ }
+ if ip.is_loopback() {
+ continue;
+ }
+ match ip {
+ IpAddr::V4(v4) => {
+ if is_tailnet_v4(*v4) {
+ push_unique(
+ out,
+ make_endpoint(
+ "tailnet",
+ GROUP_TAILSCALE,
+ v4.to_string(),
+ port,
+ false,
+ "Over your tailnet",
+ false,
+ ),
+ );
+ } else if is_lan_v4(*v4) {
+ // RFC1918 is a genuine local-network endpoint; 169.254
+ // link-local is marginal, so it lands in "other".
+ let group = if v4.is_private() {
+ GROUP_LOCAL_NETWORK
+ } else {
+ GROUP_OTHER
+ };
+ push_unique(
+ out,
+ make_endpoint(
+ "lan",
+ group,
+ v4.to_string(),
+ port,
+ false,
+ "Local network (plain HTTP)",
+ false,
+ ),
+ );
+ }
+ }
+ IpAddr::V6(v6) => {
+ // Skip link-local (fe80::/10) — not routable for a peer
+ // without a scope id, which we cannot express in a URL.
+ if (v6.segments()[0] & 0xffc0) == 0xfe80 {
+ continue;
+ }
+ // Unique-local (fc00::/7) and global v6 have no better class
+ // than "other" from the server's point of view; plain HTTP.
+ push_unique(
+ out,
+ make_endpoint(
+ "lan",
+ GROUP_OTHER,
+ v6.to_string(),
+ port,
+ false,
+ "Other network address (plain HTTP)",
+ true,
+ ),
+ );
+ }
+ }
+ }
+}
+
+/// Mark the single best "from anywhere" endpoint as recommended: the MagicDNS
+/// name if present, else any tailnet IP, else the first local-network
+/// address. Loopback-only (nowhere to reach from) leaves nothing marked.
+fn mark_recommended(out: &mut [Endpoint]) {
+ let idx = out
+ .iter()
+ .position(|e| e.kind == "magicdns")
+ .or_else(|| out.iter().position(|e| e.group == GROUP_TAILSCALE))
+ .or_else(|| out.iter().position(|e| e.group == GROUP_LOCAL_NETWORK));
+ if let Some(i) = idx {
+ out[i].recommended = true;
+ }
+}
+
+fn push_unique(out: &mut Vec, ep: Endpoint) {
+ if !out.iter().any(|e| e.host == ep.host && e.kind == ep.kind) {
+ out.push(ep);
+ }
+}
+
+struct TailscaleStatus {
+ ips: Vec,
+ dns_name: Option,
+}
+
+/// Shell out to `tailscale status --json` and pull this node's addresses.
+/// Returns `None` when the binary is absent, errors, or the output is not
+/// the expected shape — the caller then simply omits tailnet entries.
+fn tailscale_status() -> Option {
+ // `which` avoids spawning a shell error when tailscale isn't installed.
+ if which::which("tailscale").is_err() {
+ return None;
+ }
+ let output = std::process::Command::new("tailscale")
+ .args(["status", "--json"])
+ .output()
+ .ok()?;
+ if !output.status.success() {
+ return None;
+ }
+ let value: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
+ let self_node = value.get("Self")?;
+ let ips = self_node
+ .get("TailscaleIPs")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect::>()
+ })
+ .unwrap_or_default();
+ let dns_name = self_node
+ .get("DNSName")
+ .and_then(|v| v.as_str())
+ .map(str::to_string)
+ .filter(|s| !s.is_empty());
+ Some(TailscaleStatus { ips, dns_name })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tailnet_range_detection() {
+ assert!(is_tailnet_v4("100.64.0.1".parse().unwrap()));
+ assert!(is_tailnet_v4("100.100.100.100".parse().unwrap()));
+ assert!(is_tailnet_v4("100.127.255.255".parse().unwrap()));
+ assert!(!is_tailnet_v4("100.63.255.255".parse().unwrap()));
+ assert!(!is_tailnet_v4("100.128.0.0".parse().unwrap()));
+ assert!(!is_tailnet_v4("192.168.1.5".parse().unwrap()));
+ assert!(!is_tailnet_v4("8.8.8.8".parse().unwrap()));
+ }
+
+ #[test]
+ fn lan_range_detection() {
+ assert!(is_lan_v4("192.168.1.5".parse().unwrap()));
+ assert!(is_lan_v4("10.0.0.7".parse().unwrap()));
+ assert!(is_lan_v4("172.16.4.4".parse().unwrap()));
+ assert!(!is_lan_v4("8.8.8.8".parse().unwrap()));
+ }
+
+ #[test]
+ fn virtual_iface_names_are_detected() {
+ for n in [
+ "docker0",
+ "br-1a2b3c4d",
+ "veth9f8e21",
+ "virbr0",
+ "cni0",
+ "flannel.1",
+ "cali7c1d2e",
+ "kube-ipvs0",
+ "vboxnet0",
+ "vmnet8",
+ "zt5u4d2i8n",
+ "tailscale0",
+ ] {
+ assert!(is_virtual_iface(n), "{n} should be treated as virtual");
+ }
+ for n in ["eth0", "wlan0", "en0", "enp3s0", "lo", "br0", "bond0"] {
+ assert!(!is_virtual_iface(n), "{n} should be treated as real");
+ }
+ }
+
+ #[test]
+ fn docker_and_virtual_ifaces_are_skipped() {
+ let ifaces = vec![
+ ("docker0".to_string(), "172.17.0.1".parse().unwrap()),
+ ("br-1a2b3c4d".to_string(), "172.18.0.1".parse().unwrap()),
+ ("veth1234".to_string(), "169.254.10.1".parse().unwrap()),
+ ("eth0".to_string(), "192.168.1.50".parse().unwrap()),
+ ];
+ let mut out = Vec::new();
+ push_interface_endpoints(&ifaces, 4377, &mut out);
+ // Only the real LAN address survives; the 172.x Docker bridges are gone.
+ assert_eq!(out.len(), 1);
+ assert_eq!(out[0].host, "192.168.1.50");
+ assert_eq!(out[0].group, GROUP_LOCAL_NETWORK);
+ assert!(
+ !out.iter().any(|e| e.host.starts_with("172.")),
+ "no Docker 172.x address should be surfaced as a LAN endpoint"
+ );
+ }
+
+ #[test]
+ fn group_assignment_by_address_class() {
+ let ifaces = vec![
+ ("eth0".to_string(), "192.168.68.58".parse().unwrap()),
+ ("eth0".to_string(), "100.119.27.64".parse().unwrap()),
+ ("eth0".to_string(), "169.254.5.5".parse().unwrap()),
+ ("eth0".to_string(), "fd00::5".parse().unwrap()),
+ ];
+ let mut out = Vec::new();
+ push_interface_endpoints(&ifaces, 4377, &mut out);
+ let group_of = |host: &str| {
+ out.iter()
+ .find(|e| e.host == host)
+ .map(|e| e.group.as_str())
+ };
+ assert_eq!(group_of("192.168.68.58"), Some(GROUP_LOCAL_NETWORK));
+ assert_eq!(group_of("100.119.27.64"), Some(GROUP_TAILSCALE));
+ // Link-local v4 is surfaced but demoted to "other".
+ assert_eq!(group_of("169.254.5.5"), Some(GROUP_OTHER));
+ // Unique-local v6 lands in "other" too, with a bracketed URL.
+ assert_eq!(group_of("fd00::5"), Some(GROUP_OTHER));
+ assert_eq!(
+ out.iter().find(|e| e.host == "fd00::5").unwrap().url,
+ "http://[fd00::5]:4377"
+ );
+ }
+
+ #[test]
+ fn recommended_prefers_magicdns_and_is_unique() {
+ let mut out = vec![
+ make_endpoint("loopback", GROUP_THIS_DEVICE, "127.0.0.1".into(), 4377, true, "", false),
+ make_endpoint("lan", GROUP_LOCAL_NETWORK, "192.168.1.5".into(), 4377, false, "", false),
+ make_endpoint("tailnet", GROUP_TAILSCALE, "100.64.0.1".into(), 4377, false, "", false),
+ make_endpoint("magicdns", GROUP_TAILSCALE, "box.ts.net".into(), 4377, false, "", false),
+ ];
+ mark_recommended(&mut out);
+ let recommended: Vec<&Endpoint> = out.iter().filter(|e| e.recommended).collect();
+ assert_eq!(recommended.len(), 1, "exactly one endpoint is recommended");
+ assert_eq!(recommended[0].kind, "magicdns");
+ }
+
+ #[test]
+ fn recommended_falls_back_to_tailnet_then_local_then_none() {
+ // No MagicDNS → first tailnet IP wins.
+ let mut a = vec![
+ make_endpoint("loopback", GROUP_THIS_DEVICE, "127.0.0.1".into(), 4377, true, "", false),
+ make_endpoint("lan", GROUP_LOCAL_NETWORK, "192.168.1.5".into(), 4377, false, "", false),
+ make_endpoint("tailnet", GROUP_TAILSCALE, "100.64.0.1".into(), 4377, false, "", false),
+ ];
+ mark_recommended(&mut a);
+ assert_eq!(a.iter().filter(|e| e.recommended).count(), 1);
+ assert_eq!(a.iter().find(|e| e.recommended).unwrap().kind, "tailnet");
+
+ // No Tailscale at all → first local-network address wins.
+ let mut b = vec![
+ make_endpoint("loopback", GROUP_THIS_DEVICE, "127.0.0.1".into(), 4377, true, "", false),
+ make_endpoint("lan", GROUP_LOCAL_NETWORK, "192.168.1.5".into(), 4377, false, "", false),
+ ];
+ mark_recommended(&mut b);
+ assert_eq!(b.iter().filter(|e| e.recommended).count(), 1);
+ assert_eq!(
+ b.iter().find(|e| e.recommended).unwrap().group,
+ GROUP_LOCAL_NETWORK
+ );
+
+ // Loopback only → nothing to recommend.
+ let mut c = vec![make_endpoint(
+ "loopback",
+ GROUP_THIS_DEVICE,
+ "127.0.0.1".into(),
+ 4377,
+ true,
+ "",
+ false,
+ )];
+ mark_recommended(&mut c);
+ assert_eq!(c.iter().filter(|e| e.recommended).count(), 0);
+ }
+
+ #[test]
+ fn list_always_includes_loopback_first_and_secure() {
+ let eps = list(4377);
+ assert_eq!(eps[0].kind, "loopback");
+ assert_eq!(eps[0].group, GROUP_THIS_DEVICE);
+ assert!(eps[0].secure, "loopback must be a secure context");
+ assert_eq!(eps[0].url, "http://127.0.0.1:4377");
+ // Non-loopback endpoints are never marked secure (plain HTTP).
+ for ep in eps.iter().filter(|e| e.kind != "loopback") {
+ assert!(!ep.secure, "{} should not be secure over HTTP", ep.host);
+ }
+ // At most one recommendation, whatever the host's real interfaces are.
+ assert!(eps.iter().filter(|e| e.recommended).count() <= 1);
+ }
+
+ #[test]
+ fn ipv6_url_host_is_bracketed() {
+ let ep = make_endpoint("lan", GROUP_OTHER, "fd00::1".to_string(), 4377, false, "x", true);
+ assert_eq!(ep.url, "http://[fd00::1]:4377");
+ }
+}
diff --git a/src-tauri/src/web_remote/events.rs b/src-tauri/src/web_remote/events.rs
new file mode 100644
index 00000000..ae8ce7e4
--- /dev/null
+++ b/src-tauri/src/web_remote/events.rs
@@ -0,0 +1,162 @@
+//! Global event fan-out for the web-remote server.
+//!
+//! Browsers subscribe to app events with `{"t":"listen","event":…}` and
+//! unsubscribe with `unlisten`. The hub multiplexes all subscribers onto
+//! a single `AppHandle::listen_any` registration **per event name**,
+//! reference-counted by subscriber:
+//!
+//! - first subscriber to an event name registers the `listen_any` handler,
+//! - the handler forwards every emission verbatim as
+//! `{"t":"event","event":…,"payload":}` to each current subscriber,
+//! - the last subscriber to leave (or disconnect) unregisters it.
+//!
+//! This keeps exactly one desktop-side listener alive per active event
+//! regardless of how many browsers are watching it, and none once no one
+//! is — the desktop's own event bus sees no extra churn.
+
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex};
+
+use axum::extract::ws::Message;
+use serde_json::{json, Value};
+use tauri::{AppHandle, Listener};
+
+use super::server::OutboundTx;
+
+struct EventEntry {
+ /// The single `listen_any` registration backing this event name.
+ handler_id: tauri::EventId,
+ /// Current subscribers, keyed by connection id.
+ subscribers: HashMap,
+}
+
+/// Per-event subscription registry. Cloneable (shares one inner map) so the
+/// `listen_any` handler closure can reach back in to fan a payload out.
+#[derive(Clone, Default)]
+pub struct EventHub {
+ inner: Arc>>,
+}
+
+impl EventHub {
+ /// Subscribe `conn_id` to `event`, registering the backing desktop
+ /// listener if this is the first subscriber for that event name.
+ pub fn subscribe(&self, app: &AppHandle, conn_id: u64, event: &str, out: OutboundTx) {
+ let mut map = self.inner.lock().unwrap();
+ let entry = map.entry(event.to_string()).or_insert_with(|| {
+ let inner = self.inner.clone();
+ let event_name = event.to_string();
+ let handler_id = app.listen_any(event.to_string(), move |ev| {
+ fan_out(&inner, &event_name, ev.payload());
+ });
+ EventEntry {
+ handler_id,
+ subscribers: HashMap::new(),
+ }
+ });
+ entry.subscribers.insert(conn_id, out);
+ }
+
+ /// Unsubscribe `conn_id` from `event`; tear the desktop listener down
+ /// when the last subscriber leaves.
+ pub fn unsubscribe(&self, app: &AppHandle, conn_id: u64, event: &str) {
+ let mut map = self.inner.lock().unwrap();
+ if let Some(entry) = map.get_mut(event) {
+ entry.subscribers.remove(&conn_id);
+ if entry.subscribers.is_empty() {
+ app.unlisten(entry.handler_id);
+ map.remove(event);
+ }
+ }
+ }
+
+ /// Drop every subscription held by a connection (WS closed). Any event
+ /// left with no subscribers has its desktop listener removed.
+ pub fn remove_conn(&self, app: &AppHandle, conn_id: u64) {
+ let mut map = self.inner.lock().unwrap();
+ let mut emptied = Vec::new();
+ for (name, entry) in map.iter_mut() {
+ if entry.subscribers.remove(&conn_id).is_some() && entry.subscribers.is_empty() {
+ app.unlisten(entry.handler_id);
+ emptied.push(name.clone());
+ }
+ }
+ for name in emptied {
+ map.remove(&name);
+ }
+ }
+
+ /// Live subscriber count for an event (test/introspection).
+ #[cfg(test)]
+ pub fn subscriber_count(&self, event: &str) -> usize {
+ self.inner
+ .lock()
+ .unwrap()
+ .get(event)
+ .map(|e| e.subscribers.len())
+ .unwrap_or(0)
+ }
+}
+
+/// Forward one emission to every current subscriber of `event`. The
+/// payload string is the JSON the emitter serialised; we re-embed it
+/// verbatim (parsed back to a value so it nests, not double-encodes).
+fn fan_out(inner: &Arc>>, event: &str, payload: &str) {
+ let data: Value = serde_json::from_str(payload).unwrap_or(Value::Null);
+ let frame = json!({ "t": "event", "event": event, "payload": data }).to_string();
+ let map = inner.lock().unwrap();
+ if let Some(entry) = map.get(event) {
+ for out in entry.subscribers.values() {
+ let _ = out.send(Message::Text(frame.clone()));
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+ use tokio::sync::mpsc;
+
+ // fan_out is exercised directly here; the full listen_any registration
+ // path needs a live AppHandle and is covered by the Stage-4 e2e drive.
+ #[test]
+ fn fan_out_delivers_event_frame_to_all_subscribers() {
+ let inner: Arc>> = Arc::new(Mutex::new(HashMap::new()));
+ let (tx1, mut rx1) = mpsc::unbounded_channel();
+ let (tx2, mut rx2) = mpsc::unbounded_channel();
+ {
+ let mut map = inner.lock().unwrap();
+ let mut subs = HashMap::new();
+ subs.insert(1u64, tx1);
+ subs.insert(2u64, tx2);
+ map.insert(
+ "app-state".to_string(),
+ EventEntry {
+ handler_id: 0,
+ subscribers: subs,
+ },
+ );
+ }
+
+ fan_out(&inner, "app-state", "{\"count\":3}");
+
+ for rx in [&mut rx1, &mut rx2] {
+ match rx.try_recv().expect("frame delivered") {
+ Message::Text(s) => {
+ let v: Value = serde_json::from_str(&s).unwrap();
+ assert_eq!(v["t"], json!("event"));
+ assert_eq!(v["event"], json!("app-state"));
+ assert_eq!(v["payload"], json!({ "count": 3 }));
+ }
+ other => panic!("expected text, got {other:?}"),
+ }
+ }
+ }
+
+ #[test]
+ fn fan_out_unknown_event_is_a_noop() {
+ let inner: Arc>> = Arc::new(Mutex::new(HashMap::new()));
+ // Must not panic when no one is listening.
+ fan_out(&inner, "nobody-home", "{}");
+ }
+}
diff --git a/src-tauri/src/web_remote/mod.rs b/src-tauri/src/web_remote/mod.rs
new file mode 100644
index 00000000..9cbd7d08
--- /dev/null
+++ b/src-tauri/src/web_remote/mod.rs
@@ -0,0 +1,1014 @@
+//! Embedded web remote-access server.
+//!
+//! Turns the desktop app into a second frontend for its own backend: a
+//! browser on another device loads the same UI bundle and drives the same
+//! running instance over HTTP + WebSocket. Default-off — nothing binds
+//! until the user enables it, at which point an axum server comes up on
+//! `0.0.0.0:` (default 4377).
+//!
+//! Module layout:
+//! - [`mod@self`] — managed state, config persistence, lifecycle, commands.
+//! - [`server`] — axum router, static assets, `/api/*`, `/ws`.
+//! - [`auth`] — pairing tokens, sessions, WS tickets, origin checks.
+//! - [`dispatch`] — invoke dispatch via synthesized `on_message` + channels.
+//! - [`events`] — `listen_any` fan-out hub with per-event refcounting.
+//! - [`endpoints`]— reachable-endpoint enumeration (loopback/LAN/tailnet).
+//! - [`assets`] — authenticated `/api/assets` file route (`convertFileSrc`).
+//! - [`proxy`] — auth-gated browser-pane WS + HTTP proxy to loopback daemons.
+//! - [`snapshot`] — authenticated `/api/snapshot` bulk state bootstrap (versioned API).
+//!
+//! See `docs/plans/web-remote-access.md` for the locked protocol contract.
+
+pub mod assets;
+pub mod auth;
+pub mod dispatch;
+pub mod endpoints;
+pub mod events;
+pub mod proxy;
+pub mod server;
+pub mod snapshot;
+
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::sync::{Arc, Mutex};
+
+use serde::{Deserialize, Serialize};
+use tauri::{AppHandle, Emitter, Manager};
+
+/// Default bind port. Chosen to be memorable and unlikely to clash.
+pub const DEFAULT_PORT: u16 = 4377;
+/// Settings key under which the config JSON is persisted.
+const CONFIG_KEY: &str = "web_remote.config";
+
+// ── Bind scope (which interfaces the server listens on) ──────────────
+//
+// The default (`all`) binds `0.0.0.0` — every interface — so any LAN or
+// tailnet peer can reach the server. On a hostile network that also exposes
+// the port on the untrusted segment. The other two scopes narrow the
+// exposure so the port simply isn't open on interfaces the user doesn't
+// trust:
+// - `tailscale` — the tailnet address(es) plus loopback, so the port is
+// reachable only over the mesh VPN (and locally), never on the LAN.
+// - `loopback` — `127.0.0.1` only; reachable only from this machine
+// (e.g. tunnelled in over SSH).
+
+/// All interfaces (`0.0.0.0`) — the default, historical behavior.
+pub const BIND_SCOPE_ALL: &str = "all";
+/// Tailnet address(es) + loopback only.
+pub const BIND_SCOPE_TAILSCALE: &str = "tailscale";
+/// Loopback (`127.0.0.1`) only.
+pub const BIND_SCOPE_LOOPBACK: &str = "loopback";
+
+fn default_bind_scope() -> String {
+ BIND_SCOPE_ALL.to_string()
+}
+
+/// Whether `scope` is one of the three recognised bind scopes.
+fn is_valid_bind_scope(scope: &str) -> bool {
+ matches!(
+ scope,
+ BIND_SCOPE_ALL | BIND_SCOPE_TAILSCALE | BIND_SCOPE_LOOPBACK
+ )
+}
+/// Global event both desktop and web UIs listen to for live updates.
+const STATE_CHANGED_EVENT: &str = "web-remote-state-changed";
+/// Global event a paired web client raises (via [`web_remote_request_update`])
+/// to ask the desktop to run its update + restart flow. Only the desktop
+/// frontend's updater hook listens for it.
+const UPDATE_REQUESTED_EVENT: &str = "web-remote-update-requested";
+
+/// Persisted, user-controlled configuration.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WebRemoteConfig {
+ pub enabled: bool,
+ pub port: u16,
+ pub require_approval: bool,
+ /// Which interfaces to bind: `all` (0.0.0.0) | `tailscale` | `loopback`.
+ /// `#[serde(default)]` so a config persisted before this field existed
+ /// deserializes to the historical `all` behavior instead of failing.
+ #[serde(default = "default_bind_scope")]
+ pub bind_scope: String,
+}
+
+impl Default for WebRemoteConfig {
+ fn default() -> Self {
+ Self {
+ enabled: false,
+ port: DEFAULT_PORT,
+ require_approval: false,
+ bind_scope: default_bind_scope(),
+ }
+ }
+}
+
+/// Desktop-updater availability, published by the desktop frontend's updater
+/// hook (via [`web_remote_publish_update_available`]) so paired web clients can
+/// surface a "desktop update available" prompt. Web clients have no real
+/// updater plugin, so they learn about — and request — desktop updates entirely
+/// through this snapshot + the `web-remote-update-requested` event. Purely
+/// informational: the actual download + restart always runs on the desktop.
+#[derive(Debug, Clone, Default)]
+struct UpdateAvailability {
+ available: bool,
+ version: Option,
+}
+
+/// A running server instance. Sending `true` on `shutdown` unbinds every
+/// listener it owns (one per bound address). `port` and `scope` are kept for
+/// diagnostics / introspection.
+pub(crate) struct RunningServer {
+ #[allow(dead_code)]
+ port: u16,
+ #[allow(dead_code)]
+ scope: String,
+ /// Flips to `true` to trigger graceful shutdown on all listeners.
+ shutdown: tokio::sync::watch::Sender,
+}
+
+/// Everything shared across the server, its handlers, and the commands.
+/// Lives behind an `Arc` inside the managed [`WebRemoteState`] and is
+/// constructed before the Tauri app exists (the channel interceptor in
+/// `lib.rs` needs [`Shared::channels`] at `Builder` time).
+pub(crate) struct Shared {
+ pub config: Mutex,
+ pub runtime: Mutex>,
+ pub pairing: auth::PairingStore,
+ pub tickets: auth::TicketStore,
+ pub rate: auth::RateLimiter,
+ pub connections: server::ConnectionRegistry,
+ pub channels: Arc,
+ pub events: events::EventHub,
+ /// Last desktop-update availability the frontend updater hook published.
+ update: Mutex,
+}
+
+impl Default for Shared {
+ fn default() -> Self {
+ Self {
+ config: Mutex::new(WebRemoteConfig::default()),
+ runtime: Mutex::new(None),
+ pairing: auth::PairingStore::default(),
+ tickets: auth::TicketStore::default(),
+ rate: auth::RateLimiter::default(),
+ connections: server::ConnectionRegistry::default(),
+ channels: Arc::new(dispatch::ChannelRouter::default()),
+ events: events::EventHub::default(),
+ update: Mutex::new(UpdateAvailability::default()),
+ }
+ }
+}
+
+/// Managed Tauri state handle for the web-remote server.
+#[derive(Default)]
+pub struct WebRemoteState {
+ inner: Arc,
+}
+
+impl WebRemoteState {
+ /// The channel router the `lib.rs` channel interceptor drives. Cloned
+ /// out before `.manage()` so the interceptor and the dispatcher share
+ /// one routing table.
+ pub fn channel_router(&self) -> Arc {
+ self.inner.channels.clone()
+ }
+
+ pub(crate) fn shared(&self) -> Arc {
+ self.inner.clone()
+ }
+}
+
+// ── Status snapshot (command return + event payload) ────────────────
+
+/// Live server + device state. Returned by `web_remote_status` and
+/// emitted as the `web-remote-state-changed` payload.
+#[derive(Debug, Clone, Serialize)]
+pub struct WebRemoteStatus {
+ pub enabled: bool,
+ pub running: bool,
+ pub port: u16,
+ pub require_approval: bool,
+ /// Which interfaces the server binds: `all` | `tailscale` | `loopback`.
+ /// Surfaced so the Settings pane can render the current access scope and
+ /// so a web client sees which scope is in effect.
+ pub bind_scope: String,
+ /// Number of live WebSocket connections right now (raw socket count; a
+ /// single device with two tabs counts twice).
+ pub active_connections: usize,
+ /// Number of distinct paired devices with at least one live WebSocket.
+ /// This is the "remote sessions active" signal the desktop updater keys
+ /// off to defer an auto-update restart while someone is connected
+ /// remotely (frontend defer policy is Stage 3b).
+ pub connected_sessions: usize,
+ pub sessions: Vec,
+ /// Whether the desktop's updater found an update ready to install. Set by
+ /// the desktop frontend (the web client has no updater plugin) so a paired
+ /// browser can offer a "desktop update available → update & restart" prompt.
+ pub update_available: bool,
+ /// Version string of the available desktop update, when `update_available`.
+ pub update_version: Option,
+}
+
+/// A paired device as shown in the desktop management UI.
+#[derive(Debug, Clone, Serialize)]
+pub struct SessionView {
+ pub id: String,
+ pub name: Option,
+ pub user_agent: Option,
+ pub created_at: String,
+ pub last_seen_at: Option,
+ pub approved: bool,
+ /// Has at least one live WebSocket right now.
+ pub connected: bool,
+}
+
+/// Result of `web_remote_create_pairing`. QR rendering is the frontend's job.
+#[derive(Debug, Clone, Serialize)]
+pub struct PairingInfo {
+ /// Relative link a browser opens to auto-pair: `/#pair=`.
+ pub url_path: String,
+ pub token: String,
+ pub expires_at: String,
+}
+
+/// Mint a one-time pairing token and build the [`PairingInfo`] payload,
+/// optionally carrying a suggested device name (used as a fallback label
+/// when the connecting client sends none). The single shared token path —
+/// the `web_remote_create_pairing` Tauri command and the `web_remote_pair`
+/// control-socket command both go through here, so there is exactly one
+/// place tokens are minted.
+pub(crate) fn mint_pairing(shared: &Arc, suggested_name: Option) -> PairingInfo {
+ let (token, _ttl) = shared.pairing.issue_named(suggested_name);
+ let expires_at = (chrono::Utc::now()
+ + chrono::Duration::seconds(auth::PAIRING_TTL.as_secs() as i64))
+ .to_rfc3339();
+ PairingInfo {
+ url_path: format!("/#pair={token}"),
+ token,
+ expires_at,
+ }
+}
+
+/// Result of the `web_remote_pair` control-socket command (the
+/// `codemux remote pair` CLI over SSH): a freshly minted pairing token plus
+/// the recommended endpoint's full pairing URL, so the terminal can print a
+/// scannable link + QR without the desktop GUI ever being opened.
+#[derive(Debug, Clone, Serialize)]
+pub struct ControlPairing {
+ /// Full URL a browser opens to auto-pair, on the recommended endpoint:
+ /// `http://:/#pair=`.
+ pub pairing_url: String,
+ /// The recommended endpoint's host (IP literal or MagicDNS name).
+ pub host: String,
+ pub port: u16,
+ /// The raw pairing token (also embedded in `pairing_url`).
+ pub token: String,
+ pub expires_at: String,
+ /// Whether the recommended endpoint is a browser secure context
+ /// (loopback only). Informational for the CLI.
+ pub secure: bool,
+ /// The endpoint kind backing `host` (`magicdns` | `tailnet` | `lan` |
+ /// `loopback`), so the CLI can hint how far it reaches.
+ pub endpoint_kind: String,
+}
+
+/// Pure core of [`control_pair`], taking the shared state directly so it is
+/// unit-testable without a Tauri `AppHandle`. Errors when remote access is
+/// not enabled — the server must be bound for the URL to be reachable.
+pub(crate) fn control_pair_from(
+ shared: &Arc,
+ suggested_name: Option,
+) -> Result {
+ let (enabled, port) = {
+ let cfg = shared.config.lock().unwrap();
+ (cfg.enabled, cfg.port)
+ };
+ if !enabled {
+ return Err("Remote access is not enabled — enable it in Settings first".to_string());
+ }
+ let info = mint_pairing(shared, suggested_name);
+ // Reuse the endpoint enumeration + its single `recommended` pick so the
+ // CLI's URL matches what the Settings pane would surface. Fall back to
+ // the first endpoint (loopback is always first) so a loopback-only host
+ // still yields a usable local URL.
+ let eps = endpoints::list(port);
+ let chosen = eps
+ .iter()
+ .find(|e| e.recommended)
+ .or_else(|| eps.first())
+ .cloned()
+ .ok_or_else(|| "No reachable endpoint found".to_string())?;
+ Ok(ControlPairing {
+ pairing_url: format!("{}{}", chosen.url, info.url_path),
+ host: chosen.host,
+ port,
+ token: info.token,
+ expires_at: info.expires_at,
+ secure: chosen.secure,
+ endpoint_kind: chosen.kind,
+ })
+}
+
+/// Mint a pairing token for the same-machine control socket (the
+/// `codemux remote pair` CLI over SSH). Reuses the shared [`mint_pairing`]
+/// token path and the endpoint enumeration, so the terminal flow and the
+/// GUI flow are the same code underneath.
+pub fn control_pair(
+ app: &AppHandle,
+ suggested_name: Option,
+) -> Result {
+ let shared = app.state::().shared();
+ control_pair_from(&shared, suggested_name)
+}
+
+fn build_status(app: &AppHandle, shared: &Arc) -> WebRemoteStatus {
+ let cfg = shared.config.lock().unwrap().clone();
+ let running = shared.runtime.lock().unwrap().is_some();
+ let active_connections = shared.connections.active_count();
+ let sessions: Vec = app
+ .state::()
+ .web_remote_list_sessions()
+ .into_iter()
+ .map(|s| SessionView {
+ connected: shared.connections.session_live(&s.id),
+ id: s.id,
+ name: s.name,
+ user_agent: s.user_agent,
+ created_at: s.created_at,
+ last_seen_at: s.last_seen_at,
+ approved: s.approved,
+ })
+ .collect();
+ let connected_sessions = sessions.iter().filter(|s| s.connected).count();
+ let update = shared.update.lock().unwrap().clone();
+ WebRemoteStatus {
+ enabled: cfg.enabled,
+ running,
+ port: cfg.port,
+ require_approval: cfg.require_approval,
+ bind_scope: cfg.bind_scope.clone(),
+ active_connections,
+ connected_sessions,
+ sessions,
+ update_available: update.available,
+ update_version: update.version,
+ }
+}
+
+/// Emit the current status on the global bus so desktop + web UIs
+/// live-update on every server/session state change.
+pub(crate) fn emit_state_changed(app: &AppHandle) {
+ let shared = app.state::().shared();
+ let status = build_status(app, &shared);
+ let _ = app.emit(STATE_CHANGED_EVENT, status);
+}
+
+// ── Config persistence ──────────────────────────────────────────────
+
+fn persist_config(app: &AppHandle, shared: &Arc) {
+ let cfg = shared.config.lock().unwrap().clone();
+ if let Ok(json) = serde_json::to_string(&cfg) {
+ let _ = app
+ .state::()
+ .set_setting(CONFIG_KEY, &json);
+ }
+}
+
+fn load_config(app: &AppHandle) -> WebRemoteConfig {
+ app.state::()
+ .get_setting(CONFIG_KEY)
+ .and_then(|v| serde_json::from_str(&v).ok())
+ .unwrap_or_default()
+}
+
+// ── Server lifecycle ────────────────────────────────────────────────
+
+/// Resolve the socket addresses to bind for a given `scope` + `port`, taking
+/// the discovered tailnet IPs as an argument so the decision is pure and
+/// unit-testable. See [`bind_addrs`] for the production entry point.
+///
+/// - `loopback` → `127.0.0.1:port` only.
+/// - `tailscale` → every tailnet address **plus** loopback, so the port is
+/// reachable over the mesh VPN and locally but not on the LAN. Errors when
+/// `tailnet` is empty — it must NEVER silently fall back to all interfaces
+/// (that would re-expose the port on the hostile network the scope exists
+/// to hide from).
+/// - `all` (and any unrecognised value) → `0.0.0.0:port` (every interface).
+fn bind_addrs_from(scope: &str, port: u16, tailnet: Vec) -> Result, String> {
+ match scope {
+ BIND_SCOPE_LOOPBACK => Ok(vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)]),
+ BIND_SCOPE_TAILSCALE => {
+ if tailnet.is_empty() {
+ return Err(
+ "No Tailscale address found — connect Tailscale or choose a different access scope"
+ .to_string(),
+ );
+ }
+ // Keep loopback so local use (and an SSH tunnel) still works
+ // alongside the tailnet address(es).
+ let mut addrs = vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)];
+ for ip in tailnet {
+ addrs.push(SocketAddr::new(ip, port));
+ }
+ Ok(addrs)
+ }
+ // `all` and any unknown value: bind every interface (historical
+ // behavior). Unknown values are normalised away in `web_remote_set_config`.
+ _ => Ok(vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port)]),
+ }
+}
+
+/// Production wrapper over [`bind_addrs_from`] that discovers the tailnet IPs
+/// via the endpoint enumeration (interface CGNAT range + `tailscale status`).
+fn bind_addrs(scope: &str, port: u16) -> Result, String> {
+ bind_addrs_from(scope, port, endpoints::tailnet_ips())
+}
+
+async fn start_server(app: &AppHandle, shared: &Arc) -> Result<(), String> {
+ // Idempotent: already bound → nothing to do.
+ if shared.runtime.lock().unwrap().is_some() {
+ return Ok(());
+ }
+ let (port, scope) = {
+ let cfg = shared.config.lock().unwrap();
+ (cfg.port, cfg.bind_scope.clone())
+ };
+ // Resolve the bind scope to concrete addresses first. A `tailscale` scope
+ // with no tailnet address fails HERE, before anything binds, so the
+ // caller (enable / rebind) surfaces the reason and leaves the server off.
+ let addrs = bind_addrs(&scope, port)?;
+
+ // Bind every address up front. If any bind fails, the listeners already
+ // bound in `listeners` drop at the `?` and unbind, so we never leak a
+ // half-open server on a partial failure.
+ let mut listeners = Vec::with_capacity(addrs.len());
+ for addr in &addrs {
+ listeners.push(server::bind(*addr).await?);
+ }
+
+ // One shutdown signal shared by every listener's graceful-shutdown future.
+ let (shutdown_tx, _shutdown_rx) = tokio::sync::watch::channel(false);
+ for listener in listeners {
+ let router = server::router(app.clone());
+ let mut shutdown_rx = shutdown_tx.subscribe();
+ tokio::spawn(async move {
+ let service = router.into_make_service_with_connect_info::();
+ let result = axum::serve(listener, service)
+ .with_graceful_shutdown(async move {
+ // Resolves when `stop_server` flips the watch to `true`.
+ let _ = shutdown_rx.changed().await;
+ })
+ .await;
+ if let Err(e) = result {
+ eprintln!("[codemux::web_remote] server exited: {e}");
+ }
+ });
+ }
+
+ *shared.runtime.lock().unwrap() = Some(RunningServer {
+ port,
+ scope,
+ shutdown: shutdown_tx,
+ });
+ Ok(())
+}
+
+fn stop_server(shared: &Arc) {
+ // Sever every live socket up front. `with_graceful_shutdown` only stops the
+ // listener accepting *new* connections; an already-open WebSocket would
+ // otherwise keep working (and keep full desktop control) until it happened
+ // to reload. Disabling remote access is a security action, so kick every
+ // connected device immediately — the same mechanism revocation uses.
+ shared.connections.close_all();
+ if let Some(running) = shared.runtime.lock().unwrap().take() {
+ let _ = running.shutdown.send(true);
+ }
+}
+
+/// Load persisted config on boot and, if the feature was left enabled,
+/// re-bind the server. Called once from the Tauri `setup` hook. A bind
+/// failure (port taken) is logged, not fatal — the desktop still runs.
+pub fn restore_on_boot(app: &AppHandle) {
+ let shared = app.state::().shared();
+ let cfg = load_config(app);
+ let enabled = cfg.enabled;
+ *shared.config.lock().unwrap() = cfg;
+ // In end-to-end test mode the dedicated `e2e_autostart` hook owns server
+ // startup (it also mints the pairing token); starting here too would race
+ // it on the bind. Yield to it.
+ #[cfg(debug_assertions)]
+ if std::env::var("CODEMUX_WEB_REMOTE_E2E").ok().as_deref() == Some("1") {
+ return;
+ }
+ if !enabled {
+ return;
+ }
+ let app = app.clone();
+ tauri::async_runtime::spawn(async move {
+ let shared = app.state::().shared();
+ match start_server(&app, &shared).await {
+ Ok(()) => emit_state_changed(&app),
+ Err(e) => eprintln!("[codemux::web_remote] restore-on-boot bind failed: {e}"),
+ }
+ });
+}
+
+// ── Dev/test end-to-end autostart affordance ────────────────────────
+//
+// A single, self-contained hook that an automated end-to-end harness uses to
+// drive the served web client without a human clicking inside the native
+// window. It is compiled **only into debug builds** (the `debug_assertions`
+// gate) and, even there, stays dormant unless `CODEMUX_WEB_REMOTE_E2E=1` is
+// set in the environment — so it can never affect a shipped release.
+//
+// When both conditions hold, on app boot it:
+// 1. enables the server on `CODEMUX_WEB_REMOTE_PORT` (default [`DEFAULT_PORT`]),
+// 2. mints a one-time pairing token, and
+// 3. writes the full pairing URL to the file named by
+// `CODEMUX_WEB_REMOTE_E2E_PAIRING_FILE` (created 0600 so the token is
+// readable only by the launching user).
+//
+// This is purely a harness convenience; the real pairing/enable flow is
+// unchanged and remains behind the normal desktop UI.
+
+/// Enable the server and publish a pairing URL for an automated harness. See
+/// the module-section comment above. No-op unless `CODEMUX_WEB_REMOTE_E2E=1`.
+#[cfg(debug_assertions)]
+pub fn e2e_autostart(app: &AppHandle) {
+ if std::env::var("CODEMUX_WEB_REMOTE_E2E").ok().as_deref() != Some("1") {
+ return;
+ }
+ let port = std::env::var("CODEMUX_WEB_REMOTE_PORT")
+ .ok()
+ .and_then(|s| s.parse::().ok())
+ .unwrap_or(DEFAULT_PORT);
+ let pairing_file = std::env::var("CODEMUX_WEB_REMOTE_E2E_PAIRING_FILE").ok();
+ let app = app.clone();
+ tauri::async_runtime::spawn(async move {
+ let shared = app.state::().shared();
+ {
+ let mut cfg = shared.config.lock().unwrap();
+ cfg.enabled = true;
+ cfg.port = port;
+ // Deterministic pairing for the harness: approval off.
+ cfg.require_approval = false;
+ }
+ persist_config(&app, &shared);
+ if let Err(e) = start_server(&app, &shared).await {
+ eprintln!("[codemux::web_remote] e2e autostart bind failed: {e}");
+ return;
+ }
+ let (token, _ttl) = shared.pairing.issue();
+ let url = format!("http://127.0.0.1:{port}/#pair={token}");
+ if let Some(path) = pairing_file {
+ if let Err(e) = write_pairing_file(&path, &url) {
+ eprintln!("[codemux::web_remote] e2e autostart: writing pairing file failed: {e}");
+ }
+ }
+ emit_state_changed(&app);
+ eprintln!(
+ "[codemux::web_remote] e2e autostart: server bound on {port}; pairing url published"
+ );
+ });
+}
+
+/// Write `url` to `path` with owner-only (0600) permissions on Unix.
+#[cfg(debug_assertions)]
+fn write_pairing_file(path: &str, url: &str) -> std::io::Result<()> {
+ std::fs::write(path, format!("{url}\n"))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
+ }
+ Ok(())
+}
+
+// ── Tauri commands ──────────────────────────────────────────────────
+
+#[tauri::command]
+pub fn web_remote_status(app: AppHandle) -> WebRemoteStatus {
+ let shared = app.state::().shared();
+ build_status(&app, &shared)
+}
+
+#[tauri::command]
+pub async fn web_remote_enable(app: AppHandle) -> Result {
+ let shared = app.state::().shared();
+ shared.config.lock().unwrap().enabled = true;
+ persist_config(&app, &shared);
+ if let Err(e) = start_server(&app, &shared).await {
+ // Binding failed — e.g. the `tailscale` scope with no tailnet
+ // address, or the port is already taken. Roll the master switch back
+ // off so the UI never shows "enabled but not running", persist that,
+ // and surface the reason. The server stays off.
+ shared.config.lock().unwrap().enabled = false;
+ persist_config(&app, &shared);
+ emit_state_changed(&app);
+ return Err(e);
+ }
+ emit_state_changed(&app);
+ Ok(build_status(&app, &shared))
+}
+
+#[tauri::command]
+pub fn web_remote_disable(app: AppHandle) -> Result {
+ let shared = app.state::().shared();
+ shared.config.lock().unwrap().enabled = false;
+ persist_config(&app, &shared);
+ stop_server(&shared);
+ emit_state_changed(&app);
+ Ok(build_status(&app, &shared))
+}
+
+#[tauri::command]
+pub async fn web_remote_set_config(
+ app: AppHandle,
+ port: Option,
+ require_approval: Option,
+ bind_scope: Option,
+) -> Result {
+ let shared = app.state::().shared();
+
+ // Reject an unknown scope before mutating anything, so we never persist a
+ // value the bind logic can't honour.
+ if let Some(ref s) = bind_scope {
+ if !is_valid_bind_scope(s) {
+ return Err(format!("Unknown access scope: {s}"));
+ }
+ }
+
+ let (old_port, old_scope, port_changed, scope_changed) = {
+ let mut cfg = shared.config.lock().unwrap();
+ let old_port = cfg.port;
+ let old_scope = cfg.bind_scope.clone();
+ if let Some(p) = port {
+ cfg.port = p;
+ }
+ if let Some(r) = require_approval {
+ cfg.require_approval = r;
+ }
+ if let Some(s) = bind_scope {
+ cfg.bind_scope = s;
+ }
+ (
+ old_port,
+ old_scope.clone(),
+ cfg.port != old_port,
+ cfg.bind_scope != old_scope,
+ )
+ };
+ persist_config(&app, &shared);
+
+ // A port or scope change while running requires a rebind (the same
+ // stop→start path a port change already used, which also drops existing
+ // connections — they can't follow to a new port/interface).
+ let running = shared.runtime.lock().unwrap().is_some();
+ if running && (port_changed || scope_changed) {
+ stop_server(&shared);
+ if let Err(e) = start_server(&app, &shared).await {
+ // The new binding failed (e.g. switching to `tailscale` with no
+ // tailnet address). Restore the previous config and rebind to it
+ // so the server keeps running on its last-good address instead of
+ // being left off, then report why the change was rejected.
+ {
+ let mut cfg = shared.config.lock().unwrap();
+ cfg.port = old_port;
+ cfg.bind_scope = old_scope;
+ }
+ persist_config(&app, &shared);
+ let _ = start_server(&app, &shared).await;
+ emit_state_changed(&app);
+ return Err(e);
+ }
+ }
+ emit_state_changed(&app);
+ Ok(build_status(&app, &shared))
+}
+
+#[tauri::command]
+pub fn web_remote_create_pairing(app: AppHandle) -> PairingInfo {
+ let shared = app.state::().shared();
+ mint_pairing(&shared, None)
+}
+
+#[tauri::command]
+pub fn web_remote_list_endpoints(app: AppHandle) -> Vec {
+ let shared = app.state::().shared();
+ let port = shared.config.lock().unwrap().port;
+ endpoints::list(port)
+}
+
+#[tauri::command]
+pub fn web_remote_list_sessions(app: AppHandle) -> Vec {
+ let shared = app.state::().shared();
+ build_status(&app, &shared).sessions
+}
+
+#[tauri::command]
+pub fn web_remote_revoke_session(app: AppHandle, session_id: String) -> Result {
+ let shared = app.state::().shared();
+ app.state::()
+ .web_remote_revoke_session(&session_id)?;
+ // Drop any live sockets belonging to the revoked device immediately.
+ shared.connections.close_session(&session_id);
+ emit_state_changed(&app);
+ Ok(build_status(&app, &shared))
+}
+
+#[tauri::command]
+pub fn web_remote_approve_session(
+ app: AppHandle,
+ session_id: String,
+) -> Result {
+ let shared = app.state::().shared();
+ app.state::()
+ .web_remote_set_session_approved(&session_id, true)?;
+ emit_state_changed(&app);
+ Ok(build_status(&app, &shared))
+}
+
+#[tauri::command]
+pub fn web_remote_reject_session(
+ app: AppHandle,
+ session_id: String,
+) -> Result {
+ let shared = app.state::().shared();
+ // Close live sockets first, then erase the pending row entirely.
+ shared.connections.close_session(&session_id);
+ app.state::()
+ .web_remote_delete_session(&session_id)?;
+ emit_state_changed(&app);
+ Ok(build_status(&app, &shared))
+}
+
+// ── Update-while-remote bridge (Stage 3b) ───────────────────────────
+//
+// Desktop-update state lives in the desktop frontend's `useUpdateChecker`
+// hook. These two commands are the seam that lets paired web clients — which
+// have no updater plugin — see and drive it:
+//
+// - `web_remote_publish_update_available`: the DESKTOP updater hook pushes its
+// availability here; it rides out on `web-remote-state-changed` (and the
+// `web_remote_status` snapshot) so web clients live-update and late joiners
+// still see it.
+// - `web_remote_request_update`: a WEB client calls it to ask the desktop to
+// run its standard download + restart flow (the desktop confirmation UX
+// still applies). The PTY daemon keeps agents alive across the restart; the
+// web client reconnects via the shim's backoff loop.
+
+/// Publish the desktop updater's availability so paired web clients can offer a
+/// "desktop update available" prompt. Called by the desktop frontend only.
+#[tauri::command]
+pub fn web_remote_publish_update_available(
+ app: AppHandle,
+ available: bool,
+ version: Option,
+) {
+ let shared = app.state::().shared();
+ {
+ let mut update = shared.update.lock().unwrap();
+ update.available = available;
+ update.version = if available { version } else { None };
+ }
+ emit_state_changed(&app);
+}
+
+/// Ask the desktop to run its update + restart flow. Emits the global
+/// `web-remote-update-requested` event the desktop updater hook listens for.
+#[tauri::command]
+pub fn web_remote_request_update(app: AppHandle) {
+ let _ = app.emit(UPDATE_REQUESTED_EVENT, ());
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn config_default_is_off_on_4377() {
+ let cfg = WebRemoteConfig::default();
+ assert!(!cfg.enabled);
+ assert_eq!(cfg.port, DEFAULT_PORT);
+ assert!(!cfg.require_approval);
+ assert_eq!(cfg.bind_scope, BIND_SCOPE_ALL, "default scope is all interfaces");
+ }
+
+ #[test]
+ fn config_roundtrips_through_json() {
+ let cfg = WebRemoteConfig {
+ enabled: true,
+ port: 5000,
+ require_approval: true,
+ bind_scope: BIND_SCOPE_TAILSCALE.to_string(),
+ };
+ let json = serde_json::to_string(&cfg).unwrap();
+ let back: WebRemoteConfig = serde_json::from_str(&json).unwrap();
+ assert_eq!(back.enabled, cfg.enabled);
+ assert_eq!(back.port, cfg.port);
+ assert_eq!(back.require_approval, cfg.require_approval);
+ assert_eq!(back.bind_scope, cfg.bind_scope);
+ }
+
+ #[test]
+ fn config_without_bind_scope_deserializes_to_all() {
+ // A config persisted before `bind_scope` existed must load as the
+ // historical all-interfaces behavior, not fail — `#[serde(default)]`.
+ let legacy = r#"{"enabled":true,"port":4377,"require_approval":false}"#;
+ let cfg: WebRemoteConfig = serde_json::from_str(legacy).unwrap();
+ assert_eq!(cfg.bind_scope, BIND_SCOPE_ALL);
+ assert!(cfg.enabled);
+ }
+
+ #[test]
+ fn status_carries_bind_scope_in_snake_case() {
+ let status = WebRemoteStatus {
+ enabled: true,
+ running: true,
+ port: DEFAULT_PORT,
+ require_approval: false,
+ bind_scope: BIND_SCOPE_TAILSCALE.to_string(),
+ active_connections: 0,
+ connected_sessions: 0,
+ sessions: vec![],
+ update_available: false,
+ update_version: None,
+ };
+ let v = serde_json::to_value(&status).unwrap();
+ assert_eq!(v["bind_scope"], "tailscale");
+ assert!(v.get("bindScope").is_none(), "no camelCase leakage");
+ }
+
+ // ── Bind-scope address resolution ───────────────────────────────
+
+ #[test]
+ fn bind_scope_all_binds_every_interface() {
+ let addrs = bind_addrs_from(BIND_SCOPE_ALL, 4377, vec![]).unwrap();
+ assert_eq!(addrs, vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 4377)]);
+ }
+
+ #[test]
+ fn bind_scope_unknown_value_defaults_to_all() {
+ // `bind_addrs_from` treats an unrecognised scope as all-interfaces so
+ // it can never leave the server unbindable; `web_remote_set_config`
+ // rejects unknown values before they are ever persisted.
+ let addrs = bind_addrs_from("wat", 4377, vec![]).unwrap();
+ assert_eq!(addrs, vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 4377)]);
+ }
+
+ #[test]
+ fn bind_scope_loopback_binds_loopback_only() {
+ let addrs = bind_addrs_from(BIND_SCOPE_LOOPBACK, 4377, vec![]).unwrap();
+ assert_eq!(addrs, vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4377)]);
+ }
+
+ #[test]
+ fn bind_scope_tailscale_without_address_fails_and_never_falls_back() {
+ // The whole point of the scope is to NOT expose the port when there's
+ // no tailnet — a silent fall-back to 0.0.0.0 would defeat it.
+ let err = bind_addrs_from(BIND_SCOPE_TAILSCALE, 4377, vec![]).unwrap_err();
+ assert!(err.contains("No Tailscale address found"), "clear error: {err}");
+ }
+
+ #[test]
+ fn bind_scope_tailscale_binds_tailnet_plus_loopback() {
+ let tailnet: IpAddr = "100.101.102.103".parse().unwrap();
+ let addrs = bind_addrs_from(BIND_SCOPE_TAILSCALE, 4377, vec![tailnet]).unwrap();
+ // Loopback is kept so local use / an SSH tunnel still works.
+ assert!(addrs.contains(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4377)));
+ assert!(addrs.contains(&SocketAddr::new(tailnet, 4377)));
+ assert_eq!(addrs.len(), 2, "exactly loopback + the one tailnet address");
+ }
+
+ // ── Control-socket pairing (codemux remote pair over SSH) ───────
+
+ #[test]
+ fn control_pair_errors_when_disabled() {
+ // Default config is disabled — pairing over the control socket must
+ // refuse with a clear, actionable message and mint no token.
+ let shared = Arc::new(Shared::default());
+ let err = control_pair_from(&shared, None).unwrap_err();
+ assert!(
+ err.contains("Remote access is not enabled"),
+ "clear error: {err}"
+ );
+ assert_eq!(shared.pairing.live_count(), 0, "no token minted on the error path");
+ }
+
+ #[test]
+ fn control_pair_when_enabled_mints_a_usable_single_use_token() {
+ let shared = Arc::new(Shared::default());
+ shared.config.lock().unwrap().enabled = true;
+
+ let res = control_pair_from(&shared, Some("Phone".to_string())).unwrap();
+ // The URL is the recommended endpoint's origin + the pairing fragment;
+ // loopback is always present so we always get a usable http:// URL.
+ assert!(res.pairing_url.starts_with("http://"), "url: {}", res.pairing_url);
+ assert!(
+ res.pairing_url.ends_with(&format!("/#pair={}", res.token)),
+ "url embeds the token: {}",
+ res.pairing_url
+ );
+ assert_eq!(res.port, DEFAULT_PORT);
+
+ // The token pairs via the SAME path `/api/pair` uses (PairingStore),
+ // and is single-use: the first consume succeeds, the second fails.
+ let first = shared.pairing.consume_named(&res.token);
+ assert!(first.consumed, "minted token pairs successfully");
+ assert_eq!(
+ first.suggested_name.as_deref(),
+ Some("Phone"),
+ "the --name label rides along as the fallback device name"
+ );
+ assert!(
+ !shared.pairing.consume(&res.token),
+ "token is single-use — a second pair attempt fails"
+ );
+ }
+
+ #[test]
+ fn status_exposes_connection_counts_in_snake_case() {
+ // The desktop updater's defer-while-remote policy (Stage 3b) reads
+ // both counts off this payload, so the wire contract must carry them.
+ let status = WebRemoteStatus {
+ enabled: true,
+ running: true,
+ port: DEFAULT_PORT,
+ require_approval: false,
+ bind_scope: BIND_SCOPE_ALL.to_string(),
+ active_connections: 3,
+ connected_sessions: 2,
+ sessions: vec![],
+ update_available: false,
+ update_version: None,
+ };
+ let v = serde_json::to_value(&status).unwrap();
+ assert_eq!(v["active_connections"], 3);
+ assert_eq!(v["connected_sessions"], 2);
+ // No camelCase leakage.
+ assert!(v.get("connectedSessions").is_none());
+ }
+
+ #[test]
+ fn status_carries_desktop_update_availability_in_snake_case() {
+ // The web client keys its "desktop update available" prompt off these
+ // fields, so the wire contract must carry them in snake_case.
+ let status = WebRemoteStatus {
+ enabled: true,
+ running: true,
+ port: DEFAULT_PORT,
+ require_approval: false,
+ bind_scope: BIND_SCOPE_ALL.to_string(),
+ active_connections: 0,
+ connected_sessions: 0,
+ sessions: vec![],
+ update_available: true,
+ update_version: Some("1.2.3".to_string()),
+ };
+ let v = serde_json::to_value(&status).unwrap();
+ assert_eq!(v["update_available"], true);
+ assert_eq!(v["update_version"], "1.2.3");
+ assert!(v.get("updateAvailable").is_none(), "no camelCase leakage");
+ }
+
+ #[test]
+ fn publish_update_clears_version_when_unavailable() {
+ // `available=false` must drop any stale version so a web client never
+ // shows a phantom update after the desktop's update state clears.
+ let mut update = UpdateAvailability {
+ available: true,
+ version: Some("9.9.9".to_string()),
+ };
+ // Mirror the command body's write path.
+ update.available = false;
+ update.version = if update.available {
+ Some("9.9.9".to_string())
+ } else {
+ None
+ };
+ assert!(!update.available);
+ assert_eq!(update.version, None);
+ }
+
+ #[test]
+ fn connected_sessions_counts_only_live_devices() {
+ let mk = |id: &str, connected: bool| SessionView {
+ id: id.to_string(),
+ name: None,
+ user_agent: None,
+ created_at: String::new(),
+ last_seen_at: None,
+ approved: true,
+ connected,
+ };
+ let sessions = vec![mk("a", true), mk("b", false), mk("c", true)];
+ let connected = sessions.iter().filter(|s| s.connected).count();
+ assert_eq!(connected, 2, "only sessions with a live socket count");
+ }
+
+ #[test]
+ fn shared_channel_router_is_shared_by_clone() {
+ let state = WebRemoteState::default();
+ let a = state.channel_router();
+ let b = state.shared().channels.clone();
+ assert!(Arc::ptr_eq(&a, &b), "interceptor + dispatcher share one router");
+ }
+}
diff --git a/src-tauri/src/web_remote/proxy.rs b/src-tauri/src/web_remote/proxy.rs
new file mode 100644
index 00000000..55151584
--- /dev/null
+++ b/src-tauri/src/web_remote/proxy.rs
@@ -0,0 +1,552 @@
+//! Browser-pane proxy for the web-remote server.
+//!
+//! The embedded browser pane is a screenshot-driven Chromium session served by
+//! a per-workspace `agent-browser` daemon bound to `127.0.0.1:` (ports
+//! 9223–9299; see `docs/features/browser.md` and
+//! `src-tauri/src/agent_browser.rs`). The desktop pane talks to that daemon
+//! directly: a WebSocket for the screencast + input events, and HTTP
+//! `/api/status` / `/api/command` for liveness probes and evals
+//! (`src/components/browser/{BrowserPane,stream-protocol}.ts`).
+//!
+//! A remote browser can't reach those loopback daemons, so this module bridges
+//! them through the authenticated web-remote origin:
+//!
+//! - `GET /proxy/browser/:port/ws` upgrades the client socket and pipes it,
+//! frame-for-frame (binary-safe, backpressure-aware), to `ws://127.0.0.1:`.
+//! - `ANY /proxy/browser/:port/api/*rest` forwards the daemon's HTTP endpoints.
+//!
+//! ## The single-segment HTTP constraint
+//!
+//! The daemon reads each HTTP request with ONE peek and only sees the first
+//! TCP segment (~1.4 KB); a request that spans segments looks like it has an
+//! empty body (`docs/features/browser.md`). The HTTP forwarder therefore
+//! rebuilds a MINIMAL request — request line, `Host`, and (only with a body)
+//! `Content-Type` / `Content-Length`, plus `Connection: close` — and writes it
+//! in a single `write_all`. Every inbound header (cookies, the session bearer,
+//! `User-Agent`, `Accept*`, and all hop-by-hop headers) is dropped, both to
+//! respect the size budget and to never leak the paired session's credentials
+//! to the daemon.
+//!
+//! ## Port validation
+//!
+//! `:port` is validated strictly against the agent-browser range before any
+//! connection is attempted; anything else is rejected. That keeps the proxy
+//! from being turned into a general-purpose request forwarder against
+//! arbitrary loopback ports.
+
+use std::time::Duration;
+
+use axum::{
+ body::Bytes,
+ extract::{
+ ws::{Message as AxumMessage, WebSocket, WebSocketUpgrade},
+ Path, State,
+ },
+ http::{header, HeaderMap, Method, StatusCode, Uri},
+ response::{IntoResponse, Response},
+};
+use futures_util::{SinkExt, StreamExt};
+use tauri::AppHandle;
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
+use tokio_tungstenite::tungstenite::Message as TungMessage;
+
+/// Lowest agent-browser stream port. Sourced from the public constant so the
+/// proxy range tracks the allocator's floor automatically.
+const BROWSER_PORT_MIN: u16 = crate::agent_browser::DEFAULT_STREAM_PORT; // 9223
+/// Highest agent-browser stream port. Mirrors the (private)
+/// `agent_browser::MAX_STREAM_PORT`; kept in sync by this constant + the
+/// `port_range_low_bound_tracks_agent_browser` test's floor assertion.
+const BROWSER_PORT_MAX: u16 = 9299;
+
+/// Whether `port` is inside the agent-browser stream-port range.
+fn port_in_range(port: u16) -> bool {
+ (BROWSER_PORT_MIN..=BROWSER_PORT_MAX).contains(&port)
+}
+
+/// Whether `s` contains an ASCII control character (CR, LF, NUL, …). axum
+/// percent-decodes the wildcard `rest` path segment, so a `%0d%0a` would
+/// otherwise be written verbatim into the raw HTTP request line built in
+/// [`forward`] — smuggling extra headers or a second request line into the
+/// loopback daemon. A legitimate daemon path (`status`, `command`) never
+/// carries a control character, so rejecting them closes the injection with
+/// no false positives.
+fn has_control_char(s: &str) -> bool {
+ s.bytes().any(|b| b < 0x20 || b == 0x7f)
+}
+
+// ── WebSocket proxy ─────────────────────────────────────────────────
+
+/// `GET /proxy/browser/:port/ws` — upgrade and bridge to the daemon socket.
+pub async fn ws_proxy(
+ State(app): State,
+ Path(port): Path,
+ headers: HeaderMap,
+ ws: WebSocketUpgrade,
+) -> Response {
+ if let Err(resp) = super::server::require_session(&app, &headers) {
+ return resp;
+ }
+ if !port_in_range(port) {
+ return (StatusCode::BAD_REQUEST, "port out of range").into_response();
+ }
+ ws.on_upgrade(move |socket| bridge_ws(socket, port))
+}
+
+/// Bidirectionally pipe an upgraded client socket to the loopback daemon at
+/// `port`. Both directions await each send, so a slow peer stalls the reader
+/// on the other side (TCP backpressure) instead of buffering unboundedly.
+async fn bridge_ws(client: WebSocket, port: u16) {
+ let url = format!("ws://127.0.0.1:{port}");
+ let daemon = match tokio_tungstenite::connect_async(&url).await {
+ Ok((ws, _)) => ws,
+ Err(_) => {
+ // Daemon not up (pane still launching / already closed). Close the
+ // client cleanly; the pane's reconnect loop retries.
+ let mut client = client;
+ let _ = client.send(AxumMessage::Close(None)).await;
+ return;
+ }
+ };
+
+ let (mut client_tx, mut client_rx) = client.split();
+ let (mut daemon_tx, mut daemon_rx) = daemon.split();
+
+ // Client → daemon.
+ let c2d = async move {
+ while let Some(Ok(msg)) = client_rx.next().await {
+ let closing = matches!(msg, AxumMessage::Close(_));
+ if let Some(out) = axum_to_tungstenite(msg) {
+ if daemon_tx.send(out).await.is_err() {
+ break;
+ }
+ }
+ if closing {
+ break;
+ }
+ }
+ let _ = daemon_tx.close().await;
+ };
+
+ // Daemon → client.
+ let d2c = async move {
+ while let Some(Ok(msg)) = daemon_rx.next().await {
+ let closing = matches!(msg, TungMessage::Close(_));
+ if let Some(out) = tungstenite_to_axum(msg) {
+ if client_tx.send(out).await.is_err() {
+ break;
+ }
+ }
+ if closing {
+ break;
+ }
+ }
+ let _ = client_tx.close().await;
+ };
+
+ // First direction to end (peer closed / errored) tears the other down when
+ // its future is dropped, closing both halves.
+ tokio::select! {
+ _ = c2d => {}
+ _ = d2c => {}
+ }
+}
+
+/// Convert an axum client frame into the daemon-side frame type. Binary-safe:
+/// screencast bytes and JSON input frames pass through unchanged.
+fn axum_to_tungstenite(msg: AxumMessage) -> Option {
+ Some(match msg {
+ AxumMessage::Text(t) => TungMessage::Text(t.into()),
+ AxumMessage::Binary(b) => TungMessage::Binary(b.into()),
+ AxumMessage::Ping(b) => TungMessage::Ping(b.into()),
+ AxumMessage::Pong(b) => TungMessage::Pong(b.into()),
+ AxumMessage::Close(_) => TungMessage::Close(None),
+ })
+}
+
+/// Convert a daemon frame back into an axum client frame.
+fn tungstenite_to_axum(msg: TungMessage) -> Option {
+ Some(match msg {
+ TungMessage::Text(t) => AxumMessage::Text(t.as_str().to_owned()),
+ TungMessage::Binary(b) => AxumMessage::Binary(b.to_vec()),
+ TungMessage::Ping(b) => AxumMessage::Ping(b.to_vec()),
+ TungMessage::Pong(b) => AxumMessage::Pong(b.to_vec()),
+ TungMessage::Close(_) => AxumMessage::Close(None),
+ // Raw frames are an internal tungstenite detail; nothing to forward.
+ TungMessage::Frame(_) => return None,
+ })
+}
+
+// ── HTTP forward ────────────────────────────────────────────────────
+
+/// `ANY /proxy/browser/:port/api/*rest` — forward the daemon's HTTP endpoints
+/// (`/api/status`, `/api/command`) with a minimal, credential-free request.
+pub async fn http_forward(
+ State(app): State