refactor: split c0re module into options/theme/environment, hive-priv standalone

This commit is contained in:
müde 2026-07-13 21:50:32 +02:00
commit 874a900bdc
6 changed files with 750 additions and 688 deletions

View file

@ -18,6 +18,7 @@
./hive-gateway
./hive-matrix.nix
./hive-network.nix
./hive-priv.nix
./hive-tls.nix
./otel.nix
./swarm.nix

View file

@ -1,11 +1,9 @@
# The hive-c0re coordinator daemon (unprivileged `hive-core` user) and
# its narrow root helper hive-priv, both socket-activated. Options
# under `services.hyperhive.c0re.*`. The package/source options
# (`package`, `frontend`, `assets`, `xdgIcons`, `hyperhiveFlake`,
# `hyperhiveDocs`, `agentBaseToplevel`, `managerToplevel`) have no
# in-module defaults — the flake's `nixosModules.default` wires them
# to this flake's own package outputs via `lib.mkDefault`, so
# operator overrides still win and no overlay is involved.
# The hive-c0re coordinator daemon (runs as the unprivileged
# `hive-core` user), socket-activated at /run/hyperhive/host.sock.
# Layout: ./options.nix (option declarations), ./theme.nix (stylix
# frontend theming → `servedFrontend`), ./environment.nix (the daemon
# unit's env attrset). The root privileged helper it delegates to is
# its own module (../hive-priv.nix).
{
pkgs,
lib,
@ -53,383 +51,12 @@ let
model_prices = cfg.modelPrices;
build_slots = cfg.buildSlots;
};
# Stylix theme integration (zero-op auto-detect). When the operator's
# host config has stylix enabled, generate a base16 `colors.css` from
# its palette and overlay it onto the bundled frontend dist so the
# dashboard re-themes with no operator action and no npm/esbuild rebuild
# (a pure file-copy over the prebuilt dist). `colors.css` is the entire
# swap contract — `theme.css` derives every semantic var from the 16
# base16 slots (see docs/web-ui/css-vars.md). The guarded access makes
# this a clean no-op when stylix isn't imported into the host config.
stylixThemeColors =
if (config.stylix.enable or false) && ((config.lib.stylix or { }) ? colors) then
config.lib.stylix.colors.withHashtag
else
null;
themedColorsCss =
c:
pkgs.writeText "hyperhive-colors.css" ''
:root {
--base00: ${c.base00};
--base01: ${c.base01};
--base02: ${c.base02};
--base03: ${c.base03};
--base04: ${c.base04};
--base05: ${c.base05};
--base06: ${c.base06};
--base07: ${c.base07};
--base08: ${c.base08};
--base09: ${c.base09};
--base0A: ${c.base0A};
--base0B: ${c.base0B};
--base0C: ${c.base0C};
--base0D: ${c.base0D};
--base0E: ${c.base0E};
--base0F: ${c.base0F};
}
'';
# Overlay the generated colors.css onto both dist subtrees. Both the
# dashboard (served by hive-c0re via HIVE_STATIC_DIR) and the agent UIs
# (served by the gateway from HIVE_AGENT_FRONTEND_DIR — static files
# straight from the store) read their colors.css from this host-side
# tree, so swapping both re-themes both surfaces.
#
# Not covered here: an agent reached directly on its own harness web
# server (no gateway) serves from its per-agent `mergedDist`, built in
# the agent's own nixosSystem with no access to the host's stylix
# colours — theming that path needs the base16 palette forwarded
# host→agent, tracked separately.
themedFrontend =
c:
pkgs.runCommand "hyperhive-frontend-themed" { } ''
cp -r ${cfg.frontend} $out
chmod -R u+w $out
install -m644 ${themedColorsCss c} $out/dashboard/static/colors.css
install -m644 ${themedColorsCss c} $out/agent/static/colors.css
'';
servedFrontend =
if stylixThemeColors != null then themedFrontend stylixThemeColors else cfg.frontend;
in
{
options.services.hyperhive.c0re = {
enable = lib.mkOption {
type = lib.types.bool;
default = config.services.hyperhive.enable;
defaultText = lib.literalExpression "config.services.hyperhive.enable";
description = "Enable hive-c0re coordinator daemon (auto-enabled by services.hyperhive.enable).";
};
package = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.default";
description = ''
hyperhive workspace package. Provides `/bin/hive-c0re`
(coordinator daemon + admin-socket CLI) and `/bin/hivectl`
(operator-facing host CLI for ad-hoc administration). Wired to
this flake's `packages.<system>.default` by
`nixosModules.default` (via `lib.mkDefault`, so setting it here
wins).
'';
};
frontend = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend";
description = ''
Bundled frontend dist (see `nix/packages/frontend.nix`). Output
has `dashboard/` and `agent/` subdirectories hive-c0re serves
`dashboard/` via `tower_http::ServeDir` from the path passed
in `HIVE_STATIC_DIR`. Override to ship a custom dashboard SPA;
the JSON contract (`/api/state`, the SSE streams, the action
endpoints) is the source of truth for any replacement.
'';
};
servedFrontend = lib.mkOption {
type = lib.types.package;
internal = true;
readOnly = true;
default = servedFrontend;
defaultText = lib.literalExpression "<stylix-themed overlay of `frontend`>";
description = ''
Internal, read-only: `frontend` re-themed with the active stylix
palette (or `frontend` verbatim when unthemed); has `dashboard/`
and `agent/`. Exposed so the hive-gateway module can static-serve
`dashboard/` as an nginx root instead of proxying to hive-c0re.
'';
};
assets = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets";
description = ''
Bundled static runtime assets (see `nix/packages/assets.nix`): the
project's branding family + the claude system-prompt template +
claude-settings JSON. Output has `share/hyperhive/{branding,prompts}/`;
passed to hive-c0re's systemd unit via `HIVE_ASSETS_DIR`
(`hive_sh4re::assets::*` resolve paths underneath). Override to
ship customised branding or prompts without rebuilding the
rust derivation.
'';
};
xdgIcons = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.xdg-icons";
description = ''
XDG icon set + .desktop entries for hyperhive processes (see
`nix/packages/hive-xdg-icons.nix`), installed into the host
system packages so desktop environments can match hyperhive
processes to their icon.
'';
};
hyperhiveFlake = lib.mkOption {
type = lib.types.str;
defaultText = lib.literalMD "the hyperhive flake's own filtered source store path";
description = ''
URL of the hyperhive flake (no fragment). Inlined into each
per-agent `flake.nix` at `inputs.hyperhive.url`. The per-agent
flake then pulls `hyperhive.nixosConfigurations.agent-base` to
build the container. Wired by `nixosModules.default` to this
flake's own filtered source only override if you want agents
tracking a different ref.
'';
};
hyperhiveDocs = lib.mkOption {
type = lib.types.str;
defaultText = lib.literalMD "the docs/ tree's own store path";
description = ''
URL of the narrow `docs/` source (no fragment). Inlined into the
generated meta `flake.nix` at `inputs.hyperhive-docs.url` and
threaded to each agent as `hyperhive.docs.source`, from which the
harness resolves `$HIVE_DOCS_DIR`. Its own store path separate
from `hyperhiveFlake` so a doc edit only re-locks this input
instead of rebuilding every agent container.
'';
};
agentBaseToplevel = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.agent-base-toplevel";
description = ''
Pre-built agent-base container system closure, pulled into the
host system closure when `preBuildAgentTemplates` is on. Wired
by `nixosModules.default`; only evaluated when that option is
enabled.
'';
};
managerToplevel = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.ruth-toplevel";
description = ''
Pre-built manager (ruth) container system closure see
`agentBaseToplevel`.
'';
};
nixpkgsFlake = lib.mkOption {
type = lib.types.str;
default = "path:${pkgs.path}";
defaultText = lib.literalMD "`\"path:\${pkgs.path}\"`";
description = ''
Store-path URL for the `nixpkgs` input in the generated meta
flake. The meta flake declares this as a top-level input and
wires `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` so
every agent container evaluates with this exact nixpkgs.
Defaults to `"path:''${pkgs.path}"` the store path of the
nixpkgs the host NixOS module was evaluated with. When the
operator sets `inputs.hyperhive.inputs.nixpkgs.follows =
"nixpkgs"` in their host flake, `pkgs.path` resolves to the
host's own nixpkgs, so agents transparently track the same
channel as the host.
Override to pin agents to a specific nixpkgs version regardless
of the host's channel.
'';
};
dashboardPort = lib.mkOption {
type = lib.types.port;
default = 7000;
description = "TCP port the hive-c0re dashboard listens on.";
};
operatorPronouns = lib.mkOption {
type = lib.types.str;
default = "she/her";
example = "they/them";
description = ''
Operator pronouns, free text. Threaded into every agent
container as the `HIVE_OPERATOR_PRONOUNS` env var; the
harness substitutes it into the agent / manager system
prompt at boot so claude refers to the operator naturally
in third person ("ask her", "tell them", etc.). Changes
propagate to running agents on the next ` R3BU1LD`
forwards as a meta flake env-var bump, no per-agent
approval needed.
'';
};
preBuildAgentTemplates = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Pre-fetch the per-container system closures (agent-base +
manager toplevels) into the host's /nix/store as part of this
host's NixOS build, instead of letting the first agent spawn
do all the work.
Enabling this adds roughly the full nixpkgs runtime closure +
claude-code + the harness binary to your system closure size
(low single-digit GB), but the first `nixos-container start`
for any agent then completes in seconds instead of minutes
because nothing's left to fetch.
Off by default because the toplevels are pinned to
`x86_64-linux` (nixos-containers run native arch). Enabling
on an aarch64 host would force nix to build the x86 closure
via cross or a remote builder, which is rarely what you want.
Flip to `true` on an x86_64 host when you care more about
first-spawn latency than host store size or just
`nix build .#agent-base-toplevel` once manually to warm the
store.
'';
};
contextWindowTokens = lib.mkOption {
type = lib.types.attrsOf lib.types.int;
default = {
haiku = 200000;
sonnet = 1000000;
opus = 1000000;
};
example = {
haiku = 150000;
sonnet = 900000;
};
description = ''
Per-model context-window sizes in tokens. Each key is a
model-family short name matched case-insensitively as a
substring of the active model name at runtime (e.g. `"sonnet"`
matches `"claude-sonnet-4-5"`). The defaults cover the known
Anthropic families; add entries for new models or override
existing ones here to change the window for all agents at once.
Passed to `hive-c0re serve` as JSON and injected into every
container's harness service environment as
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>`. Changes propagate
on the next ` R3BU1LD` no per-agent approval needed.
'';
};
modelPrices = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
input = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million input tokens.";
};
output = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million output tokens.";
};
cache_read = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million cache-read tokens.";
};
cache_write = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million cache-creation (write) tokens.";
};
};
}
);
# Current Anthropic list prices for the Claude 4.x family (Opus
# 4.x, Sonnet 4.x, Haiku 4.5); cache_write is the 1-hour cache-TTL
# price (the default through the Claude subscription the agents run
# on). Keep in sync with `builtin_prices` in
# hive-c0re/src/hive_stats.rs.
default = {
opus = {
input = 5.0;
output = 25.0;
cache_read = 0.5;
cache_write = 10.0;
};
sonnet = {
input = 3.0;
output = 15.0;
cache_read = 0.3;
cache_write = 6.0;
};
haiku = {
input = 1.0;
output = 5.0;
cache_read = 0.1;
cache_write = 2.0;
};
};
example = {
sonnet = {
input = 3.0;
output = 15.0;
cache_read = 0.3;
cache_write = 6.0;
};
};
description = ''
Per-model USD prices (per **million** tokens) used for the
hive-wide cost *estimate* on the dashboard's ST4TS tab. Each key
is a model-family short name matched case-insensitively as a
substring of the active model id at runtime (e.g. `"sonnet"`
matches `"claude-sonnet-4-5"`); the longest matching key wins, so
a specific entry beats a generic family name. Any model not
covered by this table falls back to hive-c0re's built-in
estimate.
The defaults track Anthropic list pricing at the time of
writing override them here to keep the estimate current
without a code change. Passed to `hive-c0re serve` as JSON via
`--model-prices`; read only by hive-c0re itself (not injected
into containers). Changes apply on the next host rebuild.
'';
};
agentCpuQuota = lib.mkOption {
type = lib.types.str;
default = "200%";
example = "400%";
description = ''
systemd `CPUQuota=` applied to every agent container via a
`container@h-<name>.service.d/` drop-in written on each
spawn/rebuild. Expressed as a percentage of one CPU core
`"200%"` allows each agent to use up to 2 cores. Bump this if
agents are hitting CPU limits during builds or heavy tool use.
For a hive-wide cap across all containers, set
`systemd.slices.machine.serviceConfig.CPUQuota` in your NixOS
config (all nspawn containers live in `machine.slice`).
'';
};
agentMemoryMax = lib.mkOption {
type = lib.types.str;
default = "4G";
example = "8G";
description = ''
systemd `MemoryMax=` applied to every agent container via the
same drop-in as `agentCpuQuota`.
'';
};
buildSlots = lib.mkOption {
type = lib.types.ints.positive;
default = 1;
example = 2;
description = ''
Number of nix-heavy job-queue nodes (container prebuilds,
profile swaps, first-spawn creates, meta lock bumps) hive-c0re
runs concurrently. The default of 1 serializes all heavy nix
work; raise it on hosts with the cores/RAM to build several
agent toplevels at once. Per-agent correctness is independent
of this count each agent's container-affecting operations are
serialized by its lifecycle lease regardless.
'';
};
};
imports = [
./options.nix
./theme.nix
];
config = lib.mkIf cfg.enable {
environment.systemPackages = [
@ -458,7 +85,7 @@ in
# Unprivileged coordinator user. hive-c0re runs as this user;
# privileged operations are delegated to hive-priv which runs as
# root, socket-activated at /run/hive/priv.sock.
# root, socket-activated at /run/hive/priv.sock (./hive-priv.nix).
users.users.hive-core = {
isSystemUser = true;
group = "hive-core";
@ -473,8 +100,8 @@ in
# NB: `services.hyperhive.domain` is required when hyperhive is
# enabled — the canonical assertion lives in `hive-network.nix` (the
# hive resolver is authoritative for `<domain>` and agents reach the
# forge/matrix through the gateway by it). So everything below can
# treat `config.services.hyperhive.domain` as non-null.
# forge/matrix through the gateway by it). So the daemon environment
# (./environment.nix) can treat it as non-null.
systemd.services.hive-c0re = {
description = "hyperhive coordinator daemon";
wantedBy = [ "multi-user.target" ];
@ -491,174 +118,7 @@ in
pkgs.git
"/run/current-system/sw"
];
environment = {
# nix (the prebuild `nix build`, flake-check, and meta eval) writes
# its fetcher/eval cache under $HOME/.cache. As a system user
# hive-core has no home, so HOME defaults to the unwritable
# /var/empty and Lix fails to initialise its cache. Point HOME at
# the writable StateDirectory.
HOME = "/var/lib/hyperhive";
HYPERHIVE_GIT = "${pkgs.git}/bin/git";
# No HIVE_STATIC_DIR: the gateway static-serves the dashboard dist
# (see the hive-gateway module); this router is API-only.
# Path to the base agent frontend dist. hive-c0re's
# gateway_nginx.rs uses this to generate split location
# blocks in agents.conf — static HTML/CSS/JS served from the
# nix store directly; dynamic API paths still proxied to the
# agent daemon. The nix store is shared across nspawn
# containers, so this path is reachable from inside the
# gateway container's nginx.
HIVE_AGENT_FRONTEND_DIR = "${servedFrontend}/agent";
# Path to the static runtime asset tree (branding + claude
# prompts). `hive_sh4re::assets::*` reads paths underneath.
# `forge.rs` reads the avatar PNGs from here on startup.
HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive";
# Whether this hive runs ruthless — no root/manager agent at all
# (`auto_update::ensure_root_agent`). Default false = root
# auto-managed; true makes the sweep a no-op.
HYPERHIVE_RUTHLESS = lib.boolToString config.services.hyperhive.ruthless;
}
// {
# Identity env vars threaded into c0re's own service env and
# forwarded by meta.rs into every sub-agent's harness env —
# full chain in docs/conventions.md::Hive identity. `domain` is
# required (asserted in hive-network.nix), so it's always set.
HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain;
}
// lib.optionalAttrs (config.services.hyperhive.hiveName != null) {
HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName;
}
// lib.optionalAttrs (config.services.hyperhive.swarmName != null) {
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName;
}
// lib.optionalAttrs (!config.services.hyperhive.github.enable) {
# GitHub integration is on by default; only signal the OFF override to
# meta.rs, which then injects `hyperhive.github.enable = false` into
# every agent. See services.hyperhive.github.enable.
HYPERHIVE_GITHUB_DISABLED = "1";
}
// lib.optionalAttrs config.services.hyperhive.otel.enable (
# Hive-wide OTEL config -> read by meta.rs::otel_config and
# injected as build-time `hyperhive.otel.*` into every agent.
# Endpoint presence is the enable signal on the meta side; the
# optional fields are only emitted when set so absent values
# don't render no-op env lines.
let
otel = config.services.hyperhive.otel;
in
{
HYPERHIVE_OTEL_ENDPOINT = otel.endpoint;
HYPERHIVE_OTEL_PROTOCOL = otel.protocol;
}
// lib.optionalAttrs (otel.extraResourceAttributes != "") {
HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = otel.extraResourceAttributes;
}
// lib.optionalAttrs (otel.headersCredential != null) {
HYPERHIVE_OTEL_HEADERS_CREDENTIAL = otel.headersCredential;
}
// lib.optionalAttrs (otel.metricIntervalMs != null) {
HYPERHIVE_OTEL_METRIC_INTERVAL_MS = toString otel.metricIntervalMs;
}
// lib.optionalAttrs otel.debug {
HYPERHIVE_OTEL_DEBUG = "1";
}
)
// {
# In-cluster forge URL — the gateway vhost (`forge.<domain>`), which
# nginx proxies to forgejo. Used both for internal API calls in
# hive-c0re (forge/mod.rs `forge_http_base()`) and forwarded to
# agents via meta.rs for their forge-notify client. The forge is
# mandatory, so this is unconditional (the whole env block is already
# gated on hyperhive being enabled). See `docs/gateway.md::HIVE_FORGE_URL`.
HIVE_FORGE_URL = "http://${config.services.hyperhive.forge.domain}";
}
// lib.optionalAttrs config.services.hyperhive.matrix.enable {
# In-cluster matrix homeserver URL for each agent's
# hive-matrix-daemon — the gateway vhost (`matrix.<domain>`). The
# gatewayHost null-guard falls back to loopback so a domain-less
# config still evals. Forwarded to agents by meta.rs alongside
# HIVE_FORGE_URL; shares the same env-forwarding ordering caveat
# (value baked at config-generation time).
HIVE_MATRIX_URL =
if config.services.hyperhive.matrix.gatewayHost != null then
"http://${config.services.hyperhive.matrix.gatewayHost}"
else
"http://127.0.0.1:${toString config.services.hyperhive.matrix.httpPort}";
}
// lib.optionalAttrs config.services.hyperhive.matrix.gui.enable {
# Availability flags read by the dashboard's `/api/state`.
# Matrix GUI lives entirely on the gateway nginx (matrix tab
# only shows when both flags are on). Gateway routing detail:
# docs/gateway.md::Vhost map.
HIVE_MATRIX_GUI_ENABLED = "1";
}
// {
# The gateway always runs, so the dashboard always builds
# same-origin `/agent/<name>/` links (never the direct
# `<host>:<port>` TCP fallback). Kept as an env flag so the
# dashboard doesn't need to learn the gateway is unconditional.
HIVE_GATEWAY_ENABLED = "1";
}
// lib.optionalAttrs config.services.hyperhive.forge.behindGateway {
# Public URL of the forge vhost served by hive-gateway. The
# dashboard uses this to build browser-facing forge links
# instead of hardcoding `<hostname>:3000`, which breaks when
# the operator accesses the dashboard through the gateway
# (forge sub-domain has no port; direct port URL would be
# wrong). Absent when `behindGateway = false` — dashboard
# falls back to `<hostname>:3000`.
HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}";
}
//
lib.optionalAttrs
(
config.services.hyperhive.matrix.gui.enable && config.services.hyperhive.matrix.gatewayHost != null
)
{
# Browser-facing matrix GUI (fluffychat) URL — the gateway
# vhost (`matrix.<domain>`). Surfaced via the daemon's `Urls`
# request for `hivectl open matrix`. Absent when the GUI is off
# or no gatewayHost is set (no browser-reachable matrix vhost).
HIVE_MATRIX_PUBLIC_URL = "https://${config.services.hyperhive.matrix.gatewayHost}/";
}
// lib.optionalAttrs (config.services.hyperhive.swarm.peers != { }) {
# Peer hives serialised as a JSON array of {domain, cert_fingerprint,
# wireguard_address?} objects. Consumed by hive-ag3nt::identity::peers()
# + the dashboard's peer_hives StateSnapshot field (P33RS tab). Domain
# is the attrset key; cert_fingerprint is null for CA-trusted peers;
# wireguard_address is omitted when not part of the mesh.
HYPERHIVE_PEERS = builtins.toJSON (
lib.mapAttrsToList (
domain: p:
{
inherit domain;
cert_fingerprint = p.certFingerprint;
}
// lib.optionalAttrs (p.wireguardAddress != null) {
wireguard_address = p.wireguardAddress;
}
) config.services.hyperhive.swarm.peers
);
}
//
lib.optionalAttrs
(lib.any (p: p.caCert != null) (lib.attrValues config.services.hyperhive.swarm.peers))
{
# Peer-hive root CA file paths (colon-joined), one per peer that
# declares `swarm.peers.<domain>.caCert`. hive-c0re's meta-flake
# renderer (meta.rs) embeds each next to every agent's flake and
# adds it to `security.pki.certificateFiles`, so a peer CA is
# trusted everywhere the hive's own internal CA (`hive-ca.pem`)
# is — i.e. by every agent. The matrix container trusts the same
# CAs separately for federation TLS. The `caCert` files are
# copied into the nix store at build, so these are store paths —
# nothing mutable lives on the host.
HIVE_PEER_CA_PATHS = lib.concatStringsSep ":" (
lib.filter (c: c != null) (
lib.mapAttrsToList (_domain: p: p.caCert) config.services.hyperhive.swarm.peers
)
);
};
environment = import ./environment.nix { inherit lib config pkgs; };
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --config /etc/hyperhive/serve.json";
SyslogIdentifier = "hive-c0re";
@ -754,139 +214,5 @@ in
DirectoryMode = "0750";
};
};
# Socket unit for hive-priv — the narrow root helper that executes
# privileged operations on behalf of hive-c0re. Systemd creates and
# holds `/run/hive/priv.sock` before the first connection arrives.
#
# Mode 0660 hive-core:hive-core: only the hive-c0re service user can
# connect. hive-priv (server) runs as root and validates every request
# against a strict allowlist before executing any privileged op.
systemd.sockets.hive-priv = {
description = "hive-priv privileged helper socket";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "/run/hive/priv.sock";
SocketMode = "0660";
SocketGroup = "hive-core";
# Create /run/hive/ if absent; 0755 so the hive-core user can
# traverse into it to reach the socket.
DirectoryMode = "0755";
};
};
# Service unit for hive-priv. Runs as root — it genuinely needs root to
# invoke `nixos-container`, write `/etc/nixos-containers/`, write
# systemd drop-ins in `/run/systemd/system/`, and call `chown(2)`.
# Every request is validated against a strict container-name allowlist
# inside the binary; the attack surface is narrow by design.
#
# Socket-activated: systemd starts hive-priv on the first connection
# (no earlier). LISTEN_FDS + LISTEN_PID are set by systemd; hive-priv
# reads them to accept the pre-bound socket fd instead of binding its
# own.
systemd.services.hive-priv = {
description = "hive-priv privileged helper";
# No wantedBy — socket-activated exclusively. The socket unit is the
# entry point; systemd starts this service on first connect.
after = [ "hive-priv.socket" ];
requires = [ "hive-priv.socket" ];
# `nixos-container` is a perl script that shells out by bare name to
# nix / nix-env / nix-instantiate (create + update), machinectl +
# systemctl (start/stop), and find / rm / umount / chattr (destroy);
# only nsenter + su are hardcoded. Give the helper exactly those —
# not the whole system profile — on top of the systemd/coreutils/
# findutils already in the default unit PATH. Without `nixos-container`
# on PATH every container op fails ENOENT, which `build_all` silently
# swallows into an empty list ("no managed containers").
#
# `nix` itself shells out by bare name too: `git` whenever it has to
# fetch/re-resolve a git-source flake input (an agent.nix with a
# `git+https://…` input, or a stale flake.lock whose node URL no longer
# matches the flake's declared input → nix re-resolves at eval), and
# `ssh` to dispatch to remote builders (`nix.buildMachines` /
# `ssh-ng://`). Without these on PATH `nixos-container update` dies with
# `executing "git": No such file or directory` / `Could not find
# executable 'ssh'` — the agent build fails before it starts.
path = [
pkgs.nixos-container
pkgs.nix # nix, nix-env, nix-instantiate — create + update
pkgs.gitMinimal # git — nix fetches/re-resolves git-source flake inputs
pkgs.openssh # ssh — nix dispatches builds to remote builders
pkgs.util-linux # umount (nsenter is hardcoded in the script)
pkgs.e2fsprogs # chattr
pkgs.btrfs-progs # btrfs subvolume create/delete — Ensure/DeleteAgentSubvolume
];
environment = {
# `nixos-container update/create` runs `nix`, which writes its
# fetcher/eval cache under $HOME/.cache. With ProtectHome and no
# explicit HOME this lands on the unwritable /var/empty and Lix
# errors out. Point HOME at the StateDirectory below (persistent,
# so the cache survives across rebuilds).
HOME = "/var/lib/hive-priv";
# hive-priv runs as root. Root nix defaults to store=auto which
# resolves to the LOCAL store — bypassing the host daemon, its
# remote builders, and prebuilt derivation outputs. Force daemon
# routing so nixos-container update and the nix prebuild see the
# same store and substituters as every other build context.
NIX_REMOTE = "daemon";
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-priv";
SyslogIdentifier = "hive-priv";
Type = "simple";
User = "root";
PrivateTmp = true;
ProtectHome = true;
# Harden the file system view: strict makes the entire hierarchy
# read-only by default; ReadWritePaths carves out exactly the
# paths hive-priv must write to at runtime.
#
# Why each entry is needed:
# /etc/nixos-containers — writes <container>.conf (bind mounts,
# network isolation, nspawn flags)
# /run/hive-agent — chown/chmod per-agent socket directories
# /run/systemd — container@ unit drop-ins (resource limits)
# + machinectl / systemd-machined state
# /run/lock — `nixos-container` opens a lock file at
# /run/lock/nixos-container to serialise
# create/destroy. Under ProtectSystem=strict
# /run is read-only, so without this the very
# first `nixos-container create` (ruth, on a
# fresh host) dies with "Read-only file
# system" before any container exists.
# /var/lib/nixos-containers — container rootfs written by nixos-container
# /var/lib/hyperhive — agent state files written by WriteAgentForgeToken
# / WriteAgentMatrixToken (tokens under agents/<n>/state/)
# /nix — nix store + profile updates during
# container create/update
ProtectSystem = "strict";
ReadWritePaths = [
"/etc/nixos-containers"
"/run/hive-agent"
"/run/systemd"
"/run/lock"
"/var/lib/nixos-containers"
"/var/lib/hyperhive"
"/nix"
];
# Writable HOME for nix's caches (see environment.HOME above).
StateDirectory = "hive-priv";
# With ProtectSystem=strict the root filesystem is read-only inside
# hive-priv. When `nixos-container create/update` invokes nix, nix
# creates a temporary result symlink in its working directory. Without
# an explicit WorkingDirectory the cwd is / (inherited from systemd),
# which is read-only under strict, causing:
# error: creating symlink "/.tmp.tmp-..." -> ...: Read-only file system
# Point the working directory at the writable StateDirectory so nix
# drops its temp symlink there instead.
WorkingDirectory = "/var/lib/hive-priv";
# nix (run here as root for `nixos-container update --flake
# /var/lib/hyperhive/meta#<agent>`) fetches the hive-core-owned
# meta/applied repos; libgit2 refuses them without safe.directory.
# See safeDirGitconfig above.
ExecStartPre = "+-${pkgs.coreutils}/bin/cp ${safeDirGitconfig} /var/lib/hive-priv/.gitconfig";
};
};
};
}

