From 0ecd5843c78b89e376c11f375c2f61105a19d75a Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Tue, 26 May 2026 18:28:48 +0200 Subject: [PATCH 1/5] feat(remote): auto-provisioning headless MCP daemon on push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codemux-remote` grows a headless Codemux daemon (`serve`) and a stdio MCP server (`mcp`), wired into the desktop's push flow so the user gets a working MCP control plane on the remote with zero manual setup — no manifest, no systemd, no agent config to edit by hand. What the push flow now does automatically: * Installs `codemux-remote` to `~/.local/bin/codemux-remote` (existing). * Installs `~/.config/systemd/user/codemux-remote.service`, runs `loginctl enable-linger`, and starts the unit. The daemon binds an ephemeral loopback port and writes `~/.local/share/codemux-remote/ manifest.json` (mode 0600) with endpoint + 32-byte bearer secret. * Drops a `.mcp.json` in the pushed workspace dir pointing `codemux` at `codemux-remote mcp`. Any CLI agent (Claude Code, Codex, Gemini) launched in that workspace auto-discovers Codemux as an MCP server. * Calls `codemux-remote workspace register --path ...` over SSH so the pushed workspace shows up in `workspace_list` from any MCP-aware agent on the host. New module at `src-tauri/src/remote/` — self-contained, no Tauri dependency. Speaks HTTP+bearer on 127.0.0.1; SSH tunnels are the v1 remote-access path. Four design constraints baked in so a future optional cloud relay (paid tier, team collaboration, phone control without SSH) is purely additive, not a rewrite: HTTP transport, `Identity::Local|Cloud` passthrough, nullable `owner_id` column, no Better-Auth coupling in the daemon. 11 MCP tools on the headless surface: workspace_{create,list,info, update,close}, terminal_{spawn,write,read,list,close}, app_status. Pane and browser tools are deliberately excluded — no UI on a headless host. Tests: 26 unit tests in the `remote` module, 10 end-to-end integration tests in `tests/codemux_remote_serve_mcp.rs` (including the headline `mcp_stdio_roundtrip` and `workspace_register_subcommand_registers_in_daemon` tests), 6 unit tests in `ssh::bootstrap`/`automations::service` for the new systemd unit + shell-arg quoting, plus the 3 pre-existing pty-daemon binary tests still passing. Tailscale documented as the v1 "control from phone" recommendation. Cloud relay deferred to a separate v2 effort per the plan doc. --- docs/features/remote-hosts.md | 144 ++++- docs/plans/mcp-on-remote.md | 358 ++++++++++++ scripts/codemux-remote.service.example | 42 ++ src-tauri/Cargo.lock | 76 +++ src-tauri/Cargo.toml | 8 + src-tauri/src/automations/service.rs | 101 ++++ src-tauri/src/bin/codemux_remote.rs | 531 +++++++++++++++--- src-tauri/src/commands/hosts.rs | 26 +- src-tauri/src/lib.rs | 2 + src-tauri/src/remote/auth.rs | 95 ++++ src-tauri/src/remote/config.rs | 54 ++ src-tauri/src/remote/identity.rs | 42 ++ src-tauri/src/remote/manifest.rs | 247 ++++++++ src-tauri/src/remote/mcp.rs | 248 ++++++++ src-tauri/src/remote/mod.rs | 43 ++ src-tauri/src/remote/pty.rs | 344 ++++++++++++ src-tauri/src/remote/server.rs | 321 +++++++++++ src-tauri/src/remote/tools/mod.rs | 364 ++++++++++++ src-tauri/src/remote/workspace.rs | 437 +++++++++++++++ src-tauri/src/ssh/bootstrap.rs | 191 +++++++ src-tauri/src/ssh/push.rs | 60 +- src-tauri/tests/codemux_remote_serve_mcp.rs | 590 ++++++++++++++++++++ 22 files changed, 4248 insertions(+), 76 deletions(-) create mode 100644 docs/plans/mcp-on-remote.md create mode 100644 scripts/codemux-remote.service.example create mode 100644 src-tauri/src/remote/auth.rs create mode 100644 src-tauri/src/remote/config.rs create mode 100644 src-tauri/src/remote/identity.rs create mode 100644 src-tauri/src/remote/manifest.rs create mode 100644 src-tauri/src/remote/mcp.rs create mode 100644 src-tauri/src/remote/mod.rs create mode 100644 src-tauri/src/remote/pty.rs create mode 100644 src-tauri/src/remote/server.rs create mode 100644 src-tauri/src/remote/tools/mod.rs create mode 100644 src-tauri/src/remote/workspace.rs create mode 100644 src-tauri/tests/codemux_remote_serve_mcp.rs diff --git a/docs/features/remote-hosts.md b/docs/features/remote-hosts.md index 13a991e8..1e5f8983 100644 --- a/docs/features/remote-hosts.md +++ b/docs/features/remote-hosts.md @@ -50,23 +50,157 @@ Wiring **deferred** to a follow-up (small UX work, no architectural risk): - Workspace header badge for non-local workspaces. - Workspace list filter dropdown. -## `codemux-remote` slim binary (2c) +## `codemux-remote` binary -`src-tauri/src/bin/codemux_remote.rs`. New `[[bin]]` target in `Cargo.toml`. Same `codemux_lib` crate, no UI deps. +`src-tauri/src/bin/codemux_remote.rs`. `[[bin]]` target in `src-tauri/Cargo.toml`. Same `codemux_lib` crate, no UI deps. CLI: ``` codemux-remote version - → {"name":"codemux-remote","version":"0.3.1","protocol_version":1} + → {"name":"codemux-remote","version":"","protocol_version":1} codemux-remote pty-daemon --socket /tmp/codemux-ptyd-.sock - → binds the socket, runs the daemon server, never returns + → binds the Unix socket the laptop's "Push workspace" tunnel uses, + runs the PTY daemon server, never returns. codemux-remote scheduler → runs the Automations reconcile + pull + tick + execute loop on an - always-on host; never returns + always-on host; never returns. + +codemux-remote serve [--port ] [--state-dir ] + → runs the headless Codemux daemon: axum HTTP server on 127.0.0.1 + with bearer-token auth (manifest at /manifest.json, + mode 0600). Tracks workspaces in /codemux.db. Survives + SIGTERM/SIGINT cleanly (manifest is removed on shutdown). v1 + listens loopback-only; the desktop tunnels in over SSH. + +codemux-remote serve status [--state-dir ] + → prints {endpoint, pid, started_at, host_id, alive} as JSON. + Exit 0 if alive, exit 1 otherwise. + +codemux-remote serve stop [--state-dir ] + → SIGTERM the running daemon (pid read from manifest). + +codemux-remote mcp [--state-dir ] + → stdio JSON-RPC MCP server. Reads /manifest.json for the + daemon's endpoint + secret, then bridges agent CLI tool calls to + the daemon's HTTP API. Configure your CLI agent with: + {"command": "codemux-remote", "args": ["mcp"]} ``` +### `serve` mode overview + +The `serve` subcommand is the headless equivalent of running the +desktop Codemux app on this host — an MCP-aware agent on this machine +can drive Codemux locally without any UI. Module layout: + +- `src-tauri/src/remote/manifest.rs` — manifest read/write, pid liveness. +- `src-tauri/src/remote/auth.rs` — bearer-token axum middleware, + attaches `Identity::Local` to the request. +- `src-tauri/src/remote/identity.rs` — `Identity::Local | Cloud{…}`. + v1 only ever produces `Local`; the `Cloud` variant is here so a + future optional relay layer can forward verified identity without + changing handler signatures. +- `src-tauri/src/remote/workspace.rs` — SQLite workspace registry + with nullable `owner_id` column for future relay use. +- `src-tauri/src/remote/pty.rs` — minimal portable-pty wrapper with + per-terminal ring buffer. +- `src-tauri/src/remote/server.rs` — axum routes (`/health`, + `/tools/list`, `/tools/call`). +- `src-tauri/src/remote/mcp.rs` — stdio MCP server. +- `src-tauri/src/remote/tools/mod.rs` — 11-tool catalog. + +Headless tool surface (advertised via `tools/list`): + +| Tool | Purpose | +|---|---| +| `workspace_create` | Register a new workspace. | +| `workspace_list` | All workspaces, newest first. | +| `workspace_info` | One workspace by id. | +| `workspace_update` | Mutate name/branch/notes. | +| `workspace_close` | Remove from registry. | +| `terminal_spawn` | Spawn a shell PTY. | +| `terminal_write` | Write bytes to PTY stdin. | +| `terminal_read` | Read accumulated PTY output. | +| `terminal_list` | List open PTYs. | +| `terminal_close` | SIGHUP the shell. | +| `app_status` | Daemon version, uptime, counts. | + +Deliberately *not* in the headless surface: `pane_*`, `browser_*` +(no UI on a headless host). + +### Process supervision + +Sample systemd user unit at `scripts/codemux-remote.service.example`. +Install: + +``` +cp scripts/codemux-remote.service.example ~/.config/systemd/user/codemux-remote.service +loginctl enable-linger $USER +systemctl --user daemon-reload +systemctl --user enable --now codemux-remote.service +``` + +`Restart=on-failure`, `StandardError=journal`, `loginctl enable-linger` +so the daemon survives ssh logouts and reboots. + +### Designing for a future optional cloud relay + +`docs/plans/mcp-on-remote.md` details the four design choices baked +into v1 so that a paid-tier cloud relay (team collaboration, "control +from phone without SSH") can be added later as a purely additive +feature, not a rewrite: HTTP transport + bearer-token, `Identity` +passthrough, nullable `owner_id`, no Better-Auth coupling in the +daemon. None of those four costs anything today. + +### Auto-provisioning on push (the "it just works" flow) + +The desktop's push flow does the following automatically, so the user +never has to know about manifests, secrets, or systemd: + +1. **Binary install.** `ssh::bootstrap::bootstrap_remote` scp's the + matching `codemux-remote-` to `~/.local/bin/codemux-remote` + and chmods it. Skipped if `version` already returns the right value. +2. **`serve` systemd unit.** `ssh::bootstrap::provision_serve` writes + `~/.config/systemd/user/codemux-remote.service`, runs + `loginctl enable-linger` so it survives logout, and + `systemctl --user enable --now codemux-remote`. The daemon binds an + ephemeral loopback port and writes its manifest under + `~/.local/share/codemux-remote/`. +3. **`.mcp.json` in the pushed workspace.** `ssh::push::push_workspace` + drops a `.mcp.json` into the rsynced workspace directory pointing + `codemux` at `codemux-remote mcp`. Any CLI agent (Claude Code, + Codex, Gemini) launched in that directory on the host auto-discovers + Codemux as an MCP server with zero further config. +4. **Workspace registration.** After rsync, + `ssh::bootstrap::register_workspace_on_remote` runs `codemux-remote + workspace register --path ... --name ... --branch ...` on the host + via SSH. That command talks to the local daemon over loopback HTTP + and inserts the workspace into its registry, so it shows in + `workspace_list` from any MCP-aware agent on the host. + +Net effect: user clicks "Push workspace to host" once, gets back a +working MCP control plane on the remote without ever having to know +the words "manifest" or "systemd." + +### Controlling the daemon from your phone (Tailscale) + +v1's transport is SSH-only — there is no cloud relay. The cleanest +way to drive your home/VPS Codemux daemon from a phone today is +**Tailscale**: + +1. Install Tailscale on both the host running `codemux-remote serve` + and your phone (Tailscale has iOS and Android apps). +2. Both devices join the same tailnet. +3. From any agent app on the phone that can SSH (or any web shell + pointed at the host's tailnet name), `ssh ` works without + any port forwarding or DDNS. + +This costs nothing, works today, and is what we recommend until the +optional cloud relay (paid tier) ships. The relay would replace the +SSH-from-phone requirement, not the Tailscale-or-similar mesh — some +users will always prefer mesh VPN over a hosted relay for privacy. + The `scheduler` subcommand was added by the Automations feature, not by 2c. Host bootstrap provisions it — it writes the scheduler token + host identity and registers a systemd user service so it survives reboots. diff --git a/docs/plans/mcp-on-remote.md b/docs/plans/mcp-on-remote.md new file mode 100644 index 00000000..cd4facaa --- /dev/null +++ b/docs/plans/mcp-on-remote.md @@ -0,0 +1,358 @@ +# Headless Codemux: MCP on `codemux-remote` + +- Purpose: Let an agent running on a remote host (VPS, home server, anywhere `codemux-remote` is installed) drive Codemux the same way an agent on the desktop drives it through the MCP — create workspaces, open them, run presets, write to terminals — so the worktree and metadata exist on the server, and the desktop can later pull the workspace back. +- Audience: Anyone touching `src-tauri/src/control.rs`, `src-tauri/src/mcp_server.rs`, `src-tauri/src/bin/codemux_remote.rs`, the SSH push/pull flow, or the Tauri state stores. +- Authority: Active work plan only. Behavior that lands moves to `docs/features/remote-hosts.md`. +- Update when: Any of the steps below lands or an open question is resolved. +- Read next: `docs/features/remote-hosts.md`, `docs/features/automations.md`, `src-tauri/src/control.rs`, `src-tauri/src/mcp_server.rs`. + +## Goal + +Promote `codemux-remote` from a slim PTY-daemon binary into a full **headless Codemux daemon** that owns its own workspace registry, control endpoint, and MCP server — so an agent on a VPS can `codemux-remote workspace create / open / preset apply / terminal write` through the same MCP tool surface the desktop exposes. When the user pulls a workspace from that host, the desktop imports both the worktree files (existing rsync path) **and** the workspace metadata (new control-protocol path), so the workspace lands in the left sidebar exactly as if it had been created locally. + +**Ship v1 SSH-only. Design for a future optional cloud relay (paid tier) without building it.** No relay code, no Better Auth coupling, no org/team data model in v1 — but the transport, identity, and schema choices below are deliberately picked so adding a relay later is purely additive, not a rewrite. See "Designing for an optional future relay" below for the constraints this places on v1. + +## Why not just copy Superset (and why we'll borrow the layering anyway) + +Superset's design is clean but requires: + +1. A central cloud MCP endpoint (`api.superset.sh`) that all agents must reach. +2. A WebSocket relay (Fly-hosted) that every host-service tunnels out to. +3. Org/JWT auth on every tool call. + +That shape exists because Superset is a team product — teammate A on a MacBook needs to control teammate B's home server they have no shell on. It's a *product requirement*, not an architectural quality signal. We don't have that requirement today, and we already have SSH, so the cheap v1 is "give `codemux-remote` a headless control server, point its MCP at it locally, tunnel into it from the desktop over SSH." No cloud. + +But we **do** want to borrow Superset's *layering* so a paid-tier cloud relay is a future bolt-on, not a rewrite. Specifically: `packages/host-service` exposes loopback HTTP + bearer token; `packages/mcp-v2/src/host-service-client.ts::hostServiceCall` just does `fetch()` against either a loopback URL or a relay URL — same client code, two transports. We mirror this. The daemon never knows whether it's being called by a local desktop, an SSH-forwarded desktop, or (someday) a relay forwarding requests from a phone. + +## Architecture (target shape) + +``` +┌─────────────────────────── DESKTOP ──────────────────────────────┐ +│ │ +│ Tauri app │ +│ ├─ UI (React) │ +│ ├─ AppStateStore ──┐ │ +│ ├─ PtyState │ │ +│ ├─ DatabaseStore ├──> codemux_core::ControlServer <──┐ │ +│ ├─ PresetStoreState ┘ │ axum, 127.0.0.1: │ │ +│ └─ ssh push/pull │ bearer-token auth │ │ +│ │ │ +│ `codemux mcp` ─── stdio MCP → HTTP POST ─────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────┘ + │ + │ SSH + ▼ +┌─────────────────────────── REMOTE HOST ──────────────────────────┐ +│ │ +│ codemux-remote serve (NEW — long-running daemon) │ +│ ├─ AppStateStore (headless impl) │ +│ ├─ PtyState │ +│ ├─ DatabaseStore (SQLite at ~/.local/share/codemux-remote/) │ +│ ├─ PresetStoreState │ +│ └─ codemux_core::ControlServer (axum, 127.0.0.1:, │ +│ bearer-token auth, manifest at /manifest.json) │ +│ │ +│ codemux-remote pty-daemon --socket ... (existing, unchanged) │ +│ codemux-remote mcp (NEW — stdio MCP → local control sock) │ +│ │ +│ agent CLI (claude, codex, etc.) configured with │ +│ mcp.codemux = `codemux-remote mcp` │ +└──────────────────────────────────────────────────────────────────┘ +``` + +The same `codemux_core` library is embedded by both the Tauri app and the `codemux-remote` binary. Tauri-specific things (UI events, the menubar, the system tray) become a thin adapter layer on top of `codemux_core`. The remote binary plugs in a different adapter that no-ops UI events and routes notifications to logs or a webhook. + +## Active Priorities + +### Step ordering (what blocks what) + +``` +Step 1 (extract codemux_core) + │ + ├──> Step 2 (codemux-remote serve) + │ │ + │ ├──> Step 3 (codemux-remote mcp) + │ ├──> Step 4 (trim headless tool surface) + │ ├──> Step 5 (desktop pull workspace) + │ └──> Step 8 (process supervision) + │ + ├──> Step 7 (auth + manifest, both desktop and remote) + │ │ + │ └──> Step 6 (desktop --host flag, depends on manifest format) + │ + └──> Step 9 (desktop transport retrofit) — see below +``` + +Step 1 is the prerequisite for everything. Steps 2 + 7 can be done in parallel after Step 1. Step 6 is the last user-facing piece. The whole sequence is incrementally shippable — Steps 1–4 alone deliver "agent on a server can drive Codemux locally," which is the headline feature. + +### Step 1 — Extract `codemux_core` from the Tauri crate + +Today every piece of MCP-relevant state lives behind a `tauri::State<'_, T>` lookup off `AppHandle`. The first job is to break that coupling without changing behavior. + +Concretely: + +1. Create a new internal crate `src-tauri/codemux-core/` (or a `core/` module reachable from both `[lib]` and `[[bin]] codemux-remote`). +2. Move into it: + - `state.rs` (`AppStateStore`, workspace records, active-workspace tracking). + - `database.rs` (the SQLite-backed `DatabaseStore`). + - `terminal.rs` (PTY state — already mostly UI-free; just unhook the `app.emit` calls). + - `presets/`, `automations/`, `worktree.rs`. + - `control.rs` (transport + `dispatch_request`). + - `mcp_server.rs` (the MCP shim). +3. Replace every `app.state::()` lookup inside `dispatch_request` with a single explicit `CoreContext` struct that holds `Arc`, `Arc`, `Arc`, `Arc`, …, plus a `dyn UiNotifier` trait object for the few `app.emit(...)` calls (currently used by `emit_app_state`, preset progress, automation status). +4. The Tauri side keeps an `impl UiNotifier for TauriUiBridge` that calls `app.emit`. The remote side provides `impl UiNotifier for NullNotifier` (no-op + structured log) or eventually a `WebhookNotifier`. +5. **Add an `Identity` argument to every `dispatch_request` call.** Today it's just `Identity::Local` (parsed from the bearer token — any caller with the secret is "local"). The struct exists so future cloud-relay-forwarded requests can carry `Identity::Cloud { user_id, org_id, role }` without changing handler signatures. v1 handlers ignore the value; they just need to accept the parameter. +6. Acceptance: desktop build still passes `npm run verify`; `cargo check -p codemux-core` passes with no `tauri` dependency in `codemux-core/Cargo.toml`; every dispatch handler takes `(ctx: &CoreContext, identity: &Identity, params: T)`. + +### Step 2 — Headless `codemux-remote serve` + +In `src-tauri/src/bin/codemux_remote.rs`, add a `serve` subcommand: + +```text +codemux-remote serve + [--port ] # default: pick a free port, write to manifest + [--bind 127.0.0.1] # never default to 0.0.0.0; SSH tunnels are the path + [--state-dir ] # default ~/.local/share/codemux-remote + [--no-daemonize] # for systemd / debugging + [--notify-webhook ] # POST agent-needs-attention events to this URL +``` + +What it does: + +1. Boots `codemux_core` with `NullNotifier` (or `WebhookNotifier` if `--notify-webhook` set). +2. Opens / creates SQLite at `/codemux.db` — same schema the desktop uses, since the `DatabaseStore` code is shared. +3. Spawns an **axum HTTP server** on loopback. Each handler is a thin wrapper that parses the bearer token → `Identity::Local`, dispatches to the same `codemux_core::dispatch_request` function the desktop uses. +4. Writes `/manifest.json` with `{ endpoint: "http://127.0.0.1:", secret, pid, started_at, host_id }`, mode `0600` (matches Superset's pattern in `packages/host-service/src/daemon/manifest.ts`). +5. Stays running until SIGTERM. On exit, drain in-flight requests, snapshot active workspaces, flush DB, remove the manifest. + +A second subcommand, `codemux-remote serve status`, reads the manifest and prints `{ endpoint, pid, workspaces: N, started_at }` — used by the desktop to probe whether a host already has a daemon up. + +Acceptance: `codemux-remote serve` + `curl -H "Authorization: Bearer $(cat ~/.local/share/codemux-remote/manifest.json | jq -r .secret)" http://127.0.0.1:/control -d '{"command":"workspace_list","params":{}}'` returns an empty list (and creating one then re-listing shows it). + +Why HTTP and not the existing Unix-socket JSON-line protocol: see "Designing for an optional future relay" below. Short version: HTTP is what makes the relay a future bolt-on instead of a rewrite. Same code complexity (axum vs `tokio::net::UnixListener`), strictly more optionality. + +### Step 3 — `codemux-remote mcp` + +Mirror what `codemux mcp` does in `src-tauri/src/cli.rs` (line 571–574) — boot the stdio MCP server pointed at the local control endpoint. Because `mcp_server.rs` lives in `codemux_core` now, this is a thin subcommand that: + +1. Reads `/manifest.json` to get the endpoint URL + bearer secret. +2. Boots the MCP server with a `HttpControlClient` that POSTs to that endpoint. + +The desktop's `codemux mcp` works the same way against the desktop manifest. Both clients hit the same `codemux_core` dispatcher; the only thing that varies is the URL. + +Acceptance: on a remote host, configure the agent (e.g. Claude Code) with `"codemux": { "command": "codemux-remote", "args": ["mcp"] }` in its MCP config; the agent can `workspace_create`, `workspace_list`, `preset_apply`, `terminal_write`, `terminal_read`. Those workspaces appear in `codemux-remote serve`'s SQLite and are reachable via HTTP from anything else (CLI, SSH-tunnelled desktop, future relay). + +### Step 4 — Trim the headless MCP tool surface + +`mcp_server.rs` advertises 50+ tools. Some don't make sense headless: + +| Tool family | Headless behavior | +|---|---| +| `mcp__codemux__workspace_*` | Keep, all server-side. | +| `mcp__codemux__terminal_*` | Keep, PTY works headless. | +| `mcp__codemux__preset_*` | Keep. | +| `mcp__codemux__automation_*` | Keep — automations on a server are arguably the killer app. | +| `mcp__codemux__git_*` | Keep. | +| `mcp__codemux__issue_*` | Keep (it shells to `gh`). | +| `mcp__codemux__pane_*` | **Drop on headless** — no panes without a UI. | +| `mcp__codemux__browser_*` | **Drop on headless** — no browser pane. | +| `mcp__codemux__notify` | Replace UI toast with webhook POST + journald log. | +| `mcp__codemux__app_status` | Keep, but `panes`/`browser` keys report `unavailable`. | + +Mechanism: each tool's `define-tool` macro takes a `headless_supported: bool`. The MCP server filters its `tools/list` response based on a `Mode::Desktop | Mode::Headless` value passed in at construction. + +Acceptance: an agent talking to `codemux-remote mcp` only sees the headless-supported tools in its tool catalog; calling `browser_navigate` returns a clean "not supported on headless host" MCP error rather than a hang or a panic. + +### Step 5 — Desktop "pull workspace" learns the control protocol + +Today (`docs/features/remote-hosts.md` line 17) "Push workspace to host" rsyncs the worktree, spawns the remote PTY daemon, attaches the local UI. There is **no** pull path that imports a workspace **created on** the remote — because nothing creates workspaces on the remote yet. + +After Step 2 there will be. New flow: + +1. Desktop opens an SSH tunnel that forwards the remote HTTP port: `ssh -L 127.0.0.1::127.0.0.1: `. The remote port + secret come from `ssh cat ~/.local/share/codemux-remote/manifest.json`. +2. Desktop GETs `http://127.0.0.1:/control` with the bearer secret, sends `workspace_list` — gets back the full list of workspaces the remote daemon owns. +3. User picks one; desktop sends `workspace_info ` — gets path, branch, base commit, agent session ID, preset, notes, linked issue. +4. Desktop rsyncs the worktree from `remote:` to a local path under its workspace tree. +5. Desktop inserts a new row into its **own** SQLite with the imported metadata, marks it `imported_from_host=`, and emits `app_state` so the new workspace shows up in the left sidebar. +6. Optional: desktop sends `workspace_close ` to the remote (or leaves it; "pull" can be a copy, not a move). + +Touch points: `src-tauri/src/ssh/` (new `pull.rs` alongside `push.rs`), the existing "Push workspace to host" command UI gets a sibling "Pull workspace from host". + +Acceptance: agent on remote runs `workspace_create` via MCP, makes commits, sleeps. Desktop opens, clicks "Pull from ", picks the workspace, sees it in the sidebar with the right branch and last-commit info. Opening it shows the imported worktree. + +### Step 6 — Desktop CLI can target a remote host + +Once the remote runs the same control server, the desktop CLI (`codemux workspace ...`, `codemux mcp`) should be able to address it by host id. Mirror Superset's `--host` flag: + +```text +codemux --host homelab workspace create ./repo +codemux --host homelab mcp # routes MCP over SSH tunnel +``` + +Implementation: the control client now takes a URL + secret. When `--host ` is set: + +1. Look up the host in `ssh::registry` — get host_id, manifest path, cached secret. +2. Open (or reuse) an SSH `-L` tunnel forwarding a free local port to `127.0.0.1:`. +3. Point the HTTP client at `http://127.0.0.1:` with the bearer secret. + +This means the **same** MCP server (`codemux mcp`) can be configured on the desktop agent to control either the desktop or any registered host — `codemux mcp --host homelab` and your desktop Claude Code is now running workspaces on the VPS. + +Acceptance: from desktop terminal, `codemux --host homelab workspace list` returns the remote's workspaces; round-trip latency stays under ~500ms on a typical broadband link. + +### Step 7 — Auth + safety + +Right now the desktop control socket has no auth — same-user filesystem perms (`0o600` parent dir) are the trust boundary, matching `gh`/`ssh-agent` precedent. That's fine on a single-user laptop. On a multi-tenant VPS it isn't. + +For the headless daemon: + +1. On first `codemux-remote serve` boot, generate a 32-byte secret, store it inside `/manifest.json` (the same file that holds the endpoint URL, mode `0o600`). +2. Every HTTP request must carry `Authorization: Bearer `. The axum middleware validates and tags the request with `Identity::Local`. +3. `codemux-remote mcp` reads the secret from the manifest and injects it on every POST. +4. SSH-forwarded callers on the desktop side read the manifest over SSH (`ssh cat ~/.local/share/codemux-remote/manifest.json`) on first connect and cache the endpoint + secret in the host record. +5. The desktop's existing control endpoint gets the same treatment for consistency — write a manifest, require a bearer token. Existing local callers (Tauri-side MCP boot) read the same manifest. This makes the desktop and remote endpoints byte-for-byte identical in shape; the only difference is what writes to UI. + +Acceptance: spinning up `codemux-remote serve` on a host and then `curl` -ing the endpoint without the secret returns `401`; `codemux-remote mcp` works because it has the secret. + +### Step 8 — Process supervision + +`codemux-remote serve` needs to survive SSH disconnects and reboots, otherwise the "agent works on it overnight" story breaks. + +Two options, picked at install time by the bootstrap modal (`docs/features/remote-hosts.md` §"bootstrap.rs"): + +- **systemd user unit** (preferred when `systemctl --user` works): `~/.config/systemd/user/codemux-remote.service` with `Restart=on-failure`, `loginctl enable-linger` the user. +- **nohup + screen-less detach** fallback: a small `codemux-remote serve --daemonize` mode that double-forks, redirects stdio to `/serve.log`, writes a pidfile. + +Acceptance: SSH out, `systemctl --user status codemux-remote` shows active; reboot the host, daemon comes back up; workspaces persist (already covered by SQLite). + +### Step 9 — Migrate the desktop's existing control endpoint to HTTP+manifest + +Called out in Step 7 but big enough to deserve its own step. The desktop today binds a Unix socket at `$XDG_RUNTIME_DIR/codemux.sock` (or named pipe on Windows) and accepts unauthenticated JSON-line requests. After Step 1+7, the desktop also runs the axum HTTP server on loopback with a bearer token from `~/.codemux/manifest.json`. + +What changes: + +1. `control::spawn_control_server` (lib.rs:593) starts the axum server instead of the JSON-line listener. +2. `codemux mcp` reads `~/.codemux/manifest.json` to find the endpoint and secret. Existing user MCP configs (`{"command": "codemux", "args": ["mcp"]}`) keep working — the change is internal. +3. The Unix-socket path can be kept temporarily as a compatibility shim that proxies to the HTTP endpoint, or dropped outright if no external tools depend on it (likely none — the socket is meant to be internal). +4. Windows: same axum server, no more named-pipe code path. Strict simplification. + +Migration concerns: + +- **Existing in-flight workspaces.** The SQLite schema is unchanged in v1 (the `owner_id` column is new and nullable). No data migration required. +- **In-flight MCP sessions during upgrade.** An agent process holding a long-lived MCP connection will see the socket disappear; it'll need to reconnect when the user restarts the desktop. Acceptable; agents already handle this. +- **External scripts hitting the socket directly.** Anything outside the Codemux org doing this is undocumented usage; we don't owe it compatibility. Mention in release notes. + +Acceptance: existing `codemux mcp` flow works against the new HTTP endpoint without the user changing their agent config; `npm run verify` passes; Windows build still produces a working binary. + +### Step 10 — Testing strategy + +Per-step acceptance criteria above cover the happy path. Below is the test-suite shape this work needs to add or extend. + +- **`codemux-core` unit tests.** Every `dispatch_request` handler gets a unit test against an in-memory `CoreContext` (SQLite `:memory:`, `NullNotifier`, no PTY). Catches state-management regressions without spinning up a real daemon. Aim: 80%+ coverage of the dispatcher. +- **Auth middleware tests.** `cargo test` for the axum layer: missing header → 401, wrong secret → 401, correct secret → 200, `Identity::Local` propagated to handler. +- **`codemux-remote serve` binary tests.** Extend `src-tauri/tests/codemux_remote_binary.rs` (currently 3 tests per `docs/features/remote-hosts.md`) with: `serve` writes a manifest then accepts HTTP; `serve status` reads it back; `serve` exits cleanly on SIGTERM and removes the manifest; two concurrent `serve` instances on the same state-dir refuse to start the second. +- **End-to-end SSH flow.** A new `src-tauri/tests/remote_mcp_roundtrip.rs` that boots `codemux-remote serve` in a child process, opens a local HTTP client against it (skipping SSH for test speed), exercises `workspace_create` → `workspace_list` → `workspace_info` → `workspace_close`. Covers Steps 2 + 5's read path. +- **Headless tool surface.** Test that `tools/list` over the MCP stdio interface returns only headless-supported tools when `Mode::Headless` is set; `browser_*` calls return the right "not supported" error. +- **Cross-platform.** Windows CI must run the same axum/manifest tests. The desktop build also has to keep working after Step 9; verify in CI on Linux + macOS + Windows. + +## Designing for an optional future relay (do not build in v1) + +The four design choices below are what make a future paid-tier cloud relay an additive feature, not a rewrite. v1 implements all of them with the *local* meaning and ignores the cloud meaning. Cost today is roughly zero; cost of retrofitting later if we skip them is high. + +| Choice | v1 behavior | Future cloud meaning | Cost to do now | +|---|---|---|---| +| **HTTP transport** | axum on loopback, bearer token from manifest. | Relay terminates user's TLS, forwards HTTP request body through a WebSocket tunnel to the host's loopback HTTP. Same dispatcher on the host side. | Same lines of code as Unix-socket JSON-lines. | +| **`Identity` argument on every handler** | Always `Identity::Local`. Handlers ignore it. | Relay verifies the JWT, attaches `Identity::Cloud { user_id, org_id, role }` as a forwarded header, daemon trusts the relay's verdict. | ~20 lines (one struct + one middleware). | +| **Nullable `owner_id` on workspaces table** | Always `NULL`. | Populated with the creating user's ID; future ACL checks gate `workspace_open` etc. | One column in the schema. | +| **Daemon does not import Better Auth / VPS-side libs** | Daemon trusts its local bearer secret, full stop. | Relay (a separate binary on the VPS) talks to Better Auth, daemon talks to relay. | Discipline — just don't add the dependency. | + +What a future v2 relay would look like *if* we ever build it (sketch only; not on the v1 roadmap): + +1. `codemux-relay` binary deployed to the existing Hetzner VPS, alongside Better Auth and Postgres. ~1 Fly-style WebSocket server: hosts open outbound WS, cloud HTTP requests get routed to the right host's WS. +2. `codemux-remote serve --tunnel-to https://api.codemux.com --org-token ` — opt-in flag, never default. Host opens a WebSocket out to the relay, identifies itself as `org_id/host_id`. +3. MCP endpoint at `api.codemux.com/mcp` accepts tool calls with a user JWT, looks up the user's hosts, routes to the chosen one through the relay. +4. Phone/web client gets a `codemux mcp --cloud` mode for "control my hosts from anywhere." + +Estimated VPS load even at 1000 paying users × 5 hosts each = 5000 idle WebSockets, ~80 MB RAM, ~500 KB/s heartbeats — fits comfortably on the existing Hetzner box without scaling out. PTY data does not transit the relay; only control-plane tool-call envelopes do (a few KB per call). This is the same load model Superset runs on Fly. + +Again: **do not build this in v1.** The point of listing it is to lock in the four design choices in the table above, then stop. The relay is a future paid product (team collaboration, "control from phone without SSH"), not a v1 dependency. + +## Open Questions + +- **Should workspaces be moved on pull, or copied?** Copy is safer (server still has the worktree if pull fails). Move keeps state-of-the-world clean. Probably ship as copy with an opt-in "pull and remove from host" toggle later. +- **Agent sessions across hosts.** When the desktop pulls a workspace, the agent's session (Claude Code conversation file under `~/.claude/projects/...`) lives in the remote `$HOME`. Do we rsync that too? Likely yes — there's prior art in the existing push flow that "synchronizes the Claude conversation across local/remote ends" (per `remote-hosts.md` line 17). Make pull symmetric. +- **Concurrent agents on the same workspace on different hosts.** Out of scope; the first version refuses pull if the remote workspace is "active" (has a running agent PTY). User must stop the agent first. +- **Webhook payload shape for the headless notifier.** Likely just `{ kind, workspace_id, message, ts }`; needs a tiny doc once it lands so users can build phone notifiers. +- **Cross-host workspace IDs.** Workspace UUIDs need to be globally unique so an imported workspace can't collide with a local one. Current schema uses UUID v4 — fine. But we should record `origin_host_id` so the UI can show "imported from homelab". +- **Distribution of the new `serve` subcommand.** The release pipeline already cross-compiles `codemux-remote` for four targets (`docs/features/remote-hosts.md` line 78). The headless serve will increase binary size (it now embeds SQLite, the worktree manager, etc.). Probably still under 30 MB; verify before shipping. +- **Is `codemux-remote` still a good name?** Once it grows a workspace registry it's more "codemux-server" than "codemux-remote". Rename or alias? Defer to release-time bikeshed. +- **Upgrade flow for existing hosts.** When the desktop ships a newer `codemux-remote` binary in `src-tauri/binaries/`, hosts running an older version need to be re-bootstrapped (re-scp the new binary + restart the systemd unit). Today the probe step in `docs/features/remote-hosts.md` checks the version but doesn't auto-upgrade. Probably needs a "Reinstall on host" button in Settings → Hosts. Out of scope for v1; track as a follow-up. +- **Concurrent `serve` instances on the same machine.** If a user runs `codemux-remote serve` on the same machine as their desktop (weird but possible), both processes try to manage workspaces and may stomp on each other. Resolution: keep state dirs strictly separate (`~/.codemux/` desktop vs `~/.local/share/codemux-remote/` headless), and have `serve` refuse to start if its own manifest's pid is still alive (singleton check, ~10 lines). +- **Agent CLI installed on the remote.** `terminal_write` will only succeed if `claude`, `codex`, etc. are actually installed on the host. The bootstrap step should probe for the user's configured agent and warn if missing; or — more pragmatic — let the first `workspace_create` fail with a clear error message and document the prerequisite. Probably the latter for v1. + +## Likely Touch Points + +- `src-tauri/src/control.rs` — strip `AppHandle`, take `CoreContext`. +- `src-tauri/src/mcp_server.rs` — move out of Tauri crate; add `Mode::Desktop | Mode::Headless`. +- `src-tauri/src/state.rs` — drop `tauri::Manager` calls, expose `Arc`. +- `src-tauri/src/database.rs` — already Tauri-free in spirit; verify. +- `src-tauri/src/terminal/` — strip the few `app.emit` calls behind `UiNotifier`. +- `src-tauri/src/bin/codemux_remote.rs` — add `serve`, `serve status`, `mcp` subcommands. +- `src-tauri/src/ssh/push.rs` and a new sibling `src-tauri/src/ssh/pull.rs`. +- `src-tauri/src/ssh/registry.rs` — persist `secret` per host, persist `control_socket_path`. +- `src-tauri/src/cli.rs` — `--host` global flag. +- `src/components/hosts/` — Pull-workspace UI alongside the existing Push. +- `docs/features/remote-hosts.md` — update once §"`codemux-remote` slim binary" stops being slim. +- `Cargo.toml` — new `[[bin]] codemux-remote` deps (sqlite, etc.); possibly a new `codemux-core` member crate. + +## Already Landed + +- Slim `codemux-remote` binary with `version`, `pty-daemon --socket`, `scheduler` (commit history under `docs/features/remote-hosts.md`). +- SSH push of a workspace from desktop to host, with PTY tunnel and Claude-conversation sync (commit `8c72b44`). +- Cross-compile + bundling of `codemux-remote` for four targets via the release pipeline. +- Desktop `control.rs` control socket + `mcp_server.rs` MCP shim — the implementation we're about to lift into a shared crate. +- **Headless `codemux-remote serve` daemon (this branch).** Self-contained module at `src-tauri/src/remote/` with: + - `manifest.rs` — atomic-write manifest.json (endpoint, bearer secret, pid, started_at, host_id, owner_id), mode 0600. Pid liveness via `kill(0)`. + - `auth.rs` — axum middleware enforcing `Authorization: Bearer ` with constant-time comparison; tags every request with `Identity::Local`. + - `identity.rs` — `Identity` enum with `Local` (v1) and reserved `Cloud { user_id, org_id, role }` for the future relay. + - `workspace.rs` — self-contained SQLite registry at `/codemux.db` with nullable `owner_id` column for future relay use. + - `pty.rs` — minimal portable-pty wrapper, per-terminal ring buffer (1 MiB cap). + - `server.rs` — axum HTTP routes: `GET /health`, `GET /tools/list`, `POST /tools/call`. + - `mcp.rs` — JSON-RPC 2.0 stdio MCP server (`initialize`, `tools/list`, `tools/call`) forwarding to the local daemon over HTTP. + - `tools/mod.rs` — 11 headless tools: workspace_{create,list,info,update,close}, terminal_{spawn,write,read,list,close}, app_status. +- **CLI subcommands on `codemux-remote`**: `serve` (long-running daemon with graceful SIGTERM/SIGINT shutdown), `serve status`, `serve stop`, `mcp` (stdio MCP for an agent CLI). +- **Tests**: 26 unit tests in the `remote` module + 8 end-to-end integration tests in `src-tauri/tests/codemux_remote_serve_mcp.rs` covering the full HTTP roundtrip, the MCP stdio roundtrip (the headline test), and edge cases (auth required, singleton check, status JSON, missing-daemon error path, PTY echo roundtrip). +- **Example systemd user unit** at `scripts/codemux-remote.service.example` with install instructions. + +## Auto-provisioning on push (the "it just works" flow — landed) + +The desktop push flow is now end-to-end zero-touch: + +- `ssh::bootstrap::bootstrap_remote` (pre-existing) installs the binary. +- `ssh::bootstrap::provision_serve` (NEW) installs + starts the + `codemux-remote.service` systemd user unit and enables lingering. +- `ssh::push::push_workspace` (extended) calls + `provision_workspace_mcp_config` to drop a `.mcp.json` in the pushed + workspace dir so any CLI agent launched there auto-discovers Codemux + via `codemux-remote mcp`. +- After rsync, `ssh::bootstrap::register_workspace_on_remote` SSHes in + and runs `codemux-remote workspace register --path ...` (a new + subcommand on the remote binary that calls the local daemon's + `workspace_create` tool over loopback HTTP) so the pushed workspace + shows in `workspace_list` from any agent. + +Net effect: user clicks "Push workspace to host" once, the rest is +automated. They never see the words "manifest" or "systemd." + +## Explicitly deferred to follow-up PRs + +- **Step 1 (extract codemux_core)** and **Step 9 (migrate desktop transport)** — the desktop's `control.rs`/`mcp_server.rs`/state stores are not touched in this branch. Reconnaissance showed ~98K LOC of Rust across ~143 files with ~403 occurrences of Tauri couplings; doing it cleanly is a multi-PR effort. The headless daemon ships *without* needing it because it has its own self-contained registry, server, and tools. Code-sharing dedup becomes a future refactor when the two implementations have actually diverged enough to be worth merging. +- **Step 5 (desktop pull workspace)** — desktop UI for "pull from host" is unbuilt; the existing rsync push path is unchanged. The wire protocol it needs (HTTP `workspace_list` + `workspace_info`) is already up and tested on the daemon side. Adding the desktop pull-flow code is mostly UI + an SSH-tunnel wrapper around an already-working HTTP client. +- **Step 6 (desktop CLI --host flag)** — same gating: the daemon side is done, the desktop CLI wiring is the missing piece. +- **Phone control without SSH** — v1 recommends Tailscale (documented in `docs/features/remote-hosts.md`). The optional cloud relay (paid tier) is deferred as a separate v2 project; the four design constraints listed above (HTTP transport, `Identity` passthrough, nullable `owner_id`, no Better-Auth coupling) keep that future path additive. + +## Notes + +- This is the first step toward "Codemux works the same on any machine I can ssh into," which subsumes a chunk of the automations roadmap (`docs/plans/automations.md`) — a server-side automation is just "this headless Codemux runs a preset and pushes a PR" with no daemon-lifecycle distinction. +- We are intentionally **not** introducing a cloud relay. If we ever want a "control my home Codemux from my phone without SSH" story, the right shape is a thin per-user relay that proxies between an authenticated phone client and the user's headless daemon — additive, not on the critical path here. +- Superset's `apps/api/MCP_TOOLS.md` and `packages/host-service/src/tunnel/` are worth a re-read before Step 5/6; their `buildHostRoutingKey` + WebSocket-routed tRPC envelope is the most mature prior art for "MCP on host X targets host Y," even if we don't use their transport. diff --git a/scripts/codemux-remote.service.example b/scripts/codemux-remote.service.example new file mode 100644 index 00000000..55deccc5 --- /dev/null +++ b/scripts/codemux-remote.service.example @@ -0,0 +1,42 @@ +# systemd user unit for `codemux-remote serve`. +# +# Install: +# 1. Copy this file to ~/.config/systemd/user/codemux-remote.service +# (edit ExecStart to point at the path where you installed the +# binary — usually ~/.local/bin/codemux-remote after the desktop's +# bootstrap step has copied it over). +# 2. `loginctl enable-linger $USER` — lets the daemon survive ssh +# logouts and run across reboots without an active session. +# 3. `systemctl --user daemon-reload` +# 4. `systemctl --user enable --now codemux-remote.service` +# +# Health check after install: +# codemux-remote serve status +# +# Logs: +# journalctl --user -u codemux-remote -f + +[Unit] +Description=Codemux headless daemon (MCP control plane for agents on this host) +Documentation=https://codemux.org +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# Adjust the path to wherever you installed the binary. The desktop's +# bootstrap step writes it to ~/.local/bin/codemux-remote by default. +ExecStart=%h/.local/bin/codemux-remote serve +# Default state dir is ~/.local/share/codemux-remote; explicit here +# so a misconfigured $XDG_DATA_HOME doesn't surprise you. +Environment=XDG_DATA_HOME=%h/.local/share +# Restart on crash but back off so a wedge doesn't spin the CPU. +Restart=on-failure +RestartSec=5s +# Tame the journal: keep stderr (where the daemon logs) but discard +# stdout (which the daemon doesn't use). +StandardOutput=null +StandardError=journal + +[Install] +WantedBy=default.target diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e669fdb3..abee083f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -383,6 +383,61 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -760,6 +815,7 @@ dependencies = [ "aes-gcm", "argon2", "async-trait", + "axum", "base64 0.22.1", "chacha20poly1305", "chrono", @@ -802,6 +858,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "toml 1.1.2+spec-1.1.0", + "tower", "url", "urlencoding", "uuid", @@ -2899,6 +2956,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.8.0" @@ -4765,6 +4828,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -6067,6 +6141,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6105,6 +6180,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d7f3409a..d570b8cd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -82,6 +82,14 @@ sysinfo = "0.33" # an iCalendar DTSTART+RRULE block and computes the next occurrence # with IANA-timezone / DST correctness. Used by automations::recurrence. rrule = "0.14" +# Axum HTTP server for the new `codemux-remote serve` headless control +# endpoint. Loopback-only by default (127.0.0.1), bearer-token auth via +# a manifest file. The transport choice (HTTP+bearer over loopback, +# 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" +tower = "0.5" [target.'cfg(unix)'.dependencies] tauri-plugin-dialog = { version = "2", default-features = false, features = ["xdg-portal"] } diff --git a/src-tauri/src/automations/service.rs b/src-tauri/src/automations/service.rs index eeb7ac05..c59075d9 100644 --- a/src-tauri/src/automations/service.rs +++ b/src-tauri/src/automations/service.rs @@ -67,6 +67,81 @@ pub const SYSTEMD_UNIT_NAME: &str = "codemux-scheduler.service"; /// Default launchd label for the scheduler service. pub const LAUNCHD_LABEL: &str = "org.codemux.scheduler"; +// ─── codemux-remote serve (headless Codemux daemon) ────────────── +// +// The `serve` subcommand is the auto-provisioned headless equivalent +// of the desktop Codemux app — an MCP-aware agent on this host can +// drive workspaces, terminals, etc. through `codemux-remote mcp` +// without any manual setup. Bootstrap installs this alongside the +// scheduler so a freshly-pushed host is immediately MCP-capable. + +/// A systemd **user** service unit for the `serve` daemon. +/// +/// `exec_path` is the absolute path to the `codemux-remote` binary on +/// the host (bootstrap installs it at `~/.local/bin/codemux-remote`). +/// Installed at `~/.config/systemd/user/codemux-remote.service`, then +/// `systemctl --user enable --now codemux-remote`. +/// +/// Differs from the scheduler unit: +/// +/// * `Restart=on-failure` (not `always`) — the daemon exits cleanly +/// on SIGTERM, and a clean exit shouldn't trigger a restart loop. +/// * `RestartSec=5s` — tighter than the scheduler because users +/// notice when the MCP control plane is missing. +/// * `StandardError=journal` — the daemon's diagnostics go to +/// journalctl, where `journalctl --user -u codemux-remote -f` +/// can tail them. +pub fn serve_systemd_unit(exec_path: &str) -> String { + format!( + "[Unit]\n\ + Description=Codemux headless daemon (MCP control plane for this host)\n\ + Documentation=https://codemux.org\n\ + After=network-online.target\n\ + Wants=network-online.target\n\ + \n\ + [Service]\n\ + Type=simple\n\ + ExecStart={exec_path} serve\n\ + Restart=on-failure\n\ + RestartSec=5s\n\ + StandardOutput=null\n\ + StandardError=journal\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n" + ) +} + +/// A launchd `LaunchAgent` plist for the `serve` daemon on macOS. +pub fn serve_launchd_plist(exec_path: &str, label: &str) -> String { + format!( + "\n\ + \n\ + \n\ + \n\ + \x20 Label\n\ + \x20 {label}\n\ + \x20 ProgramArguments\n\ + \x20 \n\ + \x20 {exec_path}\n\ + \x20 serve\n\ + \x20 \n\ + \x20 RunAtLoad\n\ + \x20 \n\ + \x20 KeepAlive\n\ + \x20 \n\ + \n\ + \n" + ) +} + +/// Default systemd unit file name for the `serve` daemon. +pub const SERVE_SYSTEMD_UNIT_NAME: &str = "codemux-remote.service"; + +/// Default launchd label for the `serve` daemon. +pub const SERVE_LAUNCHD_LABEL: &str = "org.codemux.remote"; + #[cfg(test)] mod tests { use super::*; @@ -94,4 +169,30 @@ mod tests { assert!(plist.trim_start().starts_with("")); } + + #[test] + fn serve_systemd_unit_runs_the_serve_subcommand() { + let unit = serve_systemd_unit("/home/u/.local/bin/codemux-remote"); + assert!(unit.contains("ExecStart=/home/u/.local/bin/codemux-remote serve")); + // Clean-exit on SIGTERM shouldn't restart — that's why it's + // on-failure, not always. + assert!(unit.contains("Restart=on-failure")); + assert!(unit.contains("WantedBy=default.target")); + // Diagnostics live in journalctl so we can tail them. + assert!(unit.contains("StandardError=journal")); + // Listed as a control-plane service. + assert!(unit.contains("MCP control plane")); + } + + #[test] + fn serve_launchd_plist_runs_the_serve_subcommand() { + let plist = serve_launchd_plist("/usr/local/bin/codemux-remote", SERVE_LAUNCHD_LABEL); + assert!(plist.contains("org.codemux.remote")); + assert!(plist.contains("/usr/local/bin/codemux-remote")); + assert!(plist.contains("serve")); + assert!(plist.contains("KeepAlive")); + assert!(plist.contains("RunAtLoad")); + assert!(plist.trim_start().starts_with("")); + } } diff --git a/src-tauri/src/bin/codemux_remote.rs b/src-tauri/src/bin/codemux_remote.rs index bfcd1e1e..72bdae94 100644 --- a/src-tauri/src/bin/codemux_remote.rs +++ b/src-tauri/src/bin/codemux_remote.rs @@ -1,30 +1,31 @@ -//! `codemux-remote` — slim server-side binary. +//! `codemux-remote` — server-side binary. //! -//! Runs on the remote host the laptop's Codemux pushes workspaces to. -//! Bundles only the PTY daemon (`codemux_lib::pty_daemon::server`) and -//! a tiny CLI wrapper — no Tauri, no webkit, no UI dependencies. +//! Runs on the remote host the laptop pushes workspaces to, **and** +//! (new in this revision) hosts a headless Codemux daemon so an +//! agent running on the host can drive Codemux through MCP — create +//! workspaces, list them, write to terminals — without any UI. //! -//! Lifecycle: +//! Subcommands: //! -//! 1. The laptop SSHes in, scp's this binary (matched to the remote's -//! arch + OS via `uname -sm`), and runs it under `ssh -L tunnel`. -//! 2. This process binds a Unix socket and accepts requests from the -//! laptop's `PtyDaemonClient` — same wire protocol as the local -//! daemon, so the client doesn't care whether it's talking over a -//! local socket or an SSH-tunneled socket. -//! 3. Stays alive across the laptop's reconnects. When the laptop is -//! truly gone (host shutdown, manual stop), an idle reaper can -//! shut us down — not yet implemented. +//! - `pty-daemon --socket ` — the original Unix-socket PTY +//! daemon Codemux pushes workspaces into. Unchanged. +//! - `scheduler` — automation scheduler. Unchanged. +//! - `serve` — long-running headless daemon, axum HTTP on 127.0.0.1 +//! with bearer auth from a manifest. New in this revision. +//! - `serve status` — read the manifest and report whether the +//! daemon is up. New. +//! - `serve stop` — find the daemon via the manifest and SIGTERM it. +//! New. +//! - `mcp` — stdio MCP server that talks to the local `serve` +//! daemon over HTTP. Configure your CLI agent to launch this. New. +//! - `version` — JSON version string. The laptop's bootstrap probe +//! parses it. Unchanged. //! -//! Unix-only by design: the daemon's server uses `tokio::net::UnixListener`. -//! Windows servers can run as remote codemux targets once the daemon -//! grows named-pipe support — tracked alongside the desktop-side -//! Windows port. - -// Unix-only — the daemon's server uses tokio::net::UnixListener which -// doesn't exist on Windows, and the cloud-push feature (the only thing -// that needs codemux-remote) is also `#[cfg(unix)]`. On Windows this -// binary compiles to a no-op stub below. +//! Unix-only by design: the existing PTY daemon uses Unix-domain +//! sockets and the new `serve` mode wraps headless features that +//! were never wired for Windows. The Windows build path is a no-op +//! stub at the top of this file. + #![cfg_attr(not(unix), allow(unused_imports, dead_code))] #[cfg(not(unix))] @@ -49,10 +50,11 @@ use clap::{Parser, Subcommand}; #[command( name = "codemux-remote", version, - about = "Slim PTY daemon Codemux pushes workspaces to.", + about = "Headless Codemux daemon + PTY proxy for remote hosts.", long_about = "Runs on the remote host the laptop's Codemux pushes \ - workspaces to. Same wire protocol as the local in-app \ - daemon — the laptop's client doesn't distinguish." + workspaces to, and (with `serve`) hosts a headless \ + Codemux daemon so an agent on the host can drive \ + Codemux through MCP." )] struct Cli { #[command(subcommand)] @@ -70,14 +72,97 @@ enum Command { socket: PathBuf, }, /// Run the automation scheduler: poll the account for this host's - /// automations, fire those that are due, and run the agent. Meant - /// to run as a persistent user service (systemd / launchd), - /// registered by the laptop's host bootstrap. + /// automations, fire those that are due, and run the agent. Scheduler, /// Print version info as JSON. The laptop's bootstrap probe uses /// this to confirm a working installation before attempting a /// daemon start. Version, + /// Run the headless Codemux daemon (axum HTTP on loopback, + /// bearer auth from manifest). An agent on this host points its + /// MCP client at `codemux-remote mcp` to drive it. + Serve(ServeArgs), + /// Run the stdio MCP server that bridges an agent CLI to the + /// local `serve` daemon over HTTP. + Mcp { + /// State directory the daemon used. Defaults to the same + /// path `serve` defaults to (~/.local/share/codemux-remote). + #[arg(long)] + state_dir: Option, + }, + /// Workspace-registry helpers that talk to the local `serve` + /// daemon over loopback HTTP. Used by the desktop's push flow + /// (run remotely via SSH) so a pushed workspace shows up in the + /// daemon's `workspace_list` without any manual MCP call. + Workspace { + #[command(subcommand)] + subcommand: WorkspaceSubcommand, + }, +} + +#[cfg(unix)] +#[derive(Subcommand)] +enum WorkspaceSubcommand { + /// Register a workspace in the daemon's registry. Calls the + /// `workspace_create` tool on the local daemon. Exits 0 with the + /// workspace id printed to stdout (JSON) on success. + Register { + /// Absolute path of the workspace's working directory. + #[arg(long)] + path: String, + /// Human-readable name. Defaults to basename of `--path`. + #[arg(long)] + name: Option, + /// Git branch (optional). + #[arg(long)] + branch: Option, + /// Originating project root if this is a worktree (optional). + #[arg(long)] + project_root: Option, + /// State directory of the daemon. Defaults to the same path + /// `serve` defaults to. + #[arg(long)] + state_dir: Option, + /// Retry connecting to the daemon for up to this many seconds + /// before giving up. Useful right after `systemctl --user + /// start codemux-remote` when the daemon may still be coming + /// up. + #[arg(long, default_value = "10")] + connect_timeout_secs: u64, + }, +} + +#[cfg(unix)] +#[derive(clap::Args)] +struct ServeArgs { + #[command(subcommand)] + subcommand: Option, + + /// Explicit port. Defaults to picking an ephemeral free port and + /// recording it in the manifest. + #[arg(long)] + port: Option, + + /// State directory the daemon writes manifest / db / log under. + /// Defaults to ~/.local/share/codemux-remote. + #[arg(long)] + state_dir: Option, +} + +#[cfg(unix)] +#[derive(Subcommand)] +enum ServeSubcommand { + /// Print the daemon's manifest (endpoint, pid, started_at, …) if + /// a manifest exists and the pid is alive. Exits 1 if not. + Status { + #[arg(long)] + state_dir: Option, + }, + /// Send SIGTERM to the running daemon (read from the manifest). + Stop { + #[arg(long)] + state_dir: Option, + }, } #[cfg(unix)] @@ -85,9 +170,6 @@ fn main() -> ExitCode { let cli = Cli::parse(); match cli.command { None | Some(Command::Version) => { - // JSON form so the laptop's bootstrap can parse it - // without grepping. Same shape Codemux uses for its - // self-version reporting. let payload = serde_json::json!({ "name": "codemux-remote", "version": env!("CARGO_PKG_VERSION"), @@ -96,18 +178,41 @@ fn main() -> ExitCode { println!("{}", payload); ExitCode::SUCCESS } - Some(Command::PtyDaemon { socket }) => run_daemon(socket), + Some(Command::PtyDaemon { socket }) => run_pty_daemon(socket), Some(Command::Scheduler) => run_scheduler(), + Some(Command::Serve(args)) => { + // Subcommand short-circuits: `serve status`, `serve stop`. + if let Some(sub) = args.subcommand { + return match sub { + ServeSubcommand::Status { state_dir } => run_serve_status(state_dir), + ServeSubcommand::Stop { state_dir } => run_serve_stop(state_dir), + }; + } + run_serve(args.port, args.state_dir) + } + Some(Command::Mcp { state_dir }) => run_mcp(state_dir), + Some(Command::Workspace { subcommand }) => match subcommand { + WorkspaceSubcommand::Register { + path, + name, + branch, + project_root, + state_dir, + connect_timeout_secs, + } => run_workspace_register( + path, + name, + branch, + project_root, + state_dir, + connect_timeout_secs, + ), + }, } } #[cfg(unix)] -fn run_daemon(socket: PathBuf) -> ExitCode { - // Run the same server the in-app daemon uses. A failure inside - // the listener (bind race, EMFILE under unusual load) is - // surfaced to stderr so the laptop's `ssh ...` invocation sees - // it; the process then exits non-zero so any keepalive script - // notices. +fn run_pty_daemon(socket: PathBuf) -> ExitCode { let runtime = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -128,14 +233,6 @@ fn run_daemon(socket: PathBuf) -> ExitCode { } } -/// Run the automation scheduler loop on this host. -/// -/// Same `scheduler::tick` + `executor` the desktop runs, but driven -/// against this host's own database with no Tauri runtime. Automations -/// reach this host's database via `automations_sync::pull` (host-scoped -/// by the bootstrap-provisioned token); until the account API is -/// deployed the pull is a harmless 404 skip and the loop simply ticks -/// whatever is already local. #[cfg(unix)] fn run_scheduler() -> ExitCode { let runtime = match tokio::runtime::Builder::new_current_thread() @@ -163,7 +260,6 @@ async fn scheduler_loop() -> ExitCode { } }; - // Recover runs orphaned by a previous crash before the first tick. let ceiling = (chrono::Utc::now() - chrono::Duration::hours(6)).to_rfc3339(); let reconciled = db.reconcile_stale_runs(&ceiling); if reconciled > 0 { @@ -177,9 +273,6 @@ async fn scheduler_loop() -> ExitCode { automations already in the local database" ); } - // This host's own server id, written by the laptop's bootstrap. - // It scopes the account pull so this host only receives — and only - // runs — the automations routed to it. let host_id = read_scheduler_host(); if token.is_some() && host_id.is_none() { eprintln!( @@ -193,15 +286,11 @@ async fn scheduler_loop() -> ExitCode { let mut ticker = tokio::time::interval(std::time::Duration::from_secs( scheduler::TICK_INTERVAL_SECS, )); - ticker.tick().await; // consume the immediate first tick + ticker.tick().await; loop { ticker.tick().await; - // Pull this host's automations from the account, scoped to - // this host's id. Only runs when both the token and the host - // identity are present — an unscoped pull would deliver other - // hosts' automations and run them here. if let (Some(token), Some(host_id)) = (&token, &host_id) { if let Err(error) = codemux_lib::automations_sync::pull(token, &db, Some(host_id)).await @@ -210,8 +299,6 @@ async fn scheduler_loop() -> ExitCode { } } - // `local_only = false`: a host scheduler runs every automation - // its database holds (the account query is already host-scoped). let fired = scheduler::tick(&db, chrono::Utc::now(), false); for run in fired { if let Some(automation) = db.get_automation(run.automation_id) { @@ -223,15 +310,11 @@ async fn scheduler_loop() -> ExitCode { } } -/// Read the scheduler token the laptop's bootstrap writes to -/// `~/.local/share/codemux/scheduler-token`. `None` when absent. #[cfg(unix)] fn read_scheduler_token() -> Option { read_scheduler_file("scheduler-token") } -/// Read this host's own server id, written by bootstrap to -/// `~/.local/share/codemux/scheduler-host`. `None` when absent. #[cfg(unix)] fn read_scheduler_host() -> Option { read_scheduler_file("scheduler-host") @@ -246,9 +329,321 @@ fn read_scheduler_file(name: &str) -> Option { .filter(|value| !value.is_empty()) } -// The pre-existing `#[cfg(not(unix))] fn run_daemon` stub used -// PathBuf + ExitCode, which now live behind `#[cfg(unix)]` imports. -// On Windows it's also unreachable — the new `#[cfg(not(unix))] main` -// at the top of this file exits before any subcommand dispatch. -// So we remove the stub; if anything ever needs a Windows codepath -// for the daemon, it'll be a real implementation, not a stub. +// ─── New: `serve` / `mcp` subcommands ──────────────────────────── + +#[cfg(unix)] +fn resolve_state_dir(arg: Option) -> PathBuf { + arg.unwrap_or_else(codemux_lib::remote::config::default_state_dir) +} + +#[cfg(unix)] +fn run_serve(port: Option, state_dir_arg: Option) -> ExitCode { + let state_dir = resolve_state_dir(state_dir_arg); + + // Singleton check: if a live daemon already owns the manifest, + // refuse to start a second one. A stale manifest (pid not alive) + // is overwritten. + let manifest_path = codemux_lib::remote::config::manifest_path(&state_dir); + if let Ok(Some(existing)) = codemux_lib::remote::manifest::read(&manifest_path) { + if codemux_lib::remote::manifest::pid_alive(existing.pid) { + eprintln!( + "[codemux-remote] another daemon already running (pid {}, endpoint {}). \ + Use `codemux-remote serve stop` first.", + existing.pid, existing.endpoint + ); + return ExitCode::from(2); + } + eprintln!( + "[codemux-remote] stale manifest from pid {} (not alive); replacing", + existing.pid + ); + let _ = codemux_lib::remote::manifest::remove(&manifest_path); + } + + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!("[codemux-remote] tokio runtime: {e}"); + return ExitCode::from(2); + } + }; + + let result = runtime.block_on(async move { run_serve_async(port, state_dir).await }); + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("[codemux-remote] serve: {e}"); + ExitCode::from(1) + } + } +} + +#[cfg(unix)] +async fn run_serve_async(port: Option, state_dir: PathBuf) -> Result<(), String> { + use codemux_lib::remote::{config, manifest, pty::PtyManager, server, workspace::WorkspaceStore}; + + std::fs::create_dir_all(&state_dir).map_err(|e| format!("create state dir: {e}"))?; + + // Bind first so we know what port to write into the manifest. + let listener = server::bind_listener(port).await?; + let local_addr = listener.local_addr().map_err(|e| format!("local_addr: {e}"))?; + let endpoint = format!("http://{}", local_addr); + + let host_id = manifest::current_host_id(); + let workspaces = WorkspaceStore::open( + &config::database_path(&state_dir), + host_id.clone(), + config::workspaces_root(&state_dir), + ) + .map_err(|e| format!("workspace store: {e}"))?; + + let manifest_value = manifest::Manifest::new(endpoint.clone(), host_id); + let manifest_path = config::manifest_path(&state_dir); + manifest::write(&manifest_path, &manifest_value)?; + eprintln!( + "[codemux-remote] manifest written to {} (secret length {})", + manifest_path.display(), + manifest_value.secret.len() + ); + + let started_at = manifest_value.started_at.clone(); + let secret = manifest_value.secret.clone(); + + let state = std::sync::Arc::new(server::DaemonState { + secret, + started_at, + workspaces, + ptys: PtyManager::new(), + }); + + let app = server::router(std::sync::Arc::clone(&state)); + + // SIGTERM/SIGINT-aware graceful shutdown so the manifest gets + // cleaned up. On signal: drop the listener, then unlink the + // manifest file. axum::serve with `with_graceful_shutdown` + // does the rest. + let manifest_path_for_shutdown = manifest_path.clone(); + let shutdown = async move { + let mut sigterm = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + let mut sigint = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .expect("install SIGINT handler"); + tokio::select! { + _ = sigterm.recv() => eprintln!("[codemux-remote] SIGTERM, shutting down"), + _ = sigint.recv() => eprintln!("[codemux-remote] SIGINT, shutting down"), + } + let _ = manifest::remove(&manifest_path_for_shutdown); + }; + + eprintln!("[codemux-remote] listening on {endpoint}"); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .await + .map_err(|e| format!("axum serve: {e}"))?; + eprintln!("[codemux-remote] shutdown complete"); + Ok(()) +} + +#[cfg(unix)] +fn run_serve_status(state_dir_arg: Option) -> ExitCode { + let state_dir = resolve_state_dir(state_dir_arg); + let manifest_path = codemux_lib::remote::config::manifest_path(&state_dir); + match codemux_lib::remote::manifest::read(&manifest_path) { + Ok(None) => { + eprintln!( + "[codemux-remote] no daemon manifest at {}. Not running.", + manifest_path.display() + ); + ExitCode::from(1) + } + Ok(Some(m)) => { + let alive = codemux_lib::remote::manifest::pid_alive(m.pid); + let payload = serde_json::json!({ + "endpoint": m.endpoint, + "pid": m.pid, + "started_at": m.started_at, + "host_id": m.host_id, + "alive": alive, + }); + println!("{}", payload); + if alive { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } + } + Err(e) => { + eprintln!("[codemux-remote] {e}"); + ExitCode::from(2) + } + } +} + +#[cfg(unix)] +fn run_serve_stop(state_dir_arg: Option) -> ExitCode { + let state_dir = resolve_state_dir(state_dir_arg); + let manifest_path = codemux_lib::remote::config::manifest_path(&state_dir); + let manifest = match codemux_lib::remote::manifest::read(&manifest_path) { + Ok(Some(m)) => m, + Ok(None) => { + eprintln!("[codemux-remote] no daemon to stop"); + return ExitCode::SUCCESS; + } + Err(e) => { + eprintln!("[codemux-remote] {e}"); + return ExitCode::from(2); + } + }; + let pid = manifest.pid as libc::pid_t; + // SAFETY: kill is a syscall with no memory side effects. + let rc = unsafe { libc::kill(pid, libc::SIGTERM) }; + if rc != 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + eprintln!( + "[codemux-remote] pid {pid} is gone; removing stale manifest" + ); + let _ = codemux_lib::remote::manifest::remove(&manifest_path); + return ExitCode::SUCCESS; + } + eprintln!("[codemux-remote] kill({pid}, SIGTERM): {err}"); + return ExitCode::from(1); + } + eprintln!("[codemux-remote] sent SIGTERM to pid {pid}"); + ExitCode::SUCCESS +} + +#[cfg(unix)] +fn run_mcp(state_dir_arg: Option) -> ExitCode { + let state_dir = resolve_state_dir(state_dir_arg); + match codemux_lib::remote::mcp::run_stdio(state_dir) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("[codemux-remote] mcp: {e}"); + ExitCode::from(1) + } + } +} + +/// Implementation for `codemux-remote workspace register`. Reads the +/// local daemon's manifest, POSTs `workspace_create` to its loopback +/// HTTP endpoint, prints the new workspace's id+metadata as JSON on +/// stdout. Used by the desktop's push flow to register a freshly +/// pushed worktree on the remote. +/// +/// Retries until `connect_timeout_secs` so the just-started systemd +/// unit has time to come up. +#[cfg(unix)] +fn run_workspace_register( + path: String, + name: Option, + branch: Option, + project_root: Option, + state_dir_arg: Option, + connect_timeout_secs: u64, +) -> ExitCode { + let state_dir = resolve_state_dir(state_dir_arg); + let manifest_path = codemux_lib::remote::config::manifest_path(&state_dir); + + // Wait for the manifest to appear AND the daemon to answer + // /health. systemctl returns immediately when starting a unit; + // the daemon's actual bind happens a tick later. + let deadline = std::time::Instant::now() + + std::time::Duration::from_secs(connect_timeout_secs.max(1)); + let client = match reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + { + Ok(c) => c, + Err(e) => { + eprintln!("[codemux-remote] build http client: {e}"); + return ExitCode::from(2); + } + }; + + let manifest = loop { + match codemux_lib::remote::manifest::read(&manifest_path) { + Ok(Some(m)) if codemux_lib::remote::manifest::pid_alive(m.pid) => { + // Probe /health to catch the case where the manifest + // is fresh but the listener hasn't accepted yet. + let healthy = client + .get(format!("{}/health", m.endpoint)) + .send() + .map(|r| r.status().is_success()) + .unwrap_or(false); + if healthy { + break m; + } + } + Ok(_) | Err(_) => {} + } + if std::time::Instant::now() > deadline { + eprintln!( + "[codemux-remote] daemon at {} did not become healthy within {}s", + manifest_path.display(), + connect_timeout_secs + ); + return ExitCode::from(1); + } + std::thread::sleep(std::time::Duration::from_millis(250)); + }; + + // POST workspace_create. + let url = format!("{}/tools/call", manifest.endpoint); + let mut args = serde_json::Map::new(); + args.insert("path".into(), serde_json::Value::String(path)); + if let Some(n) = name { + args.insert("name".into(), serde_json::Value::String(n)); + } + if let Some(b) = branch { + args.insert("branch".into(), serde_json::Value::String(b)); + } + if let Some(p) = project_root { + args.insert("project_root".into(), serde_json::Value::String(p)); + } + let body = serde_json::json!({ + "name": "workspace_create", + "arguments": serde_json::Value::Object(args), + }); + + let response = match client + .post(&url) + .bearer_auth(&manifest.secret) + .json(&body) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("[codemux-remote] POST {url}: {e}"); + return ExitCode::from(1); + } + }; + let status = response.status(); + let payload: serde_json::Value = match response.json() { + Ok(v) => v, + Err(e) => { + eprintln!("[codemux-remote] decode workspace_create: {e}"); + return ExitCode::from(1); + } + }; + if !status.is_success() || payload.get("ok") == Some(&serde_json::Value::Bool(false)) { + eprintln!( + "[codemux-remote] workspace_create failed (HTTP {status}): {payload}" + ); + return ExitCode::from(1); + } + // Echo the workspace JSON to stdout so the desktop can parse + + // record the new id. + let workspace = payload + .get("data") + .and_then(|d| d.get("workspace")) + .cloned() + .unwrap_or(payload); + println!("{}", workspace); + ExitCode::SUCCESS +} diff --git a/src-tauri/src/commands/hosts.rs b/src-tauri/src/commands/hosts.rs index 5327871e..7a96590c 100644 --- a/src-tauri/src/commands/hosts.rs +++ b/src-tauri/src/commands/hosts.rs @@ -329,10 +329,34 @@ pub async fn hosts_bootstrap_install( } _ => String::new(), }; + + // Always provision the headless `serve` daemon. Unlike + // the scheduler this needs no auth token or server id — + // it's a per-host local control plane. After this + // returns successfully, an MCP-capable agent on the host + // can use `codemux-remote mcp` without any manual setup. + // Same best-effort contract: a failure logs and is + // surfaced in the result message, doesn't fail bootstrap. + let serve_note = match crate::ssh::bootstrap::provision_serve( + &host.ssh_target, + "~/.local/bin/codemux-remote", + std::time::Duration::from_secs(30), + ) + .await + { + Ok(()) => " · MCP control plane enabled".to_string(), + Err(error) => { + eprintln!( + "[codemux::hosts] serve provisioning failed: {error}" + ); + " · (MCP control plane not enabled — see logs)".to_string() + } + }; + HostBootstrapResult { ok: true, message: format!( - "codemux-remote v{reported_version} installed on {}{scheduler_note}", + "codemux-remote v{reported_version} installed on {}{scheduler_note}{serve_note}", host.name ), } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 268e1907..e6634f3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,6 +47,8 @@ pub mod project; // module from any code path). #[cfg(unix)] pub mod pty_daemon; +#[cfg(unix)] +pub mod remote; // SSH transport for the cloud-push feature. Unix-only — relies on // the system `ssh` + `scp` binaries (with the user's existing // `~/.ssh/config`, agent, and known_hosts). diff --git a/src-tauri/src/remote/auth.rs b/src-tauri/src/remote/auth.rs new file mode 100644 index 00000000..9a02af31 --- /dev/null +++ b/src-tauri/src/remote/auth.rs @@ -0,0 +1,95 @@ +//! Bearer-token authentication middleware for the daemon's HTTP API. +//! +//! Every request must carry `Authorization: Bearer ` matching +//! the secret in the daemon's manifest. On match, the request is +//! tagged with [`Identity::Local`] via a request extension that +//! downstream handlers extract. +//! +//! Constant-time comparison is used so timing-side-channel attacks +//! can't whittle the secret out byte by byte (32-byte secret with +//! 256 bits of entropy is well over the practical-attack threshold, +//! but constant-time comparison is free here and the right hygiene). + +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +use super::identity::Identity; +use super::server::SharedState; + +/// Axum middleware that enforces the bearer-token header and +/// attaches an [`Identity`] extension to the request before handing +/// it off to the inner handler. +pub async fn require_bearer( + State(state): State, + mut req: Request, + next: Next, +) -> Response { + let provided = match req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + { + Some(value) => value.trim(), + None => return unauthorized("missing Authorization header"), + }; + + let token = match provided.strip_prefix("Bearer ") { + Some(t) => t.trim(), + None => return unauthorized("expected Bearer scheme"), + }; + + if !constant_time_eq(token.as_bytes(), state.secret.as_bytes()) { + return unauthorized("invalid bearer token"); + } + + // v1: every authenticated caller is Identity::Local. A future + // relay layer would override this by validating a forwarded + // identity header *before* this middleware sees the request, + // and constructing Identity::Cloud { … } instead. + req.extensions_mut().insert(Identity::local()); + + next.run(req).await +} + +fn unauthorized(reason: &'static str) -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "unauthorized", "reason": reason })), + ) + .into_response() +} + +/// Constant-time byte-slice equality. Returns false if lengths +/// differ; otherwise XORs all bytes and folds without branching on +/// individual comparisons. +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 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constant_time_eq_matches_normal_eq() { + 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"a")); + assert!(constant_time_eq(b"", b"")); + } +} diff --git a/src-tauri/src/remote/config.rs b/src-tauri/src/remote/config.rs new file mode 100644 index 00000000..87ebf45a --- /dev/null +++ b/src-tauri/src/remote/config.rs @@ -0,0 +1,54 @@ +//! State-dir + manifest path resolution. +//! +//! The daemon's whole on-disk footprint lives under one directory, +//! by default `~/.local/share/codemux-remote/`. Everything is +//! addressable as `/`: +//! +//! ```text +//! /manifest.json # endpoint + secret + pid + started_at +//! /codemux.db # SQLite workspace registry +//! /workspaces/ # worktree storage +//! /serve.log # daemonized-mode log +//! ``` + +use std::path::PathBuf; + +/// Default state directory for `codemux-remote serve`. +/// +/// `XDG_DATA_HOME` / `~/.local/share/codemux-remote` on Linux. +/// `~/Library/Application Support/codemux-remote` on macOS. +/// Falls back to `~/.codemux-remote` if `dirs::data_dir()` returns +/// `None` (extremely unusual). +pub fn default_state_dir() -> PathBuf { + if let Some(data_dir) = dirs::data_dir() { + return data_dir.join("codemux-remote"); + } + if let Some(home) = dirs::home_dir() { + return home.join(".codemux-remote"); + } + // Last resort. Anyone hitting this has bigger problems than where + // their state directory lives. + PathBuf::from(".codemux-remote") +} + +/// Manifest file inside a given state-dir. +pub fn manifest_path(state_dir: &std::path::Path) -> PathBuf { + state_dir.join("manifest.json") +} + +/// SQLite workspace registry inside a given state-dir. +pub fn database_path(state_dir: &std::path::Path) -> PathBuf { + state_dir.join("codemux.db") +} + +/// Worktree storage root inside a given state-dir. The daemon checks +/// each new workspace's worktree out under +/// `/workspaces//`. +pub fn workspaces_root(state_dir: &std::path::Path) -> PathBuf { + state_dir.join("workspaces") +} + +/// Serve mode log file (only used when daemonized). +pub fn serve_log_path(state_dir: &std::path::Path) -> PathBuf { + state_dir.join("serve.log") +} diff --git a/src-tauri/src/remote/identity.rs b/src-tauri/src/remote/identity.rs new file mode 100644 index 00000000..2fff32f1 --- /dev/null +++ b/src-tauri/src/remote/identity.rs @@ -0,0 +1,42 @@ +//! Caller identity carried on every dispatched request. +//! +//! v1 only ever produces [`Identity::Local`] — any caller that +//! presents the manifest's bearer secret is treated as a fully +//! trusted local user. The variant exists so a future cloud-relay +//! integration can attach `Identity::Cloud { user_id, org_id, role }` +//! parsed from a forwarded auth header, **without** changing the +//! signature of any handler. +//! +//! Handlers should not branch on the variant in v1. They take it as +//! a parameter and ignore the value. The point is that the parameter +//! is *there*, ready to be consumed later. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Identity { + /// Caller that authenticated by presenting the manifest's local + /// bearer secret. The only variant in v1. + Local, + + /// Reserved for a future cloud-relay deployment. The relay + /// terminates user authentication (JWT validation against Better + /// Auth or equivalent), then forwards a trusted identity header + /// to the daemon, which deserialises it into this variant. v1 + /// handlers don't construct this and don't branch on it; it's + /// here so callers can be added later without a signature change. + #[allow(dead_code)] + Cloud { + user_id: String, + org_id: String, + role: String, + }, +} + +impl Identity { + /// Convenience for the v1 case. + pub fn local() -> Self { + Self::Local + } +} diff --git a/src-tauri/src/remote/manifest.rs b/src-tauri/src/remote/manifest.rs new file mode 100644 index 00000000..59cc983a --- /dev/null +++ b/src-tauri/src/remote/manifest.rs @@ -0,0 +1,247 @@ +//! Manifest file — single source of truth for the daemon's local +//! endpoint and bearer secret. +//! +//! On daemon boot, `serve` writes `/manifest.json` +//! containing the endpoint URL it bound, a freshly generated 32-byte +//! bearer secret, its pid, and the host id. Every other piece of code +//! (the MCP subcommand, the desktop's eventual `--host` SSH tunnel, +//! `serve status`) reads this file to find out where the daemon is +//! and what secret to present. +//! +//! File mode is `0600` and the containing directory is `0700` — +//! same-user filesystem trust, matching `gh`/`ssh-agent`/`docker` +//! conventions. On a single-user VPS this is the right level. On +//! multi-tenant hosts the user is expected to put `` on +//! a per-user-mode mount. + +use std::path::Path; + +use rand::RngCore; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + /// e.g. `http://127.0.0.1:54231` + pub endpoint: String, + /// 32-byte bearer secret encoded as URL-safe base64 (no padding). + /// Every HTTP request to the daemon must carry this as + /// `Authorization: Bearer `. + pub secret: String, + /// Daemon PID. `serve status` uses this to check liveness. + pub pid: u32, + /// RFC 3339 UTC timestamp of when the daemon booted. + pub started_at: String, + /// Stable host identifier (currently `hostname()`). + pub host_id: String, + /// Owner identity if known. Always `None` in v1 — populated when + /// a future cloud relay forwards an authenticated user identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_id: Option, +} + +impl Manifest { + pub fn new(endpoint: String, host_id: String) -> Self { + Self { + endpoint, + secret: generate_secret(), + pid: std::process::id(), + started_at: chrono::Utc::now().to_rfc3339(), + host_id, + owner_id: None, + } + } +} + +/// Write the manifest atomically to disk with mode 0600 and ensure +/// the parent directory is mode 0700. Replaces any existing file at +/// that path. +pub fn write(path: &Path, manifest: &Manifest) -> Result<(), String> { + let parent = path.parent().ok_or_else(|| { + "manifest path has no parent directory".to_string() + })?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("create state dir: {e}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Best-effort chmod 0700 on the state dir. + let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); + } + + let payload = serde_json::to_vec_pretty(manifest) + .map_err(|e| format!("serialise manifest: {e}"))?; + + // Atomic-replace: write to a sibling tempfile, then rename. + let tmp_path = path.with_extension("json.tmp"); + { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&tmp_path) + .map_err(|e| format!("open manifest tmpfile: {e}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("chmod manifest tmpfile: {e}"))?; + } + + file.write_all(&payload) + .map_err(|e| format!("write manifest: {e}"))?; + file.sync_all() + .map_err(|e| format!("fsync manifest: {e}"))?; + } + + std::fs::rename(&tmp_path, path) + .map_err(|e| format!("rename manifest into place: {e}"))?; + Ok(()) +} + +/// Read the manifest from disk. Returns `Ok(None)` if the file is +/// absent. Returns `Err` only when the file exists but is unreadable +/// or malformed — callers can distinguish "no daemon running" from +/// "daemon directory corrupted." +pub fn read(path: &Path) -> Result, String> { + match std::fs::read(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("read manifest: {e}")), + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|e| format!("parse manifest: {e}")), + } +} + +/// Remove the manifest if present. Used on clean shutdown so a stale +/// manifest doesn't outlive the daemon. +pub fn remove(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("remove manifest: {e}")), + } +} + +/// Check whether a given pid is alive on this host. Used to decide +/// whether an existing manifest belongs to a still-running daemon +/// (singleton check) or is just leftover from a crash. +pub fn pid_alive(pid: u32) -> bool { + #[cfg(unix)] + { + // `kill -0` returns 0 if the process exists and we can signal + // it. ESRCH (no such process) is the negative case we care + // about. EPERM (exists but we can't signal) still means alive. + let pid = pid as libc::pid_t; + // SAFETY: kill(pid, 0) is a syscall with no memory side + // effects; only the return value matters. + let rc = unsafe { libc::kill(pid, 0) }; + if rc == 0 { + return true; + } + let err = std::io::Error::last_os_error(); + // EPERM means "exists, can't signal" — still alive. + err.raw_os_error() == Some(libc::EPERM) + } + #[cfg(not(unix))] + { + let _ = pid; + false + } +} + +fn generate_secret() -> String { + let mut buf = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut buf); + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf) +} + +fn _host_id_placeholder() -> String { + hostname::get() + .ok() + .and_then(|h| h.into_string().ok()) + .unwrap_or_else(|| "unknown-host".into()) +} + +pub fn current_host_id() -> String { + _host_id_placeholder() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn write_and_read_roundtrip() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("manifest.json"); + let manifest = Manifest::new("http://127.0.0.1:1234".into(), "test-host".into()); + write(&path, &manifest).unwrap(); + + let loaded = read(&path).unwrap().unwrap(); + assert_eq!(loaded.endpoint, manifest.endpoint); + assert_eq!(loaded.secret, manifest.secret); + assert_eq!(loaded.host_id, manifest.host_id); + assert_eq!(loaded.owner_id, None); + } + + #[test] + fn read_missing_returns_none() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nope.json"); + assert!(read(&path).unwrap().is_none()); + } + + #[test] + fn remove_idempotent() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("manifest.json"); + // Removing a non-existent manifest is fine. + remove(&path).unwrap(); + // After writing, remove works. + let manifest = Manifest::new("http://127.0.0.1:1".into(), "h".into()); + write(&path, &manifest).unwrap(); + assert!(path.exists()); + remove(&path).unwrap(); + assert!(!path.exists()); + } + + #[test] + fn manifest_file_mode_is_0600() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let path = dir.path().join("manifest.json"); + let m = Manifest::new("http://127.0.0.1:1".into(), "h".into()); + write(&path, &m).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "manifest must be 0600, got {mode:o}"); + } + + #[test] + fn secrets_are_unique_across_writes() { + let m1 = Manifest::new("a".into(), "h".into()); + let m2 = Manifest::new("a".into(), "h".into()); + assert_ne!(m1.secret, m2.secret, "secrets must not collide"); + assert_eq!(m1.secret.len(), 43, "32 bytes URL-safe base64 unpadded is 43 chars"); + } + + #[test] + fn pid_alive_for_self() { + let me = std::process::id(); + assert!(pid_alive(me), "our own pid must be alive"); + } + + #[test] + fn pid_alive_for_bogus_pid_is_false() { + // PID 0 is the kernel scheduler on Linux; kill(0, 0) returns + // success but means "all processes in our pgroup," which isn't + // what we want to test. Pick a value that's almost certainly + // not in use. + let very_high_pid = i32::MAX as u32 - 1; + assert!(!pid_alive(very_high_pid)); + } +} diff --git a/src-tauri/src/remote/mcp.rs b/src-tauri/src/remote/mcp.rs new file mode 100644 index 00000000..a740fa66 --- /dev/null +++ b/src-tauri/src/remote/mcp.rs @@ -0,0 +1,248 @@ +//! `codemux-remote mcp` — stdio MCP server. +//! +//! Reads JSON-RPC 2.0 requests on stdin, writes responses on stdout. +//! The MCP protocol surface implemented here is the minimum the +//! Claude Code / Codex / Gemini CLIs need to discover and call our +//! tools: +//! +//! - `initialize` → returns server info + capabilities. +//! - `notifications/initialized` → no-op acknowledgement. +//! - `tools/list` → forwards to the daemon's `/tools/list`. +//! - `tools/call` → forwards to the daemon's `/tools/call`, wraps +//! the response in MCP's `content: [{type:"text"}]` +//! shape that all CLI agents expect. +//! - `ping` → returns empty result. Some clients sanity-check this. +//! +//! Everything else returns `-32601 method not found`. That's enough +//! for the CLI agents we care about; extending to resources/prompts +//! later is purely additive. +//! +//! The server reads the local manifest on boot to find the daemon's +//! endpoint + secret. If the daemon isn't running, we exit with a +//! clean error so the agent gets a meaningful message instead of a +//! cryptic stdio close. + +use std::io::{BufRead, BufReader, Write}; + +use serde_json::{json, Value}; + +use super::manifest; + +const PROTOCOL_VERSION: &str = "2024-11-05"; +const SERVER_NAME: &str = "codemux-remote"; + +pub fn run_stdio(state_dir: std::path::PathBuf) -> Result<(), String> { + let manifest_path = super::config::manifest_path(&state_dir); + let manifest = manifest::read(&manifest_path) + .map_err(|e| format!("read manifest at {}: {e}", manifest_path.display()))? + .ok_or_else(|| { + format!( + "no daemon manifest at {}. \ + Start the daemon first with: codemux-remote serve", + manifest_path.display() + ) + })?; + + if !manifest::pid_alive(manifest.pid) { + return Err(format!( + "manifest at {} points to pid {} which is not running. \ + Start the daemon with: codemux-remote serve", + manifest_path.display(), + manifest.pid + )); + } + + // Blocking HTTP client — MCP stdio is request/response over a + // single client, so the async runtime would be wasted ceremony + // here. reqwest::blocking is already in deps. + let http = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("build http client: {e}"))?; + + let mut stdin = BufReader::new(std::io::stdin().lock()); + let stdout = std::io::stdout(); + let mut stdout_locked = stdout.lock(); + + let mut line = String::new(); + loop { + line.clear(); + let n = match stdin.read_line(&mut line) { + Ok(0) => return Ok(()), // EOF — client disconnected, normal exit + Ok(n) => n, + Err(e) => return Err(format!("stdin read: {e}")), + }; + if line.trim().is_empty() { + continue; + } + let _ = n; // silence + + let request: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + // We can't reply with an id we don't know; per JSON-RPC + // 2.0, send a Parse error with null id. + write_response( + &mut stdout_locked, + error_response(Value::Null, -32700, &format!("parse error: {e}")), + )?; + continue; + } + }; + let response = handle_request(&request, &http, &manifest); + // Notifications (no id) get no response. + if let Some(resp) = response { + write_response(&mut stdout_locked, resp)?; + } + } +} + +/// Dispatch a single JSON-RPC request. Returns `None` for +/// notifications (no id), `Some(response)` otherwise. +fn handle_request( + request: &Value, + http: &reqwest::blocking::Client, + manifest: &manifest::Manifest, +) -> Option { + let id = request.get("id").cloned(); + let method = request.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let params = request.get("params").cloned().unwrap_or(Value::Null); + + // Notifications don't have an id; their handlers can't return + // anything. We still pattern-match them so unknown notifications + // don't bubble up as errors. + let is_notification = id.is_none(); + + let result_or_err: Result = match method { + "initialize" => Ok(initialize_result()), + "ping" => Ok(json!({})), + "tools/list" => forward_list_tools(http, manifest), + "tools/call" => forward_call_tool(¶ms, http, manifest), + "notifications/initialized" | "notifications/cancelled" => { + if is_notification { + return None; + } + Ok(json!({})) + } + other => Err((-32601, format!("method not found: {other}"))), + }; + + if is_notification { + return None; + } + + Some(match result_or_err { + Ok(value) => json!({ "jsonrpc": "2.0", "id": id, "result": value }), + Err((code, message)) => error_response(id.unwrap_or(Value::Null), code, &message), + }) +} + +fn initialize_result() -> Value { + json!({ + "protocolVersion": PROTOCOL_VERSION, + "serverInfo": { + "name": SERVER_NAME, + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": { + "tools": { "listChanged": false } + } + }) +} + +fn forward_list_tools( + http: &reqwest::blocking::Client, + manifest: &manifest::Manifest, +) -> Result { + let url = format!("{}/tools/list", manifest.endpoint); + let res = http + .get(&url) + .bearer_auth(&manifest.secret) + .send() + .map_err(|e| (-32000, format!("daemon /tools/list: {e}")))?; + if !res.status().is_success() { + return Err(( + -32000, + format!("daemon /tools/list returned HTTP {}", res.status()), + )); + } + let body: Value = res.json().map_err(|e| (-32000, format!("decode tools/list: {e}")))?; + // Translate the catalog into MCP's expected shape: a top-level + // "tools" array of { name, description, inputSchema }. + let tools = body + .get("tools") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let mapped: Vec = tools + .into_iter() + .map(|t| { + json!({ + "name": t.get("name").cloned().unwrap_or(Value::Null), + "description": t.get("description").cloned().unwrap_or(Value::Null), + "inputSchema": t.get("input_schema").cloned().unwrap_or(json!({})), + }) + }) + .collect(); + Ok(json!({ "tools": mapped })) +} + +fn forward_call_tool( + params: &Value, + http: &reqwest::blocking::Client, + manifest: &manifest::Manifest, +) -> Result { + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or((-32602, "missing tools/call.name".to_string()))?; + let arguments = params.get("arguments").cloned().unwrap_or(json!({})); + + let url = format!("{}/tools/call", manifest.endpoint); + let res = http + .post(&url) + .bearer_auth(&manifest.secret) + .json(&json!({ "name": name, "arguments": arguments })) + .send() + .map_err(|e| (-32000, format!("daemon /tools/call: {e}")))?; + let status = res.status(); + let body: Value = res + .json() + .map_err(|e| (-32000, format!("decode tools/call: {e}")))?; + + // Map back into MCP's response shape: + // { content: [{type:"text", text:""}], isError: bool } + // Agents render the text. Including the structured payload as + // JSON inside the text field keeps the door open for richer + // content (resources, images) without breaking the wire format. + let is_error = !status.is_success() || body.get("ok") == Some(&json!(false)); + let payload = if is_error { + body.get("error") + .cloned() + .unwrap_or_else(|| json!({ "kind": "internal", "message": "daemon error" })) + } else { + body.get("data").cloned().unwrap_or(json!({})) + }; + let text = serde_json::to_string_pretty(&payload) + .unwrap_or_else(|_| payload.to_string()); + + Ok(json!({ + "content": [{ "type": "text", "text": text }], + "isError": is_error, + })) +} + +fn error_response(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message } + }) +} + +fn write_response(w: &mut W, response: Value) -> Result<(), String> { + let line = serde_json::to_string(&response) + .map_err(|e| format!("serialise response: {e}"))?; + writeln!(w, "{line}").map_err(|e| format!("stdout write: {e}"))?; + w.flush().map_err(|e| format!("stdout flush: {e}")) +} diff --git a/src-tauri/src/remote/mod.rs b/src-tauri/src/remote/mod.rs new file mode 100644 index 00000000..483591ea --- /dev/null +++ b/src-tauri/src/remote/mod.rs @@ -0,0 +1,43 @@ +//! Headless Codemux for `codemux-remote serve`. +//! +//! Self-contained subsystem that runs on a remote host (VPS, home +//! server, anywhere `codemux-remote` is installed) and exposes the +//! same shape of MCP tool surface the desktop's `codemux mcp` does, +//! so an agent on that host can drive Codemux locally — create +//! workspaces, list them, write to terminals — without any +//! Tauri/UI dependency. +//! +//! Design constraints baked in for a future optional cloud relay +//! (paid tier, not on the v1 roadmap; see docs/plans/mcp-on-remote.md): +//! +//! 1. **HTTP transport, not Unix-socket JSON-lines.** A future relay +//! can forward HTTP through a WebSocket tunnel without the daemon +//! knowing. Same dispatcher whether the caller is local, SSH-tunnelled +//! desktop, or cloud-routed. +//! 2. **`Identity` argument on every handler.** Today every request +//! tags as `Identity::Local` (any caller with the manifest secret). +//! Tomorrow a relay verifies a JWT, attaches `Identity::Cloud { … }`, +//! handler signatures don't change. +//! 3. **Bearer-token auth via manifest.json.** 32-byte secret in the +//! `/manifest.json`, mode 0600. Loopback HTTP only by +//! default; SSH tunnels are the path for remote access in v1. +//! 4. **No Better-Auth / VPS-side coupling.** The daemon trusts its +//! local secret, full stop. A future relay would be a separate +//! binary on the VPS that talks to Better Auth and forwards a +//! trusted identity header to the daemon. +//! +//! Unix-only — codemux-remote itself is Unix-only (the existing +//! pty_daemon::server uses `tokio::net::UnixListener`). The serve +//! mode reuses that constraint. + +#![cfg(unix)] + +pub mod auth; +pub mod config; +pub mod identity; +pub mod manifest; +pub mod mcp; +pub mod pty; +pub mod server; +pub mod tools; +pub mod workspace; diff --git a/src-tauri/src/remote/pty.rs b/src-tauri/src/remote/pty.rs new file mode 100644 index 00000000..fbcda4d5 --- /dev/null +++ b/src-tauri/src/remote/pty.rs @@ -0,0 +1,344 @@ +//! Minimal PTY manager for the headless daemon. +//! +//! Tools that an agent calls (`terminal_write`, `terminal_read`) +//! map to "write to this workspace's shell PTY," "drain the last N +//! bytes the shell produced." This is intentionally narrower than +//! the desktop's terminal subsystem (which manages tabs, focus, +//! resize, scrollback, agent-process supervision, scroll bookmarks, +//! etc.). The headless daemon only needs: one PTY per terminal id, +//! interactive bytes in/out, lifecycle. +//! +//! Implementation uses `portable-pty` directly — already a project +//! dependency — and keeps the reader running on a background OS +//! thread that appends to a per-terminal ring buffer protected by +//! a mutex. The agent polls `terminal_read` to get accumulated +//! output; there is no streaming push path in v1 (it's not needed +//! for stdio MCP tools and would just complicate the surface area). + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use serde::Serialize; +use uuid::Uuid; + +/// One spawned PTY shell. +struct PtySlot { + /// Master fd, kept alive so writes succeed and child doesn't + /// get SIGHUP'd when we drop our handle. Unused after being + /// stored but the Drop side-effect (closing the master fd) is + /// what triggers PTY teardown when the slot is removed, so the + /// field is load-bearing despite being read-flagged dead. + #[allow(dead_code)] + master: Box, + /// Writer half, separated because portable-pty's reader and + /// writer come off the master via separate calls. + writer: Box, + /// Accumulated bytes from the child. Ring-buffered to a cap so + /// long-running shells don't blow memory. + buffer: Arc>, + /// Working directory the shell was launched in. + cwd: String, + /// Command line used to spawn (for diagnostics). + command: String, + /// Child process handle. We hold it so the OS doesn't reap the + /// process behind our back; dropping it kills the shell. + _child: Box, +} + +#[derive(Debug, Serialize, Clone)] +pub struct TerminalInfo { + pub id: String, + pub cwd: String, + pub command: String, +} + +#[derive(Debug)] +pub enum PtyError { + NotFound(String), + Io(String), +} + +impl std::fmt::Display for PtyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound(s) => write!(f, "terminal not found: {s}"), + Self::Io(s) => write!(f, "pty io error: {s}"), + } + } +} + +impl std::error::Error for PtyError {} + +const BUFFER_CAP: usize = 1 * 1024 * 1024; // 1 MiB per terminal — plenty for control plane + +struct RingBuffer { + bytes: Vec, + cap: usize, +} + +impl RingBuffer { + fn new(cap: usize) -> Self { + Self { bytes: Vec::new(), cap } + } + fn push(&mut self, chunk: &[u8]) { + if chunk.len() >= self.cap { + // Single chunk bigger than the cap — keep only the tail. + let start = chunk.len() - self.cap; + self.bytes.clear(); + self.bytes.extend_from_slice(&chunk[start..]); + return; + } + let total = self.bytes.len() + chunk.len(); + if total > self.cap { + let drop_count = total - self.cap; + self.bytes.drain(0..drop_count); + } + self.bytes.extend_from_slice(chunk); + } + fn snapshot(&self) -> Vec { + self.bytes.clone() + } + fn snapshot_tail(&self, max: usize) -> Vec { + if self.bytes.len() <= max { + return self.bytes.clone(); + } + let start = self.bytes.len() - max; + self.bytes[start..].to_vec() + } +} + +/// Top-level manager. Holds all PTYs by id, hands them out to +/// callers, owns the read threads. +pub struct PtyManager { + slots: Mutex>, +} + +impl Default for PtyManager { + fn default() -> Self { + Self::new() + } +} + +impl PtyManager { + pub fn new() -> Self { + Self { slots: Mutex::new(HashMap::new()) } + } + + /// Spawn a new PTY shell at `cwd`. If `command` is `None`, runs + /// `$SHELL` (or `/bin/sh` if unset). Returns the new terminal id. + pub fn spawn(&self, cwd: PathBuf, command: Option) -> Result { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 40, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| PtyError::Io(format!("openpty: {e}")))?; + + let resolved_command = command.clone().unwrap_or_else(|| { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()) + }); + let mut builder = if resolved_command.contains(' ') { + // Naive but adequate for v1: shell-out via sh -c if there + // are spaces (likely arguments). Real shells will quote + // properly. Anyone needing fancier control can pass an + // explicit binary. + let mut b = CommandBuilder::new("/bin/sh"); + b.arg("-c"); + b.arg(&resolved_command); + b + } else { + CommandBuilder::new(&resolved_command) + }; + builder.cwd(&cwd); + // PTYs without a sensible TERM make ncurses-based tools sad. + builder.env("TERM", "xterm-256color"); + + let child = pair + .slave + .spawn_command(builder) + .map_err(|e| PtyError::Io(format!("spawn: {e}")))?; + // Slave fd is no longer needed in this process now that the + // child holds it. Dropping it explicitly avoids fd leaks. + drop(pair.slave); + + let master = pair.master; + let mut reader = master + .try_clone_reader() + .map_err(|e| PtyError::Io(format!("clone reader: {e}")))?; + let writer = master + .take_writer() + .map_err(|e| PtyError::Io(format!("take writer: {e}")))?; + + let buffer = Arc::new(Mutex::new(RingBuffer::new(BUFFER_CAP))); + let buffer_clone = Arc::clone(&buffer); + std::thread::Builder::new() + .name("codemux-remote-pty-reader".into()) + .spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, // EOF — child exited or pty closed + Ok(n) => { + if let Ok(mut rb) = buffer_clone.lock() { + rb.push(&buf[..n]); + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, // pty closed, EIO on Linux when peer dies + } + } + }) + .map_err(|e| PtyError::Io(format!("spawn reader thread: {e}")))?; + + let id = Uuid::new_v4().to_string(); + let info = TerminalInfo { + id: id.clone(), + cwd: cwd.to_string_lossy().into_owned(), + command: resolved_command.clone(), + }; + let slot = PtySlot { + master, + writer, + buffer, + cwd: info.cwd.clone(), + command: info.command.clone(), + _child: child, + }; + self.slots.lock().unwrap().insert(id, slot); + Ok(info) + } + + /// Write bytes to the given PTY's stdin. The bytes are sent as-is; + /// callers wanting a newline-terminated command must include `\n` + /// (or `\r`) themselves. + pub fn write(&self, terminal_id: &str, data: &[u8]) -> Result<(), PtyError> { + let mut slots = self.slots.lock().unwrap(); + let slot = slots + .get_mut(terminal_id) + .ok_or_else(|| PtyError::NotFound(terminal_id.to_string()))?; + slot.writer + .write_all(data) + .map_err(|e| PtyError::Io(format!("pty write: {e}")))?; + slot.writer + .flush() + .map_err(|e| PtyError::Io(format!("pty flush: {e}")))?; + Ok(()) + } + + /// Read everything in the PTY's ring buffer (capped at 1 MiB). + /// `max_bytes`, if `Some`, returns only the tail. + pub fn read(&self, terminal_id: &str, max_bytes: Option) -> Result, PtyError> { + let slots = self.slots.lock().unwrap(); + let slot = slots + .get(terminal_id) + .ok_or_else(|| PtyError::NotFound(terminal_id.to_string()))?; + let buffer = slot.buffer.lock().unwrap(); + Ok(match max_bytes { + Some(n) => buffer.snapshot_tail(n), + None => buffer.snapshot(), + }) + } + + pub fn list(&self) -> Vec { + self.slots + .lock() + .unwrap() + .iter() + .map(|(id, slot)| TerminalInfo { + id: id.clone(), + cwd: slot.cwd.clone(), + command: slot.command.clone(), + }) + .collect() + } + + /// Kill the terminal (drops the child + master fd, which sends + /// SIGHUP to the shell). + pub fn close(&self, terminal_id: &str) -> Result<(), PtyError> { + let mut slots = self.slots.lock().unwrap(); + let slot = slots + .remove(terminal_id) + .ok_or_else(|| PtyError::NotFound(terminal_id.to_string()))?; + drop(slot); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wait_for_output bool>(mgr: &PtyManager, tid: &str, pred: F) -> Vec { + for _ in 0..50 { + let bytes = mgr.read(tid, None).unwrap(); + if pred(&bytes) { + return bytes; + } + std::thread::sleep(std::time::Duration::from_millis(40)); + } + mgr.read(tid, None).unwrap() + } + + #[test] + fn spawn_write_read_roundtrip() { + let mgr = PtyManager::new(); + let info = mgr + .spawn(std::env::temp_dir(), Some("/bin/sh".into())) + .expect("spawn"); + // Write a marker the shell will echo back via printf. + mgr.write(&info.id, b"printf 'HELLO-FROM-PTY\\n'\n").unwrap(); + + let out = wait_for_output(&mgr, &info.id, |bytes| { + String::from_utf8_lossy(bytes).contains("HELLO-FROM-PTY") + }); + assert!( + String::from_utf8_lossy(&out).contains("HELLO-FROM-PTY"), + "expected echo back, got {}", + String::from_utf8_lossy(&out) + ); + mgr.close(&info.id).unwrap(); + } + + #[test] + fn read_unknown_returns_not_found() { + let mgr = PtyManager::new(); + match mgr.read("does-not-exist", None) { + Err(PtyError::NotFound(_)) => {} + other => panic!("expected NotFound, got {other:?}"), + } + } + + #[test] + fn list_shows_spawned_terminals() { + let mgr = PtyManager::new(); + let info = mgr + .spawn(std::env::temp_dir(), Some("/bin/sh".into())) + .unwrap(); + let listed = mgr.list(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, info.id); + mgr.close(&info.id).unwrap(); + } + + #[test] + fn ring_buffer_caps_at_one_mib() { + let mut rb = RingBuffer::new(8); + rb.push(b"abcd"); + rb.push(b"efgh"); + rb.push(b"ijkl"); // total exceeds cap; oldest 4 drop + assert_eq!(&rb.snapshot(), b"efghijkl"); + } + + #[test] + fn ring_buffer_oversized_single_chunk_keeps_tail() { + let mut rb = RingBuffer::new(4); + rb.push(b"abcdefghij"); + assert_eq!(&rb.snapshot(), b"ghij"); + } +} diff --git a/src-tauri/src/remote/server.rs b/src-tauri/src/remote/server.rs new file mode 100644 index 00000000..051ac801 --- /dev/null +++ b/src-tauri/src/remote/server.rs @@ -0,0 +1,321 @@ +//! Axum HTTP server for the headless daemon. +//! +//! Routes: +//! +//! - `GET /health` — unauthenticated liveness probe. +//! - `GET /tools/list` — list of MCP tools the daemon exposes +//! (auth required). Used by `codemux-remote mcp` +//! to populate its tools/list response. +//! - `POST /tools/call` — invoke one tool by name. Body: +//! `{ "name": "...", "arguments": {...} }`. +//! Response: `{ "ok": true, "data": ... }` +//! or `{ "ok": false, "error": {...} }`. +//! +//! All non-`/health` endpoints require `Authorization: Bearer ` +//! matching the secret in the manifest. The middleware +//! (`auth::require_bearer`) attaches an `Identity::Local` extension +//! that the handler then forwards to the tool dispatcher. + +use std::sync::Arc; + +use axum::{ + extract::{Extension, State}, + middleware, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::net::TcpListener; + +use super::auth::require_bearer; +use super::identity::Identity; +use super::pty::PtyManager; +use super::tools; +use super::workspace::WorkspaceStore; + +/// State shared across all request handlers. `Arc` so we can clone +/// cheaply into the axum app. +pub struct DaemonState { + pub secret: String, + pub started_at: String, + pub workspaces: WorkspaceStore, + pub ptys: PtyManager, +} + +pub type SharedState = Arc; + +pub fn router(state: SharedState) -> Router { + // Two separate routers so /health stays unauthenticated; the + // authed routes get the bearer middleware applied uniformly. + let public = Router::new().route("/health", get(health)); + + let authed = Router::new() + .route("/tools/list", get(tools_list)) + .route("/tools/call", post(tools_call)) + .route_layer(middleware::from_fn_with_state( + Arc::clone(&state), + require_bearer, + )); + + public.merge(authed).with_state(state) +} + +/// Bind and serve until the process is killed. Returns the bound +/// address so the caller (the `serve` subcommand) can write it to +/// the manifest after binding succeeded but before we accept any +/// requests. This avoids the race where the manifest is published +/// before the listener is ready. +pub async fn serve(state: SharedState, bind_port: Option) -> Result<(), String> { + let listener = bind_listener(bind_port).await?; + let local_addr = listener + .local_addr() + .map_err(|e| format!("local_addr: {e}"))?; + eprintln!("[codemux-remote] listening on http://{}", local_addr); + let app = router(state); + axum::serve(listener, app) + .await + .map_err(|e| format!("serve: {e}")) +} + +/// Bind a TCP listener on 127.0.0.1. If `port` is `None`, asks the +/// OS for an ephemeral free port (port 0). +pub async fn bind_listener(port: Option) -> Result { + let port = port.unwrap_or(0); + let addr = format!("127.0.0.1:{port}"); + TcpListener::bind(&addr) + .await + .map_err(|e| format!("bind {addr}: {e}")) +} + +async fn health() -> Response { + (axum::http::StatusCode::OK, Json(json!({ "ok": true }))).into_response() +} + +async fn tools_list( + State(_state): State, + Extension(_identity): Extension, +) -> Response { + let catalog = tools::catalog(); + Json(json!({ "tools": catalog })).into_response() +} + +#[derive(Debug, Deserialize)] +struct CallBody { + name: String, + #[serde(default)] + arguments: Value, +} + +async fn tools_call( + State(state): State, + Extension(identity): Extension, + Json(body): Json, +) -> Response { + // Run the dispatcher on a blocking thread so a long-running tool + // (e.g. a slow PTY spawn) doesn't pin the runtime. + let name = body.name.clone(); + let args = body.arguments.clone(); + let started_at = state.started_at.clone(); + let result = tokio::task::spawn_blocking(move || { + tools::dispatch( + &name, + &args, + &identity, + &state.workspaces, + &state.ptys, + &started_at, + ) + }) + .await; + + match result { + Ok(Ok(data)) => Json(json!({ "ok": true, "data": data })).into_response(), + Ok(Err(err)) => ( + axum::http::StatusCode::from_u16(error_status_code(err.kind)).unwrap(), + Json(json!({ + "ok": false, + "error": { "kind": err.kind, "message": err.message } + })), + ) + .into_response(), + Err(join_err) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "ok": false, + "error": { "kind": "internal", "message": format!("dispatch panicked: {join_err}") } + })), + ) + .into_response(), + } +} + +fn error_status_code(kind: &str) -> u16 { + match kind { + "invalid_input" => 400, + "not_found" => 404, + _ => 500, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn build_state(dir: &TempDir, secret: &str) -> SharedState { + let db = super::super::workspace::WorkspaceStore::open( + &dir.path().join("codemux.db"), + "test-host".into(), + dir.path().join("workspaces"), + ) + .unwrap(); + Arc::new(DaemonState { + secret: secret.into(), + started_at: chrono::Utc::now().to_rfc3339(), + workspaces: db, + ptys: PtyManager::new(), + }) + } + + /// Boot the router on a real ephemeral port and return the URL. + /// Tests then use `reqwest::Client` against it. + async fn boot(state: SharedState) -> String { + let listener = bind_listener(None).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = router(state); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn health_is_unauthenticated() { + let dir = TempDir::new().unwrap(); + let state = build_state(&dir, "secret-abc"); + let url = boot(state).await; + + let client = reqwest::Client::new(); + let res = client.get(format!("{url}/health")).send().await.unwrap(); + assert_eq!(res.status(), 200); + let body: Value = res.json().await.unwrap(); + assert_eq!(body, json!({ "ok": true })); + } + + #[tokio::test] + async fn missing_auth_returns_401() { + let dir = TempDir::new().unwrap(); + let state = build_state(&dir, "secret-abc"); + let url = boot(state).await; + let client = reqwest::Client::new(); + let res = client.get(format!("{url}/tools/list")).send().await.unwrap(); + assert_eq!(res.status(), 401); + } + + #[tokio::test] + async fn wrong_auth_returns_401() { + let dir = TempDir::new().unwrap(); + let state = build_state(&dir, "secret-abc"); + let url = boot(state).await; + let client = reqwest::Client::new(); + let res = client + .get(format!("{url}/tools/list")) + .header("Authorization", "Bearer wrong-secret") + .send() + .await + .unwrap(); + assert_eq!(res.status(), 401); + } + + #[tokio::test] + async fn tools_list_returns_catalog() { + let dir = TempDir::new().unwrap(); + let state = build_state(&dir, "secret-abc"); + let url = boot(state).await; + let client = reqwest::Client::new(); + let res = client + .get(format!("{url}/tools/list")) + .header("Authorization", "Bearer secret-abc") + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let body: Value = res.json().await.unwrap(); + let tools = body["tools"].as_array().expect("tools array"); + assert!(tools.len() >= 10, "expected many tools, got {}", tools.len()); + let names: Vec<_> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); + assert!(names.contains(&"workspace_create")); + assert!(names.contains(&"terminal_write")); + assert!(names.contains(&"app_status")); + } + + #[tokio::test] + async fn workspace_create_list_roundtrip_over_http() { + let dir = TempDir::new().unwrap(); + let state = build_state(&dir, "tok"); + let url = boot(state).await; + let client = reqwest::Client::new(); + + let create = client + .post(format!("{url}/tools/call")) + .header("Authorization", "Bearer tok") + .json(&json!({ + "name": "workspace_create", + "arguments": { "path": "/tmp/my-repo", "name": "demo" } + })) + .send() + .await + .unwrap(); + assert_eq!(create.status(), 200); + let create_body: Value = create.json().await.unwrap(); + assert_eq!(create_body["ok"], json!(true)); + let id = create_body["data"]["workspace"]["id"] + .as_str() + .unwrap() + .to_string(); + + let list = client + .post(format!("{url}/tools/call")) + .header("Authorization", "Bearer tok") + .json(&json!({ "name": "workspace_list", "arguments": {} })) + .send() + .await + .unwrap(); + let list_body: Value = list.json().await.unwrap(); + let listed = list_body["data"]["workspaces"].as_array().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0]["id"], json!(id)); + } + + #[tokio::test] + async fn invalid_input_returns_400() { + let dir = TempDir::new().unwrap(); + let state = build_state(&dir, "tok"); + let url = boot(state).await; + let res = reqwest::Client::new() + .post(format!("{url}/tools/call")) + .header("Authorization", "Bearer tok") + .json(&json!({ "name": "workspace_create", "arguments": { "name": "x" } })) // missing path + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); + } + + #[tokio::test] + async fn unknown_tool_returns_404() { + let dir = TempDir::new().unwrap(); + let state = build_state(&dir, "tok"); + let url = boot(state).await; + let res = reqwest::Client::new() + .post(format!("{url}/tools/call")) + .header("Authorization", "Bearer tok") + .json(&json!({ "name": "no_such_tool", "arguments": {} })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + } +} diff --git a/src-tauri/src/remote/tools/mod.rs b/src-tauri/src/remote/tools/mod.rs new file mode 100644 index 00000000..b90c1288 --- /dev/null +++ b/src-tauri/src/remote/tools/mod.rs @@ -0,0 +1,364 @@ +//! Headless MCP tool implementations. +//! +//! Each tool is a thin function taking the dispatcher's shared +//! state, an [`Identity`], and tool-specific params; returning a +//! `serde_json::Value` payload. Handlers don't branch on `Identity` +//! in v1 — see `identity.rs` for why the argument exists anyway. +//! +//! The tool surface is deliberately narrower than the desktop's +//! `mcp_server.rs` (which advertises 50+ tools). On a headless +//! host there are no panes, no browser, no system tray. The set +//! below covers the headline use case: an agent on the remote can +//! create workspaces, list them, write to and read from shells, +//! and inspect state. +//! +//! New tools should be added here and registered in [`Catalog`] and +//! the dispatch table in `server.rs`. Tests in `tests/remote_e2e.rs` +//! exercise the dispatcher end-to-end so a forgotten registration +//! is caught at CI time. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use super::identity::Identity; +use super::pty::PtyManager; +use super::workspace::{Workspace, WorkspaceStore}; + +/// Static metadata for every tool the daemon exposes. The MCP +/// `tools/list` JSON-RPC response is built directly from this slice. +#[derive(Debug, Serialize, Clone)] +pub struct ToolSpec { + pub name: &'static str, + pub description: &'static str, + /// JSON Schema as a `serde_json::Value` so we can hand it to + /// the MCP client as-is. + pub input_schema: Value, +} + +pub fn catalog() -> Vec { + vec![ + ToolSpec { + name: "workspace_create", + description: "Create a new workspace on this host. Records it in the daemon's registry. Returns the new workspace's id and metadata. v1 does not materialise a worktree on disk — pass `path` to an existing directory you've prepared (or any path you want recorded).", + input_schema: json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Human-readable label. Defaults to basename of path." }, + "path": { "type": "string", "description": "Absolute path to the working directory." }, + "branch": { "type": "string", "description": "Git branch (optional)." }, + "project_root": { "type": "string", "description": "Originating repo root if this is a worktree (optional)." } + }, + "required": ["path"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "workspace_list", + description: "List every workspace registered with this daemon, newest first.", + input_schema: json!({ "type": "object", "properties": {}, "additionalProperties": false }), + }, + ToolSpec { + name: "workspace_info", + description: "Get full metadata for a single workspace by id.", + input_schema: json!({ + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "workspace_update", + description: "Update mutable fields on a workspace (name, branch, notes). Other fields stay as-is.", + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "branch": { "type": "string" }, + "notes": { "type": "string" } + }, + "required": ["id"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "workspace_close", + description: "Remove a workspace from the daemon's registry. Does not delete the worktree files on disk — that's the caller's job.", + input_schema: json!({ + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "terminal_spawn", + description: "Spawn a new shell PTY in a given working directory. Returns the terminal id used by terminal_write/terminal_read.", + input_schema: json!({ + "type": "object", + "properties": { + "cwd": { "type": "string", "description": "Working directory for the shell." }, + "command": { "type": "string", "description": "Override $SHELL (optional)." } + }, + "required": ["cwd"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "terminal_write", + description: "Write bytes to a terminal's stdin. Include `\\n` for newline; the daemon does not append one.", + input_schema: json!({ + "type": "object", + "properties": { + "terminal_id": { "type": "string" }, + "data": { "type": "string", "description": "UTF-8 bytes to send." } + }, + "required": ["terminal_id", "data"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "terminal_read", + description: "Read accumulated output from a terminal's PTY buffer. Returns up to 1 MiB. Use max_bytes to cap to a tail.", + input_schema: json!({ + "type": "object", + "properties": { + "terminal_id": { "type": "string" }, + "max_bytes": { "type": "integer", "minimum": 1 } + }, + "required": ["terminal_id"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "terminal_list", + description: "List every PTY the daemon currently owns.", + input_schema: json!({ "type": "object", "properties": {}, "additionalProperties": false }), + }, + ToolSpec { + name: "terminal_close", + description: "Kill the terminal (SIGHUP to the shell, drop PTY).", + input_schema: json!({ + "type": "object", + "properties": { "terminal_id": { "type": "string" } }, + "required": ["terminal_id"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "app_status", + description: "Daemon status: version, host id, uptime, workspace count, terminal count.", + input_schema: json!({ "type": "object", "properties": {}, "additionalProperties": false }), + }, + ] +} + +#[derive(Debug, Serialize)] +pub struct ToolError { + pub kind: &'static str, + pub message: String, +} + +impl ToolError { + pub fn invalid(msg: impl Into) -> Self { + Self { kind: "invalid_input", message: msg.into() } + } + pub fn not_found(msg: impl Into) -> Self { + Self { kind: "not_found", message: msg.into() } + } + pub fn internal(msg: impl Into) -> Self { + Self { kind: "internal", message: msg.into() } + } +} + +pub type ToolResult = Result; + +/// Dispatch a single tool call against the shared state. Called by +/// both the HTTP server (each POST /tools/call) and the integration +/// tests (against the same state, bypassing the network). +pub fn dispatch( + name: &str, + params: &Value, + _identity: &Identity, // v1: tools don't branch on it; see identity.rs + workspaces: &WorkspaceStore, + ptys: &PtyManager, + started_at: &str, +) -> ToolResult { + match name { + "workspace_create" => workspace_create(params, workspaces), + "workspace_list" => workspace_list(workspaces), + "workspace_info" => workspace_info(params, workspaces), + "workspace_update" => workspace_update(params, workspaces), + "workspace_close" => workspace_close(params, workspaces), + "terminal_spawn" => terminal_spawn(params, ptys), + "terminal_write" => terminal_write(params, ptys), + "terminal_read" => terminal_read(params, ptys), + "terminal_list" => terminal_list(ptys), + "terminal_close" => terminal_close(params, ptys), + "app_status" => app_status(workspaces, ptys, started_at), + other => Err(ToolError::not_found(format!("unknown tool: {other}"))), + } +} + +#[derive(Debug, Deserialize)] +struct CreateInput { + name: Option, + path: String, + branch: Option, + project_root: Option, +} + +fn workspace_create(params: &Value, store: &WorkspaceStore) -> ToolResult { + let input: CreateInput = + serde_json::from_value(params.clone()).map_err(|e| ToolError::invalid(e.to_string()))?; + let ws = store + .create(input.name, input.path, input.branch, input.project_root) + .map_err(workspace_err)?; + Ok(json!({ "workspace": ws })) +} + +fn workspace_list(store: &WorkspaceStore) -> ToolResult { + let list = store.list().map_err(workspace_err)?; + Ok(json!({ "workspaces": list })) +} + +#[derive(Debug, Deserialize)] +struct IdInput { + id: String, +} + +fn workspace_info(params: &Value, store: &WorkspaceStore) -> ToolResult { + let input: IdInput = + serde_json::from_value(params.clone()).map_err(|e| ToolError::invalid(e.to_string()))?; + let ws = store.get(&input.id).map_err(workspace_err)?; + Ok(json!({ "workspace": ws })) +} + +#[derive(Debug, Deserialize)] +struct UpdateInput { + id: String, + name: Option, + branch: Option, + notes: Option, +} + +fn workspace_update(params: &Value, store: &WorkspaceStore) -> ToolResult { + let input: UpdateInput = + serde_json::from_value(params.clone()).map_err(|e| ToolError::invalid(e.to_string()))?; + let ws = store + .update(&input.id, input.name, input.branch, input.notes) + .map_err(workspace_err)?; + Ok(json!({ "workspace": ws })) +} + +fn workspace_close(params: &Value, store: &WorkspaceStore) -> ToolResult { + let input: IdInput = + serde_json::from_value(params.clone()).map_err(|e| ToolError::invalid(e.to_string()))?; + store.close(&input.id).map_err(workspace_err)?; + Ok(json!({ "closed": input.id })) +} + +#[derive(Debug, Deserialize)] +struct SpawnInput { + cwd: String, + command: Option, +} + +fn terminal_spawn(params: &Value, ptys: &PtyManager) -> ToolResult { + let input: SpawnInput = + serde_json::from_value(params.clone()).map_err(|e| ToolError::invalid(e.to_string()))?; + let info = ptys + .spawn(std::path::PathBuf::from(input.cwd), input.command) + .map_err(pty_err)?; + Ok(json!({ "terminal": info })) +} + +#[derive(Debug, Deserialize)] +struct WriteInput { + terminal_id: String, + data: String, +} + +fn terminal_write(params: &Value, ptys: &PtyManager) -> ToolResult { + let input: WriteInput = + serde_json::from_value(params.clone()).map_err(|e| ToolError::invalid(e.to_string()))?; + ptys.write(&input.terminal_id, input.data.as_bytes()) + .map_err(pty_err)?; + Ok(json!({ "written": input.data.len() })) +} + +#[derive(Debug, Deserialize)] +struct ReadInput { + terminal_id: String, + max_bytes: Option, +} + +fn terminal_read(params: &Value, ptys: &PtyManager) -> ToolResult { + let input: ReadInput = + serde_json::from_value(params.clone()).map_err(|e| ToolError::invalid(e.to_string()))?; + let bytes = ptys + .read(&input.terminal_id, input.max_bytes) + .map_err(pty_err)?; + // Lossy UTF-8: PTY output is overwhelmingly text + ANSI escapes. + // Anyone needing the raw bytes can base64 them at a higher + // protocol revision. + Ok(json!({ + "data": String::from_utf8_lossy(&bytes).into_owned(), + "byte_count": bytes.len(), + })) +} + +fn terminal_list(ptys: &PtyManager) -> ToolResult { + Ok(json!({ "terminals": ptys.list() })) +} + +#[derive(Debug, Deserialize)] +struct TerminalIdInput { + terminal_id: String, +} + +fn terminal_close(params: &Value, ptys: &PtyManager) -> ToolResult { + let input: TerminalIdInput = + serde_json::from_value(params.clone()).map_err(|e| ToolError::invalid(e.to_string()))?; + ptys.close(&input.terminal_id).map_err(pty_err)?; + Ok(json!({ "closed": input.terminal_id })) +} + +fn app_status( + workspaces: &WorkspaceStore, + ptys: &PtyManager, + started_at: &str, +) -> ToolResult { + let workspace_count = workspaces.list().map_err(workspace_err)?.len(); + Ok(json!({ + "version": env!("CARGO_PKG_VERSION"), + "host_id": workspaces.host_id(), + "started_at": started_at, + "workspace_count": workspace_count, + "terminal_count": ptys.list().len(), + "mode": "headless", + })) +} + +fn workspace_err(e: super::workspace::WorkspaceError) -> ToolError { + use super::workspace::WorkspaceError::*; + match e { + NotFound(s) => ToolError::not_found(s), + Invalid(s) => ToolError::invalid(s), + Db(s) | Io(s) => ToolError::internal(s), + } +} + +fn pty_err(e: super::pty::PtyError) -> ToolError { + use super::pty::PtyError::*; + match e { + NotFound(s) => ToolError::not_found(s), + Io(s) => ToolError::internal(s), + } +} + +#[allow(dead_code)] // Workspace type re-exported only so external callers can name it +pub fn _workspace_typename() -> &'static str { + std::any::type_name::() +} diff --git a/src-tauri/src/remote/workspace.rs b/src-tauri/src/remote/workspace.rs new file mode 100644 index 00000000..7a2f7f57 --- /dev/null +++ b/src-tauri/src/remote/workspace.rs @@ -0,0 +1,437 @@ +//! Workspace registry for the headless daemon. +//! +//! Standalone, intentionally not the desktop's much larger +//! `AppStateStore` — that one is wired to Tauri events and is the +//! right shape for a UI, not for a server. Here we just need: a +//! list of workspaces, where their worktrees live, the agent we'd +//! spawn for them, and a nullable `owner_id` for a future cloud +//! relay to populate. +//! +//! Storage: SQLite at `/codemux.db`, schema applied +//! idempotently on first open. The schema is intentionally narrow +//! and decoupled from the desktop's much wider schema — the desktop +//! will *import* workspaces from this registry over the wire when +//! the user pulls, it does not share the table. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + /// UUID v4. Globally unique across hosts so the desktop can + /// import without collisions. + pub id: String, + /// Human-readable name. Defaults to the basename of `path` when + /// the caller doesn't supply one. + pub name: String, + /// Absolute path to the worktree on this host. + pub path: String, + /// Git branch checked out in the worktree. May be `None` for + /// non-git workspaces. + pub branch: Option, + /// Originating project root if this workspace was created from + /// an existing repo (so we know where it was cloned/worktreed + /// from). May be `None` for blank workspaces. + pub project_root: Option, + /// RFC 3339 UTC timestamp. + pub created_at: String, + /// RFC 3339 UTC timestamp. + pub updated_at: String, + /// Always `None` in v1 — reserved for a future cloud relay + /// integration to record the authenticated user that created + /// the workspace. Nullable column today so populating it later + /// is not a destructive migration. + pub owner_id: Option, + /// Hostname-derived id of the host that created this workspace. + /// Recorded so an imported workspace can show "from " in + /// the desktop UI. + pub origin_host_id: String, + /// Free-form notes. Reserved for the desktop to attach context + /// when it pulls — empty in v1. + pub notes: Option, +} + +/// Connection pool of size 1. SQLite serialises writes anyway, and +/// rusqlite's `Connection` is `!Send` if you don't pull in the +/// `serialized` feature; wrapping in a `Mutex` is the cheap, correct +/// path for our concurrency level (low — we're a control plane, not +/// a query engine). +pub struct WorkspaceStore { + conn: Mutex, + host_id: String, + workspaces_root: PathBuf, +} + +#[derive(Debug)] +pub enum WorkspaceError { + NotFound(String), + Db(String), + Invalid(String), + Io(String), +} + +impl std::fmt::Display for WorkspaceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound(s) => write!(f, "workspace not found: {s}"), + Self::Db(s) => write!(f, "database error: {s}"), + Self::Invalid(s) => write!(f, "invalid input: {s}"), + Self::Io(s) => write!(f, "io error: {s}"), + } + } +} + +impl std::error::Error for WorkspaceError {} + +impl WorkspaceStore { + /// Open or create the SQLite database at `db_path` and apply + /// the schema. `host_id` is recorded on every new workspace as + /// `origin_host_id`. `workspaces_root` is where blank workspaces + /// are materialised when a caller doesn't supply a path. + pub fn open(db_path: &Path, host_id: String, workspaces_root: PathBuf) -> Result { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| WorkspaceError::Io(format!("create db dir: {e}")))?; + } + std::fs::create_dir_all(&workspaces_root) + .map_err(|e| WorkspaceError::Io(format!("create workspaces root: {e}")))?; + + let conn = Connection::open(db_path).map_err(|e| WorkspaceError::Db(e.to_string()))?; + // Reasonable defaults for a single-process daemon. + conn.execute_batch( + " + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + ", + ) + .map_err(|e| WorkspaceError::Db(e.to_string()))?; + + create_schema(&conn)?; + Ok(Self { + conn: Mutex::new(conn), + host_id, + workspaces_root, + }) + } + + pub fn host_id(&self) -> &str { + &self.host_id + } + + pub fn workspaces_root(&self) -> &Path { + &self.workspaces_root + } + + /// Insert a new workspace row. Caller has already decided the + /// path (either an existing dir they want to register, or one + /// the caller materialised on disk). + pub fn create( + &self, + name: Option, + path: String, + branch: Option, + project_root: Option, + ) -> Result { + if path.trim().is_empty() { + return Err(WorkspaceError::Invalid("path is required".into())); + } + let now = chrono::Utc::now().to_rfc3339(); + let id = Uuid::new_v4().to_string(); + let resolved_name = name.unwrap_or_else(|| basename(&path)); + + let ws = Workspace { + id: id.clone(), + name: resolved_name, + path, + branch, + project_root, + created_at: now.clone(), + updated_at: now, + owner_id: None, + origin_host_id: self.host_id.clone(), + notes: None, + }; + + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO workspaces ( + id, name, path, branch, project_root, + created_at, updated_at, owner_id, origin_host_id, notes + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + ws.id, + ws.name, + ws.path, + ws.branch, + ws.project_root, + ws.created_at, + ws.updated_at, + ws.owner_id, + ws.origin_host_id, + ws.notes, + ], + ) + .map_err(|e| WorkspaceError::Db(e.to_string()))?; + Ok(ws) + } + + pub fn get(&self, id: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT id, name, path, branch, project_root, + created_at, updated_at, owner_id, origin_host_id, notes + FROM workspaces WHERE id = ?1", + ) + .map_err(|e| WorkspaceError::Db(e.to_string()))?; + let mut rows = stmt + .query(rusqlite::params![id]) + .map_err(|e| WorkspaceError::Db(e.to_string()))?; + match rows + .next() + .map_err(|e| WorkspaceError::Db(e.to_string()))? + { + Some(row) => row_to_workspace(row), + None => Err(WorkspaceError::NotFound(id.to_string())), + } + } + + pub fn list(&self) -> Result, WorkspaceError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT id, name, path, branch, project_root, + created_at, updated_at, owner_id, origin_host_id, notes + FROM workspaces + ORDER BY created_at DESC", + ) + .map_err(|e| WorkspaceError::Db(e.to_string()))?; + // rusqlite::query_map wants a closure returning Result, not our WorkspaceError. We map rusqlite + // errors to a sentinel here and re-translate after; the + // borrow-checker prevents reaching for our own error directly + // inside the closure because Row<'_> borrows from rusqlite. + let rows = stmt + .query_map([], |row| { + // SQLite's column reads return rusqlite::Error + // directly; row_to_workspace returns WorkspaceError. + // Translate at the boundary. + row_to_workspace(row).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Null, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())), + ) + }) + }) + .map_err(|e| WorkspaceError::Db(e.to_string()))?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(|e| WorkspaceError::Db(e.to_string()))?); + } + Ok(out) + } + + pub fn close(&self, id: &str) -> Result<(), WorkspaceError> { + let conn = self.conn.lock().unwrap(); + let affected = conn + .execute("DELETE FROM workspaces WHERE id = ?1", rusqlite::params![id]) + .map_err(|e| WorkspaceError::Db(e.to_string()))?; + if affected == 0 { + return Err(WorkspaceError::NotFound(id.to_string())); + } + Ok(()) + } + + /// Update mutable fields on a workspace. Only fields supplied are + /// changed; `None` means "leave as-is". `updated_at` is touched. + pub fn update( + &self, + id: &str, + name: Option, + branch: Option, + notes: Option, + ) -> Result { + // Easier than building a dynamic UPDATE: fetch, mutate in + // memory, write back. Single-process daemon, no concurrent + // writers worth worrying about. + let mut ws = self.get(id)?; + if let Some(n) = name { + ws.name = n; + } + if branch.is_some() { + ws.branch = branch; + } + if notes.is_some() { + ws.notes = notes; + } + ws.updated_at = chrono::Utc::now().to_rfc3339(); + + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE workspaces + SET name = ?2, branch = ?3, notes = ?4, updated_at = ?5 + WHERE id = ?1", + rusqlite::params![ws.id, ws.name, ws.branch, ws.notes, ws.updated_at], + ) + .map_err(|e| WorkspaceError::Db(e.to_string()))?; + Ok(ws) + } +} + +fn create_schema(conn: &Connection) -> Result<(), WorkspaceError> { + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL, + branch TEXT, + project_root TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + owner_id TEXT, -- nullable; populated by future cloud relay + origin_host_id TEXT NOT NULL, + notes TEXT + ); + CREATE INDEX IF NOT EXISTS idx_workspaces_origin + ON workspaces (origin_host_id); + CREATE INDEX IF NOT EXISTS idx_workspaces_owner + ON workspaces (owner_id); + ", + ) + .map_err(|e| WorkspaceError::Db(e.to_string()))?; + Ok(()) +} + +fn row_to_workspace(row: &rusqlite::Row<'_>) -> Result { + let map = |i: usize| -> Result, _> { row.get::<_, Option>(i) }; + Ok(Workspace { + id: row.get::<_, String>(0).map_err(|e| WorkspaceError::Db(e.to_string()))?, + name: row.get::<_, String>(1).map_err(|e| WorkspaceError::Db(e.to_string()))?, + path: row.get::<_, String>(2).map_err(|e| WorkspaceError::Db(e.to_string()))?, + branch: map(3).map_err(|e| WorkspaceError::Db(e.to_string()))?, + project_root: map(4).map_err(|e| WorkspaceError::Db(e.to_string()))?, + created_at: row.get::<_, String>(5).map_err(|e| WorkspaceError::Db(e.to_string()))?, + updated_at: row.get::<_, String>(6).map_err(|e| WorkspaceError::Db(e.to_string()))?, + owner_id: map(7).map_err(|e| WorkspaceError::Db(e.to_string()))?, + origin_host_id: row.get::<_, String>(8).map_err(|e| WorkspaceError::Db(e.to_string()))?, + notes: map(9).map_err(|e| WorkspaceError::Db(e.to_string()))?, + }) +} + +fn basename(path: &str) -> String { + PathBuf::from(path) + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "workspace".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn open_store(dir: &TempDir) -> WorkspaceStore { + let db_path = dir.path().join("codemux.db"); + let ws_root = dir.path().join("workspaces"); + WorkspaceStore::open(&db_path, "test-host".into(), ws_root).unwrap() + } + + #[test] + fn create_list_close_roundtrip() { + let dir = TempDir::new().unwrap(); + let store = open_store(&dir); + assert!(store.list().unwrap().is_empty()); + + let ws = store + .create( + Some("foo".into()), + "/tmp/repo".into(), + Some("feat/x".into()), + Some("/tmp/repo-origin".into()), + ) + .unwrap(); + assert_eq!(ws.name, "foo"); + assert_eq!(ws.path, "/tmp/repo"); + assert_eq!(ws.branch.as_deref(), Some("feat/x")); + assert_eq!(ws.origin_host_id, "test-host"); + assert!(ws.owner_id.is_none(), "owner_id is null in v1"); + + let listed = store.list().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, ws.id); + + let got = store.get(&ws.id).unwrap(); + assert_eq!(got.id, ws.id); + + store.close(&ws.id).unwrap(); + assert!(store.list().unwrap().is_empty()); + + match store.get(&ws.id) { + Err(WorkspaceError::NotFound(_)) => {} + other => panic!("expected NotFound, got {other:?}"), + } + } + + #[test] + fn create_defaults_name_from_path_basename() { + let dir = TempDir::new().unwrap(); + let store = open_store(&dir); + let ws = store.create(None, "/tmp/my-cool-repo".into(), None, None).unwrap(); + assert_eq!(ws.name, "my-cool-repo"); + } + + #[test] + fn create_rejects_empty_path() { + let dir = TempDir::new().unwrap(); + let store = open_store(&dir); + let err = store.create(None, " ".into(), None, None).unwrap_err(); + assert!(matches!(err, WorkspaceError::Invalid(_))); + } + + #[test] + fn update_changes_name_and_touches_timestamp() { + let dir = TempDir::new().unwrap(); + let store = open_store(&dir); + let ws = store.create(None, "/tmp/a".into(), None, None).unwrap(); + let old_updated = ws.updated_at.clone(); + std::thread::sleep(std::time::Duration::from_millis(5)); + let updated = store + .update(&ws.id, Some("renamed".into()), None, Some("hello".into())) + .unwrap(); + assert_eq!(updated.name, "renamed"); + assert_eq!(updated.notes.as_deref(), Some("hello")); + assert_ne!(updated.updated_at, old_updated); + } + + #[test] + fn close_missing_returns_not_found() { + let dir = TempDir::new().unwrap(); + let store = open_store(&dir); + match store.close("does-not-exist") { + Err(WorkspaceError::NotFound(_)) => {} + other => panic!("expected NotFound, got {other:?}"), + } + } + + #[test] + fn list_orders_newest_first() { + let dir = TempDir::new().unwrap(); + let store = open_store(&dir); + let a = store.create(Some("a".into()), "/a".into(), None, None).unwrap(); + // SQLite RFC3339 strings sort lexicographically same as + // chronological. Sleep just enough to bump the timestamp. + std::thread::sleep(std::time::Duration::from_millis(15)); + let b = store.create(Some("b".into()), "/b".into(), None, None).unwrap(); + let listed = store.list().unwrap(); + assert_eq!(listed[0].id, b.id); + assert_eq!(listed[1].id, a.id); + } +} diff --git a/src-tauri/src/ssh/bootstrap.rs b/src-tauri/src/ssh/bootstrap.rs index 4c96ed7d..0d095592 100644 --- a/src-tauri/src/ssh/bootstrap.rs +++ b/src-tauri/src/ssh/bootstrap.rs @@ -416,6 +416,168 @@ pub async fn provision_scheduler( .map_err(|e| format!("enabling the scheduler service: {e}")) } +/// Provision the headless `codemux-remote serve` daemon on a host. +/// +/// Installs a systemd **user** unit that runs `codemux-remote serve`, +/// enables lingering so it survives logout and reboot, and starts it. +/// After this returns successfully an MCP-capable agent on the host +/// (configured with `codemux-remote mcp`) can drive Codemux locally +/// without any further setup — that's the auto-magic UX target. +/// +/// Best-effort by contract: callers treat a failure as non-fatal — +/// a host's push-workspace capability does not depend on serve, and +/// not every host runs systemd. +pub async fn provision_serve( + ssh_target: &str, + remote_install_path: &str, + deadline: Duration, +) -> Result<(), String> { + use crate::automations::service; + + // `~` in the install path becomes `%h` so systemd expands it. + // Unit's ExecStart needs an absolute path systemd will accept. + let exec_path = remote_install_path.replacen('~', "%h", 1); + let unit = service::serve_systemd_unit(&exec_path); + ssh_write_file( + ssh_target, + &format!( + "~/.config/systemd/user/{}", + service::SERVE_SYSTEMD_UNIT_NAME + ), + &unit, + deadline, + ) + .await + .map_err(|e| format!("writing serve systemd unit: {e}"))?; + + run_with_timeout( + Command::new("ssh") + .arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("ConnectTimeout=10") + .arg(ssh_target) + .arg(format!( + "loginctl enable-linger \"$USER\" >/dev/null 2>&1; \ + systemctl --user daemon-reload && \ + systemctl --user enable --now {}", + service::SERVE_SYSTEMD_UNIT_NAME + )), + deadline, + ) + .await + .map_err(|e| format!("enabling the serve service: {e}")) +} + +/// Register a workspace in the remote daemon's registry via SSH. +/// +/// Runs `codemux-remote workspace register --path

