1use std::ffi::OsString;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use gate4agent_catalog::EnvMutation;
13
14pub 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
35pub struct SessionAccount {
37 pub nick: String,
38 pub session_id: String,
41 cap: String,
42}
43
44impl Drop for SessionAccount {
45 fn drop(&mut self) {
46 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 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 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 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 let bin = codex_safe_command(&self.bin).ok_or("m4a-agent path has whitespace and is not on PATH")?;
135 if !codex_safe_value(&sf) {
136 return Err("session file path is not safe for codex -c".into());
137 }
138 Ok(vec![
139 OsString::from("-c"),
140 OsString::from(format!("mcp_servers.m4a.command='{bin}'")),
141 OsString::from("-c"),
142 OsString::from(format!("mcp_servers.m4a.args=['--session-file','{sf}','mcp','--provider','codex']")),
143 ])
144 }
145 _ => Ok(Vec::new()),
146 }
147 }
148
149 pub fn remove_session_file(&self, nick: &str) {
150 if let Some(path) = self.session_file_path(nick) {
151 let _ = std::fs::remove_file(path);
152 }
153 let _ = std::fs::remove_file(self.base.join("sessions").join(format!("{nick}.mcp.json")));
154 }
155
156 pub fn new(bin: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
157 Self { bin: bin.into(), base: base.into() }
158 }
159
160 pub fn wake_token(&self) -> Result<String, String> {
162 let path = self.base.join(".node-wake-token");
163 if let Ok(t) = std::fs::read_to_string(&path) {
164 if t.trim().len() >= 32 {
165 return Ok(t.trim().to_owned());
166 }
167 }
168 std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
169 let mut raw = [0u8; 24];
170 getrandom(&mut raw)?;
171 let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
172 let mut opts = std::fs::OpenOptions::new();
173 opts.write(true).create(true).truncate(true);
174 #[cfg(unix)]
175 {
176 use std::os::unix::fs::OpenOptionsExt;
177 opts.mode(0o600);
178 }
179 use std::io::Write;
180 opts.open(&path).and_then(|mut f| f.write_all(token.as_bytes())).map_err(|e| format!("wake token: {}", e.kind()))?;
181 Ok(token)
182 }
183
184 pub fn from_env() -> Option<Self> {
185 let get = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
186 Some(Self::new(get(AGENT_BIN_ENV)?, get(ACCOUNTS_ROOT_ENV)?))
187 }
188
189 pub fn base(&self) -> &Path {
190 &self.base
191 }
192
193 pub fn provision(&self, provider: &str, session_key: &str) -> Result<SessionAccount, String> {
196 std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
197 let mut rand = [0u8; 12];
198 getrandom(&mut rand)?;
199 let tag: String = rand.iter().map(|b| format!("{b:02x}")).collect();
200 let cap_file = self.base.join(format!(".spawn-{tag}.cap"));
201 let out = Command::new(&self.bin)
202 .args(["account", "provision", "--provider", provider, "--session", session_key, "--rotate", "--cap-out"])
203 .arg(&cap_file)
204 .env("M4A_ACCOUNTS_ROOT", &self.base)
205 .env_remove("M4A_SESSION_CAP")
206 .env_remove("M4A_PRODUCT_INVITE")
207 .output()
208 .map_err(|e| format!("m4a-agent: {}", e.kind()))?;
209 let cap = std::fs::read_to_string(&cap_file);
210 let _ = std::fs::remove_file(&cap_file);
211 if !out.status.success() {
212 return Err(format!("m4a-agent account provision failed ({})", out.status.code().unwrap_or(-1)));
213 }
214 let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|_| "m4a-agent answer is not json".to_string())?;
215 let nick = v["nick"].as_str().ok_or("m4a-agent answer has no nick")?.to_string();
216 let cap = cap.map_err(|_| "no capability issued".to_string())?;
217 if cap.trim().is_empty() || cap.contains('\n') {
218 return Err("capability is malformed".into());
219 }
220 Ok(SessionAccount { nick, session_id: session_key.to_owned(), cap })
221 }
222}
223
224fn getrandom(buf: &mut [u8]) -> Result<(), String> {
225 use std::io::Read;
226 std::fs::File::open("/dev/urandom")
227 .and_then(|mut f| f.read_exact(buf))
228 .or_else(|_| {
229 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
231 for (i, b) in buf.iter_mut().enumerate() {
232 *b = (t >> ((i % 16) * 8)) as u8 ^ (i as u8);
233 }
234 Ok::<(), std::io::Error>(())
235 })
236 .map_err(|e| e.to_string())
237}
238
239#[cfg(all(test, unix))]
240mod tests {
241 use super::*;
242
243 fn fake_agent(dir: &Path) -> PathBuf {
244 let p = dir.join("fake-m4a-agent");
246 std::fs::write(
247 &p,
248 "#!/bin/sh\nwhile [ $# -gt 0 ]; do case \"$1\" in --session) s=$2; shift;; --cap-out) o=$2; shift;; esac; shift; done\n\
249 umask 077; printf 'cap-%s-%s' \"$s\" \"$$\" > \"$o\"; printf '{\"nick\":\"codex-%s\",\"cap_issued\":true}\\n' \"$s\"\n",
250 )
251 .unwrap();
252 use std::os::unix::fs::PermissionsExt;
253 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
254 p
255 }
256
257 #[test]
258 fn each_spawn_gets_its_own_account_and_only_nick_and_cap_are_injected() {
259 let dir = std::env::temp_dir().join(format!("hatch-m4a-{}", std::process::id()));
260 let _ = std::fs::remove_dir_all(&dir);
261 std::fs::create_dir_all(&dir).unwrap();
262 let acc = M4aSessionAccounts::new(fake_agent(&dir), dir.join("accounts"));
263 let a = acc.provision("codex", "inc-1").unwrap();
264 let b = acc.provision("codex", "inc-2").unwrap();
265 assert_ne!(a.nick, b.nick);
266 let env = a.env_mutations();
267 let keys: Vec<_> = env.iter().map(|m| m.key.to_string_lossy().into_owned()).collect();
268 assert_eq!(keys, ["M4A_NICK", "M4A_SESSION_CAP", "M4A_SESSION_ID"]);
269 assert_eq!(env[2].value.as_deref(), Some(std::ffi::OsStr::new("inc-1")));
270 assert_ne!(env[1].value, b.env_mutations()[1].value);
271 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();
273 assert!(left.is_empty());
274 assert!(!format!("{a:?}").contains("cap-"));
275 }
276
277 #[test]
278 fn every_builtin_g4a_provider_maps_to_an_m4a_provider() {
279 for spec in gate4agent_catalog::builtin_specs() {
280 let id = spec.id.as_str().to_string();
281 assert!(m4a_provider(&id).is_some(), "g4a provider {id} has no m4a mapping");
282 }
283 assert_eq!(m4a_provider("custom"), None);
284 }
285
286 #[cfg(unix)]
287 #[test]
288 fn wake_token_is_private_stable_and_long() {
289 use std::os::unix::fs::PermissionsExt;
290 let dir = std::env::temp_dir().join(format!("node-wake-token-{}", std::process::id()));
291 let a = M4aSessionAccounts::new("/bin/false", &dir);
292 let t1 = a.wake_token().unwrap();
293 assert_eq!(t1.len(), 48);
294 assert_eq!(a.wake_token().unwrap(), t1);
295 let mode = std::fs::metadata(dir.join(".node-wake-token")).unwrap().permissions().mode() & 0o777;
296 assert_eq!(mode, 0o600);
297 let _ = std::fs::remove_dir_all(&dir);
298 }
299
300 #[test]
301 fn a_failing_agent_injects_nothing() {
302 let acc = M4aSessionAccounts::new("/nonexistent/m4a-agent", std::env::temp_dir().join("hatch-m4a-none"));
303 assert!(acc.provision("codex", "x").is_err());
304 }
305}
306
307fn codex_safe_value(v: &str) -> bool {
308 !v.is_empty() && !v.chars().any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '%' | '!' | '^' | '&' | '|' | '<' | '>' | '(' | ')'))
309}
310
311fn codex_safe_command(bin: &Path) -> Option<String> {
314 let full = bin.to_string_lossy().into_owned();
315 if codex_safe_value(&full) {
316 return Some(full);
317 }
318 let name = bin.file_name()?.to_string_lossy().into_owned();
319 if !codex_safe_value(&name) {
320 return None;
321 }
322 let path = std::env::var_os("PATH")?;
323 let dir = bin.parent()?;
324 std::env::split_paths(&path)
325 .any(|p| p == dir)
326 .then(|| name.strip_suffix(".exe").map(str::to_owned).unwrap_or(name))
327}
328
329trait ZeroizeBestEffort {
330 fn zeroize_best_effort(&mut self);
331}
332
333impl ZeroizeBestEffort for String {
334 fn zeroize_best_effort(&mut self) {
335 unsafe { self.as_bytes_mut() }.iter_mut().for_each(|b| *b = 0);
337 }
338}
339
340#[cfg(test)]
341mod session_file_tests {
342 use super::*;
343
344 #[test]
345 fn mcp_args_are_session_scoped_and_carry_no_capability() {
346 let root = std::env::temp_dir().join(format!("m4a-mcp-args-{}", std::process::id()));
347 let a = M4aSessionAccounts::new("m4a-agent", &root);
348 let c = a.mcp_launch_args("claude", "claude-1").unwrap();
349 assert_eq!(c[0], OsString::from("--mcp-config"));
350 let cfg = std::fs::read_to_string(&c[1]).unwrap();
351 assert!(cfg.contains("--session-file") && cfg.contains("claude-1.session"));
352 let x = a.mcp_launch_args("codex", "codex-1").unwrap();
353 assert_eq!(x.len(), 4);
354 assert!(x.iter().all(|v| !v.to_string_lossy().contains('"')));
355 assert!(a.mcp_launch_args("grok", "grok-1").unwrap().is_empty());
356 a.remove_session_file("claude-1");
357 assert!(!std::path::Path::new(&c[1]).exists());
358 let _ = std::fs::remove_dir_all(root);
359 }
360
361 #[test]
362 fn codex_values_never_carry_whitespace_or_quotes() {
363 assert!(codex_safe_value("/opt/m4a/bin/m4a-agent"));
364 assert!(!codex_safe_value("/home/a b/m4a-agent"));
365 assert!(!codex_safe_value("x'y"));
366 assert_eq!(codex_safe_command(Path::new("/opt/m4a/m4a-agent")).as_deref(), Some("/opt/m4a/m4a-agent"));
367 assert_eq!(codex_safe_command(Path::new("/no such dir/m4a-agent")), None);
368 }
369
370 #[test]
371 fn session_file_path_refuses_path_like_nicks() {
372 let a = M4aSessionAccounts::new("bin", std::env::temp_dir().join("m4a-sf-test"));
373 assert!(a.session_file_path("grok-1").unwrap().ends_with("sessions/grok-1.session"));
374 assert!(a.session_file_path("../x").is_none());
375 assert!(a.session_file_path("").is_none());
376 }
377}