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