From ecaad48fad75d1e171740fcf3b737ea0ea5ad408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Mon, 13 Jul 2026 21:16:29 +0200 Subject: [PATCH] refactor: split harness-base.nix into harness/ feature modules --- nix/templates/agent-base.nix | 2 +- nix/templates/harness-base.nix | 2205 -------------------- nix/templates/harness/agent-service.nix | 245 +++ nix/templates/harness/bash-env.nix | 134 ++ nix/templates/harness/claude-settings.nix | 376 ++++ nix/templates/harness/dashboard-links.nix | 73 + nix/templates/harness/default.nix | 220 ++ nix/templates/harness/docs.nix | 50 + nix/templates/harness/forge.nix | 194 ++ nix/templates/harness/frontend.nix | 156 ++ nix/templates/harness/github.nix | 89 + nix/templates/harness/matrix.nix | 298 +++ nix/templates/harness/mcp.nix | 257 +++ nix/templates/harness/network.nix | 82 + nix/templates/harness/user.nix | 215 ++ nix/templates/{ => harness}/weston-vnc.nix | 2 +- nix/templates/manager.nix | 2 +- 17 files changed, 2392 insertions(+), 2208 deletions(-) delete mode 100644 nix/templates/harness-base.nix create mode 100644 nix/templates/harness/agent-service.nix create mode 100644 nix/templates/harness/bash-env.nix create mode 100644 nix/templates/harness/claude-settings.nix create mode 100644 nix/templates/harness/dashboard-links.nix create mode 100644 nix/templates/harness/default.nix create mode 100644 nix/templates/harness/docs.nix create mode 100644 nix/templates/harness/forge.nix create mode 100644 nix/templates/harness/frontend.nix create mode 100644 nix/templates/harness/github.nix create mode 100644 nix/templates/harness/matrix.nix create mode 100644 nix/templates/harness/mcp.nix create mode 100644 nix/templates/harness/network.nix create mode 100644 nix/templates/harness/user.nix rename nix/templates/{ => harness}/weston-vnc.nix (99%) diff --git a/nix/templates/agent-base.nix b/nix/templates/agent-base.nix index e905e813..87932048 100644 --- a/nix/templates/agent-base.nix +++ b/nix/templates/agent-base.nix @@ -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//flake.nix`. diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix deleted file mode 100644 index f85fb7a0..00000000 --- a/nix/templates/harness-base.nix +++ /dev/null @@ -1,2205 +0,0 @@ -{ - 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 ? { }, - ... -}: -let - # Agent user metadata. `userName` defaults to `"agent"` when the - # meta-flake doesn't inject the per-agent override (stand-alone - # `nixos-rebuild` against `nixosConfigurations.agent-base` works - # without erroring on a missing per-agent name). `homeDir` derives - # from `userName` to keep them coupled. - userName = config.hyperhive.user.name; - homeDir = "/home/${userName}"; - # 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. - 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 - ''; - # 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"; - # 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. - # 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 "-" for identification. - CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}"; - }; - 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; - }; - # 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). - # Replaces the old `matrix-avatar-sync` curl oneshot. 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 -{ - # 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. - - # Optional feature modules. Each declares its own `hyperhive.*` - # option(s), default-off, so every agent has them available but - # only opts in from its own `agent.nix`. - imports = [ ./weston-vnc.nix ]; - - # 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. - ''; - }; - - 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.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_` 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.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 `../docs`)"; - description = '' - Store path of the reference-docs tree exposed at `$HIVE_DOCS_DIR` - when `hyperhive.docs.enable` is set. Defaults to the overlay's - `pkgs.hyperhive-docs` (the `nix/reference-docs.nix` build from - `../docs`) 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. - ''; - }; - - 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; - }; - - # 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 `). 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.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____` 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____`). 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.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 `/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] in : …`) - 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.` 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. This replaces - the wasteful "one MCP server + daemon per account" pattern. 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` + `/matrix-token` + - `/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`, exactly as before. 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). - ''; - }; - - 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 - `/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). - ''; - }; - - 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/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 (`//...`) 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 '' 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). - ''; - }; - - 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 --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"` - (= `/agents//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). - ''; - }; - - 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. - ''; - }; - - 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 ` - (`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 ` 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. - ''; - }; - - 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. - ''; - }; - - 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//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:` (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. - ''; - }; - - # 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. Loaded via - # `$BASH_ENV` for non-interactive shells (claude's `Bash` tool - # runs `bash -c`) and via `programs.bash.interactiveShellInit` - # for interactive shells. 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). - ''; - }; - - 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.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:` and `render_claude_config` - points claude at the stable `http://127.0.0.1:/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:` 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. - ''; - - 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.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."; - } - # 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}\")"; - } - # 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"; - } - # 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..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 - ) - ) - + "."; - } - # 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. - ''; - } - { - 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. - ''; - } - ]; - - # Per-agent unix user. Runs the hive harness + - # co-process daemons under a non-root principal. UID auto-assigned by - # NixOS unless `hyperhive.user.uid` is set. 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 (see SHELL env - # var declaration below). - 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 — same surface as the - # previous root-by-default shape, just elevated explicitly. - # 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 - ''; - - # Auto-inject built-in MCP servers. bash is always present; matrix is - # conditional on hyperhive.matrix.enable. Both use lib.mkDefault so - # the operator's own agent.nix can override individual entries. - hyperhive.extraMcpServers = lib.mkMerge [ - { - bash = lib.mkDefault { - command = "${pkgs.hive-bash-mcp}/bin/hive-bash-mcp"; - args = [ ]; - env.HIVE_BASH_SOCKET = "/run/hive-bash/socket"; - allowedTools = [ "*" ]; - }; - } - (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 = [ "*" ]; - }; - }) - ]; - - environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; - - # 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. - environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) { - source = config.hyperhive.icon; - }; - - # Cargo `--message-format short` injector. Contributes a `cargo` - # shell function to `hyperhive._bashEnvFragments`; the bash-env - # infrastructure below packages that into a single file sourced - # by both non-interactive and interactive shells. - # `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; - }; - - environment.etc."hyperhive/send-allow.json".text = - builtins.toJSON config.hyperhive.allowedRecipients; - - 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 no longer - # passes `--settings` or copies 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" - ''; - }; - }; - - # 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) - ); - - # HIVE_DEFAULT_MODEL seeds the initial model selection when no persisted - # model choice exists in the state dir. SHELL must be set so claude's - # Bash tool finds a POSIX shell. - # HIVE_ASSETS_DIR points at the project's static runtime assets - # (branding + claude prompts; see `nix/assets.nix`). Set here so - # both the harness binary and any user-shell `cargo run` inside the - # container resolve them from the same path. - # 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_DEFAULT_MODEL = config.hyperhive.model; - # HIVE_AVAILABLE_MODELS is the 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; - # HIVE_DEFAULT_EFFORT is the 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; - 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"; - } - // 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"; - } - // lib.optionalAttrs config.hyperhive.docs.enable { - # hyperhive.docs.enable: the in-container reference-docs tree. 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}"; - } - // lib.optionalAttrs (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). Interactive shells source the - # same file via the `interactiveShellInit` hook below so - # behaviour matches across both modes. - 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 - ''; - - 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 same - # ephemeral-per-boot semantics agents already expect from the old tmpfs, - # 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" ]; - - environment.systemPackages = - with pkgs; - [ - # Per-bin split packages (see flake.nix `packages..hive-*`) - # 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`) and a second, - # redundant `hive-forge` copy (already provided below via - # `hive-forge-tools.nix`) — closure bloat + dead surface with no - # functional upside inside a container. 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 above 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). - 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 - # 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. - tea - # 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-forge : CLI wrapping common Forgejo REST API operations - # (view, pr, issue, comment, assign, close, labels, branches, etc.) - (pkgs.callPackage ../packages/hive-forge-tools.nix { }) - ] - ++ lib.optionals config.hyperhive.github.enable [ - # gh wrapper + git credential helper for hyperhive.github.enable. - # (No bare pkgs.gh here — the wrapper *is* `gh` and hardcodes the real - # binary path, so it can't be shadowed.) - 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. Gated on hyperhive.github.enable; 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 above) — 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 - ''; - }; - - # 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. - # 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.` 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 hive-gateway.nix). useDHCP runs dhcpcd on every - # interface (just eth0 in practice — the nspawn bridge veth). - 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. 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 `-…-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" - ''; - }; - - 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 - # `/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 (unlike the old true) 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//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":""} — 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 - ''; - }; - - # 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.` 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 below. - RuntimeDirectoryPreserve = "yes"; - }; - }; - - # 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. - # Pre-split this ran inside hive-ag3nt.service and inherited the - # agent's PATH; the standalone daemon needs the same or `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//harness and /agents//state - # respectively). Listed here for explicitness — the daemon - # uses these to derive its task + loose-ends dir paths. - # Without them the daemon 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"; - # See the matching note on hive-matrix-daemon. Without this, 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". - 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; - }; - }; - - # 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*"; - }; - - # 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//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" - ''; - }; - - # 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//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"; - }; - }; - - # 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"; - # OTEL is shipped declaratively via the managed claude settings - # json (`environment.etc."claude-code/managed-settings.json"`, - # `otelSettingsEnv` in the top-level let) — claude reads it for - # both the harness turn-loop and `hivectl choom`, so there's no - # launch wrapper or per-unit OTEL env here anymore. - 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; TCP fallback is removed. 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 - # above 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 - # nix/templates/weston-vnc.nix::hyperhive.gui.vncPort. - HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort; - } - // lib.optionalAttrs (config.hyperhive.extraWebProxies != { }) { - # JSON object {"": ""} 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; - }; - }; - - system.stateVersion = "25.11"; - }; -} diff --git a/nix/templates/harness/agent-service.nix b/nix/templates/harness/agent-service.nix new file mode 100644 index 00000000..d7f60026 --- /dev/null +++ b/nix/templates/harness/agent-service.nix @@ -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_` 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//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:` (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 {"": ""} 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; + }; + }; + }; +} diff --git a/nix/templates/harness/bash-env.nix b/nix/templates/harness/bash-env.nix new file mode 100644 index 00000000..302ece25 --- /dev/null +++ b/nix/templates/harness/bash-env.nix @@ -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 + ''; + }; +} diff --git a/nix/templates/harness/claude-settings.nix b/nix/templates/harness/claude-settings.nix new file mode 100644 index 00000000..ca8a2fa1 --- /dev/null +++ b/nix/templates/harness/claude-settings.nix @@ -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 "-" 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 `). 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 ` + (`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 ` 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" + ''; + }; + }; + }; +} diff --git a/nix/templates/harness/dashboard-links.nix b/nix/templates/harness/dashboard-links.nix new file mode 100644 index 00000000..3181b1b1 --- /dev/null +++ b/nix/templates/harness/dashboard-links.nix @@ -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//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" + ''; + }; + }; +} diff --git a/nix/templates/harness/default.nix b/nix/templates/harness/default.nix new file mode 100644 index 00000000..8e249b32 --- /dev/null +++ b/nix/templates/harness/default.nix @@ -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//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"; + }; +} diff --git a/nix/templates/harness/docs.nix b/nix/templates/harness/docs.nix new file mode 100644 index 00000000..c50dbd64 --- /dev/null +++ b/nix/templates/harness/docs.nix @@ -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}"; + }; + }; +} diff --git a/nix/templates/harness/forge.nix b/nix/templates/harness/forge.nix new file mode 100644 index 00000000..af37a183 --- /dev/null +++ b/nix/templates/harness/forge.nix @@ -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 --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"` + (= `/agents//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 : 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 + # `/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//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":""} — 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 + ''; + }; + }; +} diff --git a/nix/templates/harness/frontend.nix b/nix/templates/harness/frontend.nix new file mode 100644 index 00000000..759ca03f --- /dev/null +++ b/nix/templates/harness/frontend.nix @@ -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 (`//...`) 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 '' 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) + ); + }; +} diff --git a/nix/templates/harness/github.nix b/nix/templates/harness/github.nix new file mode 100644 index 00000000..d1a9aa6a --- /dev/null +++ b/nix/templates/harness/github.nix @@ -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 + `/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 + ''; + }; + }; +} diff --git a/nix/templates/harness/matrix.nix b/nix/templates/harness/matrix.nix new file mode 100644 index 00000000..10517a78 --- /dev/null +++ b/nix/templates/harness/matrix.nix @@ -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 `/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] in : …`) + 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.` 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` + `/matrix-token` + + `/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..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.` 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*"; + }; + }; +} diff --git a/nix/templates/harness/mcp.nix b/nix/templates/harness/mcp.nix new file mode 100644 index 00000000..dd664dc3 --- /dev/null +++ b/nix/templates/harness/mcp.nix @@ -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____` 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____`). 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:` and `render_claude_config` + points claude at the stable `http://127.0.0.1:/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:` 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//harness and /agents//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; + }; + }; + }; +} diff --git a/nix/templates/harness/network.nix b/nix/templates/harness/network.nix new file mode 100644 index 00000000..0b9c2589 --- /dev/null +++ b/nix/templates/harness/network.nix @@ -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.` 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. 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 `-…-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" + ''; + }; + }; +} diff --git a/nix/templates/harness/user.nix b/nix/templates/harness/user.nix new file mode 100644 index 00000000..f4a2d8b0 --- /dev/null +++ b/nix/templates/harness/user.nix @@ -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 + ''; + }; +} diff --git a/nix/templates/weston-vnc.nix b/nix/templates/harness/weston-vnc.nix similarity index 99% rename from nix/templates/weston-vnc.nix rename to nix/templates/harness/weston-vnc.nix index aff4c1d3..a9caa8a7 100644 --- a/nix/templates/weston-vnc.nix +++ b/nix/templates/harness/weston-vnc.nix @@ -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 diff --git a/nix/templates/manager.nix b/nix/templates/manager.nix index b6a9d1ea..a3b2bc50 100644 --- a/nix/templates/manager.nix +++ b/nix/templates/manager.nix @@ -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