View file

@ -0,0 +1,180 @@
# Environment of the hive-c0re daemon unit — a plain function file
# (not a module) returning the env attrset, imported by ./default.nix.
# Everything meta.rs forwards into agent containers or reads for the
# meta-flake render is assembled here.
{
lib,
config,
pkgs,
}:
let
cfg = config.services.hyperhive.c0re;
in
{
# nix (the prebuild `nix build`, flake-check, and meta eval) writes
# its fetcher/eval cache under $HOME/.cache. As a system user
# hive-core has no home, so HOME defaults to the unwritable
# /var/empty and Lix fails to initialise its cache. Point HOME at
# the writable StateDirectory.
HOME = "/var/lib/hyperhive";
HYPERHIVE_GIT = "${pkgs.git}/bin/git";
# No HIVE_STATIC_DIR: the gateway static-serves the dashboard dist
# (see the hive-gateway module); this router is API-only.
# Path to the base agent frontend dist. hive-c0re's
# gateway_nginx.rs uses this to generate split location
# blocks in agents.conf — static HTML/CSS/JS served from the
# nix store directly; dynamic API paths still proxied to the
# agent daemon. The nix store is shared across nspawn
# containers, so this path is reachable from inside the
# gateway container's nginx.
HIVE_AGENT_FRONTEND_DIR = "${cfg.servedFrontend}/agent";
# Path to the static runtime asset tree (branding + claude
# prompts). `hive_sh4re::assets::*` reads paths underneath.
# `forge.rs` reads the avatar PNGs from here on startup.
HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive";
# Whether this hive runs ruthless — no root/manager agent at all
# (`auto_update::ensure_root_agent`). Default false = root
# auto-managed; true makes the sweep a no-op.
HYPERHIVE_RUTHLESS = lib.boolToString config.services.hyperhive.ruthless;
}
// {
# Identity env vars threaded into c0re's own service env and
# forwarded by meta.rs into every sub-agent's harness env —
# full chain in docs/conventions.md::Hive identity. `domain` is
# required (asserted in hive-network.nix), so it's always set.
HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain;
}
// lib.optionalAttrs (config.services.hyperhive.hiveName != null) {
HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName;
}
// lib.optionalAttrs (config.services.hyperhive.swarmName != null) {
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName;
}
// lib.optionalAttrs (!config.services.hyperhive.github.enable) {
# GitHub integration is on by default; only signal the OFF override to
# meta.rs, which then injects `hyperhive.github.enable = false` into
# every agent. See services.hyperhive.github.enable.
HYPERHIVE_GITHUB_DISABLED = "1";
}
// lib.optionalAttrs config.services.hyperhive.otel.enable (
# Hive-wide OTEL config -> read by meta.rs::otel_config and
# injected as build-time `hyperhive.otel.*` into every agent.
# Endpoint presence is the enable signal on the meta side; the
# optional fields are only emitted when set so absent values
# don't render no-op env lines.
let
otel = config.services.hyperhive.otel;
in
{
HYPERHIVE_OTEL_ENDPOINT = otel.endpoint;
HYPERHIVE_OTEL_PROTOCOL = otel.protocol;
}
// lib.optionalAttrs (otel.extraResourceAttributes != "") {
HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = otel.extraResourceAttributes;
}
// lib.optionalAttrs (otel.headersCredential != null) {
HYPERHIVE_OTEL_HEADERS_CREDENTIAL = otel.headersCredential;
}
// lib.optionalAttrs (otel.metricIntervalMs != null) {
HYPERHIVE_OTEL_METRIC_INTERVAL_MS = toString otel.metricIntervalMs;
}
// lib.optionalAttrs otel.debug {
HYPERHIVE_OTEL_DEBUG = "1";
}
)
// {
# In-cluster forge URL — the gateway vhost (`forge.<domain>`), which
# nginx proxies to forgejo. Used both for internal API calls in
# hive-c0re (forge/mod.rs `forge_http_base()`) and forwarded to
# agents via meta.rs for their forge-notify client. The forge is
# mandatory, so this is unconditional (the whole env block is already
# gated on hyperhive being enabled). See `docs/gateway.md::HIVE_FORGE_URL`.
HIVE_FORGE_URL = "http://${config.services.hyperhive.forge.domain}";
}
// lib.optionalAttrs config.services.hyperhive.matrix.enable {
# In-cluster matrix homeserver URL for each agent's
# hive-matrix-daemon — the gateway vhost (`matrix.<domain>`). The
# gatewayHost null-guard falls back to loopback so a domain-less
# config still evals. Forwarded to agents by meta.rs alongside
# HIVE_FORGE_URL; shares the same env-forwarding ordering caveat
# (value baked at config-generation time).
HIVE_MATRIX_URL =
if config.services.hyperhive.matrix.gatewayHost != null then
"http://${config.services.hyperhive.matrix.gatewayHost}"
else
"http://127.0.0.1:${toString config.services.hyperhive.matrix.httpPort}";
}
// lib.optionalAttrs config.services.hyperhive.matrix.gui.enable {
# Availability flags read by the dashboard's `/api/state`.
# Matrix GUI lives entirely on the gateway nginx (matrix tab
# only shows when both flags are on). Gateway routing detail:
# docs/gateway.md::Vhost map.
HIVE_MATRIX_GUI_ENABLED = "1";
}
// {
# The gateway always runs, so the dashboard always builds
# same-origin `/agent/<name>/` links (never the direct
# `<host>:<port>` TCP fallback). Kept as an env flag so the
# dashboard doesn't need to learn the gateway is unconditional.
HIVE_GATEWAY_ENABLED = "1";
}
// lib.optionalAttrs config.services.hyperhive.forge.behindGateway {
# Public URL of the forge vhost served by hive-gateway. The
# dashboard uses this to build browser-facing forge links
# instead of hardcoding `<hostname>:3000`, which breaks when
# the operator accesses the dashboard through the gateway
# (forge sub-domain has no port; direct port URL would be
# wrong). Absent when `behindGateway = false` — dashboard
# falls back to `<hostname>:3000`.
HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}";
}
//
lib.optionalAttrs
(
config.services.hyperhive.matrix.gui.enable && config.services.hyperhive.matrix.gatewayHost != null
)
{
# Browser-facing matrix GUI (fluffychat) URL — the gateway
# vhost (`matrix.<domain>`). Surfaced via the daemon's `Urls`
# request for `hivectl open matrix`. Absent when the GUI is off
# or no gatewayHost is set (no browser-reachable matrix vhost).
HIVE_MATRIX_PUBLIC_URL = "https://${config.services.hyperhive.matrix.gatewayHost}/";
}
// lib.optionalAttrs (config.services.hyperhive.swarm.peers != { }) {
# Peer hives serialised as a JSON array of {domain, cert_fingerprint,
# wireguard_address?} objects. Consumed by hive-ag3nt::identity::peers()
# + the dashboard's peer_hives StateSnapshot field (P33RS tab). Domain
# is the attrset key; cert_fingerprint is null for CA-trusted peers;
# wireguard_address is omitted when not part of the mesh.
HYPERHIVE_PEERS = builtins.toJSON (
lib.mapAttrsToList (
domain: p:
{
inherit domain;
cert_fingerprint = p.certFingerprint;
}
// lib.optionalAttrs (p.wireguardAddress != null) {
wireguard_address = p.wireguardAddress;
}
) config.services.hyperhive.swarm.peers
);
}
//
lib.optionalAttrs
(lib.any (p: p.caCert != null) (lib.attrValues config.services.hyperhive.swarm.peers))
{
# Peer-hive root CA file paths (colon-joined), one per peer that
# declares `swarm.peers.<domain>.caCert`. hive-c0re's meta-flake
# renderer (meta.rs) embeds each next to every agent's flake and
# adds it to `security.pki.certificateFiles`, so a peer CA is
# trusted everywhere the hive's own internal CA (`hive-ca.pem`)
# is — i.e. by every agent. The matrix container trusts the same
# CAs separately for federation TLS. The `caCert` files are
# copied into the nix store at build, so these are store paths —
# nothing mutable lives on the host.
HIVE_PEER_CA_PATHS = lib.concatStringsSep ":" (
lib.filter (c: c != null) (
lib.mapAttrsToList (_domain: p: p.caCert) config.services.hyperhive.swarm.peers
)
);
}

