refactor: split harness-base.nix into harness/ feature modules

This commit is contained in:
müde 2026-07-13 21:16:29 +02:00
commit ecaad48fad
17 changed files with 2392 additions and 2208 deletions

View file

@ -1,6 +1,6 @@
{ ... }:
{
imports = [ ./harness-base.nix ];
imports = [ ./harness ];
# Entry-point for sub-agent containers. Referenced from `flake.nix`
# (`nixosConfigurations.agent-base`) and the meta-flake's
# `applied/<name>/flake.nix`.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,245 @@
# The hive-ag3nt harness service itself, plus the per-agent knobs it
# reads from its environment: model selection, effort level,
# compaction watermark, and the extra reverse-proxies of the per-agent
# web UI.
{
pkgs,
lib,
config,
...
}:
let
userName = config.hyperhive.user.name;
homeDir = "/home/${userName}";
in
{
options.hyperhive.model = lib.mkOption {
type = lib.types.str;
default = "haiku";
example = "sonnet";
description = ''
Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL`
environment variable; the harness applies it at boot and it takes
priority over any persisted runtime override. The operator can still
switch the model at runtime via the per-agent web UI that choice
is tracked in the state dir for the current session but is reset by
any rebuild that changes this option.
Valid values are the short model names that `claude --model` accepts:
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context
window sizes are looked up at runtime from the
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars injected by the
meta flake; override sizes via `services.hyperhive.c0re.contextWindowTokens`
on the host.
'';
};
options.hyperhive.availableModels = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"haiku"
"sonnet"
"opus"
];
example = [
"sonnet"
"opus"
];
description = ''
Models offered in the per-agent web UI's model quick-picker. Rendered
into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated)
which the harness surfaces to the agent UI, so the picker lists exactly
these models instead of a hardcoded set.
Configure hive-wide by setting a shared default (e.g. in your
`agent-base.nix`) or per-agent to narrow the menu for example a
haiku-only agent can hide `opus` and `sonnet`. The *current* model is
still set by `hyperhive.model` and remains switchable at runtime via the
UI; this option only controls which choices the picker presents.
Values are the short model names that `claude --model` accepts:
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier).
'';
};
options.hyperhive.effortLevel = lib.mkOption {
type = lib.types.enum [
"low"
"medium"
"high"
"xhigh"
"max"
];
default = "medium";
example = "high";
description = ''
Baseline claude effort level for this agent. Rendered into the
`HIVE_DEFAULT_EFFORT` environment variable; the harness resolves
effort as operator-override-file this env built-in `"medium"`,
and passes the result to `claude --effort` at turn launch.
Ascending scale: `"low"` (minimal thinking budget), `"medium"`
(default balanced), `"high"` (platform default), `"xhigh"`
(recommended for autonomous coding on capable models), `"max"`
(maximum thinking budget, highest cost). The operator can override
at runtime per-agent via the web UI (applied on the next session);
any rebuild that changes this option resets that override.
'';
};
options.hyperhive.autoCompact = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable proactive watermark-based compaction. When `true` (the
default) the harness automatically runs a notes-checkpoint turn
followed by `/compact` once the context window crosses 75% of
the model's limit, keeping later turns from hitting the hard
overflow path. Set to `false` to disable proactive compaction
entirely (`HIVE_COMPACT_WATERMARK_TOKENS=0`); the reactive path
(compact-on-overflow when the session is already past the limit)
still applies.
Disable for agents that run large-context models (sonnet/opus)
where the heuristic fires too early and discards useful history
before the session is actually close to the limit.
'';
};
options.hyperhive.extraWebProxies = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''{ "stats" = "http://127.0.0.1:3737"; }'';
description = ''
Transparent reverse-proxies mounted under `/extra/` in the per-agent web UI.
Each attribute name becomes the sub-path and the value is the upstream.
E.g. `{ "stats" = "http://127.0.0.1:3737"; }` mounts a proxy at
`/agent/<name>/extra/stats/` that forwards to port 3737 with the prefix
stripped. All user-declared proxies live under `/extra/` so they can
never conflict with native agent endpoints (`/api/*`, `/events/*`, etc.).
The upstream value is either an `http(s)://` URL or a Unix domain
socket, spelled `unix:<path>` (e.g. `unix:/run/myapp/http.sock`) for
agents whose secondary web server only listens on a UDS.
Intended for agents that run secondary web servers in the same container.
Static assets served by the secondary app must use relative paths to
resolve correctly under the sub-path prefix.
Sets the `HIVE_EXTRA_WEB_PROXIES` environment variable (JSON object)
on the harness service unit.
'';
};
config = {
assertions = [
# hyperhive.model must be a non-empty string — an empty value causes
# the harness to pass an invalid model flag to claude.
{
assertion = config.hyperhive.model != "";
message = "hyperhive.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")";
}
# The current model must appear in the quick-picker menu, otherwise the
# UI would offer no way back to the model the agent is actually running.
{
assertion =
config.hyperhive.availableModels == [ ]
|| builtins.elem config.hyperhive.model config.hyperhive.availableModels;
message =
"hyperhive.model (\"${config.hyperhive.model}\") must be one of "
+ "hyperhive.availableModels ([ ${lib.concatStringsSep " " config.hyperhive.availableModels} ]) "
+ " add it to the list or change the model.";
}
];
# HIVE_DEFAULT_MODEL seeds the initial model selection when no
# persisted model choice exists in the state dir.
environment.variables = {
HIVE_DEFAULT_MODEL = config.hyperhive.model;
# Comma-separated menu for the per-agent UI model quick-picker
# (see hyperhive.availableModels). The harness surfaces it to the
# frontend; an empty value falls back to the built-in default list.
HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.hyperhive.availableModels;
# Per-agent baseline effort (see hyperhive.effortLevel). The
# harness resolves operator-override-file → this env → "medium"
# and passes it to claude --effort at turn launch.
HIVE_DEFAULT_EFFORT = config.hyperhive.effortLevel;
}
// lib.optionalAttrs (!config.hyperhive.autoCompact) {
# Zero watermark disables proactive compaction; the reactive path
# (compact-on-overflow) still fires when the session is truly full.
HIVE_COMPACT_WATERMARK_TOKENS = "0";
};
# Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars,
# RuntimeDirectory, User=, standalone-eval fallbacks):
# docs/agent-hierarchy.md::Harness systemd unit shape. PATH /bin
# auto-append behaviour: docs/gotchas.md::systemd.services.*.path
# appends /bin to every entry.
systemd.services.hive-ag3nt =
let
binary = "hive-agent";
in
{
description = "${binary} harness";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
# `/run/wrappers` before `/run/current-system/sw` so setuid
# `sudo` resolves first. Passing the bare prefixes (no trailing
# `/bin`) is intentional — see docs pointer above.
path = [
"/run/wrappers"
"/run/current-system/sw"
];
environment = {
SHELL = "${pkgs.bashInteractive}/bin/bash";
HOME = homeDir;
HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}";
HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive";
# Unix-socket path for the harness web UI. All agents always bind
# here; there is no TCP fallback. Path matches
# `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle
# bind-mounts and gateway upstream config stay in sync.
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
# Loopback URL of the persistent `hive-mcp-http` daemon that
# `render_claude_config` points claude at for the built-in
# surface (HTTP is the sole transport — no per-turn stdio child).
# Kept in sync with the `hive-mcp-http` unit's `--http` port
# (see ./mcp.nix) via the same option. Always set — network
# isolation is unconditional, so a fixed per-container port is
# collision-free.
HYPERHIVE_MCP_HTTP_PORT = toString config.hyperhive.mcp.httpPort;
}
// lib.optionalAttrs config.hyperhive.gui.enable {
# Tells the harness which fixed VNC port weston bound, and (by
# its presence) that gui is enabled — the harness `/screen/ws`
# relay reads this instead of a runtime marker file. The port is
# container-local + fixed (network isolation is unconditional),
# so the same value for every gui agent is fine. See
# ./weston-vnc.nix::hyperhive.gui.vncPort.
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
}
// lib.optionalAttrs (config.hyperhive.extraWebProxies != { }) {
# JSON object {"<path>": "<upstream>"} for the transparent
# reverse-proxies. See `hyperhive.extraWebProxies` option
# and `web_ui/proxy.rs::extra_proxy_service`.
HIVE_EXTRA_WEB_PROXIES = builtins.toJSON config.hyperhive.extraWebProxies;
};
serviceConfig = {
ExecStart = "${pkgs.hive-agent}/bin/${binary}";
# Pin the journal identity to the binary name (otherwise systemd
# derives SyslogIdentifier from the ExecStart basename).
SyslogIdentifier = binary;
Restart = "on-failure";
RestartSec = 2;
# Per-service runtime dir owned by `User=` below; the harness
# writes its regenerated claude-{mcp-config,settings,system-prompt}
# files here (`paths::config_dir`). Separate from /run/hive,
# which holds hive-c0re's mcp.sock.
RuntimeDirectory = "hive-config";
User = userName;
Group = userName;
};
};
};
}

View file

@ -0,0 +1,134 @@
# Shell-environment feature hooks: the `_bashEnvFragments`
# accumulator, the `/etc/hyperhive/bash-env.sh` file it renders to,
# and the cargo `--message-format short` injector that contributes to
# it. Loaded via `$BASH_ENV` for non-interactive shells (claude's
# `Bash` tool runs `bash -c`) and via `programs.bash` for interactive
# ones.
{
lib,
config,
...
}:
{
# Internal accumulator for shell snippets that should land in
# `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via
# `lib.mkIf` gated on their own option; the lines type merges
# all contributions across modules into one file. Generic by
# design so future hooks don't need to rename this file or
# invent a parallel dispatcher.
options.hyperhive._bashEnvFragments = lib.mkOption {
type = lib.types.lines;
default = "";
internal = true;
description = ''
Shell snippets concatenated into `/etc/hyperhive/bash-env.sh`.
Feature hooks contribute via `lib.mkIf` gated on their own
option. When empty, the file isn't created, `BASH_ENV` stays
unset, and the interactive bashrc hook is omitted zero cost
when no feature is on. Internal set indirectly via the
per-feature options that own the gate (e.g.
`hyperhive.cargo.shortMessages`).
'';
};
options.hyperhive.cargo.shortMessages = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = ''
Auto-inject `--message-format short` on cargo compile
subcommands (`build`, `check`, `clippy`, `test`, `run`,
`doc`, `bench`, `install`, `rustc`, `fix`) when claude (or
anything else) invokes `cargo` inside this container.
Saves tokens + context the verbose default output floods
the response window with per-crate progress lines that
carry no signal beyond the warning/error summary.
Implementation: contributes a `cargo` shell function to
`/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`).
Loaded via `BASH_ENV` for non-interactive shells (`bash -c`
what the claude `Bash` tool runs) and sourced from
`programs.bash.interactiveShellInit` for interactive shells.
The function:
- handles the `+toolchain` selector prefix (`cargo +nightly
build` works);
- passes through cleanly when the caller already specified
`--message-format` (any form);
- leaves non-compile subcommands (`new`, `add`, `search`,
third-party `cargo-*` subcommands) untouched so they
don't error on the unknown flag.
Set to `false` for agents that need full cargo output (e.g.
tooling that parses `--message-format json` programmatically
and doesn't pass the flag explicitly).
'';
};
config = {
# Cargo `--message-format short` injector. `command cargo …` falls
# back to the un-wrapped binary in PATH (the rust toolchain's cargo
# — either from `environment.systemPackages` or from whatever
# `nix develop` shell the agent's working in).
hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages ''
# Auto-injects --message-format short on cargo compile
# subcommands so per-crate progress lines don't flood
# claude's context. Bypassed when the caller already passes
# --message-format (any form).
cargo() {
# Strip leading +toolchain selectors (cargo +nightly …).
local pre=()
while [ "''${1:0:1}" = "+" ] && [ -n "''${1:-}" ]; do
pre+=("$1")
shift
done
case "''${1:-}" in
build|check|clippy|test|run|doc|bench|install|rustc|fix)
local sub="$1"
shift
local arg
for arg in "$@"; do
case "$arg" in
--message-format|--message-format=*)
command cargo "''${pre[@]}" "$sub" "$@"
return $?
;;
esac
done
command cargo "''${pre[@]}" "$sub" --message-format short "$@"
;;
*)
command cargo "''${pre[@]}" "$@"
;;
esac
}
'';
# Single bash-env file with all configured shell fragments.
# Wiring is gated on at least one fragment being active so a
# fully feature-disabled agent has neither the file nor the
# `BASH_ENV` / interactive sourcing — zero cost in that case.
environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") {
text = config.hyperhive._bashEnvFragments;
};
# Non-interactive bash invocations (claude's `Bash` tool runs
# `bash -c`) source $BASH_ENV at startup — drops every active
# feature hook's snippet into scope without touching
# `/etc/profile` (login-only).
environment.variables = lib.mkIf (config.hyperhive._bashEnvFragments != "") {
BASH_ENV = "/etc/hyperhive/bash-env.sh";
};
# Interactive shells don't honour BASH_ENV — wire the same file
# in via the bashrc hook so operator SSH sessions get the same
# hook surface as claude's non-interactive calls. Gated on at
# least one fragment being active so we don't write a no-op
# source line into `/etc/bashrc` on fully-feature-disabled agents.
programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") ''
if [ -r /etc/hyperhive/bash-env.sh ]; then
. /etc/hyperhive/bash-env.sh
fi
'';
};
}

View file

@ -0,0 +1,376 @@
# Everything that shapes claude-code's own configuration inside the
# container: the managed settings json (base env + OTEL), the
# onboarding/trust seed, the runtime OTEL auth-header injection, and
# the plugin/marketplace install lists the harness reads at boot.
{
pkgs,
lib,
config,
...
}:
let
userName = config.hyperhive.user.name;
homeDir = "/home/${userName}";
# Hive-wide OpenTelemetry config (host-driven; baked in per-agent by
# meta.rs `otel_config`).
otelCfg = config.hyperhive.otel;
# Hive/swarm display names are forwarded into each agent's build by
# meta.rs as `environment.variables` (per-agent, build-time strings),
# so they can be baked into the resource attributes below without a
# runtime shell. Absent (option unset) → "unknown".
hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown";
swarmDisplayName = config.environment.variables.HYPERHIVE_SWARM_NAME or "unknown";
# Base claude-code environment applied to every agent regardless of OTEL.
# Shipped via the managed settings `env` block so claude and `hivectl
# choom` both inherit them without a launch wrapper.
baseClaudeEnv = {
# Suppress analytics, survey pings, and other non-essential outbound
# traffic — agents are headless and don't need any of that.
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
DO_NOT_TRACK = "1";
# Disable claude's self-update machinery; package management is nix's job.
DISABLE_AUTOUPDATER = "1";
DISABLE_UPDATES = "1";
# Keep plugin updates synchronized on install; prefer HTTPS for fetches.
CLAUDE_CODE_PLUGIN_PREFER_HTTPS = "1";
CLAUDE_CODE_SYNC_PLUGIN_INSTALL = "1";
FORCE_AUTOUPDATE_PLUGINS = "1";
# Suppress the "install GitHub app" prompt — not applicable in-hive.
DISABLE_INSTALL_GITHUB_APP_COMMAND = "1";
# Disable Anthropic's hosted claude.ai MCP servers; the hive supplies its own.
ENABLE_CLAUDEAI_MCP_SERVERS = "0";
# Resume an interrupted turn on reconnect (recovers from transient MCP flaps).
CLAUDE_CODE_RESUME_INTERRUPTED_TURN = "1";
# Use the simpler system prompt variant suited to headless operation.
CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1";
# Tag remote-control sessions with "<hive>-<agent>" for identification.
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}";
};
# OTEL environment Claude Code reads to export metrics/logs/traces.
# Shipped via the managed claude settings json (below), which claude
# auto-discovers for BOTH the harness turn-loop and `hivectl choom` —
# so telemetry parity is declarative, with no launch wrapper. The
# auth header (`otel.headersCredential`) is deliberately NOT included
# here: it's a secret and this file lives in the world-readable nix
# store. It's injected at *runtime* into the agent's `0600`
# `~/.claude/settings.json` by the `hive-otel-header` oneshot below
# (claude merges the `env` from the user settings on top of these
# managed ones), so the token is read from disk at start and never
# touches the store.
otelSettingsEnv = {
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
# Attach feedback-survey data to the OTEL pipeline.
CLAUDE_CODE_ENABLE_FEEDBACK_SURVEY_FOR_OTEL = "1";
OTEL_METRICS_EXPORTER = "otlp";
OTEL_LOGS_EXPORTER = "otlp";
OTEL_TRACES_EXPORTER = "otlp";
OTEL_EXPORTER_OTLP_PROTOCOL = otelCfg.protocol;
OTEL_EXPORTER_OTLP_ENDPOINT = otelCfg.endpoint;
# Force CUMULATIVE temporality — Claude Code defaults to DELTA,
# which Prometheus/Mimir-family backends (incl. grafana-lgtm)
# silently drop without a deltatocumulative processor.
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative";
OTEL_RESOURCE_ATTRIBUTES =
"service.name=hyperhive-agent,agent=${userName},hive=${hiveDisplayName},swarm=${swarmDisplayName}"
+ lib.optionalString (otelCfg.extraResourceAttributes != "") ",${otelCfg.extraResourceAttributes}";
# Include the Claude Code version label in emitted metrics.
OTEL_METRICS_INCLUDE_VERSION = "1";
}
// lib.optionalAttrs (otelCfg.metricIntervalMs != null) {
OTEL_METRIC_EXPORT_INTERVAL = toString otelCfg.metricIntervalMs;
};
in
{
# OTEL stats export is configured ONCE at host level via
# `services.hyperhive.otel.*` (see nix/modules/hive-c0re.nix) and
# injected into every agent's build by the meta-flake renderer
# (`hive-c0re/src/meta.rs::otel_config`). These per-agent options are
# the build-time implementation surface that injection writes into;
# they are not meant to be set directly in an agent.nix. Marked
# `internal` so the host option is the only documented operator knob.
options.hyperhive.otel = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
internal = true;
description = ''
Export this agent's Claude Code stats (token usage, cost, tool
calls) to an OTLP endpoint via Claude Code's built-in
OpenTelemetry. Each agent's harness exports directly to the
collector, so it keeps working even when hive-c0re is down.
Host-driven: set `services.hyperhive.otel.enable` instead.
'';
};
endpoint = lib.mkOption {
type = lib.types.str;
default = "";
internal = true;
description = ''
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`.
Host-driven via `services.hyperhive.otel.endpoint`.
'';
};
protocol = lib.mkOption {
type = lib.types.enum [
"http/protobuf"
"http/json"
"grpc"
];
default = "http/protobuf";
internal = true;
description = ''
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
Host-driven via `services.hyperhive.otel.protocol`.
'';
};
headersCredential = lib.mkOption {
# `str`, not `path`: a `path`-typed *relative* literal (e.g.
# `./otel-headers`) is hash-copied into the world-readable nix store
# at eval time, which would defeat the whole point of this option.
# Keep it a string and require an absolute runtime path so the secret
# is only ever read from disk by systemd at start, never nix-stored.
type = lib.types.nullOr lib.types.str;
default = null;
internal = true;
description = ''
Absolute path to an operator-provided secret file whose contents
become `OTEL_EXPORTER_OTLP_HEADERS` (e.g.
`Authorization=Bearer <token>`). Host-driven via
`services.hyperhive.otel.headersCredential`.
The rest of the OTEL config ships in the world-readable managed
claude settings json, but the header is a secret, so it's handled
separately: hive-c0re forwards this file into the container's
systemd credential store, and the `hive-otel-header` oneshot
reads it at runtime (`LoadCredential`) and writes it into the
agent's `0600` `~/.claude/settings.json` `env` block. The token
is read from disk at start and never copied into the nix store or
the world-readable settings file.
'';
};
extraResourceAttributes = lib.mkOption {
type = lib.types.str;
default = "";
internal = true;
description = ''
Extra comma-separated entries appended to
`OTEL_RESOURCE_ATTRIBUTES` after the built-in
`service.name` / `agent` / `hive` / `swarm` labels.
Host-driven via `services.hyperhive.otel.extraResourceAttributes`.
'';
};
metricIntervalMs = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive;
default = null;
internal = true;
description = ''
Metric export interval in milliseconds, set as
`OTEL_METRIC_EXPORT_INTERVAL`. Null leaves Claude Code's 60s
default. Host-driven via `services.hyperhive.otel.metricIntervalMs`.
'';
};
debug = lib.mkOption {
type = lib.types.bool;
default = false;
internal = true;
description = ''
Emit OTEL SDK diagnostics to stderr (`CLAUDE_CODE_OTEL_DIAG_STDERR=1`).
Host-driven via `services.hyperhive.otel.debug`.
'';
};
};
options.hyperhive.claudeMarketplaces = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "anthropics/claude-plugins-official" ];
example = [
"anthropics/claude-plugins-official"
"anthropics/claude-plugins-community"
];
description = ''
Claude Code plugin marketplaces to add at harness boot. Each
entry is passed to `claude plugin marketplace add <source>`
(`owner/repo`, full git URL, or local path). Idempotent
re-adding an existing marketplace is treated as success.
Required before `hyperhive.claudePlugins` entries that
reference a marketplace (e.g. `foo@claude-plugins-official`).
Rendered to `/etc/hyperhive/claude-marketplaces.json`.
Defaults to Anthropic's official marketplace; agents get it
out of the box without any per-agent.nix wiring.
'';
};
options.hyperhive.claudePlugins = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"formatter@my-marketplace"
"thinking-tools@anthropics"
];
description = ''
Claude Code plugins to install at harness boot. Each entry is
passed verbatim to `claude plugin install <spec>` once per
container start, before the turn loop opens. `claude plugin
install` is expected to be idempotent, so reinstalling on every
boot is cheap. Failures log a warning but do not abort boot a
missing plugin is preferable to a non-serving agent. Rendered to
`/etc/hyperhive/claude-plugins.json`; the harness reads it via
`plugins::install_configured`.
'';
};
options.hyperhive.claudePluginsAutoUpdate = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
When true, the harness runs `claude plugin marketplace update`
before installing plugins at boot, pulling the latest index from
all configured marketplaces. Disabled by default most agents
want pinned plugin versions and the network round-trip adds to
boot time. Enable for agents that should always install the latest
available version of their plugins.
'';
};
config = {
environment.etc."hyperhive/claude-plugins.json".text =
builtins.toJSON config.hyperhive.claudePlugins;
environment.etc."hyperhive/claude-marketplaces.json".text =
builtins.toJSON config.hyperhive.claudeMarketplaces;
environment.etc."hyperhive/claude-plugins-auto-update.json".text =
builtins.toJSON config.hyperhive.claudePluginsAutoUpdate;
# Hive-enforced claude settings. claude-code auto-discovers managed
# settings at this canonical Linux path (precedence #1, read-only,
# un-overridable by user/project/CLI) — so the harness doesn't pass
# `--settings` or copy the blob per turn. effortLevel is
# deliberately NOT shipped here: effort is controlled live via the
# `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider),
# which managed scope would otherwise override and lock.
# Hive-enforced settings merged with a per-agent `env` block at BUILD
# time via `jq` (not eval-time `readFile`, which would be import-from-
# derivation). The `env` block is always present: `baseClaudeEnv` sets
# behaviour flags and the remote-control session prefix for every agent;
# `otelSettingsEnv` is merged on top when OTEL is enabled. claude-code
# auto-discovers this file in every context (harness turn-loop AND
# `hivectl choom`) so no launch wrapper is needed.
environment.etc."claude-code/managed-settings.json".source =
let
baseSettings = "${pkgs.hyperhive-assets}/share/hyperhive/prompts/claude-settings.json";
# Merge base env (always) with OTEL env (when enabled). jq is always
# run — `baseClaudeEnv` contains per-agent values (e.g.
# CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX) that can't live in the
# static store asset.
allEnv =
baseClaudeEnv
// lib.optionalAttrs otelCfg.enable otelSettingsEnv
// lib.optionalAttrs (otelCfg.enable && otelCfg.debug) {
# SDK diagnostics — noisy; only on when services.hyperhive.otel.debug = true.
CLAUDE_CODE_OTEL_DIAG_STDERR = "1";
};
in
pkgs.runCommand "managed-settings.json" { nativeBuildInputs = [ pkgs.jq ]; } ''
jq --argjson env ${lib.escapeShellArg (builtins.toJSON allEnv)} \
'. + { env: $env }' ${baseSettings} > "$out"
'';
# Inject the OTEL auth header (a secret) into the agent's *user*
# claude settings at runtime, keeping it out of the world-readable
# managed settings json above and out of the nix store entirely.
# hive-c0re forwards the operator's `headersCredential` file into
# this container's systemd credential store; this oneshot reads it
# via `LoadCredential` at start and merges `OTEL_EXPORTER_OTLP_HEADERS`
# into `~/.claude/settings.json` (0600, agent-owned). claude layers
# the user `env` on top of the managed one, so both the harness
# turn-loop and `hivectl choom` (same agent user) pick it up. Ordering
# is best-effort (`before`, not a hard dep): if it fails the harness
# still starts and telemetry just exports unauthenticated.
systemd.services.hive-otel-header =
lib.mkIf (config.hyperhive.otel.enable && config.hyperhive.otel.headersCredential != null)
{
description = "Inject the OTEL auth header into the agent's claude user settings";
wantedBy = [ "multi-user.target" ];
before = [ "hive-ag3nt.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = userName;
Group = userName;
LoadCredential = [ "otel-headers" ];
ExecStart = pkgs.writeShellScript "hive-otel-header" ''
set -eu
umask 077
hdr="$CREDENTIALS_DIRECTORY/otel-headers"
[ -r "$hdr" ] || exit 0
dir=${homeDir}/.claude
settings="$dir/settings.json"
mkdir -p "$dir"
base='{}'
[ -s "$settings" ] && base="$(cat "$settings")"
printf '%s' "$base" | ${pkgs.jq}/bin/jq \
--rawfile h "$hdr" \
'.env = ((.env // {}) + { OTEL_EXPORTER_OTLP_HEADERS: ($h | rtrimstr("\n")) })' \
> "$settings.tmp"
mv "$settings.tmp" "$settings"
chmod 0600 "$settings"
'';
};
};
# Seed claude's onboarding + per-project trust state once. claude only
# marks `hasCompletedOnboarding` (global) and the project trust dialog
# as accepted when run *interactively*; the harness only ever runs it
# headless (`--print`) and `claude auth login` doesn't set them either.
# So the first interactive launch (`hivectl choom`) would drop the
# operator into the onboarding/trust walkthrough despite valid OAuth
# creds. This oneshot is the single place hyperhive touches
# `~/.claude.json`: it runs before the harness (so nothing races it),
# is idempotent (skips when the flags are already set), and is
# best-effort (`before`, not a hard dep — a failed seed leaves the file
# untouched and the harness still starts). Credentials live in the
# separate `~/.claude/.credentials.json`, so this never touches secrets.
systemd.services.hive-claude-onboarding = {
description = "Seed claude onboarding + project-trust so choom skips the walkthrough";
wantedBy = [ "multi-user.target" ];
before = [ "hive-ag3nt.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = userName;
Group = userName;
ExecStart = pkgs.writeShellScript "hive-claude-onboarding" ''
set -eu
umask 077
cfg=${homeDir}/.claude.json
dir=/agents/${userName}/state
base='{}'
[ -s "$cfg" ] && base="$(cat "$cfg")"
# Idempotent: nothing to do when already onboarded + trusted.
if printf '%s' "$base" | ${pkgs.jq}/bin/jq -e \
--arg d "$dir" \
'.hasCompletedOnboarding == true and (.projects[$d].hasTrustDialogAccepted == true)' \
>/dev/null 2>&1; then
exit 0
fi
printf '%s' "$base" | ${pkgs.jq}/bin/jq \
--arg d "$dir" \
'.hasCompletedOnboarding = true
| .projects[$d].hasTrustDialogAccepted = true
| .projects[$d].hasCompletedProjectOnboarding = true' \
> "$cfg.tmp"
mv "$cfg.tmp" "$cfg"
chmod 0600 "$cfg"
'';
};
};
};
}

View file

@ -0,0 +1,73 @@
# Extra navigation links surfaced on the hive-c0re dashboard card for
# this agent: the option and the oneshot that writes them into the
# state dir where hive-c0re reads them.
{
lib,
config,
...
}:
{
options.hyperhive.dashboardLinks = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
label = lib.mkOption {
type = lib.types.str;
description = "Display label for the link.";
};
icon = lib.mkOption {
type = lib.types.str;
default = "";
description = "Optional icon emoji or short glyph.";
};
url = lib.mkOption {
type = lib.types.str;
description = "Full URL (may include a different port, e.g. http://localhost:9001/stats).";
};
};
}
);
default = [ ];
example = lib.literalExpression ''
[
{ label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; }
]
'';
description = ''
Extra navigation links surfaced on the hive-c0re dashboard card for
this agent. Declare any additional web UI pages the agent exposes
stats pages, custom UIs, etc. hive-c0re reads the JSON file this
option produces at each container-view snapshot and attaches the
links to the agent card without any code changes.
'';
};
config = {
# Write declared dashboardLinks to the state dir so hive-c0re can
# read them without accessing the container's /etc/ from the host.
# Best-effort oneshot (always exit 0):
# docs/conventions.md::Best-effort oneshot services.
systemd.services.hive-dashboard-links = lib.mkIf (config.hyperhive.dashboardLinks != [ ]) {
description = "write declarative dashboardLinks to agent state dir";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# Pin the journal identity (else it's the `script` store-path wrapper).
SyslogIdentifier = "hive-dashboard-links";
};
environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks;
script = ''
# Sub-agents have their state dir bind-mounted at /agents/<name>/state.
# Use a glob — exactly one match per container at runtime.
STATE_DIR=$(echo /agents/*/state)
if [ ! -d "$STATE_DIR" ]; then
echo "hive-dashboard-links: no state dir found at /agents/*/state; skipping"
exit 0
fi
printf '%s' "$LINKS_JSON" > "$STATE_DIR/hyperhive-dashboard-links.json"
echo "hive-dashboard-links: wrote $(printf '%s' "$LINKS_JSON" | wc -c) bytes to $STATE_DIR/hyperhive-dashboard-links.json"
'';
};
};
}

