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