Skip to main content

hatchery_node/
m4a_accounts.rs

1//! Per-session m4a accounts assigned by the node at spawn (owner model: session = nick = one key
2//! = one role). When `HATCHERY_M4A_AGENT_BIN` and `HATCHERY_M4A_ACCOUNTS_ROOT` are set, every PTY
3//! spawn gets its own account: the node runs `m4a-agent account provision` (registry and vaults
4//! stay under the node's accounts root, outside the session sandbox), reads the fresh capability
5//! from a one-shot 0600 file it removes at once, and injects ONLY `M4A_NICK` and
6//! `M4A_SESSION_CAP` into that session's child environment. The capability is never logged.
7
8use std::ffi::OsString;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use gate4agent_catalog::EnvMutation;
13
14/// g4a provider id -> the provider name `m4a-agent` accepts. g4a's built-in catalog is
15/// `claude`, `codex`, `grok`, `kimi`; anything else (custom agents) gets no account.
16pub fn m4a_provider(g4a_agent_id: &str) -> Option<&'static str> {
17    match g4a_agent_id {
18        "claude" => Some("claude-code"),
19        "codex" => Some("codex"),
20        "grok" => Some("grok"),
21        "kimi" => Some("kimi-code"),
22        _ => None,
23    }
24}
25
26pub const AGENT_BIN_ENV: &str = "HATCHERY_M4A_AGENT_BIN";
27pub const ACCOUNTS_ROOT_ENV: &str = "HATCHERY_M4A_ACCOUNTS_ROOT";
28
29#[derive(Debug, Clone)]
30pub struct M4aSessionAccounts {
31    bin: PathBuf,
32    base: PathBuf,
33}
34
35/// One session's account as handed to its child: nick and capability only.
36pub struct SessionAccount {
37    pub nick: String,
38    /// The provider session key the account is bound to (also given to the session as
39    /// `M4A_SESSION_ID`, so its own inbox commands address the right account).
40    pub session_id: String,
41    cap: String,
42}
43
44impl Drop for SessionAccount {
45    fn drop(&mut self) {
46        // Best effort wipe of the capability copy held by the node.
47        let bytes = unsafe { self.cap.as_bytes_mut() };
48        bytes.iter_mut().for_each(|b| *b = 0);
49    }
50}
51
52impl std::fmt::Debug for SessionAccount {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("SessionAccount").field("nick", &self.nick).finish_non_exhaustive()
55    }
56}
57
58impl SessionAccount {
59    /// The only variables the session gets from the node for m4a.
60    /// The one-time `/m4a/attach` reply (the capability travels only in this body).
61    pub fn attach_reply(&self) -> serde_json::Value {
62        serde_json::json!({"nick": self.nick, "session_id": self.session_id, "cap": self.cap})
63    }
64
65    pub fn env_mutations(&self) -> Vec<EnvMutation> {
66        vec![
67            EnvMutation { key: OsString::from("M4A_NICK"), value: Some(OsString::from(&self.nick)) },
68            EnvMutation { key: OsString::from("M4A_SESSION_CAP"), value: Some(OsString::from(&self.cap)) },
69            EnvMutation { key: OsString::from("M4A_SESSION_ID"), value: Some(OsString::from(&self.session_id)) },
70        ]
71    }
72}
73
74impl M4aSessionAccounts {
75    /// Fallback identity channel: `<root>/sessions/<nick>.session` (owner-only), the same three
76    /// keys as [`SessionAccount::env_mutations`], for a provider whose tools run in a process
77    /// that did not inherit the session environment (`m4a-agent --session-file <path> ...`).
78    pub fn session_file_path(&self, nick: &str) -> Option<PathBuf> {
79        let ok = !nick.is_empty() && nick.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
80        ok.then(|| self.base.join("sessions").join(format!("{nick}.session")))
81    }
82
83    pub fn write_session_file(&self, account: &SessionAccount) -> Result<PathBuf, String> {
84        let path = self.session_file_path(&account.nick).ok_or("invalid nick")?;
85        let dir = path.parent().ok_or("no dir")?;
86        std::fs::create_dir_all(dir).map_err(|e| format!("sessions dir: {}", e.kind()))?;
87        let mut body = String::new();
88        for m in account.env_mutations() {
89            if let Some(v) = m.value {
90                body.push_str(&format!("{}={}\n", m.key.to_string_lossy(), v.to_string_lossy()));
91            }
92        }
93        let mut opts = std::fs::OpenOptions::new();
94        opts.write(true).create(true).truncate(true);
95        #[cfg(unix)]
96        {
97            use std::os::unix::fs::OpenOptionsExt;
98            opts.mode(0o600);
99        }
100        use std::io::Write;
101        opts.open(&path)
102            .and_then(|mut f| f.write_all(body.as_bytes()))
103            .map_err(|e| format!("session file: {}", e.kind()))?;
104        body.zeroize_best_effort();
105        Ok(path)
106    }
107
108    /// Provider flags that give one spawned session its own m4a MCP server (`m4a-agent
109    /// --session-file <file> mcp`), without touching any user config. Empty for providers with
110    /// no verified per-session MCP flag (grok TUI, kimi PTY): those use attach (`/m4a/attach`).
111    pub fn mcp_launch_args(&self, g4a_provider: &str, nick: &str) -> Result<Vec<OsString>, String> {
112        let session_file = self.session_file_path(nick).ok_or("invalid nick")?;
113        let bin = self.bin.to_string_lossy().into_owned();
114        let sf = session_file.to_string_lossy().into_owned();
115        let provider = g4a_provider.to_owned();
116        match g4a_provider {
117            "claude" => {
118                let config = serde_json::json!({"mcpServers": {"m4a": {
119                    "type": "stdio",
120                    "command": bin,
121                    "args": ["--session-file", sf, "mcp", "--provider", provider],
122                }}});
123                let path = self.base.join("sessions").join(format!("{nick}.mcp.json"));
124                std::fs::create_dir_all(self.base.join("sessions")).map_err(|e| format!("sessions dir: {}", e.kind()))?;
125                std::fs::write(&path, config.to_string()).map_err(|e| format!("mcp config: {}", e.kind()))?;
126                Ok(vec![OsString::from("--mcp-config"), path.into_os_string()])
127            }
128            "codex" => {
129                // TOML literal strings: no escapes, no double quotes (Windows wrapper safe).
130                if bin.contains('\'') || sf.contains('\'') {
131                    return Err("path not representable as a TOML literal string".into());
132                }
133                Ok(vec![
134                    OsString::from("-c"),
135                    OsString::from(format!("mcp_servers.m4a.command='{bin}'")),
136                    OsString::from("-c"),
137                    OsString::from(format!("mcp_servers.m4a.args=['--session-file','{sf}','mcp','--provider','codex']")),
138                ])
139            }
140            _ => Ok(Vec::new()),
141        }
142    }
143
144    pub fn remove_session_file(&self, nick: &str) {
145        if let Some(path) = self.session_file_path(nick) {
146            let _ = std::fs::remove_file(path);
147        }
148        let _ = std::fs::remove_file(self.base.join("sessions").join(format!("{nick}.mcp.json")));
149    }
150
151    pub fn new(bin: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
152        Self { bin: bin.into(), base: base.into() }
153    }
154
155    /// The node's letter-wake token (`<root>/.node-wake-token`, 0600), created on first use.
156    pub fn wake_token(&self) -> Result<String, String> {
157        let path = self.base.join(".node-wake-token");
158        if let Ok(t) = std::fs::read_to_string(&path) {
159            if t.trim().len() >= 32 {
160                return Ok(t.trim().to_owned());
161            }
162        }
163        std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
164        let mut raw = [0u8; 24];
165        getrandom(&mut raw)?;
166        let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
167        let mut opts = std::fs::OpenOptions::new();
168        opts.write(true).create(true).truncate(true);
169        #[cfg(unix)]
170        {
171            use std::os::unix::fs::OpenOptionsExt;
172            opts.mode(0o600);
173        }
174        use std::io::Write;
175        opts.open(&path).and_then(|mut f| f.write_all(token.as_bytes())).map_err(|e| format!("wake token: {}", e.kind()))?;
176        Ok(token)
177    }
178
179    pub fn from_env() -> Option<Self> {
180        let get = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
181        Some(Self::new(get(AGENT_BIN_ENV)?, get(ACCOUNTS_ROOT_ENV)?))
182    }
183
184    pub fn base(&self) -> &Path {
185        &self.base
186    }
187
188    /// The account of `(provider, session_key)`, created if missing, with a fresh capability
189    /// (a respawn of the same session invalidates the previous process's key).
190    pub fn provision(&self, provider: &str, session_key: &str) -> Result<SessionAccount, String> {
191        std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
192        let mut rand = [0u8; 12];
193        getrandom(&mut rand)?;
194        let tag: String = rand.iter().map(|b| format!("{b:02x}")).collect();
195        let cap_file = self.base.join(format!(".spawn-{tag}.cap"));
196        let out = Command::new(&self.bin)
197            .args(["account", "provision", "--provider", provider, "--session", session_key, "--rotate", "--cap-out"])
198            .arg(&cap_file)
199            .env("M4A_ACCOUNTS_ROOT", &self.base)
200            .env_remove("M4A_SESSION_CAP")
201            .env_remove("M4A_PRODUCT_INVITE")
202            .output()
203            .map_err(|e| format!("m4a-agent: {}", e.kind()))?;
204        let cap = std::fs::read_to_string(&cap_file);
205        let _ = std::fs::remove_file(&cap_file);
206        if !out.status.success() {
207            return Err(format!("m4a-agent account provision failed ({})", out.status.code().unwrap_or(-1)));
208        }
209        let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|_| "m4a-agent answer is not json".to_string())?;
210        let nick = v["nick"].as_str().ok_or("m4a-agent answer has no nick")?.to_string();
211        let cap = cap.map_err(|_| "no capability issued".to_string())?;
212        if cap.trim().is_empty() || cap.contains('\n') {
213            return Err("capability is malformed".into());
214        }
215        Ok(SessionAccount { nick, session_id: session_key.to_owned(), cap })
216    }
217}
218
219fn getrandom(buf: &mut [u8]) -> Result<(), String> {
220    use std::io::Read;
221    std::fs::File::open("/dev/urandom")
222        .and_then(|mut f| f.read_exact(buf))
223        .or_else(|_| {
224            // Non-unix fallback: time + address entropy is enough for a unique temp name.
225            let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
226            for (i, b) in buf.iter_mut().enumerate() {
227                *b = (t >> ((i % 16) * 8)) as u8 ^ (i as u8);
228            }
229            Ok::<(), std::io::Error>(())
230        })
231        .map_err(|e| e.to_string())
232}
233
234#[cfg(all(test, unix))]
235mod tests {
236    use super::*;
237
238    fn fake_agent(dir: &Path) -> PathBuf {
239        // Stands in for m4a-agent: one nick per --session, the cap written to --cap-out.
240        let p = dir.join("fake-m4a-agent");
241        std::fs::write(
242            &p,
243            "#!/bin/sh\nwhile [ $# -gt 0 ]; do case \"$1\" in --session) s=$2; shift;; --cap-out) o=$2; shift;; esac; shift; done\n\
244             umask 077; printf 'cap-%s-%s' \"$s\" \"$$\" > \"$o\"; printf '{\"nick\":\"codex-%s\",\"cap_issued\":true}\\n' \"$s\"\n",
245        )
246        .unwrap();
247        use std::os::unix::fs::PermissionsExt;
248        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
249        p
250    }
251
252    #[test]
253    fn each_spawn_gets_its_own_account_and_only_nick_and_cap_are_injected() {
254        let dir = std::env::temp_dir().join(format!("hatch-m4a-{}", std::process::id()));
255        let _ = std::fs::remove_dir_all(&dir);
256        std::fs::create_dir_all(&dir).unwrap();
257        let acc = M4aSessionAccounts::new(fake_agent(&dir), dir.join("accounts"));
258        let a = acc.provision("codex", "inc-1").unwrap();
259        let b = acc.provision("codex", "inc-2").unwrap();
260        assert_ne!(a.nick, b.nick);
261        let env = a.env_mutations();
262        let keys: Vec<_> = env.iter().map(|m| m.key.to_string_lossy().into_owned()).collect();
263        assert_eq!(keys, ["M4A_NICK", "M4A_SESSION_CAP", "M4A_SESSION_ID"]);
264        assert_eq!(env[2].value.as_deref(), Some(std::ffi::OsStr::new("inc-1")));
265        assert_ne!(env[1].value, b.env_mutations()[1].value);
266        // The one-shot cap file is gone and the cap is not in Debug output.
267        let left: Vec<_> = std::fs::read_dir(acc.base()).unwrap().filter_map(Result::ok).filter(|e| e.file_name().to_string_lossy().ends_with(".cap")).collect();
268        assert!(left.is_empty());
269        assert!(!format!("{a:?}").contains("cap-"));
270    }
271
272    #[test]
273    fn every_builtin_g4a_provider_maps_to_an_m4a_provider() {
274        for spec in gate4agent_catalog::builtin_specs() {
275            let id = spec.id.as_str().to_string();
276            assert!(m4a_provider(&id).is_some(), "g4a provider {id} has no m4a mapping");
277        }
278        assert_eq!(m4a_provider("custom"), None);
279    }
280
281    #[cfg(unix)]
282    #[test]
283    fn wake_token_is_private_stable_and_long() {
284        use std::os::unix::fs::PermissionsExt;
285        let dir = std::env::temp_dir().join(format!("node-wake-token-{}", std::process::id()));
286        let a = M4aSessionAccounts::new("/bin/false", &dir);
287        let t1 = a.wake_token().unwrap();
288        assert_eq!(t1.len(), 48);
289        assert_eq!(a.wake_token().unwrap(), t1);
290        let mode = std::fs::metadata(dir.join(".node-wake-token")).unwrap().permissions().mode() & 0o777;
291        assert_eq!(mode, 0o600);
292        let _ = std::fs::remove_dir_all(&dir);
293    }
294
295    #[test]
296    fn a_failing_agent_injects_nothing() {
297        let acc = M4aSessionAccounts::new("/nonexistent/m4a-agent", std::env::temp_dir().join("hatch-m4a-none"));
298        assert!(acc.provision("codex", "x").is_err());
299    }
300}
301
302trait ZeroizeBestEffort {
303    fn zeroize_best_effort(&mut self);
304}
305
306impl ZeroizeBestEffort for String {
307    fn zeroize_best_effort(&mut self) {
308        // SAFETY: zero bytes keep the buffer valid UTF-8.
309        unsafe { self.as_bytes_mut() }.iter_mut().for_each(|b| *b = 0);
310    }
311}
312
313#[cfg(test)]
314mod session_file_tests {
315    use super::*;
316
317    #[test]
318    fn mcp_args_are_session_scoped_and_carry_no_capability() {
319        let root = std::env::temp_dir().join(format!("m4a-mcp-args-{}", std::process::id()));
320        let a = M4aSessionAccounts::new("m4a-agent", &root);
321        let c = a.mcp_launch_args("claude", "claude-1").unwrap();
322        assert_eq!(c[0], OsString::from("--mcp-config"));
323        let cfg = std::fs::read_to_string(&c[1]).unwrap();
324        assert!(cfg.contains("--session-file") && cfg.contains("claude-1.session"));
325        let x = a.mcp_launch_args("codex", "codex-1").unwrap();
326        assert_eq!(x.len(), 4);
327        assert!(x.iter().all(|v| !v.to_string_lossy().contains('"')));
328        assert!(a.mcp_launch_args("grok", "grok-1").unwrap().is_empty());
329        a.remove_session_file("claude-1");
330        assert!(!std::path::Path::new(&c[1]).exists());
331        let _ = std::fs::remove_dir_all(root);
332    }
333
334    #[test]
335    fn session_file_path_refuses_path_like_nicks() {
336        let a = M4aSessionAccounts::new("bin", std::env::temp_dir().join("m4a-sf-test"));
337        assert!(a.session_file_path("grok-1").unwrap().ends_with("sessions/grok-1.session"));
338        assert!(a.session_file_path("../x").is_none());
339        assert!(a.session_file_path("").is_none());
340    }
341}