View file

@ -0,0 +1,220 @@
# Shared scaffolding for every hyperhive harness container.
# `../agent-base.nix` and `../manager.nix` both import this; all
# agents use the same service unit regardless of which entry-point
# they came from.
#
# This is the core module: container plumbing (boot/nix/nixpkgs),
# base tooling, and the cross-cutting `hyperhive.icon` option. Each
# feature lives in its own sibling module (imported below) that
# declares its own `hyperhive.*` options + config.
{
pkgs,
lib,
config,
# Flake inputs routed through _module.args by the agent flake.nix.
# Default to {} so the module evaluates cleanly even when the agent
# flake doesn't set up the routing pattern (e.g. during standalone
# nixos-rebuild without a flake wrapper).
flakeInputs ? { },
...
}:
{
imports = [
./agent-service.nix
./bash-env.nix
./claude-settings.nix
./dashboard-links.nix
./docs.nix
./forge.nix
./frontend.nix
./github.nix
./matrix.nix
./mcp.nix
./network.nix
./user.nix
./weston-vnc.nix
];
options.hyperhive.web.useUnixSocket = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Deprecated. Unix socket mode is now always enabled for all agents.
Setting this option to `true` has no effect and the option will be
removed in a future version. Safe to drop from agent configs.
'';
};
options.hyperhive.icon = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = lib.literalExpression "./icon.svg";
description = ''
Path to an SVG file used as this agent's icon shown on the
dashboard and the per-agent web UI (header + favicon). Commit
the SVG into the agent's config repo next to `agent.nix` and
reference it as a relative path (`./icon.svg`).
When null (the default) the agent falls back to the shared
hyperhive logo. The harness serves the icon (configured or
default) at `GET /icon` on the per-agent web port.
'';
};
config = {
assertions = [
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
# expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`.
# If `self` leaks into flakeInputs the agent gets a spurious attrset
# entry that can shadow real inputs and is almost certainly a bug.
# Guard with `or {}` so standalone evaluation stays clean when
# flakeInputs is absent from _module.args.
{
assertion = !(builtins.hasAttr "self" (config._module.args.flakeInputs or { }));
message = ''
hyperhive: `flakeInputs` must not contain "self".
In your agent flake.nix, use:
_module.args.flakeInputs = builtins.removeAttrs inputs [ "self" ];
'';
}
# hyperhive.icon must reference an SVG file when set.
{
assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon);
message = "hyperhive.icon must point to an .svg file";
}
];
# Operator-set per-agent icon (hyperhive.icon). When configured, the
# SVG lands at /etc/hyperhive/icon.svg; the harness serves it at
# GET /icon, falling back to the bundled hyperhive logo when absent.
# Consumed by forge-avatar-sync (./forge.nix) and the matrix avatar
# sync (./matrix.nix) too.
environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) {
source = config.hyperhive.icon;
};
boot.isNspawnContainer = true;
# Use a disk-backed /tmp instead of the default tmpfs so large scratch
# writes (nix-develop shells, cargo build dirs, multi-GB downloads) land
# on disk rather than eating container RAM. The tmpfs default mounts
# ~3.2 GB of RAM per container; disk-backed /tmp is effectively unlimited
# and cheaper for agents that do heavy build work.
#
# cleanOnBoot defaults to false in nixpkgs — set it explicitly so /tmp is
# cleared on each container start (D! tmpfiles rule), preserving the
# ephemeral-per-boot semantics agents expect from a tmpfs /tmp, just
# without the RAM cost.
boot.tmp.useTmpfs = false;
boot.tmp.cleanOnBoot = true;
# Every agent gets flakes + the modern `nix` CLI out of the box.
# Equivalent to passing `--extra-experimental-features 'nix-command
# flakes'` on every invocation. Agents shell out to `nix build` /
# `nix flake` constantly (devshells, ad-hoc evals, fetching their
# own MCP-server flakes); without this they hit the "experimental
# feature not enabled" wall on the first try.
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
# `lib.mkForce` overrides nixpkgs's normal-priority `false` so
# in-container `nix build` invocations fall back to unsandboxed
# local builds rather than failing on the missing user-namespace.
# See `docs/gotchas.md::Containerized nix-daemon needs
# sandbox-fallback = true` + `docs/security.md` for the rationale.
#
# Note: with NIX_REMOTE=daemon below this becomes a no-op for the
# common case — daemon-routed builds run on the host where sandboxing
# works. It stays as a belt-and-suspenders fallback for any context
# that bypasses the daemon (e.g. direct nix-store invocations).
nix.settings.sandbox-fallback = lib.mkForce true;
# Route ALL nix invocations in this container through the host
# nix-daemon socket, regardless of whether the caller is root or
# non-root. Without this, root contexts (PID 1, systemd services
# running as root) default to store=auto which resolves to the LOCAL
# store — bypassing the shared daemon, its remote builders, and the
# host's prebuilt derivation cache, causing spurious full rebuilds.
#
# systemd.globalEnvironment sets DefaultEnvironment in systemd.conf,
# so every unit started by PID 1 inherits NIX_REMOTE=daemon.
# Non-root nix clients already default to the daemon socket, so this
# is a no-op for them; it only matters for root services that would
# otherwise silently use the local store.
systemd.globalEnvironment.NIX_REMOTE = "daemon";
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
# evaluates its own `nixpkgs` instance, so the operator's host-level
# `nixpkgs.config.allowUnfreePredicate` does not propagate into here —
# we have to allow it inside the container's config as well.
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ];
# Core tooling every agent gets. Per-bin split packages (see
# nix/packages/default.nix) rather than the full `pkgs.hyperhive`
# bundle — that bundle also carries `hivectl` (a host-admin CLI
# that dials the *host* admin socket — useless and unreachable
# from inside a container — wrapped with `wireguard-tools` for
# `hivectl wg`). The daemon/harness/MCP bins the harness execs
# (hive-agent{,-mcp}, hive-bash-daemon, hive-matrix-daemon,
# hive-bash-mcp, hive-matrix-mcp) are wired via their own
# ExecStart/command lines in the sibling modules with the matching
# `pkgs.hive-*` package — they don't need to be on PATH too. Only
# these two are actually looked up on PATH by claude/shell code
# inside the container: `hive-agent-wake` (external wake CLI,
# docs/turn-loop/mcp.md) and `hive-metric` (agent-emitted custom
# metrics CLI, docs/observability.md).
environment.systemPackages = with pkgs; [
hive-agent-wake
hive-metric
claude-code
bashInteractive
coreutils-full
# procps for pkill — used by the web UI's /api/cancel to SIGINT the
# in-flight claude turn.
procps
# jq: JSON processing in shell — useful for parsing API responses,
# forge REST calls, sqlite output, etc.
jq
# curl: HTTP client for forge REST API and other web requests.
curl
];
# HIVE_ASSETS_DIR points at the project's static runtime assets
# (branding + claude prompts; see `nix/packages/assets.nix`). Set
# here so both the harness binary and any user-shell `cargo run`
# inside the container resolve them from the same path.
# SHELL must be set so claude's Bash tool finds a POSIX shell.
# HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the
# host-level `services.hyperhive.c0re.contextWindowTokens` option — not
# set here.
environment.variables = {
HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive";
SHELL = "${pkgs.bashInteractive}/bin/bash";
# Route interactive-shell nix invocations through the host daemon.
# Redundant with /etc/profile.d/nix-daemon.sh but ensures it's set
# regardless of which profile files are sourced.
NIX_REMOTE = "daemon";
};
# Git is needed by claude's Bash tool (for the agent <-> manager config
# request flow) and by hive-c0re's own setup_applied / setup_proposed.
# The per-agent `applied/<name>/flake.nix` overrides `user.name` and
# `user.email` with the agent's identity — values here are `mkDefault`
# so the per-agent override wins without needing `mkForce`.
programs.git = {
enable = true;
config = {
user = {
name = lib.mkDefault "hyperhive";
email = lib.mkDefault "hyperhive@local";
};
init.defaultBranch = lib.mkDefault "main";
};
};
system.stateVersion = "25.11";
};
}

