Skip to main content

agentmux_srv/backend/remote/
host.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! One SSH host reached for commands rather than a terminal: a durable pane's
5//! `attach`, the helper install's scripts, and listing or ending the host's
6//! durable sessions (SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md
7//! §6.2, §7.6). The one place these run `ssh`.
8
9use std::path::PathBuf;
10use std::time::Duration;
11
12use tokio::io::AsyncWriteExt;
13
14use super::conn::SshDest;
15use super::ConnTarget;
16
17#[derive(Debug, Clone)]
18pub struct HostSsh {
19    pub dest: SshDest,
20    pub ssh_path: PathBuf,
21    /// ssh's connection-sharing directory (macOS and Linux), if any.
22    pub control_dir: Option<PathBuf>,
23    /// Set on `ssh` after the sanitizing: askpass ([`Self::ask_user_in`]).
24    pub env: Vec<(String, String)>,
25}
26
27/// What a command on the host left behind.
28#[derive(Debug)]
29pub struct HostOutput {
30    pub code: Option<i32>,
31    pub stdout: String,
32    pub stderr: String,
33}
34
35impl HostOutput {
36    /// Its output if it succeeded; otherwise ssh's or the command's last
37    /// line of complaint.
38    pub fn ok(self) -> Result<String, String> {
39        if self.code == Some(0) {
40            return Ok(self.stdout);
41        }
42        Err(self
43            .stderr
44            .lines()
45            .rev()
46            .find(|l| !l.trim().is_empty())
47            .unwrap_or("ssh failed")
48            .trim()
49            .to_string())
50    }
51}
52
53impl HostSsh {
54    /// The host of an SSH connection name, with this machine's `ssh`.
55    pub fn for_connection(conn: &str) -> Result<Self, String> {
56        let dest = match ConnTarget::parse(conn)? {
57            ConnTarget::Ssh(d) => d,
58            _ => return Err(format!("{conn:?} is not an SSH connection")),
59        };
60        let ssh_path = super::ssh::binary().ok_or_else(super::ssh::missing_binary_message)?;
61        Ok(Self {
62            dest,
63            ssh_path,
64            control_dir: super::ssh::shared_control_dir(),
65            env: Vec::new(),
66        })
67    }
68
69    /// Send ssh's prompts (a password, a passphrase, a new host key) to the
70    /// user, in the window of their pane `block_id`, through the askpass
71    /// bridge: this `ssh` has no terminal to ask in. The grant lasts until
72    /// the returned guard is dropped. Nothing changes without the askpass
73    /// helper; a login that needs a prompt then fails with ssh's reason.
74    pub fn ask_user_in(
75        &mut self,
76        block_id: &str,
77        conn: &str,
78        auth_key: &str,
79    ) -> Option<super::askpass::Revoke> {
80        use super::askpass;
81        let program = askpass::program()?;
82        let secret = askpass::grant(askpass::AskpassGrant {
83            agent_block_id: block_id.to_string(),
84            agent: String::new(),
85            connection: conn.to_string(),
86            user_pane: true,
87        });
88        let local_url = std::env::var("AGENTMUX_LOCAL_URL").unwrap_or_default();
89        self.env = askpass::ssh_env(&secret, &program, &local_url, auth_key);
90        Some(askpass::Revoke(secret))
91    }
92
93    /// `ssh -T ... -- <host> <remote>`: no terminal on the remote side
94    /// (frames or a script's output, not a session, go over it). The caller
95    /// sets its stdio.
96    pub fn command(&self, remote: &str) -> tokio::process::Command {
97        let mut args = super::ssh::launch_exec(&self.dest, remote, "", self.control_dir.as_deref());
98        // With nowhere to ask (no askpass route), never prompt: on the
99        // terminal srv was started from, if any, nobody would answer. ssh
100        // fails with its reason instead.
101        if self.env.is_empty() {
102            args.insert(1, "-oBatchMode=yes".to_string());
103        }
104        self.ssh(&args)
105    }
106
107    /// `ssh` with `args`, sanitized like every process srv starts, and
108    /// askpass's variables after the sanitizing (it needs srv's address and
109    /// key). Killed if dropped.
110    fn ssh(&self, args: &[String]) -> tokio::process::Command {
111        let mut cmd = tokio::process::Command::new(&self.ssh_path);
112        cmd.args(args).kill_on_drop(true);
113        #[cfg(windows)]
114        {
115            use agentmux_common::win32::NoWindow;
116            cmd.no_window();
117        }
118        crate::backend::pane_env::sanitize_process_command(&mut cmd);
119        cmd.envs(self.env.iter().map(|(k, v)| (k, v)));
120        cmd
121    }
122
123    /// Whether this host's shared connection is up, which it is only once the
124    /// user has logged in (`ssh -O check`). `None` where ssh doesn't share
125    /// connections (Windows), or ssh couldn't be asked: nothing is known.
126    pub async fn shared_connection_up(&self) -> Option<bool> {
127        let dir = self.control_dir.as_deref()?;
128        let args = super::ssh::control(&self.dest, dir, super::ssh::Control::Check);
129        let out = self.run_control(&args).await?;
130        Some(out.status.success())
131    }
132
133    /// Close this host's shared connection (`ssh -O exit`): every session on
134    /// it ends. Nothing to do where there is none, or ssh doesn't share
135    /// connections.
136    pub async fn close_shared_connection(&self) {
137        if let Some(dir) = self.control_dir.as_deref() {
138            let args = super::ssh::control(&self.dest, dir, super::ssh::Control::Exit);
139            let _ = self.run_control(&args).await;
140        }
141    }
142
143    /// An `ssh -O` request: a local socket, answered at once, so a few seconds
144    /// is plenty; `None` if ssh couldn't run or didn't answer.
145    async fn run_control(&self, args: &[String]) -> Option<std::process::Output> {
146        let mut cmd = self.ssh(args);
147        cmd.stdin(std::process::Stdio::null())
148            .stdout(std::process::Stdio::piped())
149            .stderr(std::process::Stdio::piped());
150        tokio::time::timeout(Duration::from_secs(5), cmd.output())
151            .await
152            .ok()?
153            .ok()
154    }
155
156    /// Run `remote` with `input` on its stdin, for at most `limit`, so a
157    /// host that stops answering never holds the caller.
158    pub async fn run(
159        &self,
160        remote: &str,
161        input: Option<Vec<u8>>,
162        limit: Duration,
163    ) -> Result<HostOutput, String> {
164        let mut cmd = self.command(remote);
165        cmd.stdin(if input.is_some() {
166            std::process::Stdio::piped()
167        } else {
168            std::process::Stdio::null()
169        })
170        .stdout(std::process::Stdio::piped())
171        .stderr(std::process::Stdio::piped());
172        let mut child = cmd.spawn().map_err(|e| format!("could not run ssh: {e}"))?;
173        if let (Some(bytes), Some(mut stdin)) = (input, child.stdin.take()) {
174            stdin
175                .write_all(&bytes)
176                .await
177                .map_err(|e| format!("upload: {e}"))?;
178            drop(stdin);
179        }
180        let out = tokio::time::timeout(limit, child.wait_with_output())
181            .await
182            .map_err(|_| "ssh took too long".to_string())?
183            .map_err(|e| format!("ssh: {e}"))?;
184        Ok(HostOutput {
185            code: out.status.code(),
186            stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
187            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
188        })
189    }
190}