hyperhive/nix/agent-modules/default.nix
atlas 837e658d4a swarm: courier an agent's store identity into its container, and log in with it
`swarm-controller` mints an agent's mTLS leaf at creation and publishes it
at `swarm/agents/<agent>/bao-mtls`. Nothing read it back. This adds the
hop that carries it the rest of the way, and the in-container consumer
that proves the hop works.

Host side, `lifecycle::agent_identity` reads the row under *this hive's*
own certificate — the hive is a principal the store already knows — and
stages the leaf and its key `0600` under a new `agent-identity/<name>`
state dir, deliberately outside every bind-mounted tree. Both files go in
as systemd credentials rather than binds, the same answer and the same
mode reason as the queue secret beside it: the staged key is unreadable
to the unprivileged agent user, and the container manager reads a
`--load-credential` source as root before re-exposing it under the
consuming unit's own `User=`. The agent is never asked to authenticate in
order to obtain the thing it authenticates with.

Container side, `hive-agent-bao-identity.service` logs in with that
certificate and reads the agent's own path back, failing the unit when
either step does not succeed. It fails loudly where the hive-side readers
degrade quietly, because a refused certificate means an agent that
believes it reaches the store and never does — a cause only the login
itself can name.

The address is the whole switch, no separate `enable`, matching how
`queue.nix` and `logs.nix` already gate themselves. A hive with a store
forwards `HIVE_AGENT_BAO_ADDR` and every agent on it gets the check; a
hive without one forwards nothing and no agent does. That is what keeps
the delivery from landing in a container with nothing to read it.

The hive can now reach an agent's identity, so hive privilege covers
agent privilege. Accepted, not mitigated: the alternative is an agent
fetching its own credential with a credential it does not yet have.

Refs #4137
2026-09-19 01:55:31 +02:00

328 lines
15 KiB
Nix

