diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index c914499c..6c711847 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -206,6 +206,99 @@ a full nspawn agent. Open questions, not yet wired: - Filesystem: share parent's `/state` RW, or a sub-dir? - Identity: distinct broker recipient name, or address the parent? +## Harness systemd unit shape (per-role) + +One harness binary (`hive`), one `harness-base.nix` template, two +systemd units depending on `hyperhive.role`: + +- `agent-base.nix` (`role = "agent"`) → `systemd.services.hive-ag3nt` +- `manager.nix` (`role = "manager"`) → `systemd.services.hive-m1nd` + +The unit names diverge but the binary is the same. `HIVE_ROLE` env +var picks the surface at startup (agent vs manager); naming the +units after the historical per-role binaries keeps dashboard log +queries, ExecStartPre paths, and ancestor PR diffs working without a +rename cascade. + +### Manager-only defaults + +`harness-base.nix` flips these when `hyperhive.role == "manager"`, +via `lib.mkDefault` so any agent can invert if needed: + +- `hyperhive.forge.keepSubscriptions = false` +- `hyperhive.forge.skipNotifyReasons = [ "subscribed" "participating" ]` + +Skips the subscription / participation firehose so the manager's +inbox only carries direct mentions, reviews, and assignments. Sub- +agents keep the noisier defaults so they see anything aimed at the +repos they're working on. + +### Standalone-eval fallbacks + +`nixosConfigurations.manager` must build standalone (without the +meta-flake's per-agent flake.nix wrapper). For the manager unit +that means hardcoded `HIVE_PORT` / `HIVE_LABEL` env values: + +- `HIVE_PORT = "8875"` — FNV-1a(`"hm1nd"`) % 900 + 8100, matching + `lifecycle::agent_web_port`. Sub-agents have the same shape via + the meta-flake-generated `applied//flake.nix`. +- `HIVE_LABEL = "hm1nd"` — container name; matches what `meta.rs` + injects at deploy time. + +Real deploys never read these — `meta::render_flake` overrides them +via the generated wrapper. They exist so the manager +`nixosConfigurations` evaluates cleanly even outside the meta-flake +boundary. + +### Environment variables set on the unit + +- `HOME = /home/` — systemd defaults `HOME` to `/` for + services without `User=` set; with the per-agent user (#658) the + harness needs the right home so claude finds its bind-mounted + `~/.claude/` session dir. +- `HIVE_STATIC_DIR = ` — `tower_http::ServeDir` root for + the per-agent web UI; merged dist = agent default + every + `hyperhive.frontend.extraFiles` overlay. +- `HIVE_ASSETS_DIR = pkgs.hyperhive-assets/share/hyperhive` — set + directly on the unit, **not** via `environment.variables`, because + the latter only populates `/etc/profile` which systemd services + don't inherit. +- `HIVE_ROLE = config.hyperhive.role` — picks the binary surface + (agent / manager) at startup. + +### `PATH` setup (the wrapper-dir trick) + +```nix +path = [ "/run/wrappers" "/run/current-system/sw" ]; +``` + +`/run/wrappers` comes first so setuid wrappers (notably `sudo`) +resolve before bare nix-store binaries. NixOS's +`systemd.services..path` appends `/bin` to every entry via +`lib.makeBinPath`; passing `/run/wrappers/bin` directly produces +`/run/wrappers/bin/bin` which doesn't exist (`docs/gotchas.md:: +systemd.services.*.path appends /bin to every entry`). Post-#658 +when the harness runs as the per-agent user this matters: without +the wrapper dir on PATH, `sudo` resolves to the un-setuid nix-store +binary and rejects with `must be owned by uid 0 and have the setuid +bit set` regardless of `hyperhive.user.passwordlessSudo`. + +### `serviceConfig` highlights + +- `ExecStart = pkgs.hyperhive/bin/hive serve` — single binary, + surface picked from `HIVE_ROLE`. +- `Restart = on-failure`, `RestartSec = 2` — keeps the harness + resilient across transient crashes without thundering retries. +- `RuntimeDirectory = "hive-config"` → `/run/hive-config/` owned by + `User=`, auto-cleared on stop. The harness writes regenerated + `claude-{mcp-config,settings,system-prompt}` files there + (`paths::config_dir`). Deliberately separate from `/run/hive`, + which the host bind-mounts in root-owned and which holds + hive-c0re's `mcp.sock` (#658 fixup). +- `User = Group = userName` — drops root inside the container; sudo + is the explicit escalation surface + (`hyperhive.user.passwordlessSudo`). + ## Cross-references - Milestone: [#361 "Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361) diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index e2ba4a3a..a5aab8c7 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -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//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..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; };