Skip to main content

agentmux_srv/backend/remote/
ssh.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! SSH terminals (P2 of SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md).
5//!
6//! A pane on an SSH connection runs the system `ssh` in its PTY (spec §3.1):
7//! the user's own `~/.ssh/config`, keys, agent and known hosts apply, and
8//! `ssh` asks for a password, a passphrase or a new host key in the terminal
9//! itself, where the user answers it. The askpass bridge (§5.3), which an
10//! agent's non-interactive use needs, comes later.
11
12use std::path::{Path, PathBuf};
13
14use super::conn::SshDest;
15
16/// `ssh` exits 255 when it could not connect, could not authenticate, or the
17/// connection dropped; any other code is the remote command's own.
18pub const EXIT_CONNECTION_FAILED: u32 = 255;
19
20/// The `ssh` to run: the first on `PATH`, then (Windows) the OpenSSH client
21/// Windows installs, which an inherited `PATH` can lack.
22///
23/// On Windows a native client that can force askpass (OpenSSH 8.4+) comes
24/// first, before an MSYS2 or Cygwin build on `PATH` (Git for Windows' own
25/// `usr/bin/ssh.exe`, there when AgentMux is started from Git Bash): that
26/// build resolves names with its own POSIX resolver, which can't find a
27/// machine on the local network by name ("Could not resolve hostname"), and
28/// doesn't use Windows' ssh-agent. A native client older than 8.4 is not
29/// preferred: an agent's ssh relies on `SSH_ASKPASS_REQUIRE=force`
30/// (askpass.rs) to keep its prompts out of the agent's terminal. With no such
31/// client, the order is the plain one above. For anything an agent can drive;
32/// the user's own pane uses [`binary_for_user`].
33pub fn binary() -> Option<PathBuf> {
34    pick(on_path(), windows_client(), |p| {
35        !cfg!(windows) || (!is_posix_layer_build(p) && forces_askpass(p))
36    })
37}
38
39/// [`binary`] for the user's own interactive ssh pane, whose prompts are the
40/// user's to answer in it: a native client is preferred whatever its version
41/// (Windows 10's inbox OpenSSH is 8.1), still for its name resolution.
42pub fn binary_for_user() -> Option<PathBuf> {
43    pick(on_path(), windows_client(), |p| !cfg!(windows) || !is_posix_layer_build(p))
44}
45
46fn on_path() -> impl Iterator<Item = PathBuf> {
47    which::which_all("ssh").into_iter().flatten()
48}
49
50/// Windows' own OpenSSH client, if installed; `None` elsewhere.
51fn windows_client() -> Option<PathBuf> {
52    #[cfg(windows)]
53    {
54        let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
55        Some(Path::new(&root).join(r"System32\OpenSSH\ssh.exe")).filter(|p| p.is_file())
56    }
57    #[cfg(not(windows))]
58    {
59        None
60    }
61}
62
63/// [`binary`]'s order: the first `preferred` ssh on `PATH`, then Windows'
64/// own client if preferred, else the first on `PATH`, else Windows' client.
65fn pick(
66    on_path: impl IntoIterator<Item = PathBuf>,
67    windows_client: Option<PathBuf>,
68    preferred: impl Fn(&Path) -> bool,
69) -> Option<PathBuf> {
70    let on_path: Vec<PathBuf> = on_path.into_iter().collect();
71    if let Some(p) = on_path.iter().find(|p| preferred(p)) {
72        return Some(p.clone());
73    }
74    if let Some(w) = windows_client.as_ref().filter(|w| preferred(w)) {
75        return Some(w.clone());
76    }
77    on_path.into_iter().next().or(windows_client)
78}
79
80/// Whether `ssh` is OpenSSH 8.4 or newer, which honours
81/// `SSH_ASKPASS_REQUIRE=force`; asked once per binary (`ssh -V`).
82fn forces_askpass(ssh: &Path) -> bool {
83    static SEEN: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<PathBuf, bool>>> =
84        std::sync::OnceLock::new();
85    let seen = SEEN.get_or_init(Default::default);
86    if let Some(&ok) = seen.lock().unwrap_or_else(|e| e.into_inner()).get(ssh) {
87        return ok;
88    }
89    let mut cmd = std::process::Command::new(ssh);
90    cmd.arg("-V").stdin(std::process::Stdio::null());
91    #[cfg(windows)]
92    {
93        use agentmux_common::win32::NoWindow;
94        cmd.no_window();
95    }
96    let ok = cmd
97        .output()
98        .ok()
99        .and_then(|out| openssh_version(&String::from_utf8_lossy(&out.stderr)))
100        .is_some_and(|v| v >= (8, 4));
101    seen.lock().unwrap_or_else(|e| e.into_inner()).insert(ssh.to_path_buf(), ok);
102    ok
103}
104
105/// `(major, minor)` from `ssh -V`'s "OpenSSH_9.5p2, …" or
106/// "OpenSSH_for_Windows_8.1p1, …".
107fn openssh_version(banner: &str) -> Option<(u32, u32)> {
108    let rest = &banner[banner.find("OpenSSH")?..];
109    let start = rest.find(|c: char| c.is_ascii_digit())?;
110    let mut parts = rest[start..].split(|c: char| !c.is_ascii_digit());
111    let major = parts.next()?.parse().ok()?;
112    let minor = parts.next()?.parse().ok()?;
113    Some((major, minor))
114}
115
116/// An `ssh.exe` built for MSYS2 or Cygwin: its runtime DLL sits beside it.
117/// Never true off Windows.
118fn is_posix_layer_build(ssh: &Path) -> bool {
119    cfg!(windows)
120        && ssh
121            .parent()
122            .is_some_and(|dir| dir.join("msys-2.0.dll").is_file() || dir.join("cygwin1.dll").is_file())
123}
124
125/// What to say when there is no `ssh`, with how to get it on this platform.
126pub fn missing_binary_message() -> String {
127    let how = if cfg!(windows) {
128        "install the OpenSSH Client (Settings > System > Optional features)"
129    } else if cfg!(target_os = "macos") {
130        "macOS includes it at /usr/bin/ssh; check that PATH reaches it"
131    } else {
132        "install your distribution's openssh-client package"
133    };
134    format!("SSH terminals need the ssh command, which was not found: {how}")
135}
136
137/// The `ssh` arguments for a pane. `control_dir`, on macOS and Linux, is where
138/// connection-sharing sockets live (`ControlMaster`), so a second pane on the
139/// same connection reuses the first one's and asks for nothing.
140pub fn launch(
141    dest: &SshDest,
142    cmd: &str,
143    cmd_args: &[String],
144    cwd: &str,
145    control_dir: Option<&Path>,
146) -> Vec<String> {
147    let mut args: Vec<String> = vec!["-tt".into()];
148    let mut opt = |o: String| {
149        args.push("-o".into());
150        args.push(o);
151    };
152    // A dead link ends ssh within about 45 s instead of the TCP timeout (§7.5).
153    opt("ServerAliveInterval=15".into());
154    opt("ServerAliveCountMax=3".into());
155    // Which AgentMux this is (`pane_env::build_info`) rides along with
156    // TERM_PROGRAM. sshd passes a SetEnv name on only where its AcceptEnv
157    // lists it (`AcceptEnv AGENTMUX_*`), and drops it quietly otherwise. Not
158    // prefixed to the remote command instead: durable panes send their own
159    // control commands through here, to hosts that may not run a POSIX shell.
160    let build_info = crate::backend::pane_env::build_info();
161    let set_env = std::iter::once("TERM_PROGRAM=agentmux".to_string())
162        .chain(build_info.iter().map(|(k, v)| format!("{k}={v}")))
163        .collect::<Vec<_>>()
164        .join(" ");
165    opt(format!("SetEnv={set_env}"));
166    if let Some(dir) = control_dir {
167        opt("ControlMaster=auto".into());
168        opt(control_path_option(dir, dest));
169        opt("ControlPersist=10m".into());
170    }
171    push_destination(&mut args, dest);
172    if let Some(remote) = remote_command(cmd, cmd_args, cwd) {
173        args.push(remote);
174    }
175    args
176}
177
178/// Name the pane in [`launch`]'s `SetEnv` too (`AGENTMUX_BLOCKID`), so a host
179/// that accepts it (`AcceptEnv AGENTMUX_*`) lets the pane's stats badge find
180/// its shell there (`PaneProcs`). Dropped quietly by any other host, like the
181/// rest of that `SetEnv`. A block id is AgentMux's own UUID; anything with
182/// another character is left out rather than quoted.
183pub fn set_pane_env(args: &mut [String], block_id: &str) {
184    if block_id.is_empty() || !block_id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
185        return;
186    }
187    if let Some(set_env) = args.iter_mut().find(|a| a.starts_with("SetEnv=")) {
188        set_env.push_str(&format!(" AGENTMUX_BLOCKID={block_id}"));
189    }
190}
191
192/// The port, if any, then `--` and the destination: `--` ends ssh's options,
193/// so the destination can never be read as one (conn.rs also refuses a
194/// leading `-`).
195fn push_destination(args: &mut Vec<String>, dest: &SshDest) {
196    if let Some(port) = dest.port {
197        args.push("-p".into());
198        args.push(port.to_string());
199    }
200    args.push("--".into());
201    args.push(dest.destination.clone());
202}
203
204/// Hex characters of the connection name's hash in a socket's name.
205const NAME_HASH_HEX: usize = 8;
206
207/// The longest socket name ssh binds in a control dir: the name's hash, `-`,
208/// the 40-hex `%C`, and while binding `.` plus 16 random characters.
209#[cfg(any(unix, test))]
210const SOCKET_NAME_MAX: usize = NAME_HASH_HEX + 1 + 40 + 17;
211
212/// `ControlPath=` for `dest`'s shared connection in `dir`: `<name hash>-%C`.
213/// ssh's `%C` hashes the user, host and port ssh resolves, not the name it was
214/// given, so two aliases for one account (one with a key, one password-only)
215/// shared whichever connection logged in first, and the password-only one got
216/// in without asking (#4513). The connection's own name keeps them apart; `%C`
217/// stays so an alias pointed elsewhere gets a new connection.
218fn control_path_option(dir: &Path, dest: &SshDest) -> String {
219    use sha2::{Digest, Sha256};
220    let name = hex::encode(&Sha256::digest(dest.name().as_bytes())[..NAME_HASH_HEX / 2]);
221    format!("ControlPath={}", dir.join(format!("{name}-%C")).display())
222}
223
224/// What to ask a shared connection (`ssh -O`).
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum Control {
227    /// Answers (exit 0) while the connection is up, which it is only once the
228    /// user has logged in: at a password prompt there is none yet.
229    Check,
230    /// Close it: every session on it ends, with ssh's exit 255.
231    Exit,
232}
233
234/// The `ssh` arguments asking `dest`'s shared connection in `dir` to `op`:
235/// the same socket a pane's [`launch`] shares.
236pub fn control(dest: &SshDest, dir: &Path, op: Control) -> Vec<String> {
237    let word = match op {
238        Control::Check => "check",
239        Control::Exit => "exit",
240    };
241    let mut args = vec!["-o".into(), control_path_option(dir, dest), "-O".into(), word.into()];
242    push_destination(&mut args, dest);
243    args
244}
245
246/// The `ssh` arguments for one command run without a terminal (an agent's
247/// `Shell`): [`launch`]'s, with `-T` in place of `-tt`, since its output is
248/// read, not a session shown.
249pub fn launch_exec(
250    dest: &SshDest,
251    cmd: &str,
252    cwd: &str,
253    control_dir: Option<&Path>,
254) -> Vec<String> {
255    let mut args = launch(dest, cmd, &[], cwd, control_dir);
256    args[0] = "-T".into();
257    args
258}
259
260/// The command line `ssh` hands to the remote user's login shell: the pane's
261/// command, run in `cwd` when one is set, or for a plain pane with a cwd, the
262/// login shell started there. `None` for a plain pane with no cwd: ssh then
263/// starts the login shell itself.
264///
265/// The remote shell parses this, whatever it is (bash, zsh, fish), so only
266/// single-quoted words, a leading `~/`, `;`, `exec` and `"$SHELL"` appear, which
267/// all of them read the same way.
268pub fn remote_command(cmd: &str, cmd_args: &[String], cwd: &str) -> Option<String> {
269    let run = if cmd.is_empty() {
270        None
271    } else if cmd_args.is_empty() {
272        // A command line, as a local pane runs it through `sh -c`.
273        Some(cmd.to_string())
274    } else {
275        Some(
276            std::iter::once(cmd)
277                .chain(cmd_args.iter().map(String::as_str))
278                .map(quote)
279                .collect::<Vec<_>>()
280                .join(" "),
281        )
282    };
283    // A failed `cd` prints its own error and the shell goes on, in the
284    // remote home: shown, not hidden.
285    let cd = (!cwd.trim().is_empty()).then(|| format!("cd {}; ", quote_path(cwd)));
286    match (cd, run) {
287        (None, None) => None,
288        (None, Some(run)) => Some(run),
289        (Some(cd), Some(run)) => Some(format!("{cd}{run}")),
290        (Some(cd), None) => Some(format!("{cd}exec \"$SHELL\" -l")),
291    }
292}
293
294/// [`quote`] for a remote path, leaving a leading `~` or `~/` unquoted so the
295/// remote shell expands it to the remote home (OSC 7 and users write it).
296pub fn quote_path(path: &str) -> String {
297    if path == "~" {
298        return "~".to_string();
299    }
300    match path.strip_prefix("~/") {
301        Some("") => "~/".to_string(),
302        Some(rest) => format!("~/{}", quote(rest)),
303        None => quote(path),
304    }
305}
306
307/// One word for a POSIX shell: single-quoted, with `'` written as `'\''`.
308pub fn quote(word: &str) -> String {
309    if !word.is_empty()
310        && word
311            .chars()
312            .all(|c| c.is_ascii_alphanumeric() || "-_./=:@%+,".contains(c))
313    {
314        return word.to_string();
315    }
316    format!("'{}'", word.replace('\'', r"'\''"))
317}
318
319/// Whether `ssh` ending with `code` means it had no connection, or lost it,
320/// rather than that the remote shell ended (`exit` included). A session on a
321/// shared connection that was closed or died ends this way too.
322pub fn connection_lost(code: u32) -> bool {
323    code == EXIT_CONNECTION_FAILED
324}
325
326/// Whether a pane's `ssh`, ended with `code`, lost its connection (or never
327/// had one). `stopped_by_srv`: srv ended it itself (the pane closed, or
328/// restarted), and ssh then exits 255 too (closing its pty does, and a session
329/// on a shared connection does on SIGTERM), which is no lost connection.
330pub fn pane_lost_connection(code: Option<u32>, stopped_by_srv: bool) -> bool {
331    !stopped_by_srv && code.is_some_and(connection_lost)
332}
333
334/// What the connection's status says when a pane's `ssh` lost it.
335pub fn lost_message(dest: &str) -> String {
336    format!("ssh could not connect to {dest}, or the connection dropped (see the terminal for ssh's own message)")
337}
338
339/// The line a plain SSH pane shows, under ssh's own reason, when it has no
340/// connection: the pane stays, and Enter starts ssh again.
341pub fn lost_note(conn: &str) -> String {
342    format!("No connection to {conn}: it dropped, or could not be made. Press Enter to try again.")
343}
344
345/// Where `ControlMaster` sockets go (macOS and Linux; Windows' OpenSSH has no
346/// connection sharing): a private, owner-only directory, since anyone who can
347/// open a socket there can use the connection. `None` (no sharing; ssh still
348/// connects) if it can't be made so, or a socket in it would be too long.
349///
350/// Not under `config_home`: a channel's config dir is deep
351/// (`/Users/<name>/.agentmux/channels/stable/config`), and a Unix socket path
352/// has to fit in `sun_path`, 104 bytes on macOS and 108 on Linux. Under it,
353/// ssh failed with "too long for Unix domain socket" right after logging in,
354/// on every macOS install. So: the launcher's short runtime dir
355/// (`$XDG_RUNTIME_DIR/agentmux`, else `/tmp/agentmux-<uid>`, as
356/// `launcher::ipc::ipc_socket_dir_path`) and one `ssh-<hash>` per channel.
357#[cfg(unix)]
358pub fn control_dir(config_home: &Path) -> Option<PathBuf> {
359    let base = std::env::var_os("XDG_RUNTIME_DIR")
360        .map(PathBuf::from)
361        .filter(|d| d.is_absolute())
362        .map(|d| d.join("agentmux"))
363        .unwrap_or_else(|| PathBuf::from(format!("/tmp/agentmux-{}", unsafe { libc::getuid() })));
364    control_dir_in(&base, config_home)
365}
366
367#[cfg(not(unix))]
368pub fn control_dir(_config_home: &Path) -> Option<PathBuf> {
369    None
370}
371
372/// [`control_dir`] for this srv's channel: where every `ssh` it runs to a host
373/// (panes, agents' commands, the helper, files) shares one connection. `None`
374/// where ssh can't share connections, and then there is nothing to ask `-O`.
375pub fn shared_control_dir() -> Option<PathBuf> {
376    control_dir(&crate::backend::base::get_mux_config_dir())
377}
378
379/// [`control_dir`] under `base`: `base/ssh-<8 hex of config_home>`, each
380/// level owner-only. A test can pass its own `base`.
381#[cfg(unix)]
382fn control_dir_in(base: &Path, config_home: &Path) -> Option<PathBuf> {
383    use sha2::{Digest, Sha256};
384    let channel = hex::encode(&Sha256::digest(config_home.as_os_str().as_encoded_bytes())[..4]);
385    let dir = base.join(format!("ssh-{channel}"));
386    private_dir(base)?;
387    private_dir(&dir)?;
388    socket_fits(&dir).then_some(dir)
389}
390
391/// Make `dir` (mode `0700`), or accept it if it already exists as a real
392/// directory owned by this user that no one else can enter. `/tmp` is shared:
393/// someone else could have made the directory, or a symlink in its place.
394#[cfg(unix)]
395fn private_dir(dir: &Path) -> Option<()> {
396    use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
397    match std::fs::DirBuilder::new().mode(0o700).create(dir) {
398        Ok(()) => {}
399        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
400        Err(_) => return None,
401    }
402    let meta = std::fs::symlink_metadata(dir).ok()?;
403    let ours = meta.file_type().is_dir() && meta.uid() == unsafe { libc::getuid() };
404    (ours && meta.permissions().mode() & 0o077 == 0).then_some(())
405}
406
407/// Whether ssh can bind a socket in `dir`: the longest path it binds is
408/// `dir/<name hash>-<40-hex %C>.<16 random>` ([`SOCKET_NAME_MAX`]), and it has
409/// to fit in `sun_path` with its NUL.
410#[cfg(any(unix, test))]
411fn socket_fits(dir: &Path) -> bool {
412    let sun_path = if cfg!(target_os = "linux") { 108 } else { 104 };
413    dir.as_os_str().len() + 1 + SOCKET_NAME_MAX < sun_path
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    /// The preferred ssh on PATH first, then Windows' client if preferred;
421    /// with neither, the first on PATH, as before.
422    #[test]
423    fn a_preferred_ssh_comes_first_and_otherwise_the_order_is_the_plain_one() {
424        let p = |s: &str| PathBuf::from(s);
425        let preferred = |path: &Path| path.starts_with("/native");
426        assert_eq!(
427            pick([p("/git/ssh"), p("/native/ssh")], Some(p("/win/ssh")), preferred),
428            Some(p("/native/ssh"))
429        );
430        let win_ok = |path: &Path| path.starts_with("/win");
431        assert_eq!(pick([p("/git/ssh")], Some(p("/win/ssh")), win_ok), Some(p("/win/ssh")));
432        // An old native client (no forced askpass) isn't preferred: the
433        // first on PATH, as before this change.
434        let none = |_: &Path| false;
435        assert_eq!(pick([p("/git/ssh"), p("/old/ssh")], Some(p("/win/ssh")), none), Some(p("/git/ssh")));
436        assert_eq!(pick(Vec::<PathBuf>::new(), Some(p("/win/ssh")), none), Some(p("/win/ssh")));
437        assert_eq!(pick(Vec::<PathBuf>::new(), None, none), None);
438    }
439
440    #[test]
441    fn openssh_versions_are_read_from_the_banner() {
442        assert_eq!(openssh_version("OpenSSH_for_Windows_9.5p2, LibreSSL 3.8.2"), Some((9, 5)));
443        assert_eq!(openssh_version("OpenSSH_for_Windows_8.1p1, LibreSSL 3.0.2"), Some((8, 1)));
444        assert_eq!(openssh_version("OpenSSH_10.2p1, OpenSSL 3.5.4 30 Sep 2025"), Some((10, 2)));
445        assert_eq!(openssh_version("Dropbear v2022.83"), None);
446        assert!((8, 1) < (8, 4) && (10, 2) >= (8, 4));
447    }
448
449    /// Git for Windows' and Cygwin's ssh are passed over for the native one.
450    #[cfg(windows)]
451    #[test]
452    fn an_msys_or_cygwin_ssh_is_told_from_a_native_one() {
453        let dir = tempfile::tempdir().unwrap();
454        let (msys, cygwin, native) = (dir.path().join("msys"), dir.path().join("cygwin"), dir.path().join("native"));
455        for d in [&msys, &cygwin, &native] {
456            std::fs::create_dir_all(d).unwrap();
457            std::fs::write(d.join("ssh.exe"), b"").unwrap();
458        }
459        std::fs::write(msys.join("msys-2.0.dll"), b"").unwrap();
460        std::fs::write(cygwin.join("cygwin1.dll"), b"").unwrap();
461        assert!(is_posix_layer_build(&msys.join("ssh.exe")));
462        assert!(is_posix_layer_build(&cygwin.join("ssh.exe")));
463        assert!(!is_posix_layer_build(&native.join("ssh.exe")));
464    }
465
466    fn dest(d: &str, port: Option<u16>) -> SshDest {
467        SshDest {
468            destination: d.to_string(),
469            port,
470        }
471    }
472
473    #[test]
474    fn a_plain_pane_is_the_login_shell_with_keepalives() {
475        let args = launch(&dest("asaf@area54", None), "", &[], "", None);
476        assert_eq!(
477            args,
478            [
479                "-tt",
480                "-o",
481                "ServerAliveInterval=15",
482                "-o",
483                "ServerAliveCountMax=3",
484                "-o",
485                build_set_env().as_str(),
486                "--",
487                "asaf@area54"
488            ]
489        );
490    }
491
492    fn build_set_env() -> String {
493        let mut s = "SetEnv=TERM_PROGRAM=agentmux".to_string();
494        for (k, v) in crate::backend::pane_env::build_info() {
495            s.push_str(&format!(" {k}={v}"));
496        }
497        s
498    }
499
500    #[test]
501    fn the_build_rides_along_without_touching_the_remote_command() {
502        let args = launch(&dest("area54", None), "claude", &[], "", None);
503        let set_env = args.iter().find(|a| a.starts_with("SetEnv=")).unwrap();
504        assert!(set_env.starts_with("SetEnv=TERM_PROGRAM=agentmux "));
505        assert!(set_env.contains(&format!("AGENTMUX_VERSION={}", env!("CARGO_PKG_VERSION"))));
506        assert_eq!(args.last().unwrap(), "claude", "durable panes' control commands go through here unchanged");
507    }
508
509    #[test]
510    fn a_plain_pane_names_itself_in_set_env() {
511        let mut args = launch(&dest("area54", None), "", &[], "", None);
512        let before = args.clone();
513        set_pane_env(&mut args, "6f1c2b9e-0a4d-4c3e-9b1a-2f5e7d8c9a01");
514        let set_env = args.iter().find(|a| a.starts_with("SetEnv=")).unwrap();
515        assert!(set_env.ends_with(" AGENTMUX_BLOCKID=6f1c2b9e-0a4d-4c3e-9b1a-2f5e7d8c9a01"), "{set_env}");
516        assert_eq!(args.len(), before.len(), "no argument added, only the SetEnv value");
517        let mut odd = before.clone();
518        set_pane_env(&mut odd, "x y=z");
519        assert_eq!(odd, before, "an id with any other character is left out");
520    }
521
522    #[test]
523    fn a_port_and_connection_sharing_come_before_the_destination() {
524        let args = launch(
525            &dest("area54", Some(2222)),
526            "",
527            &[],
528            "",
529            Some(Path::new("/home/u/.agentmux/ssh")),
530        );
531        let at = |s: &str| args.iter().position(|a| a == s).unwrap();
532        assert!(args.iter().any(|a| a == "ControlMaster=auto"));
533        assert!(args
534            .iter()
535            .any(|a| a.starts_with("ControlPath=") && a.ends_with("%C")));
536        assert_eq!(args[at("-p") + 1], "2222");
537        assert!(at("-p") < at("--") && at("--") + 1 == at("area54"));
538        assert_eq!(args.last().unwrap(), "area54");
539    }
540
541    fn control_path_opt(args: &[String]) -> &str {
542        args.iter().find(|a| a.starts_with("ControlPath=")).expect("a ControlPath")
543    }
544
545    /// Two `Host` aliases for one account (one with a key, one password-only)
546    /// resolve to the same user, host and port, which is all ssh's `%C`
547    /// hashes: they shared whichever connection logged in first, so the
548    /// password-only alias got in without asking (#4513, finding 3). The
549    /// connection's own name keeps them apart.
550    #[test]
551    fn two_aliases_for_one_account_get_their_own_shared_connection() {
552        let dir = Path::new("/tmp/agentmux-501/ssh-1a2b3c4d");
553        let path = |d: &SshDest| control_path_opt(&launch(d, "", &[], "", Some(dir))).to_string();
554        let (key, pw) = (dest("exp-key", None), dest("exp-pw", None));
555        assert_ne!(path(&key), path(&pw));
556        assert_eq!(path(&key), path(&dest("exp-key", None)), "one name, one connection");
557        assert_ne!(path(&dest("u@h", Some(22))), path(&dest("u@h", Some(2222))), "the port is part of the name");
558        // ssh's own %C stays in it: an alias pointed somewhere else gets a new one.
559        assert!(path(&key).ends_with("-%C"), "{}", path(&key));
560    }
561
562    /// `ssh -O` reaches the very connection a pane's ssh shares: same socket,
563    /// same destination and port.
564    #[test]
565    fn control_asks_the_connection_a_pane_shares() {
566        let dir = Path::new("/tmp/agentmux-501/ssh-1a2b3c4d");
567        let d = dest("tester@area54", Some(2222));
568        let pane = launch(&d, "", &[], "", Some(dir));
569        let check = control(&d, dir, Control::Check);
570        assert_eq!(control_path_opt(&check), control_path_opt(&pane));
571        assert_eq!(&check[check.len() - 6..], ["-O", "check", "-p", "2222", "--", "tester@area54"]);
572        let exit = control(&d, dir, Control::Exit);
573        assert_eq!(&exit[exit.len() - 6..], ["-O", "exit", "-p", "2222", "--", "tester@area54"]);
574    }
575
576    /// Exit 255 is ssh's own failure (no connection, or it dropped: also a
577    /// session on a shared connection that was closed); any other code is the
578    /// remote shell's, `exit` included.
579    #[test]
580    fn only_ssh_s_own_failure_is_a_lost_connection() {
581        assert!(connection_lost(255));
582        for code in [0, 1, 2, 127, 130] {
583            assert!(!connection_lost(code), "{code}");
584        }
585        let note = lost_note("area54");
586        assert!(note.contains("area54") && note.contains("Press Enter"), "{note}");
587    }
588
589    /// The longest socket path ssh binds in `dir`: `/`, the connection's 8-hex
590    /// name, `-`, the 40-hex `%C`, and while binding `.` plus 16 random
591    /// characters. It must stay under the OS's `sun_path` (104 bytes on macOS,
592    /// 108 on Linux, NUL included).
593    #[cfg(unix)]
594    fn bind_path_len(dir: &Path) -> usize {
595        dir.as_os_str().len() + 1 + 8 + 1 + 40 + 17
596    }
597
598    #[cfg(unix)]
599    #[test]
600    fn a_deep_config_dir_still_gets_a_socket_path_ssh_can_bind() {
601        // A channel's config dir is deep: the stable one on macOS is
602        // `/Users/<name>/.agentmux/channels/stable/config`, and every socket
603        // under it was too long, so ssh failed right after logging in.
604        let tmp = tempfile::tempdir().unwrap();
605        let deep = tmp.path().join("users-name/.agentmux/channels/local-main-b28b7a-51e8e6a3/config");
606        let dir = control_dir(&deep).expect("a control dir");
607        let max = if cfg!(target_os = "linux") { 108 } else { 104 };
608        assert!(bind_path_len(&dir) < max, "{} bytes: {}", bind_path_len(&dir), dir.display());
609        // It's in the real runtime dir: don't leave one behind per run.
610        let _ = std::fs::remove_dir(&dir);
611    }
612
613    /// Under `/tmp`: macOS's own temp dir (`/var/folders/…`) is itself too
614    /// long for a socket, so a dir there is refused for its length alone.
615    #[cfg(unix)]
616    fn short_tempdir() -> tempfile::TempDir {
617        tempfile::Builder::new().prefix("am").tempdir_in("/tmp").unwrap()
618    }
619
620    #[cfg(unix)]
621    #[test]
622    fn each_channel_gets_its_own_owner_only_dir() {
623        use std::os::unix::fs::PermissionsExt;
624        let tmp = short_tempdir();
625        let base = tmp.path().join("b");
626        let (stable, local) = (Path::new("/u/.agentmux/channels/stable/config"), Path::new("/u/.agentmux/channels/local-x/config"));
627        let a = control_dir_in(&base, stable).unwrap();
628        assert_eq!(control_dir_in(&base, stable), Some(a.clone()));
629        assert_ne!(control_dir_in(&base, local), Some(a.clone()));
630        let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
631        assert_eq!((mode(&base), mode(&a)), (0o700, 0o700));
632    }
633
634    #[cfg(unix)]
635    #[test]
636    fn a_base_others_can_enter_or_a_symlink_is_refused() {
637        use std::os::unix::fs::PermissionsExt;
638        let tmp = short_tempdir();
639        let config = Path::new("/u/config");
640        let open = tmp.path().join("open");
641        std::fs::create_dir(&open).unwrap();
642        std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o755)).unwrap();
643        assert_eq!(control_dir_in(&open, config), None);
644        let real = tmp.path().join("real");
645        std::fs::create_dir(&real).unwrap();
646        std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)).unwrap();
647        let link = tmp.path().join("link");
648        std::os::unix::fs::symlink(&real, &link).unwrap();
649        assert_eq!(control_dir_in(&link, config), None);
650    }
651
652    #[test]
653    fn a_socket_fits_only_below_sun_path() {
654        let sun_path = if cfg!(target_os = "linux") { 108 } else { 104 };
655        let dir = |len: usize| PathBuf::from(format!("/{}", "d".repeat(len - 1)));
656        let longest = sun_path - 1 - (1 + 8 + 1 + 40 + 17);
657        assert!(socket_fits(&dir(longest)));
658        assert!(!socket_fits(&dir(longest + 1)));
659        // The stable channel's old dir on macOS, for an 8-letter user name.
660        assert!(!socket_fits(Path::new("/Users/asafebgi/.agentmux/channels/stable/config/ssh")) || cfg!(target_os = "linux"));
661    }
662
663    #[test]
664    fn a_command_and_a_cwd_become_one_remote_command_line() {
665        assert_eq!(remote_command("", &[], ""), None);
666        assert_eq!(
667            remote_command("make test", &[], ""),
668            Some("make test".into())
669        );
670        assert_eq!(
671            remote_command("", &[], "/srv/my app"),
672            Some(r#"cd '/srv/my app'; exec "$SHELL" -l"#.into())
673        );
674        assert_eq!(
675            remote_command("htop", &["-d".into(), "10".into()], "/tmp"),
676            Some("cd /tmp; htop -d 10".into())
677        );
678        assert_eq!(
679            remote_command("echo", &["it's $HOME".into()], ""),
680            Some(r"echo 'it'\''s $HOME'".into())
681        );
682        let args = launch(&dest("h", None), "uptime", &[], "/tmp", None);
683        assert_eq!(&args[args.len() - 3..], ["--", "h", "cd /tmp; uptime"]);
684    }
685
686    #[test]
687    fn a_command_without_a_terminal_is_dash_capital_t() {
688        let args = launch_exec(&dest("area54", Some(22)), "make test", "~/proj", None);
689        assert_eq!(args[0], "-T");
690        assert!(!args.iter().any(|a| a == "-tt"));
691        assert_eq!(
692            &args[args.len() - 3..],
693            ["--", "area54", "cd ~/proj; make test"]
694        );
695    }
696
697    #[test]
698    fn only_a_connection_failure_is_an_error() {
699        assert!(lost_message("area54").contains("could not connect to area54"));
700        assert!(pane_lost_connection(Some(255), false));
701        for code in [Some(0), Some(1), Some(130), None] {
702            assert!(!pane_lost_connection(code, false), "{code:?}");
703        }
704    }
705
706    /// srv ending a pane's ssh itself (the pane closed, or restarted) makes it
707    /// exit 255 too: closing its pty does at once, and a session on a shared
708    /// connection exits 255 on SIGTERM (measured, OpenSSH 9). That is not a
709    /// lost connection: no line in the pane, no error on the host.
710    #[test]
711    fn ssh_ended_by_srv_is_not_a_lost_connection() {
712        assert!(!pane_lost_connection(Some(255), true));
713    }
714
715    /// `~` is the remote home: left for the remote shell to expand.
716    #[test]
717    fn a_tilde_cwd_is_the_remote_home() {
718        assert_eq!(quote_path("~"), "~");
719        assert_eq!(quote_path("~/"), "~/");
720        assert_eq!(quote_path("~/my proj"), "~/'my proj'");
721        assert_eq!(quote_path("~/src"), "~/src");
722        assert_eq!(
723            quote_path("/a/~b"),
724            "'/a/~b'",
725            "a tilde inside a path stays literal"
726        );
727        assert_eq!(quote_path("~bob/x"), "'~bob/x'", "only the user's own home");
728        assert_eq!(
729            remote_command("", &[], "~/proj"),
730            Some(r#"cd ~/proj; exec "$SHELL" -l"#.into())
731        );
732    }
733
734    #[test]
735    fn quoting_leaves_plain_words_alone() {
736        assert_eq!(quote("src/main.rs"), "src/main.rs");
737        assert_eq!(quote(""), "''");
738        assert_eq!(quote("a b"), "'a b'");
739        assert_eq!(quote("$(rm -rf ~)"), "'$(rm -rf ~)'");
740    }
741}