Skip to main content

agentmux_srv/backend/remote/
remotes.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The Connectors → Remotes list (SPEC_REMOTES_PANE_2026_10_05.md §4.6): every remote
5//! machine AgentMux knows about, one record each, built from what srv already
6//! keeps:
7//! - the hosts named in `~/.ssh/config` (`ssh_config`);
8//! - the per-connection settings (`settings.json` → `connections`);
9//! - recent connections (`remote-recent.json`, kept here, §4.5);
10//! - WSL distributions (`wsl`);
11//! - each connection's status (`status`);
12//! - where the helper is, and each host's platform (`helper_hosts`);
13//! - which agents may use a host (`agent_access`);
14//! - the last count of durable sessions seen on a host (kept here).
15//!
16//! Building the list never opens an ssh connection.
17
18use std::collections::{BTreeMap, HashMap};
19use std::path::{Path, PathBuf};
20use std::sync::{Mutex, OnceLock};
21
22use serde::{Deserialize, Serialize};
23
24use super::helper_hosts::HostInfo;
25use super::ConnTarget;
26use crate::backend::mps::Broker;
27use crate::backend::rpc_types::{ConnStatus, RemoteHelper, RemotePlatform, RemoteRecord, RemoteStatus};
28use crate::backend::wconfig::ConnKeywords;
29
30// ── Recent connections (§4.5) ───────────────────────────────────────────────
31
32const RECENT_FILE: &str = "remote-recent.json";
33/// Recent connections kept; the least recently used goes first.
34pub const MAX_RECENT: usize = 20;
35/// A connection already recent is re-written at most this often.
36const TOUCH_EVERY_MS: u64 = 60_000;
37
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
39struct RecentFile {
40    #[serde(default)]
41    recent: Vec<RecentEntry>,
42}
43
44/// One SSH connection that has reached `connected`.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct RecentEntry {
47    /// Its canonical name (`ConnTarget::name`).
48    pub name: String,
49    pub first_used_ms: u64,
50    pub last_used_ms: u64,
51}
52
53static RECENT_LOCK: Mutex<()> = Mutex::new(());
54
55fn recent_path(config_home: &Path) -> PathBuf {
56    config_home.join(RECENT_FILE)
57}
58
59fn read_recent(path: &Path) -> RecentFile {
60    std::fs::read_to_string(path)
61        .ok()
62        .and_then(|s| serde_json::from_str(&s).ok())
63        .unwrap_or_default()
64}
65
66fn write_recent(config_home: &Path, file: &RecentFile) -> std::io::Result<()> {
67    let path = recent_path(config_home);
68    std::fs::create_dir_all(config_home)?;
69    let tmp = path.with_extension("json.tmp");
70    std::fs::write(&tmp, serde_json::to_vec_pretty(file).unwrap_or_default())?;
71    std::fs::rename(&tmp, &path)
72}
73
74fn ssh_name(connection: &str) -> Option<String> {
75    match ConnTarget::parse(connection) {
76        Ok(t @ ConnTarget::Ssh(_)) => Some(t.name()),
77        _ => None,
78    }
79}
80
81/// Record that `connection` (SSH only) was used at `now_ms`. Returns whether the
82/// list changed (a new entry); touching an existing one is written at most
83/// once a minute.
84pub fn record_recent_in(config_home: &Path, connection: &str, now_ms: u64) -> std::io::Result<bool> {
85    let Some(name) = ssh_name(connection) else {
86        return Ok(false);
87    };
88    let _g = RECENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
89    let mut file = read_recent(&recent_path(config_home));
90    if let Some(entry) = file.recent.iter_mut().find(|e| e.name == name) {
91        if now_ms.saturating_sub(entry.last_used_ms) < TOUCH_EVERY_MS {
92            return Ok(false);
93        }
94        entry.last_used_ms = now_ms;
95        write_recent(config_home, &file)?;
96        return Ok(false);
97    }
98    file.recent.push(RecentEntry {
99        name,
100        first_used_ms: now_ms,
101        last_used_ms: now_ms,
102    });
103    if file.recent.len() > MAX_RECENT {
104        file.recent.sort_by(|a, b| b.last_used_ms.cmp(&a.last_used_ms));
105        file.recent.truncate(MAX_RECENT);
106    }
107    write_recent(config_home, &file)?;
108    Ok(true)
109}
110
111/// Forget `connection` from the recent list. Returns whether it was there.
112pub fn forget_recent_in(config_home: &Path, connection: &str) -> std::io::Result<bool> {
113    let Some(name) = ssh_name(connection) else {
114        return Ok(false);
115    };
116    let _g = RECENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
117    let mut file = read_recent(&recent_path(config_home));
118    let before = file.recent.len();
119    file.recent.retain(|e| e.name != name);
120    if file.recent.len() == before {
121        return Ok(false);
122    }
123    write_recent(config_home, &file)?;
124    Ok(true)
125}
126
127/// The recent list, most recently used first.
128pub fn recent_in(config_home: &Path) -> Vec<RecentEntry> {
129    let _g = RECENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
130    let mut recent = read_recent(&recent_path(config_home)).recent;
131    recent.sort_by(|a, b| b.last_used_ms.cmp(&a.last_used_ms));
132    recent
133}
134
135#[cfg(not(test))]
136use agentmux_common::time::now_ms_u64 as now_ms;
137
138/// [`record_recent_in`] under this instance's config dir, now. Best effort.
139#[cfg(not(test))]
140pub fn record_recent(connection: &str) -> bool {
141    record_recent_in(&crate::backend::base::get_mux_config_dir(), connection, now_ms()).unwrap_or_else(|e| {
142        tracing::warn!(connection = %connection, error = %e, "could not record a recent connection");
143        false
144    })
145}
146
147/// [`forget_recent_in`] under this instance's config dir.
148pub fn forget_recent(connection: &str) -> std::io::Result<bool> {
149    forget_recent_in(&crate::backend::base::get_mux_config_dir(), connection)
150}
151
152// ── Durable session counts (§4.6) ───────────────────────────────────────────
153
154fn session_counts() -> &'static Mutex<HashMap<String, u32>> {
155    static COUNTS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
156    COUNTS.get_or_init(|| Mutex::new(HashMap::new()))
157}
158
159/// Remember how many durable sessions `connection`'s host had when last
160/// listed (the list never opens ssh to count them).
161pub fn note_sessions(connection: &str, count: usize) {
162    if let Some(name) = ssh_name(connection) {
163        session_counts()
164            .lock()
165            .unwrap_or_else(|e| e.into_inner())
166            .insert(name, count as u32);
167    }
168}
169
170// ── Settings writes (§4.3) ──────────────────────────────────────────────────
171
172/// Apply `values` to `connection`'s entry in the raw `connections` object of
173/// `settings.json`: a `null` removes a key, an entry left empty is removed.
174/// Works on the raw JSON, so keys AgentMux doesn't know survive untouched.
175pub fn apply_connection_values(
176    connections: &mut serde_json::Map<String, serde_json::Value>,
177    connection: &str,
178    values: &serde_json::Map<String, serde_json::Value>,
179) {
180    let name = connection.trim().to_string();
181    let mut entry = match connections.remove(&name) {
182        Some(serde_json::Value::Object(map)) => map,
183        _ => serde_json::Map::new(),
184    };
185    for (key, value) in values {
186        if value.is_null() {
187            entry.remove(key);
188        } else {
189            entry.insert(key.clone(), value.clone());
190        }
191    }
192    if !entry.is_empty() {
193        connections.insert(name, serde_json::Value::Object(entry));
194    }
195}
196
197/// Refuse an edit that would leave `connection`'s entry unreadable (a string
198/// where `display:pinned` wants a boolean, say): the write would land on disk
199/// while the live config silently kept the old values.
200pub fn check_connection_entry(
201    connections: &serde_json::Map<String, serde_json::Value>,
202    connection: &str,
203) -> Result<(), String> {
204    let name = connection.trim();
205    match connections.get(name) {
206        None => Ok(()),
207        Some(entry) => serde_json::from_value::<ConnKeywords>(entry.clone())
208            .map(|_| ())
209            .map_err(|e| format!("invalid setting for {name}: {e}")),
210    }
211}
212
213/// Tell every open Connectors → Remotes its list changed.
214pub fn publish_change(broker: &Broker) {
215    broker.publish_event::<crate::backend::mps_events::RemotesChanged>(&(), vec![], 0);
216}
217
218// ── The list (§4.6) ─────────────────────────────────────────────────────────
219
220/// Everything [`build`] reads, gathered by [`list`].
221#[derive(Debug, Clone, Default)]
222pub struct Inputs {
223    pub ssh_config_hosts: Vec<String>,
224    pub wsl_distros: Vec<String>,
225    pub settings: HashMap<String, ConnKeywords>,
226    pub recent: Vec<RecentEntry>,
227    pub statuses: Vec<ConnStatus>,
228    pub helper_hosts: Vec<String>,
229    pub host_info: BTreeMap<String, HostInfo>,
230    pub sessions: HashMap<String, u32>,
231    pub grants: Vec<(String, String)>,
232}
233
234/// `uname -sm` → platform.
235pub fn platform_of(uname: &str) -> Option<RemotePlatform> {
236    let mut words = uname.split_whitespace();
237    let os = words.next()?;
238    let arch = words.next().unwrap_or_default();
239    let os = match os {
240        "Linux" => "linux".to_string(),
241        "Darwin" => "macos".to_string(),
242        other => other.to_ascii_lowercase(),
243    };
244    let arch = match arch {
245        "x86_64" | "amd64" => "x86_64".to_string(),
246        "aarch64" | "arm64" => "arm64".to_string(),
247        other => other.to_string(),
248    };
249    Some(RemotePlatform { os, arch })
250}
251
252/// The canonical name and kind of a connection name, or `None` for `local`.
253fn identify(connection: &str) -> Option<(String, &'static str)> {
254    match ConnTarget::parse(connection).ok()? {
255        ConnTarget::Local => None,
256        t @ ConnTarget::Wsl(_) => Some((t.name(), "wsl")),
257        t @ ConnTarget::Ssh(_) => Some((t.name(), "ssh")),
258    }
259}
260
261/// One record per remote, by name (the pane sorts them into sections).
262pub fn build(inputs: &Inputs) -> Vec<RemoteRecord> {
263    // name → (kind, sources)
264    let mut found: BTreeMap<String, (&'static str, Vec<&'static str>)> = BTreeMap::new();
265    let mut add = |connection: &str, source: &'static str| {
266        if let Some((name, kind)) = identify(connection) {
267            let entry = found.entry(name).or_insert((kind, Vec::new()));
268            if !entry.1.contains(&source) {
269                entry.1.push(source);
270            }
271        }
272    };
273    for host in &inputs.ssh_config_hosts {
274        add(host, "ssh_config");
275    }
276    for name in inputs.settings.keys() {
277        add(name, "settings");
278    }
279    for entry in &inputs.recent {
280        add(&entry.name, "recent");
281    }
282    for distro in &inputs.wsl_distros {
283        add(&format!("wsl://{distro}"), "wsl");
284    }
285
286    let same = |a: &str, b: &str| super::conn::same_connection(a, b);
287    found
288        .into_iter()
289        .map(|(name, (kind, sources))| {
290            let status = inputs
291                .statuses
292                .iter()
293                .find(|s| same(&s.connection, &name))
294                .map(|s| RemoteStatus {
295                    state: s.status.clone(),
296                    error: s.error.clone(),
297                })
298                .unwrap_or_else(|| RemoteStatus {
299                    state: super::status::state::DISCONNECTED.to_string(),
300                    error: String::new(),
301                });
302            let settings = inputs.settings.iter().find(|(k, _)| same(k, &name)).map(|(_, v)| v);
303            let info = inputs.host_info.get(&name);
304            let platform = info.and_then(|i| platform_of(&i.uname));
305            let helper = if kind == "wsl" {
306                RemoteHelper { state: "none".to_string(), version: String::new(), installed: false }
307            } else {
308                let version = info.map(|i| i.helper_version.clone()).unwrap_or_default();
309                let state = if settings.is_some_and(|s| s.conn_helper == "never") {
310                    "never"
311                } else if inputs.helper_hosts.contains(&name) {
312                    "installed"
313                } else if info.is_some_and(|i| {
314                    !i.uname.is_empty() && super::helper_install::target_for(&i.uname).is_none()
315                }) {
316                    "unsupported"
317                } else {
318                    "absent"
319                };
320                let installed = inputs.helper_hosts.contains(&name);
321                let version = if installed { version } else { String::new() };
322                RemoteHelper { state: state.to_string(), version, installed }
323            };
324            let mut agents: Vec<String> = inputs
325                .grants
326                .iter()
327                .filter(|(_, conn)| same(conn, &name))
328                .map(|(agent, _)| agent.clone())
329                .collect();
330            agents.sort();
331            agents.dedup();
332            RemoteRecord {
333                sessions: inputs.sessions.get(&name).copied(),
334                last_used_ms: inputs.recent.iter().find(|e| e.name == name).map(|e| e.last_used_ms),
335                settings: settings
336                    .and_then(|s| serde_json::to_value(s).ok())
337                    .unwrap_or_else(|| serde_json::json!({})),
338                name,
339                kind: kind.to_string(),
340                sources: sources.into_iter().map(str::to_string).collect(),
341                status,
342                platform,
343                helper,
344                agents,
345            }
346        })
347        .collect()
348}
349
350/// The list now, for `RemotesList`. `settings` is `FullConfigType::connections`.
351pub async fn list(settings: HashMap<String, ConnKeywords>) -> Vec<RemoteRecord> {
352    let ssh_config_hosts = tokio::task::spawn_blocking(super::ssh_config::hosts)
353        .await
354        .unwrap_or_default();
355    let wsl_distros = super::wsl::list_cached().await;
356    let config_home = crate::backend::base::get_mux_config_dir();
357    let (helper_hosts, host_info) = super::helper_hosts::all_in(&config_home);
358    let inputs = Inputs {
359        ssh_config_hosts,
360        wsl_distros,
361        settings,
362        recent: recent_in(&config_home),
363        statuses: super::status::all(),
364        helper_hosts,
365        host_info,
366        sessions: session_counts().lock().unwrap_or_else(|e| e.into_inner()).clone(),
367        grants: super::agent_access::all_in(&config_home),
368    };
369    build(&inputs)
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    fn status(connection: &str, state: &str) -> ConnStatus {
377        ConnStatus {
378            status: state.to_string(),
379            connection: connection.to_string(),
380            connected: state == "connected",
381            hasconnected: true,
382            activeconnnum: 0,
383            error: String::new(),
384        }
385    }
386
387    fn named<'a>(records: &'a [RemoteRecord], name: &str) -> &'a RemoteRecord {
388        records.iter().find(|r| r.name == name).unwrap_or_else(|| panic!("no record for {name}"))
389    }
390
391    #[test]
392    fn one_record_per_remote_whatever_lists_it() {
393        let mut settings = HashMap::new();
394        settings.insert("db1".to_string(), ConnKeywords::default());
395        settings.insert("local".to_string(), ConnKeywords::default());
396        let inputs = Inputs {
397            ssh_config_hosts: vec!["db1".into(), "web".into()],
398            wsl_distros: vec!["Ubuntu".into()],
399            settings,
400            recent: vec![
401                RecentEntry { name: "db1".into(), first_used_ms: 1, last_used_ms: 5 },
402                RecentEntry { name: "me@10.0.0.7".into(), first_used_ms: 2, last_used_ms: 9 },
403            ],
404            ..Default::default()
405        };
406        let records = build(&inputs);
407        let names: Vec<&str> = records.iter().map(|r| r.name.as_str()).collect();
408        assert_eq!(names, ["db1", "me@10.0.0.7", "web", "wsl://Ubuntu"], "local is never a remote");
409        assert_eq!(named(&records, "db1").sources, ["ssh_config", "settings", "recent"]);
410        assert_eq!(named(&records, "me@10.0.0.7").sources, ["recent"]);
411        assert_eq!(named(&records, "me@10.0.0.7").last_used_ms, Some(9));
412        let wsl = named(&records, "wsl://Ubuntu");
413        assert_eq!((wsl.kind.as_str(), wsl.helper.state.as_str()), ("wsl", "none"));
414        assert_eq!(named(&records, "web").status.state, "disconnected", "no status yet");
415    }
416
417    #[test]
418    fn a_record_carries_status_platform_helper_sessions_and_agents() {
419        let mut host_info = BTreeMap::new();
420        host_info.insert(
421            "db1".to_string(),
422            HostInfo { uname: "Linux aarch64".into(), helper_version: "0.59.9".into(), seen_at_ms: 1 },
423        );
424        host_info.insert("router".to_string(), HostInfo { uname: "FreeBSD amd64".into(), ..Default::default() });
425        let mut sessions = HashMap::new();
426        sessions.insert("db1".to_string(), 2);
427        let inputs = Inputs {
428            ssh_config_hosts: vec!["db1".into(), "router".into(), "web".into()],
429            statuses: vec![status("db1", "connected")],
430            helper_hosts: vec!["db1".into()],
431            host_info,
432            sessions,
433            grants: vec![("korp".into(), "db1".into()), ("camper".into(), " db1".into()), ("korp".into(), "web".into())],
434            ..Default::default()
435        };
436        let records = build(&inputs);
437        let db1 = named(&records, "db1");
438        assert_eq!(db1.status.state, "connected");
439        assert_eq!(db1.platform, Some(RemotePlatform { os: "linux".into(), arch: "arm64".into() }));
440        assert_eq!((db1.helper.state.as_str(), db1.helper.version.as_str()), ("installed", "0.59.9"));
441        assert_eq!(db1.sessions, Some(2));
442        assert_eq!(db1.agents, ["camper", "korp"]);
443        assert_eq!(named(&records, "router").helper.state, "unsupported", "no build for FreeBSD");
444        let web = named(&records, "web");
445        assert_eq!((web.helper.state.as_str(), web.sessions, web.platform.clone()), ("absent", None, None));
446    }
447
448    #[test]
449    fn never_wins_over_an_installed_helper() {
450        let mut settings = HashMap::new();
451        settings.insert("db1".to_string(), ConnKeywords { conn_helper: "never".into(), ..Default::default() });
452        let inputs = Inputs {
453            ssh_config_hosts: vec!["db1".into()],
454            settings,
455            helper_hosts: vec!["db1".into()],
456            ..Default::default()
457        };
458        let db1 = &build(&inputs)[0];
459        assert_eq!(db1.helper.state, "never");
460        assert!(db1.helper.installed, "Never stops new installs; the one there can still be removed");
461    }
462
463    #[test]
464    fn a_records_settings_are_its_stored_keys_only() {
465        let mut settings = HashMap::new();
466        settings.insert(
467            "db1".to_string(),
468            ConnKeywords {
469                display_name: "prod-db".into(),
470                display_color: "#e5484d".into(),
471                display_pinned: Some(true),
472                ..Default::default()
473            },
474        );
475        let inputs = Inputs { settings, ..Default::default() };
476        let db1 = &build(&inputs)[0];
477        assert_eq!(
478            db1.settings,
479            serde_json::json!({ "display:name": "prod-db", "display:color": "#e5484d", "display:pinned": true })
480        );
481    }
482
483    #[test]
484    fn platforms_read_from_uname() {
485        assert_eq!(platform_of("Darwin arm64"), Some(RemotePlatform { os: "macos".into(), arch: "arm64".into() }));
486        assert_eq!(platform_of("Linux x86_64\n"), Some(RemotePlatform { os: "linux".into(), arch: "x86_64".into() }));
487        assert_eq!(platform_of(""), None);
488    }
489
490    #[test]
491    fn recent_keeps_ssh_connections_touched_at_most_once_a_minute() {
492        let dir = tempfile::tempdir().unwrap();
493        let home = dir.path();
494        assert!(record_recent_in(home, "me@box", 1_000).unwrap(), "new");
495        assert!(!record_recent_in(home, " me@box ", 2_000).unwrap(), "same host, same minute");
496        assert_eq!(recent_in(home)[0].last_used_ms, 1_000, "not re-written within a minute");
497        assert!(!record_recent_in(home, "me@box", 70_000).unwrap());
498        assert_eq!(recent_in(home)[0].last_used_ms, 70_000);
499        assert!(!record_recent_in(home, "wsl://Ubuntu", 1).unwrap(), "not SSH");
500        assert!(!record_recent_in(home, "local", 1).unwrap());
501        assert_eq!(recent_in(home).len(), 1);
502
503        assert!(forget_recent_in(home, "me@box").unwrap());
504        assert!(!forget_recent_in(home, "me@box").unwrap());
505        assert!(recent_in(home).is_empty());
506    }
507
508    #[test]
509    fn recent_drops_the_least_recently_used_past_the_limit() {
510        let dir = tempfile::tempdir().unwrap();
511        for i in 0..(MAX_RECENT as u64 + 3) {
512            record_recent_in(dir.path(), &format!("me@host{i}"), 1_000 * (i + 1)).unwrap();
513        }
514        let recent = recent_in(dir.path());
515        assert_eq!(recent.len(), MAX_RECENT);
516        assert_eq!(recent[0].name, format!("me@host{}", MAX_RECENT + 2), "newest first");
517        assert!(!recent.iter().any(|e| e.name == "me@host0"), "oldest dropped");
518    }
519
520    #[test]
521    fn a_settings_write_changes_only_the_keys_given() {
522        let mut connections = serde_json::json!({
523            "db1": { "display:name": "old", "ssh:user": "admin", "custom:key": 7 },
524            "web": { "term:theme": "red" }
525        })
526        .as_object()
527        .unwrap()
528        .clone();
529        let values = serde_json::json!({ "display:name": "prod-db", "display:color": "#e5484d", "term:theme": null })
530            .as_object()
531            .unwrap()
532            .clone();
533        apply_connection_values(&mut connections, " db1 ", &values);
534        assert_eq!(
535            connections["db1"],
536            serde_json::json!({ "display:name": "prod-db", "display:color": "#e5484d", "ssh:user": "admin", "custom:key": 7 }),
537            "keys AgentMux doesn't know are kept"
538        );
539        assert_eq!(connections["web"], serde_json::json!({ "term:theme": "red" }), "other connections untouched");
540
541        // Removing the last key removes the entry.
542        let clear = serde_json::json!({ "term:theme": null }).as_object().unwrap().clone();
543        apply_connection_values(&mut connections, "web", &clear);
544        assert!(!connections.contains_key("web"));
545
546        // A new connection.
547        let pin = serde_json::json!({ "display:pinned": true }).as_object().unwrap().clone();
548        apply_connection_values(&mut connections, "me@box", &pin);
549        assert_eq!(connections["me@box"], serde_json::json!({ "display:pinned": true }));
550    }
551
552    #[test]
553    fn an_edit_that_would_not_parse_is_refused() {
554        let connections = serde_json::json!({
555            "ok": { "display:pinned": true, "custom:key": 7 },
556            "bad": { "display:pinned": "yes" }
557        })
558        .as_object()
559        .unwrap()
560        .clone();
561        assert!(check_connection_entry(&connections, "ok").is_ok(), "unknown keys are fine");
562        assert!(check_connection_entry(&connections, "gone").is_ok(), "a removed entry is fine");
563        let err = check_connection_entry(&connections, " bad ").unwrap_err();
564        assert!(err.contains("bad"), "{err}");
565    }
566}