# Shared scaffolding for every hyperhive harness container.
# `../templates/agent.nix` and `../templates/ruth.nix` both import
# agents use the same service unit regardless of which entry-point
# they came from.
#
# This is the core module: container plumbing (boot/nix/nixpkgs),
# base tooling, and the cross-cutting `services.hyperhive.agent.icon` option. Each
# feature lives in its own sibling module (imported below) that
# declares its own `services.hyperhive.agent.*` options + config.
{
pkgs,
lib,
config,
# Flake inputs routed through _module.args by the agent flake.nix.
# Default to {} so the module evaluates cleanly even when the agent
# flake doesn't set up the routing pattern (e.g. during standalone
# nixos-rebuild without a flake wrapper).
flakeInputs ? { },
...
}:
let
# What an `agent.nix` still setting the dropped per-agent matrix switch is
# told. Shared by the two `mkRemovedOptionModule` entries below, one per
# spelling of the path.
matrixEnableRemoved = ''
The per-agent matrix switch is gone. Matrix is enabled for an agent exactly
when it has at least one services.hyperhive.agent.matrixAccounts entry, and
the hive-internal `main` entry is declared for you whenever
services.hyperhive.agent.matrix.url is non-null.
- `enable = true` was the default: drop the line, nothing else to do.
- `enable = false`: set services.hyperhive.agent.matrix.url = null instead
(and declare no accounts of your own). That is what leaves the account set
empty, which is now what suppresses the daemon, its path watcher and the
injected extraMcpServers.matrix entry.
See docs/tools/matrix.md.
'';
in
{
imports = [
./agent-service.nix
./bao.nix
./bash-env.nix
./claude-settings.nix
./dashboard-links.nix
./docs.nix
./forge.nix
./frontend.nix
./github.nix
./logs.nix
./matrix.nix
./mcp.nix
./network.nix
./otel.nix
./packages.nix
./queue.nix
./renamed-options.nix
./user.nix
./screen.nix
./weston-vnc.nix
(lib.mkRemovedOptionModule [ "hyperhive" "web" "useUnixSocket" ] ''
Unix socket mode is always enabled for all agents. Remove the
setting from your agent.nix.
'')
(lib.mkRemovedOptionModule [ "hyperhive" "allowedBashPatterns" ] ''
The built-in Bash tool is fully disabled; agents use
mcp__bash__run instead. Remove the setting from your agent.nix.
'')
# The dropped per-agent matrix switch. A removal, not a silent delete,
# because the definition that *changes meaning* is `false`: left undeclared
# it would be ignored and the agent would quietly gain the matrix tools its
# operator turned off. Failing the eval with the replacement spelling is the
# only outcome that cannot do that.
#
# Both spellings, for the same reason ../host-modules/deploy.nix lists both
# of `swarm|deploy.matrix.registrationTokenFile`: an existing agent.nix may
# carry either the pre-rename `hyperhive.*` path or the current one, and only
# the one it actually carries will fire. The rename shim that used to bridge
# them is gone from ./renamed-options.nix — a rename pointing at an
# undeclared option breaks evaluation for everyone, removed or not.
(lib.mkRemovedOptionModule [ "hyperhive" "matrix" "enable" ] matrixEnableRemoved)
(lib.mkRemovedOptionModule [ "services" "hyperhive" "agent" "matrix" "enable" ] matrixEnableRemoved)
];
options.services.hyperhive.agent.icon = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = lib.literalExpression "./icon.svg";
description = ''
Path to an SVG file used as this agent's icon shown on the
dashboard and the per-agent web UI (header + favicon). Commit
the SVG into the agent's config repo next to `agent.nix` and
reference it as a relative path (`./icon.svg`).
When null (the default), `GET /icon` on the per-agent web port
404s and consumers (dashboard, this agent's own page header)
fall back client-side to the frontend-bundled `/favicon.svg`
rather than a server-resolved default.
'';
};
options.services.hyperhive.agent.claudeCodePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/nix/store/-claude-code-2.1.220";
description = ''
Store path of the `claude-code` this agent runs, or `null` (the
default) to use the `claude-code` from the container's own
nixpkgs.
Set by the generated meta flake when the operator sets
`services.hyperhive.c0re.claudeCodePackage` host-side, so a hive
can run a claude built from a *different* nixpkgs than the one
its agents evaluate against a release channel can trail
unstable by weeks on this one package. It arrives as a path and
not a package because agents share the host's `/nix/store`, so
the build is already reachable here with its whole closure and
has nothing to travel.
When set, `claude` on PATH is a symlink to this path's
`bin/claude` and the container's own `claude-code` is dropped, so
there is only ever one claude in the container. Note that neither
the symlink nor anything else in the container's closure *refers*
to the target keeping it alive is the host's job, see
`services.hyperhive.c0re.claudeCodePackage`.
'';
};
config = {
assertions = [
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
# expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`.
# If `self` leaks into flakeInputs the agent gets a spurious attrset
# entry that can shadow real inputs and is almost certainly a bug.
# Guard with `or {}` so standalone evaluation stays clean when
# flakeInputs is absent from _module.args.
{
assertion = !(builtins.hasAttr "self" (config._module.args.flakeInputs or { }));
message = ''
hyperhive: `flakeInputs` must not contain "self".
In your agent flake.nix, use:
_module.args.flakeInputs = builtins.removeAttrs inputs [ "self" ];
'';
}
# services.hyperhive.agent.icon must reference an SVG file when set.
{
assertion =
config.services.hyperhive.agent.icon == null
|| lib.hasSuffix ".svg" (toString config.services.hyperhive.agent.icon);
message = "services.hyperhive.agent.icon must point to an .svg file";
}
];
# Operator-set per-agent icon (services.hyperhive.agent.icon). When configured, the
# SVG lands at /etc/hyperhive/icon.svg; the harness serves it at
# GET /icon, 404ing when absent (client-side fallback, no
# server-side default). Consumed by forge-avatar-sync (./forge.nix)
# and the matrix avatar sync (./matrix.nix) too.
environment.etc."hyperhive/icon.svg" = lib.mkIf (config.services.hyperhive.agent.icon != null) {
source = config.services.hyperhive.agent.icon;
};
boot.isNspawnContainer = true;
# Use a disk-backed /tmp instead of the default tmpfs so large scratch
# writes (nix-develop shells, cargo build dirs, multi-GB downloads) land
# on disk rather than eating container RAM. The tmpfs default mounts
# ~3.2 GB of RAM per container; disk-backed /tmp is effectively unlimited
# and cheaper for agents that do heavy build work.
#
# cleanOnBoot defaults to false in nixpkgs — set it explicitly so /tmp is
# cleared on each container start (D! tmpfiles rule), preserving the
# ephemeral-per-boot semantics agents expect from a tmpfs /tmp, just
# without the RAM cost.
boot.tmp.useTmpfs = false;
boot.tmp.cleanOnBoot = true;
# Every agent gets flakes + the modern `nix` CLI out of the box.
# Equivalent to passing `--extra-experimental-features 'nix-command
# flakes'` on every invocation. Agents shell out to `nix build` /
# `nix flake` constantly (devshells, ad-hoc evals, fetching their
# own MCP-server flakes); without this they hit the "experimental
# feature not enabled" wall on the first try.
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
# `lib.mkForce` overrides nixpkgs's normal-priority `false` so
# in-container `nix build` invocations fall back to unsandboxed
# local builds rather than failing on the missing user-namespace.
# See `docs/process/gotchas.md::Containerized nix-daemon needs
# sandbox-fallback = true` + `docs/trust-boundary/security.md` for the rationale.
#
# Note: with NIX_REMOTE=daemon below this becomes a no-op for the
# common case — daemon-routed builds run on the host where sandboxing
# works. It stays as a belt-and-suspenders fallback for any context
# that bypasses the daemon (e.g. direct nix-store invocations).
nix.settings.sandbox-fallback = lib.mkForce true;
# Fall back to a local build when a remote builder can't be reached,
# instead of hard-failing the whole invocation.
#
# This is NOT redundant with the host daemon's own setting, and that
# is the subtle part: `fallback` (protocol `tryFallback`) is a *client*
# option. Every nix client transmits it to the daemon on connect, so
# the client — i.e. this container — decides whether a failed remote
# dispatch may degrade to a local build, even though the build itself
# executes on the host daemon under NIX_REMOTE=daemon below. Contrast
# `builders`, which is genuinely daemon-side and which an untrusted
# client cannot override. Without this, a container inherits nix's
# default `false`, so a momentarily unreachable remote builder kills
# the invocation while the exact same build started on the host
# succeeds.
nix.settings.fallback = true;
# Route ALL nix invocations in this container through the host
# nix-daemon socket, regardless of whether the caller is root or
# non-root. Without this, root contexts (PID 1, systemd services
# running as root) default to store=auto which resolves to the LOCAL
# store — bypassing the shared daemon, its remote builders, and the
# host's prebuilt derivation cache, causing spurious full rebuilds.
#
# systemd.globalEnvironment sets DefaultEnvironment in systemd.conf,
# so every unit started by PID 1 inherits NIX_REMOTE=daemon.
# Non-root nix clients already default to the daemon socket, so this
# is a no-op for them; it only matters for root services that would
# otherwise silently use the local store.
systemd.globalEnvironment.NIX_REMOTE = "daemon";
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
# evaluates its own `nixpkgs` instance, so the operator's host-level
# `nixpkgs.config.allowUnfreePredicate` does not propagate into here —
# we have to allow it inside the container's config as well.
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ];
# Core tooling every agent gets. Per-bin split packages (see
# nix/packages/default.nix + ./packages.nix) rather than the full
# `hyperhive` bundle — that bundle also carries `hivectl` (a
# host-admin CLI that dials the *host* admin socket — useless and
# unreachable from inside a container — wrapped with
# `wireguard-tools` for `hivectl wg`). The daemon/harness/MCP bins
# the harness execs (hive-agent{,-mcp}, hive-bash-daemon,
# hive-subagent-daemon, hive-matrix-daemon, hive-matrix-mcp) are
# wired via their own
# ExecStart/command lines in the sibling modules — they don't need
# to be on PATH too. Only this one is actually looked up on PATH
# by claude/shell code inside the container:
# `hive-metric` (agent-emitted custom metrics CLI,
# docs/scheduler/observability.md).
environment.systemPackages = [
config.services.hyperhive.agent.packages.hive-metric
]
++ [
(
if config.services.hyperhive.agent.claudeCodePath == null then
pkgs.claude-code
else
# Host-pinned claude: a symlink farm around a path the
# container was handed as text. It has to be a derivation —
# `environment.systemPackages` coerces a store-path *string*
# with `toDerivation`, i.e. `builtins.storePath`, which pure
# evaluation rejects (`systemd.services.*.path` does the same,
# which is why the harness gets this via PATH like everything
# else rather than a unit-level entry). Interpolating the path
# into the builder is just text, so it evaluates anywhere.
#
# The symlink registers no store reference — the target isn't
# among this derivation's inputs, so nothing here keeps the
# binary alive. That is deliberate and it is the host's job:
# see `services.hyperhive.c0re.claudeCodePackage`.
pkgs.runCommandLocal "claude-code-pinned" { } ''
mkdir -p "$out/bin"
ln -s ${config.services.hyperhive.agent.claudeCodePath}/bin/claude "$out/bin/claude"
''
)
]
++ (with pkgs; [
bashInteractive
coreutils-full
# procps for kill — used by the web UI's /api/cancel and /api/logout
# to SIGINT the harness-spawned claude child found via a /proc scan
# (see hive-agent::web_ui::find_claude_child).
procps
# jq: JSON processing in shell — useful for parsing API responses,
# forge REST calls, sqlite output, etc.
jq
# curl: HTTP client for forge REST API and other web requests.
curl
]);
# HIVE_ASSETS_DIR points at the project's static runtime assets
# (branding + claude prompts; see `nix/packages/assets.nix`). Set
# here so both the harness binary and any user-shell `cargo run`
# inside the container resolve them from the same path.
# SHELL must be set so claude's Bash tool finds a POSIX shell.
# HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the
# host-level `services.hyperhive.c0re.contextWindowTokens` option — not
# set here.
environment.variables = {
HIVE_ASSETS_DIR = "${config.services.hyperhive.agent.packages.assets}/share/hyperhive";
SHELL = "${pkgs.bashInteractive}/bin/bash";
# Route interactive-shell nix invocations through the host daemon.
# Redundant with /etc/profile.d/nix-daemon.sh but ensures it's set
# regardless of which profile files are sourced.
NIX_REMOTE = "daemon";
};
# Git is needed by claude's Bash tool (for the agent <-> manager config
# request flow) and by hive-c0re's own setup_applied / setup_proposed.
# The per-agent `applied/<name>/flake.nix` overrides `user.name` and
# `user.email` with the agent's identity — values here are `mkDefault`
# so the per-agent override wins without needing `mkForce`.
programs.git = {
enable = true;
config = {
user = {
name = lib.mkDefault "hyperhive";
email = lib.mkDefault "hyperhive@local";
};
init.defaultBranch = lib.mkDefault "main";
};
};
system.stateVersion = "25.11";
};
}