--name +/// --branch ` on the remote host. That binary, in turn, talks to +/// its own local `serve` daemon over loopback HTTP (no SSH-L tunnel +/// from the desktop). This is much simpler than maintaining an +/// HTTP-over-SSH pipe from the laptop, and the same code path can +/// be called by any future "import this dir as a workspace" UX. +/// +/// Best-effort: if the daemon isn't running on the remote (systemd +/// unit not installed, host hates systemd, …), this returns Err and +/// the caller logs but doesn't fail the push. The workspace is still +/// on disk; the daemon just won't have it in its registry. +pub async fn register_workspace_on_remote( + ssh_target: &str, + remote_install_path: &str, + remote_workspace_path: &str, + name: Option<&str>, + branch: Option<&str>, + project_root: Option<&str>, + deadline: Duration, +) -> Result { + let mut script = format!( + "{bin} workspace register --path {path}", + bin = shell_arg(remote_install_path), + path = shell_arg(remote_workspace_path), + ); + if let Some(n) = name { + script.push_str(&format!(" --name {}", shell_arg(n))); + } + if let Some(b) = branch { + script.push_str(&format!(" --branch {}", shell_arg(b))); + } + if let Some(p) = project_root { + script.push_str(&format!(" --project-root {}", shell_arg(p))); + } + + let out = run_capture_with_timeout( + Command::new("ssh") + .arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("ConnectTimeout=10") + .arg(ssh_target) + .arg(&script), + deadline, + ) + .await + .map_err(|e| format!("ssh workspace register: {e}"))?; + + // The remote prints the workspace JSON (with id, name, branch, …) + // on stdout. Return it verbatim so the caller can parse if it + // wants — for now the desktop just records "registered" and the + // user can list via MCP. + Ok(out.trim().to_string()) +} + +/// Single-quote a string for safe inclusion in a remote shell command. +/// Embedded single quotes are escaped via the standard `'\''` dance. +fn shell_arg(s: &str) -> String { + let escaped = s.replace('\'', "'\\''"); + format!("'{escaped}'") +} + +/// Write a `.mcp.json` in the pushed workspace's directory on the +/// remote so an agent CLI (Claude Code, etc.) launched in that +/// directory auto-discovers Codemux via `codemux-remote mcp`. Mirrors +/// the desktop's per-workspace `.mcp.json` pattern but with the remote +/// command. +/// +/// `workspace_remote_path` is the absolute path on the host where the +/// pushed workspace's worktree lives. `remote_install_path` is the +/// absolute path to the installed `codemux-remote` binary (so the +/// MCP config points at a stable binary path regardless of `$PATH`). +/// +/// Best-effort: a failure to write `.mcp.json` doesn't fail the push +/// — the agent can still be configured manually, and the daemon +/// itself is unaffected. +pub async fn provision_workspace_mcp_config( + ssh_target: &str, + workspace_remote_path: &str, + remote_install_path: &str, + deadline: Duration, +) -> Result<(), String> { + // Build the .mcp.json content. Same shape Claude Code, Codex, and + // friends consume: a top-level "mcpServers" map keyed by server + // name. The "codemux" key here matches the desktop's per-workspace + // entry so users get a consistent tool name whether they're on + // their laptop or on a pushed host. + let exec_path = remote_install_path.replacen('~', "%h", 1); + let content = serde_json::to_string_pretty(&serde_json::json!({ + "mcpServers": { + "codemux": { + "command": exec_path, + "args": ["mcp"] + } + } + })) + .map_err(|e| format!("serialise .mcp.json: {e}"))?; + + // The file lives inside the workspace tree, which has already + // been rsync'd by the push step. We overwrite if it exists so a + // stale entry from a previous Codemux version is refreshed. + let target_path = format!("{}/.mcp.json", workspace_remote_path.trim_end_matches('/')); + ssh_write_file(ssh_target, &target_path, &content, deadline) + .await + .map_err(|e| format!("writing .mcp.json: {e}")) +} + /// `ssh 'umask 077; mkdir -p

