Four host glue units fetched a secret from swarm-bao and rendered it with `> path; chmod`: a reader racing the write could see a truncated file, and briefly one at the wrong mode before the chmod landed. glue-matrix-bao-token.nix, glue-queue-agent-credential.nix (both files), swarm-grafana.nix and swarm-otel.nix now write to a same- directory temp file, set its final mode/owner, then `mv -f` it over the target — a shared `atomic_write_secret` helper (nix/host-modules/lib/atomic-write-secret.nix) so the five call sites share one implementation. The first-boot `/root/.claude` migration in nix/agent-modules/user.nix wrote its done-marker unconditionally, so a failed `cp` (disk full, permission error) left the marker behind and no boot ever retried the copy. The marker is now written only when there was nothing to migrate or the copy succeeded; `cp -an`'s no-clobber semantics already make a retry after a partial copy safe. Refs #4723
233 lines
10 KiB
Nix
233 lines
10 KiB
Nix
# Per-agent unix user: the `services.hyperhive.agent.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.services.hyperhive.agent.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.services.hyperhive.agent.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 `services.hyperhive.agent.user.uid` is explicitly set.
|
|
'';
|
|
};
|
|
|
|
options.services.hyperhive.agent.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.services.hyperhive.agent.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 `services.hyperhive.agent.user.uid`
|
|
to the same value (the conventional Unix pattern for per-user
|
|
groups where uid == gid), but can be set independently.
|
|
'';
|
|
};
|
|
|
|
options.services.hyperhive.agent.user.passwordlessSudo = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
example = false;
|
|
description = ''
|
|
Grant `${config.services.hyperhive.agent.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.services.hyperhive.agent.user.uid == null
|
|
|| (
|
|
config.services.hyperhive.agent.user.uid >= 1000 && config.services.hyperhive.agent.user.uid < 60000
|
|
);
|
|
message = ''
|
|
services.hyperhive.agent.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.services.hyperhive.agent.user.gid == null
|
|
|| (
|
|
config.services.hyperhive.agent.user.gid >= 1000 && config.services.hyperhive.agent.user.gid < 60000
|
|
);
|
|
message = ''
|
|
services.hyperhive.agent.user.gid must be in [1000, 60000) — same range
|
|
constraint as services.hyperhive.agent.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.services.hyperhive.agent.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.services.hyperhive.agent.user.uid != null) {
|
|
uid = config.services.hyperhive.agent.user.uid;
|
|
};
|
|
users.groups.${userName} =
|
|
{ }
|
|
// lib.optionalAttrs (config.services.hyperhive.agent.user.gid != null) {
|
|
gid = config.services.hyperhive.agent.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
|
|
# `services.hyperhive.agent.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.services.hyperhive.agent.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/agent-lifecycle/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"
|
|
# The marker is only written once nothing is left to migrate — either
|
|
# there was nothing under /root/.claude, or `cp` copied it all. A
|
|
# failed `cp` (disk full, permission error) leaves the marker absent,
|
|
# so the next boot's activation retries; `-an` never clobbers a file
|
|
# this attempt already placed, so a retry after a partial copy is
|
|
# exactly as safe as the first attempt.
|
|
marker=/var/lib/hive-agent-user-migrated
|
|
if [ ! -e "$marker" ]; then
|
|
if [ -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"
|
|
mkdir -p "$(dirname "$marker")"
|
|
: > "$marker"
|
|
else
|
|
echo "hive-agent-user-migrate: copying /root/.claude to $homeDir/.claude failed; will retry next boot" >&2
|
|
fi
|
|
else
|
|
mkdir -p "$(dirname "$marker")"
|
|
: > "$marker"
|
|
fi
|
|
fi
|
|
# Scope state + harness chowns to THIS container's own dirs only.
|
|
# The glob `/agents/*/state` also matches other agents' state dirs
|
|
# bind-mounted into a `ManageRootAgent` holder's container, which
|
|
# would clobber the ownership those dirs' own activation scripts set
|
|
# — producing intermittent EACCES for that agent's harness between
|
|
# the holder's rebuild and its own next activation. Config dirs are
|
|
# kept broad because the holder legitimately owns the proposed-config
|
|
# repos it edits.
|
|
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 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
|
|
'';
|
|
};
|
|
}
|