Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/backend-env.nix
atlas 75e9e205e3 fetch the agent's backend env file from the store, seeded from the existing file
the agent's own root unit reads swarm/agents/<agent>/backend-env into /run and
creates it from backendEnvironmentFile with -cas=0 when the store holds nothing.
the shared agent policy gains create (not update) on that one leaf. the harness
and the subagent daemon load the old file first and the fetched one after it.
2026-10-28 01:24:29 +01:00

205 lines
7.7 KiB
Nix

# This agent's backend environment (`ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`,
# an ACP provider key), fetched from the swarm secret store by the agent
# itself into the file the harness and the subagent daemon load as
# `EnvironmentFile=`.
#
# The store holds the file's text at `swarm/agents/<agent>/backend-env`, field
# `env` (`swarm_secret_client::backend_env`). Each run does one of three things:
#
# - the store has a value: it is written to `/run/hive-agent-backend-env/env`;
# - the store has none and `backendEnvironmentFile` is a non-empty file: the
# file's exact bytes are created at the leaf with `-cas=0`, then read back
# and written the same way. A run that loses the race to another writer, or
# whose create the store's policy refuses, uses whatever the read-back finds;
# - neither: nothing is written, which is an agent with no backend file.
#
# It never rotates a value and never touches `backendEnvironmentFile`. That
# file stays an `EnvironmentFile=` of its own, listed before the fetched one:
# systemd lets a later file override an earlier one, so the store's value wins
# when there is one, and the file carries the agent when the store is down.
#
# The unit runs as root because the file is root-owned `0600` where an operator
# placed it. Root can still read the agent's `0400` store identity.
{
pkgs,
lib,
config,
...
}:
let
agentCfg = config.services.hyperhive.agent;
cfg = agentCfg.bao;
agentName = agentCfg.user.name;
# The server CA id ./bao.nix loads.
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-backend-env";
# The nix half of `swarm_secret_client::backend_env::env_path` plus
# `path::MOUNT`.
leaf = "secret/swarm/agents/${agentName}/backend-env";
runtimeDir = unitName;
envFile = "/run/${runtimeDir}/env";
stagedFile = "/run/${runtimeDir}/env.new";
errFile = "/run/${runtimeDir}/bao.err";
# An OAuth claude agent has no backend credentials to fetch.
fetches = cfg.addr != null && (agentCfg.useApiKey || agentCfg.runtime == "acp");
earlier = agentCfg.backendEnvironmentFile;
storeRetry = import ../host-modules/lib/store-retry.nix { };
in
{
options.services.hyperhive.agent.backendEnvironment.environmentFiles = lib.mkOption {
type = lib.types.listOf lib.types.str;
readOnly = true;
default = lib.optional (earlier != null) "-${earlier}" ++ lib.optional fetches "-${envFile}";
description = ''
The `EnvironmentFile=` entries that carry this agent's backend
environment, in load order: `backendEnvironmentFile` when set, then the
file `${unitName}.service` keeps when the agent has a store and uses an
API key. Read-only: the harness and the subagent daemon take it as it is.
'';
};
config = lib.mkIf fetches {
systemd.services.${unitName} = {
description = "fetch this agent's backend environment from the secret store";
after = [
"network.target"
"hive-agent-bao-identity.service"
];
wantedBy = [ "multi-user.target" ];
path = [
pkgs.openbao
pkgs.coreutils
pkgs.diffutils
pkgs.gnugrep
];
# ../host-modules/lib/store-retry.nix.
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
# Not `RemainAfterExit`, so the timer below can start it again.
# `RuntimeDirectoryPreserve` keeps the file between runs and after the
# last one: the consumers read it at their own start.
RemainAfterExit = false;
TimeoutStartSec = 60;
RuntimeDirectory = runtimeDir;
RuntimeDirectoryMode = "0700";
RuntimeDirectoryPreserve = "yes";
# `0600`, the mode of the file an operator placed.
UMask = "0077";
LoadCredential = [ serverCaCredential ];
};
environment = {
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = cfg.clientCertFile;
BAO_CLIENT_KEY = cfg.clientKeyFile;
};
script = ''
set -euo pipefail
# No identity delivered: ./bao.nix's check reports that.
for f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
if [ ! -s "$f" ]; then
echo "this agent has no store identity, so it cannot fetch its backend environment." >&2
exit 0
fi
done
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
fi
err=${lib.escapeShellArg errFile}
staged=${lib.escapeShellArg stagedFile}
live=${lib.escapeShellArg envFile}
earlier=${lib.escapeShellArg (if earlier == null then "" else earlier)}
trap 'rm -f "$err" "$staged"' EXIT
# Bounded, so a store that drops packets cannot hold the unit past its
# start timeout.
store() { timeout 15 bao "$@"; }
if ! BAO_TOKEN="$(store login -method=cert -token-only 2>"$err")"; then
echo "the swarm secret store at $BAO_ADDR did not accept this agent's certificate login:" >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 1
fi
export BAO_TOKEN
# Sets READ to have, missing or unusable, and leaves the value in
# $staged. `kv get` exits 2 with "No value found at <path>" for a leaf
# nobody wrote, and 2 again for a refused read, so the message tells
# them apart. The value goes from the store's answer into a file and
# is never in a variable or an argument.
read_leaf() {
rm -f "$staged"
if store kv get -field=env ${lib.escapeShellArg leaf} >"$staged" 2>"$err"; then
if [ -s "$staged" ]; then READ=have; else READ=unusable; fi
elif grep -q '^No value found at ' "$err"; then
READ=missing
elif grep -q '^Field "env" not present' "$err"; then
READ=unusable
else
echo "could not read ${leaf}:" >&2
cat "$err" >&2
exit 1
fi
}
read_leaf
if [ "$READ" = missing ]; then
if [ -n "$earlier" ] && [ -s "$earlier" ]; then
echo "${leaf} holds nothing; creating it from $earlier."
if ! store kv put -cas=0 ${lib.escapeShellArg leaf} env=@"$earlier" >/dev/null 2>"$err"; then
echo "could not create ${leaf}; reading it back in case another run did:" >&2
cat "$err" >&2
fi
read_leaf
else
echo "${leaf} holds nothing and there is no file to create it from."
exit 0
fi
fi
if [ "$READ" != have ]; then
echo "${leaf} holds no usable env; the file in place, if any, is used." >&2
exit 0
fi
if cmp -s "$staged" "$live"; then
echo "this agent's backend environment at ${leaf} is unchanged."
exit 0
fi
mv -f "$staged" "$live"
echo "fetched this agent's backend environment from ${leaf}."
'';
};
# The same cadence as ./github-token.nix.
systemd.timers.${unitName} = {
description = "re-fetch this agent's backend environment from the secret store";
wantedBy = [ "timers.target" ];
timerConfig = {
OnUnitInactiveSec = "2min";
RandomizedDelaySec = "20s";
};
};
# `wants`, not `requires`: a store outage must not stop the harness, whose
# `backendEnvironmentFile` still loads. `after` waits for one attempt.
systemd.services.hive-agent = {
wants = [ "${unitName}.service" ];
after = [ "${unitName}.service" ];
};
systemd.services.hive-subagent-daemon = {
wants = [ "${unitName}.service" ];
after = [ "${unitName}.service" ];
};
};
}