diff --git a/hive-c0re/src/agent_sockets.rs b/hive-c0re/src/agent_sockets.rs index 9a715f36..4ea40ed1 100644 --- a/hive-c0re/src/agent_sockets.rs +++ b/hive-c0re/src/agent_sockets.rs @@ -1,8 +1,8 @@ //! `/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. Includes -//! manager and sub-agents so the gateway can route -//! `/agent//` for all containers with a bound unix socket. +//! 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//`). //! //! Full mechanism — per-agent subdir bind-mount, `hyperhive-socket-bound` //! marker gate, gateway UDS upstream, transition vs `agent-ports.json`, @@ -67,9 +67,14 @@ pub fn socket_path_for(name: &str) -> PathBuf { } /// Compute the agent-socket map for the given logical agent names. -/// Includes manager and sub-agents. Filters by `READY_MARKER` -/// presence: only agents whose harness has actually bound the unix -/// socket appear in the map. Without this, the gateway would +/// Sub-agents only — manager is filtered out at the call boundary +/// for the same reason it's filtered from `agent_ports::build_map` +/// (manager UI is routed via the c0re dashboard upstream, not via +/// `/agent//`). +/// +/// Also filters by `READY_MARKER` presence: only agents whose +/// harness has actually bound the unix socket (and dropped the +/// 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`. /// @@ -100,6 +105,7 @@ where { names .iter() + .filter(|n| n.as_str() != MANAGER_NAME) .filter(|n| is_ready(n)) .map(|n| (n.clone(), socket_path_for(n))) .collect() @@ -235,13 +241,18 @@ mod tests { } #[test] - fn build_map_includes_manager() { + fn build_map_filters_manager() { + // 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. All-ready predicate bypasses the + // marker check so we exercise the manager filter in isolation. let names: Vec = ["iris", MANAGER_NAME, "argus"] .iter() .map(|s| (*s).to_owned()) .collect(); let map = build_map_with(&names, |_| true); - assert!(map.contains_key(MANAGER_NAME)); + assert!(!map.contains_key(MANAGER_NAME)); assert!(map.contains_key("iris")); assert!(map.contains_key("argus")); } @@ -332,4 +343,3 @@ mod tests { assert!(body.contains("\"/run/hive-agent/iris/web.sock\"")); } } - diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 69178473..afb66009 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1174,31 +1174,60 @@ fn set_nspawn_flags( std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?; let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config"); + // Per-agent socket subdir. Bind-mounts `/run/hive-agent//` + // into the container at the same path so the harness's + // `HIVE_WEB_SOCKET` bind has a stable location both sides can + // see. Sub-agents only — the manager's UI is served at `/` + // via the c0re dashboard upstream, not via `/agent//`, + // so it never needs the per-agent socket dir. + // + // Bind-mounting the SUBDIR (not the socket file) is mandatory: + // the harness's `bind_unix` helper unlinks any stale socket + // before calling `bind(2)`, and a file bind-mount drops its + // host-side anchor on unlink — the rebind would land in the + // container's private namespace, invisible to the gateway. + // Dir bind keeps the same dir inode visible on both sides, so + // the new `web.sock` shows up on the host the moment the + // harness binds it. + // + // Per-agent dir (rather than a shared `/run/hive-agent/` + // mount) means the agent's container only sees its own + // subdir — never siblings'. See `docs/gateway.md::Per-agent + // unix-socket upstream`. + // + // mkdir source defensively: nspawn refuses to start when the + // bind source is missing, and on a fresh host `/run/hive-agent/` + // doesn't exist yet. + let socket_dir = crate::agent_sockets::agent_dir_for(agent_name); + std::fs::create_dir_all(&socket_dir) + .with_context(|| format!("create {}", socket_dir.display()))?; + // chown to the in-container agent user so its harness can + // `bind(2)` web.sock here. `create_dir_all` lands the dir at + // 0755 root:root and the harness runs as the non-root agent + // user; without this chown the bind fails with EACCES, the + // gateway's agent-sockets.json stays empty, and the agent + // looks unreachable. uid resolution can return None on the + // very first spawn (container's /etc/passwd not yet rendered) + // — fall back to a permissive 0777 in that window so the + // first harness boot still binds. nspawn shares uids with the + // host (no PrivateUsers), so the in-container uid is the same + // uid we chown to here. + if let Some((uid, gid)) = agent_uid_gid(agent_name) { + std::os::unix::fs::chown(&socket_dir, Some(uid), Some(gid)) + .with_context(|| format!("chown {} to {uid}:{gid}", socket_dir.display()))?; + } else { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o777)) + .with_context(|| { + format!("chmod 0777 {} (uid lookup failed)", socket_dir.display()) + })?; + } + let _ = write!( + binds, + " --bind={socket_dir}:{socket_dir}", + socket_dir = socket_dir.display(), + ); } - // Web-socket subdir: bind-mount `/run/hive-agent//` into the - // container so the harness can bind `web.sock` there and the host-side - // gateway sees it. Subdir bind (not socket file) keeps the inode - // visible after the harness unlinks a stale socket on rebind. - // Applies to manager and sub-agents alike. - let socket_dir = crate::agent_sockets::agent_dir_for(agent_name); - std::fs::create_dir_all(&socket_dir) - .with_context(|| format!("create {}", socket_dir.display()))?; - // Chown to the agent user so the non-root harness can bind(2) here. - // Falls back to 0777 on first spawn when uid lookup returns None - // (container /etc/passwd not yet rendered). - if let Some((uid, gid)) = agent_uid_gid(agent_name) { - std::os::unix::fs::chown(&socket_dir, Some(uid), Some(gid)) - .with_context(|| format!("chown {} to {uid}:{gid}", socket_dir.display()))?; - } else { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o777)) - .with_context(|| format!("chmod 0777 {}", socket_dir.display()))?; - } - let _ = write!( - binds, - " --bind={socket_dir}:{socket_dir}", - socket_dir = socket_dir.display(), - ); let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\""); let mut lines: Vec = original .lines() @@ -1440,4 +1469,3 @@ mod tests { ); } } - diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 53ffc9f1..9d098608 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -95,9 +95,12 @@ in 4. eventually drop this option once every agent is on unix and the TCP fallback is removed from the harness. - Sub-agents only: the manager always has `HIVE_WEB_SOCKET` set - unconditionally in the `isManager` env block, so this toggle - has no effect when `hyperhive.role = "manager"`. + Sub-agent-only by design: the manager's UI serves at `/` via + the c0re dashboard upstream, not via `/agent//`, so this + option has no effect when `hyperhive.role = "manager"` (the + env var is set unconditionally for clarity, but the manager's + web UI doesn't route through the gateway's per-agent unix + upstream — its bind socket would just sit unused). ''; }; @@ -1322,9 +1325,6 @@ in # HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100. HIVE_PORT = "8875"; HIVE_LABEL = "hm1nd"; - # Manager always uses a unix socket so the gateway can route - # /agent// to it the same way it routes sub-agents. - HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; @@ -1343,4 +1343,3 @@ in system.stateVersion = "25.11"; }; } -