Skip to content

feat(remote): web remote access — drive the desktop app from any browser on your LAN or tailnet#163

Merged
Zeus-Deus merged 9 commits into
mainfrom
web-remote-access
Jul 12, 2026
Merged

feat(remote): web remote access — drive the desktop app from any browser on your LAN or tailnet#163
Zeus-Deus merged 9 commits into
mainfrom
web-remote-access

Conversation

@Zeus-Deus

@Zeus-Deus Zeus-Deus commented Jul 11, 2026

Copy link
Copy Markdown
Owner

What

Turn on Remote Access in Settings and Codemux's desktop process starts an embedded server. Pair a browser on any other machine on your LAN or tailnet — scan a QR from your phone or open a one-time link — and the full app loads in that browser: the same projects, terminals, and agent chats, driving the same running desktop instance. Work keeps going when the browser disconnects (the PTY daemon already guarantees that), and reconnecting picks up exactly where it left off.

Architecture

  • Embedded, default-off axum server inside the desktop process (src-tauri/src/web_remote/). It binds only when you enable it; disabling severs live sockets and unbinds the port. The frontend served is the app's own bundle, resolved through Tauri's AssetResolver (no separate build).
  • WebSocket runtime shim (src/remote/) that installs __TAURI_INTERNALS__ over the same internals seam the dev mock uses, so app components can't tell they're running in a browser vs. the desktop WebView. Invokes, events, and channels all ride one GET /ws?ticket=… socket.
  • Invoke dispatch through the real webview: commands the shim can't answer locally are forwarded over the socket and executed against the live AppHandle, so behavior is byte-identical to in-process IPC. Terminal input (write_to_pty) drains through a per-connection serial lane so rapid keystrokes can't race to the PTY.
  • Multi-client stream fan-out: terminals and agent-chat output multiplex to every attached client (desktop + browsers at once) via a reference-counted listen_any hub — one desktop-side listener per event no matter how many browsers watch.
  • HTTP snapshot bootstrap (GET /api/snapshot): a versioned {api_version, app_state, status} envelope built from the same AppStateStore::snapshot() the get_app_state command returns. The shim prefetches it in parallel with the WS handshake and seeds the app's first render without a post-mount round-trip; the WS stays authoritative (a stale seed can never overwrite newer state), and it re-syncs on every reconnect.
  • Endpoint enumeration: the settings pane lists reachable URLs — loopback, LAN address, and the user's own tailnet / MagicDNS name (mesh VPN detected, not embedded) — so you can pick the right one to hand to a device.

Security

  • Off by default. Nothing binds until you explicitly enable it.
  • One-time pairing → persistent, revocable sessions. A pairing token (32 random bytes, 10-min TTL, single use) is traded for a session whose token is stored only as a SHA-256 hash. Every device is listed and revocable from the UI.
  • Single-use WS tickets (30s TTL) keep the session secret out of WebSocket URLs and logs.
  • Origin checks on every state-touching request (same-origin required; cross-origin → 403, unauth → 401), so another site can't drive the server with a stored cookie.
  • Approval mode: an optional gate where a newly-paired device stays pending until you approve it in the desktop UI.
  • Rate limiting on pairing (5/min/IP) to blunt token guessing.

Screenshots

Pairing screen — no token yet: the browser lands here; nothing is reachable until you pair.
Pairing screen

Paired, full UI — the whole app loads in the browser with the Remote indicator, same projects and sessions as the desktop.
Paired full UI

Web path browser — opening a project from the browser uses a transport-served path picker (no native file dialog).
Web path browser

Live terminal — real PTY output streams to the browser, including keystrokes typed in the web client.
Live terminal

Terminal replay — after a reload the PTY scrollback replays, so the terminal comes back exactly where it was.
Terminal replay

QR pairing — create a pairing link + QR from Settings → Remote Access to pair a phone by scanning.
QR pairing

