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 valid_owner(o: &str) -> bool {
93 let o = o.trim();
94 !o.is_empty() && o != "owner" && o.len() <= 64 && o.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b))
95}
96
97pub fn owner_from_enroll_output(stdout: &str) -> Option<String> {
99 stdout.lines().find_map(|l| l.trim().strip_prefix("node-owner:")).map(|o| o.trim().to_owned()).filter(|o| valid_owner(o))
100}
101
102#[cfg(test)]
103mod owner_tests {
104 #[test]
105 fn owner_from_registry_answer() {
106 assert_eq!(super::owner_from_enroll_output("enrolled grok-7\nnode-owner: zeng3ld\n").as_deref(), Some("zeng3ld"));
107 assert_eq!(super::owner_from_enroll_output("node-owner: owner\n"), None);
108 assert_eq!(super::owner_from_enroll_output("enrolled x\n"), None);
109 assert!(!super::valid_owner("a/b"));
110 }
111}
112
113pub fn node_account_message(audience: &str, node_id: &str, nick: Option<&str>, ts_ms: i64, nonce: &str) -> Vec<u8> {
115 format!("m4a-node-account-v1\n{audience}\n{node_id}\n{}\n{ts_ms}\n{nonce}\n", nick.unwrap_or("")).into_bytes()
116}
117pub const ACCOUNTS_ROOT_ENV: &str = "HATCHERY_M4A_ACCOUNTS_ROOT";
118pub const OWNER_ENV: &str = "HATCHERY_M4A_OWNER";
120
121#[derive(Debug, Clone)]
122pub struct M4aSessionAccounts {
123 bin: PathBuf,
124 base: PathBuf,
125}
126
127pub struct SessionAccount {
129 pub nick: String,
130 pub session_id: String,
133 pub store_root: Option<String>,
135 pub product_url: Option<String>,
137 cap: String,
138}
139
140impl Drop for SessionAccount {
141 fn drop(&mut self) {
142 let bytes = unsafe { self.cap.as_bytes_mut() };
144 bytes.iter_mut().for_each(|b| *b = 0);
145 }
146}
147
148impl std::fmt::Debug for SessionAccount {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.debug_struct("SessionAccount").field("nick", &self.nick).finish_non_exhaustive()
151 }
152}
153
154impl SessionAccount {
155 pub fn attach_reply(&self) -> serde_json::Value {
158 serde_json::json!({"nick": self.nick, "session_id": self.session_id, "cap": self.cap, "store_root": self.store_root, "product_url": self.product_url})
159 }
160
161 pub fn env_mutations(&self) -> Vec<EnvMutation> {
162 vec![
163 EnvMutation { key: OsString::from("M4A_NICK"), value: Some(OsString::from(&self.nick)) },
164 EnvMutation { key: OsString::from("M4A_SESSION_CAP"), value: Some(OsString::from(&self.cap)) },
165 EnvMutation { key: OsString::from("M4A_SESSION_ID"), value: Some(OsString::from(&self.session_id)) },
166 EnvMutation { key: OsString::from("M4A_BOT_NAME"), value: Some(OsString::from(&self.nick)) },
167 ]
168 .into_iter()
169 .chain(self.store_root.iter().map(|r| EnvMutation { key: OsString::from("M4A_STORE_ROOT"), value: Some(OsString::from(r)) }))
170 .chain(self.product_url.iter().map(|u| EnvMutation { key: OsString::from("M4A_PRODUCT_URL"), value: Some(OsString::from(u)) }))
171 .collect()
172 }
173}
174
175impl M4aSessionAccounts {
176 pub fn bin(&self) -> &Path {
178 &self.bin
179 }
180
181 pub fn session_file_path(&self, nick: &str) -> Option<PathBuf> {
185 let ok = !nick.is_empty() && !nick.starts_with('.') && !nick.contains("..") && nick.len() <= 64
187 && nick.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.');
188 ok.then(|| self.base.join("sessions").join(format!("{nick}.session")))
189 }
190
191 pub fn write_session_file(&self, account: &SessionAccount) -> Result<PathBuf, String> {
192 let path = self.session_file_path(&account.nick).ok_or("invalid nick")?;
193 let dir = path.parent().ok_or("no dir")?;
194 std::fs::create_dir_all(dir).map_err(|e| format!("sessions dir: {}", e.kind()))?;
195 let mut body = String::new();
196 for m in account.env_mutations() {
197 if let Some(v) = m.value {
198 body.push_str(&format!("{}={}\n", m.key.to_string_lossy(), v.to_string_lossy()));
199 }
200 }
201 let mut opts = std::fs::OpenOptions::new();
202 opts.write(true).create(true).truncate(true);
203 #[cfg(unix)]
204 {
205 use std::os::unix::fs::OpenOptionsExt;
206 opts.mode(0o600);
207 }
208 use std::io::Write;
209 opts.open(&path)
210 .and_then(|mut f| f.write_all(body.as_bytes()))
211 .map_err(|e| format!("session file: {}", e.kind()))?;
212 body.zeroize_best_effort();
213 Ok(path)
214 }
215
216 pub fn mcp_launch_args(&self, g4a_provider: &str, nick: &str) -> Result<Vec<OsString>, String> {
220 let session_file = self.session_file_path(nick).ok_or("invalid nick")?;
221 let bin = self.bin.to_string_lossy().into_owned();
222 let sf = session_file.to_string_lossy().into_owned();
223 let provider = g4a_provider.to_owned();
224 match g4a_provider {
225 "claude" => {
226 let config = serde_json::json!({"mcpServers": {"m4a": {
227 "type": "stdio",
228 "command": bin,
229 "args": ["--session-file", sf, "mcp", "--provider", provider],
230 }}});
231 let path = self.base.join("sessions").join(format!("{nick}.mcp.json"));
232 std::fs::create_dir_all(self.base.join("sessions")).map_err(|e| format!("sessions dir: {}", e.kind()))?;
233 std::fs::write(&path, config.to_string()).map_err(|e| format!("mcp config: {}", e.kind()))?;
234 Ok(vec![OsString::from("--mcp-config"), path.into_os_string()])
235 }
236 "codex" => {
237 let name = mcp_server_name("codex");
243 let bin = codex_safe_command(&self.bin).ok_or("m4a-agent path has whitespace and is not on PATH")?;
244 if !codex_safe_value(&sf) {
245 return Err("session file path is not safe for codex -c".into());
246 }
247 Ok(vec![
248 OsString::from("-c"),
249 OsString::from(format!("mcp_servers.{name}.command='{bin}'")),
250 OsString::from("-c"),
251 OsString::from(format!("mcp_servers.{name}.args=['--session-file','{sf}','mcp','--provider','codex']")),
252 ])
253 }
254 _ => Ok(Vec::new()),
255 }
256 }
257
258 pub fn session_workdir(&self, workspace_root: &Path, g4a_provider: &str, nick: &str) -> Result<Option<PathBuf>, String> {
263 let session_file = self.session_file_path(nick).ok_or("invalid nick")?;
264 let dir = workspace_root.join(".hatchery-sessions").join(nick);
265 let name = mcp_server_name(g4a_provider);
266 let bin = self.bin.to_string_lossy().into_owned();
267 let sf = session_file.to_string_lossy().into_owned();
268 let write = |rel: &str, text: String| -> Result<(), String> {
269 let path = dir.join(rel);
270 if let Some(parent) = path.parent() {
271 std::fs::create_dir_all(parent).map_err(|e| format!("session dir: {}", e.kind()))?;
272 }
273 std::fs::write(&path, text).map_err(|e| format!("project mcp: {}", e.kind()))
274 };
275 match g4a_provider {
276 "kimi" => {
277 let provider = m4a_provider(g4a_provider).unwrap_or(g4a_provider);
278 let config = serde_json::json!({"mcpServers": {name: {
279 "command": bin,
280 "args": ["--session-file", sf, "mcp", "--provider", provider],
281 }}});
282 write(".kimi-code/mcp.json", serde_json::to_string_pretty(&config).unwrap_or_default())?;
283 }
284 "grok" => {
285 let q = |v: &str| serde_json::to_string(v).unwrap_or_default();
287 let text = format!(
288 "[mcp_servers.{name}]\ncommand = {}\nargs = [{}, {}, \"mcp\", \"--provider\", \"grok\"]\n",
289 q(&bin), q("--session-file"), q(&sf)
290 );
291 write(".grok/config.toml", text)?;
292 }
293 _ => return Ok(None),
294 }
295 Ok(Some(dir))
296 }
297
298 fn register_auto_nick(&self, account: &mut SessionAccount, provider: &str, session_key: &str) {
303 let owner = std::env::var(OWNER_ENV).ok().filter(|o| valid_owner(o)).or_else(|| {
307 let t = std::fs::read_to_string(enroll_dir_hint()?.join("m4a-owner")).ok()?;
308 Some(t.trim().to_owned()).filter(|o| valid_owner(o))
309 });
310 let node_id = NODE_SIGNER.get().map(|(id, _)| id.clone());
311 let (Some(owner), Some(node_id)) = (owner, node_id) else {
312 if let Err(error) = self.node_enroll(account) {
313 tracing::warn!(nick = %account.nick, %error, "m4a account not registered on the messenger (stays local)");
314 }
315 return;
316 };
317 let stem = provider.strip_suffix("-code").unwrap_or(provider);
319 let model = Some(model_slug(stem, &crate::server::spawn_model().unwrap_or_else(|| default_model(stem).to_owned()))).filter(|m| !m.is_empty());
320 let head = match model { Some(m) => format!("{stem}-{m}"), None => stem.to_owned() };
321 let mut last = String::new();
323 for _ in 0..8 {
324 let want = auto_nick(&head, &node_id, &owner, &random_hex());
325 if let Err(e) = self.renick(provider, session_key, &want) {
326 last = e;
327 continue;
328 }
329 account.nick = want;
330 match self.node_enroll(account) {
331 Ok(()) => return,
332 Err(e) if e.contains("409") || e.contains("taken") => last = e,
333 Err(e) => {
334 tracing::warn!(nick = %account.nick, error = %e, "m4a account not registered on the messenger (stays local)");
335 return;
336 }
337 }
338 }
339 tracing::warn!(nick = %account.nick, error = %last, "m4a auto-nick: renick or registration kept failing");
340 }
341
342 fn renick(&self, provider: &str, session_key: &str, nick: &str) -> Result<(), String> {
343 let mut cmd = Command::new(&self.bin);
344 cmd.args(["account", "renick", "--provider", provider, "--session", session_key, "--nick", nick])
345 .env("M4A_ACCOUNTS_ROOT", &self.base)
346 .env_remove("M4A_SESSION_CAP")
347 .env_remove("M4A_PRODUCT_INVITE");
348 crate::windows_process::hide_console(&mut cmd);
349 let out = cmd.output().map_err(|e| format!("m4a-agent: {}", e.kind()))?;
350 if out.status.success() {
351 Ok(())
352 } else {
353 Err(String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("renick refused").chars().take(200).collect())
354 }
355 }
356
357 fn node_enroll(&self, account: &SessionAccount) -> Result<(), String> {
360 let url = account.product_url.as_deref().ok_or("no messenger product url (HATCHERY_M4A_PRODUCT_URL)")?;
361 let store_root = account.store_root.as_deref().ok_or("m4a-agent reported no store root")?;
362 let (node_id, identity) = NODE_SIGNER.get().ok_or("this node is not enrolled: it registers no accounts")?;
363 let audience = std::env::var(PRODUCT_AUDIENCE_ENV).ok().filter(|a| !a.is_empty()).unwrap_or_else(|| "m4a-product".into());
364 let ts_ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0);
365 let mut raw = [0u8; 16];
366 getrandom(&mut raw)?;
367 let nonce: String = raw.iter().map(|b| format!("{b:02x}")).collect();
368 let signature = identity.sign_bytes(&node_account_message(&audience, node_id, Some(&account.nick), ts_ms, &nonce));
369 let mut cmd = Command::new(&self.bin);
370 cmd.args(["account", "node-enroll", "--node-id", node_id, "--ts", &ts_ms.to_string(), "--nonce", &nonce, "--signature", &signature, "--nick", &account.nick])
371 .env("M4A_PRODUCT_URL", url)
372 .env("M4A_SESSION_ID", &account.session_id)
373 .env("M4A_STORE_ROOT", store_root)
374 .env("M4A_BOT_NAME", &account.nick)
375 .env("M4A_ACCOUNTS_ROOT", &self.base)
376 .env_remove("M4A_SESSION_CAP")
377 .env_remove("M4A_PRODUCT_INVITE");
378 crate::windows_process::hide_console(&mut cmd);
379 let out = cmd.output().map_err(|e| format!("m4a-agent: {}", e.kind()))?;
380 if !out.status.success() {
381 let why = String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("").chars().take(200).collect::<String>();
382 return Err(format!("node-enroll failed: {why}"));
383 }
384 tracing::info!(nick = %account.nick, node_id = %node_id, "m4a account registered on the messenger by this node");
385 if let Some(owner) = owner_from_enroll_output(&String::from_utf8_lossy(&out.stdout)) {
386 if let Some(dir) = enroll_dir_hint() {
387 let path = dir.join("m4a-owner");
388 if std::fs::read_to_string(&path).ok().map(|t| t.trim().to_owned()).as_deref() != Some(owner.as_str()) {
389 match std::fs::write(&path, format!("{owner}\n")) {
390 Ok(()) => tracing::info!(%owner, "node owner taken from the m4a registry"),
391 Err(e) => tracing::warn!(error = %e, "node owner not saved"),
392 }
393 }
394 }
395 }
396 Ok(())
397 }
398
399 pub fn remove_session_file(&self, nick: &str) {
400 if let Some(path) = self.session_file_path(nick) {
401 let _ = std::fs::remove_file(path);
402 }
403 let _ = std::fs::remove_file(self.base.join("sessions").join(format!("{nick}.mcp.json")));
404 }
405
406 pub fn new(bin: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
407 Self { bin: bin.into(), base: base.into() }
408 }
409
410 pub fn wake_token(&self) -> Result<String, String> {
412 let path = self.base.join(".node-wake-token");
413 if let Ok(t) = std::fs::read_to_string(&path) {
414 if t.trim().len() >= 32 {
415 return Ok(t.trim().to_owned());
416 }
417 }
418 std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
419 let mut raw = [0u8; 24];
420 getrandom(&mut raw)?;
421 let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
422 let mut opts = std::fs::OpenOptions::new();
423 opts.write(true).create(true).truncate(true);
424 #[cfg(unix)]
425 {
426 use std::os::unix::fs::OpenOptionsExt;
427 opts.mode(0o600);
428 }
429 use std::io::Write;
430 opts.open(&path).and_then(|mut f| f.write_all(token.as_bytes())).map_err(|e| format!("wake token: {}", e.kind()))?;
431 Ok(token)
432 }
433
434 pub fn find_agent_bin() -> Option<PathBuf> {
436 if let Some(p) = std::env::var_os(AGENT_BIN_ENV).filter(|v| !v.is_empty()).map(PathBuf::from) {
437 return Some(p);
438 }
439 if let Ok(exe) = std::env::current_exe() {
440 if let Some(dir) = exe.parent() {
441 for name in ["m4a-agent", "m4a-agent.exe"] {
442 let p = dir.join(name);
443 if p.is_file() {
444 return Some(p);
445 }
446 }
447 }
448 }
449 which("m4a-agent").or_else(|| which("m4a-agent.exe")).or_else(|| {
450 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
451 let p = PathBuf::from(home).join(".cargo").join("bin").join(if cfg!(windows) { "m4a-agent.exe" } else { "m4a-agent" });
452 p.is_file().then_some(p)
453 })
454 }
455
456 pub fn default_accounts_root(enroll_dir: Option<&Path>) -> Option<PathBuf> {
458 if let Some(p) = std::env::var_os(ACCOUNTS_ROOT_ENV).filter(|v| !v.is_empty()).map(PathBuf::from) {
459 return Some(p);
460 }
461 enroll_dir.map(|d| d.join("m4a-accounts"))
462 }
463
464 pub fn resolve_product_url(enroll_dir: Option<&Path>) -> Option<String> {
467 if let Some(u) = std::env::var(PRODUCT_URL_ENV).ok().filter(|u| !u.is_empty()) {
468 return Some(u);
469 }
470 if let Some(u) = c2_product_url() {
471 return Some(u);
472 }
473 let dir = enroll_dir?;
474 let text = std::fs::read_to_string(dir.join("enrollment.json")).ok()?;
475 let v: serde_json::Value = serde_json::from_str(&text).ok()?;
476 v.get("response")
477 .and_then(|r| r.get("m4a_product_url"))
478 .or_else(|| v.get("m4a_product_url"))
479 .and_then(|u| u.as_str())
480 .filter(|u| !u.is_empty())
481 .map(str::to_owned)
482 }
483
484 pub fn from_env() -> Option<Self> {
486 Self::from_enroll_dir(enroll_dir_hint().as_deref())
487 }
488
489 pub fn from_enroll_dir(enroll_dir: Option<&Path>) -> Option<Self> {
490 let bin = match Self::find_agent_bin() {
491 Some(p) => p,
492 None => {
493 tracing::warn!(
494 "m4a-agent not found (set HATCHERY_M4A_AGENT_BIN, put it next to gate4agent-node, on PATH, or in ~/.cargo/bin); m4a off"
495 );
496 return None;
497 }
498 };
499 let base = match Self::default_accounts_root(enroll_dir) {
500 Some(p) => p,
501 None => {
502 tracing::warn!("m4a accounts root unknown (set HATCHERY_M4A_ACCOUNTS_ROOT or pass --enroll-state); m4a off");
503 return None;
504 }
505 };
506 if let Err(e) = std::fs::create_dir_all(&base) {
507 tracing::warn!(error = %e.kind(), path = %base.display(), "m4a accounts root not writable; m4a off");
508 return None;
509 }
510 let product = Self::resolve_product_url(enroll_dir);
511 if product.is_none() {
512 tracing::info!("m4a product url unset (env / C2 connect / enrollment); accounts stay local until set");
513 }
514 tracing::info!(bin = %bin.display(), accounts = %base.display(), product = product.as_deref().unwrap_or("-"), "m4a accounts ready");
515 Some(Self::new(bin, base))
516 }
517
518 pub fn base(&self) -> &Path {
519 &self.base
520 }
521
522 pub fn provision(&self, provider: &str, session_key: &str) -> Result<SessionAccount, String> {
525 std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
526 let mut rand = [0u8; 12];
527 getrandom(&mut rand)?;
528 let tag: String = rand.iter().map(|b| format!("{b:02x}")).collect();
529 let cap_file = self.base.join(format!(".spawn-{tag}.cap"));
530 let mut cmd = Command::new(&self.bin);
531 cmd.args(["account", "provision", "--provider", provider, "--session", session_key, "--rotate", "--cap-out"])
532 .arg(&cap_file)
533 .env("M4A_ACCOUNTS_ROOT", &self.base)
534 .env_remove("M4A_SESSION_CAP")
535 .env_remove("M4A_PRODUCT_INVITE");
536 crate::windows_process::hide_console(&mut cmd);
537 let out = cmd.output().map_err(|e| format!("m4a-agent: {}", e.kind()))?;
538 let cap = std::fs::read_to_string(&cap_file);
539 let _ = std::fs::remove_file(&cap_file);
540 if !out.status.success() {
541 return Err(format!("m4a-agent account provision failed ({})", out.status.code().unwrap_or(-1)));
542 }
543 let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|_| "m4a-agent answer is not json".to_string())?;
544 let nick = v["nick"].as_str().ok_or("m4a-agent answer has no nick")?.to_string();
545 let cap = cap.map_err(|_| "no capability issued".to_string())?;
546 if cap.trim().is_empty() || cap.contains('\n') {
547 return Err("capability is malformed".into());
548 }
549 let session_id = v["account_ref"].as_str().filter(|r| !r.is_empty()).unwrap_or(session_key).to_owned();
552 let store_root = v["store_root"].as_str().filter(|r| !r.is_empty()).map(str::to_owned);
553 let product_url = Self::resolve_product_url(enroll_dir_hint().as_deref());
554 let mut account = SessionAccount { nick, session_id, store_root, product_url, cap };
555 if !v["enrolled"].as_bool().unwrap_or(false) {
556 self.register_auto_nick(&mut account, provider, session_key);
557 }
558 Ok(account)
559 }
560}
561
562fn which(name: &str) -> Option<PathBuf> {
563 let path = std::env::var_os("PATH")?;
564 for dir in std::env::split_paths(&path) {
565 let p = dir.join(name);
566 if p.is_file() {
567 return Some(p);
568 }
569 }
570 None
571}
572
573static C2_PRODUCT_URL: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
574
575pub fn set_c2_product_url(url: Option<String>) {
577 *C2_PRODUCT_URL.write().unwrap_or_else(|e| e.into_inner()) = url.filter(|u| !u.is_empty());
578}
579
580pub fn c2_product_url() -> Option<String> {
581 C2_PRODUCT_URL.read().unwrap_or_else(|e| e.into_inner()).clone()
582}
583
584pub(crate) fn enroll_dir_hint() -> Option<PathBuf> {
585 std::env::var_os("HATCHERY_ENROLL_STATE")
586 .filter(|v| !v.is_empty())
587 .map(PathBuf::from)
588 .or_else(|| {
589 None
591 })
592}
593
594fn getrandom(buf: &mut [u8]) -> Result<(), String> {
595 use std::io::Read;
596 std::fs::File::open("/dev/urandom")
597 .and_then(|mut f| f.read_exact(buf))
598 .or_else(|_| {
599 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
601 for (i, b) in buf.iter_mut().enumerate() {
602 *b = (t >> ((i % 16) * 8)) as u8 ^ (i as u8);
603 }
604 Ok::<(), std::io::Error>(())
605 })
606 .map_err(|e| e.to_string())
607}
608
609#[cfg(all(test, unix))]
610mod tests {
611 use super::*;
612
613 fn fake_agent(dir: &Path) -> PathBuf {
614 let p = dir.join("fake-m4a-agent");
616 std::fs::write(
617 &p,
618 "#!/bin/sh\nwhile [ $# -gt 0 ]; do case \"$1\" in --session) s=$2; shift;; --cap-out) o=$2; shift;; esac; shift; done\n\
619 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",
620 )
621 .unwrap();
622 use std::os::unix::fs::PermissionsExt;
623 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
624 p
625 }
626
627 #[test]
628 fn defaults_find_agent_and_accounts_root_and_product_from_enrollment() {
629 let dir = std::env::temp_dir().join(format!("m4a-defaults-{}", std::process::id()));
630 let _ = std::fs::remove_dir_all(&dir);
631 std::fs::create_dir_all(&dir).unwrap();
632 let agent = fake_agent(&dir);
633 std::env::set_var(AGENT_BIN_ENV, &agent);
634 std::env::remove_var(ACCOUNTS_ROOT_ENV);
635 std::env::remove_var(PRODUCT_URL_ENV);
636 let root = M4aSessionAccounts::default_accounts_root(Some(dir.as_path())).unwrap();
637 assert_eq!(root, dir.join("m4a-accounts"));
638 assert_eq!(M4aSessionAccounts::find_agent_bin().as_deref(), Some(agent.as_path()));
639 std::fs::write(
640 dir.join("enrollment.json"),
641 r#"{"node_id":"n","response":{"node_id":"n","status":"approved","m4a_product_url":"https://example.invalid"}}"#,
642 )
643 .unwrap();
644 assert_eq!(
645 M4aSessionAccounts::resolve_product_url(Some(dir.as_path())).as_deref(),
646 Some("https://example.invalid")
647 );
648 let got = M4aSessionAccounts::from_enroll_dir(Some(dir.as_path())).unwrap();
649 assert_eq!(got.base(), root.as_path());
650 std::env::remove_var(AGENT_BIN_ENV);
651 std::env::remove_var(PRODUCT_URL_ENV);
652 std::fs::remove_dir_all(&dir).unwrap();
653 }
654
655 #[test]
656 fn product_url_order_env_then_c2_then_enrollment() {
657 let dir = std::env::temp_dir().join(format!("m4a-purl-{}", std::process::id()));
658 let _ = std::fs::remove_dir_all(&dir);
659 std::fs::create_dir_all(&dir).unwrap();
660 std::fs::write(dir.join("enrollment.json"), r#"{"response":{"m4a_product_url":"https://enroll.invalid"}}"#).unwrap();
661 std::env::remove_var(PRODUCT_URL_ENV);
662 set_c2_product_url(None);
663 assert_eq!(M4aSessionAccounts::resolve_product_url(Some(&dir)).as_deref(), Some("https://enroll.invalid"));
664 set_c2_product_url(Some("https://c2.invalid".into()));
665 assert_eq!(M4aSessionAccounts::resolve_product_url(Some(&dir)).as_deref(), Some("https://c2.invalid"));
666 std::env::set_var(PRODUCT_URL_ENV, "https://env.invalid");
667 assert_eq!(M4aSessionAccounts::resolve_product_url(Some(&dir)).as_deref(), Some("https://env.invalid"));
668 std::env::remove_var(PRODUCT_URL_ENV);
669 set_c2_product_url(None);
670 std::fs::remove_dir_all(&dir).unwrap();
671 }
672
673 #[test]
674 fn each_spawn_gets_its_own_account_and_its_own_session_env() {
675 let dir = std::env::temp_dir().join(format!("hatch-m4a-{}", std::process::id()));
676 let _ = std::fs::remove_dir_all(&dir);
677 std::fs::create_dir_all(&dir).unwrap();
678 let acc = M4aSessionAccounts::new(fake_agent(&dir), dir.join("accounts"));
679 let a = acc.provision("codex", "inc-1").unwrap();
680 let b = acc.provision("codex", "inc-2").unwrap();
681 assert_ne!(a.nick, b.nick);
682 let env = a.env_mutations();
683 let keys: Vec<_> = env.iter().map(|m| m.key.to_string_lossy().into_owned()).collect();
684 assert_eq!(&keys[..5], ["M4A_NICK", "M4A_SESSION_CAP", "M4A_SESSION_ID", "M4A_BOT_NAME", "M4A_STORE_ROOT"]);
685 assert_eq!(env[2].value.as_deref(), Some(std::ffi::OsStr::new("ref-inc-1")));
686 assert_eq!(env[4].value.as_deref(), Some(std::ffi::OsStr::new("/s/inc-1")));
687 assert_ne!(env[1].value, b.env_mutations()[1].value);
688 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();
690 assert!(left.is_empty());
691 assert!(!format!("{a:?}").contains("cap-"));
692 }
693
694 #[test]
695 fn every_builtin_g4a_provider_maps_to_an_m4a_provider() {
696 for spec in gate4agent_catalog::builtin_specs() {
697 let id = spec.id.as_str().to_string();
698 assert!(m4a_provider(&id).is_some(), "g4a provider {id} has no m4a mapping");
699 }
700 assert_eq!(m4a_provider("custom"), None);
701 }
702
703 #[cfg(unix)]
704 #[test]
705 fn wake_token_is_private_stable_and_long() {
706 use std::os::unix::fs::PermissionsExt;
707 let dir = std::env::temp_dir().join(format!("node-wake-token-{}", std::process::id()));
708 let a = M4aSessionAccounts::new("/bin/false", &dir);
709 let t1 = a.wake_token().unwrap();
710 assert_eq!(t1.len(), 48);
711 assert_eq!(a.wake_token().unwrap(), t1);
712 let mode = std::fs::metadata(dir.join(".node-wake-token")).unwrap().permissions().mode() & 0o777;
713 assert_eq!(mode, 0o600);
714 let _ = std::fs::remove_dir_all(&dir);
715 }
716
717 #[test]
718 fn a_failing_agent_injects_nothing() {
719 let acc = M4aSessionAccounts::new("/nonexistent/m4a-agent", std::env::temp_dir().join("hatch-m4a-none"));
720 assert!(acc.provision("codex", "x").is_err());
721 }
722}
723
724fn codex_safe_value(v: &str) -> bool {
725 !v.is_empty() && !v.chars().any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '%' | '!' | '^' | '&' | '|' | '<' | '>' | '(' | ')'))
726}
727
728fn codex_safe_command(bin: &Path) -> Option<String> {
731 let full = bin.to_string_lossy().into_owned();
732 if codex_safe_value(&full) {
733 return Some(full);
734 }
735 let name = bin.file_name()?.to_string_lossy().into_owned();
736 if !codex_safe_value(&name) {
737 return None;
738 }
739 let path = std::env::var_os("PATH")?;
740 let dir = bin.parent()?;
741 std::env::split_paths(&path)
742 .any(|p| p == dir)
743 .then(|| name.strip_suffix(".exe").map(str::to_owned).unwrap_or(name))
744}
745
746trait ZeroizeBestEffort {
747 fn zeroize_best_effort(&mut self);
748}
749
750impl ZeroizeBestEffort for String {
751 fn zeroize_best_effort(&mut self) {
752 unsafe { self.as_bytes_mut() }.iter_mut().for_each(|b| *b = 0);
754 }
755}
756
757#[cfg(test)]
758mod session_file_tests {
759 use super::*;
760
761 #[test]
762 fn mcp_args_are_session_scoped_and_carry_no_capability() {
763 let root = std::env::temp_dir().join(format!("m4a-mcp-args-{}", std::process::id()));
764 let a = M4aSessionAccounts::new("m4a-agent", &root);
765 let c = a.mcp_launch_args("claude", "claude-1").unwrap();
766 assert_eq!(c[0], OsString::from("--mcp-config"));
767 let cfg = std::fs::read_to_string(&c[1]).unwrap();
768 assert!(cfg.contains("--session-file") && cfg.contains("claude-1.session"));
769 let x = a.mcp_launch_args("codex", "codex-1").unwrap();
770 assert_eq!(x.len(), 4);
771 assert!(x.iter().all(|v| !v.to_string_lossy().contains('"')));
772 assert!(a.mcp_launch_args("grok", "grok-1").unwrap().is_empty());
773 a.remove_session_file("claude-1");
774 assert!(!std::path::Path::new(&c[1]).exists());
775 let _ = std::fs::remove_dir_all(root);
776 }
777
778 #[test]
779 fn codex_values_never_carry_whitespace_or_quotes() {
780 assert!(codex_safe_value("/opt/m4a/bin/m4a-agent"));
781 assert!(!codex_safe_value("/home/a b/m4a-agent"));
782 assert!(!codex_safe_value("x'y"));
783 assert_eq!(codex_safe_command(Path::new("/opt/m4a/m4a-agent")).as_deref(), Some("/opt/m4a/m4a-agent"));
784 assert_eq!(codex_safe_command(Path::new("/no such dir/m4a-agent")), None);
785 }
786
787 #[test]
788 fn session_file_path_refuses_path_like_nicks() {
789 let a = M4aSessionAccounts::new("bin", std::env::temp_dir().join("m4a-sf-test"));
790 assert!(a.session_file_path("grok-1").unwrap().ends_with("sessions/grok-1.session"));
791 assert!(a.session_file_path("../x").is_none());
792 assert!(a.session_file_path("claude-3.hatchery-box.zeng3ld").is_some());
793 assert!(a.session_file_path("..x").is_none());
794 assert!(a.session_file_path(".x").is_none());
795 assert!(a.session_file_path("").is_none());
796 }
797}
798
799#[cfg(test)]
800mod node_account_message_tests {
801 #[test]
802 fn signed_bytes_follow_the_product_contract() {
803 let m = super::node_account_message("aud", "node-1", Some("nick-a"), 7, "n0");
804 assert_eq!(m, b"m4a-node-account-v1\naud\nnode-1\nnick-a\n7\nn0\n".to_vec());
805 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());
806 }
807}
808
809pub fn auto_nick(head: &str, node_id: &str, owner: &str, hex: &str) -> String {
813 format!("{head}-{hex}.{node_id}.{owner}")
814}
815
816pub fn model_slug(stem: &str, model: &str) -> String {
819 let m = model.to_ascii_lowercase();
820 let m = m.strip_prefix(&format!("{stem}-")).unwrap_or(&m);
821 let mut out = String::new();
822 for c in m.chars() {
823 let c = if c.is_ascii_alphanumeric() { c } else { '-' };
824 if c == '-' && (out.is_empty() || out.ends_with('-')) { continue; }
825 out.push(c);
826 }
827 out.trim_end_matches('-').chars().take(24).collect::<String>().trim_end_matches('-').to_owned()
828}
829
830pub fn default_model(stem: &str) -> &'static str {
832 match stem {
833 "claude" => "sonnet",
834 "codex" => "gpt-5",
835 "grok" => "grok",
836 "kimi" => "k2",
837 _ => "",
838 }
839}
840
841fn random_hex() -> String {
842 let mut b = [0u8; 3];
843 if getrandom::fill(&mut b).is_err() {
844 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
845 b = [(t >> 16) as u8, (t >> 8) as u8, t as u8];
846 }
847 b.iter().map(|x| format!("{x:02x}")).collect()
848}
849
850#[cfg(test)]
851mod auto_nick_tests {
852 use super::*;
853 #[test]
854 fn shape() {
855 assert_eq!(auto_nick(&format!("claude-{}", model_slug("claude", "sonnet")), "hatchery-box", "zeng3ld", "a3f9c2"), "claude-sonnet-a3f9c2.hatchery-box.zeng3ld");
856 assert_eq!(model_slug("claude", "claude-sonnet-4-5"), "sonnet-4-5");
857 assert_eq!(model_slug("codex", "gpt-5.1-codex"), "gpt-5-1-codex");
858 let h = random_hex();
859 assert_eq!(h.len(), 6);
860 assert!(h.bytes().all(|b| b.is_ascii_hexdigit()));
861 }
862}