MCP on remote: headless daemon + auto-provisioning everywhere#42
Merged
Conversation
`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.
…e bug)
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.
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<old> on host, v<new> 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).
…ve startup 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.
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.
# Conflicts: # src-tauri/src/lib.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Codemux can now drive an agent on any SSH-reachable host via MCP, with zero manual setup. After this lands:
.mcp.json+ registers the workspace in the daemon. No clicks, no manifest editing, no systemd files to copy.Solves the case the user described: an agent on a home server (Telegram bot, Claude Code, Vexis, anything MCP-aware) wants to use Codemux. Today they'd need to SSH in, hand-install the binary, hand-write systemd configs, hand-edit
~/.claude.json. After this PR: none of that.What's in here
New: headless `codemux-remote serve` daemon
`src-tauri/src/remote/` — self-contained, no Tauri dependency:
New CLI
```
codemux-remote serve [--port N] [--state-dir DIR]
codemux-remote serve status
codemux-remote serve stop
codemux-remote mcp
codemux-remote workspace register --path … --name … --branch …
```
Bug fix: OpenSSH 9+ scp tilde
The install failure reported as `scp: dest open ".local/bin/codemux-remote": Failure` was the classic OpenSSH 9.0+ SFTP-default + tilde-expansion footgun. Replaced `scp src host:
/.local/bin/...` with `ssh host 'cat > path.tmp && chmod +x && mv -f'` — the remote login shell expands `` correctly, the atomic-replace handles "stale older binary already there" cleanly, and we go from 3 SSH round-trips to 1.Upgrade story
Three triggers, all running the same idempotent provision_serve path that restarts the systemd unit:
Design constraints for a future optional cloud relay
Four choices baked into v1 so a paid-tier relay (team, phone-without-SSH) can be added later as a purely additive feature, not a rewrite. None cost anything today; all are documented in `docs/plans/mcp-on-remote.md`.
Test plan
Out of scope (deferred, tracked in `docs/plans/mcp-on-remote.md`)
Companion docs PR
codemux-sitev2 already has matching commits on main: