From c8df666bf3ef76c96c3804bf91968a30f165850d Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 12 Oct 2026 05:04:06 +0200 Subject: [PATCH] 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. --- docs/process/conventions.md | 9 ++-- hive-agent/src/identity.rs | 6 +-- nix/agent-modules/hive-facts.nix | 67 +++++++++++++++++-------- nix/checks.nix | 6 +++ nix/script-tests/agent-hive-facts.nix | 41 ++++++++++++++++ nix/script-tests/agent-hive-facts.sh | 71 +++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 28 deletions(-) create mode 100644 nix/script-tests/agent-hive-facts.nix create mode 100644 nix/script-tests/agent-hive-facts.sh diff --git a/docs/process/conventions.md b/docs/process/conventions.md index e6d817e4..de91633f 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -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 agent's build. `hive-priv` writes them to `/etc/hyperhive-hive-facts` before -every container start, and the agent's environment generator -(`nix/agent-modules/hive-facts.nix`) passes them to every unit. Neither is -part of an agent's closure, and an agent started on another hive reports -that hive. +every container start. The agent's environment generator +(`nix/agent-modules/hive-facts.nix`) passes them to every unit, including +`hivectl choom` sessions, and `/etc/set-environment` exports them in login +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 `HYPERHIVE_HIVE_DOMAIN`: the domain may carry the hive name as diff --git a/hive-agent/src/identity.rs b/hive-agent/src/identity.rs index 878b4319..21d7d702 100644 --- a/hive-agent/src/identity.rs +++ b/hive-agent/src/identity.rs @@ -31,9 +31,9 @@ pub fn hive_domain() -> Option { /// Human display name of this hive (e.g. `pr1ma`). Distinct from /// [`hive_domain`] — the domain is the machine-readable DNS address; /// this is the prose label humans use in conversation. Returns None -/// when the host-side `services.hyperhive.hiveName` option is unset, -/// in which case callers fall back to the domain or the short label -/// at their discretion. +/// when `/etc/hyperhive-hive-facts` carries no name (the host's +/// `services.hyperhive.hiveName` is unset), in which case callers fall +/// back to the domain or the short label at their discretion. #[must_use] pub fn hive_name() -> Option { non_empty_env("HYPERHIVE_HIVE_NAME") diff --git a/nix/agent-modules/hive-facts.nix b/nix/agent-modules/hive-facts.nix index 345c4a7a..c5e33e06 100644 --- a/nix/agent-modules/hive-facts.nix +++ b/nix/agent-modules/hive-facts.nix @@ -3,9 +3,17 @@ # # hive-priv writes `/etc/hyperhive-hive-facts` into the container before every # start (`HYPERHIVE_HIVE_NAME`, `HYPERHIVE_HIVE_DOMAIN`, one `KEY=value` line -# each). This environment generator hands those lines to the container's -# systemd, which passes them to every unit it starts and to `machinectl shell` -# sessions; a unit's own `Environment=` still overrides them. +# each). Two readers hand those lines on: +# +# - 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, lib, @@ -14,24 +22,41 @@ }: let 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 `-`. + 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 { - # Shell builtins only: nothing is assumed on PATH this early. Only the two - # known keys are passed on. - environment.etc."systemd/system-environment-generators/50-hyperhive-hive-facts".source = - 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 `-`. - printf 'CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX=%s-%s\n' "$hive" ${lib.escapeShellArg userName} - ''; + environment.etc."systemd/system-environment-generators/50-hyperhive-hive-facts".source = generator; + environment.etc."hyperhive/hive-facts.sh".source = profile; + environment.extraInit = ". /etc/hyperhive/hive-facts.sh"; } diff --git a/nix/checks.nix b/nix/checks.nix index 01b56173..04c3d8bc 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -132,6 +132,12 @@ in inherit pkgs self nixosSystem; 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 { inherit pkgs self nixosSystem; inherit (pkgs) lib; diff --git a/nix/script-tests/agent-hive-facts.nix b/nix/script-tests/agent-hive-facts.nix new file mode 100644 index 00000000..82c1a2c1 --- /dev/null +++ b/nix/script-tests/agent-hive-facts.nix @@ -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" + '' diff --git a/nix/script-tests/agent-hive-facts.sh b/nix/script-tests/agent-hive-facts.sh new file mode 100644 index 00000000..cddb1bf7 --- /dev/null +++ b/nix/script-tests/agent-hive-facts.sh @@ -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 +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 ]