agents: drop root, run as per-agent unix user with passwordless sudo (#658)

This commit is contained in:
damocles 2026-05-30 21:14:46 +02:00 committed by Mara
commit 6b6c6775ee
10 changed files with 349 additions and 71 deletions

View file

@ -184,7 +184,8 @@ hive-ag3nt/ in-container harness crate; produces TWO binaries
`label()` / `qualified_label()` / `qualify(label)`. `label()` / `qualified_label()` / `qualify(label)`.
Reads `HYPERHIVE_HIVE_DOMAIN`; falls back to short Reads `HYPERHIVE_HIVE_DOMAIN`; falls back to short
name when unset. name when unset.
src/login.rs probe /root/.claude/ for a valid session src/login.rs probe $HOME/.claude/ (post-#658 `/home/<agent>/.claude`)
for a valid session
src/login_session.rs drives `claude auth login` over stdio pipes src/login_session.rs drives `claude auth login` over stdio pipes
src/prompt.rs system prompt renderer: filters the unified src/prompt.rs system prompt renderer: filters the unified
template through `<!-- role:agent -->` / template through `<!-- role:agent -->` /

View file

@ -10,9 +10,11 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Returns the Claude credentials directory for this agent, derived from /// Returns the Claude credentials directory for this agent. Delegates
/// `HIVE_LABEL`. Manager ("hm1nd") uses `/root/.claude`; sub-agents use /// to `paths::claude_dir`, which reads `$HOME/.claude` (post-#658 the
/// `/agents/{label}/claude`. Overridable via `HYPERHIVE_CLAUDE_DIR`. /// service runs as a non-root unix user named after the agent, so
/// `$HOME` resolves to `/home/<agent>` and the OAuth dir lives at
/// `/home/<agent>/.claude`). Overridable via `HYPERHIVE_CLAUDE_DIR`.
#[must_use] #[must_use]
pub fn default_dir() -> PathBuf { pub fn default_dir() -> PathBuf {
crate::paths::claude_dir() crate::paths::claude_dir()

View file

@ -62,9 +62,11 @@ impl LoginSession {
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
// `claude` reads $HOME for the credentials dir; the bind-mount // `claude` reads $HOME/.claude for the credentials dir. The
// puts it at /root/.claude, which is already the default home // harness service env sets HOME to /home/<agent> (post-#658)
// for uid 0 inside the container. Nothing extra to set here. // 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) .kill_on_drop(true)
.spawn() .spawn()
.with_context(|| format!("spawn `{cmd}`"))?; .with_context(|| format!("spawn `{cmd}`"))?;

View file

@ -1,7 +1,9 @@
//! Per-agent path resolution for state and credential directories. //! Per-agent path resolution for state and credential directories.
//! //!
//! All agents (including the manager "hm1nd") use `/agents/{label}/state`. //! 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/<agent-name>/.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`, //! Both paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`,
//! `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios. //! `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios.
@ -21,12 +23,22 @@ pub fn state_dir() -> PathBuf {
PathBuf::from(format!("/agents/{label}/state")) PathBuf::from(format!("/agents/{label}/state"))
} }
/// Claude credentials directory for the current agent. Always `/root/.claude` /// Claude credentials directory for the current agent. `$HOME/.claude`
/// because the `claude` CLI reads `$HOME/.claude` (uid 0 → `/root`), and /// matches what the `claude` CLI reads at runtime — both binaries see
/// hive-c0re binds the per-agent credentials dir there for every container. /// the same `$HOME` set by the per-service systemd `environment`
/// declaration (`/home/<agent>` 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. /// Overridable via `HYPERHIVE_CLAUDE_DIR` for dev / test scenarios.
#[must_use] #[must_use]
pub fn claude_dir() -> PathBuf { pub fn claude_dir() -> PathBuf {
std::env::var_os("HYPERHIVE_CLAUDE_DIR") if let Some(p) = std::env::var_os("HYPERHIVE_CLAUDE_DIR") {
.map_or_else(|| PathBuf::from("/root/.claude"), PathBuf::from) 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")
} }

View file

@ -47,8 +47,9 @@ const RATE_LIMIT_MARKERS: &[&str] = &[
]; ];
/// Substrings that indicate the Anthropic API rejected the request as /// Substrings that indicate the Anthropic API rejected the request as
/// unauthenticated — the OAuth session in `/root/.claude/` has expired /// unauthenticated — the OAuth session in `$HOME/.claude/` (post-#658
/// or been revoked. Surfaced as `TurnOutcome::AuthFailed`, which the /// `/home/<agent>/.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 /// harness uses to flip the container into `needs_login_idle` so the
/// dashboard's re-auth flow takes over (closes #419). Matched against /// dashboard's re-auth flow takes over (closes #419). Matched against
/// both stdout JSON `error` events and stderr; the markers come from /// both stdout JSON `error` events and stderr; the markers come from
@ -163,9 +164,13 @@ pub async fn write_settings(socket: &Path) -> Result<PathBuf> {
// same socket-adjacent location every time and so a future override // same socket-adjacent location every time and so a future override
// (per-agent settings JSON layer) drops in cleanly. // (per-agent settings JSON layer) drops in cleanly.
let src = hive_sh4re::assets::claude_settings(); let src = hive_sh4re::assets::claude_settings();
tokio::fs::copy(&src, &path) tokio::fs::copy(&src, &path).await.with_context(|| {
.await format!(
.with_context(|| format!("copy claude settings from {} to {}", src.display(), path.display()))?; "copy claude settings from {} to {}",
src.display(),
path.display()
)
})?;
tracing::info!(path = %path.display(), "wrote claude settings"); tracing::info!(path = %path.display(), "wrote claude settings");
Ok(path) Ok(path)
} }
@ -919,7 +924,10 @@ mod tests {
// file_count=1 + no mtime, then writing a second file. // file_count=1 + no mtime, then writing a second file.
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a"), b"{}").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(); fs::write(dir.path().join("b"), b"{}").unwrap();
// Real snapshot has file_count=2, so refresh fires even // Real snapshot has file_count=2, so refresh fires even
// though the mtime axis would be inconclusive. // though the mtime axis would be inconclusive.

View file

@ -22,9 +22,20 @@ pub const MANAGER_PORT: u16 = 8000;
/// Mount point of the per-agent runtime directory inside the container. /// Mount point of the per-agent runtime directory inside the container.
pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive"; pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive";
/// Mount point of the per-agent Claude credentials dir inside the container. /// Where the per-agent Claude credentials dir mounts inside the
/// Persistent across destroy/recreate so OAuth login survives. /// container. Pre-#658 this was a constant (`/root/.claude`, because
pub const CONTAINER_CLAUDE_MOUNT: &str = "/root/.claude"; /// 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/<agent>/`, so the mount path now varies per agent —
/// `container_claude_mount(name)` returns `/home/<name>/.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. /// Mount point of the shared directory accessible to all agents.
/// All agents can read/write here; agents should only put things they're /// 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#<name>` // before `nixos-container create` so the `--flake meta#<name>`
// ref resolves. // ref resolves.
let agents = agents_after_spawn(name).await?; 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 container = container_name(name);
let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display()); let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display());
run(&["create", &container, "--flake", &flake_ref]).await?; run(&["create", &container, "--flake", &flake_ref]).await?;
@ -298,7 +316,14 @@ pub async fn rebuild(
// got added directly via `nixos-container create` outside // got added directly via `nixos-container create` outside
// hive-c0re). // hive-c0re).
let agents = agents_for_meta(None).await?; 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 // Then bump just this agent's input — picks up whatever
// `applied/<n>/main` currently points at (deployed/<latest>). // `applied/<n>/main` currently points at (deployed/<latest>).
// Commits the lock if it changed. // Commits the lock if it changed.
@ -860,10 +885,14 @@ fn set_nspawn_flags(
// below are gated on `container == MANAGER_NAME` anyway. // below are gated on `container == MANAGER_NAME` anyway.
let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container); let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container);
// Claude credentials always land at /root/.claude so the // Claude credentials land at `/home/<agent>/.claude` so the
// `claude` CLI (which reads $HOME/.claude) finds them without // `claude` CLI (which reads `$HOME/.claude`) finds them. The
// any HOME override. // harness service's environment sets `HOME` to the same path
let claude_mount = CONTAINER_CLAUDE_MOUNT; // (`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!( let mut binds = format!(
"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{claude_mount} --bind={shared}:{CONTAINER_SHARED_MOUNT}", "--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, // `setup_proposed` seeds this dir before spawn reaches here,
// but create defensively so a missing repo degrades to an // but create defensively so a missing repo degrades to an
// empty RO dir instead of a container that won't boot. // empty RO dir instead of a container that won't boot.
std::fs::create_dir_all(&config_dir) std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?;
.with_context(|| format!("create {config_dir}"))?;
let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config"); let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config");
} }
let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\""); let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
@ -1138,6 +1166,9 @@ mod tests {
.await .await
.expect("git rev-list"); .expect("git rev-list");
let count = String::from_utf8_lossy(&out.stdout).trim().to_owned(); 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"
);
} }
} }

