docs/agent-hierarchy.md: extract harness systemd unit shape (#718 batch 4)

Move the harness systemd unit rationale (role-driven unit name,
manager-only forge defaults, PATH wrapper-dir trick, env vars,
standalone-eval fallbacks, RuntimeDirectory + User= reasoning)
from `nix/templates/harness-base.nix` to a new
`docs/agent-hierarchy.md::Harness systemd unit shape (per-role)`
section.

In-code comments trim to short purpose statements + pointers; the
PATH /bin auto-append behaviour is already documented in
docs/gotchas.md, so the harness file just cross-refs both.

`description = ''…''` blocks (operator-facing options docs)
preserved per iris #718.

`nix flake check` clean; `nix fmt` clean (after formatter pass).
This commit is contained in:
atlas 2026-05-31 15:25:21 +02:00 committed by mara
commit 8a50f36c0c
2 changed files with 121 additions and 93 deletions

View file

@ -815,10 +815,9 @@ in
# 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/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") {
text = config.hyperhive._bashEnvFragments;
};
environment.etc."hyperhive/bash-allow.json".text =
builtins.toJSON config.hyperhive.allowedBashPatterns;
@ -897,12 +896,11 @@ in
# 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
'';
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;
@ -1284,12 +1282,10 @@ in
};
};
# Manager-only forge defaults (#671): skip the
# subscription/participation firehose so the manager's inbox
# only carries direct mentions, reviews, and assignments. Sub-
# agents keep the noisier defaults (`keepSubscriptions = true`,
# `skipNotifyReasons = [ ]`). `mkDefault` so any agent that
# wants to invert it can.
# Manager-only forge defaults: subscription/participation
# firehose stays off so the manager's inbox isn't drowned in
# noise. Full rationale + sub-agent contrast:
# docs/agent-hierarchy.md::Manager-only defaults.
hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") {
keepSubscriptions = lib.mkDefault false;
skipNotifyReasons = lib.mkDefault [
@ -1298,91 +1294,39 @@ in
];
};
# Harness systemd unit. Role-driven so the same `harness-base.nix`
# covers both `nixosConfigurations.agent-base` (`hive-ag3nt serve`)
# and `nixosConfigurations.manager` (`hive-m1nd serve`) without a
# second template file (#671). Per-agent HIVE_PORT / HIVE_LABEL
# come from the meta-flake's generated `applied/<name>/flake.nix`;
# the manager has hardcoded fallbacks here so `nixosConfigurations.manager`
# still builds standalone.
# Role-driven harness systemd unit: one binary, two unit names
# for log/ExecStartPre stability. Unit shape (PATH wrapper-dir
# trick, env vars, RuntimeDirectory, User=, standalone-eval
# fallbacks): docs/agent-hierarchy.md::Harness systemd unit
# shape (per-role). PATH /bin auto-append behaviour:
# docs/gotchas.md::systemd.services.*.path appends /bin to
# every entry.
systemd.services.${if config.hyperhive.role == "manager" then "hive-m1nd" else "hive-ag3nt"} =
let
isManager = config.hyperhive.role == "manager";
# Post-#598 there is exactly one harness binary (`hive`), and
# it picks its surface from `HIVE_ROLE` at startup. We still
# name the systemd unit `hive-ag3nt` / `hive-m1nd` so dashboard
# log queries + ExecStartPre paths + ancestor PR diffs keep
# working without a unit rename cascade.
binary = "hive";
in
{
description = "${binary}${lib.optionalString isManager " manager"} harness";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
# systemd units get a minimal PATH by default and don't inherit
# `environment.systemPackages`. Pointing at `/run/current-system/sw`
# gives the harness (and any tools claude shells out to via Bash)
# access to everything declared in `systemPackages` — including
# anything an agent adds to its own `agent.nix` — without having
# to touch the service definition.
#
# `/run/wrappers/bin` prepended so the `security.wrappers`
# setuid shims (notably `sudo`) resolve before the bare
# nix-store binaries in `/run/current-system/sw/bin`.
# Post-#658 the harness runs as the per-agent user — without
# the wrapper dir on PATH, `sudo` resolves to the un-setuid
# nix-store binary and refuses with "must be owned by uid 0
# and have the setuid bit set" even when
# `hyperhive.user.passwordlessSudo = true` is configured
# (#672 fixup pulled forward into this PR to avoid the
# regression argus flagged on #676).
#
# `systemd.services.<name>.path` appends `/bin` to each entry,
# so the bare prefixes here resolve to `/run/wrappers/bin` +
# `/run/current-system/sw/bin` inside the unit's PATH. Passing
# the trailing `/bin` ourselves (the natural-looking spelling)
# would yield `/run/wrappers/bin/bin` + `/run/current-system/sw/bin/bin`,
# neither of which exists — that's how #672 originally landed
# broken: every agent had a PATH pointing at non-existent dirs
# and `which sudo` kept falling back to the un-setuid binary.
# `/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` defaults to `/` for systemd services without a User=
# set. With #658 the harness runs as the agent user — set HOME
# explicitly so claude (which the harness spawns) finds its
# `~/.claude/` session dir at the bind-mounted location.
HOME = homeDir;
# Path to the merged agent static dist. The harness serves this
# via `tower_http::ServeDir` for any request it doesn't route to
# an API endpoint. `mergedDist` is the agent-default dist with
# `hyperhive.frontend.extraFiles` layered on top.
HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}";
# Static runtime assets (branding + claude prompts). Set on the
# unit directly — `environment.variables` only populates
# /etc/profile, which systemd services don't inherit.
HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive";
# Post-#598: the unified `hive` binary picks its surface from
# this env var at startup. Default (`"agent"`) matches the
# binary's standalone fallback when this is unset.
HIVE_ROLE = config.hyperhive.role;
}
// lib.optionalAttrs isManager {
# Standalone-eval fallbacks for `nixosConfigurations.manager`.
# meta.rs overrides both via the per-agent generated
# `applied/hm1nd/flake.nix` (see `lifecycle::setup_applied`);
# the values here keep the container sensible if anyone
# evaluates the standalone config.
#
# `HIVE_PORT` = FNV-1a("hm1nd") % 900 + 8100 = 8875 per
# `lifecycle::agent_web_port` (#753 dropped the
# pre-#753 "manager pinned at 8000" special case). Hardcoded
# here because the standalone-eval path doesn't go through
# `meta::render_flake`; real deploys pick up the rust-computed
# value via meta and never touch this fallback.
# Standalone-eval fallbacks; meta.rs overrides at deploy time.
# HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100.
HIVE_PORT = "8875";
HIVE_LABEL = "hm1nd";
};
@ -1390,20 +1334,11 @@ in
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
Restart = "on-failure";
RestartSec = 2;
# `/run/hive-config/` is a per-service runtime dir owned by
# the agent user (`User=` below), auto-cleared by systemd on
# stop. The harness writes its regenerated
# claude-{mcp-config,settings,system-prompt} files there
# (see `paths::config_dir`). Kept separate from `/run/hive`
# — that bind comes in root-owned from the host and holds
# hive-c0re's `mcp.sock` we only connect to (#658 fixup).
# 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";
# Run the harness as the per-agent user (#658). claude itself
# spawned by the harness then runs as that user too — drops
# root inside the container while sudo (`NOPASSWD: ALL` by
# default, see `hyperhive.user.passwordlessSudo`) keeps the
# previous root-by-default surface available explicitly for
# tools that need it.
User = userName;
Group = userName;
};