View file

@ -0,0 +1,319 @@
# Option declarations for `services.hyperhive.c0re.*` — the c0re
# daemon's knobs plus the package/source options the flake's
# `nixosModules.default` wires to its own outputs (they carry no
# in-module defaults; see ../../../flake.nix). The read-only
# `servedFrontend` option lives in ./theme.nix with the stylix wiring
# that computes it.
{
pkgs,
lib,
config,
...
}:
{
options.services.hyperhive.c0re = {
enable = lib.mkOption {
type = lib.types.bool;
default = config.services.hyperhive.enable;
defaultText = lib.literalExpression "config.services.hyperhive.enable";
description = "Enable hive-c0re coordinator daemon (auto-enabled by services.hyperhive.enable).";
};
package = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.default";
description = ''
hyperhive workspace package. Provides `/bin/hive-c0re`
(coordinator daemon + admin-socket CLI) and `/bin/hivectl`
(operator-facing host CLI for ad-hoc administration). Wired to
this flake's `packages.<system>.default` by
`nixosModules.default` (via `lib.mkDefault`, so setting it here
wins).
'';
};
frontend = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend";
description = ''
Bundled frontend dist (see `nix/packages/frontend.nix`). Output
has `dashboard/` and `agent/` subdirectories hive-c0re serves
`dashboard/` via `tower_http::ServeDir` from the path passed
in `HIVE_STATIC_DIR`. Override to ship a custom dashboard SPA;
the JSON contract (`/api/state`, the SSE streams, the action
endpoints) is the source of truth for any replacement.
'';
};
assets = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets";
description = ''
Bundled static runtime assets (see `nix/packages/assets.nix`): the
project's branding family + the claude system-prompt template +
claude-settings JSON. Output has `share/hyperhive/{branding,prompts}/`;
passed to hive-c0re's systemd unit via `HIVE_ASSETS_DIR`
(`hive_sh4re::assets::*` resolve paths underneath). Override to
ship customised branding or prompts without rebuilding the
rust derivation.
'';
};
xdgIcons = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.xdg-icons";
description = ''
XDG icon set + .desktop entries for hyperhive processes (see
`nix/packages/hive-xdg-icons.nix`), installed into the host
system packages so desktop environments can match hyperhive
processes to their icon.
'';
};
hyperhiveFlake = lib.mkOption {
type = lib.types.str;
defaultText = lib.literalMD "the hyperhive flake's own filtered source store path";
description = ''
URL of the hyperhive flake (no fragment). Inlined into each
per-agent `flake.nix` at `inputs.hyperhive.url`. The per-agent
flake then pulls `hyperhive.nixosConfigurations.agent-base` to
build the container. Wired by `nixosModules.default` to this
flake's own filtered source only override if you want agents
tracking a different ref.
'';
};
hyperhiveDocs = lib.mkOption {
type = lib.types.str;
defaultText = lib.literalMD "the docs/ tree's own store path";
description = ''
URL of the narrow `docs/` source (no fragment). Inlined into the
generated meta `flake.nix` at `inputs.hyperhive-docs.url` and
threaded to each agent as `hyperhive.docs.source`, from which the
harness resolves `$HIVE_DOCS_DIR`. Its own store path separate
from `hyperhiveFlake` so a doc edit only re-locks this input
instead of rebuilding every agent container.
'';
};
agentBaseToplevel = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.agent-base-toplevel";
description = ''
Pre-built agent-base container system closure, pulled into the
host system closure when `preBuildAgentTemplates` is on. Wired
by `nixosModules.default`; only evaluated when that option is
enabled.
'';
};
managerToplevel = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.ruth-toplevel";
description = ''
Pre-built manager (ruth) container system closure see
`agentBaseToplevel`.
'';
};
nixpkgsFlake = lib.mkOption {
type = lib.types.str;
default = "path:${pkgs.path}";
defaultText = lib.literalMD "`\"path:\${pkgs.path}\"`";
description = ''
Store-path URL for the `nixpkgs` input in the generated meta
flake. The meta flake declares this as a top-level input and
wires `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` so
every agent container evaluates with this exact nixpkgs.
Defaults to `"path:''${pkgs.path}"` the store path of the
nixpkgs the host NixOS module was evaluated with. When the
operator sets `inputs.hyperhive.inputs.nixpkgs.follows =
"nixpkgs"` in their host flake, `pkgs.path` resolves to the
host's own nixpkgs, so agents transparently track the same
channel as the host.
Override to pin agents to a specific nixpkgs version regardless
of the host's channel.
'';
};
dashboardPort = lib.mkOption {
type = lib.types.port;
default = 7000;
description = "TCP port the hive-c0re dashboard listens on.";
};
operatorPronouns = lib.mkOption {
type = lib.types.str;
default = "she/her";
example = "they/them";
description = ''
Operator pronouns, free text. Threaded into every agent
container as the `HIVE_OPERATOR_PRONOUNS` env var; the
harness substitutes it into the agent / manager system
prompt at boot so claude refers to the operator naturally
in third person ("ask her", "tell them", etc.). Changes
propagate to running agents on the next ` R3BU1LD`
forwards as a meta flake env-var bump, no per-agent
approval needed.
'';
};
preBuildAgentTemplates = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Pre-fetch the per-container system closures (agent-base +
manager toplevels) into the host's /nix/store as part of this
host's NixOS build, instead of letting the first agent spawn
do all the work.
Enabling this adds roughly the full nixpkgs runtime closure +
claude-code + the harness binary to your system closure size
(low single-digit GB), but the first `nixos-container start`
for any agent then completes in seconds instead of minutes
because nothing's left to fetch.
Off by default because the toplevels are pinned to
`x86_64-linux` (nixos-containers run native arch). Enabling
on an aarch64 host would force nix to build the x86 closure
via cross or a remote builder, which is rarely what you want.
Flip to `true` on an x86_64 host when you care more about
first-spawn latency than host store size or just
`nix build .#agent-base-toplevel` once manually to warm the
store.
'';
};
contextWindowTokens = lib.mkOption {
type = lib.types.attrsOf lib.types.int;
default = {
haiku = 200000;
sonnet = 1000000;
opus = 1000000;
};
example = {
haiku = 150000;
sonnet = 900000;
};
description = ''
Per-model context-window sizes in tokens. Each key is a
model-family short name matched case-insensitively as a
substring of the active model name at runtime (e.g. `"sonnet"`
matches `"claude-sonnet-4-5"`). The defaults cover the known
Anthropic families; add entries for new models or override
existing ones here to change the window for all agents at once.
Passed to `hive-c0re serve` as JSON and injected into every
container's harness service environment as
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>`. Changes propagate
on the next ` R3BU1LD` no per-agent approval needed.
'';
};
modelPrices = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
input = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million input tokens.";
};
output = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million output tokens.";
};
cache_read = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million cache-read tokens.";
};
cache_write = lib.mkOption {
type = lib.types.numbers.nonnegative;
description = "USD per million cache-creation (write) tokens.";
};
};
}
);
# Current Anthropic list prices for the Claude 4.x family (Opus
# 4.x, Sonnet 4.x, Haiku 4.5); cache_write is the 1-hour cache-TTL
# price (the default through the Claude subscription the agents run
# on). Keep in sync with `builtin_prices` in
# hive-c0re/src/hive_stats.rs.
default = {
opus = {
input = 5.0;
output = 25.0;
cache_read = 0.5;
cache_write = 10.0;
};
sonnet = {
input = 3.0;
output = 15.0;
cache_read = 0.3;
cache_write = 6.0;
};
haiku = {
input = 1.0;
output = 5.0;
cache_read = 0.1;
cache_write = 2.0;
};
};
example = {
sonnet = {
input = 3.0;
output = 15.0;
cache_read = 0.3;
cache_write = 6.0;
};
};
description = ''
Per-model USD prices (per **million** tokens) used for the
hive-wide cost *estimate* on the dashboard's ST4TS tab. Each key
is a model-family short name matched case-insensitively as a
substring of the active model id at runtime (e.g. `"sonnet"`
matches `"claude-sonnet-4-5"`); the longest matching key wins, so
a specific entry beats a generic family name. Any model not
covered by this table falls back to hive-c0re's built-in
estimate.
The defaults track Anthropic list pricing at the time of
writing override them here to keep the estimate current
without a code change. Passed to `hive-c0re serve` as JSON via
`--model-prices`; read only by hive-c0re itself (not injected
into containers). Changes apply on the next host rebuild.
'';
};
agentCpuQuota = lib.mkOption {
type = lib.types.str;
default = "200%";
example = "400%";
description = ''
systemd `CPUQuota=` applied to every agent container via a
`container@h-<name>.service.d/` drop-in written on each
spawn/rebuild. Expressed as a percentage of one CPU core
`"200%"` allows each agent to use up to 2 cores. Bump this if
agents are hitting CPU limits during builds or heavy tool use.
For a hive-wide cap across all containers, set
`systemd.slices.machine.serviceConfig.CPUQuota` in your NixOS
config (all nspawn containers live in `machine.slice`).
'';
};
agentMemoryMax = lib.mkOption {
type = lib.types.str;
default = "4G";
example = "8G";
description = ''
systemd `MemoryMax=` applied to every agent container via the
same drop-in as `agentCpuQuota`.
'';
};
buildSlots = lib.mkOption {
type = lib.types.ints.positive;
default = 1;
example = 2;
description = ''
Number of nix-heavy job-queue nodes (container prebuilds,
profile swaps, first-spawn creates, meta lock bumps) hive-c0re
runs concurrently. The default of 1 serializes all heavy nix
work; raise it on hosts with the cores/RAM to build several
agent toplevels at once. Per-agent correctness is independent
of this count each agent's container-affecting operations are
serialized by its lifecycle lease regardless.
'';
};
};
}

View file

@ -0,0 +1,79 @@
# Stylix theme integration (zero-op auto-detect). When the operator's
# host config has stylix enabled, generate a base16 `colors.css` from
# its palette and overlay it onto the bundled frontend dist so the
# dashboard re-themes with no operator action and no npm/esbuild
# rebuild (a pure file-copy over the prebuilt dist). `colors.css` is
# the entire swap contract — `theme.css` derives every semantic var
# from the 16 base16 slots (see docs/web-ui/css-vars.md). The guarded
# access makes this a clean no-op when stylix isn't imported into the
# host config. Exposed as the read-only `c0re.servedFrontend` option.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.c0re;
stylixThemeColors =
if (config.stylix.enable or false) && ((config.lib.stylix or { }) ? colors) then
config.lib.stylix.colors.withHashtag
else
null;
themedColorsCss =
c:
pkgs.writeText "hyperhive-colors.css" ''
:root {
--base00: ${c.base00};
--base01: ${c.base01};
--base02: ${c.base02};
--base03: ${c.base03};
--base04: ${c.base04};
--base05: ${c.base05};
--base06: ${c.base06};
--base07: ${c.base07};
--base08: ${c.base08};
--base09: ${c.base09};
--base0A: ${c.base0A};
--base0B: ${c.base0B};
--base0C: ${c.base0C};
--base0D: ${c.base0D};
--base0E: ${c.base0E};
--base0F: ${c.base0F};
}
'';
# Overlay the generated colors.css onto both dist subtrees. Both the
# dashboard (served by hive-c0re via HIVE_STATIC_DIR) and the agent UIs
# (served by the gateway from HIVE_AGENT_FRONTEND_DIR — static files
# straight from the store) read their colors.css from this host-side
# tree, so swapping both re-themes both surfaces.
#
# Not covered here: an agent reached directly on its own harness web
# server (no gateway) serves from its per-agent `mergedDist`, built in
# the agent's own nixosSystem with no access to the host's stylix
# colours — theming that path needs the base16 palette forwarded
# host→agent, tracked separately.
themedFrontend =
c:
pkgs.runCommand "hyperhive-frontend-themed" { } ''
cp -r ${cfg.frontend} $out
chmod -R u+w $out
install -m644 ${themedColorsCss c} $out/dashboard/static/colors.css
install -m644 ${themedColorsCss c} $out/agent/static/colors.css
'';
in
{
options.services.hyperhive.c0re.servedFrontend = lib.mkOption {
type = lib.types.package;
internal = true;
readOnly = true;
default = if stylixThemeColors != null then themedFrontend stylixThemeColors else cfg.frontend;
defaultText = lib.literalExpression "<stylix-themed overlay of `frontend`>";
description = ''
Internal, read-only: `frontend` re-themed with the active stylix
palette (or `frontend` verbatim when unthemed); has `dashboard/`
and `agent/`. Exposed so the hive-gateway module can static-serve
`dashboard/` as an nginx root instead of proxying to hive-c0re.
'';
};
}

157
nix/modules/hive-priv.nix Normal file
View file

@ -0,0 +1,157 @@
# hive-priv — the narrow root privileged helper hive-c0re delegates
# to, socket-activated at /run/hive/priv.sock. See docs/boundary.md
# for the operator/agent trust-boundary design.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.c0re;
# Same safe.directory gitconfig as the c0re unit (see ./hive-c0re)
# — hive-priv (root) runs nix, which fetches the hive-core-owned
# meta/applied repos; libgit2 refuses cross-user reads without it.
safeDirGitconfig = pkgs.writeText "hyperhive-safe-gitconfig" ''
[safe]
directory = *
'';
in
{
config = lib.mkIf cfg.enable {
# Socket unit for hive-priv — the narrow root helper that executes
# privileged operations on behalf of hive-c0re. Systemd creates and
# holds `/run/hive/priv.sock` before the first connection arrives.
#
# Mode 0660 hive-core:hive-core: only the hive-c0re service user can
# connect. hive-priv (server) runs as root and validates every request
# against a strict allowlist before executing any privileged op.
systemd.sockets.hive-priv = {
description = "hive-priv privileged helper socket";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "/run/hive/priv.sock";
SocketMode = "0660";
SocketGroup = "hive-core";
# Create /run/hive/ if absent; 0755 so the hive-core user can
# traverse into it to reach the socket.
DirectoryMode = "0755";
};
};
# Service unit for hive-priv. Runs as root — it genuinely needs root to
# invoke `nixos-container`, write `/etc/nixos-containers/`, write
# systemd drop-ins in `/run/systemd/system/`, and call `chown(2)`.
# Every request is validated against a strict container-name allowlist
# inside the binary; the attack surface is narrow by design.
#
# Socket-activated: systemd starts hive-priv on the first connection
# (no earlier). LISTEN_FDS + LISTEN_PID are set by systemd; hive-priv
# reads them to accept the pre-bound socket fd instead of binding its
# own.
systemd.services.hive-priv = {
description = "hive-priv privileged helper";
# No wantedBy — socket-activated exclusively. The socket unit is the
# entry point; systemd starts this service on first connect.
after = [ "hive-priv.socket" ];
requires = [ "hive-priv.socket" ];
# `nixos-container` is a perl script that shells out by bare name to
# nix / nix-env / nix-instantiate (create + update), machinectl +
# systemctl (start/stop), and find / rm / umount / chattr (destroy);
# only nsenter + su are hardcoded. Give the helper exactly those —
# not the whole system profile — on top of the systemd/coreutils/
# findutils already in the default unit PATH. Without `nixos-container`
# on PATH every container op fails ENOENT, which `build_all` silently
# swallows into an empty list ("no managed containers").
#
# `nix` itself shells out by bare name too: `git` whenever it has to
# fetch/re-resolve a git-source flake input (an agent.nix with a
# `git+https://…` input, or a stale flake.lock whose node URL no longer
# matches the flake's declared input → nix re-resolves at eval), and
# `ssh` to dispatch to remote builders (`nix.buildMachines` /
# `ssh-ng://`). Without these on PATH `nixos-container update` dies with
# `executing "git": No such file or directory` / `Could not find
# executable 'ssh'` — the agent build fails before it starts.
path = [
pkgs.nixos-container
pkgs.nix # nix, nix-env, nix-instantiate — create + update
pkgs.gitMinimal # git — nix fetches/re-resolves git-source flake inputs
pkgs.openssh # ssh — nix dispatches builds to remote builders
pkgs.util-linux # umount (nsenter is hardcoded in the script)
pkgs.e2fsprogs # chattr
pkgs.btrfs-progs # btrfs subvolume create/delete — Ensure/DeleteAgentSubvolume
];
environment = {
# `nixos-container update/create` runs `nix`, which writes its
# fetcher/eval cache under $HOME/.cache. With ProtectHome and no
# explicit HOME this lands on the unwritable /var/empty and Lix
# errors out. Point HOME at the StateDirectory below (persistent,
# so the cache survives across rebuilds).
HOME = "/var/lib/hive-priv";
# hive-priv runs as root. Root nix defaults to store=auto which
# resolves to the LOCAL store — bypassing the host daemon, its
# remote builders, and prebuilt derivation outputs. Force daemon
# routing so nixos-container update and the nix prebuild see the
# same store and substituters as every other build context.
NIX_REMOTE = "daemon";
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-priv";
SyslogIdentifier = "hive-priv";
Type = "simple";
User = "root";
PrivateTmp = true;
ProtectHome = true;
# Harden the file system view: strict makes the entire hierarchy
# read-only by default; ReadWritePaths carves out exactly the
# paths hive-priv must write to at runtime.
#
# Why each entry is needed:
# /etc/nixos-containers — writes <container>.conf (bind mounts,
# network isolation, nspawn flags)
# /run/hive-agent — chown/chmod per-agent socket directories
# /run/systemd — container@ unit drop-ins (resource limits)
# + machinectl / systemd-machined state
# /run/lock — `nixos-container` opens a lock file at
# /run/lock/nixos-container to serialise
# create/destroy. Under ProtectSystem=strict
# /run is read-only, so without this the very
# first `nixos-container create` (ruth, on a
# fresh host) dies with "Read-only file
# system" before any container exists.
# /var/lib/nixos-containers — container rootfs written by nixos-container
# /var/lib/hyperhive — agent state files written by WriteAgentForgeToken
# / WriteAgentMatrixToken (tokens under agents/<n>/state/)
# /nix — nix store + profile updates during
# container create/update
ProtectSystem = "strict";
ReadWritePaths = [
"/etc/nixos-containers"
"/run/hive-agent"
"/run/systemd"
"/run/lock"
"/var/lib/nixos-containers"
"/var/lib/hyperhive"
"/nix"
];
# Writable HOME for nix's caches (see environment.HOME above).
StateDirectory = "hive-priv";
# With ProtectSystem=strict the root filesystem is read-only inside
# hive-priv. When `nixos-container create/update` invokes nix, nix
# creates a temporary result symlink in its working directory. Without
# an explicit WorkingDirectory the cwd is / (inherited from systemd),
# which is read-only under strict, causing:
# error: creating symlink "/.tmp.tmp-..." -> ...: Read-only file system
# Point the working directory at the writable StateDirectory so nix
# drops its temp symlink there instead.
WorkingDirectory = "/var/lib/hive-priv";
# nix (run here as root for `nixos-container update --flake
# /var/lib/hyperhive/meta#<agent>`) fetches the hive-core-owned
# meta/applied repos; libgit2 refuses them without safe.directory.
# See safeDirGitconfig above.
ExecStartPre = "+-${pkgs.coreutils}/bin/cp ${safeDirGitconfig} /var/lib/hive-priv/.gitconfig";
};
};
};
}