Replace the hardcoded FORGE_HTTP const with forge_http_base() which reads HIVE_FORGE_URL from the environment (already set unconditionally by hive-c0re.nix to http://<forge.domain>). Add forge_git_url() helper that inserts core:<token> credentials between scheme and authority for git push/clone URLs. All call sites updated: - forge/mod.rs: api() OnceLock + new forge_git_url/forge_http_base fns - forge/repos.rs: push_meta, push_config, ensure_meta_remote - forge/pr_merge.rs: tokenised_repo_url delegate + test loosened - workers/knowledge.rs: clone + push URLs - socket_server/mod.rs: clone_url in RepoCreated response No new env var: HIVE_FORGE_URL was already the right knob (mara). Closes #1868. Closes #2174 (this supersedes the operators-team fix from the closed #2218, which is re-applied in the ensure_operators_team call that was already merged separately).
1311 lines
58 KiB
Nix
1311 lines
58 KiB
Nix
{
|
|
hyperhivePackage,
|
|
hyperhiveFrontend,
|
|
hyperhiveAssets,
|
|
hyperhiveFlake,
|
|
hyperhiveDocs,
|
|
hyperhiveXdgIcons,
|
|
agentBaseToplevel,
|
|
managerToplevel,
|
|
}:
|
|
{
|
|
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
|
|
{
|
|
# The forge is mandatory — hive-c0re mirrors every agent's applied
|
|
# config repo into it and it's the canonical store for the meta flake
|
|
# + `internal/*` repos, so there's no enable toggle; it deploys with
|
|
# hyperhive itself. hive-matrix is opt-in (off by default). All
|
|
# subsystems rely on `services.hyperhive.domain`, which is required
|
|
# (asserted in hive-network.nix) whenever hyperhive is enabled.
|
|
imports = [
|
|
./hive-ci.nix
|
|
./hive-forge.nix
|
|
./hive-gateway.nix
|
|
./hive-matrix.nix
|
|
./hive-network.nix
|
|
./hive-tls.nix
|
|
];
|
|
|
|
# Top-level hyperhive enable flag. When true, automatically enables
|
|
# hive-c0re and the on-by-default hyperhive subsystems.
|
|
options.services.hyperhive.enable = lib.mkEnableOption "hyperhive — the agent swarm coordinator";
|
|
|
|
# Canonical hive DNS domain shared by every subsystem that needs a
|
|
# stable hostname. Typed nullOr (default null) so the option always
|
|
# exists, but it's REQUIRED whenever hyperhive is enabled — an
|
|
# assertion in hive-network.nix fails eval when it's unset, since
|
|
# matrix bakes it in on first boot and the gateway/forge/agent URLs all
|
|
# derive from it (no safe default). Full identity-surface
|
|
# context (HYPERHIVE_HIVE_DOMAIN / HIVE_NAME / SWARM_NAME env-var
|
|
# chain → identity.rs → claude prompt): docs/conventions.md::
|
|
# Hive identity (label + domain + display names).
|
|
options.services.hyperhive.domain = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "darkest.space";
|
|
description = ''
|
|
Canonical host domain for hyperhive subsystems that need a
|
|
stable name (currently: `services.hyperhive.matrix.serverName`
|
|
derives from this, defaulting to
|
|
`matrix.''${services.hyperhive.domain}` when `serverName` is
|
|
null). **Required** when `services.hyperhive.enable` — eval fails
|
|
with a helpful message if it's unset (it's baked into matrix on
|
|
first boot and drives the gateway/forge/agent URLs, with no safe
|
|
default; changing it later is destructive). Exposed to agents as
|
|
`HYPERHIVE_HIVE_DOMAIN`; consumed by
|
|
`hive-ag3nt::identity::hive_domain()` for `<name>@<domain>`
|
|
qualified labels.
|
|
'';
|
|
};
|
|
|
|
# Human display names for hive + swarm. Distinct from the DNS
|
|
# domain above (machine-readable) — see
|
|
# docs/conventions.md::Hive identity for the
|
|
# domain-vs-name-vs-swarm distinction + the env-var
|
|
# propagation chain.
|
|
options.services.hyperhive.hiveName = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "pr1ma";
|
|
description = ''
|
|
Human-readable name of this single-host hive instance.
|
|
Distinct from `services.hyperhive.domain` (the machine-
|
|
addressable DNS name): the domain may carry the hive name as
|
|
its leftmost label by convention, but this option is the
|
|
canonical readable identity. Exposed to agents as
|
|
`HYPERHIVE_HIVE_NAME`; surfaced in the dashboard chrome and
|
|
per-agent system prompt when set. Null falls back to the
|
|
default behaviour (chrome shows the domain, prompt doesn't
|
|
mention a hive name).
|
|
'';
|
|
};
|
|
|
|
options.services.hyperhive.swarmName = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "constellat1on";
|
|
description = ''
|
|
Human-readable name of the wider swarm this hive belongs to.
|
|
Hives at different DNS domains can share a swarm name when
|
|
they federate together. Exposed to agents as
|
|
`HYPERHIVE_SWARM_NAME`; surfaced in the dashboard chrome and
|
|
per-agent system prompt when set.
|
|
'';
|
|
};
|
|
|
|
# Whether this hive runs "ruthless" — with no root/manager agent at all.
|
|
# When true, hive-c0re skips the root-agent auto-management sweep (create
|
|
# if missing, restart if present-but-stopped). Some hives don't want a
|
|
# root agent at all — see issue tracker "scope concept: special agents".
|
|
options.services.hyperhive.ruthless = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
example = true;
|
|
description = ''
|
|
Run this hive "ruthless" — with no root (manager) agent at all (no
|
|
ruth). When `true`, hive-c0re skips the root-agent auto-management
|
|
sweep entirely (it otherwise creates the root agent's container when
|
|
missing and restarts it when present but stopped). Defaults to
|
|
`false` (the historical behaviour — the root agent is auto-managed
|
|
as required infrastructure). Exposed to hive-c0re as
|
|
`HYPERHIVE_RUTHLESS`.
|
|
'';
|
|
};
|
|
|
|
# Hive-wide OTEL stats export. Set ONCE here at host level; the
|
|
# meta-flake renderer (`hive-c0re/src/meta.rs::otel_config`) reads the
|
|
# HYPERHIVE_OTEL_* env exported below off hive-c0re's unit and injects
|
|
# the matching `hyperhive.otel.*` build-time config into EVERY agent
|
|
# (mirroring the CA-cert injection), so each agent's harness exports
|
|
# its own Claude Code stats directly to the collector. There is no
|
|
# per-agent opt-in — this is the single switch for the whole hive.
|
|
options.services.hyperhive.otel = {
|
|
enable = lib.mkEnableOption ''
|
|
hive-wide export of every agent's Claude Code stats (token usage,
|
|
cost, tool calls) to an OTLP endpoint via Claude Code's built-in
|
|
OpenTelemetry. One switch for all agents; each harness exports
|
|
directly to the collector, so it keeps working even when hive-c0re
|
|
is down
|
|
'';
|
|
|
|
endpoint = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
example = "https://collector.example.com/otel";
|
|
description = ''
|
|
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
for every agent. Required when `enable` is true.
|
|
'';
|
|
};
|
|
|
|
protocol = lib.mkOption {
|
|
type = lib.types.enum [
|
|
"http/protobuf"
|
|
"http/json"
|
|
"grpc"
|
|
];
|
|
default = "http/protobuf";
|
|
description = ''
|
|
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
|
'';
|
|
};
|
|
|
|
headersCredential = lib.mkOption {
|
|
# `str`, not `path`: a `path`-typed relative literal is hash-copied
|
|
# into the world-readable nix store at eval time, defeating the
|
|
# point. Keep it a string + require an absolute runtime path so the
|
|
# secret is only ever read from disk by systemd at start.
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "/run/secrets/otel-headers";
|
|
description = ''
|
|
Absolute path to an operator-provided secret file whose contents
|
|
become `OTEL_EXPORTER_OTLP_HEADERS` (e.g.
|
|
`Authorization=Bearer <token>`). hive-c0re forwards this host
|
|
file into each agent container's credential store via
|
|
systemd-nspawn `--load-credential=otel-headers:<path>`; the inner
|
|
harness unit inherits it by name (`LoadCredential`), so the token
|
|
is never copied into the nix store, the generated config, a bind
|
|
mount, or argv. Must be absolute. Leave null if the endpoint
|
|
needs no auth header. A configured-but-missing file is skipped
|
|
with a log warning (OTEL still exports, without the auth header).
|
|
'';
|
|
};
|
|
|
|
extraResourceAttributes = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
example = "deployment.environment=prod";
|
|
description = ''
|
|
Extra comma-separated entries appended to
|
|
`OTEL_RESOURCE_ATTRIBUTES` after the built-in
|
|
`service.name` / `agent` / `hive` / `swarm` labels.
|
|
'';
|
|
};
|
|
|
|
debug = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = ''
|
|
Emit OTEL SDK diagnostic messages to every agent's stderr by
|
|
setting `CLAUDE_CODE_OTEL_DIAG_STDERR=1`. Useful when
|
|
troubleshooting collector connectivity or endpoint config;
|
|
leave off in normal operation to avoid noise in agent logs.
|
|
Only meaningful when `enable` is true.
|
|
'';
|
|
};
|
|
|
|
metricIntervalMs = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.ints.positive;
|
|
default = null;
|
|
example = 10000;
|
|
description = ''
|
|
Metric export interval in milliseconds, set as
|
|
`OTEL_METRIC_EXPORT_INTERVAL` for every agent. Claude Code's
|
|
default is 60000 (60s). Leave `null` to use that default.
|
|
|
|
Each agent runs claude as a short-lived per-turn process; claude
|
|
force-flushes metrics on shutdown, so this is not required for
|
|
metrics to be exported, but a lower value gives more frequent
|
|
intermediate flushes within long turns. Cosmetic, not a
|
|
correctness knob.
|
|
'';
|
|
};
|
|
};
|
|
|
|
# Peer hives in the same swarm. Each entry declares a remote hive
|
|
# reachable from this host. Serialised to JSON and injected as
|
|
# `HYPERHIVE_PEERS` into the hive-c0re service and forwarded to agent
|
|
# containers via `meta.rs::FORWARDED_VARS`. Consumed by
|
|
# `identity.rs::peers()` + the dashboard's `peer_hives` state field
|
|
# (feeds the P33RS dashboard tab).
|
|
options.services.hyperhive.swarm.peers = lib.mkOption {
|
|
type = lib.types.attrsOf (
|
|
lib.types.submodule {
|
|
options = {
|
|
certFingerprint = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12";
|
|
description = ''
|
|
Expected TLS certificate fingerprint for this peer's HTTPS
|
|
endpoint. Null = trust the system CA bundle (for Let's
|
|
Encrypt peers). Set to pin a self-signed cert.
|
|
|
|
Format: the literal `sha256:` followed by exactly 64
|
|
hex digits (case-insensitive, no colon separators) — the
|
|
SHA-256 digest of the peer's DER-encoded leaf certificate.
|
|
Generate with `openssl x509 -noout -fingerprint -sha256`,
|
|
then strip the colons and prepend `sha256:`. A malformed
|
|
value is ignored with a warning rather than weakening
|
|
trust. See docs/swarm.md for the full recipe.
|
|
|
|
Scopes only to hive-c0re's own peer HTTPS checks — it does
|
|
NOT help Matrix federation (tuwunel validates against its
|
|
container trust bundle). For a self-signed peer whose root
|
|
CA you want trusted hive-wide (every agent + Matrix
|
|
federation), set `caCert` below.
|
|
'';
|
|
};
|
|
|
|
caCert = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.path;
|
|
default = null;
|
|
example = "./peers/edge-ca.pem";
|
|
description = ''
|
|
Path to this peer hive's root CA certificate (PEM). When
|
|
set, the CA is embedded (at build time, into the nix store
|
|
— no runtime file on the host) and trusted **everywhere the
|
|
hive's own internal CA is**: it rides alongside `hive-ca.pem`
|
|
in each agent's `security.pki.certificateFiles` (via the
|
|
meta-flake renderer), and is added to the Matrix homeserver
|
|
container's trust bundle so tuwunel validates *federation*
|
|
TLS from a self-signed peer hive whose cert chains to it.
|
|
This is the CA-trust path that `certFingerprint`
|
|
(leaf-pinning, c0re-only) can't cover, and is what unblocks
|
|
Matrix federation with a self-signed peer hive. Trust stays
|
|
inside the hive (agents + the Matrix container), never the
|
|
host system trust store. Mutually complementary with
|
|
`certFingerprint`; set `caCert` for the federation case. See
|
|
docs/swarm.md.
|
|
'';
|
|
};
|
|
|
|
wireguardPublicKey = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "base64pubkey=";
|
|
description = ''
|
|
WireGuard public key for this peer host. Required when
|
|
`services.hyperhive.swarm.wireguard.enable = true` and
|
|
you want this peer reachable over the mesh. Null = TLS-
|
|
only peering (public internet, no mesh tunnel).
|
|
'';
|
|
};
|
|
|
|
wireguardEndpoint = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "203.0.113.1:51820";
|
|
description = ''
|
|
WireGuard endpoint for this peer in `host:port` form.
|
|
Required when the peer host is behind a firewall and
|
|
this host needs to initiate the tunnel. Null = this host
|
|
waits for the peer to connect (peer-initiates; peer must
|
|
have an endpoint pointing back at this host).
|
|
'';
|
|
};
|
|
|
|
wireguardAddress = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
example = "10.100.0.2/32";
|
|
description = ''
|
|
IP address (with prefix) of the peer host on the
|
|
WireGuard mesh. Used as the `allowedIPs` for the peer's
|
|
WireGuard config entry and injected into `HYPERHIVE_PEERS`
|
|
so hive-c0re can route intra-swarm traffic to the mesh
|
|
address rather than the public domain. Required to include
|
|
the peer in the WireGuard mesh (peers missing this field
|
|
are silently excluded from `wg-hive`).
|
|
'';
|
|
};
|
|
};
|
|
}
|
|
);
|
|
default = { };
|
|
example = {
|
|
"lab.example.com" = {
|
|
certFingerprint = "sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12";
|
|
};
|
|
"edge.corp" = { };
|
|
};
|
|
description = ''
|
|
Peer hives in the same swarm. The attrset key is the peer's DNS
|
|
domain — used for dashboard links and Matrix federation discovery.
|
|
Null `certFingerprint` trusts the system CA bundle; set it to pin
|
|
a self-signed TLS cert. Add `wireguardPublicKey` + `wireguardAddress`
|
|
(and optionally `wireguardEndpoint`) to include the peer in the
|
|
WireGuard mesh when `swarm.wireguard.enable = true`.
|
|
'';
|
|
};
|
|
|
|
# WireGuard mesh config for the local host.
|
|
# When enabled, hive-c0re configures a `wg-hive` interface on the host
|
|
# connecting to all peers that have `wireguardPublicKey` declared.
|
|
# Peers reachable over the mesh are preferred for inter-hive traffic
|
|
# (no public TLS round-trip needed); peers without a public key still
|
|
# work via normal HTTPS.
|
|
options.services.hyperhive.swarm.wireguard = {
|
|
enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = ''
|
|
Enable the WireGuard inter-hive mesh. When true, a `wg-hive`
|
|
interface is brought up connecting to all swarm peers that
|
|
declare a `wireguardPublicKey`. Requires
|
|
`privateKeyFile` to be set.
|
|
'';
|
|
};
|
|
|
|
privateKeyFile = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.path;
|
|
default = null;
|
|
example = "/etc/wireguard/hive.key";
|
|
description = ''
|
|
Path to the host's WireGuard private key file. The file must
|
|
be readable by root and should have mode 0400. Generate with
|
|
`wg genkey > /etc/wireguard/hive.key`. Required when
|
|
`swarm.wireguard.enable = true`.
|
|
'';
|
|
};
|
|
|
|
address = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
example = "10.100.0.1/24";
|
|
description = ''
|
|
IP address (with prefix) of this host on the WireGuard mesh.
|
|
Use a /24 (or broader) prefix so the routing table covers all
|
|
peer /32 routes. Example: `"10.100.0.1/24"` for a 256-host mesh.
|
|
'';
|
|
};
|
|
|
|
listenPort = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 51820;
|
|
description = ''
|
|
UDP port the local WireGuard interface listens on. Must be
|
|
reachable from peer hosts when they initiate the tunnel.
|
|
Default: 51820 (standard WireGuard port).
|
|
'';
|
|
};
|
|
|
|
persistentKeepalive = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.int;
|
|
default = 25;
|
|
example = 25;
|
|
description = ''
|
|
Seconds between keepalive packets sent to each peer. Useful
|
|
when this host (or a peer) is behind NAT — keeps the UDP hole
|
|
open. Set to null to disable. Default: 25 seconds.
|
|
'';
|
|
};
|
|
};
|
|
|
|
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;
|
|
default = hyperhivePackage pkgs.stdenv.hostPlatform.system;
|
|
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).
|
|
'';
|
|
};
|
|
frontend = lib.mkOption {
|
|
type = lib.types.package;
|
|
default = hyperhiveFrontend pkgs.stdenv.hostPlatform.system;
|
|
defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend";
|
|
description = ''
|
|
Bundled frontend dist (see `./nix/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 `hive-gateway.nix` can static-serve
|
|
`dashboard/` as an nginx root instead of proxying to hive-c0re.
|
|
'';
|
|
};
|
|
assets = lib.mkOption {
|
|
type = lib.types.package;
|
|
default = hyperhiveAssets pkgs.stdenv.hostPlatform.system;
|
|
defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets";
|
|
description = ''
|
|
Bundled static runtime assets (see `./nix/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.
|
|
'';
|
|
};
|
|
hyperhiveFlake = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = hyperhiveFlake;
|
|
defaultText = lib.literalMD "the flake's own 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. Defaults to this flake's own store path —
|
|
only override if you want agents tracking a different ref.
|
|
'';
|
|
};
|
|
hyperhiveDocs = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = hyperhiveDocs;
|
|
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.
|
|
'';
|
|
};
|
|
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 ${hyperhiveFlake}#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. The old
|
|
hard-coded value was `"50%"`; 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`. The old hard-coded value was
|
|
`"2G"`.
|
|
'';
|
|
};
|
|
|
|
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 like the pre-DAG rebuild queue did; 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.).
|
|
(hyperhiveXdgIcons pkgs.stdenv.hostPlatform.system)
|
|
];
|
|
|
|
# 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 [
|
|
agentBaseToplevel
|
|
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)`.
|
|
|
|
# WireGuard inter-hive mesh. Enabled when
|
|
# `services.hyperhive.swarm.wireguard.enable = true`. Brings up a
|
|
# `wg-hive` interface and connects to each peer that has
|
|
# `wireguardPublicKey` set. Firewall opens the UDP listen port on
|
|
# the host (not inside containers — this is host-level networking).
|
|
networking.wireguard.interfaces = lib.mkIf config.services.hyperhive.swarm.wireguard.enable (
|
|
let
|
|
wgCfg = config.services.hyperhive.swarm.wireguard;
|
|
meshPeers = lib.filterAttrs (
|
|
_: p: p.wireguardPublicKey != null && p.wireguardAddress != null
|
|
) config.services.hyperhive.swarm.peers;
|
|
in
|
|
{
|
|
wg-hive = {
|
|
ips = [ wgCfg.address ];
|
|
listenPort = wgCfg.listenPort;
|
|
privateKeyFile = wgCfg.privateKeyFile;
|
|
peers = lib.mapAttrsToList (
|
|
_domain: p:
|
|
{
|
|
publicKey = p.wireguardPublicKey;
|
|
allowedIPs = [ p.wireguardAddress ];
|
|
}
|
|
// lib.optionalAttrs (p.wireguardEndpoint != null) {
|
|
endpoint = p.wireguardEndpoint;
|
|
}
|
|
// lib.optionalAttrs (wgCfg.persistentKeepalive != null) {
|
|
persistentKeepalive = wgCfg.persistentKeepalive;
|
|
}
|
|
) meshPeers;
|
|
};
|
|
}
|
|
);
|
|
|
|
# Open the WireGuard UDP port on the host firewall when the mesh is on.
|
|
networking.firewall.allowedUDPPorts = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [
|
|
config.services.hyperhive.swarm.wireguard.listenPort
|
|
];
|
|
|
|
# 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.
|
|
assertions =
|
|
lib.optionals config.services.hyperhive.swarm.wireguard.enable [
|
|
{
|
|
assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null;
|
|
message = ''
|
|
services.hyperhive.swarm.wireguard.enable requires
|
|
services.hyperhive.swarm.wireguard.privateKeyFile to be set.
|
|
Generate a key: wg genkey > /etc/wireguard/hive.key
|
|
'';
|
|
}
|
|
{
|
|
assertion = config.services.hyperhive.swarm.wireguard.address != "";
|
|
message = ''
|
|
services.hyperhive.swarm.wireguard.enable requires
|
|
services.hyperhive.swarm.wireguard.address to be set
|
|
(e.g. "10.100.0.1/24").
|
|
'';
|
|
}
|
|
]
|
|
++ lib.optionals config.services.hyperhive.otel.enable [
|
|
{
|
|
assertion = config.services.hyperhive.otel.endpoint != "";
|
|
message = "services.hyperhive.otel.enable is true but services.hyperhive.otel.endpoint is empty.";
|
|
}
|
|
];
|
|
|
|
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
|
|
# now (see hive-gateway.nix); 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 = historical
|
|
# behaviour (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.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
|
|
)
|
|
);
|
|
};
|
|
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 <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";
|
|
};
|
|
};
|
|
};
|
|
}
|