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
29/// Provider-specific process environment the node adds to its own sessions so the node's
30/// project-level m4a MCP entry is honoured without an interactive prompt.
31pub fn provider_session_env(g4a_agent_id: &str) -> Vec<EnvMutation> {
32    match g4a_agent_id {
33        // kimi-code: project `.kimi-code/mcp.json` stays disabled in an untrusted folder.
34        "kimi" => vec![EnvMutation { key: OsString::from("KIMI_CODE_TRUST_WORKSPACE"), value: Some(OsString::from("1")) }],
35        _ => Vec::new(),
36    }
37}
38
39/// Name of the node's m4a MCP server entry for a provider. A session's project entry under the
40/// same name as the user's own entry replaces it (kimi-code, grok and codex merge by name), so
41/// no duplicate m4a server runs. `HATCHERY_M4A_MCP_NAME_<PROVIDER>` (e.g. `..._KIMI`) or
42/// `HATCHERY_M4A_MCP_NAME`; default `m4a`.
43pub 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
60/// `--m4a-mcp-name [provider=]name` (repeatable): the node's m4a MCP entry name, taking
61/// precedence over the env. Returns false for a malformed value.
62pub 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    // Provider-specific entries win over a plain one regardless of order.
72    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";
77/// Messenger product base URL; with it (and an enrolled node) the node registers its sessions'
78/// accounts on the messenger itself, signed with the node key.
79pub const PRODUCT_URL_ENV: &str = "HATCHERY_M4A_PRODUCT_URL";
80/// The product's audience name the node signs for (default `m4a-product`).
81pub 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
85/// Installs this node's identity (enrolled, trusted) as the signer of account registrations.
86/// An unenrolled node never has one, so it registers nothing.
87pub fn set_node_signer(node_id: String, identity: std::sync::Arc<hatchery_enroll::NodeIdentity>) {
88    let _ = NODE_SIGNER.set((node_id, identity));
89}
90
91/// A real owner nick (not empty, not the placeholder `owner`).
92pub 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
97/// `node-owner: <nick>` printed by `m4a-agent account node-enroll` (registry's owner of this node).
98pub 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
113/// Exact bytes the messenger product verifies (`m4a-node-account-v1`).
114pub 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";
118/// Owner nick for session auto-nicks `<provider>-<N>.<node_id>.<owner>`.
119pub const OWNER_ENV: &str = "HATCHERY_M4A_OWNER";
120
121#[derive(Debug, Clone)]
122pub struct M4aSessionAccounts {
123    bin: PathBuf,
124    base: PathBuf,
125}
126
127/// One session's account as handed to its child: nick and capability only.
128pub struct SessionAccount {
129    pub nick: String,
130    /// The provider session key the account is bound to (also given to the session as
131    /// `M4A_SESSION_ID`, so its own inbox commands address the right account).
132    pub session_id: String,
133    /// The account's own store root (`M4A_STORE_ROOT`), when m4a-agent reported it.
134    pub store_root: Option<String>,
135    /// Messenger product URL the session's client uses (`M4A_PRODUCT_URL`), from the node env.
136    pub product_url: Option<String>,
137    cap: String,
138}
139
140impl Drop for SessionAccount {
141    fn drop(&mut self) {
142        // Best effort wipe of the capability copy held by the node.
143        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    /// The only variables the session gets from the node for m4a.
156    /// The one-time `/m4a/attach` reply (the capability travels only in this body).
157    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    /// The `m4a-agent` binary the node runs (also the command of the node's MCP entry).
177    pub fn bin(&self) -> &Path {
178        &self.bin
179    }
180
181    /// Fallback identity channel: `<root>/sessions/<nick>.session` (owner-only), the same three
182    /// keys as [`SessionAccount::env_mutations`], for a provider whose tools run in a process
183    /// that did not inherit the session environment (`m4a-agent --session-file <path> ...`).
184    pub fn session_file_path(&self, nick: &str) -> Option<PathBuf> {
185        // Node auto-nicks carry dots (`claude-3.box.owner`); never a leading dot or `..`.
186        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    /// Provider flags that give one spawned session its own m4a MCP server (`m4a-agent
217    /// --session-file <file> mcp`), without touching any user config. Empty for providers with
218    /// no verified per-session MCP flag (grok TUI, kimi PTY): those use attach (`/m4a/attach`).
219    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                // TOML literal strings: no escapes, no double quotes. On Windows codex runs through
238                // a `cmd /C <codex.cmd>` wrapper: an argument with whitespace gets quoted, and more
239                // than two quotes make cmd strip the wrapper's own quotes (spawn fails). So the
240                // values must be whitespace-free; a binary in a spaced directory is named by its
241                // file name (found on PATH), otherwise the session falls back to attach.
242                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    /// Per-session working folder `<workspace>/.hatchery-sessions/<nick>` with the node's m4a
259    /// MCP in the provider's own project config (kimi-code `.kimi-code/mcp.json`, grok
260    /// `.grok/config.toml`). Returns the folder, or `None` when the provider has no project
261    /// config the node writes (the session then runs in the workspace root).
262    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                // TOML basic strings: the paths are escaped by serde_json's string form.
286                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    /// Registers the session under the node auto-nick `<provider>-<N>.<node_id>.<owner>` (unique
299    /// across boxes and humans: the server only accepts this node's own suffix and no human nick
300    /// has that shape). A taken nick moves to the next free `N`. Without an owner
301    /// (`HATCHERY_M4A_OWNER`) the local nick is registered as is.
302    fn register_auto_nick(&self, account: &mut SessionAccount, provider: &str, session_key: &str) {
303        // Env, else `<enroll-state>/m4a-owner` (one line), so a running supervisor need not restart.
304        // Env, else `<enroll-state>/m4a-owner`, which the node itself writes from the registry's
305        // answer (`node-owner:` of `account node-enroll`); the placeholder "owner" never counts.
306        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 suffix = format!(".{node_id}.{owner}");
318        let head = account.nick.strip_suffix(&suffix).unwrap_or(&account.nick).to_string();
319        let start: u64 = head.rsplit_once('-').and_then(|(_, n)| n.parse().ok()).unwrap_or(1);
320        // `claude-code` -> `claude`, `kimi-code` -> `kimi`: the nick names the agent, not the CLI.
321        let stem = provider.strip_suffix("-code").unwrap_or(provider);
322        let mut last = String::new();
323        for n in start..start + 32 {
324            let want = format!("{stem}-{n}{suffix}");
325            if want != account.nick {
326                if let Err(e) = self.renick(provider, session_key, &want) {
327                    last = e;
328                    continue;
329                }
330                account.nick = want.clone();
331            }
332            match self.node_enroll(account) {
333                Ok(()) => return,
334                Err(e) if e.contains("409") || e.contains("taken") => last = e,
335                Err(e) => {
336                    tracing::warn!(nick = %account.nick, error = %e, "m4a account not registered on the messenger (stays local)");
337                    return;
338                }
339            }
340        }
341        tracing::warn!(nick = %account.nick, error = %last, "m4a auto-nick: no free nick found");
342    }
343
344    fn renick(&self, provider: &str, session_key: &str, nick: &str) -> Result<(), String> {
345        let mut cmd = Command::new(&self.bin);
346        cmd.args(["account", "renick", "--provider", provider, "--session", session_key, "--nick", nick])
347            .env("M4A_ACCOUNTS_ROOT", &self.base)
348            .env_remove("M4A_SESSION_CAP")
349            .env_remove("M4A_PRODUCT_INVITE");
350        crate::windows_process::hide_console(&mut cmd);
351        let out = cmd.output().map_err(|e| format!("m4a-agent: {}", e.kind()))?;
352        if out.status.success() {
353            Ok(())
354        } else {
355            Err(String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("renick refused").chars().take(200).collect())
356        }
357    }
358
359    /// Registers the account on the messenger with this node's key, then the account's own client
360    /// enrolls its own key (`m4a-agent account node-enroll`). The invite never reaches the node.
361    fn node_enroll(&self, account: &SessionAccount) -> Result<(), String> {
362        let url = account.product_url.as_deref().ok_or("no messenger product url (HATCHERY_M4A_PRODUCT_URL)")?;
363        let store_root = account.store_root.as_deref().ok_or("m4a-agent reported no store root")?;
364        let (node_id, identity) = NODE_SIGNER.get().ok_or("this node is not enrolled: it registers no accounts")?;
365        let audience = std::env::var(PRODUCT_AUDIENCE_ENV).ok().filter(|a| !a.is_empty()).unwrap_or_else(|| "m4a-product".into());
366        let ts_ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0);
367        let mut raw = [0u8; 16];
368        getrandom(&mut raw)?;
369        let nonce: String = raw.iter().map(|b| format!("{b:02x}")).collect();
370        let signature = identity.sign_bytes(&node_account_message(&audience, node_id, Some(&account.nick), ts_ms, &nonce));
371        let mut cmd = Command::new(&self.bin);
372        cmd.args(["account", "node-enroll", "--node-id", node_id, "--ts", &ts_ms.to_string(), "--nonce", &nonce, "--signature", &signature, "--nick", &account.nick])
373            .env("M4A_PRODUCT_URL", url)
374            .env("M4A_SESSION_ID", &account.session_id)
375            .env("M4A_STORE_ROOT", store_root)
376            .env("M4A_BOT_NAME", &account.nick)
377            .env("M4A_ACCOUNTS_ROOT", &self.base)
378            .env_remove("M4A_SESSION_CAP")
379            .env_remove("M4A_PRODUCT_INVITE");
380        crate::windows_process::hide_console(&mut cmd);
381        let out = cmd.output().map_err(|e| format!("m4a-agent: {}", e.kind()))?;
382        if !out.status.success() {
383            let why = String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("").chars().take(200).collect::<String>();
384            return Err(format!("node-enroll failed: {why}"));
385        }
386        tracing::info!(nick = %account.nick, node_id = %node_id, "m4a account registered on the messenger by this node");
387        if let Some(owner) = owner_from_enroll_output(&String::from_utf8_lossy(&out.stdout)) {
388            if let Some(dir) = enroll_dir_hint() {
389                let path = dir.join("m4a-owner");
390                if std::fs::read_to_string(&path).ok().map(|t| t.trim().to_owned()).as_deref() != Some(owner.as_str()) {
391                    match std::fs::write(&path, format!("{owner}\n")) {
392                        Ok(()) => tracing::info!(%owner, "node owner taken from the m4a registry"),
393                        Err(e) => tracing::warn!(error = %e, "node owner not saved"),
394                    }
395                }
396            }
397        }
398        Ok(())
399    }
400
401    pub fn remove_session_file(&self, nick: &str) {
402        if let Some(path) = self.session_file_path(nick) {
403            let _ = std::fs::remove_file(path);
404        }
405        let _ = std::fs::remove_file(self.base.join("sessions").join(format!("{nick}.mcp.json")));
406    }
407
408    pub fn new(bin: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
409        Self { bin: bin.into(), base: base.into() }
410    }
411
412    /// The node's letter-wake token (`<root>/.node-wake-token`, 0600), created on first use.
413    pub fn wake_token(&self) -> Result<String, String> {
414        let path = self.base.join(".node-wake-token");
415        if let Ok(t) = std::fs::read_to_string(&path) {
416            if t.trim().len() >= 32 {
417                return Ok(t.trim().to_owned());
418            }
419        }
420        std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
421        let mut raw = [0u8; 24];
422        getrandom(&mut raw)?;
423        let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
424        let mut opts = std::fs::OpenOptions::new();
425        opts.write(true).create(true).truncate(true);
426        #[cfg(unix)]
427        {
428            use std::os::unix::fs::OpenOptionsExt;
429            opts.mode(0o600);
430        }
431        use std::io::Write;
432        opts.open(&path).and_then(|mut f| f.write_all(token.as_bytes())).map_err(|e| format!("wake token: {}", e.kind()))?;
433        Ok(token)
434    }
435
436    /// Resolve `m4a-agent`: env override, then next to this binary, then PATH, then `~/.cargo/bin`.
437    pub fn find_agent_bin() -> Option<PathBuf> {
438        if let Some(p) = std::env::var_os(AGENT_BIN_ENV).filter(|v| !v.is_empty()).map(PathBuf::from) {
439            return Some(p);
440        }
441        if let Ok(exe) = std::env::current_exe() {
442            if let Some(dir) = exe.parent() {
443                for name in ["m4a-agent", "m4a-agent.exe"] {
444                    let p = dir.join(name);
445                    if p.is_file() {
446                        return Some(p);
447                    }
448                }
449            }
450        }
451        which("m4a-agent").or_else(|| which("m4a-agent.exe")).or_else(|| {
452            let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
453            let p = PathBuf::from(home).join(".cargo").join("bin").join(if cfg!(windows) { "m4a-agent.exe" } else { "m4a-agent" });
454            p.is_file().then_some(p)
455        })
456    }
457
458    /// Accounts root: env, else `<enroll_dir>/m4a-accounts`.
459    pub fn default_accounts_root(enroll_dir: Option<&Path>) -> Option<PathBuf> {
460        if let Some(p) = std::env::var_os(ACCOUNTS_ROOT_ENV).filter(|v| !v.is_empty()).map(PathBuf::from) {
461            return Some(p);
462        }
463        enroll_dir.map(|d| d.join("m4a-accounts"))
464    }
465
466    /// Product URL: env override, else what C2 answered on connect ([`set_c2_product_url`]), else
467    /// `m4a_product_url` from enrollment.json under enroll_dir.
468    pub fn resolve_product_url(enroll_dir: Option<&Path>) -> Option<String> {
469        if let Some(u) = std::env::var(PRODUCT_URL_ENV).ok().filter(|u| !u.is_empty()) {
470            return Some(u);
471        }
472        if let Some(u) = c2_product_url() {
473            return Some(u);
474        }
475        let dir = enroll_dir?;
476        let text = std::fs::read_to_string(dir.join("enrollment.json")).ok()?;
477        let v: serde_json::Value = serde_json::from_str(&text).ok()?;
478        v.get("response")
479            .and_then(|r| r.get("m4a_product_url"))
480            .or_else(|| v.get("m4a_product_url"))
481            .and_then(|u| u.as_str())
482            .filter(|u| !u.is_empty())
483            .map(str::to_owned)
484    }
485
486    /// Open from env and defaults. Logs clearly when m4a cannot start.
487    pub fn from_env() -> Option<Self> {
488        Self::from_enroll_dir(enroll_dir_hint().as_deref())
489    }
490
491    pub fn from_enroll_dir(enroll_dir: Option<&Path>) -> Option<Self> {
492        let bin = match Self::find_agent_bin() {
493            Some(p) => p,
494            None => {
495                tracing::warn!(
496                    "m4a-agent not found (set HATCHERY_M4A_AGENT_BIN, put it next to gate4agent-node, on PATH, or in ~/.cargo/bin); m4a off"
497                );
498                return None;
499            }
500        };
501        let base = match Self::default_accounts_root(enroll_dir) {
502            Some(p) => p,
503            None => {
504                tracing::warn!("m4a accounts root unknown (set HATCHERY_M4A_ACCOUNTS_ROOT or pass --enroll-state); m4a off");
505                return None;
506            }
507        };
508        if let Err(e) = std::fs::create_dir_all(&base) {
509            tracing::warn!(error = %e.kind(), path = %base.display(), "m4a accounts root not writable; m4a off");
510            return None;
511        }
512        let product = Self::resolve_product_url(enroll_dir);
513        if product.is_none() {
514            tracing::info!("m4a product url unset (env / C2 connect / enrollment); accounts stay local until set");
515        }
516        tracing::info!(bin = %bin.display(), accounts = %base.display(), product = product.as_deref().unwrap_or("-"), "m4a accounts ready");
517        Some(Self::new(bin, base))
518    }
519
520    pub fn base(&self) -> &Path {
521        &self.base
522    }
523
524    /// The account of `(provider, session_key)`, created if missing, with a fresh capability
525    /// (a respawn of the same session invalidates the previous process's key).
526    pub fn provision(&self, provider: &str, session_key: &str) -> Result<SessionAccount, String> {
527        std::fs::create_dir_all(&self.base).map_err(|e| format!("accounts root: {}", e.kind()))?;
528        let mut rand = [0u8; 12];
529        getrandom(&mut rand)?;
530        let tag: String = rand.iter().map(|b| format!("{b:02x}")).collect();
531        let cap_file = self.base.join(format!(".spawn-{tag}.cap"));
532        let mut cmd = Command::new(&self.bin);
533        cmd.args(["account", "provision", "--provider", provider, "--session", session_key, "--rotate", "--cap-out"])
534            .arg(&cap_file)
535            .env("M4A_ACCOUNTS_ROOT", &self.base)
536            .env_remove("M4A_SESSION_CAP")
537            .env_remove("M4A_PRODUCT_INVITE");
538        crate::windows_process::hide_console(&mut cmd);
539        let out = cmd.output().map_err(|e| format!("m4a-agent: {}", e.kind()))?;
540        let cap = std::fs::read_to_string(&cap_file);
541        let _ = std::fs::remove_file(&cap_file);
542        if !out.status.success() {
543            return Err(format!("m4a-agent account provision failed ({})", out.status.code().unwrap_or(-1)));
544        }
545        let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|_| "m4a-agent answer is not json".to_string())?;
546        let nick = v["nick"].as_str().ok_or("m4a-agent answer has no nick")?.to_string();
547        let cap = cap.map_err(|_| "no capability issued".to_string())?;
548        if cap.trim().is_empty() || cap.contains('\n') {
549            return Err("capability is malformed".into());
550        }
551        // mail4agent's own session env (`Provisioned::session_env`): the login session id is the
552        // account ref and the account has its own store root.
553        let session_id = v["account_ref"].as_str().filter(|r| !r.is_empty()).unwrap_or(session_key).to_owned();
554        let store_root = v["store_root"].as_str().filter(|r| !r.is_empty()).map(str::to_owned);
555        let product_url = Self::resolve_product_url(enroll_dir_hint().as_deref());
556        let mut account = SessionAccount { nick, session_id, store_root, product_url, cap };
557        if !v["enrolled"].as_bool().unwrap_or(false) {
558            self.register_auto_nick(&mut account, provider, session_key);
559        }
560        Ok(account)
561    }
562}
563
564fn which(name: &str) -> Option<PathBuf> {
565    let path = std::env::var_os("PATH")?;
566    for dir in std::env::split_paths(&path) {
567        let p = dir.join(name);
568        if p.is_file() {
569            return Some(p);
570        }
571    }
572    None
573}
574
575static C2_PRODUCT_URL: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
576
577/// What C2 answered for `m4a_product_url` on the latest connect (`None` clears).
578pub fn set_c2_product_url(url: Option<String>) {
579    *C2_PRODUCT_URL.write().unwrap_or_else(|e| e.into_inner()) = url.filter(|u| !u.is_empty());
580}
581
582pub fn c2_product_url() -> Option<String> {
583    C2_PRODUCT_URL.read().unwrap_or_else(|e| e.into_inner()).clone()
584}
585
586pub(crate) fn enroll_dir_hint() -> Option<PathBuf> {
587    std::env::var_os("HATCHERY_ENROLL_STATE")
588        .filter(|v| !v.is_empty())
589        .map(PathBuf::from)
590        .or_else(|| {
591            // Soft hint used when main has not set HATCHERY_ENROLL_STATE yet: none.
592            None
593        })
594}
595
596fn getrandom(buf: &mut [u8]) -> Result<(), String> {
597    use std::io::Read;
598    std::fs::File::open("/dev/urandom")
599        .and_then(|mut f| f.read_exact(buf))
600        .or_else(|_| {
601            // Non-unix fallback: time + address entropy is enough for a unique temp name.
602            let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
603            for (i, b) in buf.iter_mut().enumerate() {
604                *b = (t >> ((i % 16) * 8)) as u8 ^ (i as u8);
605            }
606            Ok::<(), std::io::Error>(())
607        })
608        .map_err(|e| e.to_string())
609}
610
611#[cfg(all(test, unix))]
612mod tests {
613    use super::*;
614
615    fn fake_agent(dir: &Path) -> PathBuf {
616        // Stands in for m4a-agent: one nick per --session, the cap written to --cap-out.
617        let p = dir.join("fake-m4a-agent");
618        std::fs::write(
619            &p,
620            "#!/bin/sh\nwhile [ $# -gt 0 ]; do case \"$1\" in --session) s=$2; shift;; --cap-out) o=$2; shift;; esac; shift; done\n\
621             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",
622        )
623        .unwrap();
624        use std::os::unix::fs::PermissionsExt;
625        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
626        p
627    }
628
629    #[test]
630    fn defaults_find_agent_and_accounts_root_and_product_from_enrollment() {
631        let dir = std::env::temp_dir().join(format!("m4a-defaults-{}", std::process::id()));
632        let _ = std::fs::remove_dir_all(&dir);
633        std::fs::create_dir_all(&dir).unwrap();
634        let agent = fake_agent(&dir);
635        std::env::set_var(AGENT_BIN_ENV, &agent);
636        std::env::remove_var(ACCOUNTS_ROOT_ENV);
637        std::env::remove_var(PRODUCT_URL_ENV);
638        let root = M4aSessionAccounts::default_accounts_root(Some(dir.as_path())).unwrap();
639        assert_eq!(root, dir.join("m4a-accounts"));
640        assert_eq!(M4aSessionAccounts::find_agent_bin().as_deref(), Some(agent.as_path()));
641        std::fs::write(
642            dir.join("enrollment.json"),
643            r#"{"node_id":"n","response":{"node_id":"n","status":"approved","m4a_product_url":"https://example.invalid"}}"#,
644        )
645        .unwrap();
646        assert_eq!(
647            M4aSessionAccounts::resolve_product_url(Some(dir.as_path())).as_deref(),
648            Some("https://example.invalid")
649        );
650        let got = M4aSessionAccounts::from_enroll_dir(Some(dir.as_path())).unwrap();
651        assert_eq!(got.base(), root.as_path());
652        std::env::remove_var(AGENT_BIN_ENV);
653        std::env::remove_var(PRODUCT_URL_ENV);
654        std::fs::remove_dir_all(&dir).unwrap();
655    }
656
657    #[test]
658    fn product_url_order_env_then_c2_then_enrollment() {
659        let dir = std::env::temp_dir().join(format!("m4a-purl-{}", std::process::id()));
660        let _ = std::fs::remove_dir_all(&dir);
661        std::fs::create_dir_all(&dir).unwrap();
662        std::fs::write(dir.join("enrollment.json"), r#"{"response":{"m4a_product_url":"https://enroll.invalid"}}"#).unwrap();
663        std::env::remove_var(PRODUCT_URL_ENV);
664        set_c2_product_url(None);
665        assert_eq!(M4aSessionAccounts::resolve_product_url(Some(&dir)).as_deref(), Some("https://enroll.invalid"));
666        set_c2_product_url(Some("https://c2.invalid".into()));
667        assert_eq!(M4aSessionAccounts::resolve_product_url(Some(&dir)).as_deref(), Some("https://c2.invalid"));
668        std::env::set_var(PRODUCT_URL_ENV, "https://env.invalid");
669        assert_eq!(M4aSessionAccounts::resolve_product_url(Some(&dir)).as_deref(), Some("https://env.invalid"));
670        std::env::remove_var(PRODUCT_URL_ENV);
671        set_c2_product_url(None);
672        std::fs::remove_dir_all(&dir).unwrap();
673    }
674
675    #[test]
676    fn each_spawn_gets_its_own_account_and_its_own_session_env() {
677        let dir = std::env::temp_dir().join(format!("hatch-m4a-{}", std::process::id()));
678        let _ = std::fs::remove_dir_all(&dir);
679        std::fs::create_dir_all(&dir).unwrap();
680        let acc = M4aSessionAccounts::new(fake_agent(&dir), dir.join("accounts"));
681        let a = acc.provision("codex", "inc-1").unwrap();
682        let b = acc.provision("codex", "inc-2").unwrap();
683        assert_ne!(a.nick, b.nick);
684        let env = a.env_mutations();
685        let keys: Vec<_> = env.iter().map(|m| m.key.to_string_lossy().into_owned()).collect();
686        assert_eq!(&keys[..5], ["M4A_NICK", "M4A_SESSION_CAP", "M4A_SESSION_ID", "M4A_BOT_NAME", "M4A_STORE_ROOT"]);
687        assert_eq!(env[2].value.as_deref(), Some(std::ffi::OsStr::new("ref-inc-1")));
688        assert_eq!(env[4].value.as_deref(), Some(std::ffi::OsStr::new("/s/inc-1")));
689        assert_ne!(env[1].value, b.env_mutations()[1].value);
690        // The one-shot cap file is gone and the cap is not in Debug output.
691        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();
692        assert!(left.is_empty());
693        assert!(!format!("{a:?}").contains("cap-"));
694    }
695
696    #[test]
697    fn every_builtin_g4a_provider_maps_to_an_m4a_provider() {
698        for spec in gate4agent_catalog::builtin_specs() {
699            let id = spec.id.as_str().to_string();
700            assert!(m4a_provider(&id).is_some(), "g4a provider {id} has no m4a mapping");
701        }
702        assert_eq!(m4a_provider("custom"), None);
703    }
704
705    #[cfg(unix)]
706    #[test]
707    fn wake_token_is_private_stable_and_long() {
708        use std::os::unix::fs::PermissionsExt;
709        let dir = std::env::temp_dir().join(format!("node-wake-token-{}", std::process::id()));
710        let a = M4aSessionAccounts::new("/bin/false", &dir);
711        let t1 = a.wake_token().unwrap();
712        assert_eq!(t1.len(), 48);
713        assert_eq!(a.wake_token().unwrap(), t1);
714        let mode = std::fs::metadata(dir.join(".node-wake-token")).unwrap().permissions().mode() & 0o777;
715        assert_eq!(mode, 0o600);
716        let _ = std::fs::remove_dir_all(&dir);
717    }
718
719    #[test]
720    fn a_failing_agent_injects_nothing() {
721        let acc = M4aSessionAccounts::new("/nonexistent/m4a-agent", std::env::temp_dir().join("hatch-m4a-none"));
722        assert!(acc.provision("codex", "x").is_err());
723    }
724}
725
726fn codex_safe_value(v: &str) -> bool {
727    !v.is_empty() && !v.chars().any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '%' | '!' | '^' | '&' | '|' | '<' | '>' | '(' | ')'))
728}
729
730/// The `command` value for codex: the full path when it is safe, else the bare file name when
731/// the PATH lookup resolves to this same binary.
732fn codex_safe_command(bin: &Path) -> Option<String> {
733    let full = bin.to_string_lossy().into_owned();
734    if codex_safe_value(&full) {
735        return Some(full);
736    }
737    let name = bin.file_name()?.to_string_lossy().into_owned();
738    if !codex_safe_value(&name) {
739        return None;
740    }
741    let path = std::env::var_os("PATH")?;
742    let dir = bin.parent()?;
743    std::env::split_paths(&path)
744        .any(|p| p == dir)
745        .then(|| name.strip_suffix(".exe").map(str::to_owned).unwrap_or(name))
746}
747
748trait ZeroizeBestEffort {
749    fn zeroize_best_effort(&mut self);
750}
751
752impl ZeroizeBestEffort for String {
753    fn zeroize_best_effort(&mut self) {
754        // SAFETY: zero bytes keep the buffer valid UTF-8.
755        unsafe { self.as_bytes_mut() }.iter_mut().for_each(|b| *b = 0);
756    }
757}
758
759#[cfg(test)]
760mod session_file_tests {
761    use super::*;
762
763    #[test]
764    fn mcp_args_are_session_scoped_and_carry_no_capability() {
765        let root = std::env::temp_dir().join(format!("m4a-mcp-args-{}", std::process::id()));
766        let a = M4aSessionAccounts::new("m4a-agent", &root);
767        let c = a.mcp_launch_args("claude", "claude-1").unwrap();
768        assert_eq!(c[0], OsString::from("--mcp-config"));
769        let cfg = std::fs::read_to_string(&c[1]).unwrap();
770        assert!(cfg.contains("--session-file") && cfg.contains("claude-1.session"));
771        let x = a.mcp_launch_args("codex", "codex-1").unwrap();
772        assert_eq!(x.len(), 4);
773        assert!(x.iter().all(|v| !v.to_string_lossy().contains('"')));
774        assert!(a.mcp_launch_args("grok", "grok-1").unwrap().is_empty());
775        a.remove_session_file("claude-1");
776        assert!(!std::path::Path::new(&c[1]).exists());
777        let _ = std::fs::remove_dir_all(root);
778    }
779
780    #[test]
781    fn codex_values_never_carry_whitespace_or_quotes() {
782        assert!(codex_safe_value("/opt/m4a/bin/m4a-agent"));
783        assert!(!codex_safe_value("/home/a b/m4a-agent"));
784        assert!(!codex_safe_value("x'y"));
785        assert_eq!(codex_safe_command(Path::new("/opt/m4a/m4a-agent")).as_deref(), Some("/opt/m4a/m4a-agent"));
786        assert_eq!(codex_safe_command(Path::new("/no such dir/m4a-agent")), None);
787    }
788
789    #[test]
790    fn session_file_path_refuses_path_like_nicks() {
791        let a = M4aSessionAccounts::new("bin", std::env::temp_dir().join("m4a-sf-test"));
792        assert!(a.session_file_path("grok-1").unwrap().ends_with("sessions/grok-1.session"));
793        assert!(a.session_file_path("../x").is_none());
794        assert!(a.session_file_path("claude-3.hatchery-box.zeng3ld").is_some());
795        assert!(a.session_file_path("..x").is_none());
796        assert!(a.session_file_path(".x").is_none());
797        assert!(a.session_file_path("").is_none());
798    }
799}
800
801#[cfg(test)]
802mod node_account_message_tests {
803    #[test]
804    fn signed_bytes_follow_the_product_contract() {
805        let m = super::node_account_message("aud", "node-1", Some("nick-a"), 7, "n0");
806        assert_eq!(m, b"m4a-node-account-v1\naud\nnode-1\nnick-a\n7\nn0\n".to_vec());
807        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());
808    }
809}