View file

@ -401,6 +401,14 @@ where
modules = [ modules = [
input.nixosModules.default 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 = { programs.git.config.user = {
name = name; name = name;
email = "${name}@hyperhive"; email = "${name}@hyperhive";

View file

@ -1,4 +1,7 @@
{ pkgs, config, ... }: { pkgs, config, ... }:
let
userName = config.hyperhive.user.name;
in
{ {
imports = [ ./harness-base.nix ]; imports = [ ./harness-base.nix ];
@ -15,6 +18,11 @@
path = [ "/run/current-system/sw" ]; path = [ "/run/current-system/sw" ];
environment = { environment = {
SHELL = "${pkgs.bashInteractive}/bin/bash"; 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 # Path to the merged agent static dist. The harness serves this
# via `tower_http::ServeDir` for any request it doesn't route to # via `tower_http::ServeDir` for any request it doesn't route to
# an API endpoint. `mergedDist` is the agent-default dist with # an API endpoint. `mergedDist` is the agent-default dist with
@ -30,6 +38,14 @@
ExecStart = "${pkgs.hyperhive}/bin/hive-ag3nt serve"; ExecStart = "${pkgs.hyperhive}/bin/hive-ag3nt serve";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 2; 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;
}; };
}; };
} }

View file

@ -9,6 +9,15 @@
flakeInputs ? { }, 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 # Shared scaffolding for any hyperhive harness container — both
# sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend # sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend
@ -20,6 +29,52 @@
# only opts in from its own `agent.nix`. # only opts in from its own `agent.nix`.
imports = [ ./weston-vnc.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 { options.hyperhive.model = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "haiku"; default = "haiku";
@ -353,23 +408,25 @@
}; };
options.hyperhive.dashboardLinks = lib.mkOption { options.hyperhive.dashboardLinks = lib.mkOption {
type = lib.types.listOf (lib.types.submodule { type = lib.types.listOf (
options = { lib.types.submodule {
label = lib.mkOption { options = {
type = lib.types.str; label = lib.mkOption {
description = "Display label for the link."; 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 = [ ]; default = [ ];
example = lib.literalExpression '' example = lib.literalExpression ''
[ [
@ -505,9 +562,7 @@
} }
# hyperhive.icon must reference an SVG file when set. # hyperhive.icon must reference an SVG file when set.
{ {
assertion = assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon);
config.hyperhive.icon == null
|| lib.hasSuffix ".svg" (toString config.hyperhive.icon);
message = "hyperhive.icon must point to an .svg file"; message = "hyperhive.icon must point to an .svg file";
} }
# hyperhive.frontend.extraFiles[*].target is concatenated into # hyperhive.frontend.extraFiles[*].target is concatenated into
@ -519,9 +574,9 @@
# operator-reviewed, so this is belt-and-braces — but it's the # operator-reviewed, so this is belt-and-braces — but it's the
# kind of mistake that's easy to make and hard to spot. # kind of mistake that's easy to make and hard to spot.
{ {
assertion = lib.all ( assertion = lib.all (entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))) (
entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target)) lib.attrValues config.hyperhive.frontend.extraFiles
) (lib.attrValues config.hyperhive.frontend.extraFiles); );
message = '' message = ''
hyperhive.frontend.extraFiles: `target` must not contain hyperhive.frontend.extraFiles: `target` must not contain
`..` path segments. `..` 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/<name>/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 # Auto-inject the matrix MCP entry when matrix is enabled (#548
# phase 3). Operator can override or disable by setting their own # phase 3). Operator can override or disable by setting their own
# `extraMcpServers.matrix` (nix submodule merge takes the operator's # `extraMcpServers.matrix` (nix submodule merge takes the operator's
@ -537,7 +697,11 @@
matrix = lib.mkDefault { matrix = lib.mkDefault {
command = "${pkgs.hyperhive}/bin/hive-matrix-mcp"; command = "${pkgs.hyperhive}/bin/hive-matrix-mcp";
args = [ ]; 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 = [ "*" ]; allowedTools = [ "*" ];
}; };
}; };
@ -547,8 +711,9 @@
# Operator-set per-agent icon (hyperhive.icon). When configured, the # Operator-set per-agent icon (hyperhive.icon). When configured, the
# SVG lands at /etc/hyperhive/icon.svg; the harness serves it at # SVG lands at /etc/hyperhive/icon.svg; the harness serves it at
# GET /icon, falling back to the bundled hyperhive logo when absent. # GET /icon, falling back to the bundled hyperhive logo when absent.
environment.etc."hyperhive/icon.svg" = environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) {
lib.mkIf (config.hyperhive.icon != null) { source = config.hyperhive.icon; }; source = config.hyperhive.icon;
};
environment.etc."hyperhive/bash-allow.json".text = environment.etc."hyperhive/bash-allow.json".text =
builtins.toJSON config.hyperhive.allowedBashPatterns; builtins.toJSON config.hyperhive.allowedBashPatterns;
@ -600,13 +765,16 @@
HIVE_DEFAULT_MODEL = config.hyperhive.model; HIVE_DEFAULT_MODEL = config.hyperhive.model;
HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive";
SHELL = "${pkgs.bashInteractive}/bin/bash"; SHELL = "${pkgs.bashInteractive}/bin/bash";
} // lib.optionalAttrs (!config.hyperhive.autoCompact) { }
// lib.optionalAttrs (!config.hyperhive.autoCompact) {
# Zero watermark disables proactive compaction; the reactive path # Zero watermark disables proactive compaction; the reactive path
# (compact-on-overflow) still fires when the session is truly full. # (compact-on-overflow) still fires when the session is truly full.
HIVE_COMPACT_WATERMARK_TOKENS = "0"; HIVE_COMPACT_WATERMARK_TOKENS = "0";
} // lib.optionalAttrs config.hyperhive.forge.keepSubscriptions { }
// lib.optionalAttrs config.hyperhive.forge.keepSubscriptions {
HIVE_FORGE_KEEP_SUBSCRIPTIONS = "1"; 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; HIVE_FORGE_NOTIFY_SKIP_REASONS = lib.concatStringsSep "," config.hyperhive.forge.skipNotifyReasons;
}; };
@ -680,6 +848,8 @@
pkgs.python3 pkgs.python3
pkgs.coreutils pkgs.coreutils
]; ];
environment.HOME_DIR = homeDir;
environment.AGENT_USER = userName;
script = '' script = ''
# No `set -e`: any subshell failure must not propagate. # No `set -e`: any subshell failure must not propagate.
# A failed unit aborts `nixos-container update` which blocks rebuilds. # 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" echo "tea-login: could not resolve username from forge API; skipping"
exit 0 exit 0
fi fi
# tea reads config from ~/.config/tea/config.yml (for root: /root/.config/tea/config.yml). # tea reads config from ~/.config/tea/config.yml. The
# Write it directly so we control default:true and always # agent user's home is $HOME_DIR (set by NixOS via
# refresh a rotated token — no 'tea login add' interactive dance. # hyperhive.user.name). Write the config under that
# $HOME is unset in systemd service context (causing writes to # home + chown to the agent user so tea reads it when
# /.config/). Hardcode /root — always correct for NixOS containers # invoked as that user. Still runs as root (this
# where the harness runs as root. # service stays root-owned to avoid bootstrap
CONFIG="/root/.config/tea/config.yml" # 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 mkdir -p "$(dirname "$CONFIG")" || true
cat > "$CONFIG" << EOF cat > "$CONFIG" << EOF
logins: logins:
@ -727,7 +900,8 @@
flag_defaults: flag_defaults:
remote: "" remote: ""
EOF 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" ]; wants = [ "network-online.target" ];
environment = { environment = {
HIVE_MATRIX_URL = config.hyperhive.matrix.url; 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"; RUST_LOG = "info";
}; };
serviceConfig = { serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 5; RestartSec = 5;
# /run/hive-matrix.sock + the matrix-sdk-state sqlite dir # Run as the per-agent unix user (#658). The runtime dir
# don't need a StateDirectory= — the socket is on tmpfs (gone # (`/run/hive-matrix/`) is owned by that user via
# on restart, which is correct) and the sqlite dir lives in # `RuntimeDirectory`; claude (also as that user) can
# the bind-mounted agent state, mode-managed by the harness. # connect to the socket inside it when the stdio bridge
# spawns per turn.
User = userName;
Group = userName;
RuntimeDirectory = "hive-matrix";
}; };
}; };

View file

@ -1,4 +1,7 @@
{ pkgs, config, ... }: { pkgs, config, ... }:
let
userName = config.hyperhive.user.name;
in
{ {
imports = [ ./harness-base.nix ]; imports = [ ./harness-base.nix ];
@ -24,6 +27,9 @@
HIVE_PORT = "8000"; HIVE_PORT = "8000";
HIVE_LABEL = "hm1nd"; HIVE_LABEL = "hm1nd";
SHELL = "${pkgs.bashInteractive}/bin/bash"; 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 # Manager runs the same hive-m1nd harness binary that serves
# the per-agent web UI; point it at the merged agent static dist # the per-agent web UI; point it at the merged agent static dist
# (same shape as for sub-agents). # (same shape as for sub-agents).
@ -42,6 +48,13 @@
ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve"; ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 2; 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;
}; };
}; };
} }