211 lines
8 KiB
Nix
211 lines
8 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: `./user.nix`'s `heal_own_dir` chowns the harness dir to the
|
|
# agent at every activation, a gap the issue tracker has an open bug for.
|
|
#
|
|
# 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" ];
|
|
};
|
|
};
|
|
}
|