Skip to content

MCP on remote: headless daemon + auto-provisioning everywhere#42

Merged
Zeus-Deus merged 6 commits into
mainfrom
mcp-on-remote
May 26, 2026
Merged

MCP on remote: headless daemon + auto-provisioning everywhere#42
Zeus-Deus merged 6 commits into
mainfrom
mcp-on-remote

Conversation

@Zeus-Deus

Copy link
Copy Markdown
Owner

Summary

Codemux can now drive an agent on any SSH-reachable host via MCP, with zero manual setup. After this lands:

  • One-time per host: Settings → Hosts → Add device → Install. Auto-installs binary + systemd unit + manifest + registers codemux in known agent configs on the host.
  • After that, forever: Updating Codemux on desktop auto-upgrades every registered host (background poll on app start). Pushing a workspace auto-drops .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

Commit Subject
0ecd584 feat(remote): auto-provisioning headless MCP daemon on push
4c35458 fix(ssh): use ssh-cat pipeline for binary upload (OpenSSH 9+ scp tilde bug)
0fc7587 fix(remote): close upgrade-path gaps for codemux-remote on hosts
02a7966 feat(remote): auto-register codemux MCP in known agent configs on serve startup
604fc8d feat(hosts): background poll auto-upgrades codemux-remote on every host

New: headless `codemux-remote serve` daemon

`src-tauri/src/remote/` — self-contained, no Tauri dependency:

  • `manifest.rs` — endpoint + 32-byte bearer secret, mode 0600
  • `auth.rs` — axum bearer middleware, constant-time comparison
  • `identity.rs` — `Identity::Local | Cloud { … }` (Cloud reserved for future relay)
  • `workspace.rs` — SQLite registry with nullable `owner_id`
  • `pty.rs` — minimal portable-pty wrapper with per-terminal ring buffer
  • `server.rs` — axum HTTP routes (`/health`, `/tools/list`, `/tools/call`)
  • `mcp.rs` — stdio JSON-RPC MCP server bridging to the HTTP endpoint
  • `tools/mod.rs` — 11 headless tools: workspace_{create,list,info,update,close}, terminal_{spawn,write,read,list,close}, app_status
  • `mcp_register.rs` — atomic-write JSON/YAML config patcher for ~/.claude.json + ~/.vexis/mcp-servers.yaml

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:

Trigger When
App start ~5s after launch, background poll walks every host, version-checks, silently re-bootstraps any that's behind
Test connection Surfaces "v on host, v bundled — Install to upgrade"
Push workspace Always version-checks before pushing; re-uploads + restarts if older

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`.

  1. HTTP transport (not Unix-socket)
  2. `Identity` enum passthrough on every dispatch
  3. Nullable `owner_id` column on workspaces
  4. No Better-Auth coupling in the daemon

Test plan

  • 53 unit tests pass (`cargo test --lib -- remote:: ssh::bootstrap:: automations::service::`)
  • 10 e2e integration tests pass (`cargo test --test codemux_remote_serve_mcp`)
  • 3 pre-existing pty-daemon binary tests still green
  • Localhost SSH upload roundtrip test exercises the exact OpenSSH 10.3 + tilde-expansion path the user hit
  • Manual e2e: real daemon boot against a /tmp fixture HOME, both .claude.json + .vexis/mcp-servers.yaml correctly updated, idempotent on re-run (SHA1 hashes match)
  • Desktop binary still compiles, broader 1520-test lib suite green (1 pre-existing env failure on agent_browser unrelated to this work)

Out of scope (deferred, tracked in `docs/plans/mcp-on-remote.md`)

  • Extracting `codemux_core` from the Tauri crate (multi-PR refactor, ~98K LOC + 403 Tauri couplings — the headless daemon ships without it via its own self-contained implementation)
  • Desktop pull-workspace UI (wire protocol on daemon side is ready)
  • `codemux --host ` flag for desktop CLI
  • Optional cloud relay (separate v2 effort, paid tier)

Companion docs PR

codemux-sitev2 already has matching commits on main:

  • 4d520a6 docs: cover codemux-remote serve + mcp (headless daemon for servers)
  • 261a215 docs(mcp-server): drop Telegram/Tailscale specifics, keep it generic
  • f5c4780 docs(settings): document codemux-remote upgrade triggers
  • 594dafd docs: cover the two new auto-everything behaviours

Zeus-Deus added 6 commits May 26, 2026 18:28
`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.
@Zeus-Deus Zeus-Deus merged commit fc4db61 into main May 26, 2026
2 checks passed
@Zeus-Deus Zeus-Deus deleted the mcp-on-remote branch May 26, 2026 20:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant