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