1use std::ffi::OsString;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15use gate4agent_catalog::EnvMutation;
16
17pub fn m4a_provider(g4a_agent_id: &str) -> Option<&'static str> {
20 match g4a_agent_id {
21 "claude" => Some("claude-code"),
22 "codex" => Some("codex"),
23 "grok" => Some("grok"),
24 "kimi" => Some("kimi-code"),
25 _ => None,
26 }
27}
28
29pub fn provider_session_env(g4a_agent_id: &str) -> Vec<EnvMutation> {
32 match g4a_agent_id {
33 "kimi" => vec![EnvMutation { key: OsString::from("KIMI_CODE_TRUST_WORKSPACE"), value: Some(OsString::from("1")) }],
35 _ => Vec::new(),
36 }
37}
38
39pub fn mcp_server_name(g4a_agent_id: &str) -> String {
44 let from_arg = MCP_NAME_ARGS.lock().unwrap_or_else(|p| p.into_inner()).iter().rev()
45 .find(|(p, _)| p.as_deref().map_or(true, |p| p == g4a_agent_id)).map(|(_, n)| n.clone());
46 if let Some(name) = from_arg {
47 return name;
48 }
49 let key = format!("HATCHERY_M4A_MCP_NAME_{}", g4a_agent_id.to_ascii_uppercase());
50 let ok = |v: &String| !v.is_empty() && v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
51 std::env::var(&key)
52 .ok()
53 .filter(ok)
54 .or_else(|| std::env::var("HATCHERY_M4A_MCP_NAME").ok().filter(ok))
55 .unwrap_or_else(|| "m4a".into())
56}
57
58static MCP_NAME_ARGS: std::sync::Mutex<Vec<(Option<String>, String)>> = std::sync::Mutex::new(Vec::new());
59
60pub fn set_mcp_name_arg(value: &str) -> bool {
63 let (provider, name) = match value.split_once('=') {
64 Some((p, n)) => (Some(p.trim().to_owned()), n.trim().to_owned()),
65 None => (None, value.trim().to_owned()),
66 };
67 if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
68 return false;
69 }
70 let mut args = MCP_NAME_ARGS.lock().unwrap_or_else(|p| p.into_inner());
71 if provider.is_some() { args.push((provider, name)) } else { args.insert(0, (None, name)) }
73 true
74}
75
76pub const AGENT_BIN_ENV: &str = "HATCHERY_M4A_AGENT_BIN";
77pub const PRODUCT_URL_ENV: &str = "HATCHERY_M4A_PRODUCT_URL";
80pub const PRODUCT_AUDIENCE_ENV: &str = "HATCHERY_M4A_PRODUCT_AUDIENCE";
82
83static NODE_SIGNER: std::sync::OnceLock<(String, std::sync::Arc<hatchery_enroll::NodeIdentity>)> = std::sync::OnceLock::new();
84
85pub fn set_node_signer(node_id: String, identity: std::sync::Arc<hatchery_enroll::NodeIdentity>) {
88 let _ = NODE_SIGNER.set((node_id, identity));
89}
90
91pub fn node_account_message(audience: &str, node_id: &str, nick: Option<&str>, ts_ms: i64, nonce: &str) -> Vec<u8> {
93 format!("m4a-node-account-v1\n{audience}\n{node_id}\n{}\n{ts_ms}\n{nonce}\n", nick.unwrap_or("")).into_bytes()
94}
95pub const ACCOUNTS_ROOT_ENV: &str = "HATCHERY_M4A_ACCOUNTS_ROOT";
96
97#[derive(Debug, Clone)]
98pub struct M4aSessionAccounts {
99 bin: PathBuf,
100 base: PathBuf,
101}
102
103pub struct SessionAccount {
105 pub nick: String,
106 pub session_id: String,
109 pub store_root: Option<String>,
111 pub product_url: Option<String>,
113 cap: String,
114}
115
116impl Drop for SessionAccount {
117 fn drop(&mut self) {
118 let bytes = unsafe { self.cap.as_bytes_mut() };
120 bytes.iter_mut().for_each(|b| *b = 0);
121 }
122}
123
124impl std::fmt::Debug for SessionAccount {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.debug_struct("SessionAccount").field("nick", &self.nick).finish_non_exhaustive()
127 }
128}
129
130impl SessionAccount {
131 pub fn attach_reply(&self) -> serde_json::Value {
134 serde_json::json!({"nick": self.nick, "session_id": self.session_id, "cap": self.cap, "store_root": self.store_root, "product_url": self.product_url})
135 }
136
137 pub fn env_mutations(&self) -> Vec<EnvMutation> {
138 vec![
139 EnvMutation { key: OsString::from("M4A_NICK"), value: Some(OsString::from(&self.nick)) },
140 EnvMutation { key: OsString::from("M4A_SESSION_CAP"), value: Some(OsString::from(&self.cap)) },
141 EnvMutation { key: OsString::from("M4A_SESSION_ID"), value: Some(OsString::from(&self.session_id)) },
142 EnvMutation { key: OsString::from("M4A_BOT_NAME"), value: Some(OsString::from(&self.nick)) },
143 ]
144 .into_iter()
145 .chain(self.store_root.iter().map(|r| EnvMutation { key: OsString::from("M4A_STORE_ROOT"), value: Some(OsString::from(r)) }))
146 .chain(self.product_url.iter().map(|u| EnvMutation { key: OsString::from("M4A_PRODUCT_URL"), value: Some(OsString::from(u)) }))
147 .collect()
148 }
149}
150
151impl M4aSessionAccounts {
152 pub fn bin(&self) -> &Path {
154 &self.bin
155 }
156
157 pub fn session_file_path(&self, nick: &str) -> Option<PathBuf> {
161 let ok = !nick.is_empty() && nick.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
162 ok.then(|| self.base.join("sessions").join(format!("{nick}.session")))
163 }
164
165 pub fn write_session_file(&self, account: &SessionAccount) -> Result<PathBuf, String> {
166 let path = self.session_file_path(&account.nick).ok_or("invalid nick")?;
167 let dir = path.parent().ok_or("no dir")?;
168 std::fs::create_dir_all(dir).map_err(|e| format!("sessions dir: {}", e.kind()))?;
169 let mut body = String::new();
170 for m in account.env_mutations() {
171 if let Some(v) = m.value {
172 body.push_str(&format!("{}={}\n", m.key.to_string_lossy(), v.to_string_lossy()));
173 }
174 }
175 let mut opts = std::fs::OpenOptions::new();
176 opts.write(true).create(true).truncate(true);
177 #[cfg(unix)]
178 {
179 use std::os::unix::fs::OpenOptionsExt;
180 opts.mode(0o600);
181 }
182 use std::io::Write;
183 opts.open(&path)
184 .and_then(|mut f| f.write_all(body.as_bytes()))
185 .map_err(|e| format!("session file: {}", e.kind()))?;
186 body.zeroize_best_effort();
187 Ok(path)
188 }
189
190 pub fn mcp_launch_args(&self, g4a_provider: &str, nick: &str) -> Result<Vec<OsString>, String> {
194 let session_file = self.session_file_path(nick).ok_or("invalid nick")?;
195 let bin = self.bin.to_string_lossy().into_owned();
196 let sf = session_file.to_string_lossy().into_owned();
197 let provider = g4a_provider.to_owned();
198 match g4a_provider {
199 "claude" => {
200 let config = serde_json::json!({"mcpServers": {"m4a": {
201 "type": "stdio",
202 "command": bin,
203 "args": ["--session-file", sf, "mcp", "--provider", provider],
204 }}});
205 let path = self.base.join("sessions").join(format!("{nick}.mcp.json"));
206 std::fs::create_dir_all(self.base.join("sessions")).map_err(|e| format!("sessions dir: {}", e.kind()))?;
207 std::fs::write(&path, config.to_string()).map_err(|e| format!("mcp config: {}", e.kind()))?;
208 Ok(vec![OsString::from("--mcp-config"), path.into_os_string()])
209 }
210 "codex" => {
211 let name = mcp_server_name("codex");
217 let bin = codex_safe_command(&self.bin).ok_or("m4a-agent path has whitespace and is not on PATH")?;
218 if !codex_safe_value(&sf) {
219 return Err("session file path is not safe for codex -c".into());
220 }
221 Ok(vec![
222 OsString::from("-c"),
223 OsString::from(format!("mcp_servers.{name}.command='{bin}'")),
224 OsString::from("-c"),
225 OsString::from(format!("mcp_servers.{name}.args=['--session-file','{sf}','mcp','--provider','codex']")),
226 ])
227 }
228 _ => Ok(Vec::new()),
229 }
230 }
231
232 pub fn session_workdir(&self, workspace_root: &Path, g4a_provider: &str, nick: &str) -> Result<Option<PathBuf>, String> {
237 let session_file = self.session_file_path(nick).ok_or("invalid nick")?;
238 let dir = workspace_root.join(".hatchery-sessions").join(nick);
239 let name = mcp_server_name(g4a_provider);
240 let bin = self.bin.to_string_lossy().into_owned();
241 let sf = session_file.to_string_lossy().into_owned();
242 let write = |rel: &str, text: String| -> Result<(), String> {
243 let path = dir.join(rel);
244 if let Some(parent) = path.parent() {
245 std::fs::create_dir_all(parent).map_err(|e| format!("session dir: {}", e.kind()))?;
246 }
247 std::fs::write(&path, text).map_err(|e| format!("project mcp: {}", e.kind()))
248 };
249 match g4a_provider {
250 "kimi" => {
251 let provider = m4a_provider(g4a_provider).unwrap_or(g4a_provider);
252 let config = serde_json::json!({"mcpServers": {name: {
253 "command": bin,
254 "args": ["--session-file", sf, "mcp", "--provider", provider],
255 }}});
256 write(".kimi-code/mcp.json", serde_json::to_string_pretty(&config).unwrap_or_default())?;
257 }
258 "grok" => {
259 let q = |v: &str| serde_json::to_string(v).unwrap_or_default();
261 let text = format!(
262 "[mcp_servers.{name}]\ncommand = {}\nargs = [{}, {}, \"mcp\", \"--provider\", \"grok\"]\n",
263 q(&bin), q("--session-file"), q(&sf)
264 );
265 write(".grok/config.toml", text)?;
266 }
267 _ => return Ok(None),
268 }
269 Ok(Some(dir))
270 }
271
272 fn node_enroll(&self, account: &SessionAccount) -> Result<(), String> {
275 let url = account.product_url.as_deref().ok_or("no messenger product url (HATCHERY_M4A_PRODUCT_URL)")?;
276 let store_root = account.store_root.as_deref().ok_or("m4a-agent reported no store root")?;
277 let (node_id, identity) = NODE_SIGNER.get().ok_or("this node is not enrolled: it registers no accounts")?;
278 let audience = std::env::var(PRODUCT_AUDIENCE_ENV).ok().filter(|a| !a.is_empty()).unwrap_or_else(|| "m4a-product".into());
279 let ts_ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0);
280 let mut raw = [0u8; 16];
281 getrandom(&mut raw)?;
282 let nonce: String = raw.iter().map(|b| format!("{b:02x}")).collect();
283 let signature = identity.sign_bytes(&node_account_message(&audience, node_id, Some(&account.nick), ts_ms, &nonce));
284 let mut cmd = Command::new(&self.bin);
285 cmd.args(["account", "node-enroll", "--node-id", node_id, "--ts", &ts_ms.to_string(), "--nonce", &nonce, "--signature", &signature, "--nick", &account.nick])
286 .env("M4A_PRODUCT_URL", url)
287 .env("M4A_SESSION_ID", &account.session_id)
288 .env("M4A_STORE_ROOT", store_root)
289 .env("M4A_BOT_NAME", &account.nick)
290 .env("M4A_ACCOUNTS_ROOT", &self.base)
291 .env_remove("M4A_SESSION_CAP")
292 .env_remove("M4A_PRODUCT_INVITE");
293 crate::windows_process::hide_console(&mut cmd);
294 let out = cmd.output().map_err(|e| format!("m4a-agent: {}", e.kind()))?;
295 if !out.status.success() {
296 let why = String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("").chars().take(200).collect::<String>();
297 return Err(format!("node-enroll failed: {why}"));
298 }
299 tracing::info!(nick = %account.nick, node_id = %node_id, "m4a account registered on the messenger by this node");
300 Ok(())
301 }
302
303 pub fn remove_session_file(&self, nick: &str) {
304 if let Some(path) = self.session_file_path(nick) {
305 let _ = std::fs::remove_file(path);
306 }
307 let _ = std::fs::remove_file(self.base.join("sessions").join(format!("{nick}.mcp.json")));
308 }
309
310 pub fn new(bin: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
311 Self { bin: bin.into(), base: base.into() }
312 }
313
314 pub fn wake_token(&self) -> Result<String, String> {
316 let path = self.base.join(".node-wake-token");
317 if let Ok(t) = std::fs::read_to_string(&path) {
318 if t.trim().len() >= 32 {
319 return Ok(t.trim().to_owned());
320 }
321 }
322 std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
323 let mut raw = [0u8; 24];
324 getrandom(&mut raw)?;
325 let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
326 let mut opts = std::fs::OpenOptions::new();
327 opts.write(true).create(true).truncate(true);
328 #[cfg(unix)]
329 {
330 use std::os::unix::fs::OpenOptionsExt;
331 opts.mode(0o600);
332 }
333 use std::io::Write;
334 opts.open(&path).and_then(|mut f| f.write_all(token.as_bytes())).map_err(|e| format!("wake token: {}", e.kind()))?;
335 Ok(token)
336 }
337
338 pub fn find_agent_bin() -> Option<PathBuf> {
340 if let Some(p) = std::env::var_os(AGENT_BIN_ENV).filter(|v| !v.is_empty()).map(PathBuf::from) {
341 return Some(p);
342 }
343 if let Ok(exe) = std::env::current_exe() {
344 if let Some(dir) = exe.parent() {
345 for name in ["m4a-agent", "m4a-agent.exe"] {
346 let p = dir.join(name);
347 if p.is_file() {
348 return Some(p);
349 }
350 }
351 }
352 }
353 which("m4a-agent").or_else(|| which("m4a-agent.exe")).or_else(|| {
354 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
355 let p = PathBuf::from(home).join(".cargo").join("bin").join(if cfg!(windows) { "m4a-agent.exe" } else { "m4a-agent" });
356 p.is_file().then_some(p)
357 })
358 }
359
360 pub fn default_accounts_root(enroll_dir: Option<&Path>) -> Option<PathBuf> {
362 if let Some(p) = std::env::var_os(ACCOUNTS_ROOT_ENV).filter(|v| !v.is_empty()).map(PathBuf::from) {
363 return Some(p);
364 }
365 enroll_dir.map(|d| d.join("m4a-accounts"))
366 }
367
368 pub fn resolve_product_url(enroll_dir: Option<&Path>) -> Option<String> {
371 if let Some(u) = std::env::var(PRODUCT_URL_ENV).ok().filter(|u| !u.is_empty()) {
372 return Some(u);
373 }
374 if let Some(u) = c2_product_url() {
375 return Some(u);
376 }
377 let dir = enroll_dir?;
378 let text = std::fs::read_to_string(dir.join("enrollment.json")).ok()?;
379 let v: serde_json::Value = serde_json::from_str(&text).ok()?;
380 v.get("response")
381 .and_then(|r| r.get("m4a_product_url"))
382 .or_else(|| v.get("m4a_product_url"))
383 .and_then(|u| u.as_str())
384 .filter(|u| !u.is_empty())
385 .map(str::to_owned)
386 }
387
388 pub fn from_env() -> Option<Self> {
390 Self::from_enroll_dir(enroll_dir_hint().as_deref())
391 }
392
393 pub fn from_enroll_dir(enroll_dir: Option<&Path>) -> Option<Self> {
394 let bin = match Self::find_agent_bin() {
395 Some(p) => p,
396 None => {
397 tracing::warn!(
398 "m4a-agent not found (set HATCHERY_M4A_AGENT_BIN, put it next to gate4agent-node, on PATH, or in ~/.cargo/bin); m4a off"
399 );
400 return None;
401 }
402 };
403 let base = match Self::default_accounts_root(enroll_dir) {
404 Some(p) => p,
405 None => {
406 tracing::warn!("m4a accounts root unknown (set HATCHERY_M4A_ACCOUNTS_ROOT or pass --enroll-state); m4a off");
407 return None;
408 }
409 };
410 if let Err(e) = std::fs::create_dir_all(&base) {
411 tracing::warn!(error = %e.kind(), path = %base.display(), "m4a accounts root not writable; m4a off");
412 return None;
413 }
414 let product = Self::resolve_product_url(enroll_dir);
415 if product.is_none() {
416 tracing::info!("m4a product url unset (env / C2 connect / enrollment); accounts stay local until set");
417 }
418 tracing::info!(bin = %bin.display(), accounts = %base.display(), product = product.as_deref().unwrap_or("-"), "m4a accounts ready");
419 Some(Self::new(bin, base))
420 }
421
422 pub fn base(&self) -> &Path {
423 &self.base
424 }
425
426 pub fn provision(&self, provider: &str, session_key: &str) -> Result<SessionAccount, String> {
429 std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
430 let mut rand = [0u8; 12];
431 getrandom(&mut rand)?;
432 let tag: String = rand.iter().map(|b| format!("{b:02x}")).collect();
433 let cap_file = self.base.join(format!(".spawn-{tag}.cap"));
434 let mut cmd = Command::new(&self.bin);
435 cmd.args(["account", "provision", "--provider", provider, "--session", session_key, "--rotate", "--cap-out"])
436 .arg(&cap_file)
437 .env("M4A_ACCOUNTS_ROOT", &self.base)
438 .env_remove("M4A_SESSION_CAP")
439 .env_remove("M4A_PRODUCT_INVITE");
440 crate::windows_process::hide_console(&mut cmd);
441 let out = cmd.output().map_err(|e| format!("m4a-agent: {}", e.kind()))?;
442 let cap = std::fs::read_to_string(&cap_file);
443 let _ = std::fs::remove_file(&cap_file);
444 if !out.status.success() {
445 return Err(format!("m4a-agent account provision failed ({})", out.status.code().unwrap_or(-1)));
446 }
447 let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|_| "m4a-agent answer is not json".to_string())?;
448 let nick = v["nick"].as_str().ok_or("m4a-agent answer has no nick")?.to_string();
449 let cap = cap.map_err(|_| "no capability issued".to_string())?;
450 if cap.trim().is_empty() || cap.contains('\n') {
451 return Err("capability is malformed".into());
452 }
453 let session_id = v["account_ref"].as_str().filter(|r| !r.is_empty()).unwrap_or(session_key).to_owned();
456 let store_root = v["store_root"].as_str().filter(|r| !r.is_empty()).map(str::to_owned);
457 let product_url = Self::resolve_product_url(enroll_dir_hint().as_deref());
458 let account = SessionAccount { nick, session_id, store_root, product_url, cap };
459 if !v["enrolled"].as_bool().unwrap_or(false) {
460 if let Err(error) = self.node_enroll(&account) {
461 tracing::warn!(nick = %account.nick, %error, "m4a account not registered on the messenger (stays local)");
462 }
463 }
464 Ok(account)
465 }
466}
467
468fn which(name: &str) -> Option<PathBuf> {
469 let path = std::env::var_os("PATH")?;
470 for dir in std::env::split_paths(&path) {
471 let p = dir.join(name);
472 if p.is_file() {
473 return Some(p);
474 }
475 }
476 None
477}
478
479static C2_PRODUCT_URL: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
480
481pub fn set_c2_product_url(url: Option<String>) {
483 *C2_PRODUCT_URL.write().unwrap_or_else(|e| e.into_inner()) = url.filter(|u| !u.is_empty());
484}
485
486pub fn c2_product_url() -> Option<String> {
487 C2_PRODUCT_URL.read().unwrap_or_else(|e| e.into_inner()).clone()
488}
489
490pub(crate) fn enroll_dir_hint() -> Option<PathBuf> {
491 std::env::var_os("HATCHERY_ENROLL_STATE")
492 .filter(|v| !v.is_empty())
493 .map(PathBuf::from)
494 .or_else(|| {
495 None
497 })
498}
499
500fn getrandom(buf: &mut [u8]) -> Result<(), String> {
501 use std::io::Read;
502 std::fs::File::open("/dev/urandom")
503 .and_then(|mut f| f.read_exact(buf))
504 .or_else(|_| {
505 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
507 for (i, b) in buf.iter_mut().enumerate() {
508 *b = (t >> ((i % 16) * 8)) as u8 ^ (i as u8);
509 }
510 Ok::<(), std::io::Error>(())
511 })
512 .map_err(|e| e.to_string())
513}
514
515#[cfg(all(test, unix))]
516mod tests {
517 use super::*;
518
519 fn fake_agent(dir: &Path) -> PathBuf {
520 let p = dir.join("fake-m4a-agent");
522 std::fs::write(
523 &p,
524 "#!/bin/sh\nwhile [ $# -gt 0 ]; do case \"$1\" in --session) s=$2; shift;; --cap-out) o=$2; shift;; esac; shift; done\n\
525 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",
526 )
527 .unwrap();
528 use std::os::unix::fs::PermissionsExt;
529 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
530 p
531 }
532
533 #[test]
534 fn defaults_find_agent_and_accounts_root_and_product_from_enrollment() {
535 let dir = std::env::temp_dir().join(format!("m4a-defaults-{}", std::process::id()));
536 let _ = std::fs::remove_dir_all(&dir);
537 std::fs::create_dir_all(&dir).unwrap();
538 let agent = fake_agent(&dir);
539 std::env::set_var(AGENT_BIN_ENV, &agent);
540 std::env::remove_var(ACCOUNTS_ROOT_ENV);
541 std::env::remove_var(PRODUCT_URL_ENV);
542 let root = M4aSessionAccounts::default_accounts_root(Some(dir.as_path())).unwrap();
543 assert_eq!(root, dir.join("m4a-accounts"));
544 assert_eq!(M4aSessionAccounts::find_agent_bin().as_deref(), Some(agent.as_path()));
545 std::fs::write(
546 dir.join("enrollment.json"),
547 r#"{"node_id":"n","response":{"node_id":"n","status":"approved","m4a_product_url":"https://example.invalid"}}"#,
548 )
549 .unwrap();
550 assert_eq!(
551 M4aSessionAccounts::resolve_product_url(Some(dir.as_path())).as_deref(),
552 Some("https://example.invalid")
553 );
554 let got = M4aSessionAccounts::from_enroll_dir(Some(dir.as_path())).unwrap();
555 assert_eq!(got.base(), root.as_path());
556 std::env::remove_var(AGENT_BIN_ENV);
557 std::env::remove_var(PRODUCT_URL_ENV);
558 std::fs::remove_dir_all(&dir).unwrap();
559 }
560
561 #[test]
562 fn product_url_order_env_then_c2_then_enrollment() {
563 let dir = std::env::temp_dir().join(format!("m4a-purl-{}", std::process::id()));
564 let _ = std::fs::remove_dir_all(&dir);
565 std::fs::create_dir_all(&dir).unwrap();
566 std::fs::write(dir.join("enrollment.json"), r#"{"response":{"m4a_product_url":"https://enroll.invalid"}}"#).unwrap();
567 std::env::remove_var(PRODUCT_URL_ENV);
568 set_c2_product_url(None);
569 assert_eq!(M4aSessionAccounts::resolve_product_url(Some(&dir)).as_deref(), Some("https://enroll.invalid"));
570 set_c2_product_url(Some("https://c2.invalid".into()));
571 assert_eq!(M4aSessionAccounts::resolve_product_url(Some(&dir)).as_deref(), Some("https://c2.invalid"));
572 std::env::set_var(PRODUCT_URL_ENV, "https://env.invalid");
573 assert_eq!(M4aSessionAccounts::resolve_product_url(Some(&dir)).as_deref(), Some("https://env.invalid"));
574 std::env::remove_var(PRODUCT_URL_ENV);
575 set_c2_product_url(None);
576 std::fs::remove_dir_all(&dir).unwrap();
577 }
578
579 #[test]
580 fn each_spawn_gets_its_own_account_and_its_own_session_env() {
581 let dir = std::env::temp_dir().join(format!("hatch-m4a-{}", std::process::id()));
582 let _ = std::fs::remove_dir_all(&dir);
583 std::fs::create_dir_all(&dir).unwrap();
584 let acc = M4aSessionAccounts::new(fake_agent(&dir), dir.join("accounts"));
585 let a = acc.provision("codex", "inc-1").unwrap();
586 let b = acc.provision("codex", "inc-2").unwrap();
587 assert_ne!(a.nick, b.nick);
588 let env = a.env_mutations();
589 let keys: Vec<_> = env.iter().map(|m| m.key.to_string_lossy().into_owned()).collect();
590 assert_eq!(&keys[..5], ["M4A_NICK", "M4A_SESSION_CAP", "M4A_SESSION_ID", "M4A_BOT_NAME", "M4A_STORE_ROOT"]);
591 assert_eq!(env[2].value.as_deref(), Some(std::ffi::OsStr::new("ref-inc-1")));
592 assert_eq!(env[4].value.as_deref(), Some(std::ffi::OsStr::new("/s/inc-1")));
593 assert_ne!(env[1].value, b.env_mutations()[1].value);
594 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();
596 assert!(left.is_empty());
597 assert!(!format!("{a:?}").contains("cap-"));
598 }
599
600 #[test]
601 fn every_builtin_g4a_provider_maps_to_an_m4a_provider() {
602 for spec in gate4agent_catalog::builtin_specs() {
603 let id = spec.id.as_str().to_string();
604 assert!(m4a_provider(&id).is_some(), "g4a provider {id} has no m4a mapping");
605 }
606 assert_eq!(m4a_provider("custom"), None);
607 }
608
609 #[cfg(unix)]
610 #[test]
611 fn wake_token_is_private_stable_and_long() {
612 use std::os::unix::fs::PermissionsExt;
613 let dir = std::env::temp_dir().join(format!("node-wake-token-{}", std::process::id()));
614 let a = M4aSessionAccounts::new("/bin/false", &dir);
615 let t1 = a.wake_token().unwrap();
616 assert_eq!(t1.len(), 48);
617 assert_eq!(a.wake_token().unwrap(), t1);
618 let mode = std::fs::metadata(dir.join(".node-wake-token")).unwrap().permissions().mode() & 0o777;
619 assert_eq!(mode, 0o600);
620 let _ = std::fs::remove_dir_all(&dir);
621 }
622
623 #[test]
624 fn a_failing_agent_injects_nothing() {
625 let acc = M4aSessionAccounts::new("/nonexistent/m4a-agent", std::env::temp_dir().join("hatch-m4a-none"));
626 assert!(acc.provision("codex", "x").is_err());
627 }
628}
629
630fn codex_safe_value(v: &str) -> bool {
631 !v.is_empty() && !v.chars().any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '%' | '!' | '^' | '&' | '|' | '<' | '>' | '(' | ')'))
632}
633
634fn codex_safe_command(bin: &Path) -> Option<String> {
637 let full = bin.to_string_lossy().into_owned();
638 if codex_safe_value(&full) {
639 return Some(full);
640 }
641 let name = bin.file_name()?.to_string_lossy().into_owned();
642 if !codex_safe_value(&name) {
643 return None;
644 }
645 let path = std::env::var_os("PATH")?;
646 let dir = bin.parent()?;
647 std::env::split_paths(&path)
648 .any(|p| p == dir)
649 .then(|| name.strip_suffix(".exe").map(str::to_owned).unwrap_or(name))
650}
651
652trait ZeroizeBestEffort {
653 fn zeroize_best_effort(&mut self);
654}
655
656impl ZeroizeBestEffort for String {
657 fn zeroize_best_effort(&mut self) {
658 unsafe { self.as_bytes_mut() }.iter_mut().for_each(|b| *b = 0);
660 }
661}
662
663#[cfg(test)]
664mod session_file_tests {
665 use super::*;
666
667 #[test]
668 fn mcp_args_are_session_scoped_and_carry_no_capability() {
669 let root = std::env::temp_dir().join(format!("m4a-mcp-args-{}", std::process::id()));
670 let a = M4aSessionAccounts::new("m4a-agent", &root);
671 let c = a.mcp_launch_args("claude", "claude-1").unwrap();
672 assert_eq!(c[0], OsString::from("--mcp-config"));
673 let cfg = std::fs::read_to_string(&c[1]).unwrap();
674 assert!(cfg.contains("--session-file") && cfg.contains("claude-1.session"));
675 let x = a.mcp_launch_args("codex", "codex-1").unwrap();
676 assert_eq!(x.len(), 4);
677 assert!(x.iter().all(|v| !v.to_string_lossy().contains('"')));
678 assert!(a.mcp_launch_args("grok", "grok-1").unwrap().is_empty());
679 a.remove_session_file("claude-1");
680 assert!(!std::path::Path::new(&c[1]).exists());
681 let _ = std::fs::remove_dir_all(root);
682 }
683
684 #[test]
685 fn codex_values_never_carry_whitespace_or_quotes() {
686 assert!(codex_safe_value("/opt/m4a/bin/m4a-agent"));
687 assert!(!codex_safe_value("/home/a b/m4a-agent"));
688 assert!(!codex_safe_value("x'y"));
689 assert_eq!(codex_safe_command(Path::new("/opt/m4a/m4a-agent")).as_deref(), Some("/opt/m4a/m4a-agent"));
690 assert_eq!(codex_safe_command(Path::new("/no such dir/m4a-agent")), None);
691 }
692
693 #[test]
694 fn session_file_path_refuses_path_like_nicks() {
695 let a = M4aSessionAccounts::new("bin", std::env::temp_dir().join("m4a-sf-test"));
696 assert!(a.session_file_path("grok-1").unwrap().ends_with("sessions/grok-1.session"));
697 assert!(a.session_file_path("../x").is_none());
698 assert!(a.session_file_path("").is_none());
699 }
700}
701
702#[cfg(test)]
703mod node_account_message_tests {
704 #[test]
705 fn signed_bytes_follow_the_product_contract() {
706 let m = super::node_account_message("aud", "node-1", Some("nick-a"), 7, "n0");
707 assert_eq!(m, b"m4a-node-account-v1\naud\nnode-1\nnick-a\n7\nn0\n".to_vec());
708 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());
709 }
710}