# 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. { pkgs, lib, config, ... }: let cfg = config.services.hyperhive.c0re; # Privsep splits ownership across users, so git/libgit2's dubious- # ownership guard trips on legitimate cross-user reads: hive-priv (root) # fetches the hive-core-owned meta/applied repos via nix, and hive-c0re # (hive-core) fetches the agent-owned proposed-config repos. Both # processes are trusted and can already read the files; this gitconfig # only satisfies the ownership guard. libgit2 honours the literal `*` # (mid-path globs aren't supported, so per-agent repos can't be listed); # in practice these processes only ever touch hyperhive's own repos. safeDirGitconfig = pkgs.writeText "hyperhive-safe-gitconfig" '' [safe] directory = * ''; # The `hive-c0re serve` config JSON. Keys are snake_case to match the # `ServeConfig` serde shape the daemon deserialises (the # container-injected HiveEnv fields, flattened, plus the hive-c0re-local # model_prices table); per-flag overrides still work for ad-hoc # invocations. # # Written to `/etc/hyperhive/serve.json` (managed by # `environment.etc`) rather than embedded as a store-path argument in # ExecStart. This keeps ExecStart byte-stable across deploys that only # change hyperhive module files (gateway, frontend, unrelated nix # modules) so systemd does NOT restart hive-c0re — and therefore does # NOT trigger a startup sweep that rebuilds every agent — unless the # c0re binary itself changes. serveConfigJson = builtins.toJSON { hyperhive_flake = cfg.hyperhiveFlake; hyperhive_docs_flake = cfg.hyperhiveDocs; nixpkgs_flake = cfg.nixpkgsFlake; dashboard_port = cfg.dashboardPort; operator_pronouns = cfg.operatorPronouns; context_window_tokens = cfg.contextWindowTokens; agent_cpu_quota = cfg.agentCpuQuota; agent_memory_max = cfg.agentMemoryMax; 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..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 ""; 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_`. 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-.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. ''; }; }; config = lib.mkIf cfg.enable { environment.systemPackages = [ cfg.package pkgs.git # XDG icons + .desktop entries so desktop environments can match # hyperhive processes to their icon (task managers, CPU monitors, etc.). cfg.xdgIcons ]; # Serve config at a stable /etc path so hive-c0re's ExecStart # doesn't embed a volatile store-path argument. See serveConfigJson # above for the rationale. environment.etc."hyperhive/serve.json".text = serveConfigJson; # Pull the per-container toplevels into the host system closure. # `system.extraDependencies` adds paths to the system build # without referencing them at runtime — nixos-rebuild fetches / # builds them, they end up in /nix/store, and the first # nixos-container update + start for an agent has nothing left to # do. Gated because the closure is sizeable and pinned to x86_64. system.extraDependencies = lib.optionals cfg.preBuildAgentTemplates [ cfg.agentBaseToplevel cfg.managerToplevel ]; # 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. users.users.hive-core = { isSystemUser = true; group = "hive-core"; description = "hive-c0re coordinator daemon user"; }; users.groups.hive-core = { }; # The gateway nginx is always the sole external entry point (it runs # alongside hyperhive), so the per-agent web-port range stays closed on # the host firewall. See `docs/gateway.md::Firewall posture (host-level)`. # NB: `services.hyperhive.domain` is required when hyperhive is # enabled — the canonical assertion lives in `hive-network.nix` (the # hive resolver is authoritative for `` and agents reach the # forge/matrix through the gateway by it). So everything below can # treat `config.services.hyperhive.domain` as non-null. systemd.services.hive-c0re = { description = "hyperhive coordinator daemon"; wantedBy = [ "multi-user.target" ]; # Socket unit must start before the service so hive-c0re receives the # pre-bound fd via LISTEN_FDS (socket activation). Without this # dependency, nixos-rebuild switch activates hive-c0re.socket while # hive-c0re.service is already running (started by multi-user.target), # and systemd refuses with "Socket service already active". Adding # requires+after causes systemd to stop the service, start the socket, # then restart the service -- clean transition on every config apply. requires = [ "hive-c0re.socket" ]; after = [ "hive-c0re.socket" ]; path = [ 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.`), 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.`). 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//` links (never the direct # `:` 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 `: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 `: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.`). 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..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 ) ); }; serviceConfig = { ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --config /etc/hyperhive/serve.json"; SyslogIdentifier = "hive-c0re"; # Migrate hive-c0re's *own* state to the service user after an # upgrade from a root-run install (systemd's StateDirectory only # chowns the top-level dir, not pre-existing files inside it). The # `+` prefix runs as root despite User = hive-core; `-` tolerates # failure. coreutils ships `chown` but no `sh`, so invoke the # binaries directly rather than through a shell. # # CRITICAL: exclude the per-agent `agents/` subtree. Its contents # (each agent's `claude/` OAuth creds, `state/`, `harness/`, # `config/`) are owned by the per-agent / manager users, and each # container's `hive-agent-user-migrate` activation script chowns # them back to that user on boot. Blanket-chowning them to hive-core # makes every agent's `~/.claude` unreadable — logging them all out # with no way to log back in. So chown everything *except* agents/, # plus the `agents/` dir node itself (not its contents) so c0re can # still create new per-agent subdirs. ExecStartPre = [ # Install the safe.directory gitconfig at $HOME/.gitconfig # (HOME = /var/lib/hyperhive) so c0re's `git fetch`/`rev-parse` # against the agent-owned proposed repos pass the ownership guard. # Placed before the chown below so it's chowned to hive-core too. "+-${pkgs.coreutils}/bin/cp ${safeDirGitconfig} /var/lib/hyperhive/.gitconfig" "+-${pkgs.findutils}/bin/find /var/lib/hyperhive -mindepth 1 -maxdepth 1 -not -name agents -exec ${pkgs.coreutils}/bin/chown -R hive-core:hive-core {} +" "+-${pkgs.coreutils}/bin/chown hive-core:hive-core /var/lib/hyperhive/agents" ]; Restart = "on-failure"; RestartSec = 2; User = "hive-core"; Group = "hive-core"; SupplementaryGroups = [ "systemd-journal" ]; RuntimeDirectory = "hyperhive"; RuntimeDirectoryMode = "0750"; RuntimeDirectoryPreserve = "yes"; StateDirectory = "hyperhive"; StateDirectoryMode = "0750"; # Sandboxing. hive-c0re is unprivileged (runs as hive-core, never # setuid), makes HTTP requests to forge/matrix/Anthropic (keeps INET), # and delegates all privileged ops to hive-priv via a Unix socket. # These directives deny the subset of kernel capabilities it # provably doesn't need without restricting its network or # filesystem access (RestrictAddressFamilies deferred — needs a # watched deploy to verify no AF_UNIX/AF_INET gaps in socket paths). NoNewPrivileges = true; # already runs as unprivileged user PrivateTmp = true; # uses StateDirectory for tmpfiles, not /tmp ProtectHome = true; # HOME = /var/lib/hyperhive; no /home/* access needed # "strict" makes the entire filesystem read-only except for # StateDirectory (/var/lib/hyperhive) and RuntimeDirectory # (/run/hyperhive), which systemd keeps writable. No # ReadWritePaths needed beyond the managed directories because: # - nix is invoked directly (lifecycle, meta, flake_check), but # NIX_REMOTE=daemon routes all store writes through the host # daemon — hive-c0re never writes to /nix itself. # - flake.lock ops land in the meta worktree under StateDirectory # (kept writable by systemd). # - nix build worktrees live in PrivateTmp, not /tmp. # - /etc writes (bind-mount edits) go through hive-priv via the # privileged socket; /etc/hyperhive/serve.json is read-only. ProtectSystem = "strict"; ProtectKernelTunables = true; # no sysctl writes ProtectKernelLogs = true; # reads logs via systemd-journal group, not /dev/kmsg ProtectControlGroups = true; # cgroup writes go through hive-priv, not c0re directly RestrictNamespaces = true; # namespace creation goes through hive-priv LockPersonality = true; # no personality changes needed RestrictRealtime = true; # no real-time scheduling }; }; # Socket unit for the hive-c0re admin socket. systemd creates and holds # `/run/hyperhive/host.sock` before hive-c0re starts, then passes the fd # via LISTEN_FDS (socket activation). Benefits: `hivectl` can connect # the moment the socket unit is active — no racy retry window — and a # hive-c0re restart never drops the socket inode, so queued commands # drain cleanly. # # `hive-c0re serve` reads LISTEN_FDS via the `listenfd` crate and # accepts the fd in preference to its own `bind()` path. When invoked # directly (dev, CI, without the socket unit) LISTEN_FDS is absent and # the traditional bind path runs unchanged — no regression. systemd.sockets.hive-c0re = { description = "hive-c0re admin socket"; wantedBy = [ "sockets.target" ]; socketConfig = { # Must match the `--socket` arg passed to `hive-c0re serve`. ListenStream = "/run/hyperhive/host.sock"; # 0660 root:root — `hivectl` is a host-only tool run as root. SocketMode = "0660"; # Parent dir inherits the RuntimeDirectory mode (0750) set on the # service unit; DirectoryMode is only consulted when the dir is # absent at socket-unit activation. 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 .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//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#`) 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"; }; }; }; }