View file

@ -0,0 +1,50 @@
# In-container hyperhive reference docs: the `hyperhive.docs.*`
# options and the `$HIVE_DOCS_DIR` wiring the harness reads.
{
pkgs,
lib,
config,
...
}:
{
options.hyperhive.docs.enable = lib.mkEnableOption ''
make the hyperhive reference docs (the repo `docs/` tree, shipped
read-only as the standalone `hyperhive-docs` derivation) available
in-container. When enabled the harness exposes the docs dir to claude
via `claude --add-dir`, so the markdown is readable at
`$HIVE_DOCS_DIR/`, and appends a single pointer sentence to the agent's
system prompt so it knows the docs exist (see
`hive-ag3nt::prompt::render`). Default-on for the root/manager agent
(see `../manager.nix`), off elsewhere; any agent can flip it from its
`agent.nix`.
'';
options.hyperhive.docs.source = lib.mkOption {
type = lib.types.path;
default = pkgs.hyperhive-docs;
defaultText = lib.literalMD "`pkgs.hyperhive-docs` (built from the repo `docs/` tree)";
description = ''
Store path of the reference-docs tree exposed at `$HIVE_DOCS_DIR`
when `hyperhive.docs.enable` is set. Defaults to
`pkgs.hyperhive-docs` (the `nix/packages/reference-docs.nix`
build) so a standalone container build from a full checkout
works unchanged. The generated meta flake overrides this with the
narrow `hyperhive-docs` flake input so a doc edit only
re-locks that input instead of rebuilding the container from a
re-hashed `hyperhive` source.
'';
};
config = {
environment.variables = lib.mkIf config.hyperhive.docs.enable {
# The harness reads HIVE_DOCS_DIR and passes it to claude as
# `--add-dir` so the docs are readable, and appends a single
# pointer sentence to the system prompt
# (hive-ag3nt::prompt::render) telling the agent the docs exist.
# Source is `hyperhive.docs.source` (the narrow `hyperhive-docs`
# meta-flake input, or `pkgs.hyperhive-docs` for standalone
# builds). See hive-ag3nt::turn.
HIVE_DOCS_DIR = "${config.hyperhive.docs.source}";
};
};
}

