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.
This commit is contained in:
parent
5486fc0333
commit
75e9e205e3
15 changed files with 933 additions and 31 deletions
|
|
@ -32,6 +32,7 @@ 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-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>/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>/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/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>/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 |
|
| `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 |
|
||||||
|
|
@ -126,6 +127,7 @@ 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 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 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 |
|
| 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) |
|
| 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 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`) |
|
| 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
|
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
|
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
|
agent's certificate opens that agent's own `swarm/agents/<agent>/*` and
|
||||||
nothing else: `read` on the values there and `list` on their names.
|
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.
|
||||||
|
|
||||||
All agents share one policy, `hive-agent`, which the agent's cert-auth role
|
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}}`
|
attaches and no other. It states the rule with `{{identity.entity.name}}`
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,18 @@ is a no-op without the other:
|
||||||
(`hive-subagent-daemon`) loads the same file, so the agent's claude
|
(`hive-subagent-daemon`) loads the same file, so the agent's claude
|
||||||
subagents run on the same key.
|
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
|
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):
|
the store is world-readable and travels with the flake closure):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,15 @@ in
|
||||||
and on an agent with `useApiKey` set, so its subagents authenticate
|
and on an agent with `useApiKey` set, so its subagents authenticate
|
||||||
the same way the harness does.
|
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
|
On an ACP agent using the `opencode` preset, a provider key this file
|
||||||
leaves unset is read from the swarm secret store at
|
leaves unset is read from the swarm secret store at
|
||||||
`swarm/agents/<agent>/acp-provider`, field `api_key`, if one is stored.
|
`swarm/agents/<agent>/acp-provider`, field `api_key`, if one is stored.
|
||||||
|
|
@ -552,10 +561,11 @@ in
|
||||||
User = userName;
|
User = userName;
|
||||||
Group = userName;
|
Group = userName;
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs (config.services.hyperhive.agent.backendEnvironmentFile != null) {
|
// lib.optionalAttrs (config.services.hyperhive.agent.backendEnvironment.environmentFiles != [ ]) {
|
||||||
# See `services.hyperhive.agent.backendEnvironmentFile`'s own description for
|
# See `services.hyperhive.agent.backendEnvironmentFile`'s own description for
|
||||||
# the file shape and the leading-`-` rationale.
|
# the file shape and the leading-`-` rationale; ./backend-env.nix for
|
||||||
EnvironmentFile = "-${config.services.hyperhive.agent.backendEnvironmentFile}";
|
# the second entry.
|
||||||
|
EnvironmentFile = config.services.hyperhive.agent.backendEnvironment.environmentFiles;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
205
nix/agent-modules/backend-env.nix
Normal file
205
nix/agent-modules/backend-env.nix
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
# 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" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,7 @@ in
|
||||||
imports = [
|
imports = [
|
||||||
./agent-service.nix
|
./agent-service.nix
|
||||||
./bao.nix
|
./bao.nix
|
||||||
|
./backend-env.nix
|
||||||
./bash-env.nix
|
./bash-env.nix
|
||||||
./claude-settings.nix
|
./claude-settings.nix
|
||||||
./dashboard-links.nix
|
./dashboard-links.nix
|
||||||
|
|
|
||||||
|
|
@ -479,14 +479,14 @@ in
|
||||||
lib.optionalAttrs
|
lib.optionalAttrs
|
||||||
(
|
(
|
||||||
(config.services.hyperhive.agent.runtime == "acp" || config.services.hyperhive.agent.useApiKey)
|
(config.services.hyperhive.agent.runtime == "acp" || config.services.hyperhive.agent.useApiKey)
|
||||||
&& config.services.hyperhive.agent.backendEnvironmentFile != null
|
&& config.services.hyperhive.agent.backendEnvironment.environmentFiles != [ ]
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
# A subagent authenticates with the same credentials as the
|
# A subagent authenticates with the same credentials as the
|
||||||
# harness: an ACP subagent to its provider, a claude subagent of
|
# harness: an ACP subagent to its provider, a claude subagent of
|
||||||
# an api-key agent with `ANTHROPIC_API_KEY`, as it has no OAuth
|
# an api-key agent with `ANTHROPIC_API_KEY`, as it has no OAuth
|
||||||
# session to fall back on.
|
# session to fall back on.
|
||||||
EnvironmentFile = "-${config.services.hyperhive.agent.backendEnvironmentFile}";
|
EnvironmentFile = config.services.hyperhive.agent.backendEnvironment.environmentFiles;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,12 @@ in
|
||||||
inherit pkgs self nixosSystem;
|
inherit pkgs self nixosSystem;
|
||||||
inherit (pkgs) lib;
|
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
|
# Runs ./agent-modules/hive-facts.nix's generator and login-shell snippet
|
||||||
# against sample facts files.
|
# against sample facts files.
|
||||||
script-test-agent-hive-facts = import ./script-tests/agent-hive-facts.nix {
|
script-test-agent-hive-facts = import ./script-tests/agent-hive-facts.nix {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,14 @@ let
|
||||||
};
|
};
|
||||||
opencode = opencodeWith { };
|
opencode = opencodeWith { };
|
||||||
opencodeBao = opencodeWith { bao.addr = "https://bao.t.local:8200"; };
|
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 {
|
claudeBao = agentWith {
|
||||||
services.hyperhive.agent = {
|
services.hyperhive.agent = {
|
||||||
runtime = "claude";
|
runtime = "claude";
|
||||||
|
|
@ -133,13 +141,13 @@ let
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
name = "an ACP agent's subagent daemon loads the backend credentials";
|
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
|
# The nested `claude` inherits the daemon's environment, so this file
|
||||||
# is where its `ANTHROPIC_API_KEY` comes from.
|
# is where its `ANTHROPIC_API_KEY` comes from.
|
||||||
name = "an api-key claude agent's subagent daemon loads the backend credentials";
|
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
|
# Unset is what `RuntimeSpec` reads as claude, and a claude subagent
|
||||||
|
|
@ -149,6 +157,79 @@ let
|
||||||
lib.all (var: (subagent claude).environment.${var} or null == null) runtimeVars
|
lib.all (var: (subagent claude).environment.${var} or null == null) runtimeVars
|
||||||
&& !((subagent claude).serviceConfig ? EnvironmentFile);
|
&& !((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
|
in
|
||||||
runGroup "agent-runtime" cases
|
runGroup "agent-runtime" cases
|
||||||
|
|
|
||||||
77
nix/script-tests/agent-backend-env-fake-bao.sh
Executable file
77
nix/script-tests/agent-backend-env-fake-bao.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/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
|
||||||
56
nix/script-tests/agent-backend-env.nix
Normal file
56
nix/script-tests/agent-backend-env.nix
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# `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"
|
||||||
|
''
|
||||||
306
nix/script-tests/agent-backend-env.sh
Normal file
306
nix/script-tests/agent-backend-env.sh
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
# 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"
|
||||||
99
swarm-secret-client/src/backend_env.rs
Normal file
99
swarm-secret-client/src/backend_env.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
//! 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
|
//! 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
|
//! of secret, the path it lives at together with the fields it holds
|
||||||
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`grafana`],
|
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`grafana`],
|
||||||
//! [`acp`], [`attic`], [`ci`], [`review_classifier`], [`builder`], [`ci_metrics`],
|
//! [`acp`], [`attic`], [`backend_env`], [`ci`], [`review_classifier`], [`builder`], [`ci_metrics`],
|
||||||
//! [`claude_login`]).
|
//! [`claude_login`]).
|
||||||
//! Each of those is a thing the controller and a hive must say identically, so
|
//! Each of those is a thing the controller and a hive must say identically, so
|
||||||
//! it is said once here.
|
//! it is said once here.
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
|
|
||||||
pub mod acp;
|
pub mod acp;
|
||||||
pub mod attic;
|
pub mod attic;
|
||||||
|
pub mod backend_env;
|
||||||
pub mod builder;
|
pub mod builder;
|
||||||
pub mod cert;
|
pub mod cert;
|
||||||
#[cfg(feature = "otel")]
|
#[cfg(feature = "otel")]
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
|
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Error,
|
Error, backend_env,
|
||||||
path::{Kind, MOUNT, ROOT, checked_segment},
|
path::{Kind, MOUNT, ROOT, checked_segment},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -111,9 +111,9 @@ pub fn agents_from_role_names(names: &[String]) -> Vec<String> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One read stanza. With [`list_stanza`], the only shapes this module emits, so
|
/// One read stanza. With [`list_stanza`] and [`create_stanza`], the only shapes
|
||||||
/// "writes nothing" is a property of the renderers rather than of each call
|
/// this module emits, so what an agent may write is a property of the renderers
|
||||||
/// site.
|
/// rather than of each call site.
|
||||||
fn read_stanza(path: &str) -> String {
|
fn read_stanza(path: &str) -> String {
|
||||||
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
|
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +124,13 @@ fn list_stanza(path: &str) -> String {
|
||||||
format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n")
|
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.
|
/// The one policy every hive's cert-auth role attaches.
|
||||||
///
|
///
|
||||||
/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object
|
/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object
|
||||||
|
|
@ -196,10 +203,12 @@ const ENTITY_NAME_TEMPLATE: &str = "{{identity.entity.name}}";
|
||||||
/// each agent's entity after the agent, and a login that no entity of that
|
/// 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.
|
/// name owns reads `swarm/agents/<generated name>/`, where nothing is stored.
|
||||||
///
|
///
|
||||||
/// Nothing here writes: an agent that could write its own credentials could
|
/// The one write is `create` on the agent's own [`backend_env`] leaf, without
|
||||||
/// hand itself an identity it was never issued. An agent has no business with
|
/// `update`: the agent seeds it from the file it has today, once, and cannot
|
||||||
/// another agent's credentials, a hive's, a service's, or the controller's, so
|
/// replace it. Any other write would let an agent hand itself an identity it
|
||||||
/// none of [`render_shared_hives`]'s breadth applies. A hive still reads this
|
/// 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.
|
/// agent's secrets, and closing that is its own decision.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_shared_agent() -> String {
|
pub fn render_shared_agent() -> String {
|
||||||
|
|
@ -210,7 +219,11 @@ pub fn render_shared_agent() -> String {
|
||||||
let list = list_stanza(&format!(
|
let list = list_stanza(&format!(
|
||||||
"{MOUNT}/metadata/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/*"
|
"{MOUNT}/metadata/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/*"
|
||||||
));
|
));
|
||||||
format!("{read}{list}")
|
let seed = create_stanza(&format!(
|
||||||
|
"{MOUNT}/data/{ROOT}/{kind}/{ENTITY_NAME_TEMPLATE}/{}",
|
||||||
|
backend_env::LEAF
|
||||||
|
));
|
||||||
|
format!("{read}{list}{seed}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What an agent's token grants under [`SHARED_AGENT_POLICY`]: the document
|
/// What an agent's token grants under [`SHARED_AGENT_POLICY`]: the document
|
||||||
|
|
@ -241,7 +254,8 @@ mod tests {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
render_shared_agent(),
|
render_shared_agent(),
|
||||||
"path \"secret/data/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"read\"]\n}\n\
|
"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/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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,7 +264,6 @@ mod tests {
|
||||||
let shared = render_shared_agent();
|
let shared = render_shared_agent();
|
||||||
assert!(!shared.contains("atlas"));
|
assert!(!shared.contains("atlas"));
|
||||||
assert!(!shared.contains("agents/*"), "{shared}");
|
assert!(!shared.contains("agents/*"), "{shared}");
|
||||||
assert!(!shared.contains("create"));
|
|
||||||
assert!(!shared.contains("update"));
|
assert!(!shared.contains("update"));
|
||||||
assert!(!shared.contains("delete"));
|
assert!(!shared.contains("delete"));
|
||||||
}
|
}
|
||||||
|
|
@ -456,10 +469,11 @@ mod tests {
|
||||||
|
|
||||||
/// What `effective_agent("atlas")` must be, byte for byte.
|
/// What `effective_agent("atlas")` must be, byte for byte.
|
||||||
const ATLAS_DOCUMENT: &str = "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n\
|
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/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n\
|
||||||
|
path \"secret/data/swarm/agents/atlas/backend-env\" {\n capabilities = [\"create\", \"read\"]\n}\n";
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_agents_document_is_two_stanzas_naming_that_agent() {
|
fn an_agents_document_is_its_stanzas_naming_that_agent() {
|
||||||
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
|
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,13 +509,13 @@ mod tests {
|
||||||
assert!(!p.contains("argus"), "no other principal is named");
|
assert!(!p.contains("argus"), "no other principal is named");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
p.matches("path \"").count(),
|
p.matches("path \"").count(),
|
||||||
2,
|
3,
|
||||||
"two stanzas, or the document grants something unaccounted for: {p}"
|
"three stanzas, or the document grants something unaccounted for: {p}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_agents_document_is_exactly_its_own_two_stanzas() {
|
fn an_agents_document_is_exactly_its_own_stanzas() {
|
||||||
// Pinned byte for byte: an added stanza (a hive's queue credential, a
|
// Pinned byte for byte: an added stanza (a hive's queue credential, a
|
||||||
// second agent) is exactly what a presence check misses.
|
// second agent) is exactly what a presence check misses.
|
||||||
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
|
assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
|
||||||
|
|
@ -509,18 +523,49 @@ mod tests {
|
||||||
assert!(!effective_agent("other").contains("agents/atlas/"));
|
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]
|
#[test]
|
||||||
fn an_agents_grant_writes_nothing() {
|
fn an_agents_only_write_is_creating_its_own_backend_env() {
|
||||||
// An agent that could write its own credentials could hand itself an
|
// An agent that could write its own credentials could hand itself an
|
||||||
// identity it was never issued — and `create`/`update` on that path is
|
// identity it was never issued. The one leaf it may create is the one
|
||||||
// exactly what the controller holds, so the wall is the capability.
|
// it seeds from its backend environment file, and `update` on it would
|
||||||
|
// let the agent replace a value the operator set.
|
||||||
let p = effective_agent("atlas");
|
let p = effective_agent("atlas");
|
||||||
for capability in ["create", "update", "delete", "sudo", "patch"] {
|
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"] {
|
||||||
assert!(
|
assert!(
|
||||||
!p.contains(capability),
|
!p.contains(capability),
|
||||||
"an agent's document must not grant {capability}: {p}"
|
"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]
|
#[test]
|
||||||
|
|
@ -530,8 +575,9 @@ mod tests {
|
||||||
// granted, nor is `list` anywhere but `metadata/`.
|
// granted, nor is `list` anywhere but `metadata/`.
|
||||||
let p = effective_agent("atlas");
|
let p = effective_agent("atlas");
|
||||||
let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
|
let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
|
||||||
assert_eq!(stanzas.len(), 2, "{p}");
|
assert_eq!(stanzas.len(), 3, "{p}");
|
||||||
for stanza in stanzas {
|
// The third is the create stanza, pinned by the test above.
|
||||||
|
for stanza in &stanzas[..2] {
|
||||||
let on_data = stanza.starts_with("path \"secret/data/");
|
let on_data = stanza.starts_with("path \"secret/data/");
|
||||||
let on_metadata = stanza.starts_with("path \"secret/metadata/");
|
let on_metadata = stanza.starts_with("path \"secret/metadata/");
|
||||||
assert!(on_data != on_metadata, "{stanza}");
|
assert!(on_data != on_metadata, "{stanza}");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue