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:
parent
35a7ff03b7
commit
8a50f36c0c
2 changed files with 121 additions and 93 deletions
|
|
@ -206,6 +206,99 @@ a full nspawn agent. Open questions, not yet wired:
|
||||||
- Filesystem: share parent's `/state` RW, or a sub-dir?
|
- Filesystem: share parent's `/state` RW, or a sub-dir?
|
||||||
- Identity: distinct broker recipient name, or address the parent?
|
- 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/<name>/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/<userName>` — 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 = <mergedDist>` — `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.<unit>.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
|
## Cross-references
|
||||||
|
|
||||||
- Milestone: [#361 "Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361)
|
- Milestone: [#361 "Agent privileges and sub-agents"](http://localhost:3000/hyperhive/hyperhive/issues/361)
|
||||||
|
|
|
||||||
|
|
@ -815,10 +815,9 @@ in
|
||||||
# Wiring is gated on at least one fragment being active so a
|
# Wiring is gated on at least one fragment being active so a
|
||||||
# fully feature-disabled agent has neither the file nor the
|
# fully feature-disabled agent has neither the file nor the
|
||||||
# `BASH_ENV` / interactive sourcing — zero cost in that case.
|
# `BASH_ENV` / interactive sourcing — zero cost in that case.
|
||||||
environment.etc."hyperhive/bash-env.sh" =
|
environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") {
|
||||||
lib.mkIf (config.hyperhive._bashEnvFragments != "") {
|
text = config.hyperhive._bashEnvFragments;
|
||||||
text = config.hyperhive._bashEnvFragments;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
environment.etc."hyperhive/bash-allow.json".text =
|
environment.etc."hyperhive/bash-allow.json".text =
|
||||||
builtins.toJSON config.hyperhive.allowedBashPatterns;
|
builtins.toJSON config.hyperhive.allowedBashPatterns;
|
||||||
|
|
@ -897,12 +896,11 @@ in
|
||||||
# hook surface as claude's non-interactive calls. Gated on at
|
# hook surface as claude's non-interactive calls. Gated on at
|
||||||
# least one fragment being active so we don't write a no-op
|
# least one fragment being active so we don't write a no-op
|
||||||
# source line into `/etc/bashrc` on fully-feature-disabled agents.
|
# source line into `/etc/bashrc` on fully-feature-disabled agents.
|
||||||
programs.bash.interactiveShellInit =
|
programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") ''
|
||||||
lib.mkIf (config.hyperhive._bashEnvFragments != "") ''
|
if [ -r /etc/hyperhive/bash-env.sh ]; then
|
||||||
if [ -r /etc/hyperhive/bash-env.sh ]; then
|
. /etc/hyperhive/bash-env.sh
|
||||||
. /etc/hyperhive/bash-env.sh
|
fi
|
||||||
fi
|
'';
|
||||||
'';
|
|
||||||
|
|
||||||
boot.isNspawnContainer = true;
|
boot.isNspawnContainer = true;
|
||||||
|
|
||||||
|
|
@ -1284,12 +1282,10 @@ in
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Manager-only forge defaults (#671): skip the
|
# Manager-only forge defaults: subscription/participation
|
||||||
# subscription/participation firehose so the manager's inbox
|
# firehose stays off so the manager's inbox isn't drowned in
|
||||||
# only carries direct mentions, reviews, and assignments. Sub-
|
# noise. Full rationale + sub-agent contrast:
|
||||||
# agents keep the noisier defaults (`keepSubscriptions = true`,
|
# docs/agent-hierarchy.md::Manager-only defaults.
|
||||||
# `skipNotifyReasons = [ ]`). `mkDefault` so any agent that
|
|
||||||
# wants to invert it can.
|
|
||||||
hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") {
|
hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") {
|
||||||
keepSubscriptions = lib.mkDefault false;
|
keepSubscriptions = lib.mkDefault false;
|
||||||
skipNotifyReasons = lib.mkDefault [
|
skipNotifyReasons = lib.mkDefault [
|
||||||
|
|
@ -1298,91 +1294,39 @@ in
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# Harness systemd unit. Role-driven so the same `harness-base.nix`
|
# Role-driven harness systemd unit: one binary, two unit names
|
||||||
# covers both `nixosConfigurations.agent-base` (`hive-ag3nt serve`)
|
# for log/ExecStartPre stability. Unit shape (PATH wrapper-dir
|
||||||
# and `nixosConfigurations.manager` (`hive-m1nd serve`) without a
|
# trick, env vars, RuntimeDirectory, User=, standalone-eval
|
||||||
# second template file (#671). Per-agent HIVE_PORT / HIVE_LABEL
|
# fallbacks): docs/agent-hierarchy.md::Harness systemd unit
|
||||||
# come from the meta-flake's generated `applied/<name>/flake.nix`;
|
# shape (per-role). PATH /bin auto-append behaviour:
|
||||||
# the manager has hardcoded fallbacks here so `nixosConfigurations.manager`
|
# docs/gotchas.md::systemd.services.*.path appends /bin to
|
||||||
# still builds standalone.
|
# every entry.
|
||||||
systemd.services.${if config.hyperhive.role == "manager" then "hive-m1nd" else "hive-ag3nt"} =
|
systemd.services.${if config.hyperhive.role == "manager" then "hive-m1nd" else "hive-ag3nt"} =
|
||||||
let
|
let
|
||||||
isManager = config.hyperhive.role == "manager";
|
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";
|
binary = "hive";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
description = "${binary}${lib.optionalString isManager " manager"} harness";
|
description = "${binary}${lib.optionalString isManager " manager"} harness";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
# systemd units get a minimal PATH by default and don't inherit
|
# `/run/wrappers` before `/run/current-system/sw` so setuid
|
||||||
# `environment.systemPackages`. Pointing at `/run/current-system/sw`
|
# `sudo` resolves first. Passing the bare prefixes (no trailing
|
||||||
# gives the harness (and any tools claude shells out to via Bash)
|
# `/bin`) is intentional — see docs pointer above.
|
||||||
# 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.
|
|
||||||
path = [
|
path = [
|
||||||
"/run/wrappers"
|
"/run/wrappers"
|
||||||
"/run/current-system/sw"
|
"/run/current-system/sw"
|
||||||
];
|
];
|
||||||
environment = {
|
environment = {
|
||||||
SHELL = "${pkgs.bashInteractive}/bin/bash";
|
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;
|
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}";
|
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";
|
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;
|
HIVE_ROLE = config.hyperhive.role;
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs isManager {
|
// lib.optionalAttrs isManager {
|
||||||
# Standalone-eval fallbacks for `nixosConfigurations.manager`.
|
# Standalone-eval fallbacks; meta.rs overrides at deploy time.
|
||||||
# meta.rs overrides both via the per-agent generated
|
# HIVE_PORT = FNV-1a("hm1nd") % 900 + 8100.
|
||||||
# `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.
|
|
||||||
HIVE_PORT = "8875";
|
HIVE_PORT = "8875";
|
||||||
HIVE_LABEL = "hm1nd";
|
HIVE_LABEL = "hm1nd";
|
||||||
};
|
};
|
||||||
|
|
@ -1390,20 +1334,11 @@ in
|
||||||
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
|
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 2;
|
RestartSec = 2;
|
||||||
# `/run/hive-config/` is a per-service runtime dir owned by
|
# Per-service runtime dir owned by `User=` below; the harness
|
||||||
# the agent user (`User=` below), auto-cleared by systemd on
|
# writes its regenerated claude-{mcp-config,settings,system-prompt}
|
||||||
# stop. The harness writes its regenerated
|
# files here (`paths::config_dir`). Separate from /run/hive,
|
||||||
# claude-{mcp-config,settings,system-prompt} files there
|
# which holds hive-c0re's mcp.sock.
|
||||||
# (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).
|
|
||||||
RuntimeDirectory = "hive-config";
|
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;
|
User = userName;
|
||||||
Group = userName;
|
Group = userName;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue