Compare commits

..
11 changed files with 356 additions and 76 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,21 @@ 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.
#[must_use]
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 +162,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 +317,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 +886,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 +964,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 +1167,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

@ -15,10 +15,11 @@ pub const DEFAULT_HOMESERVER: &str = "http://localhost:8008";
/// Default unix socket path the daemon listens on inside the agent
/// container. The stdio MCP bridge `connect()`s here on every tool call.
/// `/run/hive-matrix.sock` is a tmpfs path that disappears on container
/// restart — fine, because the daemon recreates the socket on its own
/// boot.
pub const DEFAULT_DAEMON_SOCKET: &str = "/run/hive-matrix.sock";
/// Lives under systemd's `RuntimeDirectory=hive-matrix` (a tmpfs path
/// that disappears on container restart — fine, because the daemon
/// recreates the socket on its own boot) so the agent unix user
/// (post-#658) can bind a socket inside it without root in `/run`.
pub const DEFAULT_DAEMON_SOCKET: &str = "/run/hive-matrix/socket";
/// Resolve the matrix access-token file path. Override via
/// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`,
@ -41,7 +42,7 @@ pub fn homeserver_url() -> String {
}
/// Resolve the daemon's unix socket path. Override via
/// `HIVE_MATRIX_SOCKET`; default is `/run/hive-matrix.sock`.
/// `HIVE_MATRIX_SOCKET`; default is `/run/hive-matrix/socket`.
#[must_use]
pub fn daemon_socket() -> PathBuf {
std::env::var_os("HIVE_MATRIX_SOCKET").map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from)

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,7 +408,8 @@
};
options.hyperhive.dashboardLinks = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
type = lib.types.listOf (
lib.types.submodule {
options = {
label = lib.mkOption {
type = lib.types.str;
@ -369,7 +425,8 @@
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;
};
};
}