Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/backend-env.nix

212 lines
8.1 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 leaf's create grant cannot constrain content, so while the leaf is empty
# the agent's own process can also create it with any text, and the store's
# value then wins over the file. That is no wider than the file the agent
# already owns: the container activation's `heal_own_dir` (`./user.nix`)
# re-owns the harness dir to the agent recursively at every boot, so the agent
# already controls the legacy file.
#
# 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" ];
};
};
}