View file

@ -0,0 +1,194 @@
# In-container forge (Forgejo) integration: the `tea` CLI login
# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge
# avatar sync.
{
pkgs,
lib,
config,
...
}:
let
userName = config.hyperhive.user.name;
homeDir = "/home/${userName}";
in
{
options.hyperhive.forge.url = lib.mkOption {
type = lib.types.str;
default = "http://localhost:3000";
example = "http://forge.internal:3000";
description = ''
Base URL of the hyperhive-managed Forgejo. Used at container
boot by a oneshot systemd unit that calls
`tea login add --url <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
(= `/agents/<name>/state/forge-token`) so the agent's claude can
shell out to `tea` without an extra auth dance. No-op when the
forge-token file is missing (i.e. hive-forge isn't running on
the host).
'';
};
config = {
assertions = [
# hyperhive.forge.url must look like an HTTP URL when non-default.
{
assertion =
config.hyperhive.forge.url == ""
|| lib.hasPrefix "http://" config.hyperhive.forge.url
|| lib.hasPrefix "https://" config.hyperhive.forge.url;
message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")";
}
];
environment.systemPackages = [
# tea: gitea/forgejo CLI client. Configured at boot by the
# tea-login oneshot below if /state/forge-token is present, so
# claude can `tea repos create`, `tea pulls create`, etc.
pkgs.tea
# hive-forge <verb>: CLI wrapping common Forgejo REST API operations
# (view, pr, issue, comment, assign, close, labels, branches, etc.)
(pkgs.callPackage ../../packages/hive-forge-tools.nix { })
];
# One-shot: tea config.yml from the seeded forge token. Shape
# contract (always exit 0, no set -e, skip-silently, re-runnable):
# docs/conventions.md::Best-effort oneshot services.
systemd.services.tea-login = {
description = "configure tea CLI from hive-forge token (best-effort)";
wantedBy = [ "multi-user.target" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# Pin the journal identity (else it's the `script` store-path wrapper).
SyslogIdentifier = "tea-login";
};
path = [
pkgs.curl
pkgs.python3
pkgs.coreutils
];
environment.HOME_DIR = homeDir;
environment.AGENT_USER = userName;
script = ''
# No `set -e`: best-effort posture (see docs pointer above).
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
# $HYPERHIVE_STATE_DIR is system-wide via the meta flake.
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
if [ ! -f "$TOKEN_FILE" ]; then
echo "tea-login: no forge-token at $TOKEN_FILE; skipping"
exit 0
fi
TOKEN=$(cat "$TOKEN_FILE")
# Resolve the agent username from the forge API.
USER=$(curl -sf --max-time 5 \
-H "Authorization: token $TOKEN" \
"$FORGE_URL/api/v1/user" \
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \
2>/dev/null || true)
if [ -z "$USER" ]; then
echo "tea-login: could not resolve username from forge API; skipping"
exit 0
fi
# Config under the agent user's home, chown'd to them;
# service stays root-owned (see docs pointer above).
CONFIG="$HOME_DIR/.config/tea/config.yml"
mkdir -p "$(dirname "$CONFIG")" || true
cat > "$CONFIG" << EOF
logins:
- name: forge
url: $FORGE_URL
token: $TOKEN
default: true
ssh_host: ""
ssh_key: ""
insecure: false
ssh_agent: false
user: $USER
preferences:
editor: false
flag_defaults:
remote: ""
EOF
chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true
echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)"
'';
};
# Path-trigger sibling: re-fires forge-avatar-sync the moment
# `<state>/forge-token` appears. Mirrors the hive-matrix-daemon
# token-watcher pattern — on first agent deployment the container
# boots before hive-c0re has provisioned the forge-token, so the
# service fires too early and exits with "no forge-token found".
# Without this path unit, RemainAfterExit=true would prevent systemd
# from ever re-running the service. See
# docs/persistence.md::forge-avatar-sync.
systemd.paths.forge-avatar-sync = {
description = "trigger forge-avatar-sync when forge-token appears";
wantedBy = [ "multi-user.target" ];
pathConfig.PathExistsGlob = "/agents/*/state/forge-token";
};
# One-shot: hyperhive.icon → Forgejo profile avatar. Shape contract:
# docs/conventions.md::Best-effort oneshot services.
# RemainAfterExit = false so the .path trigger above can re-fire
# this unit when the forge-token arrives after boot.
systemd.services.forge-avatar-sync = {
description = "sync agent icon to Forgejo user avatar (best-effort)";
wantedBy = [ "multi-user.target" ];
after = [ "tea-login.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = false;
# Pin the journal identity (else it's the `script` store-path wrapper).
SyslogIdentifier = "forge-avatar-sync";
};
path = [
pkgs.curl
pkgs.coreutils
pkgs.jq
pkgs.librsvg
];
script = ''
ICON=/etc/hyperhive/icon.svg
if [ ! -f "$ICON" ]; then
echo "forge-avatar-sync: no icon configured; skipping"
exit 0
fi
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
# $HYPERHIVE_STATE_DIR is set system-wide by the meta flake
# (systemd.globalEnvironment) to `/agents/<name>/state`.
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
if [ ! -f "$TOKEN_FILE" ]; then
echo "forge-avatar-sync: no forge-token found; skipping"
exit 0
fi
TOKEN=$(cat "$TOKEN_FILE")
# Rasterize SVG → PNG (Forgejo's Go image library can't decode SVG).
PNG=$(mktemp --suffix=.png)
if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then
echo "forge-avatar-sync: rsvg-convert failed; skipping"
rm -f "$PNG"
exit 0
fi
IMAGE=$(base64 -w 0 < "$PNG")
rm -f "$PNG"
# Forgejo POST /user/avatar expects {"image":"<base64>"} — just the
# raw base64 string, NOT a data URI (data:image/png;base64,...).
# Use jq to build the payload so the large base64 value is safely quoted.
PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}')
RESP=$(curl -sf --max-time 10 \
-X POST "$FORGE_URL/api/v1/user/avatar" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
-w "\n%{http_code}" 2>/dev/null || true)
CODE=$(printf '%s' "$RESP" | tail -1)
if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then
echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)"
else
echo "forge-avatar-sync: upload returned HTTP $CODE skipping (non-fatal)"
fi
'';
};
};
}

View file

@ -0,0 +1,156 @@
# Per-agent web UI static tree: the shipped frontend dist, the
# operator-extendable `extraFiles` overlay, and the merged tree the
# harness serves via `HIVE_STATIC_DIR`.
{
pkgs,
lib,
config,
...
}:
{
options.hyperhive.frontend.dist = lib.mkOption {
type = lib.types.package;
default = pkgs.hyperhive-frontend;
defaultText = lib.literalExpression "pkgs.hyperhive-frontend";
description = ''
The shipped frontend dist (built by `nix/packages/frontend.nix`).
Output layout: `dashboard/` (used by hive-c0re on the host) and
`agent/` (used here, layered with `extraFiles` below at
activation time). Override to ship a fully custom per-agent SPA;
the JSON contract (`/api/state`, `/events/stream`, the action
endpoints) is the source of truth for any replacement.
'';
};
options.hyperhive.frontend.mergedDist = lib.mkOption {
type = lib.types.package;
readOnly = true;
description = ''
Computed: the merged static tree consumed by the harness via
`HIVE_STATIC_DIR`. Composed at evaluation time by copying
`hyperhive.frontend.dist`'s `agent/` subdir as the base, then
layering each `extraFiles` entry on top. Read-only do not set directly.
'';
};
options.hyperhive.frontend.extraFiles = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
source = lib.mkOption {
type = lib.types.path;
description = ''
Source file or directory to layer over the default
agent dist. A path (relative to `agent.nix` or
absolute) nix copies its contents into the merged
static tree.
'';
};
target = lib.mkOption {
# First char must be alphanumeric/underscore (rules out
# leading `/`, leading `.`, leading `-`); inner chars
# include `.` and `/` so nested layouts like
# `"games/bitburner"` work. This is the shape check —
# the `..`-segment traversal check is the assertion in
# `config.assertions` below (regex alone can't reject
# mid-path `..` segments without lookahead, which nix
# POSIX regex doesn't support).
type = lib.types.strMatching "^[A-Za-z0-9_][A-Za-z0-9_./-]*$";
default = name;
defaultText = lib.literalMD "the attribute name";
description = ''
Destination path within the merged static tree, used
as both the served URL prefix (`/<target>/...`) and
the on-disk layout in the merged derivation. Defaults
to the attribute name. Use forward slashes for
nested layouts (e.g. `"games/bitburner"`).
Constrained shape: must start with an alphanumeric or
`_`, and only contain alphanumerics, `_`, `.`, `/`,
`-`. `..` segments are separately rejected at config
eval time.
'';
};
};
}
)
);
default = { };
example = lib.literalExpression ''
{
bitburner = {
source = ./bitburner-dist;
# served at GET /bitburner/...
};
}
'';
description = ''
Per-agent additions layered on top of the default frontend
dist. Each entry copies its `source` into the served static
tree under `target`. Useful for shipping a self-contained
agent-specific surface alongside the standard agent UI (e.g.
the bitburner agent's game page at `/bitburner/`).
The default agent UI remains served at `/`; entries here only
add new routes and never replace the default. Overwrite
semantics are **hard-fail**: if `target` collides with an
existing file or directory in the default dist (or with a
prior entry's target), the `mergedDist` build aborts with
`refusing to overwrite existing path '<target>' in the
default dist`. To override a default file, fork the dist via
`hyperhive.frontend.dist` instead `extraFiles` is for
pure additions.
`target` must be a relative path inside the static dir. An
assertion rejects leading `/` and `..` segments at config
eval time (string-concat-into-paths safety, even though
agent.nix goes through operator review before deploy).
'';
};
config = {
assertions = [
# hyperhive.frontend.extraFiles[*].target is concatenated into
# $out during the mergedDist build. The option's strMatching
# type already rejects leading `/`, leading `.`, and the
# weirder characters; this assertion catches mid-path `..`
# segments (e.g. `foo/../etc/passwd`) that the type's regex
# can't easily express without lookahead. agent.nix is
# operator-reviewed, so this is belt-and-braces — but it's the
# kind of mistake that's easy to make and hard to spot.
{
assertion = lib.all (entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))) (
lib.attrValues config.hyperhive.frontend.extraFiles
);
message = ''
hyperhive.frontend.extraFiles: `target` must not contain
`..` path segments.
'';
}
];
# Merged frontend static tree. Base = `${frontend.dist}/agent/`,
# then each `extraFiles` entry is laid on top at its `target`
# path. The runCommand derivation aborts on overwrite so a
# filename collision with the default dist surfaces as a build
# failure rather than a silent override (operator gets a clear
# nix error rather than a confusing 404 / silent dist swap).
hyperhive.frontend.mergedDist = pkgs.runCommand "hyperhive-agent-frontend-merged" { } (
''
mkdir -p $out
cp -r ${config.hyperhive.frontend.dist}/agent/. $out/
chmod -R u+w $out
''
+ lib.concatMapStrings (entry: ''
mkdir -p $(dirname $out/${entry.target})
if [ -e $out/${entry.target} ]; then
echo "hyperhive.frontend.extraFiles: refusing to overwrite existing path '${entry.target}' in the default dist" >&2
exit 1
fi
cp -r ${entry.source} $out/${entry.target}
'') (lib.attrValues config.hyperhive.frontend.extraFiles)
);
};
}

View file

@ -0,0 +1,89 @@
# GitHub integration (hyperhive.github.enable): a `gh` wrapper + a git
# credential helper, both reading the PAT from the agent's
# `github-token` state file at invocation, so a dashboard-pasted token
# takes effect with no rebuild. The token PATH is baked in at build
# time (nix knows `userName`) — NOT read from `$HIVE_GITHUB_TOKEN_FILE`,
# because claude's Bash tool runs `bash -c` in a minimal env that
# doesn't source `/etc/set-environment`, so the env var isn't present
# where `gh`/`git` actually run. The token value never enters the nix
# store (only its path). github.com only; git auths as `x-access-token`
# + PAT.
{
pkgs,
lib,
config,
...
}:
let
userName = config.hyperhive.user.name;
ghWrapper = pkgs.writeShellScriptBin "gh" ''
if [ -r "/agents/${userName}/state/github-token" ]; then
GH_TOKEN="$(cat "/agents/${userName}/state/github-token")"
export GH_TOKEN
fi
exec ${pkgs.gh}/bin/gh "$@"
'';
gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-github" ''
# git credential-helper protocol: only the `get` action needs an answer.
[ "''${1:-}" = "get" ] || exit 0
if [ -r "/agents/${userName}/state/github-token" ]; then
# GitHub ignores the username for PAT auth — `x-access-token` is the
# conventional placeholder; the PAT is the password.
printf 'username=x-access-token\n'
printf 'password=%s\n' "$(cat "/agents/${userName}/state/github-token")"
fi
'';
in
{
options.hyperhive.github.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Install the GitHub integration in this agent: a `gh` CLI wrapper and a
git credential helper for `https://github.com`, both authenticated from
an operator-supplied personal access token (PAT). The PAT is written to
`<state>/github-token` out of band --- the dashboard credentials tab or
`hivectl github set-token` --- so giving an agent GitHub is a runtime
paste, no per-agent config or rebuild. The wrappers read the token file
at invocation, so a freshly-pasted PAT takes effect immediately; until
one exists, `gh` / `git push` just fail unauthenticated.
github.com only. git authenticates as `x-access-token` + the PAT (GitHub
ignores the username for PAT auth); `gh` derives its identity from the
token. Keep the PAT minimally scoped: the agent has passwordless sudo, so
a compromised agent can act within the token's scopes --- scope is the
real blast-radius limiter.
On by default. Host-driven: set `services.hyperhive.github.enable = false`
to turn the integration off hive-wide (meta.rs propagates the override
into every agent).
'';
};
config = {
# No bare pkgs.gh here — the wrapper *is* `gh` and hardcodes the
# real binary path, so it can't be shadowed.
environment.systemPackages = lib.optionals config.hyperhive.github.enable [
ghWrapper
gitCredHelper
];
# Wire the GitHub credential helper for `git push` over HTTPS. Host-scoped
# to `https://github.com`, so it never touches the forge (localhost:3000)
# or any other remote. The helper reads the PAT from the agent's
# `github-token` state file at invocation and auths as `x-access-token` +
# the PAT. System /etc/gitconfig merges under the agent's ~/.gitconfig
# (safe.directory), so this is additive.
# Nested-path binding + mkIf (matching the other `environment.etc."…"`
# entries in the harness modules) — a whole-set `environment.etc = {…}`
# here would collide with them at the nix level ("attribute already
# defined").
environment.etc."gitconfig" = lib.mkIf config.hyperhive.github.enable {
text = ''
[credential "https://github.com"]
helper = hive-github
username = x-access-token
'';
};
};
}

View file

@ -0,0 +1,298 @@
# Per-agent matrix integration: the `hyperhive.matrix.*` +
# `hyperhive.matrixAccounts` options, the long-running
# hive-matrix-daemon, its token-arrival path trigger, and the
# auto-injected stdio MCP bridge entry.
{
pkgs,
lib,
config,
...
}:
let
userName = config.hyperhive.user.name;
# Single source of truth for the default matrix homeserver URL, shared
# by the `hyperhive.matrix.url` option default and the daemon-unit guard
# that decides whether to set a unit-level HIVE_MATRIX_URL (so the two
# cannot drift). Matches the daemon's own built-in default
# (`paths::DEFAULT_HOMESERVER`).
matrixUrlDefault = "http://localhost:8008";
# Rasterize the operator-set agent icon (`hyperhive.icon`, an SVG) to a
# 512x512 PNG so the matrix daemon can upload it as each account's avatar
# over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar).
# Only forced when an icon is configured — the `HIVE_ICON_PNG` daemon-env
# entry is gated on `hyperhive.icon != null`, so this binding stays lazy
# when no icon is set.
iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } ''
rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out
'';
in
{
options.hyperhive.matrix.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable per-agent matrix integration via `hive-matrix-mcp`.
When true (the default), the harness:
- runs `hive-matrix-daemon` as a systemd unit that holds a
matrix-sdk Client + sync against the homeserver at
`HIVE_MATRIX_URL` (default `http://localhost:8008` the
in-host tuwunel from `nix/modules/hive-matrix.nix`). The
daemon auto-skips when `<state>/matrix-token` is missing,
and a `systemd.paths` watcher restarts it the moment
hive-c0re provisions the token (same path-trigger shape
as `forge-avatar-sync`).
- exposes the matrix tool surface (send_message, send_dm,
send_reaction, send_reply, mark_read, list_rooms,
list_room_members, read_room) to claude via an auto-injected
`extraMcpServers.matrix` entry. Claude spawns the stdio
`hive-matrix-mcp` bridge per turn, which forwards each tool
call to the daemon over `/run/hive-matrix/socket`.
- wakes the agent on incoming room events via a short teaser
Wake signal (`[matrix] <sender> in <room>: <first 100c>`)
to the hyperhive control socket; the full event stays
unread server-side until `read_room` consumes it.
Set to `false` for agents that should NOT have matrix tools at
all (e.g. agents on a host without `hyperhive.matrix.enable` on
the meta side). When token file is absent the daemon and MCP
both no-op cleanly anyway, so `false` is rarely necessary.
'';
};
options.hyperhive.matrix.url = lib.mkOption {
type = lib.types.str;
default = matrixUrlDefault;
example = "https://matrix.darkest.space";
description = ''
Matrix homeserver URL the agent's `hive-matrix-daemon` connects
to. At runtime hive-c0re forwards the isolation-aware URL
(`matrix.<domain>` via the gateway) so isolated agents reach
the homeserver without crossing host loopback. Override
per-agent when an agent should talk to an external homeserver
instead (e.g. a federation-only setup or a remote hive's
tuwunel reached via a vpn).
'';
};
options.hyperhive.matrixAccounts = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
tokenFile = lib.mkOption {
type = lib.types.str;
example = "/agents/dmatrix/state/matrix-token-ccc";
description = ''
Path to this account's bearer-token file. The daemon reads
the token from here to restore the matrix session; how the
file gets populated is the provisioner's concern (an
operator-supplied secret for an external account). The
daemon skips an extra account whose token file is absent.
'';
};
sessionDir = lib.mkOption {
type = lib.types.str;
example = "/agents/dmatrix/state/matrix-sdk-state-ccc";
description = ''
Per-account matrix-sdk sqlite store directory (crypto keys
+ event cache). Must differ between accounts so their
sessions do not collide.
'';
};
homeserver = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "https://matrix.example.org";
description = ''
Homeserver URL for this account. When null (the default),
the account falls back to `hyperhive.matrix.url`. Set it for
an account on a different homeserver than the agent's
default (e.g. an external public-matrix account).
'';
};
};
}
);
default = { };
example = lib.literalExpression ''
{
ccc = {
tokenFile = "/agents/dmatrix/state/matrix-token-ccc";
sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc";
homeserver = "https://matrix.example.org";
};
}
'';
description = ''
Declare *additional* matrix accounts served by the single
`hive-matrix-daemon` (one matrix-sdk Client + sync loop each),
beyond the agent's built-in hive-internal account. The
attribute name keys each account (unique by construction) and is
the handle the matrix MCP tools target via their `account`
argument.
The **hive-internal account is always present and is the primary**:
it is named `main`, synthesized by the daemon from
`hyperhive.matrix.url` + `<state>/matrix-token` +
`<state>/matrix-sdk-state`, and is the account a tool call acts as
when it omits `account`. You never declare it here --- this option
is only for the extras (e.g. an external public-matrix account).
Leave empty (the default) for the common single-account case: the
agent then has only `main`. When non-empty, the extras are
serialized to the daemon's `HIVE_MATRIX_ACCOUNTS` environment
variable and the daemon appends them after `main`. Requires
`hyperhive.matrix.enable` (there is no `main` to extend otherwise).
'';
};
config = {
assertions = [
# Extra matrix accounts only make sense alongside the hive-internal
# `main` account they extend, which exists only when matrix is
# enabled.
{
assertion = config.hyperhive.matrixAccounts == { } || config.hyperhive.matrix.enable;
message =
"hyperhive.matrixAccounts requires hyperhive.matrix.enable = true "
+ "(the extras extend the hive-internal `main` account, which only "
+ "exists when matrix is enabled).";
}
# `main` is reserved for the synthesized hive-internal account; a
# declared extra by that name would silently collide with it.
{
assertion = !builtins.hasAttr "main" config.hyperhive.matrixAccounts;
message =
"hyperhive.matrixAccounts cannot contain a key named \"main\" "
+ "--- that name is reserved for the hive-internal account.";
}
# Token files must land at the `matrix-token*` name the daemon
# path-watcher globs (`/agents/*/state/matrix-token*`), or the account
# never gets picked up live (it loads only on a full daemon restart).
# Enforce the basename prefix so a deviating name (e.g. the historical
# `matrix-catgirl-token`) is caught at build time, not silently.
{
assertion = lib.all (a: lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) (
lib.attrValues config.hyperhive.matrixAccounts
);
message =
"every hyperhive.matrixAccounts.<name>.tokenFile basename must start with "
+ "\"matrix-token\" so the daemon path-watcher glob "
+ "(/agents/*/state/matrix-token*) picks it up live. Offending: "
+ lib.concatStringsSep ", " (
lib.mapAttrsToList (n: a: "${n}=${baseNameOf a.tokenFile}") (
lib.filterAttrs (
_n: a: !lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)
) config.hyperhive.matrixAccounts
)
)
+ ".";
}
];
# Auto-inject the matrix stdio MCP bridge alongside the bash entry
# from ./mcp.nix. `lib.mkDefault` so the operator's own agent.nix
# can override the entry.
hyperhive.extraMcpServers = lib.mkIf config.hyperhive.matrix.enable {
matrix = lib.mkDefault {
command = "${pkgs.hive-matrix-mcp}/bin/hive-matrix-mcp";
args = [ ];
# Same socket path the hive-matrix-daemon service binds
# via its `RuntimeDirectory = "hive-matrix"`. Keeps the
# bridge + daemon in sync without baking the path into
# the Rust default — the env override wins for both.
env.HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
allowedTools = [ "*" ];
};
};
# Long-running matrix-sdk client + sync per agent. Holds the unix
# socket the stdio `hive-matrix-mcp` bridge connects to + emits
# hyperhive wake signals on incoming room events via
# `/run/hive/mcp.sock`. See
# `docs/persistence.md::Matrix per-agent daemon + token-arrival
# trigger` for the socket-path / first-boot-ordering rationale.
systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
description = "long-running matrix-sdk Client + MCP daemon socket";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
RUST_LOG = "info";
}
# Homeserver URL: by default the daemon inherits the host-forwarded
# HIVE_MATRIX_URL (set by hive-c0re to `matrix.<domain>` via the
# gateway, since agents run in private netns and can't reach host
# loopback directly), falling back to the daemon's built-in
# localhost default if the forward is absent. A per-agent
# `hyperhive.matrix.url` override (non-default) is set unit-level
# so it wins over the forwarded value; at the default we
# deliberately DON'T set it so the forwarded value isn't shadowed.
// lib.optionalAttrs (config.hyperhive.matrix.url != matrixUrlDefault) {
HIVE_MATRIX_URL = config.hyperhive.matrix.url;
}
# Multi-account: serialize the *extra* accounts to the JSON the
# daemon parses (`accounts::configured`). Only set when extras are
# declared; the daemon always synthesizes the primary `main`
# (hive-internal) account itself from the per-agent paths and
# prepends it, so we emit extras only. Each entry is in the
# daemon's `AccountCfg` serde shape: name (the attr key) /
# token_file / state_dir / optional homeserver.
// lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) {
HIVE_MATRIX_ACCOUNTS = builtins.toJSON (
lib.mapAttrsToList (
name: a:
{
inherit name;
token_file = a.tokenFile;
state_dir = a.sessionDir;
}
// lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; }
) config.hyperhive.matrixAccounts
);
}
# Rasterized agent icon path for the daemon's avatar sync. Only set
# when an icon is configured; absent → the daemon skips avatar setting
# (hive-matrix-mcp::client::sync_avatar returns early on unset env).
// lib.optionalAttrs (config.hyperhive.icon != null) {
HIVE_ICON_PNG = "${iconPng}";
};
serviceConfig = {
ExecStart = "${pkgs.hive-matrix-daemon}/bin/hive-matrix-daemon";
SyslogIdentifier = "hive-matrix-daemon";
Restart = "on-failure";
RestartSec = 5;
User = userName;
Group = userName;
RuntimeDirectory = "hive-matrix";
# Keep /run/hive-matrix across restarts. With the default
# `RuntimeDirectoryPreserve=no`, a `switch-to-configuration`
# restart races the outgoing instance's stop-time cleanup
# (which deletes the dir) against the incoming instance's
# start (which creates it + binds the socket inside it). The
# cleanup can win and delete the dir out from under the fresh
# daemon, which then fails to mkdir under root-owned /run and
# exits — looping on Restart=on-failure until the next boot.
# `yes` stops systemd removing it on stop; it still creates it
# on first start, and it lives on tmpfs so it's gone at
# container reboot regardless. See hive-bash-daemon (./mcp.nix).
RuntimeDirectoryPreserve = "yes";
};
};
# Re-fire the daemon when the matrix token appears (hive-c0re
# provisions it after agent containers come up). Without this
# the daemon would exit 0 silently on first boot and the MCP
# would have no backend until next restart. See
# `docs/persistence.md` (same section as above).
systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
description = "trigger hive-matrix-daemon when a matrix token appears";
wantedBy = [ "multi-user.target" ];
# `matrix-token*` (not just `matrix-token`) so a secondary
# multi-account token (e.g. `matrix-token-ccc`) landing also
# re-fires the daemon to pick up the freshly-provisioned account.
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*";
};
};
}

