# Per-agent unix user: the `hyperhive.user.*` options, the user/group # declarations, passwordless sudo, and the first-boot migration that # chowns the bind-mounted state dirs to the agent user. { pkgs, lib, config, ... }: let userName = config.hyperhive.user.name; homeDir = "/home/${userName}"; in { # Per-agent unix user the harness + co-process daemons run as. # 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 unless `hyperhive.user.uid` is explicitly set. ''; }; options.hyperhive.user.uid = lib.mkOption { type = lib.types.nullOr lib.types.int; default = null; example = 1100; description = '' Optional fixed UID for the per-agent unix user. `null` (default) lets NixOS auto-assign from the normal-user range (≥ 1000), which is the right default for most deployments — the UID stays stable across container rebuilds because each container only has one normal user and the assignment is written into the container's `/etc/passwd` at activation time. Set an explicit value only when the host needs a predictable UID for the agent's state files — e.g. if an operator script references files by numeric UID, or to keep ownership stable across full container destroy + recreate on a fresh host. Values must be in `[1000, 60000)`. Using UIDs < 1000 clashes with system accounts and is rejected by NixOS. ''; }; options.hyperhive.user.gid = lib.mkOption { type = lib.types.nullOr lib.types.int; default = null; example = 1100; description = '' Optional fixed GID for the per-agent unix group. `null` (default) lets NixOS auto-assign. Usually set alongside `hyperhive.user.uid` to the same value (the conventional Unix pattern for per-user groups where uid == gid), but can be set independently. ''; }; 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. ''; }; config = { assertions = [ { assertion = config.hyperhive.user.uid == null || (config.hyperhive.user.uid >= 1000 && config.hyperhive.user.uid < 60000); message = '' hyperhive.user.uid must be in [1000, 60000) — values below 1000 clash with system accounts; values ≥ 60000 are reserved by NixOS for dynamic allocation. Leave unset (null) to let NixOS auto-assign. ''; } { assertion = config.hyperhive.user.gid == null || (config.hyperhive.user.gid >= 1000 && config.hyperhive.user.gid < 60000); message = '' hyperhive.user.gid must be in [1000, 60000) — same range constraint as hyperhive.user.uid. ''; } ]; # The container activation script (hive-agent-user-migrate) chowns # the bind-mounted state dir — including credential files written # by hive-c0re before the container was built — to this user on # every boot, so agent processes can always read their own tokens. 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. shell = pkgs.bashInteractive; } // lib.optionalAttrs (config.hyperhive.user.uid != null) { uid = config.hyperhive.user.uid; }; users.groups.${userName} = { } // lib.optionalAttrs (config.hyperhive.user.gid != null) { gid = config.hyperhive.user.gid; }; # `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool # keep working with anything that expected root (systemctl, # nix-env, etc.) without prompting. 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 to the per-agent unix user — creates the # home dir, chowns the bind-mounted state + `~/.claude/`, and # (marker-guarded) moves any leftover `/root/.claude` content # from the previous root-run shape. See # `docs/persistence.md::First-boot agent-user migration` for the # step-by-step rationale; this script implements it. system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] '' homeDir=${lib.escapeShellArg homeDir} userName=${lib.escapeShellArg userName} mkdir -p "$homeDir" chown "$userName:$userName" "$homeDir" 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" 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" # Scope state + harness chowns to THIS container's own dirs only. # The glob `/agents/*/state` also matches child-agent state dirs that # are bind-mounted into parent containers, which would clobber the # ownership those dirs' own activation scripts set — producing # intermittent EACCES for the child agent's harness between a parent # rebuild and the child's next activation. Config dirs are kept broad # because the parent legitimately owns child proposed-config repos. if [ -d "/agents/$userName/state" ]; then chown -hR "$userName:$userName" "/agents/$userName/state" 2>/dev/null || true fi if [ -d "/agents/$userName/harness" ]; then chown -hR "$userName:$userName" "/agents/$userName/harness" 2>/dev/null || true fi # The proposed-config repo is RW-mounted into the editing (parent/ # manager) agent and owned by it; hive-c0re only pulls from it. Heal # it to this user too — same as state/harness. In an agent's own # container its config is RO-mounted, so the chown there just fails # harmlessly (|| true). for configDir in /agents/*/config; do [ -d "$configDir" ] || continue chown -hR "$userName:$userName" "$configDir" 2>/dev/null || true done if [ -d "$homeDir/.claude" ]; then chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true # 0755 so hive-core (a different unix user) can list the dir and # detect a valid claude session. Credential files inside are 0600 # so secrets stay private regardless of the directory mode. # ensure_claude_dir sets 0755 on creation but cannot re-chmod after # hive-agent-user-migrate chowns the dir to the agent user; this # activation script runs as root and handles the correction. chmod 755 "$homeDir/.claude" 2>/dev/null || true fi ''; }; }