agents: drop root, run as per-agent unix user with passwordless sudo (#658)
This commit is contained in:
parent
71211e5722
commit
6b6c6775ee
10 changed files with 349 additions and 71 deletions
|
|
@ -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 -->` /
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}`"))?;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue