hyperhive/nix/agent-modules/user.nix

215 lines
8.9 KiB
Nix

# 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
'';
};
}