Skip to main content

agentmux_srv/backend/remote/
helper_hosts.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The SSH hosts AgentMux's helper has answered on (spec §7.1 of
5//! SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md): on such a host
6//! a new terminal pane is durable unless the pane, the connection's settings
7//! or the global setting say otherwise. Kept in `remote-helper-hosts.json`
8//! under AgentMux's config dir; a host is its canonical connection name
9//! (`ConnTarget::name`), so one host is one entry however it is spelled.
10//!
11//! The same file keeps what AgentMux learned about each host for the Remotes
12//! pane (SPEC_REMOTES_PANE_2026_10_05.md §4.6): its platform (`uname -sm`, from
13//! the helper install's probe) and the AgentMux version whose helper last
14//! answered there.
15
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use std::sync::Mutex;
19
20use serde::{Deserialize, Serialize};
21
22const FILE_NAME: &str = "remote-helper-hosts.json";
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25struct Hosts {
26    #[serde(default)]
27    hosts: Vec<String>,
28    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
29    info: BTreeMap<String, HostInfo>,
30}
31
32/// What AgentMux learned about one host.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct HostInfo {
35    /// `uname -sm` as the host printed it (`Linux x86_64`, `Darwin arm64`).
36    #[serde(default, skip_serializing_if = "String::is_empty")]
37    pub uname: String,
38    /// The AgentMux version whose helper last answered here.
39    #[serde(default, skip_serializing_if = "String::is_empty")]
40    pub helper_version: String,
41    /// When this record last changed, in Unix milliseconds.
42    #[serde(default)]
43    pub seen_at_ms: u64,
44}
45
46/// Serializes read-modify-write of the file within this srv.
47static LOCK: Mutex<()> = Mutex::new(());
48
49fn path_in(config_home: &Path) -> PathBuf {
50    config_home.join(FILE_NAME)
51}
52
53fn read(path: &Path) -> Hosts {
54    std::fs::read_to_string(path)
55        .ok()
56        .and_then(|s| serde_json::from_str(&s).ok())
57        .unwrap_or_default()
58}
59
60fn write(config_home: &Path, hosts: &Hosts) -> std::io::Result<()> {
61    let path = path_in(config_home);
62    std::fs::create_dir_all(config_home)?;
63    let tmp = path.with_extension("json.tmp");
64    std::fs::write(&tmp, serde_json::to_vec_pretty(hosts).unwrap_or_default())?;
65    std::fs::rename(&tmp, &path)
66}
67
68fn canonical(connection: &str) -> Option<String> {
69    match super::ConnTarget::parse(connection) {
70        Ok(t @ super::ConnTarget::Ssh(_)) => Some(t.name()),
71        _ => None,
72    }
73}
74
75use agentmux_common::time::now_ms_u64 as now_ms;
76
77/// Change `connection`'s record (and, with `helper`, mark the helper as there),
78/// writing only when something changed.
79fn change_in(
80    config_home: &Path,
81    connection: &str,
82    helper: bool,
83    edit: impl FnOnce(&mut HostInfo),
84) -> std::io::Result<()> {
85    let Some(name) = canonical(connection) else {
86        return Ok(());
87    };
88    let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
89    let mut hosts = read(&path_in(config_home));
90    let before = hosts.clone();
91    if helper && !hosts.hosts.contains(&name) {
92        hosts.hosts.push(name.clone());
93    }
94    let info = hosts.info.entry(name).or_default();
95    let mut edited = info.clone();
96    edit(&mut edited);
97    if edited != *info {
98        edited.seen_at_ms = now_ms();
99        *info = edited;
100    }
101    if hosts == before {
102        return Ok(());
103    }
104    write(config_home, &hosts)
105}
106
107/// Whether the helper has answered on `connection`'s host.
108pub fn known_in(config_home: &Path, connection: &str) -> bool {
109    let Some(name) = canonical(connection) else {
110        return false;
111    };
112    let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
113    read(&path_in(config_home)).hosts.contains(&name)
114}
115
116/// Remember that the helper of AgentMux `version` answered on `connection`'s host.
117pub fn remember_in(config_home: &Path, connection: &str, version: &str) -> std::io::Result<()> {
118    change_in(config_home, connection, true, |info| info.helper_version = version.to_string())
119}
120
121/// Record `connection`'s platform, as `uname -sm` printed it.
122pub fn record_uname_in(config_home: &Path, connection: &str, uname: &str) -> std::io::Result<()> {
123    let uname = uname.trim();
124    if uname.is_empty() {
125        return Ok(());
126    }
127    change_in(config_home, connection, false, |info| info.uname = uname.to_string())
128}
129
130/// Every host the helper has answered on, and every host's record, by canonical name.
131pub fn all_in(config_home: &Path) -> (Vec<String>, BTreeMap<String, HostInfo>) {
132    let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
133    let hosts = read(&path_in(config_home));
134    (hosts.hosts, hosts.info)
135}
136
137/// [`known_in`] under this instance's config dir.
138pub fn known(connection: &str) -> bool {
139    known_in(&crate::backend::base::get_mux_config_dir(), connection)
140}
141
142/// [`remember_in`] under this instance's config dir, for this AgentMux's
143/// version. Best effort: a host not remembered only means its next pane is
144/// not durable by default.
145pub fn remember(connection: &str) {
146    if let Err(e) = remember_in(
147        &crate::backend::base::get_mux_config_dir(),
148        connection,
149        env!("CARGO_PKG_VERSION"),
150    ) {
151        tracing::warn!(connection = %connection, error = %e, "could not remember a helper host");
152    }
153}
154
155/// [`record_uname_in`] under this instance's config dir. Best effort.
156pub fn record_uname(connection: &str, uname: &str) {
157    if let Err(e) = record_uname_in(&crate::backend::base::get_mux_config_dir(), connection, uname) {
158        tracing::warn!(connection = %connection, error = %e, "could not record a host's platform");
159    }
160}
161
162/// The helper was removed from `connection`'s host: it is no longer a helper
163/// host, and no version is there. Its platform stays known.
164pub fn forget_helper_in(config_home: &Path, connection: &str) -> std::io::Result<()> {
165    let Some(name) = canonical(connection) else {
166        return Ok(());
167    };
168    let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
169    let mut hosts = read(&path_in(config_home));
170    let before = hosts.clone();
171    hosts.hosts.retain(|h| h != &name);
172    if let Some(info) = hosts.info.get_mut(&name) {
173        if !info.helper_version.is_empty() {
174            info.helper_version.clear();
175            info.seen_at_ms = now_ms();
176        }
177    }
178    if hosts == before {
179        return Ok(());
180    }
181    write(config_home, &hosts)
182}
183
184/// [`forget_helper_in`] under this instance's config dir.
185pub fn forget_helper(connection: &str) -> std::io::Result<()> {
186    forget_helper_in(&crate::backend::base::get_mux_config_dir(), connection)
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn a_removed_helper_is_forgotten_but_the_platform_kept() {
195        let dir = tempfile::tempdir().unwrap();
196        record_uname_in(dir.path(), "user@box", "Linux x86_64").unwrap();
197        remember_in(dir.path(), "user@box", "0.59.9").unwrap();
198        remember_in(dir.path(), "other@box", "0.59.9").unwrap();
199        forget_helper_in(dir.path(), " user@box ").unwrap();
200        assert!(!known_in(dir.path(), "user@box"));
201        assert!(known_in(dir.path(), "other@box"), "other hosts untouched");
202        let (_, info) = all_in(dir.path());
203        assert_eq!(info["user@box"].uname, "Linux x86_64");
204        assert_eq!(info["user@box"].helper_version, "");
205    }
206
207    #[test]
208    fn a_host_is_remembered_once_however_it_is_spelled() {
209        let dir = tempfile::tempdir().unwrap();
210        assert!(!known_in(dir.path(), "user@box"));
211        remember_in(dir.path(), "user@box", "0.59.9").unwrap();
212        remember_in(dir.path(), " user@box ", "0.59.9").unwrap();
213        assert!(known_in(dir.path(), "user@box"));
214        assert_eq!(read(&path_in(dir.path())).hosts.len(), 1);
215        assert!(!known_in(dir.path(), "other@box"));
216        // Not SSH: never a helper host.
217        remember_in(dir.path(), "wsl://Ubuntu", "0.59.9").unwrap();
218        assert!(!known_in(dir.path(), "wsl://Ubuntu"));
219        assert!(!known_in(dir.path(), "local"));
220    }
221
222    #[test]
223    fn a_hosts_platform_and_helper_version_are_kept_with_it() {
224        let dir = tempfile::tempdir().unwrap();
225        record_uname_in(dir.path(), "user@box", "Linux x86_64\n").unwrap();
226        assert!(!known_in(dir.path(), "user@box"), "a platform alone is not the helper");
227        remember_in(dir.path(), "user@box", "0.59.9").unwrap();
228        let (hosts, info) = all_in(dir.path());
229        assert_eq!(hosts, vec!["user@box".to_string()]);
230        let box_info = &info["user@box"];
231        assert_eq!(box_info.uname, "Linux x86_64");
232        assert_eq!(box_info.helper_version, "0.59.9");
233        assert!(box_info.seen_at_ms > 0);
234
235        // A newer helper replaces the version; a blank uname changes nothing.
236        remember_in(dir.path(), "user@box", "0.60.0").unwrap();
237        record_uname_in(dir.path(), "user@box", "  ").unwrap();
238        let (_, info) = all_in(dir.path());
239        assert_eq!(info["user@box"].helper_version, "0.60.0");
240        assert_eq!(info["user@box"].uname, "Linux x86_64");
241    }
242
243    #[test]
244    fn a_file_from_before_the_records_still_reads() {
245        let dir = tempfile::tempdir().unwrap();
246        std::fs::write(path_in(dir.path()), r#"{ "hosts": ["user@box"] }"#).unwrap();
247        assert!(known_in(dir.path(), "user@box"));
248        let (_, info) = all_in(dir.path());
249        assert!(info.is_empty());
250    }
251}