diff --git a/hive-c0re/src/agent_sockets.rs b/hive-c0re/src/agent_sockets.rs index 05a8cc25..0c0b6c58 100644 --- a/hive-c0re/src/agent_sockets.rs +++ b/hive-c0re/src/agent_sockets.rs @@ -37,6 +37,22 @@ //! //! Atomicity: same `.tmp` + `rename()` shape as `agent_ports.rs` //! so the gateway's nginx worker never reads a partial file. +//! +//! ## Per-agent subdir layout +//! +//! `//web.sock`, NOT `/.sock`. +//! Each agent's container bind-mounts the per-agent SUBDIR +//! (`/run/hive-agent//`), and the harness binds the socket +//! inside it. File-level bind-mounts don't survive the harness's +//! "unlink stale socket then `bind(2)` a new one" cycle — the unlink +//! drops the bind, the rebind happens in private container +//! namespace, host never sees the new inode. Bind-mounting the +//! parent dir keeps both sides looking at the same dir inode so the +//! socket appears on the host the moment the harness binds it. +//! +//! Per-agent dir isolation (one dir per agent rather than a shared +//! `/run/hive-agent/` bind) satisfies mara on #800: an agent's +//! container only sees its own dir + socket, never siblings'. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -47,25 +63,38 @@ use crate::lifecycle::MANAGER_NAME; const HOST_SOCKETS_PATH: &str = "/var/lib/hyperhive/agent-sockets.json"; -/// Host-side directory that holds per-agent unix sockets. Each -/// agent's container bind-mounts only its own `.sock` from -/// this dir, scoping access per mara's #800 directive ("agents can -/// only access their own sockets"). The gateway container gets the -/// whole dir mounted read-only so it can proxy to every agent. +/// Host-side parent directory holding per-agent socket subdirs. The +/// gateway container bind-mounts this whole tree (read-only) so it +/// can `proxy_pass` to any agent. Each agent's container bind-mounts +/// only its own `/` subdir, scoping access per mara's #800 +/// directive ("agents can only access their own sockets"). pub const AGENT_SOCKET_DIR: &str = "/run/hive-agent"; +/// Socket filename inside each per-agent subdir. Fixed so the path +/// derives entirely from `(AGENT_SOCKET_DIR, name)` — no second +/// degree of freedom for callers to get wrong. +pub const SOCKET_FILENAME: &str = "web.sock"; + #[must_use] pub fn host_sockets_path() -> PathBuf { PathBuf::from(HOST_SOCKETS_PATH) } +/// Per-agent socket subdir on the host. Lifecycle pre-creates this +/// before container start so the bind-mount source exists; the +/// harness binds the socket inside it as `web.sock`. +#[must_use] +pub fn agent_dir_for(name: &str) -> PathBuf { + Path::new(AGENT_SOCKET_DIR).join(name) +} + /// Compute the deterministic socket path for an agent. Pure function /// of the agent name so the value matches whatever /// [`agent_sockets::write`] writes for that agent, and whatever the /// harness binds via `HIVE_WEB_SOCKET` post-#784 phase 1. #[must_use] pub fn socket_path_for(name: &str) -> PathBuf { - Path::new(AGENT_SOCKET_DIR).join(format!("{name}.sock")) + agent_dir_for(name).join(SOCKET_FILENAME) } /// Compute the agent-socket map for the given logical agent names. @@ -142,12 +171,26 @@ mod tests { use super::*; #[test] - fn socket_path_for_uses_agent_dir_constant() { - // The path is derived from `AGENT_SOCKET_DIR` — pin both ends - // so a future move (e.g. to `/run/hyperhive/sockets/`) - // requires updating both the constant and the consumers. + fn socket_path_for_uses_subdir_layout() { + // Per-agent subdir + fixed socket filename — see module-level + // "Per-agent subdir layout" for why this isn't a flat + // `.sock`. Pin both ends so a future move (e.g. to + // `/run/hyperhive/sockets/`) requires updating both the + // constant and the consumers. let p = socket_path_for("iris"); - assert_eq!(p, Path::new("/run/hive-agent/iris.sock")); + assert_eq!(p, Path::new("/run/hive-agent/iris/web.sock")); + } + + #[test] + fn agent_dir_for_is_socket_parent() { + // `agent_dir_for` is what lifecycle bind-mounts per agent; + // `socket_path_for` lives inside it. Keep them in lockstep so + // a divergence (e.g. typo in one constant) surfaces here + // rather than as a confusing nspawn bind-source-not-found at + // container start. + let dir = agent_dir_for("iris"); + let sock = socket_path_for("iris"); + assert_eq!(sock.parent(), Some(dir.as_path())); } #[test] @@ -197,8 +240,8 @@ mod tests { #[test] fn render_is_pretty_and_sorted() { let mut map = BTreeMap::new(); - map.insert("zeta".to_owned(), PathBuf::from("/run/hive-agent/zeta.sock")); - map.insert("alpha".to_owned(), PathBuf::from("/run/hive-agent/alpha.sock")); + map.insert("zeta".to_owned(), PathBuf::from("/run/hive-agent/zeta/web.sock")); + map.insert("alpha".to_owned(), PathBuf::from("/run/hive-agent/alpha/web.sock")); let body = render(&map); // Pretty-print = newlines between keys + indentation. assert!(body.contains('\n')); @@ -218,9 +261,9 @@ mod tests { // gateway-side reader can deserialise into String values // without nested struct logic. let mut map = BTreeMap::new(); - map.insert("iris".to_owned(), PathBuf::from("/run/hive-agent/iris.sock")); + map.insert("iris".to_owned(), PathBuf::from("/run/hive-agent/iris/web.sock")); let body = render(&map); assert!(body.contains("\"iris\"")); - assert!(body.contains("\"/run/hive-agent/iris.sock\"")); + assert!(body.contains("\"/run/hive-agent/iris/web.sock\"")); } }