Skip to main content

hatchery_node/
m4a_accounts.rs

1//! Per-session m4a accounts assigned by the node at spawn (owner model: session = nick = one key
2//! = one role). Every PTY spawn gets its own account when the node can find `m4a-agent` (env
3//! `HATCHERY_M4A_AGENT_BIN`, else next to this binary, PATH, or `~/.cargo/bin`) and an accounts
4//! root (env `HATCHERY_M4A_ACCOUNTS_ROOT`, else `<enroll-state>/m4a-accounts`). Product URL comes
5//! from env or the enrollment reply (`m4a_product_url`). The node runs `m4a-agent account provision`
6//! (registry and vaults
7//! stay under the node's accounts root, outside the session sandbox), reads the fresh capability
8//! from a one-shot 0600 file it removes at once, and injects ONLY `M4A_NICK` and
9//! `M4A_SESSION_CAP` into that session's child environment. The capability is never logged.
10
11use std::ffi::OsString;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15use gate4agent_catalog::EnvMutation;
16
17/// g4a provider id -> the provider name `m4a-agent` accepts. g4a's built-in catalog is
18/// `claude`, `codex`, `grok`, `kimi`; anything else (custom agents) gets no account.
19pub 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";
30/// Messenger product base URL; with it (and an enrolled node) the node registers its sessions'
31/// accounts on the messenger itself, signed with the node key.
32pub const PRODUCT_URL_ENV: &str = "HATCHERY_M4A_PRODUCT_URL";
33/// The product's audience name the node signs for (default `m4a-product`).
34pub 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
38/// Installs this node's identity (enrolled, trusted) as the signer of account registrations.
39/// An unenrolled node never has one, so it registers nothing.
40pub fn set_node_signer(node_id: String, identity: std::sync::Arc<hatchery_enroll::NodeIdentity>) {
41    let _ = NODE_SIGNER.set((node_id, identity));
42}
43
44/// Exact bytes the messenger product verifies (`m4a-node-account-v1`).
45pub 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
56/// One session's account as handed to its child: nick and capability only.
57pub struct SessionAccount {
58    pub nick: String,
59    /// The provider session key the account is bound to (also given to the session as
60    /// `M4A_SESSION_ID`, so its own inbox commands address the right account).
61    pub session_id: String,
62    /// The account's own store root (`M4A_STORE_ROOT`), when m4a-agent reported it.
63    pub store_root: Option<String>,
64    /// Messenger product URL the session's client uses (`M4A_PRODUCT_URL`), from the node env.
65    pub product_url: Option<String>,
66    cap: String,
67}
68
69impl Drop for SessionAccount {
70    fn drop(&mut self) {
71        // Best effort wipe of the capability copy held by the node.
72        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    /// The only variables the session gets from the node for m4a.
85    /// The one-time `/m4a/attach` reply (the capability travels only in this body).
86    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    /// The `m4a-agent` binary the node runs (also the command of the node's MCP entry).
106    pub fn bin(&self) -> &Path {
107        &self.bin
108    }
109
110    /// Fallback identity channel: `<root>/sessions/<nick>.session` (owner-only), the same three
111    /// keys as [`SessionAccount::env_mutations`], for a provider whose tools run in a process
112    /// that did not inherit the session environment (`m4a-agent --session-file <path> ...`).
113    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    /// Provider flags that give one spawned session its own m4a MCP server (`m4a-agent
144    /// --session-file <file> mcp`), without touching any user config. Empty for providers with
145    /// no verified per-session MCP flag (grok TUI, kimi PTY): those use attach (`/m4a/attach`).
146    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                // TOML literal strings: no escapes, no double quotes. On Windows codex runs through
165                // a `cmd /C <codex.cmd>` wrapper: an argument with whitespace gets quoted, and more
166                // than two quotes make cmd strip the wrapper's own quotes (spawn fails). So the
167                // values must be whitespace-free; a binary in a spaced directory is named by its
168                // file name (found on PATH), otherwise the session falls back to attach.
169                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    /// Registers the account on the messenger with this node's key, then the account's own client
185    /// enrolls its own key (`m4a-agent account node-enroll`). The invite never reaches the node.
186    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    /// The node's letter-wake token (`<root>/.node-wake-token`, 0600), created on first use.
227    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    /// Resolve `m4a-agent`: env override, then next to this binary, then PATH, then `~/.cargo/bin`.
251    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    /// Accounts root: env, else `<enroll_dir>/m4a-accounts`.
273    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    /// Product URL: env override, else `m4a_product_url` from enrollment.json under enroll_dir.
281    pub fn resolve_product_url(enroll_dir: Option<&Path>) -> Option<String> {
282        if let Some(u) = std::env::var(PRODUCT_URL_ENV).ok().filter(|u| !u.is_empty()) {
283            return Some(u);
284        }
285        let dir = enroll_dir?;
286        let text = std::fs::read_to_string(dir.join("enrollment.json")).ok()?;
287        let v: serde_json::Value = serde_json::from_str(&text).ok()?;
288        v.get("response")
289            .and_then(|r| r.get("m4a_product_url"))
290            .or_else(|| v.get("m4a_product_url"))
291            .and_then(|u| u.as_str())
292            .filter(|u| !u.is_empty())
293            .map(str::to_owned)
294    }
295
296    /// Open from env and defaults. Logs clearly when m4a cannot start.
297    pub fn from_env() -> Option<Self> {
298        Self::from_enroll_dir(enroll_dir_hint().as_deref())
299    }
300
301    pub fn from_enroll_dir(enroll_dir: Option<&Path>) -> Option<Self> {
302        let bin = match Self::find_agent_bin() {
303            Some(p) => p,
304            None => {
305                tracing::warn!(
306                    "m4a-agent not found (set HATCHERY_M4A_AGENT_BIN, put it next to gate4agent-node, on PATH, or in ~/.cargo/bin); m4a off"
307                );
308                return None;
309            }
310        };
311        let base = match Self::default_accounts_root(enroll_dir) {
312            Some(p) => p,
313            None => {
314                tracing::warn!("m4a accounts root unknown (set HATCHERY_M4A_ACCOUNTS_ROOT or pass --enroll-state); m4a off");
315                return None;
316            }
317        };
318        if let Err(e) = std::fs::create_dir_all(&base) {
319            tracing::warn!(error = %e.kind(), path = %base.display(), "m4a accounts root not writable; m4a off");
320            return None;
321        }
322        let product = Self::resolve_product_url(enroll_dir);
323        if product.is_none() {
324            tracing::info!("m4a product url unset (HATCHERY_M4A_PRODUCT_URL or enrollment m4a_product_url); accounts stay local until set");
325        }
326        tracing::info!(bin = %bin.display(), accounts = %base.display(), product = product.as_deref().unwrap_or("-"), "m4a accounts ready");
327        // Stash product url for SessionAccount::provision via env only when not already set.
328        if let Some(url) = &product {
329            if std::env::var_os(PRODUCT_URL_ENV).is_none() {
330                std::env::set_var(PRODUCT_URL_ENV, url);
331            }
332        }
333        Some(Self::new(bin, base))
334    }
335
336    pub fn base(&self) -> &Path {
337        &self.base
338    }
339
340    /// The account of `(provider, session_key)`, created if missing, with a fresh capability
341    /// (a respawn of the same session invalidates the previous process's key).
342    pub fn provision(&self, provider: &str, session_key: &str) -> Result<SessionAccount, String> {
343        std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
344        let mut rand = [0u8; 12];
345        getrandom(&mut rand)?;
346        let tag: String = rand.iter().map(|b| format!("{b:02x}")).collect();
347        let cap_file = self.base.join(format!(".spawn-{tag}.cap"));
348        let mut cmd = Command::new(&self.bin);
349        cmd.args(["account", "provision", "--provider", provider, "--session", session_key, "--rotate", "--cap-out"])
350            .arg(&cap_file)
351            .env("M4A_ACCOUNTS_ROOT", &self.base)
352            .env_remove("M4A_SESSION_CAP")
353            .env_remove("M4A_PRODUCT_INVITE");
354        crate::windows_process::hide_console(&mut cmd);
355        let out = cmd.output().map_err(|e| format!("m4a-agent: {}", e.kind()))?;
356        let cap = std::fs::read_to_string(&cap_file);
357        let _ = std::fs::remove_file(&cap_file);
358        if !out.status.success() {
359            return Err(format!("m4a-agent account provision failed ({})", out.status.code().unwrap_or(-1)));
360        }
361        let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|_| "m4a-agent answer is not json".to_string())?;
362        let nick = v["nick"].as_str().ok_or("m4a-agent answer has no nick")?.to_string();
363        let cap = cap.map_err(|_| "no capability issued".to_string())?;
364        if cap.trim().is_empty() || cap.contains('\n') {
365            return Err("capability is malformed".into());
366        }
367        // mail4agent's own session env (`Provisioned::session_env`): the login session id is the
368        // account ref and the account has its own store root.
369        let session_id = v["account_ref"].as_str().filter(|r| !r.is_empty()).unwrap_or(session_key).to_owned();
370        let store_root = v["store_root"].as_str().filter(|r| !r.is_empty()).map(str::to_owned);
371        let product_url = std::env::var(PRODUCT_URL_ENV).ok().filter(|u| !u.is_empty());
372        let account = SessionAccount { nick, session_id, store_root, product_url, cap };
373        if !v["enrolled"].as_bool().unwrap_or(false) {
374            if let Err(error) = self.node_enroll(&account) {
375                tracing::warn!(nick = %account.nick, %error, "m4a account not registered on the messenger (stays local)");
376            }
377        }
378        Ok(account)
379    }
380}
381
382fn which(name: &str) -> Option<PathBuf> {
383    let path = std::env::var_os("PATH")?;
384    for dir in std::env::split_paths(&path) {
385        let p = dir.join(name);
386        if p.is_file() {
387            return Some(p);
388        }
389    }
390    None
391}
392
393fn enroll_dir_hint() -> Option<PathBuf> {
394    std::env::var_os("HATCHERY_ENROLL_STATE")
395        .filter(|v| !v.is_empty())
396        .map(PathBuf::from)
397        .or_else(|| {
398            // Soft hint used when main has not set HATCHERY_ENROLL_STATE yet: none.
399            None
400        })
401}
402
403fn getrandom(buf: &mut [u8]) -> Result<(), String> {
404    use std::io::Read;
405    std::fs::File::open("/dev/urandom")
406        .and_then(|mut f| f.read_exact(buf))
407        .or_else(|_| {
408            // Non-unix fallback: time + address entropy is enough for a unique temp name.
409            let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
410            for (i, b) in buf.iter_mut().enumerate() {
411                *b = (t >> ((i % 16) * 8)) as u8 ^ (i as u8);
412            }
413            Ok::<(), std::io::Error>(())
414        })
415        .map_err(|e| e.to_string())
416}
417
418#[cfg(all(test, unix))]
419mod tests {
420    use super::*;
421
422    fn fake_agent(dir: &Path) -> PathBuf {
423        // Stands in for m4a-agent: one nick per --session, the cap written to --cap-out.
424        let p = dir.join("fake-m4a-agent");
425        std::fs::write(
426            &p,
427            "#!/bin/sh\nwhile [ $# -gt 0 ]; do case \"$1\" in --session) s=$2; shift;; --cap-out) o=$2; shift;; esac; shift; done\n\
428             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",
429        )
430        .unwrap();
431        use std::os::unix::fs::PermissionsExt;
432        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
433        p
434    }
435
436    #[test]
437    #[test]
438    fn defaults_find_agent_and_accounts_root_and_product_from_enrollment() {
439        let dir = std::env::temp_dir().join(format!("m4a-defaults-{}", std::process::id()));
440        let _ = std::fs::remove_dir_all(&dir);
441        std::fs::create_dir_all(&dir).unwrap();
442        let agent = fake_agent(&dir);
443        std::env::set_var(AGENT_BIN_ENV, &agent);
444        std::env::remove_var(ACCOUNTS_ROOT_ENV);
445        std::env::remove_var(PRODUCT_URL_ENV);
446        let root = M4aSessionAccounts::default_accounts_root(Some(dir.as_path())).unwrap();
447        assert_eq!(root, dir.join("m4a-accounts"));
448        assert_eq!(M4aSessionAccounts::find_agent_bin().as_deref(), Some(agent.as_path()));
449        std::fs::write(
450            dir.join("enrollment.json"),
451            r#"{"node_id":"n","response":{"node_id":"n","status":"approved","m4a_product_url":"https://example.invalid"}}"#,
452        )
453        .unwrap();
454        assert_eq!(
455            M4aSessionAccounts::resolve_product_url(Some(dir.as_path())).as_deref(),
456            Some("https://example.invalid")
457        );
458        let got = M4aSessionAccounts::from_enroll_dir(Some(dir.as_path())).unwrap();
459        assert_eq!(got.base(), root.as_path());
460        std::env::remove_var(AGENT_BIN_ENV);
461        std::env::remove_var(PRODUCT_URL_ENV);
462        std::fs::remove_dir_all(&dir).unwrap();
463    }
464
465    #[test]
466    fn each_spawn_gets_its_own_account_and_its_own_session_env() {
467        let dir = std::env::temp_dir().join(format!("hatch-m4a-{}", std::process::id()));
468        let _ = std::fs::remove_dir_all(&dir);
469        std::fs::create_dir_all(&dir).unwrap();
470        let acc = M4aSessionAccounts::new(fake_agent(&dir), dir.join("accounts"));
471        let a = acc.provision("codex", "inc-1").unwrap();
472        let b = acc.provision("codex", "inc-2").unwrap();
473        assert_ne!(a.nick, b.nick);
474        let env = a.env_mutations();
475        let keys: Vec<_> = env.iter().map(|m| m.key.to_string_lossy().into_owned()).collect();
476        assert_eq!(&keys[..5], ["M4A_NICK", "M4A_SESSION_CAP", "M4A_SESSION_ID", "M4A_BOT_NAME", "M4A_STORE_ROOT"]);
477        assert_eq!(env[2].value.as_deref(), Some(std::ffi::OsStr::new("ref-inc-1")));
478        assert_eq!(env[4].value.as_deref(), Some(std::ffi::OsStr::new("/s/inc-1")));
479        assert_ne!(env[1].value, b.env_mutations()[1].value);
480        // The one-shot cap file is gone and the cap is not in Debug output.
481        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();
482        assert!(left.is_empty());
483        assert!(!format!("{a:?}").contains("cap-"));
484    }
485
486    #[test]
487    fn every_builtin_g4a_provider_maps_to_an_m4a_provider() {
488        for spec in gate4agent_catalog::builtin_specs() {
489            let id = spec.id.as_str().to_string();
490            assert!(m4a_provider(&id).is_some(), "g4a provider {id} has no m4a mapping");
491        }
492        assert_eq!(m4a_provider("custom"), None);
493    }
494
495    #[cfg(unix)]
496    #[test]
497    fn wake_token_is_private_stable_and_long() {
498        use std::os::unix::fs::PermissionsExt;
499        let dir = std::env::temp_dir().join(format!("node-wake-token-{}", std::process::id()));
500        let a = M4aSessionAccounts::new("/bin/false", &dir);
501        let t1 = a.wake_token().unwrap();
502        assert_eq!(t1.len(), 48);
503        assert_eq!(a.wake_token().unwrap(), t1);
504        let mode = std::fs::metadata(dir.join(".node-wake-token")).unwrap().permissions().mode() & 0o777;
505        assert_eq!(mode, 0o600);
506        let _ = std::fs::remove_dir_all(&dir);
507    }
508
509    #[test]
510    fn a_failing_agent_injects_nothing() {
511        let acc = M4aSessionAccounts::new("/nonexistent/m4a-agent", std::env::temp_dir().join("hatch-m4a-none"));
512        assert!(acc.provision("codex", "x").is_err());
513    }
514}
515
516fn codex_safe_value(v: &str) -> bool {
517    !v.is_empty() && !v.chars().any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '%' | '!' | '^' | '&' | '|' | '<' | '>' | '(' | ')'))
518}
519
520/// The `command` value for codex: the full path when it is safe, else the bare file name when
521/// the PATH lookup resolves to this same binary.
522fn codex_safe_command(bin: &Path) -> Option<String> {
523    let full = bin.to_string_lossy().into_owned();
524    if codex_safe_value(&full) {
525        return Some(full);
526    }
527    let name = bin.file_name()?.to_string_lossy().into_owned();
528    if !codex_safe_value(&name) {
529        return None;
530    }
531    let path = std::env::var_os("PATH")?;
532    let dir = bin.parent()?;
533    std::env::split_paths(&path)
534        .any(|p| p == dir)
535        .then(|| name.strip_suffix(".exe").map(str::to_owned).unwrap_or(name))
536}
537
538trait ZeroizeBestEffort {
539    fn zeroize_best_effort(&mut self);
540}
541
542impl ZeroizeBestEffort for String {
543    fn zeroize_best_effort(&mut self) {
544        // SAFETY: zero bytes keep the buffer valid UTF-8.
545        unsafe { self.as_bytes_mut() }.iter_mut().for_each(|b| *b = 0);
546    }
547}
548
549#[cfg(test)]
550mod session_file_tests {
551    use super::*;
552
553    #[test]
554    fn mcp_args_are_session_scoped_and_carry_no_capability() {
555        let root = std::env::temp_dir().join(format!("m4a-mcp-args-{}", std::process::id()));
556        let a = M4aSessionAccounts::new("m4a-agent", &root);
557        let c = a.mcp_launch_args("claude", "claude-1").unwrap();
558        assert_eq!(c[0], OsString::from("--mcp-config"));
559        let cfg = std::fs::read_to_string(&c[1]).unwrap();
560        assert!(cfg.contains("--session-file") && cfg.contains("claude-1.session"));
561        let x = a.mcp_launch_args("codex", "codex-1").unwrap();
562        assert_eq!(x.len(), 4);
563        assert!(x.iter().all(|v| !v.to_string_lossy().contains('"')));
564        assert!(a.mcp_launch_args("grok", "grok-1").unwrap().is_empty());
565        a.remove_session_file("claude-1");
566        assert!(!std::path::Path::new(&c[1]).exists());
567        let _ = std::fs::remove_dir_all(root);
568    }
569
570    #[test]
571    fn codex_values_never_carry_whitespace_or_quotes() {
572        assert!(codex_safe_value("/opt/m4a/bin/m4a-agent"));
573        assert!(!codex_safe_value("/home/a b/m4a-agent"));
574        assert!(!codex_safe_value("x'y"));
575        assert_eq!(codex_safe_command(Path::new("/opt/m4a/m4a-agent")).as_deref(), Some("/opt/m4a/m4a-agent"));
576        assert_eq!(codex_safe_command(Path::new("/no such dir/m4a-agent")), None);
577    }
578
579    #[test]
580    fn session_file_path_refuses_path_like_nicks() {
581        let a = M4aSessionAccounts::new("bin", std::env::temp_dir().join("m4a-sf-test"));
582        assert!(a.session_file_path("grok-1").unwrap().ends_with("sessions/grok-1.session"));
583        assert!(a.session_file_path("../x").is_none());
584        assert!(a.session_file_path("").is_none());
585    }
586}
587
588#[cfg(test)]
589mod node_account_message_tests {
590    #[test]
591    fn signed_bytes_follow_the_product_contract() {
592        let m = super::node_account_message("aud", "node-1", Some("nick-a"), 7, "n0");
593        assert_eq!(m, b"m4a-node-account-v1\naud\nnode-1\nnick-a\n7\nn0\n".to_vec());
594        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());
595    }
596}