Watch
0
0
Fork
You've already forked hyperhive
0

agents: login shells read the hive facts too; test the readers

Part of #5081. Follow-up to the review of #5131.

- Before this PR, claude read CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX
  from managed-settings.json in every context. The environment generator
  reaches every unit of the container's manager, including `machinectl
  shell` (hivectl choom): machined starts it as a transient
  container-shell@ unit (systemd 260.5 src/machine/machine.c:980-1004),
  and every unit's exec environment starts from the manager environment
  the generators fill (src/core/unit.c:5557, src/core/manager.c:3990-4001).
  Shells no unit starts do not get it: `nixos-container root-login` / `run`
  enter with nsenter + `su -l`, and getty logins go through login(1). So
  hive-facts.nix now also exports the generator's output from
  /etc/set-environment (environment.extraInit).
- The generator's read loop kept a last line only when it ended in a
  newline; it now keeps one without.
- checks.script-test-agent-hive-facts runs the rendered generator and the
  shell snippet (under dash and bash's sh) against sample files: both
  facts, a planted LD_PRELOAD/PATH line, a last line without a newline,
  an empty file and no file.
- identity.rs: hive_name()'s doc names the facts file as the source.
This commit is contained in:
atlas 2026-10-12 05:04:06 +02:00 • committed by mara
commit c8df666bf3
6 changed files with 172 additions and 28 deletions

View file

@ -40,10 +40,11 @@ Four env vars cover the identity surface, read by
Inside an agent container, the hive's name and domain don't come from the Inside an agent container, the hive's name and domain don't come from the
agent's build. `hive-priv` writes them to `/etc/hyperhive-hive-facts` before agent's build. `hive-priv` writes them to `/etc/hyperhive-hive-facts` before
every container start, and the agent's environment generator every container start. The agent's environment generator
(`nix/agent-modules/hive-facts.nix`) passes them to every unit. Neither is (`nix/agent-modules/hive-facts.nix`) passes them to every unit, including
part of an agent's closure, and an agent started on another hive reports `hivectl choom` sessions, and `/etc/set-environment` exports them in login
that hive. shells that no unit starts (`nixos-container root-login`). Neither is part of
an agent's closure, and an agent started on another hive reports that hive.
`hive_name` + `swarm_name` are **distinct** from `hive_name` + `swarm_name` are **distinct** from
`HYPERHIVE_HIVE_DOMAIN`: the domain may carry the hive name as `HYPERHIVE_HIVE_DOMAIN`: the domain may carry the hive name as

View file

@ -31,9 +31,9 @@ pub fn hive_domain() -> Option<String> {
/// Human display name of this hive (e.g. `pr1ma`). Distinct from /// Human display name of this hive (e.g. `pr1ma`). Distinct from
/// [`hive_domain`] — the domain is the machine-readable DNS address; /// [`hive_domain`] — the domain is the machine-readable DNS address;
/// this is the prose label humans use in conversation. Returns None /// this is the prose label humans use in conversation. Returns None
/// when the host-side `services.hyperhive.hiveName` option is unset, /// when `/etc/hyperhive-hive-facts` carries no name (the host's
/// in which case callers fall back to the domain or the short label /// `services.hyperhive.hiveName` is unset), in which case callers fall
/// at their discretion. /// back to the domain or the short label at their discretion.
#[must_use] #[must_use]
pub fn hive_name() -> Option<String> { pub fn hive_name() -> Option<String> {
non_empty_env("HYPERHIVE_HIVE_NAME") non_empty_env("HYPERHIVE_HIVE_NAME")

View file

@ -3,9 +3,17 @@
# #
# hive-priv writes `/etc/hyperhive-hive-facts` into the container before every # hive-priv writes `/etc/hyperhive-hive-facts` into the container before every
# start (`HYPERHIVE_HIVE_NAME`, `HYPERHIVE_HIVE_DOMAIN`, one `KEY=value` line # start (`HYPERHIVE_HIVE_NAME`, `HYPERHIVE_HIVE_DOMAIN`, one `KEY=value` line
# each). This environment generator hands those lines to the container's # each). Two readers hand those lines on:
# systemd, which passes them to every unit it starts and to `machinectl shell` #
# sessions; a unit's own `Environment=` still overrides them. # - an environment generator, for the container's systemd: it passes them to
# every unit it starts, `machinectl shell` sessions (`hivectl choom`)
# included, since those are transient units of the same manager. A unit's
# own `Environment=` still overrides them.
# - `/etc/set-environment`, for login shells that no unit starts:
# `nixos-container root-login` / `run` (nsenter + `su -l`, which resets the
# environment) and getty logins.
#
# Tested by `checks.script-test-agent-hive-facts`.
{ {
config, config,
lib, lib,
@ -14,24 +22,41 @@
}: }:
let let
userName = config.services.hyperhive.agent.user.name; userName = config.services.hyperhive.agent.user.name;
# Shell builtins only: nothing is assumed on PATH this early. Only the two
# known keys are passed on. `|| [ -n "$key" ]` keeps a last line that has
# no trailing newline.
generator = pkgs.writeShellScript "hyperhive-hive-facts" ''
hive=unknown
if [ -r /etc/hyperhive-hive-facts ]; then
while IFS='=' read -r key value || [ -n "$key" ]; do
case "$key" in
HYPERHIVE_HIVE_NAME) hive=$value ;;
HYPERHIVE_HIVE_DOMAIN) ;;
*) continue ;;
esac
printf '%s=%s\n' "$key" "$value"
done < /etc/hyperhive-hive-facts
fi
# Names claude's remote-control sessions `<hive>-<agent>`.
printf 'CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=%s-%s\n' "$hive" ${lib.escapeShellArg userName}
'';
# Sourced by every shell's `/etc/set-environment`, so POSIX sh only. Each
# line is exported as one word and never evaluated.
profile = pkgs.writeText "hyperhive-hive-facts.sh" ''
while IFS= read -r __hyperhive_fact; do
case "$__hyperhive_fact" in
?*=*) export "$__hyperhive_fact" ;;
esac
done <<__HYPERHIVE_FACTS__
$(${generator})
__HYPERHIVE_FACTS__
unset __hyperhive_fact
'';
in in
{ {
# Shell builtins only: nothing is assumed on PATH this early. Only the two environment.etc."systemd/system-environment-generators/50-hyperhive-hive-facts".source = generator;
# known keys are passed on. environment.etc."hyperhive/hive-facts.sh".source = profile;
environment.etc."systemd/system-environment-generators/50-hyperhive-hive-facts".source = environment.extraInit = ". /etc/hyperhive/hive-facts.sh";
pkgs.writeShellScript "hyperhive-hive-facts" ''
hive=unknown
if [ -r /etc/hyperhive-hive-facts ]; then
while IFS='=' read -r key value; do
case "$key" in
HYPERHIVE_HIVE_NAME) hive=$value ;;
HYPERHIVE_HIVE_DOMAIN) ;;
*) continue ;;
esac
printf '%s=%s\n' "$key" "$value"
done < /etc/hyperhive-hive-facts
fi
# Names claude's remote-control sessions `<hive>-<agent>`.
printf 'CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=%s-%s\n' "$hive" ${lib.escapeShellArg userName}
'';
} }

View file

@ -132,6 +132,12 @@ in
inherit pkgs self nixosSystem; inherit pkgs self nixosSystem;
inherit (pkgs) lib; inherit (pkgs) lib;
}; };
# Runs ./agent-modules/hive-facts.nix's generator and login-shell snippet
# against sample facts files.
script-test-agent-hive-facts = import ./script-tests/agent-hive-facts.nix {
inherit pkgs self nixosSystem;
inherit (pkgs) lib;
};
module-eval-agent-github-bao = import ./module-eval/agent-github-bao.nix { module-eval-agent-github-bao = import ./module-eval/agent-github-bao.nix {
inherit pkgs self nixosSystem; inherit pkgs self nixosSystem;
inherit (pkgs) lib; inherit (pkgs) lib;

View file

@ -0,0 +1,41 @@
# `checks.script-test-agent-hive-facts` — runs the two readers of
# `/etc/hyperhive-hive-facts` in ../agent-modules/hive-facts.nix: the systemd
# environment generator and the login-shell snippet `/etc/set-environment`
# sources. The cases are in ./agent-hive-facts.sh.
#
# What runs is the rendered generator and snippet. The one edit is the facts
# file's path, moved under the build directory, and the snippet's reference to
# the generator, pointed at that edited copy.
{
pkgs,
lib,
self,
nixosSystem,
}:
let
inherit
(import ../module-eval/lib.nix {
inherit
pkgs
lib
self
nixosSystem
;
})
agentWith
;
machine = agentWith { services.hyperhive.agent.user.name = "a1"; };
in
pkgs.runCommand "hyperhive-script-test-agent-hive-facts"
{
GENERATOR =
machine.environment.etc."systemd/system-environment-generators/50-hyperhive-hive-facts".source;
PROFILE = machine.environment.etc."hyperhive/hive-facts.sh".source;
EXTRA_INIT = machine.environment.extraInit;
SHELLS = "${pkgs.dash}/bin/dash ${pkgs.bash}/bin/sh";
}
''
${pkgs.bash}/bin/bash ${./agent-hive-facts.sh}
touch "$out"
''

View file

@ -0,0 +1,71 @@
# Cases for ./agent-hive-facts.nix. Each case writes a facts file (or none),
# runs the generator with a clean environment, and compares its whole output;
# then sources the login-shell snippet under each POSIX shell and compares what
# a child process sees.
set -uo pipefail
failures=0
count=0
fail() {
echo "FAILED: $case: $*" >&2
failures=$((failures + 1))
}
dir=$(mktemp -d)
facts=$dir/facts
sed "s|/etc/hyperhive-hive-facts|$facts|g" "$GENERATOR" >"$dir/generator"
chmod +x "$dir/generator"
sed "s|$GENERATOR|$dir/generator|g" "$PROFILE" >"$dir/profile"
envbin=$(command -v env)
case="setup"
if grep -q '/etc/hyperhive-hive-facts' "$dir/generator"; then fail "the generator still names /etc after the rewrite"; fi
if ! grep -qF "$facts" "$dir/generator"; then fail "the rewrite did not reach the generator's read"; fi
if ! grep -qF "$dir/generator" "$dir/profile"; then fail "the snippet does not call the rewritten generator"; fi
if ! printf '%s\n' "$EXTRA_INIT" | grep -qxF '. /etc/hyperhive/hive-facts.sh'; then
fail "/etc/set-environment does not source the snippet"
fi
# check <description> <expected generator output>
check() {
case=$1
count=$((count + 1))
local want=$2 got
got=$(env -i "$dir/generator")
if [ "$got" != "$want" ]; then
fail "generator printed:"$'\n'"$got"$'\n'"wanted:"$'\n'"$want"
fi
local sh
for sh in $SHELLS; do
# Only what the snippet exported reaches the child's environment. No PATH:
# the snippet must not need one.
got=$(env -i "$sh" -c ". '$dir/profile'; exec '$envbin'" | grep -E '^(HYPERHIVE_|CLAUDE_|LD_PRELOAD=|__hyperhive)' | sort)
if [ "$got" != "$(printf '%s\n' "$want" | sort)" ]; then
fail "$sh: a child of the sourcing shell saw:"$'\n'"$got"
fi
done
}
printf 'HYPERHIVE_HIVE_NAME=pr1ma\nHYPERHIVE_HIVE_DOMAIN=pr1ma.example.test\n' >"$facts"
check "both facts" "HYPERHIVE_HIVE_NAME=pr1ma
HYPERHIVE_HIVE_DOMAIN=pr1ma.example.test
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=pr1ma-a1"
printf 'LD_PRELOAD=/x\nHYPERHIVE_HIVE_NAME=pr1ma\nPATH=/evil\n' >"$facts"
check "a planted key is dropped" "HYPERHIVE_HIVE_NAME=pr1ma
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=pr1ma-a1"
printf 'HYPERHIVE_HIVE_DOMAIN=pr1ma.example.test\nHYPERHIVE_HIVE_NAME=pr1ma' >"$facts"
check "a last line without a newline is kept" "HYPERHIVE_HIVE_DOMAIN=pr1ma.example.test
HYPERHIVE_HIVE_NAME=pr1ma
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=pr1ma-a1"
: >"$facts"
check "an empty file (no facts)" "CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=unknown-a1"
rm -f "$facts"
check "no file" "CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=unknown-a1"
echo "$count cases, $failures failed"
[ "$failures" -eq 0 ]