hive-c0re: per-agent subdir socket layout (#809 design fix)

was: /run/hive-agent/<name>.sock (flat single-file bind-mount).
issue: file bind-mounts don't survive the harness's 'unlink stale
socket then bind(2) a new one' cycle. The unlink drops the bind
inside the container; the rebind happens in private container
namespace; host never sees the new inode → gateway can't connect.

now: /run/hive-agent/<name>/web.sock (per-agent SUBDIR + fixed
filename). Lifecycle bind-mounts the parent dir per agent (step 2b)
so both sides see the same dir inode; the socket appears on the
host the moment the harness binds it.

new helpers:
- AGENT_SOCKET_DIR const (parent, gateway binds this whole tree)
- SOCKET_FILENAME const ("web.sock")
- agent_dir_for(name) (per-agent subdir, lifecycle bind-mounts this)
- socket_path_for(name) (= agent_dir_for(name).join(SOCKET_FILENAME))

per-agent dir isolation also satisfies mara on #800 directly:
agent's container only sees its own subdir + socket, never siblings'.

8 tests now (added agent_dir_for_is_socket_parent invariant).
This commit is contained in:
damocles 2026-05-31 15:31:03 +02:00 committed by mara
commit 91f5588134

View file

@ -37,6 +37,22 @@
//! //!
//! Atomicity: same `<path>.tmp` + `rename()` shape as `agent_ports.rs` //! Atomicity: same `<path>.tmp` + `rename()` shape as `agent_ports.rs`
//! so the gateway's nginx worker never reads a partial file. //! so the gateway's nginx worker never reads a partial file.
//!
//! ## Per-agent subdir layout
//!
//! `<sockets-root>/<name>/web.sock`, NOT `<sockets-root>/<name>.sock`.
//! Each agent's container bind-mounts the per-agent SUBDIR
//! (`/run/hive-agent/<name>/`), 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::collections::BTreeMap;
use std::path::{Path, PathBuf}; 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"; const HOST_SOCKETS_PATH: &str = "/var/lib/hyperhive/agent-sockets.json";
/// Host-side directory that holds per-agent unix sockets. Each /// Host-side parent directory holding per-agent socket subdirs. The
/// agent's container bind-mounts only its own `<name>.sock` from /// gateway container bind-mounts this whole tree (read-only) so it
/// this dir, scoping access per mara's #800 directive ("agents can /// can `proxy_pass` to any agent. Each agent's container bind-mounts
/// only access their own sockets"). The gateway container gets the /// only its own `<name>/` subdir, scoping access per mara's #800
/// whole dir mounted read-only so it can proxy to every agent. /// directive ("agents can only access their own sockets").
pub const AGENT_SOCKET_DIR: &str = "/run/hive-agent"; 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] #[must_use]
pub fn host_sockets_path() -> PathBuf { pub fn host_sockets_path() -> PathBuf {
PathBuf::from(HOST_SOCKETS_PATH) 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 /// Compute the deterministic socket path for an agent. Pure function
/// of the agent name so the value matches whatever /// of the agent name so the value matches whatever
/// [`agent_sockets::write`] writes for that agent, and whatever the /// [`agent_sockets::write`] writes for that agent, and whatever the
/// harness binds via `HIVE_WEB_SOCKET` post-#784 phase 1. /// harness binds via `HIVE_WEB_SOCKET` post-#784 phase 1.
#[must_use] #[must_use]
pub fn socket_path_for(name: &str) -> PathBuf { 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. /// Compute the agent-socket map for the given logical agent names.
@ -142,12 +171,26 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn socket_path_for_uses_agent_dir_constant() { fn socket_path_for_uses_subdir_layout() {
// The path is derived from `AGENT_SOCKET_DIR` — pin both ends // Per-agent subdir + fixed socket filename — see module-level
// so a future move (e.g. to `/run/hyperhive/sockets/`) // "Per-agent subdir layout" for why this isn't a flat
// requires updating both the constant and the consumers. // `<name>.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"); 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] #[test]
@ -197,8 +240,8 @@ mod tests {
#[test] #[test]
fn render_is_pretty_and_sorted() { fn render_is_pretty_and_sorted() {
let mut map = BTreeMap::new(); let mut map = BTreeMap::new();
map.insert("zeta".to_owned(), PathBuf::from("/run/hive-agent/zeta.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.sock")); map.insert("alpha".to_owned(), PathBuf::from("/run/hive-agent/alpha/web.sock"));
let body = render(&map); let body = render(&map);
// Pretty-print = newlines between keys + indentation. // Pretty-print = newlines between keys + indentation.
assert!(body.contains('\n')); assert!(body.contains('\n'));
@ -218,9 +261,9 @@ mod tests {
// gateway-side reader can deserialise into String values // gateway-side reader can deserialise into String values
// without nested struct logic. // without nested struct logic.
let mut map = BTreeMap::new(); 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); let body = render(&map);
assert!(body.contains("\"iris\"")); assert!(body.contains("\"iris\""));
assert!(body.contains("\"/run/hive-agent/iris.sock\"")); assert!(body.contains("\"/run/hive-agent/iris/web.sock\""));
} }
} }