Approval flow — with approval mode on, a new device stays pending until you approve it in the desktop UI.
Approval pending

Devices panel — connected browsers are listed with platform info, and every session is revocable.
Devices panel

Toggle off — flipping the master switch off severs every live connection immediately and unbinds the port; the desktop keeps running.
Toggled off

Agent chat over web remote

The beta agent-chat GUI — the primary surface many users drive Codemux from — works end-to-end over a paired browser: enabling the beta from the web, running a live streamed turn, coexisting with a terminal, and rehydrating history after a reload. (Endpoint addresses in the pairing/devices shots above are boxed out; only loopback is shown.)

Enable Agent Chat from the web — the Beta toggle flipped on from the web client's own Settings, over the remote transport.
Agent Chat beta toggle in the web client

Live chat turn — a real streamed assistant reply in the chat GUI; the composer and the "Remote — connected" indicator are both visible.
Agent chat turn streamed over web remote

Chat and terminal together — a live terminal and the agent-chat GUI both alive as sibling tabs in one paired web session.
Agent chat and a live terminal in one web session

History restored after reload — reloading the browser rehydrates the full chat transcript from the desktop backend (late-join / history hydration over web remote).
Chat history restored after reload

Cleaner endpoint list & agent chat

Follow-up polish on top of the above: the "Reachable at" list and the pairing card now curate what they show, and the run-from-source dev build resolves the agent-chat sidecar. (Endpoint addresses are redacted and the QR is blanked in these shots.)

Grouped endpoints — "Reachable at" buckets addresses into This device / Local network / Tailscale with a one-line explainer each; Docker bridges and other virtual interfaces are filtered out, and the best "from anywhere" endpoint carries a Recommended marker.
Grouped, de-noised endpoint list

Curated pairing card — the QR/link device picker uses the same curated grouping (no Docker noise), with the recommended endpoint pre-selected.
Curated pairing card

Agent chat in run-from-source devnpm run tauri:dev now resolves the Claude sidecar, so a real streamed turn works over the paired web client.
Agent chat working under tauri:dev

SSH pairing & access scope

Two follow-ups that make the server practical to run headless and to lock down: mint a pairing code straight from a terminal (handy over SSH — no desktop GUI needed), and choose which networks the server binds.

Pair from the terminalcodemux remote pair mints a one-time pairing code over the same-machine control socket and prints a scannable QR + link, so you can pair a phone or laptop without touching the desktop GUI. Errors clearly when remote access is off; --name rides along as a fallback device label. (Real MagicDNS host and token redacted; the QR is regenerated to encode a loopback placeholder.)
codemux remote pair terminal output

Access scope — a "Who can connect" control chooses which interfaces the server binds: All networks (0.0.0.0), Tailscale only (tailnet addresses + loopback), or This device only (127.0.0.1). Changing it rebinds immediately and drops existing connections. Verified live at the socket level: all binds 0.0.0.0 (LAN reachable), tailscale binds the tailnet v4/v6 addresses plus loopback while dropping the LAN interface, and loopback binds 127.0.0.1 only.
Access-scope control in Settings → Remote Access

