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

@ -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/<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.
/// 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>`
// 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/<n>/main` currently points at (deployed/<latest>).
// 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/<agent>/.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"
);
}
}

View file

@ -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";