From 8b946a67c6a7767a6754b85f0ae27e1ae2212f8d Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:29:46 +0200 Subject: [PATCH 1/7] fix(#947): extend socket-dir bind to manager container --- hive-c0re/src/lifecycle.rs | 232 ++++++++++++++++++++----------------- 1 file changed, 125 insertions(+), 107 deletions(-) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index afb66009..7b44eb74 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -354,7 +354,6 @@ pub async fn rebuild( dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, - on_step: &(dyn Fn(&str) + Send + Sync), ) -> Result<()> { // Sync the meta flake (idempotent — no-op when the rendered // flake matches disk) so a manual rebuild from the dashboard @@ -374,7 +373,7 @@ pub async fn rebuild( // `applied//main` currently points at (deployed/). // Commits the lock if it changed. crate::meta::lock_update_for_rebuild(name).await?; - rebuild_no_meta(name, agent_dir, applied_dir, claude_dir, notes_dir, on_step).await + rebuild_no_meta(name, agent_dir, applied_dir, claude_dir, notes_dir).await } /// Container-level rebuild without touching the meta repo. Callers @@ -382,18 +381,12 @@ pub async fn rebuild( /// drives meta through the two-phase prepare/finalize/abort flow) /// use this directly. Public `rebuild` wraps it with idempotent meta /// sync + lock-bump-and-commit. -/// -/// `on_step` is called at each phase boundary with a short human-readable -/// label so callers can surface progress (e.g. update the rebuild-queue -/// step shown in the dashboard). Pass `&|_| ()` when progress reporting -/// is not needed. pub async fn rebuild_no_meta( name: &str, agent_dir: &Path, applied_dir: &Path, claude_dir: &Path, notes_dir: &Path, - on_step: &(dyn Fn(&str) + Send + Sync), ) -> Result<()> { validate(name)?; if let Some(other) = port_collision(name).await { @@ -408,24 +401,55 @@ pub async fn rebuild_no_meta( let container = container_name(name); let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display()); if container_exists(name).await { - // Rebuild strategy: stop-before-update + pre-build. - // See `docs/coordinator.md::Container lifecycle`. + // Existing container: preserve the prior running state across + // rebuild, and apply both the new system profile + // AND any `/etc/nixos-containers/.conf` / drop-in changes + // in a single start rather than `update`'s reload-then-outer- + // restart double-bounce. + // + // `nixos-container update` only runs `systemctl reload + // container@` when the container is up (per the + // `isContainerRunning` check in nixos-container.pl), so + // stopping first makes `update` boot-style: build + nix-env + // --set the new profile, skip the in-container + // switch-to-configuration, let the next `start` apply both + // the new profile and the new EXTRA_NSPAWN_FLAGS in one go. + // If the container was already stopped, `update` builds + sets + // the profile and we leave it stopped. let was_running = is_running(name).await; set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?; set_resource_limits(&container)?; systemd_daemon_reload().await?; if was_running { - on_step("nix build"); + // Pre-build the system toplevel **before** stopping the + // running container so the agent keeps serving its + // previous generation while the eval + fetch + build + // happens out-of-band. `nixos-container update` then + // finds the toplevel cached and skips straight to the + // profile-swap + restart — downtime collapses to that + // window only. Build failures surface here, before we + // touch the container. + // + // When the container is already stopped there's no + // downtime to shave — let `update` do the build inline + // rather than evaluating the flake twice for nothing. prebuild_toplevel(name, &flake_ref).await?; - on_step("nixos-container stop"); run(&["stop", &container]).await?; } - on_step("nixos-container update"); run(&["update", &container, "--flake", &flake_ref]).await?; if was_running { - // Cold-start fallback on activation errors. - // See `docs/coordinator.md::Cold-start fallback`. - on_step("nixos-container start"); + // Normal path: start into the new generation. The activation + // script runs inside the container to transition old → new. + // This can fail when packages are removed between generations — + // the old-generation activation references units that no longer + // exist in the new closure, causing systemd to exit non-zero. + // + // Fallback: stop + kill + start (cold-start). The activation + // script can fail when packages are removed between generations — + // `start` exits non-zero but the container may be half-started. + // `stop` requests a graceful SIGTERM drain; `kill` then SIGKILLs + // any lingering processes so the next `start` enters a clean state + // without a generation transition, letting the activation succeed. if let Err(start_err) = run(&["start", &container]).await { tracing::warn!( container = %container, @@ -459,24 +483,42 @@ pub async fn rebuild_no_meta( Ok(()) } } else { - // Spawn path: create is atomic, no prebuild needed. - // See `docs/coordinator.md::Spawn path`. - on_step("nixos-container create"); + // First spawn: no running container, no downtime to shave. + // `nixos-container create` builds + creates atomically — if + // the build fails, no container record is left around to + // clean up — so a pre-build adds nothing but a duplicate + // eval. run(&["create", &container, "--flake", &flake_ref]).await?; set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?; set_resource_limits(&container)?; systemd_daemon_reload().await?; - on_step("nixos-container start"); run(&["start", &container]).await } } -/// Pre-build `system.build.toplevel` against `meta#` so the -/// subsequent `nixos-container update` finds the result cached and -/// skips straight to the profile-swap. Store-warming only — container -/// is untouched. See `docs/coordinator.md::Rebuild path` for why -/// the prebuild happens before stop, and `docs/coordinator.md::Prebuild -/// attr path` for why the explicit nixosConfigurations attr is required. +/// Pre-build the agent's `system.build.toplevel` derivation against +/// `meta#` so the subsequent `nixos-container update` / +/// `create` finds the result already in the store. The container +/// itself is untouched — this is purely a store-warming pass. +/// +/// Streams nix's stdout to INFO and stderr to WARN like the +/// `nixos-container` shellouts so progress shows up in journald as +/// it happens. `--no-link` keeps us from littering the working +/// directory with `result` symlinks. Per-derivation cost: pure +/// cache hit when nothing changed (handful of seconds for the +/// eval), expensive only on the rebuild that actually has work. +/// +/// Attr path is `#nixosConfigurations..config. +/// system.build.toplevel` — `nix build` won't auto-resolve the bare +/// `` against `nixosConfigurations` like `nixos-container` does +/// internally, so we have to spell the path out explicitly. Falling +/// back to `meta#` (the shape `nixos-container update --flake +/// meta#` uses) makes nix look for `packages..`, +/// `legacyPackages..`, or `` at the flake root — +/// none of which exist in the rendered meta flake. +/// +/// Returns the same error shape as the other nixos-container +/// helpers so callers can use `?` without translation. async fn prebuild_toplevel(name: &str, flake_ref: &str) -> Result<()> { use tokio::io::{AsyncBufReadExt, BufReader}; // Split `#` so we can re-emit with the explicit @@ -755,21 +797,12 @@ pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { } /// Public for the `InitConfig` approval path in `actions.rs` which seeds -/// dirs without calling the full `spawn`. Also creates the sibling `harness/` -/// dir so the first harness startup can write its sqlite files immediately. +/// dirs without calling the full `spawn`. pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> { if !notes_dir.exists() { std::fs::create_dir_all(notes_dir) .with_context(|| format!("create {}", notes_dir.display()))?; } - // Harness dir is a sibling of the agent-visible state dir. - if let Some(parent) = notes_dir.parent() { - let harness_dir = parent.join("harness"); - if !harness_dir.exists() { - std::fs::create_dir_all(&harness_dir) - .with_context(|| format!("create {}", harness_dir.display()))?; - } - } Ok(()) } @@ -1095,30 +1128,15 @@ fn set_nspawn_flags( shared = HOST_SHARED_ROOT, ); - // Per-agent state + harness dirs. Skipped for the manager — - // the `/agents` bind below already exposes both (along with - // every sub-agent's). For regular agents the harness dir is - // the sibling of notes_dir (same parent, "harness" subdir). + // Per-agent state at `/agents//state`. Skipped for + // the manager — the `/agents` bind below already exposes its + // own state (along with every sub-agent's). if container != MANAGER_NAME { let _ = write!( binds, " --bind={notes}:/agents/{agent_name}/state", notes = notes_dir.display(), ); - // Harness dir: sibling of notes_dir under the agent state root. - // systemd-nspawn refuses to start when the bind source is missing; - // ensure_state_dir already creates it, but be defensive here. - if let Some(parent) = notes_dir.parent() { - let harness_dir = parent.join("harness"); - if !harness_dir.exists() { - let _ = std::fs::create_dir_all(&harness_dir); - } - let _ = write!( - binds, - " --bind={harness}:/agents/{agent_name}/harness", - harness = harness_dir.display(), - ); - } } if container == MANAGER_NAME { // systemd-nspawn refuses to start a container whose bind @@ -1174,60 +1192,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(), - ); } + + // Per-agent socket subdir for the web UI. 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. Applies to both manager and sub-agents — the manager + // has its own per-agent web UI (terminal, inbox, stats) that routes + // through the gateway just like sub-agents. + // + // 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 each 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(), + ); let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\""); let mut lines: Vec = original .lines() From 4435666c0096354eeaf527a8b7e22a8d88e3bc31 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:29:46 +0200 Subject: [PATCH 2/7] fix(#947): include manager in agent-sockets.json --- hive-c0re/src/agent_sockets.rs | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/hive-c0re/src/agent_sockets.rs b/hive-c0re/src/agent_sockets.rs index 4ea40ed1..99cdae1d 100644 --- a/hive-c0re/src/agent_sockets.rs +++ b/hive-c0re/src/agent_sockets.rs @@ -38,7 +38,7 @@ pub const SOCKET_FILENAME: &str = "web.sock"; /// sub-agent that hasn't flipped the option yet. /// /// Renamed from `.bound` (legacy) to match the `hyperhive-` prefix -/// convention for all harness-written state files. `build_map` +/// convention for all harness-written state files (#838). `build_map` /// checks both names during the transition window so existing containers /// don't lose gateway routing before their next rebuild. pub const READY_MARKER: &str = "hyperhive-socket-bound"; @@ -67,12 +67,9 @@ pub fn socket_path_for(name: &str) -> PathBuf { } /// Compute the agent-socket map for the given logical agent names. -/// 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 +/// Includes manager and sub-agents — all managed containers that have +/// bound a unix socket get an entry. 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 @@ -105,7 +102,6 @@ where { names .iter() - .filter(|n| n.as_str() != MANAGER_NAME) .filter(|n| is_ready(n)) .map(|n| (n.clone(), socket_path_for(n))) .collect() @@ -241,18 +237,18 @@ mod tests { } #[test] - 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. + fn build_map_includes_manager() { + // Manager is now included in the gateway socket map so the + // gateway can route `/agent//` to its unix socket, + // giving the operator access to the manager's per-agent web UI + // (terminal, inbox, stats). All-ready predicate bypasses the + // marker check so we exercise the manager inclusion 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")); } From 0ac05f06384b9cccd3ab2da2f1ac672913cf3730 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:29:47 +0200 Subject: [PATCH 3/7] fix(#947): set HIVE_WEB_SOCKET for manager unconditionally --- nix/templates/harness-base.nix | 122 +++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 53 deletions(-) diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 9d098608..fa015c53 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -10,8 +10,8 @@ ... }: let - # Agent user metadata. `userName` defaults to `"agent"` when the - # meta-flake doesn't inject the per-agent override (stand-alone + # Agent user metadata (#658). `userName` defaults to `"agent"` when + # the meta-flake doesn't inject the per-agent override (stand-alone # `nixos-rebuild` against `nixosConfigurations.agent-base` works # without erroring on a missing per-agent name). `homeDir` derives # from `userName` to keep them coupled. @@ -29,7 +29,7 @@ in # only opts in from its own `agent.nix`. imports = [ ./weston-vnc.nix ]; - # Per-agent unix user the harness + co-process daemons run as. + # Per-agent unix user the harness + co-process daemons run as (#658). # Defaults to `"agent"` so a standalone evaluation (e.g. # `nix flake check` against `nixosConfigurations.agent-base`) builds # cleanly; the meta-flake's per-agent module rebinds this to the @@ -83,24 +83,26 @@ in When `true`, set `HIVE_WEB_SOCKET=/run/hive-agent/${userName}/web.sock` on the harness service env, which makes `web_ui::serve` bind a `UnixListener` at that path instead of the legacy TCP listener - on `HIVE_PORT`. + on `HIVE_PORT`. Closes the third hop of the #784 rollout: PR + #800 added the harness-side opt-in, #809 / #813 added the c0re + bind-mount + JSON-map plumbing, this is the per-agent flip + that activates the unix-domain path. Default `false` so an agent's web UI keeps binding TCP until the per-agent flip is explicit. Rollout shape: - 1. flip one canary agent to `true` via its `agent.nix`; + 1. flip one canary agent (atlas volunteered) to `true` via its + `agent.nix` once #813 lands; 2. validate the gateway's `proxy_pass http://unix:.../web.sock` - end-to-end against that canary; + end-to-end against that canary (atlas's step 3); 3. flip remaining agents per-agent as the gateway side soaks; - 4. eventually drop this option once every agent is on unix and - the TCP fallback is removed from the harness. + 4. eventually drop this option once every agent's on unix + + atlas's gateway is the only path — step 4 of #784 drops the + harness's TCP fallback at the same time. - 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). + Sub-agents only: the manager always has its unix socket set + unconditionally in the `isManager` block below, so this toggle + has no effect when `hyperhive.role = "manager"`. ''; }; @@ -123,6 +125,9 @@ in it's exposed so a standalone `nixos-rebuild` against `nixosConfigurations.manager` keeps working without the meta-flake wrapper around it. + + Closes #671: harness + manager templates merged into a + single `harness-base.nix` driven by this option. ''; }; @@ -259,8 +264,8 @@ in type = lib.types.bool; default = true; description = '' - Enable per-agent matrix integration via `hive-matrix-mcp`. - When true (the default), the harness: + Enable per-agent matrix integration via `hive-matrix-mcp` + (#548 phase 3). When true (the default), the harness: - runs `hive-matrix-daemon` as a systemd unit that holds a matrix-sdk Client + sync against the homeserver at @@ -268,8 +273,8 @@ in in-host tuwunel from `nix/modules/hive-matrix.nix`). The daemon auto-skips when `/matrix-token` is missing, and a `systemd.paths` watcher restarts it the moment - hive-c0re provisions the token (same path-trigger shape - as `matrix-avatar-sync`). + hive-c0re provisions the token (mirrors `matrix-avatar-sync` + shape from #571). - exposes the matrix tool surface (send_message, send_dm, send_reaction, send_reply, mark_read, list_rooms, list_room_members, read_room) to claude via an auto-injected @@ -569,8 +574,9 @@ in # all contributions across modules into one file. Loaded via # `$BASH_ENV` for non-interactive shells (claude's `Bash` tool # runs `bash -c`) and via `programs.bash.interactiveShellInit` - # for interactive shells. Generic by design so future hooks - # don't need to rename this file or invent a parallel dispatcher. + # for interactive shells. Generic by design (mara on #779) so + # future hooks don't need to either rename this file or invent + # a parallel dispatcher. options.hyperhive._bashEnvFragments = lib.mkOption { type = lib.types.lines; default = ""; @@ -597,7 +603,7 @@ in anything else) invokes `cargo` inside this container. Saves tokens + context — the verbose default output floods the response window with per-crate progress lines that - carry no signal beyond the warning/error summary. + carry no signal beyond the warning/error summary (#777). Implementation: contributes a `cargo` shell function to `/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`). @@ -693,12 +699,18 @@ in } ]; - # Per-agent unix user. Runs the hive-ag3nt / hive-m1nd harness + - # co-process daemons under a non-root principal. UID auto-assigned by - # NixOS. The container activation script (hive-agent-user-migrate) - # chowns the bind-mounted state dir — including credential files - # written by hive-c0re before the container was built — to this user - # on every boot, so agent processes can always read their own tokens. + # Per-agent unix user (#658). Runs the hive-ag3nt / hive-m1nd + # harness + co-process daemons (hive-matrix-daemon) under a + # non-root principal. The user name follows + # `hyperhive.user.name` — defaults to `"agent"` for standalone + # eval, overridden per-agent by the meta-flake to the agent's + # own label so each container has a uniquely-named user. + # + # UID auto-assigned by NixOS (per mara's #8109: "no hardcoded + # uids"). Home is `/home/${userName}`. `wheel` membership + + # the sudoers rule below grants `NOPASSWD: ALL` when + # `passwordlessSudo` is true — same blast radius as the + # previous root-by-default shape, just explicit. users.users.${userName} = { isNormalUser = true; home = homeDir; @@ -733,10 +745,10 @@ in } ]; - # First-boot migration to the per-agent unix user — creates the - # home dir, chowns the bind-mounted state + `~/.claude/`, and - # (marker-guarded) moves any leftover `/root/.claude` content - # from the previous root-run shape. See + # Post-#658 first-boot migration to the per-agent unix user — + # creates the home dir, chowns the bind-mounted state + + # `~/.claude/`, and (marker-guarded) moves any leftover + # `/root/.claude` content from the pre-#658 root-run shape. See # `docs/persistence.md::First-boot agent-user migration` for the # step-by-step rationale; this script implements it. system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] '' @@ -763,8 +775,8 @@ in fi ''; - # Auto-inject the matrix MCP entry when matrix is enabled. - # Operator can override or disable by setting their own + # Auto-inject the matrix MCP entry when matrix is enabled (#548 + # phase 3). Operator can override or disable by setting their own # `extraMcpServers.matrix` (nix submodule merge takes the operator's # value) or by flipping `hyperhive.matrix.enable = false`. hyperhive.extraMcpServers = lib.mkIf config.hyperhive.matrix.enable { @@ -772,9 +784,9 @@ in command = "${pkgs.hyperhive}/bin/hive-matrix-mcp"; args = [ ]; # Same socket path the hive-matrix-daemon service binds - # via its `RuntimeDirectory = "hive-matrix"`. Keeps the - # bridge + daemon in sync without baking the path into - # the Rust default — the env override wins for both. + # via its `RuntimeDirectory = "hive-matrix"` (#658). Keeps + # the bridge + daemon in sync without baking the new path + # into the Rust default — the env override wins for both. env.HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; allowedTools = [ "*" ]; }; @@ -789,18 +801,18 @@ in source = config.hyperhive.icon; }; - # Cargo `--message-format short` injector. Contributes a `cargo` - # shell function to `hyperhive._bashEnvFragments`; the bash-env - # infrastructure below packages that into a single file sourced - # by both non-interactive and interactive shells. + # Cargo `--message-format short` injector (#777). Contributes a + # `cargo` shell function to `hyperhive._bashEnvFragments`; the + # bash-env infrastructure below packages that into a single file + # sourced by both non-interactive and interactive shells. # `command cargo …` falls back to the un-wrapped binary in PATH # (the rust toolchain's cargo — either from `environment.systemPackages` # or from whatever `nix develop` shell the agent's working in). hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages '' # Auto-injects --message-format short on cargo compile # subcommands so per-crate progress lines don't flood - # claude's context. Bypassed when the caller already passes - # --message-format (any form). + # claude's context (#777). Bypassed when the caller + # already passes --message-format (any form). cargo() { # Strip leading +toolchain selectors (cargo +nightly …). local pre=() @@ -906,7 +918,7 @@ in # feature hook's snippet into scope without touching # `/etc/profile` (login-only). Interactive shells source the # same file via the `interactiveShellInit` hook below so - # behaviour matches across both modes. + # behaviour matches across both modes (#777). BASH_ENV = "/etc/hyperhive/bash-env.sh"; }; @@ -1176,9 +1188,9 @@ in fi TOKEN=$(cat "$TOKEN_FILE") # Local tuwunel reachable on shared host netns at the - # default matrix-spec port. Override via - # `hyperhive.matrix.url` if the operator runs the - # homeserver elsewhere. + # default matrix-spec port. Override via the future + # `hyperhive.matrix.url` if the operator ever runs the + # homeserver elsewhere (deferred to #548 phase 4). MATRIX_URL=http://localhost:8008 # whoami → user_id. Needed to scope the avatar set call. # Tolerant of the homeserver being unreachable (`-f` makes @@ -1311,13 +1323,13 @@ in HIVE_ROLE = config.hyperhive.role; } // lib.optionalAttrs config.hyperhive.web.useUnixSocket { - # Per-agent unix-socket path for the web UI. When set, - # the harness's `web_ui::serve` binds a `UnixListener` - # at this path instead of TCP. Path matches - # `hive_c0re::agent_sockets::socket_path_for(name)` so - # the lifecycle bind-mount and the gateway's upstream - # config all derive from the same canonical - # `/run/hive-agent//web.sock` shape. + # Per-agent unix-socket flip for the web UI (#784 phase 2 + # step 2c). When set, the harness's `web_ui::serve` binds + # a `UnixListener` at this path instead of TCP. Path + # matches `hive_c0re::agent_sockets::socket_path_for(name)` + # so the lifecycle bind-mount (#813) and the gateway's + # upstream config all derive from the same canonical + # `/run/hive-agent//web.sock` shape — no triangulation. HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; } // lib.optionalAttrs isManager { @@ -1325,6 +1337,10 @@ in # HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100. HIVE_PORT = "8875"; HIVE_LABEL = "hm1nd"; + # Manager always uses a unix socket for its web UI so the + # gateway can route /agent// to it the same way it + # routes sub-agents. Path mirrors agent_sockets::socket_path_for. + HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; From 7af29b3249e1188ecb831c5e54d1131240a953e9 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:37:07 +0200 Subject: [PATCH 4/7] fix(#947): extend socket-dir bind to manager container --- hive-c0re/src/lifecycle.rs | 126 ++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 72 deletions(-) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 7b44eb74..6da1fd82 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -354,6 +354,7 @@ pub async fn rebuild( dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, + on_step: &(dyn Fn(&str) + Send + Sync), ) -> Result<()> { // Sync the meta flake (idempotent — no-op when the rendered // flake matches disk) so a manual rebuild from the dashboard @@ -373,7 +374,7 @@ pub async fn rebuild( // `applied//main` currently points at (deployed/). // Commits the lock if it changed. crate::meta::lock_update_for_rebuild(name).await?; - rebuild_no_meta(name, agent_dir, applied_dir, claude_dir, notes_dir).await + rebuild_no_meta(name, agent_dir, applied_dir, claude_dir, notes_dir, on_step).await } /// Container-level rebuild without touching the meta repo. Callers @@ -381,12 +382,18 @@ pub async fn rebuild( /// drives meta through the two-phase prepare/finalize/abort flow) /// use this directly. Public `rebuild` wraps it with idempotent meta /// sync + lock-bump-and-commit. +/// +/// `on_step` is called at each phase boundary with a short human-readable +/// label so callers can surface progress (e.g. update the rebuild-queue +/// step shown in the dashboard). Pass `&|_| ()` when progress reporting +/// is not needed. pub async fn rebuild_no_meta( name: &str, agent_dir: &Path, applied_dir: &Path, claude_dir: &Path, notes_dir: &Path, + on_step: &(dyn Fn(&str) + Send + Sync), ) -> Result<()> { validate(name)?; if let Some(other) = port_collision(name).await { @@ -401,55 +408,24 @@ pub async fn rebuild_no_meta( let container = container_name(name); let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display()); if container_exists(name).await { - // Existing container: preserve the prior running state across - // rebuild, and apply both the new system profile - // AND any `/etc/nixos-containers/.conf` / drop-in changes - // in a single start rather than `update`'s reload-then-outer- - // restart double-bounce. - // - // `nixos-container update` only runs `systemctl reload - // container@` when the container is up (per the - // `isContainerRunning` check in nixos-container.pl), so - // stopping first makes `update` boot-style: build + nix-env - // --set the new profile, skip the in-container - // switch-to-configuration, let the next `start` apply both - // the new profile and the new EXTRA_NSPAWN_FLAGS in one go. - // If the container was already stopped, `update` builds + sets - // the profile and we leave it stopped. + // Rebuild strategy: stop-before-update + pre-build. + // See `docs/coordinator.md::Container lifecycle`. let was_running = is_running(name).await; set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?; set_resource_limits(&container)?; systemd_daemon_reload().await?; if was_running { - // Pre-build the system toplevel **before** stopping the - // running container so the agent keeps serving its - // previous generation while the eval + fetch + build - // happens out-of-band. `nixos-container update` then - // finds the toplevel cached and skips straight to the - // profile-swap + restart — downtime collapses to that - // window only. Build failures surface here, before we - // touch the container. - // - // When the container is already stopped there's no - // downtime to shave — let `update` do the build inline - // rather than evaluating the flake twice for nothing. + on_step("nix build"); prebuild_toplevel(name, &flake_ref).await?; + on_step("nixos-container stop"); run(&["stop", &container]).await?; } + on_step("nixos-container update"); run(&["update", &container, "--flake", &flake_ref]).await?; if was_running { - // Normal path: start into the new generation. The activation - // script runs inside the container to transition old → new. - // This can fail when packages are removed between generations — - // the old-generation activation references units that no longer - // exist in the new closure, causing systemd to exit non-zero. - // - // Fallback: stop + kill + start (cold-start). The activation - // script can fail when packages are removed between generations — - // `start` exits non-zero but the container may be half-started. - // `stop` requests a graceful SIGTERM drain; `kill` then SIGKILLs - // any lingering processes so the next `start` enters a clean state - // without a generation transition, letting the activation succeed. + // Cold-start fallback on activation errors. + // See `docs/coordinator.md::Cold-start fallback`. + on_step("nixos-container start"); if let Err(start_err) = run(&["start", &container]).await { tracing::warn!( container = %container, @@ -483,42 +459,24 @@ pub async fn rebuild_no_meta( Ok(()) } } else { - // First spawn: no running container, no downtime to shave. - // `nixos-container create` builds + creates atomically — if - // the build fails, no container record is left around to - // clean up — so a pre-build adds nothing but a duplicate - // eval. + // Spawn path: create is atomic, no prebuild needed. + // See `docs/coordinator.md::Spawn path`. + on_step("nixos-container create"); run(&["create", &container, "--flake", &flake_ref]).await?; set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?; set_resource_limits(&container)?; systemd_daemon_reload().await?; + on_step("nixos-container start"); run(&["start", &container]).await } } -/// Pre-build the agent's `system.build.toplevel` derivation against -/// `meta#` so the subsequent `nixos-container update` / -/// `create` finds the result already in the store. The container -/// itself is untouched — this is purely a store-warming pass. -/// -/// Streams nix's stdout to INFO and stderr to WARN like the -/// `nixos-container` shellouts so progress shows up in journald as -/// it happens. `--no-link` keeps us from littering the working -/// directory with `result` symlinks. Per-derivation cost: pure -/// cache hit when nothing changed (handful of seconds for the -/// eval), expensive only on the rebuild that actually has work. -/// -/// Attr path is `#nixosConfigurations..config. -/// system.build.toplevel` — `nix build` won't auto-resolve the bare -/// `` against `nixosConfigurations` like `nixos-container` does -/// internally, so we have to spell the path out explicitly. Falling -/// back to `meta#` (the shape `nixos-container update --flake -/// meta#` uses) makes nix look for `packages..`, -/// `legacyPackages..`, or `` at the flake root — -/// none of which exist in the rendered meta flake. -/// -/// Returns the same error shape as the other nixos-container -/// helpers so callers can use `?` without translation. +/// Pre-build `system.build.toplevel` against `meta#` so the +/// subsequent `nixos-container update` finds the result cached and +/// skips straight to the profile-swap. Store-warming only — container +/// is untouched. See `docs/coordinator.md::Rebuild path` for why +/// the prebuild happens before stop, and `docs/coordinator.md::Prebuild +/// attr path` for why the explicit nixosConfigurations attr is required. async fn prebuild_toplevel(name: &str, flake_ref: &str) -> Result<()> { use tokio::io::{AsyncBufReadExt, BufReader}; // Split `#` so we can re-emit with the explicit @@ -797,12 +755,21 @@ pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { } /// Public for the `InitConfig` approval path in `actions.rs` which seeds -/// dirs without calling the full `spawn`. +/// dirs without calling the full `spawn`. Also creates the sibling `harness/` +/// dir so the first harness startup can write its sqlite files immediately. pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> { if !notes_dir.exists() { std::fs::create_dir_all(notes_dir) .with_context(|| format!("create {}", notes_dir.display()))?; } + // Harness dir is a sibling of the agent-visible state dir. + if let Some(parent) = notes_dir.parent() { + let harness_dir = parent.join("harness"); + if !harness_dir.exists() { + std::fs::create_dir_all(&harness_dir) + .with_context(|| format!("create {}", harness_dir.display()))?; + } + } Ok(()) } @@ -1128,15 +1095,30 @@ fn set_nspawn_flags( shared = HOST_SHARED_ROOT, ); - // Per-agent state at `/agents//state`. Skipped for - // the manager — the `/agents` bind below already exposes its - // own state (along with every sub-agent's). + // Per-agent state + harness dirs. Skipped for the manager — + // the `/agents` bind below already exposes both (along with + // every sub-agent's). For regular agents the harness dir is + // the sibling of notes_dir (same parent, "harness" subdir). if container != MANAGER_NAME { let _ = write!( binds, " --bind={notes}:/agents/{agent_name}/state", notes = notes_dir.display(), ); + // Harness dir: sibling of notes_dir under the agent state root. + // systemd-nspawn refuses to start when the bind source is missing; + // ensure_state_dir already creates it, but be defensive here. + if let Some(parent) = notes_dir.parent() { + let harness_dir = parent.join("harness"); + if !harness_dir.exists() { + let _ = std::fs::create_dir_all(&harness_dir); + } + let _ = write!( + binds, + " --bind={harness}:/agents/{agent_name}/harness", + harness = harness_dir.display(), + ); + } } if container == MANAGER_NAME { // systemd-nspawn refuses to start a container whose bind From e7a5718e83c022296feff8d52a348010450e3d6d Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:44:53 +0200 Subject: [PATCH 5/7] fix(947): strip prose/issue-tags from agent_sockets.rs --- hive-c0re/src/agent_sockets.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/hive-c0re/src/agent_sockets.rs b/hive-c0re/src/agent_sockets.rs index 99cdae1d..9a715f36 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. Manager -//! excluded from the map (manager UI routes via the dashboard -//! upstream, not per-agent `/agent//`). +//! 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. //! //! Full mechanism — per-agent subdir bind-mount, `hyperhive-socket-bound` //! marker gate, gateway UDS upstream, transition vs `agent-ports.json`, @@ -38,7 +38,7 @@ pub const SOCKET_FILENAME: &str = "web.sock"; /// sub-agent that hasn't flipped the option yet. /// /// Renamed from `.bound` (legacy) to match the `hyperhive-` prefix -/// convention for all harness-written state files (#838). `build_map` +/// convention for all harness-written state files. `build_map` /// checks both names during the transition window so existing containers /// don't lose gateway routing before their next rebuild. pub const READY_MARKER: &str = "hyperhive-socket-bound"; @@ -67,11 +67,9 @@ pub fn socket_path_for(name: &str) -> PathBuf { } /// Compute the agent-socket map for the given logical agent names. -/// Includes manager and sub-agents — all managed containers that have -/// bound a unix socket get an entry. 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 +/// 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 /// `proxy_pass` to a non-existent socket for every sub-agent that /// hasn't yet flipped `hyperhive.web.useUnixSocket = true`. /// @@ -238,11 +236,6 @@ mod tests { #[test] fn build_map_includes_manager() { - // Manager is now included in the gateway socket map so the - // gateway can route `/agent//` to its unix socket, - // giving the operator access to the manager's per-agent web UI - // (terminal, inbox, stats). All-ready predicate bypasses the - // marker check so we exercise the manager inclusion in isolation. let names: Vec = ["iris", MANAGER_NAME, "argus"] .iter() .map(|s| (*s).to_owned()) @@ -339,3 +332,4 @@ mod tests { assert!(body.contains("\"/run/hive-agent/iris/web.sock\"")); } } + From 2f25131403928b0c5bad2baa5583c1b0c9d9ed94 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:44:57 +0200 Subject: [PATCH 6/7] fix(947): strip prose/issue-tags from harness-base.nix --- nix/templates/harness-base.nix | 119 ++++++++++++++------------------- 1 file changed, 52 insertions(+), 67 deletions(-) diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index fa015c53..53ffc9f1 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -10,8 +10,8 @@ ... }: let - # Agent user metadata (#658). `userName` defaults to `"agent"` when - # the meta-flake doesn't inject the per-agent override (stand-alone + # Agent user metadata. `userName` defaults to `"agent"` when the + # meta-flake doesn't inject the per-agent override (stand-alone # `nixos-rebuild` against `nixosConfigurations.agent-base` works # without erroring on a missing per-agent name). `homeDir` derives # from `userName` to keep them coupled. @@ -29,7 +29,7 @@ in # only opts in from its own `agent.nix`. imports = [ ./weston-vnc.nix ]; - # Per-agent unix user the harness + co-process daemons run as (#658). + # Per-agent unix user the harness + co-process daemons run as. # Defaults to `"agent"` so a standalone evaluation (e.g. # `nix flake check` against `nixosConfigurations.agent-base`) builds # cleanly; the meta-flake's per-agent module rebinds this to the @@ -83,25 +83,20 @@ in When `true`, set `HIVE_WEB_SOCKET=/run/hive-agent/${userName}/web.sock` on the harness service env, which makes `web_ui::serve` bind a `UnixListener` at that path instead of the legacy TCP listener - on `HIVE_PORT`. Closes the third hop of the #784 rollout: PR - #800 added the harness-side opt-in, #809 / #813 added the c0re - bind-mount + JSON-map plumbing, this is the per-agent flip - that activates the unix-domain path. + on `HIVE_PORT`. Default `false` so an agent's web UI keeps binding TCP until the per-agent flip is explicit. Rollout shape: - 1. flip one canary agent (atlas volunteered) to `true` via its - `agent.nix` once #813 lands; + 1. flip one canary agent to `true` via its `agent.nix`; 2. validate the gateway's `proxy_pass http://unix:.../web.sock` - end-to-end against that canary (atlas's step 3); + end-to-end against that canary; 3. flip remaining agents per-agent as the gateway side soaks; - 4. eventually drop this option once every agent's on unix + - atlas's gateway is the only path — step 4 of #784 drops the - harness's TCP fallback at the same time. + 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 its unix socket set - unconditionally in the `isManager` block below, so this toggle + 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"`. ''; }; @@ -125,9 +120,6 @@ in it's exposed so a standalone `nixos-rebuild` against `nixosConfigurations.manager` keeps working without the meta-flake wrapper around it. - - Closes #671: harness + manager templates merged into a - single `harness-base.nix` driven by this option. ''; }; @@ -264,8 +256,8 @@ in type = lib.types.bool; default = true; description = '' - Enable per-agent matrix integration via `hive-matrix-mcp` - (#548 phase 3). When true (the default), the harness: + Enable per-agent matrix integration via `hive-matrix-mcp`. + When true (the default), the harness: - runs `hive-matrix-daemon` as a systemd unit that holds a matrix-sdk Client + sync against the homeserver at @@ -273,8 +265,8 @@ in in-host tuwunel from `nix/modules/hive-matrix.nix`). The daemon auto-skips when `/matrix-token` is missing, and a `systemd.paths` watcher restarts it the moment - hive-c0re provisions the token (mirrors `matrix-avatar-sync` - shape from #571). + hive-c0re provisions the token (same path-trigger shape + as `matrix-avatar-sync`). - exposes the matrix tool surface (send_message, send_dm, send_reaction, send_reply, mark_read, list_rooms, list_room_members, read_room) to claude via an auto-injected @@ -574,9 +566,8 @@ in # all contributions across modules into one file. Loaded via # `$BASH_ENV` for non-interactive shells (claude's `Bash` tool # runs `bash -c`) and via `programs.bash.interactiveShellInit` - # for interactive shells. Generic by design (mara on #779) so - # future hooks don't need to either rename this file or invent - # a parallel dispatcher. + # for interactive shells. Generic by design so future hooks + # don't need to rename this file or invent a parallel dispatcher. options.hyperhive._bashEnvFragments = lib.mkOption { type = lib.types.lines; default = ""; @@ -603,7 +594,7 @@ in anything else) invokes `cargo` inside this container. Saves tokens + context — the verbose default output floods the response window with per-crate progress lines that - carry no signal beyond the warning/error summary (#777). + carry no signal beyond the warning/error summary. Implementation: contributes a `cargo` shell function to `/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`). @@ -699,18 +690,12 @@ in } ]; - # Per-agent unix user (#658). Runs the hive-ag3nt / hive-m1nd - # harness + co-process daemons (hive-matrix-daemon) under a - # non-root principal. The user name follows - # `hyperhive.user.name` — defaults to `"agent"` for standalone - # eval, overridden per-agent by the meta-flake to the agent's - # own label so each container has a uniquely-named user. - # - # UID auto-assigned by NixOS (per mara's #8109: "no hardcoded - # uids"). Home is `/home/${userName}`. `wheel` membership + - # the sudoers rule below grants `NOPASSWD: ALL` when - # `passwordlessSudo` is true — same blast radius as the - # previous root-by-default shape, just explicit. + # Per-agent unix user. Runs the hive-ag3nt / hive-m1nd harness + + # co-process daemons under a non-root principal. UID auto-assigned by + # NixOS. The container activation script (hive-agent-user-migrate) + # chowns the bind-mounted state dir — including credential files + # written by hive-c0re before the container was built — to this user + # on every boot, so agent processes can always read their own tokens. users.users.${userName} = { isNormalUser = true; home = homeDir; @@ -745,10 +730,10 @@ in } ]; - # Post-#658 first-boot migration to the per-agent unix user — - # creates the home dir, chowns the bind-mounted state + - # `~/.claude/`, and (marker-guarded) moves any leftover - # `/root/.claude` content from the pre-#658 root-run shape. See + # First-boot migration to the per-agent unix user — creates the + # home dir, chowns the bind-mounted state + `~/.claude/`, and + # (marker-guarded) moves any leftover `/root/.claude` content + # from the previous root-run shape. See # `docs/persistence.md::First-boot agent-user migration` for the # step-by-step rationale; this script implements it. system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] '' @@ -775,8 +760,8 @@ in fi ''; - # Auto-inject the matrix MCP entry when matrix is enabled (#548 - # phase 3). Operator can override or disable by setting their own + # Auto-inject the matrix MCP entry when matrix is enabled. + # Operator can override or disable by setting their own # `extraMcpServers.matrix` (nix submodule merge takes the operator's # value) or by flipping `hyperhive.matrix.enable = false`. hyperhive.extraMcpServers = lib.mkIf config.hyperhive.matrix.enable { @@ -784,9 +769,9 @@ in command = "${pkgs.hyperhive}/bin/hive-matrix-mcp"; args = [ ]; # Same socket path the hive-matrix-daemon service binds - # via its `RuntimeDirectory = "hive-matrix"` (#658). Keeps - # the bridge + daemon in sync without baking the new path - # into the Rust default — the env override wins for both. + # via its `RuntimeDirectory = "hive-matrix"`. Keeps the + # bridge + daemon in sync without baking the path into + # the Rust default — the env override wins for both. env.HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; allowedTools = [ "*" ]; }; @@ -801,18 +786,18 @@ in source = config.hyperhive.icon; }; - # Cargo `--message-format short` injector (#777). Contributes a - # `cargo` shell function to `hyperhive._bashEnvFragments`; the - # bash-env infrastructure below packages that into a single file - # sourced by both non-interactive and interactive shells. + # Cargo `--message-format short` injector. Contributes a `cargo` + # shell function to `hyperhive._bashEnvFragments`; the bash-env + # infrastructure below packages that into a single file sourced + # by both non-interactive and interactive shells. # `command cargo …` falls back to the un-wrapped binary in PATH # (the rust toolchain's cargo — either from `environment.systemPackages` # or from whatever `nix develop` shell the agent's working in). hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages '' # Auto-injects --message-format short on cargo compile # subcommands so per-crate progress lines don't flood - # claude's context (#777). Bypassed when the caller - # already passes --message-format (any form). + # claude's context. Bypassed when the caller already passes + # --message-format (any form). cargo() { # Strip leading +toolchain selectors (cargo +nightly …). local pre=() @@ -918,7 +903,7 @@ in # feature hook's snippet into scope without touching # `/etc/profile` (login-only). Interactive shells source the # same file via the `interactiveShellInit` hook below so - # behaviour matches across both modes (#777). + # behaviour matches across both modes. BASH_ENV = "/etc/hyperhive/bash-env.sh"; }; @@ -1188,9 +1173,9 @@ in fi TOKEN=$(cat "$TOKEN_FILE") # Local tuwunel reachable on shared host netns at the - # default matrix-spec port. Override via the future - # `hyperhive.matrix.url` if the operator ever runs the - # homeserver elsewhere (deferred to #548 phase 4). + # default matrix-spec port. Override via + # `hyperhive.matrix.url` if the operator runs the + # homeserver elsewhere. MATRIX_URL=http://localhost:8008 # whoami → user_id. Needed to scope the avatar set call. # Tolerant of the homeserver being unreachable (`-f` makes @@ -1323,13 +1308,13 @@ in HIVE_ROLE = config.hyperhive.role; } // lib.optionalAttrs config.hyperhive.web.useUnixSocket { - # Per-agent unix-socket flip for the web UI (#784 phase 2 - # step 2c). When set, the harness's `web_ui::serve` binds - # a `UnixListener` at this path instead of TCP. Path - # matches `hive_c0re::agent_sockets::socket_path_for(name)` - # so the lifecycle bind-mount (#813) and the gateway's - # upstream config all derive from the same canonical - # `/run/hive-agent//web.sock` shape — no triangulation. + # Per-agent unix-socket path for the web UI. When set, + # the harness's `web_ui::serve` binds a `UnixListener` + # at this path instead of TCP. Path matches + # `hive_c0re::agent_sockets::socket_path_for(name)` so + # the lifecycle bind-mount and the gateway's upstream + # config all derive from the same canonical + # `/run/hive-agent//web.sock` shape. HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; } // lib.optionalAttrs isManager { @@ -1337,9 +1322,8 @@ in # HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100. HIVE_PORT = "8875"; HIVE_LABEL = "hm1nd"; - # Manager always uses a unix socket for its web UI so the - # gateway can route /agent// to it the same way it - # routes sub-agents. Path mirrors agent_sockets::socket_path_for. + # 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 = { @@ -1359,3 +1343,4 @@ in system.stateVersion = "25.11"; }; } + From eb108f9dd8e0b6ab8d06fcdeae99684fdf8f8fbb Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:46:07 +0200 Subject: [PATCH 7/7] fix(947): trim prose in lifecycle.rs socket-dir block --- hive-c0re/src/lifecycle.rs | 48 ++++++++------------------------------ 1 file changed, 10 insertions(+), 38 deletions(-) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 6da1fd82..69178473 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1175,53 +1175,24 @@ fn set_nspawn_flags( let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config"); } - - // Per-agent socket subdir for the web UI. 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. Applies to both manager and sub-agents — the manager - // has its own per-agent web UI (terminal, inbox, stats) that routes - // through the gateway just like sub-agents. - // - // 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 each 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. + // 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 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. + // 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 {} (uid lookup failed)", socket_dir.display()) - })?; + .with_context(|| format!("chmod 0777 {}", socket_dir.display()))?; } let _ = write!( binds, @@ -1469,3 +1440,4 @@ mod tests { ); } } +