From 6b6c6775ee1a6a763b8e5a5bc88f7a17ceaf6e9b Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 30 May 2026 21:14:46 +0200 Subject: [PATCH] agents: drop root, run as per-agent unix user with passwordless sudo (#658) --- CLAUDE.md | 3 +- hive-ag3nt/src/login.rs | 8 +- hive-ag3nt/src/login_session.rs | 8 +- hive-ag3nt/src/paths.rs | 24 ++- hive-ag3nt/src/turn.rs | 20 ++- hive-c0re/src/lifecycle.rs | 55 +++++-- hive-c0re/src/meta.rs | 8 + nix/templates/agent-base.nix | 16 ++ nix/templates/harness-base.nix | 265 +++++++++++++++++++++++++++----- nix/templates/manager.nix | 13 ++ 10 files changed, 349 insertions(+), 71 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 96fa6871..10991b55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,7 +184,8 @@ hive-ag3nt/ in-container harness crate; produces TWO binaries `label()` / `qualified_label()` / `qualify(label)`. Reads `HYPERHIVE_HIVE_DOMAIN`; falls back to short name when unset. - src/login.rs probe /root/.claude/ for a valid session + src/login.rs probe $HOME/.claude/ (post-#658 `/home//.claude`) + for a valid session src/login_session.rs drives `claude auth login` over stdio pipes src/prompt.rs system prompt renderer: filters the unified template through `` / diff --git a/hive-ag3nt/src/login.rs b/hive-ag3nt/src/login.rs index 68578dbb..b93b3caf 100644 --- a/hive-ag3nt/src/login.rs +++ b/hive-ag3nt/src/login.rs @@ -10,9 +10,11 @@ use std::path::{Path, PathBuf}; -/// Returns the Claude credentials directory for this agent, derived from -/// `HIVE_LABEL`. Manager ("hm1nd") uses `/root/.claude`; sub-agents use -/// `/agents/{label}/claude`. Overridable via `HYPERHIVE_CLAUDE_DIR`. +/// Returns the Claude credentials directory for this agent. Delegates +/// to `paths::claude_dir`, which reads `$HOME/.claude` (post-#658 the +/// service runs as a non-root unix user named after the agent, so +/// `$HOME` resolves to `/home/` and the OAuth dir lives at +/// `/home//.claude`). Overridable via `HYPERHIVE_CLAUDE_DIR`. #[must_use] pub fn default_dir() -> PathBuf { crate::paths::claude_dir() diff --git a/hive-ag3nt/src/login_session.rs b/hive-ag3nt/src/login_session.rs index 956375be..2860ebff 100644 --- a/hive-ag3nt/src/login_session.rs +++ b/hive-ag3nt/src/login_session.rs @@ -62,9 +62,11 @@ impl LoginSession { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - // `claude` reads $HOME for the credentials dir; the bind-mount - // puts it at /root/.claude, which is already the default home - // for uid 0 inside the container. Nothing extra to set here. + // `claude` reads $HOME/.claude for the credentials dir. The + // harness service env sets HOME to /home/ (post-#658) + // and the bind-mount lands the OAuth dir at the same path, + // so the child inherits the right HOME without any further + // wiring here. .kill_on_drop(true) .spawn() .with_context(|| format!("spawn `{cmd}`"))?; diff --git a/hive-ag3nt/src/paths.rs b/hive-ag3nt/src/paths.rs index 7564ebfb..edc7bad3 100644 --- a/hive-ag3nt/src/paths.rs +++ b/hive-ag3nt/src/paths.rs @@ -1,7 +1,9 @@ //! Per-agent path resolution for state and credential directories. //! //! All agents (including the manager "hm1nd") use `/agents/{label}/state`. -//! Claude credentials are always at `/root/.claude` for all agents. +//! Claude credentials live at `$HOME/.claude` (post-#658: +//! `/home//.claude` because the harness service now runs +//! as a non-root unix user matching the agent label). //! //! Both paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`, //! `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios. @@ -21,12 +23,22 @@ pub fn state_dir() -> PathBuf { PathBuf::from(format!("/agents/{label}/state")) } -/// Claude credentials directory for the current agent. Always `/root/.claude` -/// because the `claude` CLI reads `$HOME/.claude` (uid 0 → `/root`), and -/// hive-c0re binds the per-agent credentials dir there for every container. +/// Claude credentials directory for the current agent. `$HOME/.claude` +/// matches what the `claude` CLI reads at runtime — both binaries see +/// the same `$HOME` set by the per-service systemd `environment` +/// declaration (`/home/` post-#658). Falls back to `/root/.claude` +/// for dev / test environments where `HOME` isn't set so the previous +/// root-by-default shape keeps working without env wiring. /// Overridable via `HYPERHIVE_CLAUDE_DIR` for dev / test scenarios. #[must_use] pub fn claude_dir() -> PathBuf { - std::env::var_os("HYPERHIVE_CLAUDE_DIR") - .map_or_else(|| PathBuf::from("/root/.claude"), PathBuf::from) + if let Some(p) = std::env::var_os("HYPERHIVE_CLAUDE_DIR") { + return PathBuf::from(p); + } + if let Some(home) = std::env::var_os("HOME") { + let mut path = PathBuf::from(home); + path.push(".claude"); + return path; + } + PathBuf::from("/root/.claude") } diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index eddfc9e7..885b5a99 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -47,8 +47,9 @@ const RATE_LIMIT_MARKERS: &[&str] = &[ ]; /// Substrings that indicate the Anthropic API rejected the request as -/// unauthenticated — the OAuth session in `/root/.claude/` has expired -/// or been revoked. Surfaced as `TurnOutcome::AuthFailed`, which the +/// unauthenticated — the OAuth session in `$HOME/.claude/` (post-#658 +/// `/home//.claude`, previously `/root/.claude`) has expired or +/// been revoked. Surfaced as `TurnOutcome::AuthFailed`, which the /// harness uses to flip the container into `needs_login_idle` so the /// dashboard's re-auth flow takes over (closes #419). Matched against /// both stdout JSON `error` events and stderr; the markers come from @@ -163,9 +164,13 @@ pub async fn write_settings(socket: &Path) -> Result { // same socket-adjacent location every time and so a future override // (per-agent settings JSON layer) drops in cleanly. let src = hive_sh4re::assets::claude_settings(); - tokio::fs::copy(&src, &path) - .await - .with_context(|| format!("copy claude settings from {} to {}", src.display(), path.display()))?; + tokio::fs::copy(&src, &path).await.with_context(|| { + format!( + "copy claude settings from {} to {}", + src.display(), + path.display() + ) + })?; tracing::info!(path = %path.display(), "wrote claude settings"); Ok(path) } @@ -919,7 +924,10 @@ mod tests { // file_count=1 + no mtime, then writing a second file. let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("a"), b"{}").unwrap(); - let forged = DirSnapshot { file_count: 1, newest_mtime: None }; + let forged = DirSnapshot { + file_count: 1, + newest_mtime: None, + }; fs::write(dir.path().join("b"), b"{}").unwrap(); // Real snapshot has file_count=2, so refresh fires even // though the mtime axis would be inconclusive. diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 6e6f05c7..7c3084ae 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -22,9 +22,20 @@ pub const MANAGER_PORT: u16 = 8000; /// Mount point of the per-agent runtime directory inside the container. pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive"; -/// Mount point of the per-agent Claude credentials dir inside the container. -/// Persistent across destroy/recreate so OAuth login survives. -pub const CONTAINER_CLAUDE_MOUNT: &str = "/root/.claude"; +/// Where the per-agent Claude credentials dir mounts inside the +/// container. Pre-#658 this was a constant (`/root/.claude`, because +/// every agent ran as root). With the user-named-after-agent shape +/// the harness service runs as a non-root unix user whose home is +/// `/home//`, so the mount path now varies per agent — +/// `container_claude_mount(name)` returns `/home//.claude` +/// for sub-agents and `/home/hm1nd/.claude` for the manager. +/// `claude` inside the container reads `$HOME/.claude` and the +/// service environment sets `HOME` to the same path, so the OAuth +/// session survives container restarts the same way the constant +/// did. +pub fn container_claude_mount(name: &str) -> String { + format!("/home/{name}/.claude") +} /// Mount point of the shared directory accessible to all agents. /// All agents can read/write here; agents should only put things they're @@ -150,7 +161,14 @@ pub async fn spawn( // before `nixos-container create` so the `--flake meta#` // ref resolves. let agents = agents_after_spawn(name).await?; - crate::meta::sync_agents(hyperhive_flake, dashboard_port, operator_pronouns, context_window_tokens, &agents).await?; + crate::meta::sync_agents( + hyperhive_flake, + dashboard_port, + operator_pronouns, + context_window_tokens, + &agents, + ) + .await?; let container = container_name(name); let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display()); run(&["create", &container, "--flake", &flake_ref]).await?; @@ -298,7 +316,14 @@ pub async fn rebuild( // got added directly via `nixos-container create` outside // hive-c0re). let agents = agents_for_meta(None).await?; - crate::meta::sync_agents(hyperhive_flake, dashboard_port, operator_pronouns, context_window_tokens, &agents).await?; + crate::meta::sync_agents( + hyperhive_flake, + dashboard_port, + operator_pronouns, + context_window_tokens, + &agents, + ) + .await?; // Then bump just this agent's input — picks up whatever // `applied//main` currently points at (deployed/). // Commits the lock if it changed. @@ -860,10 +885,14 @@ fn set_nspawn_flags( // below are gated on `container == MANAGER_NAME` anyway. let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container); - // Claude credentials always land at /root/.claude so the - // `claude` CLI (which reads $HOME/.claude) finds them without - // any HOME override. - let claude_mount = CONTAINER_CLAUDE_MOUNT; + // Claude credentials land at `/home//.claude` so the + // `claude` CLI (which reads `$HOME/.claude`) finds them. The + // harness service's environment sets `HOME` to the same path + // (`agent-base.nix` / `manager.nix`), so no `--setenv` plumbing + // is needed here — the bind alone is enough. Pre-#658 the mount + // was the constant `/root/.claude` because the service ran as + // root. + 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}", @@ -934,8 +963,7 @@ fn set_nspawn_flags( // `setup_proposed` seeds this dir before spawn reaches here, // but create defensively so a missing repo degrades to an // empty RO dir instead of a container that won't boot. - std::fs::create_dir_all(&config_dir) - .with_context(|| format!("create {config_dir}"))?; + std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?; let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config"); } let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\""); @@ -1138,6 +1166,9 @@ mod tests { .await .expect("git rev-list"); let count = String::from_utf8_lossy(&out.stdout).trim().to_owned(); - assert_eq!(count, "1", "expected exactly one commit after idempotent call"); + assert_eq!( + count, "1", + "expected exactly one commit after idempotent call" + ); } } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 9f313525..65b64fa0 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -401,6 +401,14 @@ where modules = [ input.nixosModules.default { + # Drop root (#658): the harness service inside the + # container runs as a non-root unix user named after + # the agent (`damocles`, `iris`, `hm1nd`, …). UID + # auto-assigned by NixOS per mara on #658; the per- + # agent override here is what makes `hyperhive.user.name` + # match the agent's identity instead of the harness- + # base default of `"agent"`. + hyperhive.user.name = name; programs.git.config.user = { name = name; email = "${name}@hyperhive"; diff --git a/nix/templates/agent-base.nix b/nix/templates/agent-base.nix index 4fc233bf..53b1a315 100644 --- a/nix/templates/agent-base.nix +++ b/nix/templates/agent-base.nix @@ -1,4 +1,7 @@ { pkgs, config, ... }: +let + userName = config.hyperhive.user.name; +in { imports = [ ./harness-base.nix ]; @@ -15,6 +18,11 @@ path = [ "/run/current-system/sw" ]; environment = { SHELL = "${pkgs.bashInteractive}/bin/bash"; + # `HOME` defaults to `/` for systemd services without a User= + # set. With #658 the harness runs as the agent user — set HOME + # explicitly so claude (which the harness spawns) finds its + # `~/.claude/` session dir at the bind-mounted location. + HOME = "/home/${userName}"; # Path to the merged agent static dist. The harness serves this # via `tower_http::ServeDir` for any request it doesn't route to # an API endpoint. `mergedDist` is the agent-default dist with @@ -30,6 +38,14 @@ ExecStart = "${pkgs.hyperhive}/bin/hive-ag3nt serve"; Restart = "on-failure"; RestartSec = 2; + # Run the harness as the per-agent user (#658). claude itself + # spawned by the harness then runs as that user too — drops + # root inside the container while sudo (`NOPASSWD: ALL` by + # default, see harness-base.nix `hyperhive.user.passwordlessSudo`) + # keeps the previous root-by-default surface available + # explicitly for tools that need it. + User = userName; + Group = userName; }; }; } diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index f86e600c..6b9331f1 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -9,6 +9,15 @@ flakeInputs ? { }, ... }: +let + # 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. + userName = config.hyperhive.user.name; + homeDir = "/home/${userName}"; +in { # Shared scaffolding for any hyperhive harness container — both # sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend @@ -20,6 +29,52 @@ # only opts in from its own `agent.nix`. imports = [ ./weston-vnc.nix ]; + # 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 + # agent name (`"damocles"`, `"iris"`, …) so each container has a + # uniquely-named user matching its agent label. UID auto-assigned + # by NixOS (the auto-allocation range for normal users); no hard- + # coded UID. + options.hyperhive.user.name = lib.mkOption { + type = lib.types.strMatching "^[a-z_][a-z0-9_-]{0,30}$"; + default = "agent"; + example = "iris"; + description = '' + Unix user the harness service runs as inside the container. + The meta-flake overrides this to the agent's own name so the + user inside the container matches the agent label (`HIVE_LABEL`). + Stand-alone evaluation defaults to `"agent"` so module evaluation + without the meta-flake wrapper still builds. + + Constraints match `useradd`'s NAME_REGEX: lowercase / `_` start, + total length ≤ 31, no special characters. UID is auto-assigned + by NixOS; no `uid =` override surface (intentional — pinning + across rebuilds isn't a concern when the home and state dirs + stay bind-mounted from the host). + ''; + }; + + options.hyperhive.user.passwordlessSudo = lib.mkOption { + type = lib.types.bool; + default = true; + example = false; + description = '' + Grant `${config.hyperhive.user.name}` passwordless sudo + (`NOPASSWD: ALL`). True by default so claude's `Bash` tool + keeps working for tools that expect root inside the container + (`systemctl`, package managers in dev shells, etc.) — the + same surface the previous root-user shape had, just elevated + explicitly instead of implicitly. + + Flip to `false` for agents that should be strictly + unprivileged. Anything claude shells out to that needs root + will then fail loudly with the standard sudo error rather + than silently succeeding — easier to spot the leak. + ''; + }; + options.hyperhive.model = lib.mkOption { type = lib.types.str; default = "haiku"; @@ -353,23 +408,25 @@ }; options.hyperhive.dashboardLinks = lib.mkOption { - type = lib.types.listOf (lib.types.submodule { - options = { - label = lib.mkOption { - type = lib.types.str; - description = "Display label for the link."; + type = lib.types.listOf ( + lib.types.submodule { + options = { + label = lib.mkOption { + type = lib.types.str; + description = "Display label for the link."; + }; + icon = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Optional icon emoji or short glyph."; + }; + url = lib.mkOption { + type = lib.types.str; + description = "Full URL (may include a different port, e.g. http://localhost:9001/stats)."; + }; }; - icon = lib.mkOption { - type = lib.types.str; - default = ""; - description = "Optional icon emoji or short glyph."; - }; - url = lib.mkOption { - type = lib.types.str; - description = "Full URL (may include a different port, e.g. http://localhost:9001/stats)."; - }; - }; - }); + } + ); default = [ ]; example = lib.literalExpression '' [ @@ -505,9 +562,7 @@ } # hyperhive.icon must reference an SVG file when set. { - assertion = - config.hyperhive.icon == null - || lib.hasSuffix ".svg" (toString config.hyperhive.icon); + assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon); message = "hyperhive.icon must point to an .svg file"; } # hyperhive.frontend.extraFiles[*].target is concatenated into @@ -519,9 +574,9 @@ # operator-reviewed, so this is belt-and-braces — but it's the # kind of mistake that's easy to make and hard to spot. { - assertion = lib.all ( - entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target)) - ) (lib.attrValues config.hyperhive.frontend.extraFiles); + assertion = lib.all (entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))) ( + lib.attrValues config.hyperhive.frontend.extraFiles + ); message = '' hyperhive.frontend.extraFiles: `target` must not contain `..` path segments. @@ -529,6 +584,111 @@ } ]; + # 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; + createHome = true; + group = userName; + extraGroups = lib.optional config.hyperhive.user.passwordlessSudo "wheel"; + # Matches /bin/bash on NixOS — the harness's claude shell-outs + # expect a POSIX shell at $SHELL; bashInteractive is already + # the system default for the root user too (see SHELL env + # var declaration below). + shell = pkgs.bashInteractive; + }; + users.groups.${userName} = { }; + + # `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool + # keep working with anything that expected root (systemctl, + # nix-env, etc.) without prompting — same surface as the + # previous root-by-default shape, just elevated explicitly. + # Flip `hyperhive.user.passwordlessSudo = false` to drop both + # the wheel-group membership and this sudoers entry; anything + # that needs root then fails loudly instead of silently + # succeeding. + security.sudo.extraRules = lib.mkIf config.hyperhive.user.passwordlessSudo [ + { + users = [ userName ]; + commands = [ + { + command = "ALL"; + options = [ "NOPASSWD" ]; + } + ]; + } + ]; + + # First-boot migration from the legacy root-run shape (#658). + # Runs on every activation; marker-guarded so the move only + # happens once. The bind mount that hive-c0re sets up has + # already moved from `/root/.claude` to `${homeDir}/.claude` + # by the time we get here (per `lifecycle::CONTAINER_CLAUDE_MOUNT` + # — the host-side path stays the same, the container-side + # mount target shifts), so the bulk of the data is already at + # the new location. This script just: + # + # - ensures `${homeDir}` exists with correct ownership (covers + # the very first boot before useradd's `createHome` has + # anything to chown); + # - migrates any leftover `/root/.claude` content that an + # operator might have populated before #658 deployed (the + # bind mount didn't exist in that lifecycle, so claude + # would have written into the root user's empty home — + # nothing important typically, but safer to move than to + # strand); + # - chowns the bind-mounted state dir (`/agents/*/state`) so + # the agent user can read/write it. + system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] '' + homeDir=${lib.escapeShellArg homeDir} + userName=${lib.escapeShellArg userName} + # Always ensure the home dir exists with the right ownership — + # useradd's createHome handles the very first creation but + # doesn't re-chown if a rebuild changes the user name (rare + # but possible if the meta-flake's per-agent name evolves). + mkdir -p "$homeDir" + chown "$userName:$userName" "$homeDir" + # One-time migration of pre-#658 /root/.claude content into the + # new home. Marker-guarded so the move only runs once per + # container lifetime — subsequent activations skip the legacy + # path even if claude were to repopulate /root/.claude for any + # reason. + marker=/var/lib/hive-agent-user-migrated + if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then + mkdir -p "$homeDir/.claude" + # `mv -n` (no-clobber) so any pre-existing files at the + # destination (e.g. from the bind mount) win — we never + # blow over data already at the new location. + if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then + rm -rf /root/.claude + echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude" + fi + fi + mkdir -p "$(dirname "$marker")" + : > "$marker" + # Chown the bind-mounted state dir so the agent user can + # read/write it. `/agents/*/state` is the canonical mount + # point set by hive-c0re's `set_nspawn_flags`. Wildcard + # because each container only sees its own + # `/agents//state` (one match); -h to avoid following + # any symlinks the agent might have planted in there. + for stateDir in /agents/*/state; do + [ -d "$stateDir" ] || continue + chown -hR "$userName:$userName" "$stateDir" 2>/dev/null || true + done + ''; + # 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 @@ -537,7 +697,11 @@ matrix = lib.mkDefault { command = "${pkgs.hyperhive}/bin/hive-matrix-mcp"; args = [ ]; - env = { }; + # 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. + env.HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; allowedTools = [ "*" ]; }; }; @@ -547,8 +711,9 @@ # Operator-set per-agent icon (hyperhive.icon). When configured, the # SVG lands at /etc/hyperhive/icon.svg; the harness serves it at # GET /icon, falling back to the bundled hyperhive logo when absent. - environment.etc."hyperhive/icon.svg" = - lib.mkIf (config.hyperhive.icon != null) { source = config.hyperhive.icon; }; + environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) { + source = config.hyperhive.icon; + }; environment.etc."hyperhive/bash-allow.json".text = builtins.toJSON config.hyperhive.allowedBashPatterns; @@ -600,13 +765,16 @@ HIVE_DEFAULT_MODEL = config.hyperhive.model; HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; SHELL = "${pkgs.bashInteractive}/bin/bash"; - } // lib.optionalAttrs (!config.hyperhive.autoCompact) { + } + // lib.optionalAttrs (!config.hyperhive.autoCompact) { # Zero watermark disables proactive compaction; the reactive path # (compact-on-overflow) still fires when the session is truly full. HIVE_COMPACT_WATERMARK_TOKENS = "0"; - } // lib.optionalAttrs config.hyperhive.forge.keepSubscriptions { + } + // lib.optionalAttrs config.hyperhive.forge.keepSubscriptions { HIVE_FORGE_KEEP_SUBSCRIPTIONS = "1"; - } // lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) { + } + // lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) { HIVE_FORGE_NOTIFY_SKIP_REASONS = lib.concatStringsSep "," config.hyperhive.forge.skipNotifyReasons; }; @@ -680,6 +848,8 @@ pkgs.python3 pkgs.coreutils ]; + environment.HOME_DIR = homeDir; + environment.AGENT_USER = userName; script = '' # No `set -e`: any subshell failure must not propagate. # A failed unit aborts `nixos-container update` which blocks rebuilds. @@ -703,13 +873,16 @@ echo "tea-login: could not resolve username from forge API; skipping" exit 0 fi - # tea reads config from ~/.config/tea/config.yml (for root: /root/.config/tea/config.yml). - # Write it directly so we control default:true and always - # refresh a rotated token — no 'tea login add' interactive dance. - # $HOME is unset in systemd service context (causing writes to - # /.config/). Hardcode /root — always correct for NixOS containers - # where the harness runs as root. - CONFIG="/root/.config/tea/config.yml" + # tea reads config from ~/.config/tea/config.yml. The + # agent user's home is $HOME_DIR (set by NixOS via + # hyperhive.user.name). Write the config under that + # home + chown to the agent user so tea reads it when + # invoked as that user. Still runs as root (this + # service stays root-owned to avoid bootstrap + # ordering issues — see comment near serviceConfig + # below), but the artefact it produces is for the + # agent user. + CONFIG="$HOME_DIR/.config/tea/config.yml" mkdir -p "$(dirname "$CONFIG")" || true cat > "$CONFIG" << EOF logins: @@ -727,7 +900,8 @@ flag_defaults: remote: "" EOF - echo "tea-login: configured for $FORGE_URL as $USER" + chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true + echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)" ''; }; @@ -808,16 +982,27 @@ wants = [ "network-online.target" ]; environment = { HIVE_MATRIX_URL = config.hyperhive.matrix.url; + # Socket path lives inside the systemd-managed runtime dir + # (`RuntimeDirectory = "hive-matrix"` → `/run/hive-matrix/`, + # owned by the agent user) so the daemon can bind it without + # needing root over `/run/` itself (#658). The stdio bridge + # picks up the same path via its own `HIVE_MATRIX_SOCKET` env + # in `extraMcpServers.matrix` below. + HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; RUST_LOG = "info"; }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; Restart = "on-failure"; RestartSec = 5; - # /run/hive-matrix.sock + the matrix-sdk-state sqlite dir - # don't need a StateDirectory= — the socket is on tmpfs (gone - # on restart, which is correct) and the sqlite dir lives in - # the bind-mounted agent state, mode-managed by the harness. + # Run as the per-agent unix user (#658). The runtime dir + # (`/run/hive-matrix/`) is owned by that user via + # `RuntimeDirectory`; claude (also as that user) can + # connect to the socket inside it when the stdio bridge + # spawns per turn. + User = userName; + Group = userName; + RuntimeDirectory = "hive-matrix"; }; }; diff --git a/nix/templates/manager.nix b/nix/templates/manager.nix index 55d94805..2e09dbdd 100644 --- a/nix/templates/manager.nix +++ b/nix/templates/manager.nix @@ -1,4 +1,7 @@ { pkgs, config, ... }: +let + userName = config.hyperhive.user.name; +in { imports = [ ./harness-base.nix ]; @@ -24,6 +27,9 @@ HIVE_PORT = "8000"; HIVE_LABEL = "hm1nd"; SHELL = "${pkgs.bashInteractive}/bin/bash"; + # `HOME` set explicitly so claude finds `~/.claude/` at the + # bind-mounted location after #658 (User= drop from root). + HOME = "/home/${userName}"; # Manager runs the same hive-m1nd harness binary that serves # the per-agent web UI; point it at the merged agent static dist # (same shape as for sub-agents). @@ -42,6 +48,13 @@ ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve"; Restart = "on-failure"; RestartSec = 2; + # Same drop-from-root as agent-base.nix (#658). Manager + # interactions with the host (rebuild approvals, config + # writes) still happen via the dedicated unix sockets + # bind-mounted from hive-c0re — those don't need root + # inside the container. + User = userName; + Group = userName; }; }; }