The endpoint, protocol, temporality preference and resource labels were shipped only inside claude's managed settings json, so they applied to claude's own process. hive-bash-daemon, hive-mcp-http, hive-matrix-daemon and hive-forge-notify are systemd *siblings* of claude rather than its children, so nothing shipped there could ever reach them: `hive-metric` invoked from a tool call exited with "OTEL_EXPORTER_OTLP_ENDPOINT not set", which is the honest failure of a value it structurally could not see. Declare those variables container-wide in a new agent module instead — systemd.globalEnvironment for every unit PID 1 starts, environment.variables for login shells. Both are needed and neither implies the other; NIX_REMOTE is set both ways for the same reason. Claude keeps only what is genuinely its own: the telemetry master flag, the feedback-survey flag, the version label, and which signals it exports. A different producer in the same container may legitimately emit only metrics. The hyperhive.otel.* options move across with them. They have more than one consumer now, so their home is the OTEL module rather than the claude one.
288 lines
13 KiB
Nix
288 lines
13 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 `hyperhive.icon` option. Each
|
|
# feature lives in its own sibling module (imported below) that
|
|
# declares its own `hyperhive.*` 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 ? { },
|
|
...
|
|
}:
|
|
{
|
|
imports = [
|
|
./agent-service.nix
|
|
./bash-env.nix
|
|
./claude-settings.nix
|
|
./dashboard-links.nix
|
|
./docs.nix
|
|
./forge.nix
|
|
./frontend.nix
|
|
./github.nix
|
|
./matrix.nix
|
|
./mcp.nix
|
|
./network.nix
|
|
./otel.nix
|
|
./packages.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.
|
|
'')
|
|
];
|
|
|
|
options.hyperhive.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.hyperhive.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" ];
|
|
'';
|
|
}
|
|
# hyperhive.icon must reference an SVG file when set.
|
|
{
|
|
assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon);
|
|
message = "hyperhive.icon must point to an .svg file";
|
|
}
|
|
];
|
|
|
|
# Operator-set per-agent icon (hyperhive.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.hyperhive.icon != null) {
|
|
source = config.hyperhive.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/gotchas.md::Containerized nix-daemon needs
|
|
# sandbox-fallback = true` + `docs/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-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/observability.md).
|
|
environment.systemPackages = [
|
|
config.hyperhive.packages.hive-metric
|
|
]
|
|
++ [
|
|
(
|
|
if config.hyperhive.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.hyperhive.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.hyperhive.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";
|
|
};
|
|
}
|