hive-c0re: extract agent-socket-plumbing prose (#715 batch 5)

This commit is contained in:
damocles 2026-05-31 17:29:38 +02:00 committed by mara
commit d2f1a9d291
3 changed files with 44 additions and 98 deletions

View file

@ -1,11 +1,9 @@
//! `/var/lib/hyperhive/agent-ports.json` writer (#15 / #740).
//!
//! The hive-gateway nginx container lives in the host's system config,
//! not in the meta flake — its build can't be triggered by a
//! meta-rebuild on every agent spawn / move / destroy. Instead it
//! reads this JSON file at request-handling time to look up per-agent
//! upstream ports, so the file IS the source of truth for "which
//! agents exist and what's their web port" from the gateway's POV.
//! `/var/lib/hyperhive/agent-ports.json` writer. Legacy TCP map for
//! per-agent `/agent/<name>/` routing — the unix-socket replacement
//! lives in `agent_sockets.rs`. The gateway reads this JSON at
//! request-handling time rather than at gateway build time, so a
//! `nixos-container update` of the gateway isn't needed every time
//! an agent spawns / moves / destroys.
//!
//! Shape (flat object keyed by logical agent name → web port):
//!
@ -20,13 +18,9 @@
//!
//! Ports come from [`crate::lifecycle::agent_web_port`] — pure
//! FNV-1a(name) hash so the value is reproducible from the name
//! alone. The manager is intentionally excluded from the map: the
//! gateway routes `/` straight to it via the c0re dashboard upstream
//! (see `nix/modules/hive-gateway.nix`, atlas's #740) rather than a
//! per-agent `/agent/<name>/` prefix. Post-#753 the manager's port
//! is computed by the same hash as every other agent, but it still
//! doesn't appear here — keeping the routing surface "sub-agents
//! only" matches the gateway's current shape.
//! alone. Manager is excluded from the map: the gateway routes `/`
//! straight to it via the c0re dashboard upstream rather than a
//! per-agent `/agent/<name>/` prefix.
//!
//! Atomicity: write to a sibling `.tmp` file + rename so a partial
//! write never leaves an unparseable file in place. The gateway's
@ -116,7 +110,7 @@ mod tests {
// Use MANAGER_NAME in the input so the assert below actually
// exercises the filter path — a literal `"hm1nd"` would pass
// trivially if the constant ever changed and the filter
// silently became a no-op (argus on PR #748).
// silently became a no-op.
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
.iter()
.map(|s| (*s).to_owned())

View file

@ -30,11 +30,12 @@ pub fn start(agent: &str, socket_path: &Path, coord: Arc<Coordinator>) -> Result
let listener = UnixListener::bind(socket_path)
.with_context(|| format!("bind agent socket {}", socket_path.display()))?;
// The socket is bind-mounted into exactly one container as
// `/run/hive/mcp.sock` (`lifecycle::set_nspawn_flags`); post-#658
// the in-container harness connects as the per-agent unix user,
// not root, so the default `tokio::net::UnixListener::bind` perms
// (0755) lock it out. 0666 lets the agent user connect; the bind
// source dir is per-agent on host so blast radius is unchanged.
// `/run/hive/mcp.sock` (`lifecycle::set_nspawn_flags`); the
// in-container harness connects as the per-agent unix user,
// not root, so the default `tokio::net::UnixListener::bind`
// perms (0755) lock it out. 0666 lets the agent user connect;
// the bind source dir is per-agent on host so blast radius is
// unchanged.
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o666))
.with_context(|| format!("chmod agent socket {}", socket_path.display()))?;
@ -230,7 +231,7 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
}
}
AgentRequest::SetStatus { text } => {
// #720: cap length + reject multi-line so a confused caller
// Cap length + reject multi-line so a confused caller
// can't dump a multi-paragraph session report into the
// dashboard chip.
if let Err(message) = crate::limits::check_status_text(text) {
@ -261,10 +262,10 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
}
AgentRequest::GetAgentMeta { name } => {
let target = name.as_deref().unwrap_or(agent);
// #432: gate status on the target's running state so a
// stopped container's stale on-disk status doesn't leak
// through. Also surface `running` itself so callers can
// tell (e.g. "iris is down" vs "iris has no status set").
// Gate status on the target's running state so a stopped
// container's stale on-disk status doesn't leak through.
// Also surface `running` itself so callers can tell
// (e.g. "iris is down" vs "iris has no status set").
let (status_text, status_set_at, running) =
crate::container_view::read_agent_status_live(target).await;
let role = if target == hive_sh4re::MANAGER_AGENT {
@ -339,8 +340,9 @@ fn handle_send(
}
// Resolve magic-recipient sentinels (currently `<parent>`) against
// topology.json; no-op for ordinary names. Lets agents address
// structural roles without learning the label — runtime reparenting
// (#486) propagates for free (#692).
// structural roles without learning the label — runtime
// reparenting propagates for free. See `docs/conventions.md::
// Recipient sentinels`.
let resolved = crate::topology::resolve_recipient(agent, to);
match coord.broker.send(&Message {
from: agent.to_owned(),

View file

@ -1,58 +1,12 @@
//! `/var/lib/hyperhive/agent-sockets.json` writer (#784 phase 2,
//! prerequisite to #14 container netns isolation).
//! `/var/lib/hyperhive/agent-sockets.json` writer. Sibling to
//! `agent_ports.rs`; same atomic `<path>.tmp` + `rename()` shape so
//! the gateway's nginx worker never reads a partial file. Manager
//! excluded from the map (manager UI routes via the dashboard
//! upstream, not per-agent `/agent/<name>/`).
//!
//! Sibling to `agent_ports.rs`. The gateway needs to know which unix
//! socket to `proxy_pass` to per agent once the per-agent web UI
//! flips off TCP and on to `UnixListener::bind` (#784 phase 1
//! landed via PR #800). This file is the source of truth for
//! "which agents exist + where to reach their web UI over a domain
//! socket" from the gateway's POV — read at request-handling time,
//! not at gateway build time, so a `nixos-container update` of the
//! gateway isn't needed every time an agent spawns / moves /
//! destroys.
//!
//! Shape (flat object keyed by logical agent name → socket path):
//!
//! ```json
//! {
//! "iris": "/run/hive-agent/iris.sock",
//! "atlas": "/run/hive-agent/atlas.sock",
//! "argus": "/run/hive-agent/argus.sock",
//! "damocles": "/run/hive-agent/damocles.sock"
//! }
//! ```
//!
//! Socket paths are deterministic from the agent name —
//! [`socket_path_for`] computes them, so a name alone resolves to a
//! reproducible path. Manager is intentionally excluded from the map
//! (same reasoning as `agent_ports.rs`: the gateway routes the
//! manager's UI at `/` straight to the dashboard upstream, not via
//! per-agent `/agent/<name>/`).
//!
//! Coexists with `agent-ports.json` during the #784 phase-3
//! transition: agents that haven't opted in to `HIVE_WEB_SOCKET` yet
//! still appear in both files; the gateway picks the socket upstream
//! when one exists, falls back to the TCP port otherwise. Step 4
//! drops the TCP path entirely once every agent's web UI has flipped.
//!
//! 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'.
//! Full mechanism — per-agent subdir bind-mount, `.bound` marker
//! gate, gateway UDS upstream, transition vs `agent-ports.json`,
//! 10s poll loop: `docs/gateway.md::Per-agent unix-socket upstream`.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@ -66,8 +20,8 @@ const HOST_SOCKETS_PATH: &str = "/var/lib/hyperhive/agent-sockets.json";
/// 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").
/// only its own `<name>/` subdir — 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
@ -79,10 +33,9 @@ pub const SOCKET_FILENAME: &str = "web.sock";
/// successful `bind_unix`. Presence = "this agent has opted in to
/// `hyperhive.web.useUnixSocket = true` and its harness has bound
/// the socket"; absence = "the harness is still on TCP, don't
/// publish the unix upstream for this agent yet". Atlas's gate on
/// PR #813 — without it, the gateway would `proxy_pass` to a
/// non-existent socket for every sub-agent that hasn't flipped the
/// option yet.
/// publish the unix upstream for this agent yet". Without this gate
/// the gateway would `proxy_pass` to a non-existent socket for every
/// sub-agent that hasn't flipped the option yet.
pub const READY_MARKER: &str = ".bound";
#[must_use]
@ -101,7 +54,7 @@ pub fn agent_dir_for(name: &str) -> PathBuf {
/// 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.
/// harness binds via `HIVE_WEB_SOCKET`.
#[must_use]
pub fn socket_path_for(name: &str) -> PathBuf {
agent_dir_for(name).join(SOCKET_FILENAME)
@ -115,10 +68,9 @@ pub fn socket_path_for(name: &str) -> PathBuf {
///
/// Also filters by `READY_MARKER` presence: only agents whose
/// harness has actually bound the unix socket (and dropped the
/// marker) appear in the map. Atlas's gate on #813 — without this,
/// the gateway would `proxy_pass` to a non-existent socket for
/// every sub-agent that hasn't yet flipped
/// `hyperhive.web.useUnixSocket = true`.
/// marker) appear in the map. Without this, the gateway would
/// `proxy_pass` to a non-existent socket for every sub-agent that
/// hasn't yet flipped `hyperhive.web.useUnixSocket = true`.
///
/// `BTreeMap` keeps the JSON output sorted by key so a re-emit
/// without churn produces byte-identical output — same idempotency
@ -270,8 +222,7 @@ mod tests {
// Use `MANAGER_NAME` in the input so the assert actually
// exercises the filter path — a literal `"hm1nd"` would pass
// trivially if the constant ever changed and the filter
// silently became a no-op (same pattern as #748 fix on
// agent_ports::build_map). All-ready predicate bypasses the
// silently became a no-op. All-ready predicate bypasses the
// marker check so we exercise the manager filter in isolation.
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
.iter()
@ -320,9 +271,8 @@ mod tests {
.iter()
.map(|s| (*s).to_owned())
.collect();
// Pretend only `atlas` has flipped + bound (mara on PR #813:
// "agents can only access their own sockets" — the gate
// makes sure only opted-in agents get a UDS upstream).
// Pretend only `atlas` has flipped + bound — the gate makes
// sure only opted-in agents get a UDS upstream.
let map = build_map_with(&names, |name| name == "atlas");
assert!(map.contains_key("atlas"));
assert!(!map.contains_key("iris"));