From 0bec3af933d8e26c0a8649d3fab4acb0cd3632c6 Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 2 Jun 2026 22:14:07 +0200 Subject: [PATCH] feat(#702): drop hive-c0re from root to hive-core user (privsep phase 2) --- hive-c0re/src/lifecycle.rs | 360 +++++++++++-------------------------- nix/modules/hive-c0re.nix | 33 +++- 2 files changed, 130 insertions(+), 263 deletions(-) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 973d1323..8ff68199 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -3,6 +3,7 @@ use std::path::Path; use anyhow::{Context, Result, bail}; +use hive_sh4re::priv_proto::BindMount; use tokio::process::Command; /// Sub-agent container prefix. `nixos-container` caps the total container name @@ -206,12 +207,11 @@ pub async fn spawn( ) .await?; let container = container_name(name); - let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display()); - run(&["create", &container, "--flake", &flake_ref]).await?; - set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?; - set_resource_limits(&container)?; + priv_run("create", name).await?; + set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir).await?; + set_resource_limits(&container).await?; systemd_daemon_reload().await?; - run(&["start", &container]).await + priv_run("start", name).await } /// Build the `AgentSpec` list for the meta flake from `nixos-container @@ -280,14 +280,12 @@ pub async fn container_exists(name: &str) -> bool { pub async fn kill(name: &str) -> Result<()> { validate(name)?; - let container = container_name(name); - run(&["stop", &container]).await + priv_run("stop", name).await } pub async fn start(name: &str) -> Result<()> { validate(name)?; - let container = container_name(name); - run(&["start", &container]).await + priv_run("start", name).await } /// Stop + start without regenerating any config. For "kick the container" @@ -317,12 +315,12 @@ pub async fn destroy(name: &str) -> Result<()> { let container = container_name(name); // nixos-container destroy handles stop + removal of /var/lib/nixos-containers/ // and /etc/nixos-containers/.conf. Tolerate "no such container". - if let Err(e) = run(&["destroy", &container]).await { + if let Err(e) = priv_run("destroy", name).await { tracing::warn!(error = ?e, "nixos-container destroy returned an error; continuing cleanup"); } - let dropin_dir = format!("/run/systemd/system/container@{container}.service.d"); - if std::path::Path::new(&dropin_dir).exists() { - std::fs::remove_dir_all(&dropin_dir).with_context(|| format!("remove {dropin_dir}"))?; + // Remove the systemd resource-limits drop-in via hive-priv. + if let Err(e) = crate::priv_client::remove_service_dropin(&container).await { + tracing::warn!(error = ?e, "remove service drop-in failed (non-fatal)"); } Ok(()) } @@ -399,43 +397,43 @@ pub async fn rebuild_no_meta( // 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)?; + set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir).await?; + set_resource_limits(&container).await?; systemd_daemon_reload().await?; if was_running { on_step("nix build"); prebuild_toplevel(name, &flake_ref).await?; on_step("nixos-container stop"); - run(&["stop", &container]).await?; + priv_run("stop", name).await?; } on_step("nixos-container update"); - run(&["update", &container, "--flake", &flake_ref]).await?; + priv_run("update", name).await?; if was_running { // 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 { + if let Err(start_err) = priv_run("start", name).await { tracing::warn!( container = %container, error = %start_err, "start after rebuild failed (possible activation error); \ retrying via stop + kill + start" ); - run(&["stop", &container]).await.unwrap_or_else(|e| { + priv_run("stop", name).await.unwrap_or_else(|e| { tracing::warn!( container = %container, error = %e, "stop before cold-start retry failed (ignored)" ); }); - run(&["kill", &container]).await.unwrap_or_else(|e| { + priv_run("kill", name).await.unwrap_or_else(|e| { tracing::warn!( container = %container, error = %e, "kill before cold-start retry failed (ignored)" ); }); - run(&["start", &container]).await.map_err(|e| { + priv_run("start", name).await.map_err(|e| { anyhow::anyhow!( "cold-start fallback also failed: {e:#} \ (original start error: {start_err:#})" @@ -451,12 +449,12 @@ pub async fn rebuild_no_meta( // 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)?; + priv_run("create", name).await?; + set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir).await?; + set_resource_limits(&container).await?; systemd_daemon_reload().await?; on_step("nixos-container start"); - run(&["start", &container]).await + priv_run("start", name).await } } @@ -570,21 +568,8 @@ async fn prebuild_toplevel(name: &str, flake_ref: &str) -> Result<()> { } pub async fn list() -> Result> { - let out = Command::new("nixos-container") - .arg("list") - .output() - .await - .context("invoke nixos-container list")?; - if !out.status.success() { - bail!( - "nixos-container list exited with status {}: {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(String::from_utf8_lossy(&out.stdout) - .lines() - .map(str::trim) + let stdout = crate::priv_client::list_containers().await?; + Ok(stdout.lines().map(str::trim) .filter(|line| line.starts_with(AGENT_PREFIX)) .map(str::to_owned) .collect()) @@ -983,36 +968,12 @@ pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<( /// Write a systemd drop-in for `container@.service` that applies /// our default resource caps. Goes under `/run/systemd/system/...` so it's /// ephemeral (regenerated on every spawn / rebuild). -fn set_resource_limits(container: &str) -> Result<()> { - let dir = format!("/run/systemd/system/container@{container}.service.d"); - std::fs::create_dir_all(&dir).with_context(|| format!("create {dir}"))?; - let path = format!("{dir}/hyperhive-limits.conf"); - let content = - format!("[Service]\nMemoryMax={DEFAULT_MEMORY_MAX}\nCPUQuota={DEFAULT_CPU_QUOTA}\n"); - std::fs::write(&path, content).with_context(|| format!("write {path}"))?; - tracing::info!( - %path, - memory_max = DEFAULT_MEMORY_MAX, - cpu_quota = DEFAULT_CPU_QUOTA, - "wrote resource limits drop-in" - ); - Ok(()) +async fn set_resource_limits(container: &str) -> Result<()> { + crate::priv_client::write_resource_limits(container, DEFAULT_MEMORY_MAX, DEFAULT_CPU_QUOTA).await } async fn systemd_daemon_reload() -> Result<()> { - let out = Command::new("systemctl") - .arg("daemon-reload") - .output() - .await - .context("invoke systemctl daemon-reload")?; - if !out.status.success() { - bail!( - "systemctl daemon-reload failed ({}): {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(()) + crate::priv_client::daemon_reload().await } /// Idempotently rewrite the lines in `/etc/nixos-containers/.conf` @@ -1052,35 +1013,28 @@ const HOST_SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; /// `binds`. All three are RW so the parent can read/write state and /// submit config-change requests. Creates missing host-side directories /// so nspawn doesn't refuse to start; missing dirs are non-fatal. -fn bind_child_agent_dirs(child: &str, binds: &mut String) { - use std::fmt::Write as _; +fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { let state_dir = format!("{HOST_AGENTS_ROOT}/{child}/state"); let harness_dir = format!("{HOST_AGENTS_ROOT}/{child}/harness"); let config_dir = format!("{HOST_AGENTS_ROOT}/{child}/config"); for dir in [&state_dir, &harness_dir, &config_dir] { let _ = std::fs::create_dir_all(dir); } - let _ = write!(binds, " --bind={state_dir}:/agents/{child}/state"); - let _ = write!(binds, " --bind={harness_dir}:/agents/{child}/harness"); - let _ = write!(binds, " --bind={config_dir}:/agents/{child}/config"); + binds.push(BindMount { host_path: state_dir, container_path: format!("/agents/{child}/state"), read_only: false }); + binds.push(BindMount { host_path: harness_dir, container_path: format!("/agents/{child}/harness"), read_only: false }); + binds.push(BindMount { host_path: config_dir, container_path: format!("/agents/{child}/config"), read_only: false }); } -#[allow(clippy::too_many_lines)] -fn set_nspawn_flags( +async fn set_nspawn_flags( container: &str, runtime_dir: &Path, claude_dir: &Path, notes_dir: &Path, ) -> Result<()> { - use std::fmt::Write as _; - // Ensure /shared directory exists before binding. systemd-nspawn requires the bind source to exist. std::fs::create_dir_all(HOST_SHARED_ROOT) .with_context(|| format!("create {HOST_SHARED_ROOT}"))?; - let path = format!("/etc/nixos-containers/{container}.conf"); - let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; - // Logical agent name — strip the `h-` prefix. // For the manager: `h-ruth` → `ruth`. For sub-agents: `h-iris` → `iris`. let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container); @@ -1092,37 +1046,26 @@ fn set_nspawn_flags( // is needed here — the bind alone is enough. let claude_mount = container_claude_mount(agent_name); - let mut binds = format!( - "--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{claude_mount} --bind={shared}:{CONTAINER_SHARED_MOUNT}", - runtime = runtime_dir.display(), - claude = claude_dir.display(), - shared = HOST_SHARED_ROOT, - ); + let mut binds: Vec = vec![ + BindMount { host_path: runtime_dir.to_string_lossy().into_owned(), container_path: CONTAINER_RUNTIME_MOUNT.to_owned(), read_only: false }, + BindMount { host_path: claude_dir.to_string_lossy().into_owned(), container_path: claude_mount, read_only: false }, + BindMount { host_path: HOST_SHARED_ROOT.to_owned(), container_path: CONTAINER_SHARED_MOUNT.to_owned(), read_only: false }, + ]; // Own state, harness, and config dirs — same for every agent including - // root. Config is RO: an agent must not edit its own config; changes + // the manager. Config is RO: an agent must not edit its own config; changes // only ever flow through the approval queue. - { - let _ = write!( - binds, - " --bind={notes}:/agents/{agent_name}/state", - notes = notes_dir.display(), - ); - if let Some(state_parent) = notes_dir.parent() { - let harness_dir = state_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(), - ); + binds.push(BindMount { host_path: notes_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/state"), read_only: false }); + if let Some(state_parent) = notes_dir.parent() { + let harness_dir = state_parent.join("harness"); + if !harness_dir.exists() { + let _ = std::fs::create_dir_all(&harness_dir); } - let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config"); - std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?; - let _ = write!(binds, " --bind-ro={own_config}:/agents/{agent_name}/config"); + binds.push(BindMount { host_path: harness_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/harness"), read_only: false }); } + let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config"); + std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?; + binds.push(BindMount { host_path: own_config, container_path: format!("/agents/{agent_name}/config"), read_only: true }); // Topology-driven child mounts: every direct child of this agent gets // its state, harness, and config dirs bind-mounted RW so the parent @@ -1153,16 +1096,10 @@ fn set_nspawn_flags( // fires first (e.g. cold start with no agents). std::fs::create_dir_all(HOST_META_ROOT) .with_context(|| format!("create {HOST_META_ROOT}"))?; - let _ = write!( - binds, - " --bind-ro={HOST_APPLIED_ROOT}:{CONTAINER_MANAGER_APPLIED_MOUNT}", - ); - let _ = write!( - binds, - " --bind-ro={HOST_META_ROOT}:{mount}", - mount = crate::meta::CONTAINER_MANAGER_META_MOUNT, - ); + binds.push(BindMount { host_path: HOST_APPLIED_ROOT.to_owned(), container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(), read_only: true }); + binds.push(BindMount { host_path: HOST_META_ROOT.to_owned(), container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(), read_only: true }); } + // 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 @@ -1175,159 +1112,74 @@ fn set_nspawn_flags( // 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()))?; + if let Err(e) = crate::priv_client::chown_socket_dir(agent_name, uid, gid).await { + tracing::warn!(%agent_name, error = ?e, "chown socket dir failed"); + } + } else if let Err(e) = crate::priv_client::chmod_socket_dir(agent_name, 0o777).await { + tracing::warn!(%agent_name, error = ?e, "chmod socket dir failed"); } - 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() - .filter(|line| { - let trimmed = line.trim_start(); - // Strip any network-namespace knobs nixos-container's create - // might have populated. The start script adds `--network-veth` - // whenever HOST_ADDRESS / LOCAL_ADDRESS (or their IPv6 cousins) - // are non-empty — and veth implies a private netns, hiding our - // web-UI port from the host. Force host netns. - !trimmed.starts_with("EXTRA_NSPAWN_FLAGS=") - && !trimmed.starts_with("PRIVATE_NETWORK=") - && !trimmed.starts_with("HOST_ADDRESS=") - && !trimmed.starts_with("LOCAL_ADDRESS=") - && !trimmed.starts_with("HOST_ADDRESS6=") - && !trimmed.starts_with("LOCAL_ADDRESS6=") - && !trimmed.starts_with("HOST_BRIDGE=") - }) - .map(str::to_owned) - .collect(); - lines.push("PRIVATE_NETWORK=0".to_owned()); - lines.push("HOST_ADDRESS=".to_owned()); - lines.push("LOCAL_ADDRESS=".to_owned()); - lines.push("HOST_ADDRESS6=".to_owned()); - lines.push("LOCAL_ADDRESS6=".to_owned()); - lines.push("HOST_BRIDGE=".to_owned()); - lines.push(bind_flag); - let mut content = lines.join("\n"); - content.push('\n'); - std::fs::write(&path, content).with_context(|| format!("write {path}"))?; - tracing::info!(%path, "set PRIVATE_NETWORK=0 + EXTRA_NSPAWN_FLAGS"); - Ok(()) + binds.push(BindMount { host_path: socket_dir.to_string_lossy().into_owned(), container_path: socket_dir.to_string_lossy().into_owned(), read_only: false }); + + // Delegate the actual conf-file rewrite to hive-priv (runs as root). + crate::priv_client::write_nspawn_flags(container, &binds).await } -/// Spawn `nixos-container ` and pipe its stdout + stderr into -/// `tracing` one line at a time so a long-running command (most -/// notably `update`, which kicks off a full nix build that can run -/// for minutes on a stale flake) shows progress in journald as it -/// happens. The buffered `.output()` we used before only flushed the -/// summary at exit, which made "slow" and "stuck" look identical to -/// the operator watching `journalctl -u hive-c0re -f`. -/// -/// stdout lines log at INFO, stderr at WARN. The same lines are -/// captured per-attempt into `build_logs.sqlite` so the dashboard -/// can surface the full stream to the operator; on failure we bail -/// with a `see build log #` pointer instead of the legacy -/// 32-line ring-buffer tail that routinely truncated eval errors. -async fn run(args: &[&str]) -> Result<()> { - use tokio::io::{AsyncBufReadExt, BufReader}; - let cmdline = args.join(" "); - - // Convention: `nixos-container ...` — the - // verb is `args[0]` (kind) and the container is `args[1]` - // (h- | root | hive-matrix | ...) for every long-running - // case we care about. Strip the `h-` prefix for sub-agents so the - // build_logs row's `agent` column matches the agent's bare name - // (`alice` rather than `h-alice`) — that's what the dashboard - // groups by. Manager + sibling containers pass through as-is. - let kind = args.first().copied().unwrap_or("nixos-container"); - let agent = args - .get(1) - .copied() - .map_or_else(|| "".to_string(), |c| c.strip_prefix(AGENT_PREFIX).unwrap_or(c).to_string()); +/// Execute a container operation via hive-priv and integrate with +/// build_logs.sqlite. hive-priv runs as root and logs output to +/// journald as it arrives; this function captures the final stdout + +/// stderr into build_logs for the dashboard after the operation +/// completes. For `create` and `update` (the long-running ops) +/// hive-priv already logs each line to its own journald stream — +/// streaming into build_logs is deferred to a follow-up that adds a +/// streaming mode to the priv protocol. +async fn priv_run(kind: &str, name: &str) -> Result<()> { + let container = container_name(name); + let cmdline = format!("nixos-container {kind} {container}"); let logs = crate::build_logs::global(); let log_id = logs.as_ref().and_then(|h| { - h.start(&agent, kind, &cmdline) + h.start(name, kind, &cmdline) .map_err(|e| { - tracing::warn!(error = ?e, "build_logs: start failed (nixos-container log dropped)"); + tracing::warn!(error = ?e, "build_logs: start failed (priv_run log dropped)"); }) .ok() }); - let mut child = Command::new("nixos-container") - .args(args) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .with_context(|| format!("invoke nixos-container {cmdline}"))?; + let result: Result<(String, String)> = match kind { + "create" => crate::priv_client::create_container(name).await, + "update" => crate::priv_client::update_container(name).await, + "start" => crate::priv_client::start_container(name).await.map(|()| (String::new(), String::new())), + "stop" => crate::priv_client::stop_container(name).await.map(|()| (String::new(), String::new())), + "kill" => crate::priv_client::kill_container(name).await.map(|()| (String::new(), String::new())), + "destroy" => crate::priv_client::destroy_container(name).await.map(|()| (String::new(), String::new())), + other => Err(anyhow::anyhow!("unknown container op: {other}")), + }; - let stdout = child.stdout.take().expect("piped stdout"); - let stderr = child.stderr.take().expect("piped stderr"); - - let stdout_cmdline = cmdline.clone(); - let stdout_logs = logs.clone(); - let pump_stdout = tokio::spawn(async move { - let mut lines = BufReader::new(stdout).lines(); - while let Ok(Some(line)) = lines.next_line().await { - tracing::info!(target: "nixos-container", cmdline = %stdout_cmdline, "{line}"); - if let (Some(h), Some(id)) = (&stdout_logs, log_id) { - h.append_stdout(id, &line); - } - } - }); - - let stderr_cmdline = cmdline.clone(); - let stderr_logs = logs.clone(); - let pump_stderr = tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - tracing::warn!(target: "nixos-container", cmdline = %stderr_cmdline, "{line}"); - if let (Some(h), Some(id)) = (&stderr_logs, log_id) { - h.append_stderr(id, &line); - } - } - }); - - let status = child - .wait() - .await - .with_context(|| format!("wait nixos-container {cmdline}"))?; - let _ = pump_stdout.await; - let _ = pump_stderr.await; - - let ok = status.success(); + let ok = result.is_ok(); if let (Some(h), Some(id)) = (&logs, log_id) { - h.finish( - id, - if ok { - crate::build_logs::BuildStatus::Ok - } else { - crate::build_logs::BuildStatus::Fail - }, - ); + if let Ok((ref stdout, ref stderr)) = result { + for line in stdout.lines() { + tracing::info!(target: "nixos-container", cmdline = %cmdline, "{line}"); + h.append_stdout(id, line); + } + for line in stderr.lines() { + tracing::warn!(target: "nixos-container", cmdline = %cmdline, "{line}"); + h.append_stderr(id, line); + } + } + h.finish(id, if ok { crate::build_logs::BuildStatus::Ok } else { crate::build_logs::BuildStatus::Fail }); } - if !ok { - // `container_journal_tail` is best-effort + only fires on - // `update`; the captured build log holds the full host-side - // stderr regardless, so the bail message can stay terse: a - // pointer to the log id + the journal tail (when available) - // is enough for the operator to drill in without flooding - // every notification with the eval-error verbatim. - let journal = container_journal_tail(args).await; - match log_id { - Some(id) => { - bail!("nixos-container {cmdline} failed ({status}); see build log #{id}{journal}") + + match result { + Ok(_) => Ok(()), + Err(e) => { + let journal = if kind == "update" { container_journal_tail(&container).await } else { String::new() }; + match log_id { + Some(id) => bail!("{e:#}; see build log #{id}{journal}"), + None => bail!("{e:#}{journal}"), } - None => bail!("nixos-container {cmdline} failed ({status}){journal}"), } } - Ok(()) } /// On a failed `nixos-container update`, the stderr nixos-container @@ -1342,13 +1194,7 @@ async fn run(args: &[&str]) -> Result<()> { /// `journalctl -M` works. Best-effort — returns "" for other verbs /// or when the journal can't be read (machine gone, journalctl /// missing); it never produces an error of its own. -async fn container_journal_tail(args: &[&str]) -> String { - if args.first().copied() != Some("update") { - return String::new(); - } - let Some(container) = args.get(1) else { - return String::new(); - }; +async fn container_journal_tail(container: &str) -> String { let out = Command::new("journalctl") .args(["-M", container, "-n", "40", "--no-pager", "--output=short"]) .output() diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 8b31026d..25c0a7d7 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -315,6 +315,17 @@ in managerToplevel ]; + # Unprivileged coordinator user. hive-c0re runs as this user + # (privsep phase 2); privileged operations are delegated to + # hive-priv which runs as root, socket-activated at + # /run/hive/priv.sock. + users.users.hive-core = { + isSystemUser = true; + group = "hive-core"; + description = "hive-c0re coordinator daemon user"; + }; + users.groups.hive-core = { }; + # Open the per-agent web-port range when the gateway is *off* — # otherwise the gateway nginx is the sole external entry point. # See `docs/gateway.md::Firewall posture (host-level)`. @@ -412,12 +423,22 @@ in }; serviceConfig = { ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --nixpkgs-flake ${cfg.nixpkgsFlake} --nixpkgs-unstable-flake ${cfg.nixpkgsUnstableFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}"; + # One-time migration: chown existing state tree to the service + # user after upgrading from a root-run install. The `+` prefix + # runs this step as root even though User = hive-core. systemd's + # StateDirectory chowns the top-level dir at every start, but + # pre-existing files inside may still be root-owned. + ExecStartPre = "+${pkgs.coreutils}/bin/sh -c 'chown -R hive-core:hive-core /var/lib/hyperhive || true'"; Restart = "on-failure"; RestartSec = 2; + User = "hive-core"; + Group = "hive-core"; + SupplementaryGroups = [ "systemd-journal" ]; RuntimeDirectory = "hyperhive"; RuntimeDirectoryMode = "0750"; RuntimeDirectoryPreserve = "yes"; StateDirectory = "hyperhive"; + StateDirectoryMode = "0750"; }; }; @@ -451,18 +472,18 @@ in # privileged operations on behalf of hive-c0re. Systemd creates and # holds `/run/hive/priv.sock` before the first connection arrives. # - # Mode 0660 root:root is correct for phase 1 (hive-c0re still runs as - # root and is the only caller). Phase 2 (privsep: hive-c0re drops to a - # non-root user) will add `SocketGroup = hive-core` so the unprivileged - # hive-c0re process can still connect. + # Mode 0660 hive-core:hive-core: only the hive-c0re service user can + # connect. hive-priv (server) runs as root and validates every request + # against a strict allowlist before executing any privileged op. systemd.sockets.hive-priv = { description = "hive-priv privileged helper socket"; wantedBy = [ "sockets.target" ]; socketConfig = { ListenStream = "/run/hive/priv.sock"; SocketMode = "0660"; - # Create /run/hive/ if absent; 0755 so future unprivileged callers - # can traverse into it to reach the socket. + SocketGroup = "hive-core"; + # Create /run/hive/ if absent; 0755 so the hive-core user can + # traverse into it to reach the socket. DirectoryMode = "0755"; }; };