hatchery_node/
m4a_accounts.rs1use std::ffi::OsString;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use gate4agent_catalog::EnvMutation;
13
14pub fn m4a_provider(g4a_agent_id: &str) -> Option<&'static str> {
17 match g4a_agent_id {
18 "claude" => Some("claude-code"),
19 "codex" => Some("codex"),
20 "grok" => Some("grok"),
21 "kimi" => Some("kimi-code"),
22 _ => None,
23 }
24}
25
26pub const AGENT_BIN_ENV: &str = "HATCHERY_M4A_AGENT_BIN";
27pub const ACCOUNTS_ROOT_ENV: &str = "HATCHERY_M4A_ACCOUNTS_ROOT";
28
29#[derive(Debug, Clone)]
30pub struct M4aSessionAccounts {
31 bin: PathBuf,
32 base: PathBuf,
33}
34
35pub struct SessionAccount {
37 pub nick: String,
38 pub session_id: String,
41 cap: String,
42}
43
44impl Drop for SessionAccount {
45 fn drop(&mut self) {
46 let bytes = unsafe { self.cap.as_bytes_mut() };
48 bytes.iter_mut().for_each(|b| *b = 0);
49 }
50}
51
52impl std::fmt::Debug for SessionAccount {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.debug_struct("SessionAccount").field("nick", &self.nick).finish_non_exhaustive()
55 }
56}
57
58impl SessionAccount {
59 pub fn env_mutations(&self) -> Vec<EnvMutation> {
61 vec![
62 EnvMutation { key: OsString::from("M4A_NICK"), value: Some(OsString::from(&self.nick)) },
63 EnvMutation { key: OsString::from("M4A_SESSION_CAP"), value: Some(OsString::from(&self.cap)) },
64 EnvMutation { key: OsString::from("M4A_SESSION_ID"), value: Some(OsString::from(&self.session_id)) },
65 ]
66 }
67}
68
69impl M4aSessionAccounts {
70 pub fn new(bin: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
71 Self { bin: bin.into(), base: base.into() }
72 }
73
74 pub fn wake_token(&self) -> Result<String, String> {
76 let path = self.base.join(".node-wake-token");
77 if let Ok(t) = std::fs::read_to_string(&path) {
78 if t.trim().len() >= 32 {
79 return Ok(t.trim().to_owned());
80 }
81 }
82 std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
83 let mut raw = [0u8; 24];
84 getrandom(&mut raw)?;
85 let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
86 let mut opts = std::fs::OpenOptions::new();
87 opts.write(true).create(true).truncate(true);
88 #[cfg(unix)]
89 {
90 use std::os::unix::fs::OpenOptionsExt;
91 opts.mode(0o600);
92 }
93 use std::io::Write;
94 opts.open(&path).and_then(|mut f| f.write_all(token.as_bytes())).map_err(|e| format!("wake token: {}", e.kind()))?;
95 Ok(token)
96 }
97
98 pub fn from_env() -> Option<Self> {
99 let get = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
100 Some(Self::new(get(AGENT_BIN_ENV)?, get(ACCOUNTS_ROOT_ENV)?))
101 }
102
103 pub fn base(&self) -> &Path {
104 &self.base
105 }
106
107 pub fn provision(&self, provider: &str, session_key: &str) -> Result<SessionAccount, String> {
110 std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
111 let mut rand = [0u8; 12];
112 getrandom(&mut rand)?;
113 let tag: String = rand.iter().map(|b| format!("{b:02x}")).collect();
114 let cap_file = self.base.join(format!(".spawn-{tag}.cap"));
115 let out = Command::new(&self.bin)
116 .args(["account", "provision", "--provider", provider, "--session", session_key, "--rotate", "--cap-out"])
117 .arg(&cap_file)
118 .env("M4A_ACCOUNTS_ROOT", &self.base)
119 .env_remove("M4A_SESSION_CAP")
120 .env_remove("M4A_PRODUCT_INVITE")
121 .output()
122 .map_err(|e| format!("m4a-agent: {}", e.kind()))?;
123 let cap = std::fs::read_to_string(&cap_file);
124 let _ = std::fs::remove_file(&cap_file);
125 if !out.status.success() {
126 return Err(format!("m4a-agent account provision failed ({})", out.status.code().unwrap_or(-1)));
127 }
128 let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|_| "m4a-agent answer is not json".to_string())?;
129 let nick = v["nick"].as_str().ok_or("m4a-agent answer has no nick")?.to_string();
130 let cap = cap.map_err(|_| "no capability issued".to_string())?;
131 if cap.trim().is_empty() || cap.contains('\n') {
132 return Err("capability is malformed".into());
133 }
134 Ok(SessionAccount { nick, session_id: session_key.to_owned(), cap })
135 }
136}
137
138fn getrandom(buf: &mut [u8]) -> Result<(), String> {
139 use std::io::Read;
140 std::fs::File::open("/dev/urandom")
141 .and_then(|mut f| f.read_exact(buf))
142 .or_else(|_| {
143 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
145 for (i, b) in buf.iter_mut().enumerate() {
146 *b = (t >> ((i % 16) * 8)) as u8 ^ (i as u8);
147 }
148 Ok::<(), std::io::Error>(())
149 })
150 .map_err(|e| e.to_string())
151}
152
153#[cfg(all(test, unix))]
154mod tests {
155 use super::*;
156
157 fn fake_agent(dir: &Path) -> PathBuf {
158 let p = dir.join("fake-m4a-agent");
160 std::fs::write(
161 &p,
162 "#!/bin/sh\nwhile [ $# -gt 0 ]; do case \"$1\" in --session) s=$2; shift;; --cap-out) o=$2; shift;; esac; shift; done\n\
163 umask 077; printf 'cap-%s-%s' \"$s\" \"$$\" > \"$o\"; printf '{\"nick\":\"codex-%s\",\"cap_issued\":true}\\n' \"$s\"\n",
164 )
165 .unwrap();
166 use std::os::unix::fs::PermissionsExt;
167 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
168 p
169 }
170
171 #[test]
172 fn each_spawn_gets_its_own_account_and_only_nick_and_cap_are_injected() {
173 let dir = std::env::temp_dir().join(format!("hatch-m4a-{}", std::process::id()));
174 let _ = std::fs::remove_dir_all(&dir);
175 std::fs::create_dir_all(&dir).unwrap();
176 let acc = M4aSessionAccounts::new(fake_agent(&dir), dir.join("accounts"));
177 let a = acc.provision("codex", "inc-1").unwrap();
178 let b = acc.provision("codex", "inc-2").unwrap();
179 assert_ne!(a.nick, b.nick);
180 let env = a.env_mutations();
181 let keys: Vec<_> = env.iter().map(|m| m.key.to_string_lossy().into_owned()).collect();
182 assert_eq!(keys, ["M4A_NICK", "M4A_SESSION_CAP", "M4A_SESSION_ID"]);
183 assert_eq!(env[2].value.as_deref(), Some(std::ffi::OsStr::new("inc-1")));
184 assert_ne!(env[1].value, b.env_mutations()[1].value);
185 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();
187 assert!(left.is_empty());
188 assert!(!format!("{a:?}").contains("cap-"));
189 }
190
191 #[test]
192 fn every_builtin_g4a_provider_maps_to_an_m4a_provider() {
193 for spec in gate4agent_catalog::builtin_specs() {
194 let id = spec.id.as_str().to_string();
195 assert!(m4a_provider(&id).is_some(), "g4a provider {id} has no m4a mapping");
196 }
197 assert_eq!(m4a_provider("custom"), None);
198 }
199
200 #[cfg(unix)]
201 #[test]
202 fn wake_token_is_private_stable_and_long() {
203 use std::os::unix::fs::PermissionsExt;
204 let dir = std::env::temp_dir().join(format!("node-wake-token-{}", std::process::id()));
205 let a = M4aSessionAccounts::new("/bin/false", &dir);
206 let t1 = a.wake_token().unwrap();
207 assert_eq!(t1.len(), 48);
208 assert_eq!(a.wake_token().unwrap(), t1);
209 let mode = std::fs::metadata(dir.join(".node-wake-token")).unwrap().permissions().mode() & 0o777;
210 assert_eq!(mode, 0o600);
211 let _ = std::fs::remove_dir_all(&dir);
212 }
213
214 #[test]
215 fn a_failing_agent_injects_nothing() {
216 let acc = M4aSessionAccounts::new("/nonexistent/m4a-agent", std::env::temp_dir().join("hatch-m4a-none"));
217 assert!(acc.provision("codex", "x").is_err());
218 }
219}