View file

@ -0,0 +1,257 @@
# The MCP tool surface: the built-in hyperhive server (persistent
# streamable-http daemon), the bash-task backend daemon + its
# auto-injected stdio bridge, the `extraMcpServers` option they hang
# off, and the send-recipient allowlist.
{
pkgs,
lib,
config,
...
}:
let
userName = config.hyperhive.user.name;
in
{
options.hyperhive.allowedBashPatterns = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Deprecated - has no effect. The built-in Bash tool is fully
disabled regardless of this list; agents use mcp__bash__run
instead. Remove this option from your agent.nix.
'';
visible = false;
};
options.hyperhive.allowedRecipients = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"alice"
"manager"
];
description = ''
Names this agent is allowed to `send` to via
`mcp__hyperhive__send`. Empty list (the default) means
unrestricted the agent can message any peer, the
operator, or the manager. Non-empty list constrains the
surface: only the listed names + the manager (always
allowed) get through; anything else returns an error
string to claude without touching the broker. The
operator (`operator`) needs to be in the list if the
agent should be able to surface output on the
dashboard.
Useful for sandboxing untrusted sub-agents set
`[ "manager" ]` to scope them to manager-only chatter.
The manager itself is always exempt; this option only
affects sub-agent `send`.
'';
};
options.hyperhive.extraMcpServers = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
command = lib.mkOption {
type = lib.types.str;
description = "Absolute path to the MCP server binary. Use `\${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`.";
};
args = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Args passed to the MCP server binary.";
};
env = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = "Environment variables for the MCP server child process.";
};
allowedTools = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "*" ];
example = [
"send_message"
"join_room"
];
description = ''
Tool names this MCP server is auto-approved to call via
`--allowedTools`. Single entry `"*"` (the default) means
"every tool from this server" convenient but trusting.
Tighten to a specific list when you only want a subset.
Names are bare (e.g. `send_message`); the harness prepends
`mcp__<server-key>__` at build time.
'';
};
};
}
);
default = { };
example = lib.literalExpression ''
{
matrix = {
command = "/run/current-system/sw/bin/mcp-matrix";
args = [ "--config" "/state/matrix.toml" ];
env.MATRIX_HOMESERVER = "https://matrix.example.org";
allowedTools = [ "send_message" "join_room" ];
};
}
'';
description = ''
Extra MCP servers claude sees alongside the hyperhive tool surface.
Keys are the server names (claude addresses tools as
`mcp__<key>__<tool>`). Rendered to `/etc/hyperhive/extra-mcp.json`
at activation time; the harness reads that file at boot and merges
it into `--mcp-config` + `--allowedTools`. Take effect on the
agent's next harness restart (no operator approval needed beyond
whatever brought the new agent.nix into deployed/*).
'';
};
options.hyperhive.mcp.httpPort = lib.mkOption {
type = lib.types.port;
default = 8790;
example = 8791;
description = ''
Loopback port the built-in hyperhive MCP surface is served on. HTTP
is the *sole* transport for the built-in surface: a
long-lived `hive-mcp-http` systemd unit runs
`hive-agent-mcp --http 127.0.0.1:<port>` and `render_claude_config`
points claude at the stable `http://127.0.0.1:<port>/mcp` URL. That
URL survives the per-turn claude re-spawn (and a host-side hive-c0re
restart each tool call dials the control socket fresh), so there
is no per-turn MCP re-registration race (a resumed stdio child could
emit its first tool call before that turn's async
`initialize`/`tools-list` completed, stranding the agent with `No
such tool` the http endpoint eliminates that). Extra MCP servers
(matrix/bash) stay stdio bridges regardless.
Bound loopback-only; the rmcp streamable-http transport's default
`allowed_hosts` (`localhost` / `127.0.0.1` / `::1`) rejects Host
headers from anywhere else, so no auth token is required for a
container-local endpoint.
Failure-mode note: with no stdio fallback, if `hive-mcp-http` is
down claude hits a dead URL until the unit restarts (guarded by
`Restart=always`, `RestartSec=3`). Intended shape: no per-turn race
while up, a bounded self-healing gap while restarting.
Safe as a single fixed default across all agents: each container
runs in its own private network namespace (isolation is always-on
see docs/network.md), so `127.0.0.1:<port>` is per-container-private
and cannot collide across agents. Override only if a container-local
service already occupies this port.
Must match `mcp_config::DEFAULT_MCP_HTTP_PORT` (the harness always
exports `HYPERHIVE_MCP_HTTP_PORT`, so the const is only a fallback).
'';
};
config = {
warnings = lib.optional (config.hyperhive.allowedBashPatterns != [ ]) ''
hyperhive.allowedBashPatterns is deprecated and has no effect.
The built-in Bash tool is fully disabled; agents use mcp__bash__run instead.
Remove allowedBashPatterns from your agent.nix.
'';
# Auto-inject the built-in bash MCP server — always present, every
# agent needs bash tools. `lib.mkDefault` so the operator's own
# agent.nix can override the entry. (The matrix sibling lives in
# ./matrix.nix, gated on hyperhive.matrix.enable.)
hyperhive.extraMcpServers.bash = lib.mkDefault {
command = "${pkgs.hive-bash-mcp}/bin/hive-bash-mcp";
args = [ ];
env.HIVE_BASH_SOCKET = "/run/hive-bash/socket";
allowedTools = [ "*" ];
};
environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers;
environment.etc."hyperhive/send-allow.json".text =
builtins.toJSON config.hyperhive.allowedRecipients;
# Bash task runner daemon — long-running process that owns subprocess
# monitoring + completion wake signals. Always enabled (every agent
# needs bash tools). The stdio MCP bridge `hive-bash-mcp` connects
# to this daemon's socket per turn.
# Socket dir: /run/hive-bash/ — RuntimeDirectory keeps it on tmpfs.
systemd.services.hive-bash-daemon = {
description = "bash task runner daemon for hive-bash-mcp";
wantedBy = [ "multi-user.target" ];
# The daemon runs every bash task via `Command::new("bash")` and the
# commands themselves (hive-forge, git, jq, …) resolve from PATH.
# A standalone daemon has no inherited agent PATH, so without this
# `bash` itself isn't found (spawn fails with ENOENT, the task is
# marked done in 0s with no output / no .out/.err). Mirror the
# harness unit's PATH: NixOS appends `/bin` to each entry →
# /run/wrappers/bin (setuid sudo) + /run/current-system/sw/bin
# (bash, coreutils, hive-forge, …).
path = [
"/run/wrappers"
"/run/current-system/sw"
];
environment = {
HIVE_BASH_SOCKET = "/run/hive-bash/socket";
HIVE_CONTROL_SOCKET = "/run/hive/mcp.sock";
RUST_LOG = "info";
# HYPERHIVE_HARNESS_DIR and HYPERHIVE_STATE_DIR are already
# injected via systemd.globalEnvironment by the meta flake
# (set to /agents/<name>/harness and /agents/<name>/state
# respectively). The daemon uses these to derive its task +
# loose-ends dir paths; without them it falls back to deriving
# harness/ as a sibling of state/, which produces the same
# value but is less robust if the two vars ever diverge.
};
serviceConfig = {
ExecStart = "${pkgs.hive-bash-daemon}/bin/hive-bash-daemon";
SyslogIdentifier = "hive-bash-daemon";
Restart = "on-failure";
RestartSec = 3;
User = userName;
Group = userName;
RuntimeDirectory = "hive-bash";
# Keep /run/hive-bash across restarts. With the default
# `RuntimeDirectoryPreserve=no`, a post-rebuild restart races
# stop-time dir cleanup against the fresh daemon's socket-dir
# creation; the daemon loses, fails `mkdir /run/hive-bash`
# (Permission denied, non-root in /run), and loops on
# Restart=on-failure until the next container boot — i.e. the
# bash daemon "doesn't come up post-rebuild". Same shape as
# hive-matrix-daemon (./matrix.nix).
RuntimeDirectoryPreserve = "yes";
};
};
# Persistent streamable-http MCP daemon for the built-in hyperhive
# surface — the *sole* transport for that surface; always
# wired. Long-lived so claude reconnects to the stable URL each turn
# instead of respawning + re-registering a stdio subprocess (the
# per-turn MCP registration race). It dials the control socket
# (`/run/hive/mcp.sock`, the harness binaries' default) fresh on every
# tool call, so a host-side hive-c0re restart is transparent.
# `before = hive-ag3nt` so the URL is already listening by the time
# the harness renders the first turn's config; the harness/claude also
# reconnect on their own, so ordering is a latency nicety not a hard
# correctness dep.
systemd.services.hive-mcp-http = {
description = "persistent streamable-http MCP daemon for the hyperhive surface";
wantedBy = [ "multi-user.target" ];
before = [ "hive-ag3nt.service" ];
environment.RUST_LOG = "info";
serviceConfig = {
ExecStart = "${pkgs.hive-agent-mcp}/bin/hive-agent-mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}";
SyslogIdentifier = "hive-mcp-http";
# `always` (not `on-failure`): this endpoint is load-bearing — the
# sole hyperhive-MCP transport, so a down window is total
# hyperhive-MCP loss with no stdio fallback and no per-turn
# self-heal (the URL just stays dead). `always` also covers any
# unforeseen clean-return path and restarts after a stray SIGTERM
# stops it out from under the harness.
Restart = "always";
RestartSec = 3;
User = userName;
Group = userName;
};
};
};
}

View file

@ -0,0 +1,82 @@
# In-container network plumbing: DHCP on the bridge veth, resolvconf
# taken out of the loop, and the oneshot that points resolv.conf at
# the hive bridge resolver.
{
pkgs,
lib,
...
}:
{
# Take resolvconf + dhcpcd out of the /etc/resolv.conf loop so the
# bridge resolver the oneshot below writes actually sticks. At their
# NixOS defaults, resolvconf regenerates resolv.conf from host-tracking
# *after* the oneshot has pointed it at the bridge (dhcpcd re-triggers
# that when the veth comes up under isolation) — silently clobbering the
# bridge nameserver back to the host resolver, which isn't authoritative
# for the hive's own zones, so `forge.<domain>` stops resolving. We
# disable resolvconf and tell dhcpcd not to touch resolv.conf (without
# disabling dhcpcd itself, so the veth still gets its address); then
# the hyperhive-isolated-dns oneshot owns resolv.conf. (Same "take
# resolvconf out of the loop" approach the matrix container uses.)
# All agent containers receive their bridge IP via DHCP from the hive
# dnsmasq pool (see nix/modules/hive-gateway.nix). useDHCP runs dhcpcd
# on every interface (just eth0 in practice — the nspawn bridge veth).
config = {
networking.useDHCP = true;
networking.resolvconf.enable = false;
networking.dhcpcd.extraConfig = "nohook resolv.conf";
# Point resolv.conf at the hive bridge resolver when the container is
# network-isolated. nixos-container copies the *host's* /etc/resolv.conf
# into the container at every start — but the host resolver (e.g.
# 127.0.0.53) is unreachable from a private netns and isn't
# authoritative for the hive's own zones (forge.<domain> etc.). The
# bridge dnsmasq (gateway IP) is. hive-priv drops the marker
# `/etc/hyperhive-bridge-dns` (containing the gateway IP) since
# isolation is always on; the oneshot reads it and rewrites
# resolv.conf on every boot. Ordered before the first DNS consumer
# (tea-login) and the network targets so name resolution works for
# the very first turn.
systemd.services.hyperhive-isolated-dns = {
description = "point resolv.conf at the hive bridge resolver (isolated containers)";
wantedBy = [ "multi-user.target" ];
after = [ "local-fs.target" ];
# Ordered before every network consumer that does DNS on first
# boot. `hive-ag3nt` (the harness) is the load-bearing one: its
# first-turn api.anthropic.com lookup must not race the resolv.conf
# rewrite (it only declares `after network.target`, so without this
# edge the harness can start before we've fixed resolv.conf and the
# first turn errors — self-heals next turn, but better not to flap).
# `hive-matrix-daemon` likewise syncs over the network; the `before`
# is a harmless no-op when matrix is disabled (the unit is absent).
before = [
"network-online.target"
"tea-login.service"
"hive-ag3nt.service"
"hive-matrix-daemon.service"
];
unitConfig.ConditionPathExists = "/etc/hyperhive-bridge-dns";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# Pin the journal identity; without it systemd derives it from the
# generated `script` store-path wrapper (an opaque `<hash>-…-start`).
SyslogIdentifier = "hyperhive-isolated-dns";
};
path = [ pkgs.coreutils ];
script = ''
set -eu
gw=$(tr -d '[:space:]' < /etc/hyperhive-bridge-dns)
if [ -z "$gw" ]; then
echo "hyperhive-isolated-dns: empty marker; leaving resolv.conf as-is"
exit 0
fi
# resolv.conf is a regular file copied from the host by
# nixos-container; replace it (rm first in case it's a symlink).
rm -f /etc/resolv.conf
printf 'nameserver %s\n' "$gw" > /etc/resolv.conf
echo "hyperhive-isolated-dns: resolv.conf -> nameserver $gw"
'';
};
};
}

View file

@ -0,0 +1,215 @@
# Per-agent unix user: the `hyperhive.user.*` options, the user/group
# declarations, passwordless sudo, and the first-boot migration that
# chowns the bind-mounted state dirs to the agent user.
{
pkgs,
lib,
config,
...
}:
let
userName = config.hyperhive.user.name;
homeDir = "/home/${userName}";
in
{
# Per-agent unix user the harness + co-process daemons run as.
# Defaults to `"agent"` so a standalone evaluation (e.g.
# `nix flake check` against `nixosConfigurations.agent-base`) builds
# cleanly; the meta-flake's per-agent module rebinds this to the
# agent name (`"damocles"`, `"iris"`, …) so each container has a
# uniquely-named user matching its agent label. UID auto-assigned
# by NixOS (the auto-allocation range for normal users); no hard-
# coded UID.
options.hyperhive.user.name = lib.mkOption {
type = lib.types.strMatching "^[a-z_][a-z0-9_-]{0,30}$";
default = "agent";
example = "iris";
description = ''
Unix user the harness service runs as inside the container.
The meta-flake overrides this to the agent's own name so the
user inside the container matches the agent label (`HIVE_LABEL`).
Stand-alone evaluation defaults to `"agent"` so module evaluation
without the meta-flake wrapper still builds.
Constraints match `useradd`'s NAME_REGEX: lowercase / `_` start,
total length 31, no special characters. UID is auto-assigned
by NixOS unless `hyperhive.user.uid` is explicitly set.
'';
};
options.hyperhive.user.uid = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
example = 1100;
description = ''
Optional fixed UID for the per-agent unix user. `null` (default)
lets NixOS auto-assign from the normal-user range ( 1000),
which is the right default for most deployments the UID stays
stable across container rebuilds because each container only has
one normal user and the assignment is written into the container's
`/etc/passwd` at activation time.
Set an explicit value only when the host needs a predictable UID
for the agent's state files e.g. if an operator script
references files by numeric UID, or to keep ownership stable
across full container destroy + recreate on a fresh host.
Values must be in `[1000, 60000)`. Using UIDs < 1000 clashes with
system accounts and is rejected by NixOS.
'';
};
options.hyperhive.user.gid = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
example = 1100;
description = ''
Optional fixed GID for the per-agent unix group. `null` (default)
lets NixOS auto-assign. Usually set alongside `hyperhive.user.uid`
to the same value (the conventional Unix pattern for per-user
groups where uid == gid), but can be set independently.
'';
};
options.hyperhive.user.passwordlessSudo = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = ''
Grant `${config.hyperhive.user.name}` passwordless sudo
(`NOPASSWD: ALL`). True by default so claude's `Bash` tool
keeps working for tools that expect root inside the container
(`systemctl`, package managers in dev shells, etc.) the
same surface the previous root-user shape had, just elevated
explicitly instead of implicitly.
Flip to `false` for agents that should be strictly
unprivileged. Anything claude shells out to that needs root
will then fail loudly with the standard sudo error rather
than silently succeeding easier to spot the leak.
'';
};
config = {
assertions = [
{
assertion =
config.hyperhive.user.uid == null
|| (config.hyperhive.user.uid >= 1000 && config.hyperhive.user.uid < 60000);
message = ''
hyperhive.user.uid must be in [1000, 60000) values below
1000 clash with system accounts; values 60000 are reserved
by NixOS for dynamic allocation. Leave unset (null) to let
NixOS auto-assign.
'';
}
{
assertion =
config.hyperhive.user.gid == null
|| (config.hyperhive.user.gid >= 1000 && config.hyperhive.user.gid < 60000);
message = ''
hyperhive.user.gid must be in [1000, 60000) same range
constraint as hyperhive.user.uid.
'';
}
];
# The container activation script (hive-agent-user-migrate) chowns
# the bind-mounted state dir — including credential files written
# by hive-c0re before the container was built — to this user on
# every boot, so agent processes can always read their own tokens.
users.users.${userName} = {
isNormalUser = true;
home = homeDir;
createHome = true;
group = userName;
extraGroups = lib.optional config.hyperhive.user.passwordlessSudo "wheel";
# Matches /bin/bash on NixOS — the harness's claude shell-outs
# expect a POSIX shell at $SHELL; bashInteractive is already
# the system default for the root user too.
shell = pkgs.bashInteractive;
}
// lib.optionalAttrs (config.hyperhive.user.uid != null) {
uid = config.hyperhive.user.uid;
};
users.groups.${userName} =
{ }
// lib.optionalAttrs (config.hyperhive.user.gid != null) {
gid = config.hyperhive.user.gid;
};
# `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool
# keep working with anything that expected root (systemctl,
# nix-env, etc.) without prompting. Flip
# `hyperhive.user.passwordlessSudo = false` to drop both
# the wheel-group membership and this sudoers entry; anything
# that needs root then fails loudly instead of silently
# succeeding.
security.sudo.extraRules = lib.mkIf config.hyperhive.user.passwordlessSudo [
{
users = [ userName ];
commands = [
{
command = "ALL";
options = [ "NOPASSWD" ];
}
];
}
];
# First-boot migration to the per-agent unix user — creates the
# home dir, chowns the bind-mounted state + `~/.claude/`, and
# (marker-guarded) moves any leftover `/root/.claude` content
# from the previous root-run shape. See
# `docs/persistence.md::First-boot agent-user migration` for the
# step-by-step rationale; this script implements it.
system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] ''
homeDir=${lib.escapeShellArg homeDir}
userName=${lib.escapeShellArg userName}
mkdir -p "$homeDir"
chown "$userName:$userName" "$homeDir"
marker=/var/lib/hive-agent-user-migrated
if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then
mkdir -p "$homeDir/.claude"
if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then
rm -rf /root/.claude
echo "hive-agent-user-migrate: moved /root/.claude $homeDir/.claude"
fi
fi
mkdir -p "$(dirname "$marker")"
: > "$marker"
# Scope state + harness chowns to THIS container's own dirs only.
# The glob `/agents/*/state` also matches child-agent state dirs that
# are bind-mounted into parent containers, which would clobber the
# ownership those dirs' own activation scripts set — producing
# intermittent EACCES for the child agent's harness between a parent
# rebuild and the child's next activation. Config dirs are kept broad
# because the parent legitimately owns child proposed-config repos.
if [ -d "/agents/$userName/state" ]; then
chown -hR "$userName:$userName" "/agents/$userName/state" 2>/dev/null || true
fi
if [ -d "/agents/$userName/harness" ]; then
chown -hR "$userName:$userName" "/agents/$userName/harness" 2>/dev/null || true
fi
# The proposed-config repo is RW-mounted into the editing (parent/
# manager) agent and owned by it; hive-c0re only pulls from it. Heal
# it to this user too — same as state/harness. In an agent's own
# container its config is RO-mounted, so the chown there just fails
# harmlessly (|| true).
for configDir in /agents/*/config; do
[ -d "$configDir" ] || continue
chown -hR "$userName:$userName" "$configDir" 2>/dev/null || true
done
if [ -d "$homeDir/.claude" ]; then
chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true
# 0755 so hive-core (a different unix user) can list the dir and
# detect a valid claude session. Credential files inside are 0600
# so secrets stay private regardless of the directory mode.
# ensure_claude_dir sets 0755 on creation but cannot re-chmod after
# hive-agent-user-migrate chowns the dir to the agent user; this
# activation script runs as root and handles the correction.
chmod 755 "$homeDir/.claude" 2>/dev/null || true
fi
'';
};
}

View file

@ -32,7 +32,7 @@ in
{
# Optional Weston (Wayland compositor) with the VNC backend,
# surfaced as a per-agent `hyperhive.gui.enable` option. Imported
# from harness-base.nix so every sub-agent + the manager sees the
# from ./default.nix so every sub-agent + the manager sees the
# option; only those that flip it on get the service.
#
# Port allocation, weston bind-address quirk, PAM service name, the

View file

@ -3,7 +3,7 @@
# Entry-point for the privileged root agent (ruth). Referenced from
# `flake.nix` (`nixosConfigurations.ruth`) and the meta-flake's
# `applied/ruth/flake.nix`.
imports = [ ./harness-base.nix ];
imports = [ ./harness ];
# The root/manager bootstraps a fresh hive, so it gets the hyperhive
# reference docs made available by default (readable at