Skip to main content

agentmux_srv/backend/remote/
helper_install.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Installing AgentMux's helper (`agentmux-remote`) on an SSH host, for
5//! durable sessions (spec §6.2 of
6//! SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md).
7//!
8//! 1. Probe the host over the pane's own ssh: `uname -sm`, and whether this
9//!    version's helper is already at `~/.agentmux-remote/bin/<version>/`.
10//! 2. Get the matching static build from this package, which carries all four
11//!    (`tools/remote/<target>/`, spec §9.1; on macOS the Linux builds sit in
12//!    `Contents/Resources/remote/<target>/`). A developer can point
13//!    `AGENTMUX_REMOTE_HELPER_DIR` at their own builds
14//!    (`scripts/build-remote-helpers.sh` writes the same layout).
15//! 3. Upload it over that ssh (`cat >` a temp file in an owner-only folder;
16//!    no scp or sftp needed on the host), check its hash there, then
17//!    `chmod 700` and move it into place.
18//!
19//! Every remote script runs under `sh -c '...'`, so it works whatever the
20//! user's login shell is.
21
22use std::path::{Path, PathBuf};
23
24/// The helper's build target for `uname -sm` output (`Linux x86_64`,
25/// `Darwin arm64`, ...). `None` for a platform without a build.
26pub fn target_for(uname: &str) -> Option<&'static str> {
27    let mut words = uname.split_whitespace();
28    let os = words.next()?;
29    let arch = words.next()?;
30    Some(match (os, arch) {
31        ("Linux", "x86_64" | "amd64") => "x86_64-unknown-linux-musl",
32        ("Linux", "aarch64" | "arm64") => "aarch64-unknown-linux-musl",
33        ("Darwin", "x86_64") => "x86_64-apple-darwin",
34        ("Darwin", "arm64" | "aarch64") => "aarch64-apple-darwin",
35        _ => return None,
36    })
37}
38
39/// The flat name a build had as a release asset, `agentmux-remote-<version>-<target>`.
40/// Still accepted in `AGENTMUX_REMOTE_HELPER_DIR` beside the packaged layout.
41pub fn asset_name(version: &str, target: &str) -> String {
42    format!("agentmux-remote-{version}-{target}")
43}
44
45const HELPER_FILE: &str = "agentmux-remote";
46
47/// Where a package keeps the build for `target`, relative to srv's own folder
48/// (`exe_dir`): `tools/remote/<target>/` beside the other bundled tools, or, in
49/// a macOS app, `../Resources/remote/<target>/`, because the bundle seal only
50/// allows Mach-O code under `Contents/MacOS` and the Linux builds are data there.
51pub fn packaged_paths(exe_dir: &Path, target: &str) -> [PathBuf; 2] {
52    [
53        exe_dir.join("tools").join("remote").join(target).join(HELPER_FILE),
54        exe_dir.join("..").join("Resources").join("remote").join(target).join(HELPER_FILE),
55    ]
56}
57
58/// The paths to try for `target`, in order: `dev_dir`
59/// (`AGENTMUX_REMOTE_HELPER_DIR`) alone when it is set, else this package's.
60fn candidate_paths(dev_dir: Option<&Path>, exe_dir: Option<&Path>, version: &str, target: &str) -> Vec<PathBuf> {
61    match (dev_dir, exe_dir) {
62        (Some(dir), _) => vec![
63            dir.join(target).join(HELPER_FILE),
64            dir.join(asset_name(version, target)),
65        ],
66        (None, Some(exe_dir)) => packaged_paths(exe_dir, target).to_vec(),
67        (None, None) => Vec::new(),
68    }
69}
70
71pub fn sha256_hex(bytes: &[u8]) -> String {
72    use sha2::{Digest, Sha256};
73    hex::encode(Sha256::digest(bytes))
74}
75
76/// The helper's folder on the host, relative to the remote home.
77fn remote_dir(version: &str) -> String {
78    format!("$HOME/.agentmux-remote/bin/{version}")
79}
80
81/// One remote command: `sh -c '<script>'`, single-quoted for whatever shell
82/// the user logs in with.
83fn sh(script: &str) -> String {
84    format!("sh -c {}", super::ssh::quote(script))
85}
86
87/// Prints `uname -sm`, then this version's helper's `version` line if it is
88/// installed and runs.
89pub fn probe_command(version: &str) -> String {
90    let dir = remote_dir(version);
91    sh(&format!(
92        "uname -sm; p=\"{dir}/agentmux-remote\"; if [ -x \"$p\" ]; then \"$p\" version 2>/dev/null; fi; true"
93    ))
94}
95
96/// Reads the binary from stdin into a temp file in an owner-only folder.
97/// A temp file of this install's own (`tag`: the pane's session id, letters,
98/// digits, `-` and `_`), so two panes installing on one host at once never
99/// write, check or remove each other's upload.
100fn temp_name(tag: &str) -> String {
101    let tag: String = tag
102        .chars()
103        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
104        .collect();
105    format!("agentmux-remote.{tag}.tmp")
106}
107
108pub fn upload_command(version: &str, tag: &str) -> String {
109    let dir = remote_dir(version);
110    let tmp = temp_name(tag);
111    sh(&format!(
112        "umask 077; mkdir -p \"{dir}\" && cat > \"{dir}/{tmp}\""
113    ))
114}
115
116/// Checks the uploaded file's hash; on a match makes it the helper, else
117/// removes it. Prints `ok` or `mismatch <hash>`.
118pub fn install_command(version: &str, tag: &str, sha256: &str) -> String {
119    let dir = remote_dir(version);
120    let tmp = temp_name(tag);
121    sh(&format!(
122        "f=\"{dir}/{tmp}\"; \
123         h=$( (sha256sum \"$f\" 2>/dev/null || shasum -a 256 \"$f\") | cut -d' ' -f1); \
124         if [ \"$h\" = \"{sha256}\" ]; then chmod 700 \"$f\" && mv -f \"$f\" \"{dir}/agentmux-remote\" && echo ok; \
125         else rm -f \"$f\"; echo \"mismatch $h\"; fi"
126    ))
127}
128
129/// Removes the helper from the host (SPEC_REMOTES_PANE_2026_10_05.md §4.9):
130/// ends every session each installed version's daemon holds, then deletes
131/// `~/.agentmux-remote`. The daemon, with no sessions left, exits on its own.
132/// Prints `ok`.
133pub fn remove_command() -> String {
134    sh("for p in \"$HOME\"/.agentmux-remote/bin/*/agentmux-remote; do \
135          [ -x \"$p\" ] || continue; \
136          \"$p\" list 2>/dev/null | while read -r id rest; do \
137            \"$p\" end --session \"$id\" >/dev/null 2>&1; \
138          done; \
139        done; \
140        rm -rf \"$HOME/.agentmux-remote\" && echo ok")
141}
142
143/// What a probe found.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct Probe {
146    pub uname: String,
147    /// This version's helper is installed and answered `version`.
148    pub installed: bool,
149}
150
151/// Read a probe's output.
152pub fn parse_probe(out: &str, version: &str) -> Probe {
153    let mut lines = out.lines().map(str::trim).filter(|l| !l.is_empty());
154    let uname = lines.next().unwrap_or_default().to_string();
155    let want = format!(
156        "agentmux-remote {version} protocol {}",
157        agentmux_remote::frame::PROTOCOL
158    );
159    let installed = lines.any(|l| l == want);
160    Probe { uname, installed }
161}
162
163/// Where this version's build for `target` is on this computer (the same
164/// places [`local_build`] reads): what WSL runs in place.
165#[cfg(windows)]
166pub fn local_path(version: &str, target: &str) -> Result<PathBuf, String> {
167    let dev_dir = std::env::var_os("AGENTMUX_REMOTE_HELPER_DIR").map(PathBuf::from);
168    let exe_dir = std::env::current_exe()
169        .ok()
170        .and_then(|exe| exe.parent().map(Path::to_path_buf));
171    let paths = candidate_paths(dev_dir.as_deref(), exe_dir.as_deref(), version, target);
172    paths.iter().find(|p| p.is_file()).cloned().ok_or_else(|| {
173        format!(
174            "this AgentMux build doesn't include the helper for {target} (looked in {})",
175            paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
176        )
177    })
178}
179
180/// This version's build for `target`, and its hash: from
181/// `AGENTMUX_REMOTE_HELPER_DIR` when set (a developer's own builds), else from
182/// this package (spec §9.1). Nothing is downloaded.
183pub fn local_build(version: &str, target: &str) -> Result<(Vec<u8>, String), String> {
184    let dev_dir = std::env::var_os("AGENTMUX_REMOTE_HELPER_DIR").map(PathBuf::from);
185    let exe_dir = std::env::current_exe()
186        .ok()
187        .and_then(|exe| exe.parent().map(Path::to_path_buf));
188    read_first(&candidate_paths(dev_dir.as_deref(), exe_dir.as_deref(), version, target), target)
189}
190
191fn read_first(paths: &[PathBuf], target: &str) -> Result<(Vec<u8>, String), String> {
192    for path in paths {
193        if let Ok(bytes) = std::fs::read(path) {
194            let hash = sha256_hex(&bytes);
195            return Ok((bytes, hash));
196        }
197    }
198    Err(format!(
199        "this AgentMux build doesn't include the helper for {target} (looked in {})",
200        paths
201            .iter()
202            .map(|p| p.display().to_string())
203            .collect::<Vec<_>>()
204            .join(", ")
205    ))
206}
207
208/// Put this version's helper on `host` if it is not there (spec §6.2): probe
209/// the platform (recorded under `connection` for Connectors → Remotes), get the
210/// matching build (checked against the release's hashes), upload it over
211/// ssh, check its hash there, move it in. `tag` names
212/// this install's own temp file (letters, digits, `-`, `_`), so two installs
213/// on one host never touch each other's upload. `announce` hears the size
214/// just before the upload, for whoever is waiting to say what is happening.
215pub async fn ensure<F, Fut>(
216    host: &super::host::HostSsh,
217    connection: &str,
218    tag: &str,
219    announce: F,
220) -> Result<(), String>
221where
222    F: FnOnce(usize) -> Fut,
223    Fut: std::future::Future<Output = ()>,
224{
225    let run = |cmd: String, input: Option<Vec<u8>>| async move {
226        host.run(&cmd, input, std::time::Duration::from_secs(120))
227            .await?
228            .ok()
229    };
230    let version = env!("CARGO_PKG_VERSION");
231    let probe = parse_probe(&run(probe_command(version), None).await?, version);
232    // Connectors → Remotes shows the host's platform (SPEC_REMOTES_PANE_2026_10_05.md §4.6).
233    super::helper_hosts::record_uname(connection, &probe.uname);
234    if probe.installed {
235        return Ok(());
236    }
237    let target = target_for(&probe.uname).ok_or_else(|| {
238        format!(
239            "AgentMux has no helper build for this host ({})",
240            probe.uname.trim()
241        )
242    })?;
243    let (bytes, hash) = local_build(version, target)?;
244    announce(bytes.len()).await;
245    run(upload_command(version, tag), Some(bytes)).await?;
246    let out = run(install_command(version, tag, &hash), None).await?;
247    if out.trim() != "ok" {
248        return Err(format!(
249            "the uploaded helper did not check out on the host ({})",
250            out.trim()
251        ));
252    }
253    Ok(())
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn each_supported_platform_has_its_build() {
262        assert_eq!(
263            target_for("Linux x86_64"),
264            Some("x86_64-unknown-linux-musl")
265        );
266        assert_eq!(
267            target_for("Linux aarch64\n"),
268            Some("aarch64-unknown-linux-musl")
269        );
270        assert_eq!(target_for("Darwin arm64"), Some("aarch64-apple-darwin"));
271        assert_eq!(target_for("Darwin x86_64"), Some("x86_64-apple-darwin"));
272        assert_eq!(target_for("FreeBSD amd64"), None);
273        assert_eq!(target_for("Linux armv7l"), None);
274        assert_eq!(target_for(""), None);
275    }
276
277    fn put(path: &Path, bytes: &[u8]) {
278        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
279        std::fs::write(path, bytes).unwrap();
280    }
281
282    #[test]
283    fn a_package_carries_the_helper_beside_its_other_tools() {
284        let dir = tempfile::tempdir().unwrap();
285        let exe_dir = dir.path().join("runtime");
286        let t = "x86_64-unknown-linux-musl";
287        put(&exe_dir.join("tools").join("remote").join(t).join("agentmux-remote"), b"linux build");
288        let (bytes, hash) = read_first(&candidate_paths(None, Some(&exe_dir), "0.59.9", t), t).unwrap();
289        assert_eq!(bytes, b"linux build");
290        assert_eq!(hash, sha256_hex(b"linux build"));
291    }
292
293    #[test]
294    fn a_macos_app_keeps_the_linux_builds_in_its_resources() {
295        let dir = tempfile::tempdir().unwrap();
296        let contents = dir.path().join("AgentMux.app").join("Contents");
297        let exe_dir = contents.join("MacOS");
298        std::fs::create_dir_all(&exe_dir).unwrap();
299        let t = "aarch64-unknown-linux-musl";
300        put(&contents.join("Resources").join("remote").join(t).join("agentmux-remote"), b"arm build");
301        let (bytes, _) = read_first(&candidate_paths(None, Some(&exe_dir), "0.59.9", t), t).unwrap();
302        assert_eq!(bytes, b"arm build");
303    }
304
305    #[test]
306    fn a_developers_folder_wins_in_either_layout() {
307        let dir = tempfile::tempdir().unwrap();
308        let exe_dir = dir.path().join("runtime");
309        let dev = dir.path().join("dev");
310        let t = "x86_64-apple-darwin";
311        put(&exe_dir.join("tools").join("remote").join(t).join("agentmux-remote"), b"packaged");
312        put(&dev.join(t).join("agentmux-remote"), b"dev, script layout");
313        let (bytes, _) = read_first(&candidate_paths(Some(&dev), Some(&exe_dir), "0.59.9", t), t).unwrap();
314        assert_eq!(bytes, b"dev, script layout");
315
316        let flat = dir.path().join("flat");
317        put(&flat.join(asset_name("0.59.9", t)), b"dev, flat name");
318        let (bytes, _) = read_first(&candidate_paths(Some(&flat), Some(&exe_dir), "0.59.9", t), t).unwrap();
319        assert_eq!(bytes, b"dev, flat name");
320    }
321
322    #[test]
323    fn a_build_without_the_helper_says_so_and_downloads_nothing() {
324        let dir = tempfile::tempdir().unwrap();
325        let t = "aarch64-apple-darwin";
326        let err = read_first(&candidate_paths(None, Some(dir.path()), "0.59.9", t), t).unwrap_err();
327        assert!(err.contains("doesn't include the helper for aarch64-apple-darwin"), "{err}");
328        assert!(!err.contains("http"), "{err}");
329    }
330
331    #[test]
332    fn a_probe_reads_the_platform_and_whether_this_version_is_there() {
333        let v = "0.59.8";
334        let ok = format!(
335            "Linux x86_64\nagentmux-remote {v} protocol {}\n",
336            agentmux_remote::frame::PROTOCOL
337        );
338        assert_eq!(
339            parse_probe(&ok, v),
340            Probe {
341                uname: "Linux x86_64".into(),
342                installed: true
343            }
344        );
345        assert!(!parse_probe("Linux x86_64\n", v).installed);
346        assert!(
347            !parse_probe("Linux x86_64\nagentmux-remote 0.59.7 protocol 1\n", v).installed,
348            "another version"
349        );
350    }
351
352    #[test]
353    fn the_remote_scripts_run_under_sh_whatever_the_login_shell() {
354        for cmd in [
355            probe_command("0.59.8"),
356            upload_command("0.59.8", "amx-1"),
357            install_command("0.59.8", "amx-1", &"a".repeat(64)),
358        ] {
359            assert!(cmd.starts_with("sh -c '"), "{cmd}");
360            assert!(cmd.ends_with('\''), "{cmd}");
361            assert!(cmd.contains("$HOME/.agentmux-remote/bin/0.59.8"), "{cmd}");
362        }
363        assert!(install_command("0.59.8", "amx-1", "abc").contains("= \"abc\" ]"));
364        // Each install its own temp file; nothing but the id's own characters.
365        assert!(upload_command("0.59.8", "amx-1").contains("agentmux-remote.amx-1.tmp"));
366        assert!(upload_command("0.59.8", "a;b $x").contains("agentmux-remote.abx.tmp"));
367    }
368
369    /// The scripts do what they say in a real `sh` (where there is one).
370    #[cfg(unix)]
371    #[test]
372    fn upload_then_install_puts_a_checked_binary_in_place() {
373        let home = tempfile::tempdir().unwrap();
374        let run = |cmd: &str, input: &[u8]| {
375            use std::io::Write;
376            let mut child = std::process::Command::new("sh")
377                .arg("-c")
378                .arg(cmd)
379                .env("HOME", home.path())
380                .stdin(std::process::Stdio::piped())
381                .stdout(std::process::Stdio::piped())
382                .spawn()
383                .unwrap();
384            child.stdin.take().unwrap().write_all(input).unwrap();
385            String::from_utf8(child.wait_with_output().unwrap().stdout).unwrap()
386        };
387        let body = b"#!/bin/sh\necho agentmux-remote 0.59.8 protocol 1\n";
388        run(&upload_command("0.59.8", "s1"), body);
389        assert!(run(&install_command("0.59.8", "s1", &"0".repeat(64)), b"").starts_with("mismatch"));
390        // Another pane's upload in flight is untouched by this one's install.
391        run(&upload_command("0.59.8", "s2"), b"other");
392        run(&upload_command("0.59.8", "s1"), body);
393        assert_eq!(
394            run(&install_command("0.59.8", "s1", &sha256_hex(body)), b"").trim(),
395            "ok"
396        );
397        assert!(home
398            .path()
399            .join(".agentmux-remote/bin/0.59.8/agentmux-remote.s2.tmp")
400            .exists());
401        let probe = parse_probe(&run(&probe_command("0.59.8"), b""), "0.59.8");
402        assert!(probe.installed, "{probe:?}");
403    }
404
405    /// Removing ends each session through the helper, then deletes its folder.
406    #[cfg(unix)]
407    #[test]
408    fn remove_ends_the_sessions_and_deletes_the_folder() {
409        let home = tempfile::tempdir().unwrap();
410        let dir = home.path().join(".agentmux-remote/bin/0.59.8");
411        std::fs::create_dir_all(&dir).unwrap();
412        let ended = home.path().join("ended");
413        let fake = format!(
414            "#!/bin/sh\ncase \"$1\" in list) echo 'amx-1 10 -'; echo 'amx-2 5 0';; end) echo \"$3\" >> '{}';; esac\n",
415            ended.display()
416        );
417        let bin = dir.join("agentmux-remote");
418        std::fs::write(&bin, fake).unwrap();
419        use std::os::unix::fs::PermissionsExt;
420        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o700)).unwrap();
421        let out = std::process::Command::new("sh")
422            .arg("-c")
423            .arg(remove_command())
424            .env("HOME", home.path())
425            .output()
426            .unwrap();
427        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok");
428        assert_eq!(std::fs::read_to_string(&ended).unwrap(), "amx-1\namx-2\n");
429        assert!(!home.path().join(".agentmux-remote").exists());
430    }
431}