refactor: nix/host-modules + nix/agent-modules layout, update doc paths
This commit is contained in:
parent
cb755b677c
commit
4a48ce5024
52 changed files with 48 additions and 44 deletions
215
nix/agent-modules/user.nix
Normal file
215
nix/agent-modules/user.nix
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# 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
|
||||
'';
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue