diff --git a/hive-c0re/src/agent_ports.rs b/hive-c0re/src/agent_ports.rs index 7a8dccaf..bf82ab71 100644 --- a/hive-c0re/src/agent_ports.rs +++ b/hive-c0re/src/agent_ports.rs @@ -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//` 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//` 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//` 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 = ["iris", MANAGER_NAME, "argus"] .iter() .map(|s| (*s).to_owned()) diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index b294410d..70299b15 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -30,11 +30,12 @@ pub fn start(agent: &str, socket_path: &Path, coord: Arc) -> 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) -> } } 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) -> } 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 ``) 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(), diff --git a/hive-c0re/src/agent_sockets.rs b/hive-c0re/src/agent_sockets.rs index 804a24d7..97eec828 100644 --- a/hive-c0re/src/agent_sockets.rs +++ b/hive-c0re/src/agent_sockets.rs @@ -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 `.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//`). //! -//! 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//`). -//! -//! 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 `.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'. +//! 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 `/` subdir, scoping access per mara's #800 -/// directive ("agents can only access their own sockets"). +/// only its own `/` 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 = ["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"));