Testing

  • Rust (cargo test, src-tauri lib): 1879 passed, 0 failed, 5 ignored. The web_remote suite covers the pairing lifecycle, ticket single-use/binding/expiry, the rate limiter, the session-admission gate (bearer + cookie, missing/unknown/pending/cross-origin), the connection registry (targeted close_session and close_all), the channel router and event fan-out, endpoint enumeration, the asset route, the proxy guards, the new /api/snapshot route (api_version, envelope shape built from the real AppStateStore::snapshot, and 401/403/200 admission), and the terminal ordered-input lane (write_to_pty routing + client-send-order preservation).
  • Frontend (npm run test): 2158 passed / 151 files. Includes shim contract tests against a fake WS (invoke round-trip, channel ordering, listen refcounting), transport reconnect/ticket flow, and the new snapshot seeding (fetch validation/failure → null, first get_app_state served from the seed with no wire frame, seed discarded once a newer WS snapshot arrives, clean fall-back on failure, reconnect re-seed + stale-race discard).
  • Typecheck / build: npm run check (tsc) clean; npm run build succeeds.
  • Live end-to-end drive: the real desktop app (npm run tauri:dev) with the server enabled, driven through the browser as a user — pair via link + QR, paired full UI, open an existing project and create a workspace via the web path-browser, live terminal (replay + live bytes + input ordering), an agent-chat turn, approval mode + approve + revoke, the devices panel, and flipping the master switch off (live connections severed, port unbound, desktop still alive). GET /api/snapshot was confirmed fetched in parallel with the WS handshake (both HTTP 200 in the same millisecond) with the full authed envelope, and the auth matrix (401 unauth / 403 cross-origin / 401 bad bearer) held live.

