Skip to main content

agentmux_srv/backend/remote/
agent_access.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which agents the user has let use which SSH hosts (spec §8.2 of
5//! SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md).
6//!
7//! An agent on another machine uses the user's SSH identity, so access is
8//! opt-in per agent and host: the first time, the user is asked (which agent,
9//! which host, what it wants to run) and answers once or always. "Always" is
10//! remembered here, in `ssh-agent-access.json` under AgentMux's config dir;
11//! "once" is not stored at all. A host is its connection name, trimmed and
12//! compared exactly (an ssh config alias is case-sensitive); an agent id
13//! ignores case.
14
15use std::path::{Path, PathBuf};
16use std::sync::Mutex;
17
18use serde::{Deserialize, Serialize};
19
20const FILE_NAME: &str = "ssh-agent-access.json";
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23struct Grants {
24    #[serde(default)]
25    always: Vec<Grant>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29struct Grant {
30    agent: String,
31    connection: String,
32}
33
34/// Serializes read-modify-write of the file within this srv.
35static LOCK: Mutex<()> = Mutex::new(());
36
37fn path_in(config_home: &Path) -> PathBuf {
38    config_home.join(FILE_NAME)
39}
40
41fn read(path: &Path) -> Grants {
42    std::fs::read_to_string(path)
43        .ok()
44        .and_then(|s| serde_json::from_str(&s).ok())
45        .unwrap_or_default()
46}
47
48/// Whether the user said "always" for `agent` on `connection`.
49pub fn always_allowed_in(config_home: &Path, agent: &str, connection: &str) -> bool {
50    let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
51    read(&path_in(config_home))
52        .always
53        .iter()
54        .any(|g| g.agent.eq_ignore_ascii_case(agent) && g.connection == connection.trim())
55}
56
57/// Remember "always" for `agent` on `connection`.
58pub fn remember_in(config_home: &Path, agent: &str, connection: &str) -> std::io::Result<()> {
59    let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
60    let path = path_in(config_home);
61    let mut grants = read(&path);
62    let grant = Grant {
63        agent: agent.to_ascii_lowercase(),
64        connection: connection.trim().to_string(),
65    };
66    if !grants.always.contains(&grant) {
67        grants.always.push(grant);
68    }
69    std::fs::create_dir_all(config_home)?;
70    let tmp = path.with_extension("json.tmp");
71    std::fs::write(&tmp, serde_json::to_vec_pretty(&grants).unwrap_or_default())?;
72    std::fs::rename(&tmp, &path)
73}
74
75/// [`always_allowed_in`] under this instance's config dir.
76pub fn always_allowed(agent: &str, connection: &str) -> bool {
77    always_allowed_in(
78        &crate::backend::base::get_mux_config_dir(),
79        agent,
80        connection,
81    )
82}
83
84/// [`remember_in`] under this instance's config dir.
85pub fn remember(agent: &str, connection: &str) -> std::io::Result<()> {
86    remember_in(
87        &crate::backend::base::get_mux_config_dir(),
88        agent,
89        connection,
90    )
91}
92
93/// Forget "always" for `agent` on `connection` (Remotes' Revoke,
94/// SPEC_REMOTES_PANE_2026_10_05.md §4.10): the agent is asked again on its
95/// next use of the host. `false` if there was no such grant.
96pub fn revoke_in(config_home: &Path, agent: &str, connection: &str) -> std::io::Result<bool> {
97    let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
98    let path = path_in(config_home);
99    let mut grants = read(&path);
100    let before = grants.always.len();
101    grants
102        .always
103        .retain(|g| !(g.agent.eq_ignore_ascii_case(agent) && g.connection == connection.trim()));
104    if grants.always.len() == before {
105        return Ok(false);
106    }
107    let tmp = path.with_extension("json.tmp");
108    std::fs::write(&tmp, serde_json::to_vec_pretty(&grants).unwrap_or_default())?;
109    std::fs::rename(&tmp, &path)?;
110    Ok(true)
111}
112
113/// [`revoke_in`] under this instance's config dir.
114pub fn revoke(agent: &str, connection: &str) -> std::io::Result<bool> {
115    revoke_in(&crate::backend::base::get_mux_config_dir(), agent, connection)
116}
117
118/// Every "always" grant, as `(agent, connection)` (Connectors → Remotes lists them
119/// per host, SPEC_REMOTES_PANE_2026_10_05.md §4.10).
120pub fn all_in(config_home: &Path) -> Vec<(String, String)> {
121    let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
122    read(&path_in(config_home))
123        .always
124        .into_iter()
125        .map(|g| (g.agent, g.connection))
126        .collect()
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn always_is_remembered_per_agent_and_host() {
135        let dir = tempfile::tempdir().unwrap();
136        let home = dir.path();
137        assert!(
138            !always_allowed_in(home, "korp", "area54"),
139            "nothing until the user says so"
140        );
141        remember_in(home, "Korp", "area54").unwrap();
142        remember_in(home, "korp", "area54").unwrap();
143        assert!(always_allowed_in(home, "korp", "area54"));
144        assert!(
145            always_allowed_in(home, "KORP", " area54 "),
146            "agent ids ignore case"
147        );
148        assert!(
149            !always_allowed_in(home, "korp", "Area54"),
150            "host names are exact"
151        );
152        assert!(
153            !always_allowed_in(home, "agentx", "area54"),
154            "another agent is asked"
155        );
156        assert!(
157            !always_allowed_in(home, "korp", "nas"),
158            "another host is asked"
159        );
160        let text = std::fs::read_to_string(home.join(FILE_NAME)).unwrap();
161        assert_eq!(text.matches("area54").count(), 1, "stored once: {text}");
162    }
163
164    #[test]
165    fn revoking_asks_again_and_leaves_the_others() {
166        let dir = tempfile::tempdir().unwrap();
167        let home = dir.path();
168        remember_in(home, "korp", "area54").unwrap();
169        remember_in(home, "korp", "nas").unwrap();
170        remember_in(home, "agentx", "area54").unwrap();
171        assert!(revoke_in(home, "KORP", " area54 ").unwrap());
172        assert!(!always_allowed_in(home, "korp", "area54"));
173        assert!(always_allowed_in(home, "korp", "nas"), "another host keeps its grant");
174        assert!(always_allowed_in(home, "agentx", "area54"), "another agent keeps its grant");
175        assert!(!revoke_in(home, "korp", "area54").unwrap(), "nothing left to revoke");
176    }
177
178    #[test]
179    fn an_unreadable_file_grants_nothing() {
180        let dir = tempfile::tempdir().unwrap();
181        std::fs::write(dir.path().join(FILE_NAME), "not json").unwrap();
182        assert!(!always_allowed_in(dir.path(), "korp", "area54"));
183    }
184}