/etc/tmpfiles.d/hyperhive-agents.conf was a boot-time backstop (#2290) that pre-created every agent's bind sources. The start preamble already creates them for every c0re-driven start, and on this host only hive-c0re starts agent containers. The file was also the reason the socket dir's owner had to be declared there, which is how it spent its life at `0777 root root` whenever the uid could not be resolved (#4742). - hive-priv gains `EnsureAgentSocketDir { name }`, called from `set_nspawn_flags` in every start path. It creates `/run/hive-agent/<name>` `0751 root:root` with mkdirat relative to an O_DIRECTORY|O_NOFOLLOW fd for the parent. An existing entry has to be a directory (fstatat AT_SYMLINK_NOFOLLOW); anything else is refused, and a directory is left alone. hive-c0re's own create_dir_all went: its /run is read-only under ProtectSystem=strict. - The container's `hive-agent-user-migrate` activation chowns that dir to the agent user and sets 0751, the same way it already handles state/ and harness/. It refuses a symlink or non-directory there, since `test -d` and chmod follow links. No host-side passwd parse, and no window where the dir is world-writable. - `/run/hyperhive/agents/<name>` stays created by hive-c0re itself (`ensure_agent_runtime_dir`). It holds the `mcp.sock` that hive-c0re binds as hive-core, so it must not become root- or agent-owned. - The `/run/hive-agent` parent is declared in hive-priv.nix, `0755 root:root`, instead of hive-gateway's hive-core rule. hive-priv is its only writer now, and hive-priv's ReadWritePaths needs it to exist. - The manager start in `ensure_root_agent` now goes through `converge_start_preamble` + `start_with_fallback`. It was a bare start, so after a reboot the manager's bind sources existed only because of the tmpfiles file, and its limits drop-in did not exist at all. - Removed: `sync_tmpfiles`, `agent_uid_gid` / `parse_passwd_uid_gid`, `priv_client::sync_agent_tmpfiles`, `AgentTmpfilesEntry`, the tmpfiles body builder and their tests, plus the three call sites. - Legacy: hive-priv unlinks the file at every start, ignoring ENOENT. `SyncAgentTmpfiles` stays one release as a payload-ignoring variant that does the same unlink and returns Ok, for an older hive-c0re. Salvaged from #4752: the boundary.md correction that nginx only dials, because ProtectSystem=strict makes its /run read-only. Behaviour change: a manual `nixos-container start h-<name>` right after a reboot, before hive-c0re has started that agent, now fails on a missing bind source instead of starting. Closes #4742
247 lines
11 KiB
Nix
247 lines
11 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 socket dir is bind-mounted from the host, where hive-priv creates
|
|
# it `0751 root`. The harness binds its sockets here as this user; the
|
|
# 0751 is what lets hive-c0re and nginx dial them without listing
|
|
# (docs/trust-boundary/boundary.md, "the per-agent socket dir"). Not
|
|
# recursive: only the harness writes inside it. `test -d` and `chmod`
|
|
# follow symlinks, so a link here is refused rather than handed over.
|
|
socketDir="/run/hive-agent/$userName"
|
|
if [ -L "$socketDir" ] || { [ -e "$socketDir" ] && [ ! -d "$socketDir" ]; }; then
|
|
echo "hive-agent-user-migrate: $socketDir is a symlink or not a directory; not handing it to $userName" >&2
|
|
elif [ -d "$socketDir" ]; then
|
|
chown -h "$userName:$userName" "$socketDir" \
|
|
&& chmod 0751 "$socketDir" \
|
|
|| echo "hive-agent-user-migrate: could not hand $socketDir to $userName; the harness cannot bind its sockets" >&2
|
|
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
|
|
'';
|
|
};
|
|
}
|