Skip to main content

hatchery_server/
memory.rs

1//! memory4agent subservice (crates.io `memory4agent` 0.3, binary
2//! `memory4agent-server`). On demand only: the server never starts it by
3//! itself. It listens on loopback in 18200-18299, has no authentication, and is
4//! never published to WireGuard or outside. The child runs with a scrubbed
5//! environment so it never sees vault keys or tokens.
6
7use crate::config::{MEMORY_PORT_MAX, MEMORY_PORT_MIN};
8use crate::error::ServerError;
9use crate::subservice::{ManagedProcess, State, Status, Subservice};
10use std::net::{Ipv4Addr, SocketAddr, TcpListener};
11use std::path::{Path, PathBuf};
12use tokio::process::Command;
13
14/// Variables the child may inherit. Everything else is dropped.
15const PASS_THROUGH_ENV: &[&str] = &["PATH", "HOME", "LANG", "RUST_LOG", "TMPDIR"];
16
17pub struct MemorySubservice {
18    bin: PathBuf,
19    data_dir: PathBuf,
20    process: ManagedProcess,
21    port: Option<u16>,
22    failed: Option<String>,
23}
24
25impl MemorySubservice {
26    pub fn new(bin: PathBuf, data_dir: &Path) -> Self {
27        Self {
28            bin,
29            data_dir: data_dir.join("memory"),
30            process: ManagedProcess::new(),
31            port: None,
32            failed: None,
33        }
34    }
35
36    /// First port of the range that can be bound on loopback right now.
37    pub fn pick_port() -> Result<u16, ServerError> {
38        (MEMORY_PORT_MIN..=MEMORY_PORT_MAX)
39            .find(|port| TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, *port))).is_ok())
40            .ok_or_else(|| ServerError::sub("memory", "no free loopback port in 18200-18299"))
41    }
42}
43
44impl Subservice for MemorySubservice {
45    fn name(&self) -> &'static str {
46        "memory"
47    }
48
49    fn on_demand(&self) -> bool {
50        true
51    }
52
53    fn start(&mut self) -> Result<(), ServerError> {
54        if self.process.alive() {
55            return Ok(());
56        }
57        let result = (|| {
58            std::fs::create_dir_all(&self.data_dir)?;
59            let port = Self::pick_port()?;
60            debug_assert!((MEMORY_PORT_MIN..=MEMORY_PORT_MAX).contains(&port));
61            let mut command = Command::new(&self.bin);
62            command.env_clear();
63            for name in PASS_THROUGH_ENV {
64                if let Some(value) = std::env::var_os(name) {
65                    command.env(name, value);
66                }
67            }
68            command
69                .env("MEMORY4AGENT_PORT", port.to_string())
70                .env("MEMORY4AGENT_DATA_DIR", &self.data_dir);
71            self.process.spawn(command)?;
72            Ok::<u16, ServerError>(port)
73        })();
74        match result {
75            Ok(port) => {
76                self.port = Some(port);
77                self.failed = None;
78                Ok(())
79            }
80            Err(error) => {
81                self.port = None;
82                self.failed = Some(error.to_string());
83                Err(ServerError::sub("memory", error.to_string()))
84            }
85        }
86    }
87
88    fn stop(&mut self) {
89        self.process.stop();
90        self.port = None;
91    }
92
93    fn status(&mut self) -> Status {
94        let state = if self.process.alive() {
95            State::Running
96        } else if self.failed.is_some() {
97            State::Failed
98        } else {
99            State::Stopped
100        };
101        let port = if state == State::Running { self.port } else { None };
102        Status { name: "memory", state, port, detail: self.failed.clone() }
103    }
104}
105
106#[cfg(all(test, unix))]
107mod tests {
108    use super::*;
109    use std::os::unix::fs::PermissionsExt;
110
111    fn fake_server(dir: &Path) -> PathBuf {
112        let path = dir.join("fake-memory-server");
113        std::fs::write(&path, "#!/bin/sh\nenv > \"$MEMORY4AGENT_DATA_DIR/env.txt\"\nexec sleep 30\n").unwrap();
114        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
115        path
116    }
117
118    #[tokio::test]
119    async fn is_on_demand_and_runs_on_loopback_range_with_scrubbed_env() {
120        let dir = tempfile::tempdir().unwrap();
121        std::env::set_var("HATCHERY_TEST_SECRET_NOT_FOR_CHILD", "value");
122        let mut service = MemorySubservice::new(fake_server(dir.path()), dir.path());
123        assert!(service.on_demand());
124        assert_eq!(service.status().state, State::Stopped);
125        service.start().unwrap();
126        let status = service.status();
127        assert_eq!(status.state, State::Running);
128        let port = status.port.unwrap();
129        assert!((MEMORY_PORT_MIN..=MEMORY_PORT_MAX).contains(&port));
130        for _ in 0..50 {
131            if dir.path().join("memory/env.txt").exists() {
132                break;
133            }
134            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
135        }
136        let env = std::fs::read_to_string(dir.path().join("memory/env.txt")).unwrap();
137        assert!(env.contains(&format!("MEMORY4AGENT_PORT={port}")));
138        assert!(!env.contains("HATCHERY_TEST_SECRET_NOT_FOR_CHILD"), "{env}");
139        service.stop();
140        assert_eq!(service.status().state, State::Stopped);
141        std::env::remove_var("HATCHERY_TEST_SECRET_NOT_FOR_CHILD");
142    }
143
144    #[test]
145    fn skips_busy_ports_and_stays_in_range() {
146        let first = MemorySubservice::pick_port().unwrap();
147        let _held = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, first))).unwrap();
148        let second = MemorySubservice::pick_port().unwrap();
149        assert_ne!(first, second);
150        assert!((MEMORY_PORT_MIN..=MEMORY_PORT_MAX).contains(&second));
151    }
152
153    #[test]
154    fn missing_binary_fails_without_a_port() {
155        let dir = tempfile::tempdir().unwrap();
156        let mut service = MemorySubservice::new(dir.path().join("absent"), dir.path());
157        assert!(service.start().is_err());
158        let status = service.status();
159        assert_eq!(status.state, State::Failed);
160        assert!(status.port.is_none());
161    }
162}