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 PRODUCT_URL_ENV: &str = "HATCHERY_M4A_PRODUCT_URL";
30pub const PRODUCT_AUDIENCE_ENV: &str = "HATCHERY_M4A_PRODUCT_AUDIENCE";
32
33static NODE_SIGNER: std::sync::OnceLock<(String, std::sync::Arc<hatchery_enroll::NodeIdentity>)> = std::sync::OnceLock::new();
34
35pub fn set_node_signer(node_id: String, identity: std::sync::Arc<hatchery_enroll::NodeIdentity>) {
38 let _ = NODE_SIGNER.set((node_id, identity));
39}
40
41pub fn node_account_message(audience: &str, node_id: &str, nick: Option<&str>, ts_ms: i64, nonce: &str) -> Vec<u8> {
43 format!("m4a-node-account-v1\n{audience}\n{node_id}\n{}\n{ts_ms}\n{nonce}\n", nick.unwrap_or("")).into_bytes()
44}
45pub const ACCOUNTS_ROOT_ENV: &str = "HATCHERY_M4A_ACCOUNTS_ROOT";
46
47#[derive(Debug, Clone)]
48pub struct M4aSessionAccounts {
49 bin: PathBuf,
50 base: PathBuf,
51}
52
53pub struct SessionAccount {
55 pub nick: String,
56 pub session_id: String,
59 pub store_root: Option<String>,
61 pub product_url: Option<String>,
63 cap: String,
64}
65
66impl Drop for SessionAccount {
67 fn drop(&mut self) {
68 let bytes = unsafe { self.cap.as_bytes_mut() };
70 bytes.iter_mut().for_each(|b| *b = 0);
71 }
72}
73
74impl std::fmt::Debug for SessionAccount {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.debug_struct("SessionAccount").field("nick", &self.nick).finish_non_exhaustive()
77 }
78}
79
80impl SessionAccount {
81 pub fn attach_reply(&self) -> serde_json::Value {
84 serde_json::json!({"nick": self.nick, "session_id": self.session_id, "cap": self.cap, "store_root": self.store_root, "product_url": self.product_url})
85 }
86
87 pub fn env_mutations(&self) -> Vec<EnvMutation> {
88 vec![
89 EnvMutation { key: OsString::from("M4A_NICK"), value: Some(OsString::from(&self.nick)) },
90 EnvMutation { key: OsString::from("M4A_SESSION_CAP"), value: Some(OsString::from(&self.cap)) },
91 EnvMutation { key: OsString::from("M4A_SESSION_ID"), value: Some(OsString::from(&self.session_id)) },
92 EnvMutation { key: OsString::from("M4A_BOT_NAME"), value: Some(OsString::from(&self.nick)) },
93 ]
94 .into_iter()
95 .chain(self.store_root.iter().map(|r| EnvMutation { key: OsString::from("M4A_STORE_ROOT"), value: Some(OsString::from(r)) }))
96 .chain(self.product_url.iter().map(|u| EnvMutation { key: OsString::from("M4A_PRODUCT_URL"), value: Some(OsString::from(u)) }))
97 .collect()
98 }
99}
100
101impl M4aSessionAccounts {
102 pub fn bin(&self) -> &Path {
104 &self.bin
105 }
106
107 pub fn session_file_path(&self, nick: &str) -> Option<PathBuf> {
111 let ok = !nick.is_empty() && nick.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
112 ok.then(|| self.base.join("sessions").join(format!("{nick}.session")))
113 }
114
115 pub fn write_session_file(&self, account: &SessionAccount) -> Result<PathBuf, String> {
116 let path = self.session_file_path(&account.nick).ok_or("invalid nick")?;
117 let dir = path.parent().ok_or("no dir")?;
118 std::fs::create_dir_all(dir).map_err(|e| format!("sessions dir: {}", e.kind()))?;
119 let mut body = String::new();
120 for m in account.env_mutations() {
121 if let Some(v) = m.value {
122 body.push_str(&format!("{}={}\n", m.key.to_string_lossy(), v.to_string_lossy()));
123 }
124 }
125 let mut opts = std::fs::OpenOptions::new();
126 opts.write(true).create(true).truncate(true);
127 #[cfg(unix)]
128 {
129 use std::os::unix::fs::OpenOptionsExt;
130 opts.mode(0o600);
131 }
132 use std::io::Write;
133 opts.open(&path)
134 .and_then(|mut f| f.write_all(body.as_bytes()))
135 .map_err(|e| format!("session file: {}", e.kind()))?;
136 body.zeroize_best_effort();
137 Ok(path)
138 }
139
140 pub fn mcp_launch_args(&self, g4a_provider: &str, nick: &str) -> Result<Vec<OsString>, String> {
144 let session_file = self.session_file_path(nick).ok_or("invalid nick")?;
145 let bin = self.bin.to_string_lossy().into_owned();
146 let sf = session_file.to_string_lossy().into_owned();
147 let provider = g4a_provider.to_owned();
148 match g4a_provider {
149 "claude" => {
150 let config = serde_json::json!({"mcpServers": {"m4a": {
151 "type": "stdio",
152 "command": bin,
153 "args": ["--session-file", sf, "mcp", "--provider", provider],
154 }}});
155 let path = self.base.join("sessions").join(format!("{nick}.mcp.json"));
156 std::fs::create_dir_all(self.base.join("sessions")).map_err(|e| format!("sessions dir: {}", e.kind()))?;
157 std::fs::write(&path, config.to_string()).map_err(|e| format!("mcp config: {}", e.kind()))?;
158 Ok(vec![OsString::from("--mcp-config"), path.into_os_string()])
159 }
160 "codex" => {
161 let bin = codex_safe_command(&self.bin).ok_or("m4a-agent path has whitespace and is not on PATH")?;
167 if !codex_safe_value(&sf) {
168 return Err("session file path is not safe for codex -c".into());
169 }
170 Ok(vec![
171 OsString::from("-c"),
172 OsString::from(format!("mcp_servers.m4a.command='{bin}'")),
173 OsString::from("-c"),
174 OsString::from(format!("mcp_servers.m4a.args=['--session-file','{sf}','mcp','--provider','codex']")),
175 ])
176 }
177 _ => Ok(Vec::new()),
178 }
179 }
180
181 fn node_enroll(&self, account: &SessionAccount) -> Result<(), String> {
184 let url = account.product_url.as_deref().ok_or("no messenger product url (HATCHERY_M4A_PRODUCT_URL)")?;
185 let store_root = account.store_root.as_deref().ok_or("m4a-agent reported no store root")?;
186 let (node_id, identity) = NODE_SIGNER.get().ok_or("this node is not enrolled: it registers no accounts")?;
187 let audience = std::env::var(PRODUCT_AUDIENCE_ENV).ok().filter(|a| !a.is_empty()).unwrap_or_else(|| "m4a-product".into());
188 let ts_ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0);
189 let mut raw = [0u8; 16];
190 getrandom(&mut raw)?;
191 let nonce: String = raw.iter().map(|b| format!("{b:02x}")).collect();
192 let signature = identity.sign_bytes(&node_account_message(&audience, node_id, Some(&account.nick), ts_ms, &nonce));
193 let out = Command::new(&self.bin)
194 .args(["account", "node-enroll", "--node-id", node_id, "--ts", &ts_ms.to_string(), "--nonce", &nonce, "--signature", &signature, "--nick", &account.nick])
195 .env("M4A_PRODUCT_URL", url)
196 .env("M4A_SESSION_ID", &account.session_id)
197 .env("M4A_STORE_ROOT", store_root)
198 .env("M4A_BOT_NAME", &account.nick)
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 if !out.status.success() {
205 let why = String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("").chars().take(200).collect::<String>();
206 return Err(format!("node-enroll failed: {why}"));
207 }
208 tracing::info!(nick = %account.nick, node_id = %node_id, "m4a account registered on the messenger by this node");
209 Ok(())
210 }
211
212 pub fn remove_session_file(&self, nick: &str) {
213 if let Some(path) = self.session_file_path(nick) {
214 let _ = std::fs::remove_file(path);
215 }
216 let _ = std::fs::remove_file(self.base.join("sessions").join(format!("{nick}.mcp.json")));
217 }
218
219 pub fn new(bin: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
220 Self { bin: bin.into(), base: base.into() }
221 }
222
223 pub fn wake_token(&self) -> Result<String, String> {
225 let path = self.base.join(".node-wake-token");
226 if let Ok(t) = std::fs::read_to_string(&path) {
227 if t.trim().len() >= 32 {
228 return Ok(t.trim().to_owned());
229 }
230 }
231 std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
232 let mut raw = [0u8; 24];
233 getrandom(&mut raw)?;
234 let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
235 let mut opts = std::fs::OpenOptions::new();
236 opts.write(true).create(true).truncate(true);
237 #[cfg(unix)]
238 {
239 use std::os::unix::fs::OpenOptionsExt;
240 opts.mode(0o600);
241 }
242 use std::io::Write;
243 opts.open(&path).and_then(|mut f| f.write_all(token.as_bytes())).map_err(|e| format!("wake token: {}", e.kind()))?;
244 Ok(token)
245 }
246
247 pub fn from_env() -> Option<Self> {
248 let get = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
249 Some(Self::new(get(AGENT_BIN_ENV)?, get(ACCOUNTS_ROOT_ENV)?))
250 }
251
252 pub fn base(&self) -> &Path {
253 &self.base
254 }
255
256 pub fn provision(&self, provider: &str, session_key: &str) -> Result<SessionAccount, String> {
259 std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
260 let mut rand = [0u8; 12];
261 getrandom(&mut rand)?;
262 let tag: String = rand.iter().map(|b| format!("{b:02x}")).collect();
263 let cap_file = self.base.join(format!(".spawn-{tag}.cap"));
264 let out = Command::new(&self.bin)
265 .args(["account", "provision", "--provider", provider, "--session", session_key, "--rotate", "--cap-out"])
266 .arg(&cap_file)
267 .env("M4A_ACCOUNTS_ROOT", &self.base)
268 .env_remove("M4A_SESSION_CAP")
269 .env_remove("M4A_PRODUCT_INVITE")
270 .output()
271 .map_err(|e| format!("m4a-agent: {}", e.kind()))?;
272 let cap = std::fs::read_to_string(&cap_file);
273 let _ = std::fs::remove_file(&cap_file);
274 if !out.status.success() {
275 return Err(format!("m4a-agent account provision failed ({})", out.status.code().unwrap_or(-1)));
276 }
277 let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|_| "m4a-agent answer is not json".to_string())?;
278 let nick = v["nick"].as_str().ok_or("m4a-agent answer has no nick")?.to_string();
279 let cap = cap.map_err(|_| "no capability issued".to_string())?;
280 if cap.trim().is_empty() || cap.contains('\n') {
281 return Err("capability is malformed".into());
282 }
283 let session_id = v["account_ref"].as_str().filter(|r| !r.is_empty()).unwrap_or(session_key).to_owned();
286 let store_root = v["store_root"].as_str().filter(|r| !r.is_empty()).map(str::to_owned);
287 let product_url = std::env::var(PRODUCT_URL_ENV).ok().filter(|u| !u.is_empty());
288 let account = SessionAccount { nick, session_id, store_root, product_url, cap };
289 if !v["enrolled"].as_bool().unwrap_or(false) {
290 if let Err(error) = self.node_enroll(&account) {
291 tracing::warn!(nick = %account.nick, %error, "m4a account not registered on the messenger (stays local)");
292 }
293 }
294 Ok(account)
295 }
296}
297
298fn getrandom(buf: &mut [u8]) -> Result<(), String> {
299 use std::io::Read;
300 std::fs::File::open("/dev/urandom")
301 .and_then(|mut f| f.read_exact(buf))
302 .or_else(|_| {
303 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
305 for (i, b) in buf.iter_mut().enumerate() {
306 *b = (t >> ((i % 16) * 8)) as u8 ^ (i as u8);
307 }
308 Ok::<(), std::io::Error>(())
309 })
310 .map_err(|e| e.to_string())
311}
312
313#[cfg(all(test, unix))]
314mod tests {
315 use super::*;
316
317 fn fake_agent(dir: &Path) -> PathBuf {
318 let p = dir.join("fake-m4a-agent");
320 std::fs::write(
321 &p,
322 "#!/bin/sh\nwhile [ $# -gt 0 ]; do case \"$1\" in --session) s=$2; shift;; --cap-out) o=$2; shift;; esac; shift; done\n\
323 umask 077; printf 'cap-%s-%s' \"$s\" \"$$\" > \"$o\"; printf '{\"nick\":\"codex-%s\",\"account_ref\":\"ref-%s\",\"store_root\":\"/s/%s\",\"enrolled\":true,\"cap_issued\":true}\\n' \"$s\" \"$s\" \"$s\"\n",
324 )
325 .unwrap();
326 use std::os::unix::fs::PermissionsExt;
327 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
328 p
329 }
330
331 #[test]
332 fn each_spawn_gets_its_own_account_and_its_own_session_env() {
333 let dir = std::env::temp_dir().join(format!("hatch-m4a-{}", std::process::id()));
334 let _ = std::fs::remove_dir_all(&dir);
335 std::fs::create_dir_all(&dir).unwrap();
336 let acc = M4aSessionAccounts::new(fake_agent(&dir), dir.join("accounts"));
337 let a = acc.provision("codex", "inc-1").unwrap();
338 let b = acc.provision("codex", "inc-2").unwrap();
339 assert_ne!(a.nick, b.nick);
340 let env = a.env_mutations();
341 let keys: Vec<_> = env.iter().map(|m| m.key.to_string_lossy().into_owned()).collect();
342 assert_eq!(&keys[..5], ["M4A_NICK", "M4A_SESSION_CAP", "M4A_SESSION_ID", "M4A_BOT_NAME", "M4A_STORE_ROOT"]);
343 assert_eq!(env[2].value.as_deref(), Some(std::ffi::OsStr::new("ref-inc-1")));
344 assert_eq!(env[4].value.as_deref(), Some(std::ffi::OsStr::new("/s/inc-1")));
345 assert_ne!(env[1].value, b.env_mutations()[1].value);
346 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();
348 assert!(left.is_empty());
349 assert!(!format!("{a:?}").contains("cap-"));
350 }
351
352 #[test]
353 fn every_builtin_g4a_provider_maps_to_an_m4a_provider() {
354 for spec in gate4agent_catalog::builtin_specs() {
355 let id = spec.id.as_str().to_string();
356 assert!(m4a_provider(&id).is_some(), "g4a provider {id} has no m4a mapping");
357 }
358 assert_eq!(m4a_provider("custom"), None);
359 }
360
361 #[cfg(unix)]
362 #[test]
363 fn wake_token_is_private_stable_and_long() {
364 use std::os::unix::fs::PermissionsExt;
365 let dir = std::env::temp_dir().join(format!("node-wake-token-{}", std::process::id()));
366 let a = M4aSessionAccounts::new("/bin/false", &dir);
367 let t1 = a.wake_token().unwrap();
368 assert_eq!(t1.len(), 48);
369 assert_eq!(a.wake_token().unwrap(), t1);
370 let mode = std::fs::metadata(dir.join(".node-wake-token")).unwrap().permissions().mode() & 0o777;
371 assert_eq!(mode, 0o600);
372 let _ = std::fs::remove_dir_all(&dir);
373 }
374
375 #[test]
376 fn a_failing_agent_injects_nothing() {
377 let acc = M4aSessionAccounts::new("/nonexistent/m4a-agent", std::env::temp_dir().join("hatch-m4a-none"));
378 assert!(acc.provision("codex", "x").is_err());
379 }
380}
381
382fn codex_safe_value(v: &str) -> bool {
383 !v.is_empty() && !v.chars().any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '%' | '!' | '^' | '&' | '|' | '<' | '>' | '(' | ')'))
384}
385
386fn codex_safe_command(bin: &Path) -> Option<String> {
389 let full = bin.to_string_lossy().into_owned();
390 if codex_safe_value(&full) {
391 return Some(full);
392 }
393 let name = bin.file_name()?.to_string_lossy().into_owned();
394 if !codex_safe_value(&name) {
395 return None;
396 }
397 let path = std::env::var_os("PATH")?;
398 let dir = bin.parent()?;
399 std::env::split_paths(&path)
400 .any(|p| p == dir)
401 .then(|| name.strip_suffix(".exe").map(str::to_owned).unwrap_or(name))
402}
403
404trait ZeroizeBestEffort {
405 fn zeroize_best_effort(&mut self);
406}
407
408impl ZeroizeBestEffort for String {
409 fn zeroize_best_effort(&mut self) {
410 unsafe { self.as_bytes_mut() }.iter_mut().for_each(|b| *b = 0);
412 }
413}
414
415#[cfg(test)]
416mod session_file_tests {
417 use super::*;
418
419 #[test]
420 fn mcp_args_are_session_scoped_and_carry_no_capability() {
421 let root = std::env::temp_dir().join(format!("m4a-mcp-args-{}", std::process::id()));
422 let a = M4aSessionAccounts::new("m4a-agent", &root);
423 let c = a.mcp_launch_args("claude", "claude-1").unwrap();
424 assert_eq!(c[0], OsString::from("--mcp-config"));
425 let cfg = std::fs::read_to_string(&c[1]).unwrap();
426 assert!(cfg.contains("--session-file") && cfg.contains("claude-1.session"));
427 let x = a.mcp_launch_args("codex", "codex-1").unwrap();
428 assert_eq!(x.len(), 4);
429 assert!(x.iter().all(|v| !v.to_string_lossy().contains('"')));
430 assert!(a.mcp_launch_args("grok", "grok-1").unwrap().is_empty());
431 a.remove_session_file("claude-1");
432 assert!(!std::path::Path::new(&c[1]).exists());
433 let _ = std::fs::remove_dir_all(root);
434 }
435
436 #[test]
437 fn codex_values_never_carry_whitespace_or_quotes() {
438 assert!(codex_safe_value("/opt/m4a/bin/m4a-agent"));
439 assert!(!codex_safe_value("/home/a b/m4a-agent"));
440 assert!(!codex_safe_value("x'y"));
441 assert_eq!(codex_safe_command(Path::new("/opt/m4a/m4a-agent")).as_deref(), Some("/opt/m4a/m4a-agent"));
442 assert_eq!(codex_safe_command(Path::new("/no such dir/m4a-agent")), None);
443 }
444
445 #[test]
446 fn session_file_path_refuses_path_like_nicks() {
447 let a = M4aSessionAccounts::new("bin", std::env::temp_dir().join("m4a-sf-test"));
448 assert!(a.session_file_path("grok-1").unwrap().ends_with("sessions/grok-1.session"));
449 assert!(a.session_file_path("../x").is_none());
450 assert!(a.session_file_path("").is_none());
451 }
452}
453
454#[cfg(test)]
455mod node_account_message_tests {
456 #[test]
457 fn signed_bytes_follow_the_product_contract() {
458 let m = super::node_account_message("aud", "node-1", Some("nick-a"), 7, "n0");
459 assert_eq!(m, b"m4a-node-account-v1\naud\nnode-1\nnick-a\n7\nn0\n".to_vec());
460 assert_eq!(super::node_account_message("aud", "node-1", None, 7, "n0"), b"m4a-node-account-v1\naud\nnode-1\n\n7\nn0\n".to_vec());
461 }
462}