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)`.
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/<agent>/.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 `<!-- role:agent -->` /

View file

@ -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/<agent>` and the OAuth dir lives at
/// `/home/<agent>/.claude`). Overridable via `HYPERHIVE_CLAUDE_DIR`.
#[must_use]
pub fn default_dir() -> PathBuf {
crate::paths::claude_dir()

View file

@ -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/<agent> (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}`"))?;

View file

@ -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/<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`,
//! `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/<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.
#[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")
}

View file

@ -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/<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
/// 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<PathBuf> {
// 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.

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

View file

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

View file

@ -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/<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
# 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";
};
};

View file

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