Honest caveat: the desktop webview is WebKitGTK with no CDP, so it can't be script-driven; the first server enable used the documented debug autostart bootstrap, but every management action (pairing, QR, approval, approve, revoke, master toggle) was then performed through the real web-client Settings UI. On loopback the seed-vs-WS race for the very first get_app_state isn't externally observable (the seed's whole point is to emit no wire frame), so that path is covered by the TS unit tests; the reconnect reseed was observed firing live (a second /api/snapshot fetch after a forced socket close).

Deferred (not blocking v1)

  • Relay / account tier for reachability beyond LAN + tailnet — strictly additive.
  • Per-client views (independent layouts per browser rather than a shared mirror).
  • Per-command ACLs (scoping what a given device may invoke).
  • Native mobile client — the /api/snapshot endpoint is deliberately shaped as its first API: a versioned envelope a client with no WebView and no @tauri-apps/api shim can bootstrap its whole view from, with api_version to detect breaking changes.

Zeus-Deus and others added 9 commits July 5, 2026 00:13
Embed a default-off HTTP+WebSocket server 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, terminals, agent chats. A UI transport (second frontend
for one backend) that composes with the remote-hosts compute transport:
a paired web client transparently sees and drives the desktop's hosts.

- Embedded auth'd axum HTTP+WS server (src-tauri/src/web_remote/): binds
  0.0.0.0:4377 only when the user enables it; AssetResolver static
  bundle, /api/* control surface, /ws transport, and endpoint
  enumeration (loopback/LAN/tailnet/MagicDNS) with per-endpoint
  secure-context notes. Config persists and restores on boot.
- WebSocket runtime shim (src/remote/): production twin of the dev mock —
  installs window.__TAURI_INTERNALS__ backed by a real socket so every
  invoke/listen/Channel/convertFileSrc routes over the wire with zero
  app-component changes. Invoke dispatch reuses the main webview's
  on_message; a Builder-registered channel interceptor re-routes each
  command's Channel frames (incl. binary PTY frames) to the owning
  browser; a listen_any hub multiplexes event subscribers.
- Auth: one-time pairing token -> persistent revocable SQLite session
  (SHA-256 at rest, constant-time compare, no early-return), 30s
  single-use WS tickets, optional approval mode, HttpOnly+SameSite
  cookie and bearer through one authenticate path, origin checks on
  every state-touching route, per-IP pairing rate limit. Revocation
  severs live sockets immediately.
- Remote Access settings pane: master toggle + exposure warning, port
  field, copy-ready endpoint list, QR/link pairing generator, paired
  devices list with revoke/revoke-all, approval-mode toggle + pending
  approval flow.
- Multi-client mirror mode: terminal + agent-chat streams fan out to N
  subscribers (per-generation attach/detach, replay only to the new
  attacher, pause only when all subscribers pause); the desktop window
  stays the sole scrollback-serialization owner.
- Web fallbacks (gated on isRemoteClient(), desktop byte-identical):
  in-app path browser replacing the native file dialog (unlocks opening
  projects / creating workspaces from the browser), Web Notifications
  with toast fallback, /api/assets file streamer for convertFileSrc, an
  authed browser-pane proxy to the loopback agent-browser daemons,
  hidden window chrome, and update-defer while remote + a web-triggered
  desktop update flow.
- Docs: new docs/features/web-remote-access.md; INDEX/STATUS/plan
  updates. Debug-only e2e affordances stay #[cfg(debug_assertions)] +
  env-gated.
…ign update-flow doc wording

- relativeTime treated SQLite's timezone-less datetime('now') strings as
  local time, skewing 'paired/last seen' by the machine's UTC offset;
  normalize to ISO-8601 UTC before parsing, with regression tests.
- Feature doc described the update flow as deferring an auto-restart;
  restarts are always explicit user actions — reword to match behavior.
…socket connects

Add GET /api/snapshot: an authed, versioned {api_version, app_state, status}
envelope built from the same AppStateStore::snapshot() the get_app_state command
returns (no duplicated serialization), Cache-Control: no-store. The web shim
prefetches it concurrently with the WS ticket + upgrade and answers the app's
first get_app_state from it, so the UI paints real state without a post-mount
socket round-trip. WS stays authoritative: the seed is applied only while a
monotonic wsAppStateSeq (bumped on each app-state-changed event) is still 0, so
a WS 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 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
unchanged (the fetchSnapshot shim option is supplied only by the web bootstrap).
Shaped as the first piece of a versioned API for future native (non-web) clients.

Also fixes two defects found in live end-to-end verification:
- Terminal input could scramble over the web transport: write_to_pty invokes
  were each dispatched on their own task, letting rapid keystrokes race to the
  PTY. They now drain through a per-connection serial lane (is_ordered_invoke)
  in client-send order; every other invoke keeps the concurrent path.
- Disabling remote access left live sockets open: graceful shutdown only stops
  new connections, so a connected device kept control until it reloaded.
  stop_server now calls ConnectionRegistry::close_all() to sever every socket
  immediately (the same mechanism revocation uses); port changes rebind the
  same way.

Tests: Rust snapshot route (api_version, envelope shape, 401/403/200 admission),
ordered-lane + close_all; TS snapshot seeding (fetch validation/failure, seed
with no wire frame, discard-when-superseded, reconnect re-seed + stale-race).
# Conflicts:
#	docs/core/STATUS.md
#	src/components/layout/title-bar.tsx
#	src/dev/tauri-mock.ts
Widen the injected cancelIdle param in scrollback-idle-serializer.test.ts
from `number` to the source's exported `IdleHandle` type. In a Node type
environment `ReturnType<typeof setTimeout>` widens `IdleHandle` beyond
`number`, so a `number`-only scheduler mock is not assignable to
`(handle: IdleHandle) => void` and `tsc` fails `npm run check` / `tsc -b`.
Pre-existing on main (reproduces on a clean origin/main checkout); surfaced
by the matrix after the merge. Type-only change, no runtime effect.
The web-remote connection indicator was a floating pill anchored
bottom-left of the viewport. It overlapped the sidebar footer, the
setup-scripts hint card, and the notification bell.

Replace it with a store-driven pair of surfaces, split by loudness:

- Connected → a compact, non-overlapping chip in the title bar's
  right cluster (the slot the hidden native window controls free up
  on the web client). Small sky dot + "Remote", host in the tooltip
  and inline at wide widths; flex-shrinks/truncates so it can never
  collide at narrow widths.
- Reconnecting / offline → a loud, centered banner overlay so a
  dropped or revoked session stays impossible to miss.

Desktop never runs the bootstrap, so the store stays null and both
surfaces render nothing — chrome is byte-identical to before.
…esolve the agent-chat sidecar in dev

Endpoints:
- Skip Docker/veth/virbr/CNI/VirtualBox/VMware/ZeroTier/tailscale0 interfaces
  by name (is_virtual_iface) so 172.x/link-local noise never masquerades as a
  reachable URL. The `br-` prefix is hyphenated so a real bridge `br0` survives.
- Give each endpoint a coarse `group` (this_device/local_network/tailscale/
  other) and a single `recommended` hint; tailnet IPv4+IPv6 and MagicDNS come
  from the CLI and land under `tailscale`. mark_recommended picks the best
  from-anywhere option (MagicDNS, else tailnet, else local-network).
- Render the "Reachable at" list and the pairing device-picker as labelled
  groups (empty groups dropped, `other` behind a default-closed disclosure)
  with a Recommended chip; groupEndpoints degrades unknown groups into `other`
  and pickPrimaryEndpoint now prefers the recommended endpoint.

Sidecar:
- resolve_sidecar() adds a debug-only fallback to the dev-tree
  src-tauri/binaries/ copy so `npm run tauri:dev` resolves the Claude
  agent-chat sidecar even when the resource dir doesn't carry it; the fallback
  is compiled out of release via debug_assertions, so release resolution stays
  override -> resource_dir -> None. lib.rs pins the winner into
  CODEMUX_CLAUDE_SIDECAR_PATH.

Docs: refresh the web-remote-access endpoint-enumeration section (grouping +
virtual-interface filtering + recommended) and the agent-chat sidecar
resolution note (debug-only dev fallback).
…cope

- `codemux remote pair [--name <label>]` mints a one-time pairing code
  over the same-machine control socket and prints a scannable terminal
  QR plus link, so a phone or laptop can be paired over SSH without the
  desktop GUI. Errors clearly when remote access is disabled, reuses the
  GUI's PairingStore mint path, and carries an optional fallback device
  name that the /api/pair handler uses only when the client sends none.

- Add a `bind_scope` config (all / tailscale / loopback) with a
  "Who can connect" Settings control. `tailscale` binds every tailnet
  address plus loopback and fails loudly rather than silently falling
  back to 0.0.0.0 when no tailnet address exists; `loopback` binds
  127.0.0.1 only. Enable and set_config roll back to the last-good
  state on bind failure so the server is never "enabled but not
  running", and a scope or port change rebinds in place.
…s scope from a web client

Changing the bind scope or port from the web client rebinds the server,
which drops every socket before web_remote_set_config can answer. The
invoke then rejected with "connection lost" and the handler treated that
as a failure — error toast, control snapped back — even though the backend
had applied the change (confirmed via ss; correct on reload). Desktop
(native IPC) never drops its own transport, so it was unaffected.

On a web client (isRemoteClient()), a port/scope change now:

- Reflects the requested value optimistically and shows an "Applying —
  reconnecting" state instead of an error. isRebindDisconnectError()
  classifies the transport's connection-lost/not-connected rejection as
  the expected rebind drop, not a backend failure; a genuine backend
  rejection (e.g. Tailscale scope with no tailnet) still surfaces and
  reverts.
- Settles on reconnect: once the shim's reconnect loop re-establishes the
  socket, it refetches web_remote_status and reconciles the control to the
  server's persisted value, silently (no toast) when they match.
- Predicts a cutoff before applying a scope change and asks first. The
  origin the browser loaded from is classified (loopback / tailnet / lan)
  and checked against what the target scope still serves — loopback
  survives every scope, a tailnet/MagicDNS origin survives all+tailscale,
  a LAN origin survives only all. If the change would sever this device, a
  confirm dialog explains it; on confirm (or when a reconnect never
  succeeds) a clear terminal state replaces the endless reconnect spinner.

Desktop behavior is byte-identical. Adds unit tests for the cutoff
prediction (origin host + scope -> survives?) and the rebind-disconnect
classification, plus component tests that a remote-initiated rebind
disconnect shows no error toast while a real backend error still does.
@Zeus-Deus Zeus-Deus merged commit 02d6814 into main Jul 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant