Skip to main content

hatchery_node/
m4a_attach.rs

1//! `POST /m4a/attach`: on-demand m4a account for a provider session the node did not spawn
2//! (spec live-session-m4a-attach).
3//!
4//! Caller: `m4a-agent mcp` running inside that provider session. Checks, all required:
5//! 1. owner: the ops listener's owner-only control token (a file only the node's OS user can
6//!    read) -- checked by the router before this module runs;
7//! 2. policy: `HATCHERY_M4A_ATTACH` (default `auto`; set `off` to refuse) and the provider in
8//!    `HATCHERY_M4A_ATTACH_PROVIDERS` (default: all built-in providers);
9//! 3. process: the given pid is alive and descends (within a few generations) from a live
10//!    process of that provider.
11//! Then the node provisions a per-session account (own registry entry, own store root and key)
12//! keyed by the provider session id (or the provider's pid when none is known) and returns
13//! nick, session id and a fresh capability. The capability is returned once, never logged.
14
15use serde_json::{json, Value};
16
17pub const POLICY_ENV: &str = "HATCHERY_M4A_ATTACH";
18pub const PROVIDERS_ENV: &str = "HATCHERY_M4A_ATTACH_PROVIDERS";
19const MAX_GENERATIONS: usize = 12;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Proc {
23    pub pid: u32,
24    pub ppid: u32,
25    pub name: String,
26}
27
28/// Process image base names that count as `provider`.
29pub fn provider_images(provider: &str) -> &'static [&'static str] {
30    match provider {
31        "grok" => &["grok"],
32        "codex" => &["codex"],
33        "kimi" => &["kimi", "kimi-code"],
34        "claude" => &["claude"],
35        _ => &[],
36    }
37}
38
39fn base_name(name: &str) -> String {
40    let n = name.rsplit(['/', '\\']).next().unwrap_or(name).to_ascii_lowercase();
41    n.strip_suffix(".exe").map(str::to_owned).unwrap_or(n)
42}
43
44/// Walks from `pid` up the parent chain; returns the nearest process whose image is the provider.
45pub fn provider_ancestor(table: &[Proc], pid: u32, provider: &str) -> Option<u32> {
46    let images = provider_images(provider);
47    let mut current = pid;
48    for _ in 0..MAX_GENERATIONS {
49        let p = table.iter().find(|p| p.pid == current)?;
50        let name = base_name(&p.name);
51        if images.iter().any(|i| name == *i || name.starts_with(&format!("{i}-"))) {
52            return Some(p.pid);
53        }
54        if p.ppid == 0 || p.ppid == p.pid {
55            return None;
56        }
57        current = p.ppid;
58    }
59    None
60}
61
62/// Adoption policy: unset or `auto` → allow; `off`/`0`/`false` → refuse.
63pub fn attach_policy_is_auto() -> bool {
64    match std::env::var(POLICY_ENV) {
65        Ok(v) => {
66            let v = v.trim().to_ascii_lowercase();
67            !(v.is_empty() || matches!(v.as_str(), "off" | "0" | "false" | "no"))
68        }
69        Err(_) => true,
70    }
71}
72
73pub fn policy_allows(provider: &str) -> Result<(), &'static str> {
74    if !attach_policy_is_auto() {
75        return Err("adoption policy is off on this node");
76    }
77    if let Ok(list) = std::env::var(PROVIDERS_ENV) {
78        if !list.split(',').map(str::trim).any(|p| p == provider) {
79            return Err("provider is not allowed by the adoption policy");
80        }
81    }
82    Ok(())
83}
84
85pub fn query_get(query: &str, key: &str) -> Option<String> {
86    query.split('&').find_map(|kv| {
87        let (k, v) = kv.split_once('=')?;
88        (k == key).then(|| percent_decode(v))
89    })
90}
91
92fn percent_decode(v: &str) -> String {
93    let b = v.as_bytes();
94    let mut out = Vec::with_capacity(b.len());
95    let mut i = 0;
96    while i < b.len() {
97        if b[i] == b'%' && i + 2 < b.len() {
98            if let Ok(x) = u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or(""), 16) {
99                out.push(x);
100                i += 3;
101                continue;
102            }
103        }
104        out.push(if b[i] == b'+' { b' ' } else { b[i] });
105        i += 1;
106    }
107    String::from_utf8_lossy(&out).into_owned()
108}
109
110pub fn valid_session(s: &str) -> bool {
111    !s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b))
112}
113
114/// Handles the request; `table` is the current process table (injected for tests).
115pub fn handle(
116    query: &str,
117    accounts: Option<&crate::m4a_accounts::M4aSessionAccounts>,
118    table: &[Proc],
119) -> (u16, Value) {
120    let Some(provider) = query_get(query, "provider") else {
121        return (400, json!({"error": "provider is required"}));
122    };
123    let Some(m4a_provider) = crate::m4a_accounts::m4a_provider(&provider) else {
124        return (400, json!({"error": "unknown provider"}));
125    };
126    if let Err(reason) = policy_allows(&provider) {
127        tracing::info!(%provider, "m4a attach refused by policy");
128        return (403, json!({"error": reason, "state": "pending"}));
129    }
130    let Some(pid) = query_get(query, "pid").and_then(|p| p.parse::<u32>().ok()) else {
131        return (400, json!({"error": "pid is required"}));
132    };
133    let Some(root) = provider_ancestor(table, pid, &provider) else {
134        tracing::warn!(%provider, pid, "m4a attach refused: caller is not inside a live provider process");
135        return (403, json!({"error": "caller is not inside a live process of that provider"}));
136    };
137    let session = query_get(query, "session");
138    if session.as_deref().is_some_and(|s| !valid_session(s)) {
139        return (400, json!({"error": "invalid session id"}));
140    }
141    let Some(accounts) = accounts else {
142        return (503, json!({"error": "m4a accounts are not configured on this node"}));
143    };
144    // Per-session binding: an MCP child of a shared backend has no session of its own, so the
145    // node reads it from the provider's command line or takes a pending adoption.
146    // An adopted provider binds only to its adopted sessions (saved in the accounts root, so
147    // this holds across node restarts); it never gets a fresh per-pid account.
148    let adopted: Vec<String> = crate::m4a_wake::load_adoptions(accounts.base()).into_iter().filter(|a| a.provider == provider).map(|a| a.session).collect();
149    let key = if adopted.is_empty() {
150        let session = crate::m4a_adopt::resolve_session(session, &provider, root, crate::m4a_adopt::process_cmdline).filter(|s| valid_session(s));
151        match &session {
152            Some(s) => format!("attach-{provider}-{s}"),
153            None => format!("attach-{provider}-pid{root}"),
154        }
155    } else {
156        let from_cmdline = crate::m4a_adopt::session_from_cmdline(&crate::m4a_adopt::process_cmdline(root));
157        match pick_adopted(session, from_cmdline, &adopted) {
158            Ok(s) => format!("attach-{provider}-{s}"),
159            Err(why) => {
160                tracing::warn!(%provider, adopted = adopted.len(), "m4a attach refused: {why}");
161                return (409, json!({"error": why}));
162            }
163        }
164    };
165    match accounts.provision(m4a_provider, &key) {
166        Ok(account) => {
167            tracing::info!(%provider, provider_pid = root, nick = %account.nick, "m4a account attached to a live session");
168            (200, account.attach_reply())
169        }
170        Err(error) => {
171            tracing::warn!(%provider, %error, "m4a attach provision failed");
172            (500, json!({"error": "account provision failed"}))
173        }
174    }
175}
176
177/// The adopted session an attach belongs to: the session it names, else the one on the
178/// provider's command line when that one is adopted, else the only adopted session. Several
179/// adopted sessions and nothing to tell them apart is an error, never a new account.
180pub fn pick_adopted(explicit: Option<String>, from_cmdline: Option<String>, adopted: &[String]) -> Result<String, &'static str> {
181    if let Some(s) = explicit {
182        return Ok(s);
183    }
184    if let Some(s) = from_cmdline.filter(|s| adopted.contains(s)) {
185        return Ok(s);
186    }
187    match adopted {
188        [only] => Ok(only.clone()),
189        [] => Err("no adopted session"),
190        _ => Err("several adopted sessions of this provider and this MCP names none; start it with --provider-session"),
191    }
192}
193
194/// The live process table.
195#[cfg(target_os = "linux")]
196pub fn process_table() -> Vec<Proc> {
197    let mut out = Vec::new();
198    let Ok(dir) = std::fs::read_dir("/proc") else { return out };
199    for e in dir.flatten() {
200        let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::<u32>().ok()) else { continue };
201        let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { continue };
202        let (Some(l), Some(r)) = (stat.find('('), stat.rfind(')')) else { continue };
203        let ppid = stat[r + 1..].split_whitespace().nth(1).and_then(|s| s.parse().ok()).unwrap_or(0);
204        // comm is truncated to 15 bytes; prefer the executable name.
205        let name = std::fs::read_link(format!("/proc/{pid}/exe"))
206            .ok()
207            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
208            .unwrap_or_else(|| stat[l + 1..r].to_owned());
209        out.push(Proc { pid, ppid, name });
210    }
211    out
212}
213
214#[cfg(windows)]
215pub fn process_table() -> Vec<Proc> {
216    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
217    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
218        CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
219    };
220    let mut out = Vec::new();
221    unsafe {
222        let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
223        if snap == INVALID_HANDLE_VALUE {
224            return out;
225        }
226        let mut entry: PROCESSENTRY32W = std::mem::zeroed();
227        entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
228        let mut ok = Process32FirstW(snap, &mut entry) != 0;
229        while ok {
230            let len = entry.szExeFile.iter().position(|c| *c == 0).unwrap_or(entry.szExeFile.len());
231            out.push(Proc {
232                pid: entry.th32ProcessID,
233                ppid: entry.th32ParentProcessID,
234                name: String::from_utf16_lossy(&entry.szExeFile[..len]),
235            });
236            ok = Process32NextW(snap, &mut entry) != 0;
237        }
238        CloseHandle(snap);
239    }
240    out
241}
242
243#[cfg(not(any(target_os = "linux", windows)))]
244pub fn process_table() -> Vec<Proc> {
245    Vec::new()
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn table() -> Vec<Proc> {
253        vec![
254            Proc { pid: 1, ppid: 0, name: "init".into() },
255            Proc { pid: 10, ppid: 1, name: "Grok.EXE".into() },
256            Proc { pid: 11, ppid: 10, name: "cmd.exe".into() },
257            Proc { pid: 12, ppid: 11, name: "m4a-agent.exe".into() },
258            Proc { pid: 20, ppid: 1, name: "bash".into() },
259            Proc { pid: 21, ppid: 20, name: "m4a-agent".into() },
260        ]
261    }
262
263    #[test]
264    fn attach_without_session_takes_the_adopted_account_across_restarts() {
265        let dir = std::env::temp_dir().join(format!("m4a-attach-adopt-{}", std::process::id()));
266        let _ = std::fs::remove_dir_all(&dir);
267        std::fs::create_dir_all(&dir).unwrap();
268        let a = crate::m4a_wake::Adoption { provider: "grok".into(), session: "s-1".into(), cwd: Some("w".into()), nick: "grok-1".into(), account_ref: "r".into(), store_root: None };
269        crate::m4a_wake::save_adoption(&dir, &a).unwrap();
270        // "Restart": nothing in memory, only the saved file.
271        let adopted = |d: &std::path::Path| crate::m4a_wake::load_adoptions(d).into_iter().map(|a| a.session).collect::<Vec<_>>();
272        assert_eq!(pick_adopted(None, None, &adopted(&dir)).as_deref(), Ok("s-1"));
273        assert_eq!(pick_adopted(None, Some("not-adopted".into()), &adopted(&dir)).as_deref(), Ok("s-1"));
274        crate::m4a_wake::save_adoption(&dir, &crate::m4a_wake::Adoption { session: "s-2".into(), ..a.clone() }).unwrap();
275        assert!(pick_adopted(None, None, &adopted(&dir)).is_err(), "ambiguous: error, not a pid account");
276        assert_eq!(pick_adopted(None, Some("s-2".into()), &adopted(&dir)).as_deref(), Ok("s-2"));
277        assert_eq!(pick_adopted(Some("s-1".into()), None, &adopted(&dir)).as_deref(), Ok("s-1"));
278        std::fs::remove_dir_all(&dir).unwrap();
279    }
280
281    #[test]
282    fn ancestry_finds_the_provider_and_refuses_strangers() {
283        let t = table();
284        assert_eq!(provider_ancestor(&t, 12, "grok"), Some(10));
285        assert_eq!(provider_ancestor(&t, 12, "codex"), None);
286        assert_eq!(provider_ancestor(&t, 21, "grok"), None);
287        assert_eq!(provider_ancestor(&t, 999, "grok"), None);
288    }
289
290    #[test]
291    fn query_parsing_decodes_and_validates() {
292        assert_eq!(query_get("provider=grok&session=a%3Ab", "session").as_deref(), Some("a:b"));
293        assert!(valid_session("0aa-b_c.d:e"));
294        assert!(!valid_session("../x"));
295        assert!(!valid_session(""));
296    }
297
298    // One test owns the policy env var (tests in this binary run in parallel).
299    #[test]
300    fn policy_and_process_gates_come_before_any_account() {
301        std::env::set_var(POLICY_ENV, "off");
302        let (s, v) = handle("provider=grok&pid=12", None, &table());
303        assert_eq!((s, v["state"].as_str()), (403, Some("pending")));
304        std::env::set_var(POLICY_ENV, "auto");
305        std::env::set_var(PROVIDERS_ENV, "codex");
306        assert_eq!(handle("provider=grok&pid=12", None, &table()).0, 403);
307        std::env::remove_var(PROVIDERS_ENV);
308        assert_eq!(handle("provider=grok&pid=21", None, &table()).0, 403);
309        assert_eq!(handle("provider=grok&pid=12&session=../x", None, &table()).0, 400);
310        assert_eq!(handle("provider=grok&pid=12", None, &table()).0, 503);
311        assert_eq!(handle("provider=nope&pid=12", None, &table()).0, 400);
312        std::env::remove_var(POLICY_ENV);
313    }
314
315    #[cfg(target_os = "linux")]
316    #[test]
317    fn live_table_contains_this_process() {
318        let me = std::process::id();
319        assert!(process_table().iter().any(|p| p.pid == me));
320    }
321}