1use std::path::{Path, PathBuf};
23
24pub 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
39pub fn asset_name(version: &str, target: &str) -> String {
42 format!("agentmux-remote-{version}-{target}")
43}
44
45const HELPER_FILE: &str = "agentmux-remote";
46
47pub 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
58fn 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
76fn remote_dir(version: &str) -> String {
78 format!("$HOME/.agentmux-remote/bin/{version}")
79}
80
81fn sh(script: &str) -> String {
84 format!("sh -c {}", super::ssh::quote(script))
85}
86
87pub 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
96fn 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
116pub 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
129pub 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#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct Probe {
146 pub uname: String,
147 pub installed: bool,
149}
150
151pub 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#[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
180pub 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
208pub 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 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 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 #[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 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 #[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}