Compare commits
15 changed files with 31 additions and 953 deletions
|
|
@ -32,7 +32,6 @@ the rest of the cell says how.
|
|||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or no longer matching the forge | ✅ every 10 minutes |
|
||||
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when you link an external forge account in the swarm UI | the agent | ❌ your token; delete it and link the label again | ✅ every 2 minutes |
|
||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when you link a GitHub account in the swarm UI | the agent | ❌ your token; delete it and link again | ✅ every 2 minutes |
|
||||
| `swarm/agents/<agent>/backend-env` | `hive-agent-backend-env`, in the agent's container, from the agent's `backendEnvironmentFile` when the leaf holds nothing (field `env`, the file's exact text); or you, in the store's UI | the agent | ❌ nothing replaces it; the file stays as it was | ❌ the file in `/run` refreshes every 2 minutes, and the harness and the subagent daemon load it at their next start |
|
||||
| `swarm/agents/<agent>/claude-credentials` | `swarm-claude-login`, when you log the agent in from its card; one version per login | the agent, while it waits for a login | ❌ log in again | ✅ every 30 seconds while waiting |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or rejected by the homeserver | ✅ the hive re-reads on every sweep |
|
||||
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or no longer matching the forge | ✅ read on every forge call |
|
||||
|
|
@ -127,7 +126,6 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials.
|
|||
| the hive collector's copy of its hive's OIDC secret | `swarm-bao-hive-otel-oidc.service` reads it out of the swarm secret store under the hive's own leaf, **on every hive that runs the collector and holds that leaf**; the leaf is the source | `/run/hive-otel-oidc/hive-<name>.secret` on the hive's host tmpfs, 0400 root, loaded by `LoadCredential` — never on persistent disk, so a reboot fetches it again | same unit, same path, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`). Naming a secret file with `otel.clientSecretFile` instead is legacy for a host without a leaf: the build warns, and the fix is to place the leaf from `/var/lib/swarm-bao-pki/hives/<hive>/` and drop the setting |
|
||||
| the swarm controller's forge token | the forge's `forgejo-swarm-controller-account`, in-container, once | the swarm secret store at `swarm/controller/swarm-controller/forge/token`, written by the mint under the forge publisher's leaf, which the forge container loads as a credential; the controller holds it in memory only. `/var/lib/forgejo/swarm-controller-token-name` in the forge container holds its name, the minter's record that it has minted | nothing to place beyond the controller's store leaf. A forge host without the store needs the publisher's leaf placed (`deploy.bao.forgePublisherClientCertFile`) |
|
||||
| the swarm controller's Grafana token | `swarm-bao-grafana-controller-token.service`, **on the host that runs Grafana**, as Grafana's built-in admin over its socket: it creates the `swarm-controller` service account as a Viewer (and sets it back to Viewer on every run), mints a token for it and writes it to the swarm secret store at `swarm/controller/swarm-controller/grafana/token` under Grafana's leaf. Re-run every ten minutes: it keeps a stored token Grafana accepts, and replaces one Grafana refuses with 401 (or a missing one), deleting every other token of that account first | the swarm secret store only; the controller reads it for `GET /api/alerts` and holds it in memory, re-reading the store once when Grafana answers 401 | nothing to place: Grafana's leaf is already required to run Grafana. Until the unit has run, `/api/alerts` answers 503 naming the empty path |
|
||||
| an agent's API-key environment (`ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, an ACP provider key) | `hive-agent-backend-env.service`, in the agent's container, on an agent with a store that uses `useApiKey` or the `acp` runtime. When the store holds nothing at `swarm/agents/<agent>/backend-env` and `backendEnvironmentFile` is a non-empty file, it creates the leaf with the file's exact bytes and `-cas=0`, under the agent's own leaf, which may create that path and not update it. It never rotates a value and never rewrites or deletes the file. You can also enter the value in the store's UI | the swarm secret store at that path; the unit writes it to `/run/hive-agent-backend-env/env` (tmpfs, `0600`, root), and `hive-agent` and `hive-subagent-daemon` load that after `backendEnvironmentFile` as `EnvironmentFile=` | the same unit, same path. A variable in the store wins over the same one in the file. With the store down at a start, the file alone carries the agent, as it did before the store held a copy; with an older hive build the agent ignores the leaf and keeps reading the file |
|
||||
| an agent's claude login (`.credentials.json`) | `swarm-claude-login.service`, in its container, when an operator completes `claude auth login` from the agent's card in the swarm UI; it stores the file as a new version at `swarm/agents/<agent>/claude-credentials` under its own leaf, which may create and update that path and read nothing under an agent | the swarm secret store at that path; the agent's `hive-agent` reads it under the agent's own leaf every 30 seconds while it waits for a login and writes it to `~/.claude/.credentials.json` | the same read, co-located or not; nothing for an operator to place beyond the agent's store leaf. When the store's copy and the agent's differ the KV version decides: the agent installs only a version newer than the last it installed (`claude-credentials-version` under the harness dir), because claude rotates the refresh token in its own copy and the stored one goes stale. The agent ignores a stale or equal version, and a relogin writes a newer one — [claude-login.md](claude-login.md) |
|
||||
| the queue responder's copy of its OIDC secret | the responder reads it out of the swarm secret store itself as it starts, under its own leaf, and again when authelia refuses it | the daemon's memory only; never on disk | same read, wherever authelia runs. Nothing for an operator to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
| the queue container's journal forwarder's copy of the queue's OIDC secret | `swarm-journal-forwarder-oidc.service` inside the `swarm-nats` container reads it out of the swarm secret store under the responder's store leaf, and again every 10 minutes | `/run/swarm-journal-forwarder-oidc/client-secret` on the container's tmpfs, root `0400`, never on persistent disk; `LoadCredential` hands it to the collector | same unit, same path. After a reboot the collector restarts until the file exists, then resumes from its saved journal cursor. Nothing for an operator to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
|
|
|
|||
|
|
@ -376,7 +376,7 @@ store, and `swarm-controller`, already logged in under its own host leaf, asks
|
|||
that mount's one role for a `hive-agent-<agent>` client certificate at agent
|
||||
creation. Host roles never pin that CA and agent roles pin only it, so an
|
||||
agent's certificate opens that agent's own `swarm/agents/<agent>/*` and
|
||||
nothing else: `read` on the values there and `list` on their names, and `create` (never `update`) on one leaf, `backend-env`, which the agent seeds from its environment file.
|
||||
nothing else: `read` on the values there and `list` on their names.
|
||||
|
||||
All agents share one policy, `hive-agent`, which the agent's cert-auth role
|
||||
attaches and no other. It states the rule with `{{identity.entity.name}}`
|
||||
|
|
|
|||
|
|
@ -277,18 +277,6 @@ is a no-op without the other:
|
|||
(`hive-subagent-daemon`) loads the same file, so the agent's claude
|
||||
subagents run on the same key.
|
||||
|
||||
On an agent with a secret store (`services.hyperhive.agent.bao.addr`) that uses
|
||||
`useApiKey` or the `acp` runtime, `hive-agent-backend-env.service` also keeps the
|
||||
file's text in the store at `swarm/agents/<agent>/backend-env`, field `env`, and
|
||||
writes it to `/run/hive-agent-backend-env/env` every 2 minutes. When the store
|
||||
holds nothing there, the unit creates the leaf from `backendEnvironmentFile`,
|
||||
byte for byte and once; it never rotates a value and never rewrites or deletes
|
||||
the file. The harness and the subagent daemon load `backendEnvironmentFile`
|
||||
first and the `/run` file after it, so a variable the store holds wins, and with
|
||||
the store down at a start the file alone carries the agent. Both read it at
|
||||
their own start, so a changed value reaches a running agent at its next
|
||||
restart.
|
||||
|
||||
Provision the file once, out of band (never through nix — an API key in
|
||||
the store is world-readable and travels with the flake closure):
|
||||
|
||||
|
|
@ -305,11 +293,7 @@ The file lives in the agent's bind-mounted **harness** dir, not `state/`
|
|||
— `harness/` survives container rebuilds exactly like `state/` does, but
|
||||
(unlike `state/`) is never bind-mounted into a parent agent's container
|
||||
(see `docs/agent-lifecycle/persistence.md`'s "Parent access to child state"), so an API
|
||||
key placed here is reachable by nothing but this agent and the host. The
|
||||
file doesn't stay root-owned, though: the container's activation chowns the
|
||||
whole harness dir to the agent, so after the next boot the file is agent-owned
|
||||
whatever owner you installed it with, and the agent can rewrite it. That holds
|
||||
until the swarm secret store replaces the file.
|
||||
key placed here is reachable by nothing but this agent and the host.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
|
|
|
|||
|
|
@ -192,10 +192,7 @@ in
|
|||
read-write for recovery — see `docs/agent-lifecycle/persistence.md`'s "Cross-agent access to
|
||||
state"), so this credential is reachable by nothing but this agent
|
||||
and the host. Permissions should be `0600`, owned by the agent's
|
||||
unix user. Whatever owner it was installed with, the file becomes
|
||||
agent-owned at the container's next boot, so the agent can rewrite it;
|
||||
it is no boundary the agent can't cross until the swarm secret store
|
||||
replaces the file. Loaded with a leading `-` (optional `EnvironmentFile`),
|
||||
unix user. Loaded with a leading `-` (optional `EnvironmentFile`),
|
||||
so a path that doesn't exist yet — an operator setting this option
|
||||
before creating the file, or a fresh host rebuild before state is
|
||||
restored — makes systemd skip it rather than refuse to start the
|
||||
|
|
@ -204,15 +201,6 @@ in
|
|||
and on an agent with `useApiKey` set, so its subagents authenticate
|
||||
the same way the harness does.
|
||||
|
||||
On an agent with a store (`services.hyperhive.agent.bao.addr`) that uses
|
||||
`useApiKey` or the `acp` runtime, `hive-agent-backend-env.service` also
|
||||
keeps this file's text at `swarm/agents/<agent>/backend-env`, field `env`.
|
||||
A leaf nobody has written is created from this file once, with the same
|
||||
bytes, and this file is never rewritten or deleted. The harness and the
|
||||
subagent daemon load this file first and the store's copy after it, so
|
||||
a variable the store holds wins and this file carries the agent while
|
||||
the store is down. Both read at their own start.
|
||||
|
||||
On an ACP agent using the `opencode` preset, a provider key this file
|
||||
leaves unset is read from the swarm secret store at
|
||||
`swarm/agents/<agent>/acp-provider`, field `api_key`, if one is stored.
|
||||
|
|
@ -564,11 +552,10 @@ in
|
|||
User = userName;
|
||||
Group = userName;
|
||||
}
|
||||
// lib.optionalAttrs (config.services.hyperhive.agent.backendEnvironment.environmentFiles != [ ]) {
|
||||
// lib.optionalAttrs (config.services.hyperhive.agent.backendEnvironmentFile != null) {
|
||||
# See `services.hyperhive.agent.backendEnvironmentFile`'s own description for
|
||||
# the file shape and the leading-`-` rationale; ./backend-env.nix for
|
||||
# the second entry.
|
||||
EnvironmentFile = config.services.hyperhive.agent.backendEnvironment.environmentFiles;
|
||||
# the file shape and the leading-`-` rationale.
|
||||
EnvironmentFile = "-${config.services.hyperhive.agent.backendEnvironmentFile}";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,212 +0,0 @@
|
|||
# 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" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -66,7 +66,6 @@ in
|
|||
imports = [
|
||||
./agent-service.nix
|
||||
./bao.nix
|
||||
./backend-env.nix
|
||||
./bash-env.nix
|
||||
./claude-settings.nix
|
||||
./dashboard-links.nix
|
||||
|
|
|
|||
|
|
@ -479,14 +479,14 @@ in
|
|||
lib.optionalAttrs
|
||||
(
|
||||
(config.services.hyperhive.agent.runtime == "acp" || config.services.hyperhive.agent.useApiKey)
|
||||
&& config.services.hyperhive.agent.backendEnvironment.environmentFiles != [ ]
|
||||
&& config.services.hyperhive.agent.backendEnvironmentFile != null
|
||||
)
|
||||
{
|
||||
# A subagent authenticates with the same credentials as the
|
||||
# harness: an ACP subagent to its provider, a claude subagent of
|
||||
# an api-key agent with `ANTHROPIC_API_KEY`, as it has no OAuth
|
||||
# session to fall back on.
|
||||
EnvironmentFile = config.services.hyperhive.agent.backendEnvironment.environmentFiles;
|
||||
EnvironmentFile = "-${config.services.hyperhive.agent.backendEnvironmentFile}";
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -184,12 +184,6 @@ in
|
|||
inherit pkgs self nixosSystem;
|
||||
inherit (pkgs) lib;
|
||||
};
|
||||
# Runs ./agent-modules/backend-env.nix's fetch-and-seed unit against a stub
|
||||
# `bao` that keeps its leaves in files.
|
||||
script-test-agent-backend-env = import ./script-tests/agent-backend-env.nix {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -61,14 +61,6 @@ let
|
|||
};
|
||||
opencode = opencodeWith { };
|
||||
opencodeBao = opencodeWith { bao.addr = "https://bao.t.local:8200"; };
|
||||
claudeApiKeyBao = agentWith {
|
||||
services.hyperhive.agent = {
|
||||
runtime = "claude";
|
||||
useApiKey = true;
|
||||
backendEnvironmentFile = backendEnv;
|
||||
bao.addr = "https://bao.t.local:8200";
|
||||
};
|
||||
};
|
||||
claudeBao = agentWith {
|
||||
services.hyperhive.agent = {
|
||||
runtime = "claude";
|
||||
|
|
@ -141,13 +133,13 @@ let
|
|||
}
|
||||
{
|
||||
name = "an ACP agent's subagent daemon loads the backend credentials";
|
||||
ok = (subagent acp).serviceConfig.EnvironmentFile or null == [ "-${backendEnv}" ];
|
||||
ok = (subagent acp).serviceConfig.EnvironmentFile or null == "-${backendEnv}";
|
||||
}
|
||||
{
|
||||
# The nested `claude` inherits the daemon's environment, so this file
|
||||
# is where its `ANTHROPIC_API_KEY` comes from.
|
||||
name = "an api-key claude agent's subagent daemon loads the backend credentials";
|
||||
ok = (subagent claudeApiKey).serviceConfig.EnvironmentFile or null == [ "-${backendEnv}" ];
|
||||
ok = (subagent claudeApiKey).serviceConfig.EnvironmentFile or null == "-${backendEnv}";
|
||||
}
|
||||
{
|
||||
# Unset is what `RuntimeSpec` reads as claude, and a claude subagent
|
||||
|
|
@ -157,79 +149,6 @@ let
|
|||
lib.all (var: (subagent claude).environment.${var} or null == null) runtimeVars
|
||||
&& !((subagent claude).serviceConfig ? EnvironmentFile);
|
||||
}
|
||||
{
|
||||
# Later files override earlier ones, so the operator's file carries the
|
||||
# agent when the store is down and the store's copy wins when it is up.
|
||||
name = "an api-key agent with a store loads its backend file, then the store's copy, in the harness and the subagent daemon";
|
||||
ok =
|
||||
let
|
||||
want = [
|
||||
"-${backendEnv}"
|
||||
"-/run/hive-agent-backend-env/env"
|
||||
];
|
||||
in
|
||||
(harness claudeApiKeyBao).serviceConfig.EnvironmentFile == want
|
||||
&& (subagent claudeApiKeyBao).serviceConfig.EnvironmentFile == want
|
||||
&& (harness acpBao).serviceConfig.EnvironmentFile == [ "-/run/hive-agent-backend-env/env" ]
|
||||
&& (subagent acpBao).serviceConfig.EnvironmentFile == [ "-/run/hive-agent-backend-env/env" ];
|
||||
}
|
||||
{
|
||||
# The case above is the presence control: the same lookup finds the
|
||||
# fetched file where it is loaded.
|
||||
name = "an agent with no store, or a claude agent on OAuth, loads no fetched backend file";
|
||||
ok =
|
||||
let
|
||||
fetched =
|
||||
machine:
|
||||
lib.elem "-/run/hive-agent-backend-env/env" (
|
||||
lib.toList (harness machine).serviceConfig.EnvironmentFile or [ ]
|
||||
);
|
||||
in
|
||||
!(fetched claudeApiKey)
|
||||
&& !(fetched acp)
|
||||
&& !(fetched opencode)
|
||||
&& !(fetched claudeBao)
|
||||
&& !(claudeBao.systemd.services ? hive-agent-backend-env)
|
||||
&& !(claudeApiKey.systemd.services ? hive-agent-backend-env)
|
||||
&& claudeApiKeyBao.systemd.services ? hive-agent-backend-env
|
||||
&& claudeApiKeyBao.systemd.timers ? hive-agent-backend-env;
|
||||
}
|
||||
{
|
||||
name = "the backend environment fetch runs as root, keeps its file, and the consumers want it without requiring it";
|
||||
ok =
|
||||
let
|
||||
u = claudeApiKeyBao.systemd.services.hive-agent-backend-env;
|
||||
orders =
|
||||
unit:
|
||||
lib.elem "hive-agent-backend-env.service" unit.wants
|
||||
&& lib.elem "hive-agent-backend-env.service" unit.after
|
||||
&& !(lib.elem "hive-agent-backend-env.service" unit.requires);
|
||||
in
|
||||
!(u.serviceConfig ? User)
|
||||
&& u.serviceConfig.UMask == "0077"
|
||||
&& u.serviceConfig.RuntimeDirectoryPreserve == "yes"
|
||||
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
|
||||
&& orders (harness claudeApiKeyBao)
|
||||
&& orders (subagent claudeApiKeyBao)
|
||||
&& claudeApiKeyBao.systemd.timers.hive-agent-backend-env.timerConfig.OnUnitInactiveSec == "2min";
|
||||
}
|
||||
{
|
||||
# The nix half of `swarm_secret_client::backend_env::env_path`, and the
|
||||
# three promises the unit makes about the operator's file.
|
||||
name = "the backend environment fetch reads the agent's own leaf, seeds it create-only from the operator's file and never writes that file";
|
||||
ok =
|
||||
let
|
||||
s = claudeApiKeyBao.systemd.services.hive-agent-backend-env.script;
|
||||
in
|
||||
lib.hasInfix "kv get -field=env secret/swarm/agents/a1/backend-env" s
|
||||
&& lib.hasInfix "kv put -cas=0 secret/swarm/agents/a1/backend-env env=@\"$earlier\"" s
|
||||
&& lib.hasInfix "earlier=${backendEnv}" s
|
||||
&& !(lib.hasInfix "kv put secret" s)
|
||||
&& !(lib.hasInfix "> \"$earlier\"" s)
|
||||
&& !(lib.hasInfix ">\"$earlier\"" s)
|
||||
&& !(lib.hasInfix "rm -f \"$earlier\"" s)
|
||||
&& !(lib.hasInfix "mv -f \"$staged\" \"$earlier\"" s);
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "agent-runtime" cases
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# The stub `bao` for ./agent-backend-env.nix. Leaves are files under
|
||||
# `$FAKE_BAO_KV`, one per path. Every call is logged, a `kv` call without the
|
||||
# token `login` printed fails, and the answers are the ones openbao gives:
|
||||
# `kv get` of a leaf nobody wrote exits 2 with "No value found at <path>", and
|
||||
# a policy granting `create` and `read` only refuses a second `-cas=0` write
|
||||
# with a 403.
|
||||
#
|
||||
# `FAKE_BAO_LOGIN_RC` makes the login fail, `FAKE_BAO_DENY_READ` and
|
||||
# `FAKE_BAO_DENY_WRITE` answer 403 as a policy that grants neither does, and
|
||||
# `FAKE_BAO_RACE` has another writer create the leaf with that text between
|
||||
# this run's first read and its write.
|
||||
echo "$*" >>"$FAKE_BAO_LOG"
|
||||
case "$1 $2" in
|
||||
"login -method=cert")
|
||||
[ "${FAKE_BAO_LOGIN_RC-0}" = 0 ] || {
|
||||
echo 'Error authenticating: Put "https://bao.t.local:8200/v1/auth/cert/login": dial tcp: lookup bao.t.local: no such host' >&2
|
||||
exit 2
|
||||
}
|
||||
printf 's.fake'
|
||||
;;
|
||||
"kv get" | "kv put")
|
||||
[ "${BAO_TOKEN-}" = s.fake ] || {
|
||||
echo "fake bao: kv called without the login's token" >&2
|
||||
exit 97
|
||||
}
|
||||
;;&
|
||||
"kv get")
|
||||
[ "$3" = -field=env ] || {
|
||||
echo "fake bao: get without -field=env: $*" >&2
|
||||
exit 95
|
||||
}
|
||||
leaf=$FAKE_BAO_KV/$(printf '%s' "$4" | tr / _)
|
||||
[ -z "${FAKE_BAO_DENY_READ-}" ] || {
|
||||
printf 'Error reading %s: Error making API request.\n\nURL: GET https://bao.t.local:8200/v1/%s\nCode: 403. Errors:\n\n* 1 error occurred:\n\t* permission denied\n' "$4" "$4" >&2
|
||||
exit 2
|
||||
}
|
||||
if [ -n "${FAKE_BAO_RACE-}" ] && [ ! -e "$FAKE_BAO_KV/.raced" ]; then
|
||||
: >"$FAKE_BAO_KV/.raced"
|
||||
printf '%s' "$FAKE_BAO_RACE" >"$leaf"
|
||||
echo "No value found at ${4/secret\//secret/data/}" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -e "$leaf" ]; then
|
||||
echo "No value found at ${4/secret\//secret/data/}" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$(cat "$leaf.field" 2>/dev/null)" = other ]; then
|
||||
echo 'Field "env" not present in secret' >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$leaf"
|
||||
;;
|
||||
"kv put")
|
||||
[ "$3" = -cas=0 ] || {
|
||||
echo "fake bao: put without -cas=0: $*" >&2
|
||||
exit 96
|
||||
}
|
||||
leaf=$FAKE_BAO_KV/$(printf '%s' "$4" | tr / _)
|
||||
case "$5" in
|
||||
env=@*) ;;
|
||||
*)
|
||||
echo "fake bao: put of something other than env=@file: $*" >&2
|
||||
exit 94
|
||||
;;
|
||||
esac
|
||||
if [ -n "${FAKE_BAO_DENY_WRITE-}" ] || [ -e "$leaf" ]; then
|
||||
printf 'Error writing data to %s: Error making API request.\n\nURL: PUT https://bao.t.local:8200/v1/%s\nCode: 403. Errors:\n\n* 1 error occurred:\n\t* permission denied\n' "$4" "$4" >&2
|
||||
exit 2
|
||||
fi
|
||||
cp "${5#env=@}" "$leaf"
|
||||
;;
|
||||
*)
|
||||
echo "fake bao: unexpected call: $*" >&2
|
||||
exit 98
|
||||
;;
|
||||
esac
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
# `checks.script-test-agent-backend-env` — runs ../agent-modules/backend-env.nix's
|
||||
# unit against a stub `bao` that keeps its leaves in files. The cases are in
|
||||
# ./agent-backend-env.sh and the stub is ./agent-backend-env-fake-bao.sh.
|
||||
#
|
||||
# What runs is the unit's rendered `ExecStart`, on the unit's own `PATH` with
|
||||
# the stub in front. The edits are the unit's `/run/<unit>/` prefix and the
|
||||
# operator's file, both moved under the build directory because the sandbox has
|
||||
# no writable `/run` and no `/agents`.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
self,
|
||||
nixosSystem,
|
||||
}:
|
||||
let
|
||||
inherit
|
||||
(import ../module-eval/lib.nix {
|
||||
inherit
|
||||
pkgs
|
||||
lib
|
||||
self
|
||||
nixosSystem
|
||||
;
|
||||
})
|
||||
agentWith
|
||||
;
|
||||
|
||||
earlierFile = "/agents/a1/harness/backend.env";
|
||||
|
||||
machine = agentWith {
|
||||
services.hyperhive.agent = {
|
||||
bao.addr = "https://bao.t.local:8200";
|
||||
useApiKey = true;
|
||||
backendEnvironmentFile = earlierFile;
|
||||
};
|
||||
};
|
||||
|
||||
unit = machine.systemd.services.hive-agent-backend-env;
|
||||
|
||||
fakeBao = pkgs.runCommand "fake-bao" { } ''
|
||||
install -Dm755 ${./agent-backend-env-fake-bao.sh} $out/bin/bao
|
||||
patchShebangs $out/bin
|
||||
'';
|
||||
in
|
||||
pkgs.runCommand "hyperhive-script-test-agent-backend-env"
|
||||
{
|
||||
UNIT_SCRIPT = lib.removeSuffix " " unit.serviceConfig.ExecStart;
|
||||
UNIT_PATH = unit.environment.PATH;
|
||||
UNIT_BAO_ADDR = unit.environment.BAO_ADDR;
|
||||
EARLIER_FILE = earlierFile;
|
||||
FAKE_BAO_BIN = "${fakeBao}/bin";
|
||||
}
|
||||
''
|
||||
${pkgs.bash}/bin/bash ${./agent-backend-env.sh}
|
||||
touch "$out"
|
||||
''
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
# Cases for ./agent-backend-env.nix. Each case gets a fresh runtime directory,
|
||||
# credentials directory, operator's file and store, runs the unit's script under
|
||||
# the unit's `UMask=0077` with a clean environment, and asserts on the exit
|
||||
# code, the output, the files left behind and the `bao` calls made.
|
||||
set -uo pipefail
|
||||
|
||||
failures=0
|
||||
count=0
|
||||
|
||||
fail() {
|
||||
echo "FAILED: $case: $*" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
leaf=secret/swarm/agents/a1/backend-env
|
||||
login_call="login -method=cert -token-only"
|
||||
get_call="kv get -field=env $leaf"
|
||||
|
||||
# setup <description>
|
||||
setup() {
|
||||
case=$1
|
||||
dir=$(mktemp -d)
|
||||
rt=$dir/rt
|
||||
creds=$dir/creds
|
||||
log=$dir/bao.log
|
||||
kv=$dir/kv
|
||||
earlier=$dir/earlier.env
|
||||
mkdir -m 0700 "$rt"
|
||||
mkdir "$creds" "$kv"
|
||||
printf cert >"$creds/hive-agent-bao-cert"
|
||||
printf key >"$creds/hive-agent-bao-key"
|
||||
: >"$log"
|
||||
if ! sed -e "s|/run/hive-agent-backend-env/|$rt/|g" -e "s|$EARLIER_FILE|$earlier|g" \
|
||||
"$UNIT_SCRIPT" >"$dir/script"; then
|
||||
echo "cannot read the rendered script" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$dir/script"
|
||||
if grep -q '/run/' "$dir/script"; then fail "the script still names /run after the rewrite"; fi
|
||||
if ! grep -qF "$earlier" "$dir/script"; then fail "the rewrite did not reach the operator's file"; fi
|
||||
login_rc=0
|
||||
deny_read=
|
||||
deny_write=
|
||||
race=
|
||||
stored=$kv/$(printf '%s' "$leaf" | tr / _)
|
||||
}
|
||||
|
||||
go() {
|
||||
count=$((count + 1))
|
||||
(
|
||||
umask 0077
|
||||
cd "$dir" || exit 99
|
||||
exec env -i \
|
||||
PATH="$FAKE_BAO_BIN:$UNIT_PATH" \
|
||||
BAO_ADDR="$UNIT_BAO_ADDR" \
|
||||
BAO_CLIENT_CERT="$creds/hive-agent-bao-cert" \
|
||||
BAO_CLIENT_KEY="$creds/hive-agent-bao-key" \
|
||||
CREDENTIALS_DIRECTORY="$creds" \
|
||||
FAKE_BAO_LOG="$log" \
|
||||
FAKE_BAO_KV="$kv" \
|
||||
FAKE_BAO_LOGIN_RC="$login_rc" \
|
||||
FAKE_BAO_DENY_READ="$deny_read" \
|
||||
FAKE_BAO_DENY_WRITE="$deny_write" \
|
||||
FAKE_BAO_RACE="$race" \
|
||||
"$dir/script"
|
||||
) >"$dir/out" 2>"$dir/err"
|
||||
rc=$?
|
||||
}
|
||||
|
||||
expect_rc() {
|
||||
if [ "$rc" != "$1" ]; then fail "exit $rc, expected $1; stderr: $(<"$dir/err")"; fi
|
||||
}
|
||||
|
||||
expect_err() {
|
||||
if ! grep -qF -- "$1" "$dir/err"; then fail "stderr lacks '$1'; stderr: $(<"$dir/err")"; fi
|
||||
}
|
||||
|
||||
expect_out() {
|
||||
if ! grep -qF -- "$1" "$dir/out"; then fail "stdout lacks '$1'; stdout: $(<"$dir/out")"; fi
|
||||
}
|
||||
|
||||
# expect_calls <line>... — the exact `bao` argument lines, in order.
|
||||
expect_calls() {
|
||||
local want
|
||||
want=$(printf '%s\n' "$@")
|
||||
if [ "$(<"$log")" != "${want%$'\n'}" ]; then fail "bao calls were '$(<"$log")', expected '$*'"; fi
|
||||
}
|
||||
|
||||
expect_no_calls() {
|
||||
if [ -s "$log" ]; then fail "bao was called: $(<"$log")"; fi
|
||||
}
|
||||
|
||||
# expect_same <path> <expected text> — byte for byte, trailing newlines included.
|
||||
expect_same() {
|
||||
if [ ! -e "$1" ]; then
|
||||
fail "$1 missing"
|
||||
return
|
||||
fi
|
||||
if ! printf '%s' "$2" | cmp -s - "$1"; then fail "$1 holds '$(<"$1")', expected '$2'"; fi
|
||||
}
|
||||
|
||||
expect_no_file() {
|
||||
if [ -e "$1" ]; then fail "$1 left behind"; fi
|
||||
}
|
||||
|
||||
# The operator's file, kept to compare after the run.
|
||||
put_earlier() {
|
||||
printf '%s' "$1" >"$earlier"
|
||||
chmod 0600 "$earlier"
|
||||
earlier_inode=$(stat -c %i "$earlier")
|
||||
}
|
||||
|
||||
expect_earlier_untouched() {
|
||||
expect_same "$earlier" "$1"
|
||||
if [ "$(stat -c %i "$earlier")" != "$earlier_inode" ]; then fail "the operator's file was replaced"; fi
|
||||
}
|
||||
|
||||
# A leaf holding <text> before the run.
|
||||
put_leaf() {
|
||||
printf '%s' "$1" >"$stored"
|
||||
}
|
||||
|
||||
expect_leaf() {
|
||||
expect_same "$stored" "$1"
|
||||
}
|
||||
|
||||
file_text=$'ANTHROPIC_API_KEY=k-file\nANTHROPIC_BASE_URL=http://x\n'
|
||||
|
||||
setup "no store identity delivered"
|
||||
: >"$creds/hive-agent-bao-cert"
|
||||
put_earlier "$file_text"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_err "has no store identity"
|
||||
expect_no_calls
|
||||
expect_no_file "$rt/env"
|
||||
|
||||
setup "a store holding nothing is seeded from the file, bytes unchanged, and the store's value is used"
|
||||
put_earlier "$file_text"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_calls "$login_call" "$get_call" "kv put -cas=0 $leaf env=@$earlier" "$get_call"
|
||||
expect_leaf "$file_text"
|
||||
expect_same "$rt/env" "$file_text"
|
||||
expect_earlier_untouched "$file_text"
|
||||
if [ "$(stat -c %a "$rt/env")" != 600 ]; then fail "env is mode $(stat -c %a "$rt/env"), expected 600"; fi
|
||||
expect_no_file "$rt/env.new"
|
||||
expect_no_file "$rt/bao.err"
|
||||
|
||||
setup "a file with no final newline is seeded exactly"
|
||||
put_earlier 'ANTHROPIC_API_KEY=k-file'
|
||||
go
|
||||
expect_rc 0
|
||||
expect_leaf 'ANTHROPIC_API_KEY=k-file'
|
||||
expect_same "$rt/env" 'ANTHROPIC_API_KEY=k-file'
|
||||
expect_earlier_untouched 'ANTHROPIC_API_KEY=k-file'
|
||||
|
||||
setup "a value in the store wins over the file and is not written back to it"
|
||||
put_earlier "$file_text"
|
||||
put_leaf $'ANTHROPIC_API_KEY=k-bao\n'
|
||||
go
|
||||
expect_rc 0
|
||||
expect_calls "$login_call" "$get_call"
|
||||
expect_leaf $'ANTHROPIC_API_KEY=k-bao\n'
|
||||
expect_same "$rt/env" $'ANTHROPIC_API_KEY=k-bao\n'
|
||||
expect_earlier_untouched "$file_text"
|
||||
|
||||
setup "an unchanged store value leaves the file in place"
|
||||
put_leaf "$file_text"
|
||||
printf '%s' "$file_text" >"$rt/env"
|
||||
chmod 0600 "$rt/env"
|
||||
before=$(stat -c %i "$rt/env")
|
||||
go
|
||||
expect_rc 0
|
||||
expect_out "is unchanged"
|
||||
expect_same "$rt/env" "$file_text"
|
||||
if [ "$(stat -c %i "$rt/env")" != "$before" ]; then fail "the unchanged env was replaced"; fi
|
||||
|
||||
setup "a changed store value replaces the file in place"
|
||||
put_leaf $'ANTHROPIC_API_KEY=k-new\n'
|
||||
printf old >"$rt/env"
|
||||
chmod 0600 "$rt/env"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_out "fetched this agent's backend environment"
|
||||
expect_same "$rt/env" $'ANTHROPIC_API_KEY=k-new\n'
|
||||
expect_no_file "$rt/env.new"
|
||||
|
||||
setup "no file and nothing in the store writes nothing"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_out "no file to create it from"
|
||||
expect_calls "$login_call" "$get_call"
|
||||
expect_no_file "$rt/env"
|
||||
expect_no_file "$stored"
|
||||
|
||||
setup "an empty file is never stored"
|
||||
put_earlier ""
|
||||
go
|
||||
expect_rc 0
|
||||
expect_calls "$login_call" "$get_call"
|
||||
expect_no_file "$rt/env"
|
||||
expect_no_file "$stored"
|
||||
|
||||
setup "nothing in the store leaves an env fetched earlier alone"
|
||||
printf kept >"$rt/env"
|
||||
chmod 0600 "$rt/env"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_same "$rt/env" kept
|
||||
|
||||
setup "a store that refuses the login with a file in place changes nothing and fails the unit"
|
||||
put_earlier "$file_text"
|
||||
login_rc=2
|
||||
go
|
||||
expect_rc 1
|
||||
expect_err "did not accept this agent's certificate login"
|
||||
expect_err "no such host"
|
||||
expect_calls "$login_call"
|
||||
expect_no_file "$rt/env"
|
||||
expect_no_file "$stored"
|
||||
expect_earlier_untouched "$file_text"
|
||||
|
||||
setup "a lost race reads back the winner's value and keeps the file"
|
||||
put_earlier "$file_text"
|
||||
race=$'ANTHROPIC_API_KEY=k-winner\n'
|
||||
go
|
||||
expect_rc 0
|
||||
expect_calls "$login_call" "$get_call" "kv put -cas=0 $leaf env=@$earlier" "$get_call"
|
||||
expect_err "could not create $leaf"
|
||||
expect_leaf $'ANTHROPIC_API_KEY=k-winner\n'
|
||||
expect_same "$rt/env" $'ANTHROPIC_API_KEY=k-winner\n'
|
||||
expect_earlier_untouched "$file_text"
|
||||
|
||||
setup "a policy that refuses the create leaves the file to carry the agent"
|
||||
put_earlier "$file_text"
|
||||
deny_write=1
|
||||
go
|
||||
expect_rc 0
|
||||
expect_calls "$login_call" "$get_call" "kv put -cas=0 $leaf env=@$earlier" "$get_call"
|
||||
expect_err "permission denied"
|
||||
expect_err "holds no usable env"
|
||||
expect_no_file "$rt/env"
|
||||
expect_no_file "$stored"
|
||||
expect_earlier_untouched "$file_text"
|
||||
|
||||
setup "a leaf that exists but holds no env is not overwritten"
|
||||
put_earlier "$file_text"
|
||||
put_leaf ""
|
||||
go
|
||||
expect_rc 0
|
||||
expect_calls "$login_call" "$get_call"
|
||||
expect_err "holds no usable env"
|
||||
expect_leaf ""
|
||||
expect_no_file "$rt/env"
|
||||
|
||||
setup "a leaf without the env field is not overwritten"
|
||||
put_earlier "$file_text"
|
||||
put_leaf "x"
|
||||
printf other >"$stored.field"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_calls "$login_call" "$get_call"
|
||||
expect_leaf "x"
|
||||
expect_no_file "$rt/env"
|
||||
|
||||
setup "a refused read fails the unit and writes nothing"
|
||||
put_earlier "$file_text"
|
||||
deny_read=1
|
||||
go
|
||||
expect_rc 1
|
||||
expect_err "could not read $leaf"
|
||||
expect_err "permission denied"
|
||||
expect_calls "$login_call" "$get_call"
|
||||
expect_no_file "$rt/env"
|
||||
expect_earlier_untouched "$file_text"
|
||||
|
||||
# `UMask=0077` makes every file the script creates 0600; a stale leftover of a
|
||||
# stopped run must not block the next.
|
||||
setup "leftovers of a stopped run do not block the fetch"
|
||||
put_leaf $'ANTHROPIC_API_KEY=k-bao\n'
|
||||
printf stale >"$rt/env.new"
|
||||
printf stale >"$rt/bao.err"
|
||||
go
|
||||
expect_rc 0
|
||||
expect_same "$rt/env" $'ANTHROPIC_API_KEY=k-bao\n'
|
||||
expect_no_file "$rt/env.new"
|
||||
expect_no_file "$rt/bao.err"
|
||||
|
||||
# The control: the stub's create-only answer is what the lost-race and
|
||||
# refused-create cases lean on, so show it refuses a second create.
|
||||
setup "the stub refuses a create over an existing leaf"
|
||||
put_leaf kept
|
||||
printf x >"$dir/x"
|
||||
(
|
||||
PATH="$FAKE_BAO_BIN:$PATH" BAO_TOKEN=s.fake FAKE_BAO_LOG=$log FAKE_BAO_KV=$kv \
|
||||
bao kv put -cas=0 "$leaf" env=@"$dir/x"
|
||||
) >/dev/null 2>"$dir/err"
|
||||
if [ $? -eq 0 ]; then fail "the stub took a second create"; fi
|
||||
expect_leaf kept
|
||||
|
||||
if [ "$failures" -gt 0 ]; then
|
||||
echo "script-test-agent-backend-env: $failures failed assertions over $count runs" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "script-test-agent-backend-env: $count runs, all assertions hold"
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
//! The backend environment agreement: where an agent's backend credentials
|
||||
//! (`ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, an ACP provider key) live in
|
||||
//! the store, and what the object at that path holds.
|
||||
//!
|
||||
//! The object holds the text of a systemd `EnvironmentFile`. The agent's own
|
||||
//! `hive-agent-backend-env` unit (`nix/agent-modules/backend-env.nix`) reads it
|
||||
//! under the agent's certificate and writes it where the harness's
|
||||
//! `EnvironmentFile=` points. A leaf no one has written yet is created by that
|
||||
//! same unit from the agent's existing `backendEnvironmentFile`, which is why
|
||||
//! [`crate::policy::render_shared_agent`] grants the agent `create` on this one
|
||||
//! leaf and no other.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
path::{Kind, principal_prefix},
|
||||
};
|
||||
|
||||
/// The last segment of the path, shared with the policy stanza that lets an
|
||||
/// agent create it.
|
||||
pub(crate) const LEAF: &str = "backend-env";
|
||||
|
||||
/// The path holding `agent`'s backend environment.
|
||||
///
|
||||
/// A flat leaf under the agent's prefix, like
|
||||
/// [`crate::github::account_path`], so the agent's own read stanza
|
||||
/// ([`crate::policy::render_shared_agent`]) already covers it.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
||||
/// which is what keeps one agent's name from addressing another agent's secret.
|
||||
pub fn env_path(agent: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||
Ok(format!("{prefix}/{LEAF}"))
|
||||
}
|
||||
|
||||
/// What an [`env_path`] holds.
|
||||
///
|
||||
/// No `Debug` derive: `env` carries live credentials, and a derived `Debug` is
|
||||
/// one `{:?}` away from a log line.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BackendEnv {
|
||||
/// The file text, `KEY=value` per line. `backend-env.nix` writes it with
|
||||
/// `bao kv put … env=@file` and reads it with `bao kv get -field=env`, so
|
||||
/// this name is load-bearing for a reader this crate does not control.
|
||||
pub env: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BackendEnv {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BackendEnv")
|
||||
.field("env", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_env_lands_under_the_agent_prefix() {
|
||||
// Spelled out: `backend-env.nix` spells the same string.
|
||||
assert_eq!(
|
||||
env_path("atlas").expect("a plain name is legal"),
|
||||
"swarm/agents/atlas/backend-env"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversal_in_the_agent_name_is_refused() {
|
||||
for bad in ["../argus", "a/b", "atlas/../argus", "a.b", ""] {
|
||||
let e = env_path(bad).expect_err("a traversal is not legal");
|
||||
assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}");
|
||||
}
|
||||
// The control: the legal charset stays reachable.
|
||||
assert!(env_path("a-b_C9").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stored_field_is_env() {
|
||||
let stored: BackendEnv =
|
||||
serde_json::from_str(r#"{"env":"A=b\n"}"#).expect("the documented shape decodes");
|
||||
assert_eq!(stored.env, "A=b\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_hides_the_credentials() {
|
||||
let shown = format!(
|
||||
"{:?}",
|
||||
BackendEnv {
|
||||
env: "ANTHROPIC_API_KEY=sk-live".to_owned()
|
||||
}
|
||||
);
|
||||
assert!(!shown.contains("sk-live"), "{shown}");
|
||||
assert!(shown.contains("<redacted>"), "{shown}");
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
//! deployment's environment into a logged-in client ([`client`]), and, per kind
|
||||
//! of secret, the path it lives at together with the fields it holds
|
||||
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`grafana`],
|
||||
//! [`acp`], [`attic`], [`backend_env`], [`ci`], [`review_classifier`], [`builder`], [`ci_metrics`],
|
||||
//! [`acp`], [`attic`], [`ci`], [`review_classifier`], [`builder`], [`ci_metrics`],
|
||||
//! [`claude_login`]).
|
||||
//! Each of those is a thing the controller and a hive must say identically, so
|
||||
//! it is said once here.
|
||||
|
|
@ -31,7 +31,6 @@
|
|||
|
||||
pub mod acp;
|
||||
pub mod attic;
|
||||
pub mod backend_env;
|
||||
pub mod builder;
|
||||
pub mod cert;
|
||||
#[cfg(feature = "otel")]
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
|
||||
|
||||
use crate::{
|
||||
Error, backend_env,
|
||||
Error,
|
||||
path::{Kind, MOUNT, ROOT, checked_segment},
|
||||
};
|
||||
|
||||
|
|
@ -111,9 +111,9 @@ pub fn agents_from_role_names(names: &[String]) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// One read stanza. With [`list_stanza`] and [`create_stanza`], the only shapes
|
||||
/// this module emits, so what an agent may write is a property of the renderers
|
||||
/// rather than of each call site.
|
||||
/// One read stanza. With [`list_stanza`], the only shapes this module emits, so
|
||||
/// "writes nothing" is a property of the renderers rather than of each call
|
||||
/// site.
|
||||
fn read_stanza(path: &str) -> String {
|
||||
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
|
||||
}
|
||||
|
|
@ -124,13 +124,6 @@ fn list_stanza(path: &str) -> String {
|
|||
format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n")
|
||||
}
|
||||
|
||||
/// One create stanza: `create` and `read`, never `update`. A leaf that holds
|
||||
/// nothing takes a first write and the holder cannot replace it afterwards,
|
||||
/// which is what a `-cas=0` write needs and all it gets.
|
||||
fn create_stanza(path: &str) -> String {
|
||||
format!("path \"{path}\" {{\n capabilities = [\"create\", \"read\"]\n}}\n")
|
||||
}
|
||||
|
||||
/// The one policy every hive's cert-auth role attaches.
|
||||
///
|
||||
/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object
|
||||
|
|
@ -203,16 +196,8 @@ const ENTITY_NAME_TEMPLATE: &str = "{{identity.entity.name}}";
|
|||
/// each agent's entity after the agent, and a login that no entity of that
|
||||
/// name owns reads `swarm/agents/<generated name>/`, where nothing is stored.
|
||||
///
|
||||
/// The one write is `create` on the agent's own [`backend_env`] leaf, without
|
||||
/// `update`: the agent seeds it from the file it has today, once, and cannot
|
||||
/// replace it. The stanza cannot constrain the content, and the agent's own
|
||||
/// process holds the identity, so while the leaf is empty it can also create it
|
||||
/// with any text, and the store's value then wins over the file. That is no
|
||||
/// wider than the harness-dir file the agent already owns:
|
||||
/// the container activation's `heal_own_dir` (`nix/agent-modules/user.nix`)
|
||||
/// re-owns that dir to the agent recursively at every boot, so the agent
|
||||
/// already controls the legacy file. Any other write would let an agent hand
|
||||
/// itself an identity it was never issued. An agent has no business with
|
||||
/// Nothing here writes: an agent that could write its own credentials could
|
||||
/// hand itself an identity it was never issued. An agent has no business with
|
||||
/// another agent's credentials, a hive's, a service's, or the controller's, so
|
||||
/// none of [`render_shared_hives`]'s breadth applies. A hive still reads this
|
||||
/// agent's secrets, and closing that is its own decision.
|
||||
|
|
@ -225,11 +210,7 @@ pub fn render_shared_agent() -> String {
|
|||
let list = list_stanza(&format!(
|
||||
"{MOUNT}/metadata/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/*"
|
||||
));
|
||||
let seed = create_stanza(&format!(
|
||||
"{MOUNT}/data/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/{}",
|
||||
backend_env::LEAF
|
||||
));
|
||||
format!("{read}{list}{seed}")
|
||||
format!("{read}{list}")
|
||||
}
|
||||
|
||||
/// What an agent's token grants under [`SHARED_AGENT_POLICY`]: the document
|
||||
|
|
@ -260,8 +241,7 @@ mod tests {
|
|||
assert_eq!(
|
||||
render_shared_agent(),
|
||||
"path \"secret/data/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"read\"]\n}\n\
|
||||
path \"secret/metadata/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"list\"]\n}\n\
|
||||
path \"secret/data/swarm/agents/{{identity.entity.name}}/backend-env\" {\n capabilities = [\"create\", \"read\"]\n}\n"
|
||||
path \"secret/metadata/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"list\"]\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -270,6 +250,7 @@ mod tests {
|
|||
let shared = render_shared_agent();
|
||||
assert!(!shared.contains("atlas"));
|
||||
assert!(!shared.contains("agents/*"), "{shared}");
|
||||
assert!(!shared.contains("create"));
|
||||
assert!(!shared.contains("update"));
|
||||
assert!(!shared.contains("delete"));
|
||||
}
|
||||
|
|
@ -475,11 +456,10 @@ mod tests {
|
|||
|
||||
/// What `effective_agent("atlas")` must be, byte for byte.
|
||||
const ATLAS_DOCUMENT: &str = "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n\
|
||||
path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n\
|
||||
path \"secret/data/swarm/agents/atlas/backend-env\" {\n capabilities = [\"create\", \"read\"]\n}\n";
|
||||
path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n";
|
||||
|
||||
#[test]
|
||||
fn an_agents_document_is_its_stanzas_naming_that_agent() {
|
||||
fn an_agents_document_is_two_stanzas_naming_that_agent() {
|
||||
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
|
||||
}
|
||||
|
||||
|
|
@ -515,13 +495,13 @@ mod tests {
|
|||
assert!(!p.contains("argus"), "no other principal is named");
|
||||
assert_eq!(
|
||||
p.matches("path \"").count(),
|
||||
3,
|
||||
"three stanzas, or the document grants something unaccounted for: {p}"
|
||||
2,
|
||||
"two stanzas, or the document grants something unaccounted for: {p}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_document_is_exactly_its_own_stanzas() {
|
||||
fn an_agents_document_is_exactly_its_own_two_stanzas() {
|
||||
// Pinned byte for byte: an added stanza (a hive's queue credential, a
|
||||
// second agent) is exactly what a presence check misses.
|
||||
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
|
||||
|
|
@ -529,49 +509,18 @@ mod tests {
|
|||
assert!(!effective_agent("other").contains("agents/atlas/"));
|
||||
}
|
||||
|
||||
/// The paths of the stanzas in `document` that grant `capability`.
|
||||
fn paths_granting(document: &str, capability: &str) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
let mut current = "";
|
||||
for line in document.lines() {
|
||||
if let Some(rest) = line.strip_prefix("path \"") {
|
||||
current = rest.split_once('"').map_or("", |(path, _)| path);
|
||||
} else if line.contains(&format!("\"{capability}\"")) {
|
||||
paths.push(current.to_owned());
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_only_write_is_creating_its_own_backend_env() {
|
||||
fn an_agents_grant_writes_nothing() {
|
||||
// An agent that could write its own credentials could hand itself an
|
||||
// identity it was never issued. The one leaf it may create is the one
|
||||
// it seeds from its backend environment file, and `update` on it would
|
||||
// let the agent replace a value the operator set.
|
||||
// identity it was never issued — and `create`/`update` on that path is
|
||||
// exactly what the controller holds, so the wall is the capability.
|
||||
let p = effective_agent("atlas");
|
||||
let leaf = format!(
|
||||
"secret/data/{}",
|
||||
crate::backend_env::env_path("atlas").expect("legal")
|
||||
);
|
||||
// The control: the walk finds the stanzas that are there.
|
||||
assert_eq!(
|
||||
paths_granting(&p, "read"),
|
||||
["secret/data/swarm/agents/atlas/*", leaf.as_str()]
|
||||
);
|
||||
assert_eq!(paths_granting(&p, "create"), [leaf.as_str()]);
|
||||
for capability in ["update", "delete", "sudo", "patch"] {
|
||||
for capability in ["create", "update", "delete", "sudo", "patch"] {
|
||||
assert!(
|
||||
!p.contains(capability),
|
||||
"an agent's document must not grant {capability}: {p}"
|
||||
);
|
||||
}
|
||||
// The exact stanza overrides the glob for its path, so it carries
|
||||
// `read` itself.
|
||||
assert!(
|
||||
p.contains("backend-env\" {\n capabilities = [\"create\", \"read\"]"),
|
||||
"{p}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -581,9 +530,8 @@ mod tests {
|
|||
// granted, nor is `list` anywhere but `metadata/`.
|
||||
let p = effective_agent("atlas");
|
||||
let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
|
||||
assert_eq!(stanzas.len(), 3, "{p}");
|
||||
// The third is the create stanza, pinned by the test above.
|
||||
for stanza in &stanzas[..2] {
|
||||
assert_eq!(stanzas.len(), 2, "{p}");
|
||||
for stanza in stanzas {
|
||||
let on_data = stanza.starts_with("path \"secret/data/");
|
||||
let on_metadata = stanza.starts_with("path \"secret/metadata/");
|
||||
assert!(on_data != on_metadata, "{stanza}");
|
||||
|
|
|
|||
Loading…
Reference in a new issue