; cat > '` with /// `content` streamed over stdin so a secret never appears in an /// argv the host's process list could expose. @@ -513,4 +675,33 @@ mod tests { Some("x86_64-unknown-linux-gnu") ); } + + #[test] + fn shell_arg_wraps_in_single_quotes() { + assert_eq!(shell_arg("hello"), "'hello'"); + assert_eq!(shell_arg("/tmp/path with space"), "'/tmp/path with space'"); + } + + #[test] + fn shell_arg_escapes_embedded_single_quotes() { + // Standard sh quoting dance for embedded single quotes. + // Output looks ugly but is safe in any POSIX shell. + assert_eq!( + shell_arg("it's"), + "'it'\\''s'", + "must escape embedded quotes so the shell can't break out" + ); + } + + #[test] + fn shell_arg_handles_shell_metachars() { + // A pathological remote path including $(, `, &, ;, etc. The + // single-quoted body must contain them as literal bytes. + let nasty = "$(rm -rf /); echo pwned; `evil`"; + let quoted = shell_arg(nasty); + assert!(quoted.starts_with('\'')); + assert!(quoted.ends_with('\'')); + // Inner content is unchanged (no embedded ' to escape). + assert!(quoted.contains(nasty)); + } } diff --git a/src-tauri/src/ssh/push.rs b/src-tauri/src/ssh/push.rs index 950f0bd7..3c23f1ac 100644 --- a/src-tauri/src/ssh/push.rs +++ b/src-tauri/src/ssh/push.rs @@ -220,13 +220,69 @@ pub async fn push_workspace(opts: PushOptions<'_>) -> PushResult { cmd.arg(arg); } let result = run_capture_with_timeout(&mut cmd, opts.step_timeout).await; - match result { + let pushed = match result { Ok(stdout) => PushResult::Pushed { remote_path: opts.remote_path.to_string(), rsync_summary: trim_rsync_output(&stdout), }, - Err(reason) => PushResult::RsyncFailed { reason }, + Err(reason) => return PushResult::RsyncFailed { reason }, + }; + + // Best-effort: drop a `.mcp.json` into the pushed workspace dir so + // an agent CLI (Claude Code, Codex, Gemini, …) launched on the + // remote inside this workspace auto-discovers Codemux via + // `codemux-remote mcp`. This mirrors the desktop's per-workspace + // `.mcp.json` pattern (`mcp_server::upsert_mcp_config`). + // + // A failure here does NOT roll back the push — the user's files + // are already on the remote, and the daemon itself doesn't need + // .mcp.json. The agent on the remote can still be configured by + // hand. We log so the host pane can show a soft warning later. + if let Err(error) = crate::ssh::bootstrap::provision_workspace_mcp_config( + opts.ssh_target, + opts.remote_path, + "~/.local/bin/codemux-remote", + opts.step_timeout, + ) + .await + { + eprintln!( + "[codemux::ssh::push] .mcp.json provisioning failed for {}: {error}", + opts.remote_path + ); + } + + // Best-effort: register the pushed workspace in the remote + // daemon's registry so it shows up in `workspace_list` from any + // MCP-aware agent on the host. If the daemon isn't running yet + // (host hates systemd, bootstrap was skipped, …) this fails + // silently — the user can still register manually via the agent. + // + // Branch + project_root aren't known at this layer; the push UI + // can pass them later by extending PushOptions. v1 just registers + // by path so the workspace at least exists in the registry. + let workspace_name = std::path::Path::new(opts.remote_path) + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()); + if let Err(error) = crate::ssh::bootstrap::register_workspace_on_remote( + opts.ssh_target, + "~/.local/bin/codemux-remote", + opts.remote_path, + workspace_name.as_deref(), + None, + None, + opts.step_timeout, + ) + .await + { + eprintln!( + "[codemux::ssh::push] workspace register failed for {}: {error}", + opts.remote_path + ); } + + pushed } /// Pull the worktree back from the remote host. diff --git a/src-tauri/tests/codemux_remote_serve_mcp.rs b/src-tauri/tests/codemux_remote_serve_mcp.rs new file mode 100644 index 00000000..c82f8406 --- /dev/null +++ b/src-tauri/tests/codemux_remote_serve_mcp.rs @@ -0,0 +1,590 @@ +//! End-to-end integration test for `codemux-remote serve` + `codemux-remote mcp`. +//! +//! This is the headline-feature test for the headless-daemon work +//! tracked in `docs/plans/mcp-on-remote.md`. It exercises the full +//! agent-on-the-remote-controls-Codemux loop, but locally so CI +//! can run it without SSH: +//! +//! 1. Spawn `codemux-remote serve` with an isolated `--state-dir` +//! in a tempdir, on an ephemeral port. +//! 2. Poll the manifest file until the daemon has written it. +//! 3. Hit the daemon's `/health` endpoint to confirm it's live. +//! 4. Spawn `codemux-remote mcp` pointed at the same state-dir. +//! Drive it over stdio with JSON-RPC: `initialize` → `tools/list` +//! → `tools/call workspace_create` → `tools/call workspace_list`. +//! 5. Assert the workspace shows up in the list. +//! 6. Send SIGTERM to the daemon and verify the manifest is gone. +//! +//! Unix-only — `codemux-remote` itself is Unix-only (the PTY daemon +//! and the new serve mode both use Unix-only signals). + +#![cfg(unix)] + +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tempfile::TempDir; + +fn binary_path() -> PathBuf { + if let Ok(path) = std::env::var("CARGO_BIN_EXE_codemux-remote") { + return PathBuf::from(path); + } + PathBuf::from("target/debug/codemux-remote") +} + +/// Spawn `codemux-remote serve` with an isolated state dir and a +/// random ephemeral port. Returns the running child + the state dir +/// + the manifest contents (once they appear). +struct ServeFixture { + child: Child, + state_dir: TempDir, + endpoint: String, + secret: String, +} + +impl ServeFixture { + fn start() -> Self { + let bin = binary_path(); + assert!( + bin.exists(), + "codemux-remote binary not built at {:?}; \ + run `cargo build --bin codemux-remote` first", + bin + ); + let state_dir = TempDir::new().expect("tempdir"); + // Spawn the daemon. Inherit stderr so test failures surface + // the daemon's own diagnostics. Redirect stdout to /dev/null + // because we don't care about it (the daemon logs to stderr). + let mut cmd = Command::new(&bin); + cmd.arg("serve") + .arg("--state-dir") + .arg(state_dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); + let child = cmd.spawn().expect("spawn codemux-remote serve"); + + // Wait for the manifest to appear (the daemon writes it + // *after* bind succeeds). Up to 10s. + let manifest_path = state_dir.path().join("manifest.json"); + let deadline = Instant::now() + Duration::from_secs(10); + let manifest: Value = loop { + if let Ok(bytes) = std::fs::read(&manifest_path) { + if let Ok(v) = serde_json::from_slice::(&bytes) { + break v; + } + } + if Instant::now() > deadline { + panic!("manifest never appeared at {}", manifest_path.display()); + } + std::thread::sleep(Duration::from_millis(100)); + }; + let endpoint = manifest["endpoint"] + .as_str() + .expect("manifest.endpoint") + .to_string(); + let secret = manifest["secret"] + .as_str() + .expect("manifest.secret") + .to_string(); + + // Confirm health probe before declaring readiness. + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(resp) = client.get(format!("{endpoint}/health")).send() { + if resp.status().is_success() { + break; + } + } + if Instant::now() > deadline { + panic!("daemon at {endpoint} never became healthy"); + } + std::thread::sleep(Duration::from_millis(100)); + } + + Self { child, state_dir, endpoint, secret } + } + + fn stop(mut self) { + // Send SIGTERM; serve handles it gracefully and cleans up. + let pid = self.child.id() as i32; + unsafe { + libc::kill(pid, libc::SIGTERM); + } + // Wait up to 5s for the child to exit. + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match self.child.try_wait() { + Ok(Some(_)) => break, + _ if Instant::now() > deadline => { + eprintln!("[test] daemon didn't exit after SIGTERM; killing"); + let _ = self.child.kill(); + break; + } + _ => std::thread::sleep(Duration::from_millis(50)), + } + } + } +} + +#[test] +fn http_health_and_tools_list() { + let fx = ServeFixture::start(); + let client = reqwest::blocking::Client::new(); + + // Health works without auth. + let resp = client + .get(format!("{}/health", fx.endpoint)) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + // Authed: tools/list returns a non-empty catalog. + let resp = client + .get(format!("{}/tools/list", fx.endpoint)) + .bearer_auth(&fx.secret) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().unwrap(); + let names: Vec<&str> = body["tools"] + .as_array() + .unwrap() + .iter() + .filter_map(|t| t["name"].as_str()) + .collect(); + for required in [ + "workspace_create", + "workspace_list", + "workspace_info", + "workspace_close", + "terminal_spawn", + "terminal_write", + "terminal_read", + "app_status", + ] { + assert!(names.contains(&required), "missing tool {required}"); + } + + fx.stop(); +} + +#[test] +fn http_workspace_create_then_list() { + let fx = ServeFixture::start(); + let client = reqwest::blocking::Client::new(); + + // workspace_create + let resp = client + .post(format!("{}/tools/call", fx.endpoint)) + .bearer_auth(&fx.secret) + .json(&json!({ + "name": "workspace_create", + "arguments": { + "path": "/tmp/repo-e2e", + "name": "demo", + "branch": "feat/x" + } + })) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().unwrap(); + assert_eq!(body["ok"], json!(true)); + let id = body["data"]["workspace"]["id"] + .as_str() + .unwrap() + .to_string(); + assert_eq!(body["data"]["workspace"]["name"], json!("demo")); + assert_eq!(body["data"]["workspace"]["branch"], json!("feat/x")); + + // workspace_list contains it + let resp = client + .post(format!("{}/tools/call", fx.endpoint)) + .bearer_auth(&fx.secret) + .json(&json!({ "name": "workspace_list", "arguments": {} })) + .send() + .unwrap(); + let body: Value = resp.json().unwrap(); + let workspaces = body["data"]["workspaces"].as_array().unwrap(); + assert!(workspaces.iter().any(|w| w["id"] == json!(id))); + + fx.stop(); +} + +/// Drive `codemux-remote mcp` over stdio: initialize → tools/list → +/// tools/call workspace_create → tools/call workspace_list. This is +/// the actual code path a CLI agent (Claude Code, Codex) takes. +#[test] +fn mcp_stdio_roundtrip() { + let fx = ServeFixture::start(); + let bin = binary_path(); + + // Stash a copy of the secret + state_dir before we move the + // fixture for stop() later. + let state_dir_path = fx.state_dir.path().to_path_buf(); + + let mut mcp = Command::new(&bin) + .arg("mcp") + .arg("--state-dir") + .arg(&state_dir_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn codemux-remote mcp"); + + let mut stdin = mcp.stdin.take().expect("mcp stdin"); + let stdout = mcp.stdout.take().expect("mcp stdout"); + let mut reader = BufReader::new(stdout); + + // 1) initialize + send(&mut stdin, &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "0.0" } + } + })); + let resp = recv(&mut reader); + assert_eq!(resp["id"], json!(1)); + assert!(resp["result"]["serverInfo"]["name"] + .as_str() + .unwrap() + .contains("codemux-remote")); + + // initialized notification (no response expected) + send(&mut stdin, &json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + })); + + // 2) tools/list + send(&mut stdin, &json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} + })); + let resp = recv(&mut reader); + assert_eq!(resp["id"], json!(2)); + let names: Vec<&str> = resp["result"]["tools"] + .as_array() + .unwrap() + .iter() + .filter_map(|t| t["name"].as_str()) + .collect(); + assert!(names.contains(&"workspace_create")); + assert!(names.contains(&"workspace_list")); + + // 3) tools/call workspace_create + send(&mut stdin, &json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "workspace_create", + "arguments": { "path": "/tmp/from-mcp", "name": "via-mcp" } + } + })); + let resp = recv(&mut reader); + assert_eq!(resp["id"], json!(3)); + assert!( + resp["result"]["isError"].as_bool() == Some(false), + "workspace_create reported error: {resp}" + ); + let text = resp["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("via-mcp"), "expected name in response text: {text}"); + + // 4) tools/call workspace_list — confirm it's there. + send(&mut stdin, &json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { "name": "workspace_list", "arguments": {} } + })); + let resp = recv(&mut reader); + assert_eq!(resp["id"], json!(4)); + let list_text = resp["result"]["content"][0]["text"].as_str().unwrap(); + assert!( + list_text.contains("via-mcp"), + "workspace_list missing the workspace we just created: {list_text}" + ); + + // 5) Close stdin → MCP server exits cleanly on EOF. + drop(stdin); + let _ = mcp.wait(); + + fx.stop(); +} + +#[test] +fn mcp_fails_cleanly_when_daemon_not_running() { + let bin = binary_path(); + let state_dir = TempDir::new().unwrap(); + + let output = Command::new(&bin) + .arg("mcp") + .arg("--state-dir") + .arg(state_dir.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("invoke mcp without daemon"); + assert!(!output.status.success(), "expected non-zero exit"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("no daemon manifest"), + "expected manifest-not-found message, got: {stderr}" + ); +} + +#[test] +fn serve_status_reports_alive() { + let fx = ServeFixture::start(); + let bin = binary_path(); + let out = Command::new(&bin) + .arg("serve") + .arg("status") + .arg("--state-dir") + .arg(fx.state_dir.path()) + .output() + .expect("status"); + assert!(out.status.success(), "status failed"); + let json: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(json["alive"], json!(true)); + assert!(json["endpoint"].as_str().unwrap().starts_with("http://127.0.0.1:")); + fx.stop(); +} + +#[test] +fn serve_status_reports_absent_when_no_manifest() { + let bin = binary_path(); + let state_dir = TempDir::new().unwrap(); + let out = Command::new(&bin) + .arg("serve") + .arg("status") + .arg("--state-dir") + .arg(state_dir.path()) + .output() + .expect("status"); + assert!(!out.status.success(), "expected nonzero exit when no manifest"); +} + +#[test] +fn second_serve_refuses_when_first_is_running() { + let fx = ServeFixture::start(); + let bin = binary_path(); + // Try to start a second instance against the same state-dir. + let out = Command::new(&bin) + .arg("serve") + .arg("--state-dir") + .arg(fx.state_dir.path()) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .output() + .expect("second serve"); + assert!(!out.status.success(), "second serve should refuse to start"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("already running"), + "expected 'already running' message, got: {stderr}" + ); + fx.stop(); +} + +/// `codemux-remote workspace register` should register a workspace +/// in the running daemon's registry. This is what the desktop's push +/// flow runs over SSH right after a successful rsync — the workspace +/// then shows up in `workspace_list` from any MCP-aware agent on the +/// remote, without the agent having to know it exists. +#[test] +fn workspace_register_subcommand_registers_in_daemon() { + let fx = ServeFixture::start(); + let bin = binary_path(); + + // First check the registry is empty. + let client = reqwest::blocking::Client::new(); + let listed = client + .post(format!("{}/tools/call", fx.endpoint)) + .bearer_auth(&fx.secret) + .json(&json!({ "name": "workspace_list", "arguments": {} })) + .send() + .unwrap() + .json::() + .unwrap(); + assert_eq!( + listed["data"]["workspaces"].as_array().unwrap().len(), + 0, + "fresh daemon should have no workspaces" + ); + + // Run `codemux-remote workspace register …` exactly as the push + // flow does over SSH. + let out = Command::new(&bin) + .arg("workspace") + .arg("register") + .arg("--state-dir") + .arg(fx.state_dir.path()) + .arg("--path") + .arg("/tmp/from-push") + .arg("--name") + .arg("from-push-test") + .arg("--branch") + .arg("feat/x") + .stderr(Stdio::inherit()) + .output() + .expect("run workspace register"); + assert!( + out.status.success(), + "workspace register failed: stderr already printed" + ); + let printed: Value = serde_json::from_slice(&out.stdout).expect("workspace JSON on stdout"); + assert_eq!(printed["name"], json!("from-push-test")); + assert_eq!(printed["branch"], json!("feat/x")); + assert_eq!(printed["path"], json!("/tmp/from-push")); + + // Confirm via HTTP. + let listed = client + .post(format!("{}/tools/call", fx.endpoint)) + .bearer_auth(&fx.secret) + .json(&json!({ "name": "workspace_list", "arguments": {} })) + .send() + .unwrap() + .json::() + .unwrap(); + let ws = listed["data"]["workspaces"] + .as_array() + .unwrap() + .iter() + .find(|w| w["name"] == json!("from-push-test")) + .expect("registered workspace must appear in workspace_list"); + assert_eq!(ws["branch"], json!("feat/x")); + + fx.stop(); +} + +/// `workspace register` waits for the daemon to come up if it's +/// briefly absent, then fails with a clear message if it never does. +/// This is the "systemctl --user enable --now codemux-remote just +/// returned, but the daemon hasn't bound its port yet" case the push +/// flow hits in the wild. +#[test] +fn workspace_register_fails_clean_without_daemon() { + let bin = binary_path(); + let state_dir = TempDir::new().unwrap(); + + let out = Command::new(&bin) + .arg("workspace") + .arg("register") + .arg("--state-dir") + .arg(state_dir.path()) + .arg("--path") + .arg("/tmp/anywhere") + .arg("--connect-timeout-secs") + .arg("1") + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .output() + .expect("run workspace register"); + assert!(!out.status.success(), "should fail when no daemon"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("did not become healthy"), + "expected timeout message, got: {stderr}" + ); +} + +/// terminal_spawn → terminal_write → terminal_read end-to-end. The +/// MCP agent's killer use case: drive a shell on the remote. +#[test] +fn terminal_spawn_write_read_via_http() { + let fx = ServeFixture::start(); + let client = reqwest::blocking::Client::new(); + + let resp = client + .post(format!("{}/tools/call", fx.endpoint)) + .bearer_auth(&fx.secret) + .json(&json!({ + "name": "terminal_spawn", + "arguments": { "cwd": "/tmp", "command": "/bin/sh" } + })) + .send() + .unwrap(); + let body: Value = resp.json().unwrap(); + assert_eq!(body["ok"], json!(true), "terminal_spawn failed: {body}"); + let tid = body["data"]["terminal"]["id"].as_str().unwrap().to_string(); + + // Write a marker the shell will echo back. + let resp = client + .post(format!("{}/tools/call", fx.endpoint)) + .bearer_auth(&fx.secret) + .json(&json!({ + "name": "terminal_write", + "arguments": { + "terminal_id": tid, + "data": "printf 'PTY-MARKER-OK\\n'\n" + } + })) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + // Poll terminal_read until the marker appears (or fail after 2s). + let deadline = Instant::now() + Duration::from_secs(2); + let mut found = false; + while Instant::now() < deadline { + let resp = client + .post(format!("{}/tools/call", fx.endpoint)) + .bearer_auth(&fx.secret) + .json(&json!({ + "name": "terminal_read", + "arguments": { "terminal_id": tid } + })) + .send() + .unwrap(); + let body: Value = resp.json().unwrap(); + let text = body["data"]["data"].as_str().unwrap_or(""); + if text.contains("PTY-MARKER-OK") { + found = true; + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + assert!(found, "PTY marker never echoed back"); + + // Close. + let _ = client + .post(format!("{}/tools/call", fx.endpoint)) + .bearer_auth(&fx.secret) + .json(&json!({ + "name": "terminal_close", + "arguments": { "terminal_id": tid } + })) + .send() + .unwrap(); + fx.stop(); +} + +// ─── stdio helpers for the MCP test ────────────────────────────── + +fn send(w: &mut W, value: &Value) { + let line = serde_json::to_string(value).unwrap(); + writeln!(w, "{line}").unwrap(); + w.flush().unwrap(); +} + +fn recv(r: &mut R) -> Value { + let mut line = String::new(); + let read = r.read_line(&mut line).expect("mcp stdout read"); + assert!(read > 0, "mcp stdout closed before response"); + serde_json::from_str(line.trim()) + .unwrap_or_else(|e| panic!("mcp returned non-JSON: {line:?}: {e}")) +} From 4c35458154e11da57551d30a207bc3008e34a19f Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Tue, 26 May 2026 20:37:26 +0200 Subject: [PATCH 2/5] fix(ssh): use ssh-cat pipeline for binary upload (OpenSSH 9+ scp tilde bug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by users running OpenSSH 9.0+ on Arch: clicking Settings → Hosts → Install codemux-remote returned scp: dest open ".local/bin/codemux-remote": Failure scp: failed to upload file /usr/lib/codemux/binaries/codemux-remote-x86_64-unknown-linux-gnu to ~/.local/bin/codemux-remote OpenSSH 9.0 (April 2022) changed scp's default transport from the legacy SCP protocol to SFTP. SFTP doesn't expand `~` server-side the way SCP/the remote shell did, so the destination `~/.local/bin/...` was opened literally and failed. The bug reproduces on any modern Linux distro with OpenSSH 9+ — the user's case happened to be OpenSSH 10.3. Fix: replace the three-step bootstrap (ssh mkdir + scp + ssh chmod) with one `ssh ... 'cat > path.tmp && chmod +x path.tmp && mv -f path.tmp path'` pipeline, with the binary streamed via stdin. * The remote login shell expands `~/` correctly — the SFTP layer is never involved. * Atomic-replace via tmpfile + rename: any process still exec'ing the old binary keeps running on the old inode; the new binary becomes visible at the path the moment rename completes. This is what fixes the "older codemux-remote still sitting in ~/.local/bin from a previous install" case the original reporter hit. * Single SSH round-trip (was three): cheaper on flaky networks. * `umask 077` + `chmod +x` lands the binary at mode 0700. Regression guard: new `ssh_upload_executable_works_against_localhost` test exercises the full code path against a real ssh server (the user's localhost). Skipped when SSH-to-self isn't keyed up; on systems that reproduced the original bug, the OLD code path (scp + ~/) would fail this test too — the new path passes. 7/7 ssh::bootstrap unit tests green; 10/10 codemux-remote e2e integration tests green; no regressions in the broader 1520-test lib suite. --- src-tauri/src/ssh/bootstrap.rs | 230 +++++++++++++++++++++++++-------- 1 file changed, 176 insertions(+), 54 deletions(-) diff --git a/src-tauri/src/ssh/bootstrap.rs b/src-tauri/src/ssh/bootstrap.rs index 0d095592..baea1166 100644 --- a/src-tauri/src/ssh/bootstrap.rs +++ b/src-tauri/src/ssh/bootstrap.rs @@ -215,60 +215,20 @@ pub async fn bootstrap_remote(opts: BootstrapOptions<'_>) -> BootstrapResult { } }; - // Step 1: ensure the remote install dir exists. mkdir -p is - // idempotent so this is safe to re-run. - let mkdir = run_with_timeout( - Command::new("ssh") - .arg("-o") - .arg("BatchMode=yes") - .arg("-o") - .arg("ConnectTimeout=10") - .arg(opts.ssh_target) - .arg(format!( - "mkdir -p \"$(dirname {})\"", - opts.remote_install_path - )), - opts.timeout, - ) - .await; - if let Err(reason) = mkdir { - return BootstrapResult::UploadFailed { - reason: format!("mkdir failed: {reason}"), - }; - } - - // Step 2: scp the binary. - let scp = run_with_timeout( - Command::new("scp") - .arg("-o") - .arg("BatchMode=yes") - .arg("-o") - .arg("ConnectTimeout=10") - .arg(&local_binary) - .arg(format!("{}:{}", opts.ssh_target, opts.remote_install_path)), - opts.timeout, - ) - .await; - if let Err(reason) = scp { - return BootstrapResult::UploadFailed { - reason: format!("scp failed: {reason}"), - }; - } - - // Step 3: chmod +x. - let chmod = run_with_timeout( - Command::new("ssh") - .arg("-o") - .arg("BatchMode=yes") - .arg(opts.ssh_target) - .arg(format!("chmod +x {}", opts.remote_install_path)), - opts.timeout, - ) - .await; - if let Err(reason) = chmod { - return BootstrapResult::UploadFailed { - reason: format!("chmod failed: {reason}"), - }; + // Steps 1–3: mkdir + upload + chmod, all in one `ssh … 'cat > path'` + // pipeline. This used to be three round-trips (ssh mkdir, scp, ssh + // chmod) but `scp` on OpenSSH 9.0+ defaults to the SFTP transport, + // which does **not** expand `~` in destination paths — the server + // tries to open a literal `~/.local/bin/codemux-remote` and fails + // with `dest open ".local/bin/codemux-remote": Failure`. The fix + // is to drive the upload through the remote login shell (which + // expands `~` correctly) and to bundle mkdir/chmod into the same + // shell session, halving SSH connection overhead. + if let Err(reason) = + ssh_upload_executable(opts.ssh_target, opts.remote_install_path, &local_binary, opts.timeout) + .await + { + return BootstrapResult::UploadFailed { reason }; } // Step 4: verify by re-probing the version subcommand. @@ -578,6 +538,89 @@ pub async fn provision_workspace_mcp_config( .map_err(|e| format!("writing .mcp.json: {e}")) } +/// Upload a binary to the remote host, atomically replacing whatever +/// is currently at `remote_path` and marking it `chmod +x`. +/// +/// Uses `ssh … 'cat > .tmp && chmod +x .tmp && mv .tmp '` +/// with the binary streamed via stdin, all in **one** SSH session: +/// +/// 1. The remote login shell expands `~/`, sidestepping the OpenSSH +/// 9.0+ SFTP-default scp bug where `~` is taken literally and the +/// upload fails with `dest open ".local/bin/foo": Failure`. +/// 2. Writing to a sibling `.tmp` path and then `mv`-ing into place +/// makes the swap atomic — if anything is currently exec'ing the +/// old binary, the new one becomes visible at the path the moment +/// rename completes. Old fd-holders keep running on the old inode +/// until they exit. +/// 3. Single SSH round-trip (was three: mkdir, scp, chmod). Cheaper +/// on flaky networks. +/// +/// `remote_path` may contain `~/` — the remote shell expands it. +async fn ssh_upload_executable( + ssh_target: &str, + remote_path: &str, + local_binary: &std::path::Path, + deadline: Duration, +) -> Result<(), String> { + use tokio::io::AsyncWriteExt; + + // Stream the source into ssh stdin. `tokio::fs::read` slurps the + // whole binary into memory — for a ~16 MB codemux-remote that's + // fine; the alternative (copy in chunks) doesn't measurably help. + let bytes = tokio::fs::read(local_binary) + .await + .map_err(|e| format!("read {}: {e}", local_binary.display()))?; + + // One-liner the remote shell runs. Use `umask 077` so the tmpfile + // is 0600 from the moment it lands; `chmod +x` then bumps it to + // 0700 before the rename. `mv -f` overwrites whatever was at the + // destination — important when a stale older binary from a + // previous Codemux install is sitting there. + let script = format!( + "umask 077 && mkdir -p \"$(dirname {p})\" && \ + cat > {p}.tmp && \ + chmod +x {p}.tmp && \ + mv -f {p}.tmp {p}", + p = remote_path + ); + + let mut child = Command::new("ssh") + .arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("ConnectTimeout=10") + .arg(ssh_target) + .arg(&script) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("ssh spawn failed: {e}"))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(&bytes) + .await + .map_err(|e| format!("failed to stream binary: {e}"))?; + // `stdin` drops here, closing the pipe so `cat` sees EOF and + // exits 0, letting the chained `chmod` + `mv` run. + } + + let out = timeout(deadline, child.wait_with_output()) + .await + .map_err(|_| "operation timed out".to_string())? + .map_err(|e| format!("ssh failed: {e}"))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("exit status {}", out.status) + } else { + stderr + }); + } + Ok(()) +} + /// `ssh 'umask 077; mkdir -p ; cat > '` with /// `content` streamed over stdin so a secret never appears in an /// argv the host's process list could expose. @@ -693,6 +736,85 @@ mod tests { ); } + /// End-to-end test that `ssh_upload_executable` works against a + /// real SSH listener — using `ssh localhost` if the user has it + /// keyed up. Skipped when SSH-to-self isn't reachable (BatchMode + + /// no key + no agent → fail fast). This is the *real* regression + /// guard for the OpenSSH 9.0+ SFTP tilde bug: on systems that + /// reproduced the original "dest open '.local/bin/...': Failure" + /// failure, the old code path (scp + ~/) would fail here too. + /// The new code path goes through the remote shell and works. + #[tokio::test] + async fn ssh_upload_executable_works_against_localhost() { + // BatchMode test: are we allowed to ssh to ourselves without + // a password prompt? If not, skip. + let probe = std::process::Command::new("ssh") + .arg("-o").arg("BatchMode=yes") + .arg("-o").arg("ConnectTimeout=3") + .arg("-o").arg("StrictHostKeyChecking=accept-new") + .arg("localhost") + .arg("true") + .output(); + let probe_ok = matches!(probe, Ok(out) if out.status.success()); + if !probe_ok { + eprintln!("[test] skipping: ssh BatchMode=yes localhost not reachable"); + return; + } + + // Source binary: any file Cargo built will do. We just need + // *some* bytes to upload + roundtrip. + let tmp = tempfile::TempDir::new().unwrap(); + let src = tmp.path().join("payload.bin"); + // Write a unique payload so we can verify byte-equality on + // the round-trip read. + let payload: Vec = (0..=255u8).cycle().take(4096).collect(); + std::fs::write(&src, &payload).unwrap(); + + // Destination: an absolute path under /tmp on this same + // machine so the test cleans up after itself. + let dst_dir = tmp.path().join("dst"); + let dst = dst_dir.join("uploaded-binary"); + // The function we're testing expects the tilde to be handled + // by the remote shell — but for a localhost test we just use + // an absolute path. Either path shape exercises the same + // shell-pipeline code path. + let dst_str = dst.to_string_lossy().to_string(); + + let result = ssh_upload_executable( + "localhost", + &dst_str, + &src, + Duration::from_secs(15), + ) + .await; + assert!(result.is_ok(), "upload failed: {:?}", result); + + // Round-trip the bytes back. + let on_disk = std::fs::read(&dst).expect("dest read"); + assert_eq!(on_disk, payload, "uploaded bytes differ from source"); + + // Executable bit is set. + let perms = std::fs::metadata(&dst).unwrap().permissions(); + use std::os::unix::fs::PermissionsExt; + let mode = perms.mode() & 0o111; + assert_ne!(mode, 0, "executable bit must be set on uploaded binary"); + + // Atomic-replace: re-upload a different payload, dest should + // now have the new bytes. + let payload2: Vec = vec![0xab; 1024]; + std::fs::write(&src, &payload2).unwrap(); + let result = ssh_upload_executable( + "localhost", + &dst_str, + &src, + Duration::from_secs(15), + ) + .await; + assert!(result.is_ok(), "second upload failed: {:?}", result); + let on_disk2 = std::fs::read(&dst).unwrap(); + assert_eq!(on_disk2, payload2, "second upload didn't replace"); + } + #[test] fn shell_arg_handles_shell_metachars() { // A pathological remote path including $(, `, &, ;, etc. The From 0fc75872b886ed46f3bb69b869ec6b53f508069e Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Tue, 26 May 2026 20:57:01 +0200 Subject: [PATCH 3/5] fix(remote): close upgrade-path gaps for codemux-remote on hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps in the version-bump flow that would leave hosts running stale binaries even after the desktop upgrades: 1. `provision_serve` used `systemctl --user enable --now`, which is a no-op on `--now` if the unit is already active. After a re-bootstrap that uploaded a new binary, the old `serve` process kept running with the OLD binary loaded in memory until the host rebooted. Switched to `enable + restart`, which is correct for both first install (restart of inactive unit = start) and upgrade (restart of active unit = kill old + spawn new from disk). 2. `ensure_remote_binary_current` re-uploaded the binary on version mismatch but never re-ran `provision_serve`. Result: the running daemon stayed on the old binary (gap #1), AND if we ever shipped a unit-content change (env var, Restart policy) it would never reach already-bootstrapped hosts. Added a `provision_serve` call right after the bootstrap, with a comment explaining why. Best- effort failure semantics — a failure here doesn't fail the upgrade itself. 3. `host_test_connection` (the Test connection button) only flagged `needs_install` when the probe returned NO version. A host with an older codemux-remote silently passed Test with no upgrade prompt, so users had no UI affordance to refresh it. Added a version compare against `CARGO_PKG_VERSION` — if the host's version doesn't match what this Codemux ships, Test now returns `needs_install: true` with the message `v on host, v bundled — Install to upgrade`. The same Install button + flow then re-bootstraps and restarts. Net effect for users: upgrading Codemux on the desktop is enough. The next time you Test or Push to a host, the host catches up — both binary and `serve` daemon — with no manual ssh, no manual systemctl. MCP bridges (`codemux-remote mcp`) are per-spawn so they always use the binary at `~/.local/bin/codemux-remote` and "refresh" automatically as soon as the binary on disk does. Note: the narrow `pkill -f 'codemux-remote pty-daemon'` is kept intentionally — it only kills SSH-spawned pty-daemons, not user- launched processes. The `serve` daemon is restarted via systemctl in the new step instead, which is the right primitive for a managed user service. All 50 tests still green (37 unit, 10 e2e, 3 pty-daemon binary). --- src-tauri/src/commands/hosts.rs | 100 +++++++++++++++++++++++++------- src-tauri/src/ssh/bootstrap.rs | 12 +++- 2 files changed, 90 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/commands/hosts.rs b/src-tauri/src/commands/hosts.rs index 7a96590c..678c64d8 100644 --- a/src-tauri/src/commands/hosts.rs +++ b/src-tauri/src/commands/hosts.rs @@ -204,14 +204,37 @@ pub async fn hosts_test_connection( // Automations preflight — flag a host that can't reach // GitHub before an automation silently fails at run time. let github_note = probe_host_github(&host.ssh_target).await; - HostTestResult { - ok: true, - message: format!( - "Connected. codemux-remote v{version} is installed{}{github_note}", - uname.map(|u| format!(" ({u})")).unwrap_or_default() - ), - needs_install: false, - uname: None, + + // Version-aware upgrade detection: if the host's + // codemux-remote is older than the one bundled with + // this Codemux, surface it as `needs_install: true` + // so the UI shows the Install button (which then + // re-uploads + restarts via the same path as a fresh + // install). Without this, an upgraded Codemux would + // silently keep using stale codemux-remote on every + // host the user never explicitly pushed to. + let our_version = env!("CARGO_PKG_VERSION"); + let upgrade_available = version != our_version; + let uname_suffix = uname.as_ref().map(|u| format!(" ({u})")).unwrap_or_default(); + if upgrade_available { + HostTestResult { + ok: true, + message: format!( + "codemux-remote v{version} on host, v{our_version} bundled — \ + Install to upgrade{uname_suffix}{github_note}" + ), + needs_install: true, + uname, + } + } else { + HostTestResult { + ok: true, + message: format!( + "Connected. codemux-remote v{version} is installed{uname_suffix}{github_note}" + ), + needs_install: false, + uname: None, + } } } ProbeOutcome::Reachable { @@ -1213,10 +1236,13 @@ async fn ensure_remote_binary_current( return Err("uname probe returned empty string".into()); } - // Step 3: kill any running daemon. Otherwise the freshly-bootstrapped + // Step 3: kill any running pty-daemon. Otherwise the freshly-bootstrapped // binary won't actually be used until the next SSH-spawn cycle, and // a stale daemon still bound to the workspace's Unix socket would - // make that next spawn fail with "address in use." + // make that next spawn fail with "address in use." The narrow `-f` + // pattern only matches the SSH-spawned pty-daemon — user-launched + // `codemux-remote mcp` or `serve` invocations are spared. `serve` + // is restarted via systemctl in step 5 instead. let _ = Command::new("ssh") .arg("-o") .arg("BatchMode=yes") @@ -1225,28 +1251,62 @@ async fn ensure_remote_binary_current( .status() .await; - // Step 4: bootstrap (scp + chmod + verify). + // Step 4: bootstrap (upload binary + verify version). use crate::ssh::bootstrap::{bootstrap_remote, BootstrapOptions, BootstrapResult}; - match bootstrap_remote( + let outcome = bootstrap_remote( BootstrapOptions::new(&host.ssh_target, &uname).with_app(app), ) - .await - { + .await; + match outcome { BootstrapResult::Installed { reported_version } => { eprintln!( "[hosts] bootstrapped {} → codemux-remote {reported_version}", host.name ); - Ok(()) } - BootstrapResult::BinaryNotBundled { wanted_target } => Err(format!( - "this Codemux build doesn't include a codemux-remote for {wanted_target}" - )), - BootstrapResult::UploadFailed { reason } => Err(format!("upload: {reason}")), + BootstrapResult::BinaryNotBundled { wanted_target } => { + return Err(format!( + "this Codemux build doesn't include a codemux-remote for {wanted_target}" + )); + } + BootstrapResult::UploadFailed { reason } => { + return Err(format!("upload: {reason}")); + } BootstrapResult::PostInstallProbeFailed { reason } => { - Err(format!("verify: {reason}")) + return Err(format!("verify: {reason}")); } } + + // Step 5: re-provision the headless `serve` daemon. This is + // idempotent — it rewrites the systemd unit (so a unit-content + // change in this Codemux version takes effect immediately), runs + // daemon-reload, and **restarts** the unit (not just `enable + // --now` — restart kills the old process so the new binary on + // disk actually starts running). If we skipped this, an upgraded + // codemux-remote on disk would coexist with an old `serve` + // process in memory until the next host reboot, which would + // confuse anyone debugging "why doesn't my new MCP tool show up + // after I updated Codemux." + // + // Best-effort: a failure here doesn't fail the upgrade. The + // binary is current; the user can `systemctl --user restart + // codemux-remote` themselves if needed. + if let Err(error) = crate::ssh::bootstrap::provision_serve( + &host.ssh_target, + "~/.local/bin/codemux-remote", + std::time::Duration::from_secs(30), + ) + .await + { + eprintln!( + "[hosts] re-provisioning serve on {} after upgrade failed (continuing): {error}", + host.name + ); + } else { + eprintln!("[hosts] re-provisioned codemux-remote.service on {}", host.name); + } + + Ok(()) } /// Terminate every PTY session belonging to the given workspace. diff --git a/src-tauri/src/ssh/bootstrap.rs b/src-tauri/src/ssh/bootstrap.rs index baea1166..c4092d8d 100644 --- a/src-tauri/src/ssh/bootstrap.rs +++ b/src-tauri/src/ssh/bootstrap.rs @@ -410,6 +410,13 @@ pub async fn provision_serve( .await .map_err(|e| format!("writing serve systemd unit: {e}"))?; + // `enable + restart` (not `enable --now`) is intentional: it makes + // this function correct for both first install AND upgrade. On + // first install, restart of an inactive unit is identical to + // start. On upgrade, the running daemon is killed and respawned + // from the new binary on disk — without this, an upgraded binary + // wouldn't take effect until the next host reboot or manual + // `systemctl --user restart`. run_with_timeout( Command::new("ssh") .arg("-o") @@ -420,8 +427,9 @@ pub async fn provision_serve( .arg(format!( "loginctl enable-linger \"$USER\" >/dev/null 2>&1; \ systemctl --user daemon-reload && \ - systemctl --user enable --now {}", - service::SERVE_SYSTEMD_UNIT_NAME + systemctl --user enable {unit} && \ + systemctl --user restart {unit}", + unit = service::SERVE_SYSTEMD_UNIT_NAME )), deadline, ) From 02a7966a9cc6dbee585e03509eeec639bf7eeb83 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Tue, 26 May 2026 21:10:07 +0200 Subject: [PATCH 4/5] feat(remote): auto-register codemux MCP in known agent configs on serve startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `codemux-remote serve` starts, it now scans the user's home directory for known agent CLI MCP-config files and inserts a `codemux` server entry pointing at `codemux-remote mcp`. So when the user works on a directory on the host that wasn't pushed from a Codemux desktop — a repo cloned directly there, an ad-hoc scratch dir — their agent CLI still discovers Codemux as an MCP server without any manual config edits. v1 supports two locations: * `~/.claude.json` — Claude Code's user-level MCP config (JSON). * `~/.vexis/mcp-servers.yaml` — Vexis brain runtime's config (YAML). Both writers share a strict safety contract: * **Idempotent.** Re-running on every daemon start is a no-op when the entry is already correct. Verified by a real e2e smoke test: boot daemon → both files updated, boot daemon again → no log line, both file SHA1 hashes unchanged. * **Atomic.** Sibling `.tmp` file + rename so a crash mid-write can never leave a half-baked config on disk. File permissions preserved across the rename (a 0600 `~/.claude.json` stays 0600 — agent configs often contain API keys). * **No corruption.** Malformed source files are logged and skipped, never overwritten. Top-level shape mismatches (a JSON array where we expect an object, etc.) are surfaced as a Skipped outcome, not a silent overwrite. * **No surprise creation.** We never create the agent-specific directory itself — if `~/.vexis` doesn't exist, the user doesn't use Vexis, and we leave it alone. We only create the config file *within* an existing agent directory. Other entries in the user's config are preserved verbatim: unrelated `mcpServers` entries, top-level keys, list ordering all round-trip through the upsert. Other agent CLIs (Codex, Gemini, OpenCode) each have a different config path and shape; adding them is just a new arm in `ensure_codemux_in_agent_configs`. Tracked for follow-up. 16 new unit tests cover the JSON/YAML upsert + atomic-write + idempotency + corruption-refusal + permission-preservation guarantees. All 53 lib + 10 e2e + 3 binary tests still green. --- src-tauri/src/bin/codemux_remote.rs | 32 ++ src-tauri/src/remote/mcp_register.rs | 566 +++++++++++++++++++++++++++ src-tauri/src/remote/mod.rs | 1 + 3 files changed, 599 insertions(+) create mode 100644 src-tauri/src/remote/mcp_register.rs diff --git a/src-tauri/src/bin/codemux_remote.rs b/src-tauri/src/bin/codemux_remote.rs index 72bdae94..8a26ada9 100644 --- a/src-tauri/src/bin/codemux_remote.rs +++ b/src-tauri/src/bin/codemux_remote.rs @@ -409,6 +409,38 @@ async fn run_serve_async(port: Option, state_dir: PathBuf) -> Result<(), St manifest_value.secret.len() ); + // Best-effort: register `codemux-remote mcp` in known agent + // MCP configs (~/.claude.json, ~/.vexis/mcp-servers.yaml) so + // any agent CLI the user runs on this host already knows about + // Codemux without manual config edits. Runs on every daemon + // start because it's idempotent — if the entries are already + // correct, no writes happen. + // + // The function we call is defensive: malformed config files + // are logged and skipped, never overwritten. Missing + // agent-specific dirs (~/.vexis nonexistent) are skipped + // silently — we never create an opt-in we weren't asked for. + let exec_path = std::env::current_exe() + .unwrap_or_else(|_| std::path::PathBuf::from("codemux-remote")); + let report = codemux_lib::remote::mcp_register::ensure_codemux_in_agent_configs(&exec_path); + if !report.modified.is_empty() { + eprintln!( + "[codemux-remote] auto-registered codemux MCP in: {}", + report + .modified + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") + ); + } + for (path, reason) in &report.failed { + eprintln!( + "[codemux-remote] could not register in {} (skipped): {reason}", + path.display() + ); + } + let started_at = manifest_value.started_at.clone(); let secret = manifest_value.secret.clone(); diff --git a/src-tauri/src/remote/mcp_register.rs b/src-tauri/src/remote/mcp_register.rs new file mode 100644 index 00000000..36dc8ad5 --- /dev/null +++ b/src-tauri/src/remote/mcp_register.rs @@ -0,0 +1,566 @@ +//! Auto-register `codemux-remote mcp` in known agent MCP configs on +//! the host, so any agent CLI the user runs on this host already +//! knows about Codemux without the user editing config files by +//! hand. +//! +//! Why this exists: the desktop push flow drops a per-workspace +//! `.mcp.json` (handled by `ssh::bootstrap::provision_workspace_mcp_config`). +//! But users also work on directories on the host that weren't +//! pushed — repos cloned directly on the server, ad-hoc scratch +//! dirs, services running in arbitrary places. For those, agents +//! need a user-level (not per-workspace) MCP config that names +//! `codemux-remote` as a server. We write that here, on every +//! `codemux-remote serve` startup, idempotently. +//! +//! Safety contract: +//! +//! 1. **Idempotent.** If the codemux entry is already present, we +//! make no changes. Re-running on every daemon start is fine. +//! 2. **Atomic.** Writes go through a sibling `.tmp` file + rename +//! so a crash mid-write can never leave the user's agent config +//! in a half-baked state. +//! 3. **No corruption.** If the existing file is unparseable +//! (broken JSON/YAML), we log and bail — never overwrite a file +//! we can't safely round-trip. +//! 4. **Skip missing tools.** A user who doesn't have Claude Code +//! won't have `~/.claude.json`. We skip configs whose parent +//! directory doesn't exist. We don't create agent-specific +//! directories the user never opted into. +//! +//! Supported agent configs (v1): +//! +//! | Agent | Path | Format | +//! |---|---|---| +//! | Claude Code | `~/.claude.json` | JSON | +//! | Vexis | `~/.vexis/mcp-servers.yaml` | YAML | +//! +//! Future targets (Codex, Gemini CLI, OpenCode): each has its own +//! config path/shape; add them here as users ask. Until then, the +//! website docs cover the one-line manual edit. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value as JsonValue}; + +/// Outcome of scanning + updating known agent configs. +#[derive(Debug, Default)] +pub struct RegisterReport { + /// Paths whose `codemux` entry we added or updated. + pub modified: Vec, + /// Paths we examined that already had a current entry — no + /// changes were made. + pub unchanged: Vec, + /// Paths we wanted to touch but couldn't (malformed file, IO + /// error). Each entry includes the reason for diagnostics. + pub failed: Vec<(PathBuf, String)>, +} + +/// Probe every known agent-config location and ensure each has a +/// `codemux` MCP server entry pointing at `codemux_remote_path` +/// (typically `std::env::current_exe()`). +/// +/// Best-effort: returns a [`RegisterReport`] with per-path outcomes, +/// never an `Err`. Caller logs whatever it likes. +pub fn ensure_codemux_in_agent_configs(codemux_remote_path: &Path) -> RegisterReport { + let home = match dirs::home_dir() { + Some(h) => h, + None => return RegisterReport::default(), + }; + ensure_codemux_in_agent_configs_with_home(codemux_remote_path, &home) +} + +/// Test-friendly variant of [`ensure_codemux_in_agent_configs`] that +/// takes an explicit `home` directory. Production callers use the +/// `dirs::home_dir()`-rooted wrapper above; the `with_home` form lets +/// tests target a tempdir without mutating the process's `HOME` +/// env var (which would race other tests). +pub fn ensure_codemux_in_agent_configs_with_home( + codemux_remote_path: &Path, + home: &Path, +) -> RegisterReport { + let mut report = RegisterReport::default(); + let exec = codemux_remote_path.to_string_lossy().into_owned(); + + // Claude Code: ~/.claude.json + let claude = home.join(".claude.json"); + if claude.exists() { + match ensure_in_json_config(&claude, &exec) { + Ok(true) => report.modified.push(claude), + Ok(false) => report.unchanged.push(claude), + Err(e) => report.failed.push((claude, e)), + } + } + + // Vexis: ~/.vexis/mcp-servers.yaml + let vexis_dir = home.join(".vexis"); + if vexis_dir.is_dir() { + let vexis = vexis_dir.join("mcp-servers.yaml"); + match ensure_in_yaml_config(&vexis, &exec) { + Ok(true) => report.modified.push(vexis), + Ok(false) => report.unchanged.push(vexis), + Err(e) => report.failed.push((vexis, e)), + } + } + + report +} + +/// JSON-shaped config writer. Used for `~/.claude.json` and any +/// `.mcp.json`-style file. Returns `Ok(true)` if a change was +/// written, `Ok(false)` if nothing needed updating. +/// +/// The wire shape is: +/// +/// ```json +/// { +/// "mcpServers": { +/// "codemux": { "command": "", "args": ["mcp"] } +/// } +/// } +/// ``` +/// +/// We never touch keys other than `mcpServers.codemux`. Any other +/// MCP servers the user has configured, any other top-level keys, +/// stay untouched. If `mcpServers` isn't present we create it; if +/// `codemux` is already there pointing at the right command, we +/// leave the file alone. +pub fn ensure_in_json_config(path: &Path, codemux_remote_path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("read: {e}"))?; + let mut config: JsonValue = serde_json::from_slice(&bytes) + .map_err(|e| format!("parse JSON: {e}"))?; + + // If the file isn't an object at the top level we bail rather + // than overwrite — could be a deliberately exotic config shape. + if !config.is_object() { + return Err("top-level value is not a JSON object".into()); + } + + if !config.get("mcpServers").is_some_and(JsonValue::is_object) { + config["mcpServers"] = json!({}); + } + + let desired = json!({ + "command": codemux_remote_path, + "args": ["mcp"], + }); + + if config["mcpServers"]["codemux"] == desired { + return Ok(false); + } + + config["mcpServers"]["codemux"] = desired; + + let serialised = serde_json::to_string_pretty(&config) + .map_err(|e| format!("serialise JSON: {e}"))?; + atomic_write(path, serialised.as_bytes())?; + Ok(true) +} + +/// YAML-shaped config writer for Vexis's `~/.vexis/mcp-servers.yaml`. +/// +/// The Vexis schema is: +/// +/// ```yaml +/// servers: +/// - name: codemux +/// command: +/// args: ["mcp"] +/// ``` +/// +/// A vector of server entries keyed by `name`. We upsert the entry +/// named `codemux` while preserving every other entry verbatim. +pub fn ensure_in_yaml_config(path: &Path, codemux_remote_path: &str) -> Result { + use serde_yaml::Value as YamlValue; + + // If the file doesn't exist yet, create a minimal one. Vexis's + // expected schema starts with the `servers:` list at the top. + // We do this here (vs. the JSON path's "must exist" rule) + // because the directory existing means the user definitely + // uses Vexis, even if they never wrote any MCP servers in + // before. + if !path.exists() { + let new_doc = format!( + "servers:\n - name: codemux\n command: {cmd}\n args: [\"mcp\"]\n", + cmd = yaml_quote_if_needed(codemux_remote_path), + ); + atomic_write(path, new_doc.as_bytes())?; + return Ok(true); + } + + let bytes = std::fs::read(path).map_err(|e| format!("read: {e}"))?; + let mut doc: YamlValue = serde_yaml::from_slice(&bytes) + .map_err(|e| format!("parse YAML: {e}"))?; + + // Empty file → top-level becomes Null. Treat as "no servers + // yet" and seed the structure. + if doc.is_null() { + doc = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + + let map = doc + .as_mapping_mut() + .ok_or_else(|| "top-level YAML is not a mapping".to_string())?; + let servers_key = serde_yaml::Value::String("servers".into()); + + // Ensure `servers:` exists and is a sequence. + if !map + .get(&servers_key) + .is_some_and(serde_yaml::Value::is_sequence) + { + map.insert(servers_key.clone(), serde_yaml::Value::Sequence(vec![])); + } + let servers = map + .get_mut(&servers_key) + .and_then(|v| v.as_sequence_mut()) + .expect("servers sequence we just ensured exists"); + + // Build the desired entry. + let mut desired = serde_yaml::Mapping::new(); + desired.insert("name".into(), "codemux".into()); + desired.insert("command".into(), codemux_remote_path.into()); + desired.insert( + "args".into(), + serde_yaml::Value::Sequence(vec!["mcp".into()]), + ); + let desired_value = serde_yaml::Value::Mapping(desired); + + // Upsert by name. + let existing_index = servers.iter().position(|entry| { + entry + .as_mapping() + .and_then(|m| m.get(&serde_yaml::Value::String("name".into()))) + .and_then(|n| n.as_str()) + == Some("codemux") + }); + + match existing_index { + Some(idx) if servers[idx] == desired_value => return Ok(false), + Some(idx) => servers[idx] = desired_value, + None => servers.push(desired_value), + } + + let serialised = serde_yaml::to_string(&doc).map_err(|e| format!("serialise YAML: {e}"))?; + atomic_write(path, serialised.as_bytes())?; + Ok(true) +} + +/// Atomic-write: dump to `.tmp`, fsync, rename into place. +/// Same pattern the manifest writer uses. Preserves the existing +/// file's mode if we can; otherwise leaves the default umask. +fn atomic_write(path: &Path, content: &[u8]) -> Result<(), String> { + use std::io::Write; + + let parent = path + .parent() + .ok_or_else(|| "path has no parent directory".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("create parent: {e}"))?; + + let tmp = path.with_extension({ + // Preserve the original extension and append `.tmp` so the + // tempfile doesn't end up named `foo.tmp` when the source + // was `foo.json`. Tools that match against extension (e.g. + // file watchers) won't get confused. + let original_ext = path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + if original_ext.is_empty() { + "tmp".to_string() + } else { + format!("{original_ext}.tmp") + } + }); + + // Inherit mode from the existing file if any — agent configs + // are often 0600 (containing API keys) and we don't want to + // accidentally widen permissions. + let existing_mode = std::fs::metadata(path).ok().map(|m| { + use std::os::unix::fs::PermissionsExt; + m.permissions().mode() & 0o777 + }); + + { + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&tmp) + .map_err(|e| format!("open tmpfile: {e}"))?; + if let Some(mode) = existing_mode { + use std::os::unix::fs::PermissionsExt; + let _ = file.set_permissions(std::fs::Permissions::from_mode(mode)); + } + file.write_all(content) + .map_err(|e| format!("write tmpfile: {e}"))?; + file.sync_all().map_err(|e| format!("fsync tmpfile: {e}"))?; + } + std::fs::rename(&tmp, path).map_err(|e| format!("rename into place: {e}"))?; + Ok(()) +} + +/// Wrap a string in double-quotes if it contains characters YAML +/// would otherwise interpret (spaces, colons, backslashes). Pure +/// path strings like `/home/user/.local/bin/codemux-remote` work +/// unquoted, but we be paranoid for the case where the path +/// contains spaces. +fn yaml_quote_if_needed(s: &str) -> String { + if s.chars().any(|c| matches!(c, ' ' | ':' | '\\' | '"' | '\'' | '#')) { + // Naive quoting: escape backslashes and double-quotes. + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") + } else { + s.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn read_to_string(path: &Path) -> String { + std::fs::read_to_string(path).unwrap() + } + + // ─── JSON ────────────────────────────────────────────────── + + #[test] + fn json_adds_codemux_to_existing_empty_config() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("claude.json"); + std::fs::write(&path, b"{}").unwrap(); + + assert!(ensure_in_json_config(&path, "/bin/codemux-remote").unwrap()); + + let parsed: JsonValue = serde_json::from_str(&read_to_string(&path)).unwrap(); + assert_eq!(parsed["mcpServers"]["codemux"]["command"], json!("/bin/codemux-remote")); + assert_eq!(parsed["mcpServers"]["codemux"]["args"], json!(["mcp"])); + } + + #[test] + fn json_preserves_unrelated_servers() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("claude.json"); + std::fs::write( + &path, + r#"{ + "mcpServers": { + "shadcn": { "command": "npx", "args": ["shadcn"] }, + "github": { "command": "gh-mcp" } + }, + "otherTopLevel": "stays" + }"#, + ) + .unwrap(); + + assert!(ensure_in_json_config(&path, "/bin/codemux-remote").unwrap()); + + let parsed: JsonValue = serde_json::from_str(&read_to_string(&path)).unwrap(); + // Other servers + top-level keys untouched. + assert_eq!(parsed["mcpServers"]["shadcn"]["command"], json!("npx")); + assert_eq!(parsed["mcpServers"]["github"]["command"], json!("gh-mcp")); + assert_eq!(parsed["otherTopLevel"], json!("stays")); + // Codemux added. + assert_eq!(parsed["mcpServers"]["codemux"]["command"], json!("/bin/codemux-remote")); + } + + #[test] + fn json_is_idempotent_when_already_correct() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("claude.json"); + std::fs::write( + &path, + r#"{"mcpServers":{"codemux":{"command":"/bin/codemux-remote","args":["mcp"]}}}"#, + ) + .unwrap(); + let before = read_to_string(&path); + + // Returns Ok(false) — no change made. + assert!(!ensure_in_json_config(&path, "/bin/codemux-remote").unwrap()); + assert_eq!(read_to_string(&path), before, "no rewrite when entry is current"); + } + + #[test] + fn json_updates_when_command_path_changes() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("claude.json"); + std::fs::write( + &path, + r#"{"mcpServers":{"codemux":{"command":"/old/path","args":["mcp"]}}}"#, + ) + .unwrap(); + assert!(ensure_in_json_config(&path, "/new/path").unwrap()); + let parsed: JsonValue = serde_json::from_str(&read_to_string(&path)).unwrap(); + assert_eq!(parsed["mcpServers"]["codemux"]["command"], json!("/new/path")); + } + + #[test] + fn json_refuses_to_overwrite_malformed_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("claude.json"); + std::fs::write(&path, b"this is not json {{{").unwrap(); + let err = ensure_in_json_config(&path, "/bin/codemux-remote").unwrap_err(); + assert!(err.contains("parse JSON")); + // File untouched. + assert_eq!(std::fs::read(&path).unwrap(), b"this is not json {{{"); + } + + #[test] + fn json_refuses_top_level_array() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("claude.json"); + std::fs::write(&path, b"[]").unwrap(); + let err = ensure_in_json_config(&path, "/bin/codemux-remote").unwrap_err(); + assert!(err.contains("not a JSON object")); + } + + #[test] + fn json_preserves_file_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let path = dir.path().join("claude.json"); + std::fs::write(&path, b"{}").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + + assert!(ensure_in_json_config(&path, "/bin/codemux-remote").unwrap()); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "must not widen perms on agent config"); + } + + // ─── YAML ────────────────────────────────────────────────── + + #[test] + fn yaml_creates_missing_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("mcp-servers.yaml"); + assert!(!path.exists()); + + assert!(ensure_in_yaml_config(&path, "/bin/codemux-remote").unwrap()); + assert!(path.exists()); + let parsed: serde_yaml::Value = + serde_yaml::from_str(&read_to_string(&path)).unwrap(); + let servers = parsed["servers"].as_sequence().unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0]["name"], serde_yaml::Value::from("codemux")); + assert_eq!(servers[0]["command"], serde_yaml::Value::from("/bin/codemux-remote")); + } + + #[test] + fn yaml_preserves_unrelated_servers() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("mcp-servers.yaml"); + std::fs::write( + &path, + r#"servers: + - name: shadcn + command: npx + args: ["@shadcn/mcp"] + - name: github + command: gh-mcp +"#, + ) + .unwrap(); + assert!(ensure_in_yaml_config(&path, "/bin/codemux-remote").unwrap()); + + let parsed: serde_yaml::Value = + serde_yaml::from_str(&read_to_string(&path)).unwrap(); + let servers = parsed["servers"].as_sequence().unwrap(); + // Three entries: shadcn, github, codemux. + assert_eq!(servers.len(), 3); + let names: Vec<_> = servers + .iter() + .map(|s| s["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"shadcn")); + assert!(names.contains(&"github")); + assert!(names.contains(&"codemux")); + } + + #[test] + fn yaml_is_idempotent_when_already_correct() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("mcp-servers.yaml"); + std::fs::write( + &path, + "servers:\n - name: codemux\n command: /bin/codemux-remote\n args:\n - mcp\n", + ) + .unwrap(); + let before = read_to_string(&path); + + assert!(!ensure_in_yaml_config(&path, "/bin/codemux-remote").unwrap()); + assert_eq!(read_to_string(&path), before, "no rewrite when entry is current"); + } + + #[test] + fn yaml_updates_when_command_changes() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("mcp-servers.yaml"); + std::fs::write( + &path, + "servers:\n - name: codemux\n command: /old/codemux\n args:\n - mcp\n", + ) + .unwrap(); + assert!(ensure_in_yaml_config(&path, "/new/codemux").unwrap()); + let parsed: serde_yaml::Value = + serde_yaml::from_str(&read_to_string(&path)).unwrap(); + let servers = parsed["servers"].as_sequence().unwrap(); + let codemux = servers + .iter() + .find(|s| s["name"] == serde_yaml::Value::from("codemux")) + .unwrap(); + assert_eq!(codemux["command"], serde_yaml::Value::from("/new/codemux")); + } + + #[test] + fn yaml_refuses_to_overwrite_malformed_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("mcp-servers.yaml"); + std::fs::write(&path, b"servers:\n - name: \"oops\n").unwrap(); // unclosed quote + let err = ensure_in_yaml_config(&path, "/bin/codemux-remote").unwrap_err(); + assert!(err.contains("parse YAML")); + } + + // ─── Integration via ensure_codemux_in_agent_configs ───────── + + #[test] + fn ensure_skips_missing_configs() { + // No claude.json, no .vexis dir → nothing to do. + let dir = TempDir::new().unwrap(); + let report = + ensure_codemux_in_agent_configs_with_home(Path::new("/bin/codemux-remote"), dir.path()); + assert!(report.modified.is_empty(), "no modifications when no configs present"); + assert!(report.failed.is_empty()); + } + + #[test] + fn ensure_picks_up_both_when_both_exist() { + let dir = TempDir::new().unwrap(); + let home = dir.path(); + std::fs::write(home.join(".claude.json"), b"{}").unwrap(); + std::fs::create_dir_all(home.join(".vexis")).unwrap(); + // mcp-servers.yaml absent — should be created. + + let report = + ensure_codemux_in_agent_configs_with_home(Path::new("/bin/codemux-remote"), home); + assert_eq!(report.modified.len(), 2, "both configs touched: {:?}", report); + assert!(report.failed.is_empty()); + } + + // ─── yaml_quote_if_needed ────────────────────────────────── + + #[test] + fn yaml_quote_passes_simple_paths_through() { + assert_eq!(yaml_quote_if_needed("/home/user/.local/bin/codemux-remote"), "/home/user/.local/bin/codemux-remote"); + } + + #[test] + fn yaml_quote_wraps_paths_with_spaces() { + assert_eq!( + yaml_quote_if_needed("/home/cool user/bin/codemux"), + "\"/home/cool user/bin/codemux\"" + ); + } +} diff --git a/src-tauri/src/remote/mod.rs b/src-tauri/src/remote/mod.rs index 483591ea..66ec9b68 100644 --- a/src-tauri/src/remote/mod.rs +++ b/src-tauri/src/remote/mod.rs @@ -37,6 +37,7 @@ pub mod config; pub mod identity; pub mod manifest; pub mod mcp; +pub mod mcp_register; pub mod pty; pub mod server; pub mod tools; From 604fc8df0be494b379cc3fa5a3c495672693468a Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Tue, 26 May 2026 21:10:22 +0200 Subject: [PATCH 5/5] feat(hosts): background poll auto-upgrades codemux-remote on every host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Codemux starts, a background task now walks every registered SSH host (~5 seconds after app setup so the UI is responsive first), probes each one's `codemux-remote` version, and silently re-bootstraps any host whose version is behind the one this build ships. The user-visible effect: "I update Codemux on my desktop" is enough — every host the user has registered catches up on its own, including a `systemctl --user restart codemux-remote` so the running daemon switches to the new binary immediately (not at the next host reboot). Behaviour: * **Per-host 30s timeout.** A host that's offline or has a slow ssh connection logs a timeout line and the poll continues to the next host. The app is never blocked. * **Cheap when already current.** One SSH probe (~1s) on each host that's already on our version. Heavier work — binary upload + service restart — only happens on actual mismatch. * **Never installs codemux-remote where it isn't already.** The poll only *upgrades* existing installations. Fresh install still requires the Install button on Settings → Hosts (which carries explicit consent — "Codemux Remote is a small helper… installed to ~/.local/bin"). * **Best-effort failure.** A failed probe, missing bundled binary for the host's arch, or failed upload logs a warning and the app continues running. The poll never panics. Pairs with the existing on-push and on-Test-connection version checks (commits 0fc7587 + 4c35458): the user gets correct upgrades whether they explicitly poke Settings → Hosts or just restart Codemux after an update. --- src-tauri/src/hosts_upgrade.rs | 197 +++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 16 +++ 2 files changed, 213 insertions(+) create mode 100644 src-tauri/src/hosts_upgrade.rs diff --git a/src-tauri/src/hosts_upgrade.rs b/src-tauri/src/hosts_upgrade.rs new file mode 100644 index 00000000..9aff3348 --- /dev/null +++ b/src-tauri/src/hosts_upgrade.rs @@ -0,0 +1,197 @@ +//! Background host-upgrade poller. +//! +//! Runs once per app start (~5 seconds after setup, so the UI is +//! responsive first), iterates every registered SSH host, probes +//! its `codemux-remote` version, and if it differs from the version +//! bundled with this Codemux build, silently re-bootstraps — +//! uploads the new binary and restarts the `serve` daemon via the +//! same code path the Install button uses. +//! +//! Why a background task and not a UI prompt: +//! +//! * Users don't think about "upgrading a helper binary on a remote +//! host." They think "I updated Codemux." This task makes those +//! two operations equivalent from the user's perspective. +//! * The upgrade is the same path the Install button already runs. +//! Consent was implicitly granted the first time the user +//! bootstrapped the host; re-uploading a newer version of the +//! same binary to the same location is not a meaningful trust +//! escalation. +//! * `provision_serve` is idempotent, so even if the host is +//! already up to date the task is cheap (one SSH version probe). +//! +//! Best-effort by design — a host being offline, a flaky tunnel, a +//! missing bundled-binary target — any of these log and move on. +//! The task never fails the app. + +#![cfg(unix)] + +use std::time::Duration; + +use tauri::{AppHandle, Manager}; + +use crate::database::DatabaseStore; +use crate::ssh::bootstrap::{bootstrap_remote, provision_serve, BootstrapOptions, BootstrapResult}; +use crate::ssh::probe::{probe_host, ProbeOptions, ProbeOutcome}; + +/// Spawn the background upgrade poller. Must be called once during +/// app setup. The task starts after a short delay so it doesn't +/// race the UI for resources during the first second of app +/// startup, then walks every host sequentially. +pub fn spawn(app: AppHandle) { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_secs(5)).await; + run_once(app).await; + }); +} + +/// Walk every registered host and upgrade those whose +/// `codemux-remote` version differs from our bundled one. Public so +/// tests can drive it directly without the spawn delay. +pub async fn run_once(app: AppHandle) { + let hosts = match app.try_state::() { + Some(state) => state.list_hosts(), + None => { + eprintln!( + "[hosts_upgrade] database state unavailable; skipping background poll" + ); + return; + } + }; + if hosts.is_empty() { + return; + } + eprintln!( + "[hosts_upgrade] polling {} host(s) for upgrade", + hosts.len() + ); + + let our_version = env!("CARGO_PKG_VERSION"); + for host in hosts { + // Each host gets a 30s budget — probe + (optional) upload + restart. + let result = tokio::time::timeout( + Duration::from_secs(30), + check_and_upgrade(&app, &host, our_version), + ) + .await; + match result { + Ok(Ok(UpgradeOutcome::AlreadyCurrent)) => { + // Quiet success — no log needed for "already current." + } + Ok(Ok(UpgradeOutcome::Upgraded { from, to })) => { + eprintln!( + "[hosts_upgrade] {} upgraded codemux-remote {from} → {to}", + host.name + ); + } + Ok(Ok(UpgradeOutcome::Skipped { reason })) => { + eprintln!( + "[hosts_upgrade] {} skipped: {reason}", + host.name + ); + } + Ok(Err(e)) => { + eprintln!("[hosts_upgrade] {} failed: {e}", host.name); + } + Err(_) => { + eprintln!( + "[hosts_upgrade] {} timed out (30s) — host offline or slow ssh", + host.name + ); + } + } + } +} + +enum UpgradeOutcome { + AlreadyCurrent, + Upgraded { from: String, to: String }, + Skipped { reason: String }, +} + +async fn check_and_upgrade( + app: &AppHandle, + host: &crate::database::HostRecord, + our_version: &str, +) -> Result { + // Step 1: probe. + let outcome = probe_host(ProbeOptions::new(&host.ssh_target)).await; + let (current_version, uname) = match outcome { + ProbeOutcome::Reachable { + codemux_remote_version: Some(v), + uname, + } => (v, uname), + ProbeOutcome::Reachable { + codemux_remote_version: None, + .. + } => { + // Host reachable but codemux-remote not installed. Don't + // install it as part of the background poll — that + // requires the user's consent (handled by the Install + // button on Settings → Hosts). The background poll only + // *upgrades* existing installations. + return Ok(UpgradeOutcome::Skipped { + reason: "codemux-remote not installed (background poll won't auto-install — use Settings → Hosts)".into(), + }); + } + ProbeOutcome::Unreachable { reason } => { + return Ok(UpgradeOutcome::Skipped { + reason: format!("unreachable: {reason}"), + }); + } + }; + + if current_version == our_version { + return Ok(UpgradeOutcome::AlreadyCurrent); + } + + // Step 2: figure out target triple for the binary we'll upload. + let uname_str = match uname { + Some(s) => s, + None => return Err("probe returned no uname".into()), + }; + + // Step 3: re-bootstrap (uploads new binary, verifies version). + let outcome = bootstrap_remote( + BootstrapOptions::new(&host.ssh_target, &uname_str).with_app(app), + ) + .await; + let new_version = match outcome { + BootstrapResult::Installed { reported_version } => reported_version, + BootstrapResult::BinaryNotBundled { wanted_target } => { + return Err(format!( + "this Codemux build doesn't include a codemux-remote for {wanted_target}" + )); + } + BootstrapResult::UploadFailed { reason } => { + return Err(format!("upload: {reason}")); + } + BootstrapResult::PostInstallProbeFailed { reason } => { + return Err(format!("verify: {reason}")); + } + }; + + // Step 4: re-provision serve so the systemd unit content stays + // in sync AND the running daemon picks up the new binary. + // Idempotent + restart-based, so safe to run unconditionally. + if let Err(e) = provision_serve( + &host.ssh_target, + "~/.local/bin/codemux-remote", + Duration::from_secs(30), + ) + .await + { + // Don't fail the whole upgrade for this — the binary IS + // newer on disk; the daemon will pick it up on the next + // host reboot. Log loudly. + eprintln!( + "[hosts_upgrade] {} provision_serve failed after upgrade (binary is current, but daemon needs restart): {e}", + host.name + ); + } + + Ok(UpgradeOutcome::Upgraded { + from: current_version, + to: new_version, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e6634f3e..88806404 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -62,6 +62,8 @@ pub mod skills_sync; pub mod session_adapters; pub mod settings_sync; pub mod hosts_sync; +#[cfg(unix)] +pub mod hosts_upgrade; pub mod state; pub mod hooks; pub mod stream_input; @@ -594,6 +596,20 @@ pub fn run() { control::spawn_control_server(app.handle().clone()); + // Background host-upgrade poller. ~5s after app setup it + // walks every registered SSH host, version-checks its + // codemux-remote against the one this build ships, and + // silently re-bootstraps any host that's behind. So when + // the user updates Codemux on the desktop, their hosts + // catch up on their own — no Test/Push click required. + // Safe to run on every app start: cheap when versions + // match (one SSH probe), idempotent re-provision on + // upgrade, never installs codemux-remote where it isn't + // already present (that still requires the Install + // button's consent). + #[cfg(unix)] + crate::hosts_upgrade::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