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`
//! 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::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 `<name>.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 `<name>/` 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
// `<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");
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\""));
}
}