Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
17 changed files with 172 additions and 430 deletions

View file

@ -62,7 +62,7 @@ of the cell says how.
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts |
| `swarm/controller/swarm-controller/forge/token` | the forge, inside the `hive-forge` container, once; `swarm-bao-forge-publisher` on the forge's host copies it in under its own certificate, with a write-only grant | `swarm-controller`, under its own certificate, at start | ❌ the forge mints it once and keeps its copy in the container; the publisher writes that copy each time the forge container starts and each time the mint writes the file | ❌ the controller reads it at start, and in the background with backoff until it first finds it; a replaced token reaches it with the next restart |
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config; then `hive-agent-bao-identity` in the agent container, under the leaf it holds | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ✅ `hive-agent-bao-identity` re-reads it every 10 minutes and swaps a new leaf into `/run/hive-agent-bao-identity/`, which every store login in the container presents. It reads under the leaf it holds, so it renews only while that leaf is valid |
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires |
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by deleting it and linking the label again | ✅ the agent re-fetches on a 2-minute timer |
@ -170,10 +170,7 @@ as root and re-exposes it under the consuming unit's own user.
Inside the container, `hive-agent-bao-identity.service` logs in with that
certificate and reads this row back before reporting success, so an agent
locked out of its own identity says so at boot rather than at whichever pull
needed the store first. It keeps a live copy of the pair in `/run/hive-agent-bao-identity/`,
which every unit in the container that logs in to the store presents, and
re-runs every 10 minutes: a leaf the controller re-issued replaces that copy
once a login with it succeeds. The unit exists whenever
needed the store first. The unit exists whenever
`services.hyperhive.agent.bao.addr` has a value, which the hive's meta flake
sets from its own store address — the same all-or-nothing gate the per-hive
queue credential beside it uses, and the reason the delivery above never lands

View file

@ -1,14 +1,11 @@
# This agent's own identity at the swarm secret store: the check that proves
# it works, and the live copy every store reader in the container presents.
# This agent's own identity at the swarm secret store, and the check that
# proves it works.
#
# `swarm-controller` mints the leaf at agent creation and publishes it; this
# agent's hive collects it under the hive's own certificate and hands it in as
# systemd credentials (`hive_c0re::lifecycle::agent_identity`). That copy is
# fixed for the life of the container, and the controller re-issues the leaf
# at half its validity, so this unit seeds a live copy from it and, on a timer,
# reads the current leaf back from the store under the one it holds. Because
# that login needs the held leaf to still be valid, a container whose leaf
# expired while every pull failed recovers only at its next start.
# systemd credentials (`hive_c0re::lifecycle::agent_identity`). Nothing here
# fetches anything from the store — by the time this container boots, its
# identity is already inside it.
#
# ⚠️ The identity arrives as credentials and NOT as a bind mount, and the mode
# is why: the host file is `0600` to the hive daemon and this unit runs as the
@ -49,17 +46,6 @@ let
unitName = "hive-agent-bao-identity";
runtimeDir = unitName;
# A symlink to the directory that holds the live pair. Each pair is written
# into a fresh directory and goes live with one rename of this link, so the
# link always names one whole pair, old or new.
liveLink = "/run/${runtimeDir}/current";
certFile = "${liveLink}/cert";
keyFile = "${liveLink}/key";
# The store's whole answer, key included, in the unit's own `0700` directory.
rawFile = "/run/${runtimeDir}/bao-mtls.json";
errFile = "/run/${runtimeDir}/bao.err";
# Where the identity lives in the store, spelled from the same pieces the
# publisher uses. `swarm_secret_client::mtls::identity_path` builds
# `swarm/agents/<agent>/bao-mtls` and `path::MOUNT` is `secret`; this
@ -101,39 +87,16 @@ in
loudest possible way about a deployment that never asked for it.
'';
};
clientCertFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = certFile;
description = ''
This agent's current store certificate, kept by `${unitName}.service`.
Read-only: it is where that unit writes, not a knob. Every unit that
logs in to the store as this agent points `BAO_CLIENT_CERT` here.
'';
};
clientKeyFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = keyFile;
description = ''
The private key for `clientCertFile`, `0400` to the agent user.
🩸 A PATH and never a value.
'';
};
};
config = lib.mkIf configured {
systemd.services.${unitName} = {
description = "keep this agent's swarm secret store identity current, and prove it works";
description = "prove this agent can authenticate to the swarm secret store as itself";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
path = [
pkgs.openbao
pkgs.coreutils
pkgs.diffutils
pkgs.jq
];
# ../host-modules/lib/store-retry.nix. From an agent the store is reached
# through the gateway's stream passthrough, so either being down fails
@ -141,16 +104,10 @@ in
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
# Not `RemainAfterExit`, so the timer below can start it again.
# `RuntimeDirectoryPreserve` keeps the live pair between runs.
RemainAfterExit = false;
RemainAfterExit = true;
TimeoutStartSec = 30;
User = agentName;
Group = agentName;
RuntimeDirectory = runtimeDir;
RuntimeDirectoryMode = "0700";
RuntimeDirectoryPreserve = "yes";
UMask = "0377";
# Bare ids, no paths: the terse `LoadCredential=` form that inherits a
# credential the service *manager* received, which is what the
# container manager passed in. ./queue.nix states the same shape.
@ -162,8 +119,9 @@ in
};
environment = {
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = certFile;
BAO_CLIENT_KEY = keyFile;
# `%d` is `$CREDENTIALS_DIRECTORY`, per-unit and owned by `User=`.
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
};
script = ''
set -euo pipefail
@ -192,53 +150,8 @@ in
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
fi
# `UMask=0377` makes every file this script creates `0400`, so each one
# is removed before the command that creates it.
err=${lib.escapeShellArg errFile}
raw=${lib.escapeShellArg rawFile}
trap 'rm -f "$err" "$raw"' EXIT
live=${lib.escapeShellArg liveLink}
dir=''${live%/*}
# A fresh directory for one pair. `chmod`, because the umask cuts
# mktemp's `0700` down to a `0400` no file can be created in.
new_pair() {
local d
d=$(mktemp -d -p "$dir" pair.XXXXXX)
chmod 0700 "$d"
echo "$d"
}
# The only step that changes the live pair: one rename of the link. A
# reader whose two opens straddle it gets one file of each pair for
# that one login; the next login reads a whole pair.
go_live() {
rm -f "$live.new"
ln -s "''${1##*/}" "$live.new"
mv -Tf "$live.new" "$live"
}
# Every pair directory but the live one is left over: the pair the
# last swap replaced, kept until now for a reader that resolved the
# link before the swap, or a pair a stopped run staged.
current=$(readlink "$live" || true)
for d in "$dir"/pair.*; do
if [ -d "$d" ] && [ "''${d##*/}" != "$current" ]; then
chmod 0700 "$d"
rm -rf "$d"
fi
done
rm -f "$live.new"
# The delivered pair is the start of every container's life: /run is
# empty at boot and kept across this unit's runs.
if [ ! -s "$BAO_CLIENT_CERT" ] || [ ! -s "$BAO_CLIENT_KEY" ]; then
seed=$(new_pair)
cp "$CREDENTIALS_DIRECTORY/${keyCredential}" "$seed/key"
cp "$CREDENTIALS_DIRECTORY/${certCredential}" "$seed/cert"
go_live "$seed"
fi
err="$(mktemp)"
trap 'rm -f "$err"' EXIT
# Cert auth is a login, not a transport setting. The `BAO_CLIENT_*`
# variables above only decide which certificate the TLS handshake
@ -250,7 +163,6 @@ in
# it accepts, so this agent's leaf matches its own role and no other.
# Naming the role here would be a third copy of a string
# `swarm_secret_client::policy::agent_object_name` already owns.
rm -f "$err"
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "this agent's certificate was refused by the swarm secret store at $BAO_ADDR." >&2
if [ -s "$err" ]; then
@ -263,13 +175,16 @@ in
export BAO_TOKEN
# A token is not yet an answer: the login proves the certificate, this
# proves the policy attached to it, and the answer is the current
# leaf. One read, so the certificate and key come from one version.
# proves the policy attached to it. Reading this agent's own identity
# back is the smallest read its document grants, and it is the same
# check `swarm-controller` runs against the leaf before it reports the
# creation done — so a policy that drifted apart from the path fails
# here rather than in whichever pull needed it first.
#
# ⚠️ Written by redirect, never printed: the object holds a private
# key.
rm -f "$err" "$raw"
if ! bao kv get -format=json ${lib.escapeShellArg identityPath} >"$raw" 2>"$err"; then
# ⚠️ Output discarded, not printed: the field is a certificate and the
# object beside it is a private key. Nothing about this check needs a
# value, only whether the read succeeded.
if ! bao kv get -field=cert ${lib.escapeShellArg identityPath} >/dev/null 2>"$err"; then
echo "this agent logged in to the swarm secret store but cannot read ${identityPath}, so its policy does not cover its own path." >&2
if [ -s "$err" ]; then
cat "$err" >&2
@ -279,46 +194,8 @@ in
exit 1
fi
# `-j`: the bytes as the store holds them, which is what the hive
# delivered, so an unchanged leaf compares equal to the seeded copy.
staged=$(new_pair)
if ! jq -ej '.data.data.cert | strings' "$raw" >"$staged/cert" \
|| ! jq -ej '.data.data.key | strings' "$raw" >"$staged/key"; then
rm -rf "$staged"
echo "${identityPath} holds no certificate and key; this agent keeps the one it has." >&2
exit 1
fi
if cmp -s "$staged/cert" "$BAO_CLIENT_CERT" && cmp -s "$staged/key" "$BAO_CLIENT_KEY"; then
rm -rf "$staged"
echo "authenticated to the swarm secret store at $BAO_ADDR as ${agentName}; its certificate at ${identityPath} is unchanged."
else
# This unit's next login uses whatever goes live, so a pair the
# store refuses must never replace the one it accepts.
rm -f "$err"
if ! BAO_CLIENT_CERT="$staged/cert" BAO_CLIENT_KEY="$staged/key" \
bao login -method=cert -token-only >/dev/null 2>"$err"; then
rm -rf "$staged"
echo "the swarm secret store refused the certificate at ${identityPath}; this agent keeps the one it has." >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 1
fi
go_live "$staged"
echo "replaced this agent's store certificate with the one at ${identityPath}."
fi
echo "authenticated to the swarm secret store at $BAO_ADDR as ${agentName} and read ${identityPath}."
'';
};
# The controller re-issues a leaf at half its validity, so ten minutes is
# small against the time the old one stays valid.
systemd.timers.${unitName} = {
description = "re-read this agent's store certificate from the secret store";
wantedBy = [ "timers.target" ];
timerConfig = {
OnUnitInactiveSec = "10min";
RandomizedDelaySec = "1min";
};
};
};
}

View file

@ -23,7 +23,9 @@ let
agentName = config.services.hyperhive.agent.user.name;
stateDir = "/agents/${agentName}/state";
# The server CA id ./bao.nix loads.
# The same three ids ./bao.nix and ./forge-token.nix load.
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-forge-accounts";
@ -71,19 +73,23 @@ in
RuntimeDirectoryMode = "0700";
# `0600`, the mode the files in the state dir have always had.
UMask = "0077";
LoadCredential = [ serverCaCredential ];
LoadCredential = [
certCredential
keyCredential
serverCaCredential
];
};
environment = {
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = cfg.clientCertFile;
BAO_CLIENT_KEY = cfg.clientKeyFile;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
};
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
for id in ${lib.escapeShellArg certCredential} ${lib.escapeShellArg keyCredential}; do
if [ ! -s "$CREDENTIALS_DIRECTORY/$id" ]; then
echo "this agent has no store identity, so it cannot fetch its external forge accounts." >&2
exit 0
fi

View file

@ -28,7 +28,9 @@ let
# The name `swarm-controller` minted the token under — see ./queue-identity.nix.
agentName = config.services.hyperhive.agent.user.name;
# The server CA id ./bao.nix loads.
# The same three ids ./bao.nix and ./queue-identity.nix load.
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-forge-token";
@ -106,25 +108,29 @@ in
RuntimeDirectoryMode = "0700";
RuntimeDirectoryPreserve = "yes";
UMask = "0377";
LoadCredential = [ serverCaCredential ];
LoadCredential = [
certCredential
keyCredential
serverCaCredential
];
};
environment = {
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = cfg.clientCertFile;
BAO_CLIENT_KEY = cfg.clientKeyFile;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
};
script = ''
set -euo pipefail
# No identity delivered: ./bao.nix's check reports that; saying it
# twice adds nothing.
for f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
if [ ! -s "$f" ]; then
for id in ${lib.escapeShellArg certCredential} ${lib.escapeShellArg keyCredential}; do
if [ ! -s "$CREDENTIALS_DIRECTORY/$id" ]; then
echo "this agent has no store identity, so it cannot fetch its own forge token." >&2
exit 0
fi
if [ ! -r "$f" ]; then
echo "cannot read $f, so this agent cannot present its store identity." >&2
if [ ! -r "$CREDENTIALS_DIRECTORY/$id" ]; then
echo "cannot read $CREDENTIALS_DIRECTORY/$id, so this agent cannot present its store identity." >&2
exit 1
fi
done

View file

@ -22,7 +22,9 @@ let
agentName = config.services.hyperhive.agent.user.name;
stateDir = "/agents/${agentName}/state";
# The server CA id ./bao.nix loads.
# The same three ids ./bao.nix and ./forge-accounts.nix load.
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-github-token";
@ -74,19 +76,23 @@ in
RuntimeDirectoryMode = "0700";
# `0600`, the mode `github-token` has.
UMask = "0077";
LoadCredential = [ serverCaCredential ];
LoadCredential = [
certCredential
keyCredential
serverCaCredential
];
};
environment = {
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = cfg.clientCertFile;
BAO_CLIENT_KEY = cfg.clientKeyFile;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
};
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
for id in ${lib.escapeShellArg certCredential} ${lib.escapeShellArg keyCredential}; do
if [ ! -s "$CREDENTIALS_DIRECTORY/$id" ]; then
echo "this agent has no store identity, so it cannot fetch its GitHub token." >&2
exit 0
fi

View file

@ -21,10 +21,12 @@ let
accounts = config.services.hyperhive.agent.matrixAccounts;
# This agent's own identity at the swarm secret store (./bao.nix). The daemon
# reads each account's token from the store itself, `main` included (the
# swarm mints that one), so the store's coordinates belong on its unit: the
# live certificate pair ./bao.nix keeps, and the server CA id it loads.
# swarm mints that one), so the store's coordinates belong on its unit. The
# same three credential ids ./bao.nix and ./forge-token.nix load.
baoCfg = config.services.hyperhive.agent.bao;
storeConfigured = baoCfg.addr != null;
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
# **The enable signal.** Matrix is on for this agent exactly when it has at
# least one account, because an account is the only thing the daemon has to
@ -306,11 +308,7 @@ in
description = "long-running matrix-sdk Client + MCP daemon";
wantedBy = [ "multi-user.target" ];
before = [ "hive-agent.service" ];
# The live certificate pair exists once ./bao.nix's unit has run.
after = [
"network-online.target"
]
++ lib.optional storeConfigured "hive-agent-bao-identity.service";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
# In-agent todo socket the harness serves (loose-ends v2): the
@ -330,8 +328,8 @@ in
// lib.optionalAttrs storeConfigured {
HIVE_AGENT_NAME = userName;
BAO_ADDR = baoCfg.addr;
BAO_CLIENT_CERT = baoCfg.clientCertFile;
BAO_CLIENT_KEY = baoCfg.clientKeyFile;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
# Named even when no CA was delivered: a bare `LoadCredential=` is
# non-fatal when absent, and the daemon treats a missing or empty file
# as "use the container's own trust store".
@ -402,10 +400,14 @@ in
User = userName;
Group = userName;
}
# The store's server CA, imported by id so systemd materialises it under
# this unit's `User=` — the same bare form ./forge-token.nix uses.
# This agent's own certificate, imported by id so systemd materialises it
# under this unit's `User=` — the same bare form ./forge-token.nix uses.
// lib.optionalAttrs storeConfigured {
LoadCredential = [ serverCaCredential ];
LoadCredential = [
certCredential
keyCredential
serverCaCredential
];
};
};

View file

@ -381,8 +381,6 @@ in
description = "subagent task runner + MCP daemon for hive-subagent";
wantedBy = [ "multi-user.target" ];
before = [ "hive-agent.service" ];
# The live certificate pair exists once ./bao.nix's unit has run.
after = lib.optional subagentReadsStore "hive-agent-bao-identity.service";
# A subagent task runs its own nested `claude` invocation (via
# `hive-claude`), which needs to resolve `claude` itself off PATH —
# same reasoning as `hive-bash-daemon`'s `path` above, even though
@ -444,8 +442,8 @@ in
# Paths and a name only. `%d` is this unit's own credentials directory.
HIVE_AGENT_NAME = userName;
BAO_ADDR = config.services.hyperhive.agent.bao.addr;
BAO_CLIENT_CERT = config.services.hyperhive.agent.bao.clientCertFile;
BAO_CLIENT_KEY = config.services.hyperhive.agent.bao.clientKeyFile;
BAO_CLIENT_CERT = "%d/hive-agent-bao-cert";
BAO_CLIENT_KEY = "%d/hive-agent-bao-key";
BAO_CACERT = "%d/hive-agent-bao-server-ca";
};
serviceConfig = {
@ -483,9 +481,13 @@ in
MemoryHigh = toString subagentMemoryHigh;
}
// lib.optionalAttrs subagentReadsStore {
# Bare id: inherits the credential the container manager passed in,
# Bare ids: inherits the credentials the container manager passed in,
# the same form ./queue-identity.nix uses.
LoadCredential = [ "hive-agent-bao-server-ca" ];
LoadCredential = [
"hive-agent-bao-cert"
"hive-agent-bao-key"
"hive-agent-bao-server-ca"
];
}
//
lib.optionalAttrs

View file

@ -18,7 +18,7 @@
# before its first queue connection and again on every reconnect attempt
# (`hive-agent`'s `swarm_queue`), so a re-minted secret is picked up without a
# restart. What this module hands the harness is the store's address and the
# paths of the live certificate pair ./bao.nix keeps.
# paths of its certificate.
#
# ⛔ The store certificate is for reaching the store and nothing else. It is
# never presented to the queue: what goes to the queue is the secret read
@ -37,9 +37,11 @@ let
# role from it.
agentName = config.services.hyperhive.agent.user.name;
# The server CA id ./bao.nix loads; the id is
# `hive_c0re::lifecycle::agent_identity`'s and a rename is a rename there
# too.
# The same three ids ./bao.nix loads, because there is one store identity
# per agent; the ids are `hive_c0re::lifecycle::agent_identity`'s and a
# rename is a rename there too.
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
# The store's address is the whole switch, exactly as in ./bao.nix — and
@ -52,18 +54,20 @@ in
{
config = lib.mkIf configured {
systemd.services.hive-agent = {
# A bare id, no path: the terse `LoadCredential=` form that inherits a
# Bare ids, no paths: the terse `LoadCredential=` form that inherits a
# credential the service *manager* received, which is what the container
# manager passed in. ./bao.nix and ./queue.nix state the same shape.
serviceConfig.LoadCredential = [ serverCaCredential ];
# The live certificate pair exists once ./bao.nix's unit has run.
after = [ "hive-agent-bao-identity.service" ];
serviceConfig.LoadCredential = [
certCredential
keyCredential
serverCaCredential
];
# Paths and a name only. `%d` is this unit's own credentials directory.
environment = {
HIVE_AGENT_NAME = agentName;
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = cfg.clientCertFile;
BAO_CLIENT_KEY = cfg.clientKeyFile;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
# Named even when no CA was delivered: a bare `LoadCredential=` is
# non-fatal when absent, and the harness treats a missing or empty file
# as "use the container's own trust store".

View file

@ -75,17 +75,19 @@ let
}
{
# One store identity per agent: the fetch presents the same certificate
# the identity check keeps current. Every `BAO_*` value is an address or
# a path, never a value.
# the identity check proves. Every `BAO_*` value is an address or a
# `%d/` path, never a value.
name = "the fetch presents the agent's own store identity, by path";
ok =
let
u = fetchUnit agentForgeBao;
e = u.environment;
in
e.BAO_ADDR == baoAddr
&& e.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& e.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key";
builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
&& e.BAO_ADDR == baoAddr
&& e.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& e.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
}
{
# The field is the secret. It goes to a file by redirect and is never
@ -199,7 +201,8 @@ let
in
u.serviceConfig.User == name
&& u.serviceConfig.UMask == "0077"
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert";
&& builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert";
}
{
# Files a hive wrote keep working until the operator re-links them.

View file

@ -82,9 +82,11 @@ let
in
u.serviceConfig.User == name
&& u.serviceConfig.UMask == "0077"
&& builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
&& u.environment.BAO_ADDR == baoAddr
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key";
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
}
{
# A file a hive wrote keeps working until the operator links a token in

View file

@ -116,17 +116,20 @@ let
# The daemon reads this agent's tokens from the store ITSELF, as itself,
# from inside this container — `main` too, since the swarm mints it and
# no hive does. So it needs the same identity ./agent-forge-bao.nix's
# fetch carries: the live pair, and the server CA from its credentials.
# fetch carries, in its own credentials directory.
name = "the matrix daemon carries this agent's own store identity";
ok =
let
u = daemon agentMatrixBao;
in
u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ]
u.serviceConfig.LoadCredential == [
"hive-agent-bao-cert"
"hive-agent-bao-key"
"hive-agent-bao-server-ca"
]
&& u.environment.BAO_ADDR == "https://bao.t.local:8200"
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
&& builtins.elem "hive-agent-bao-identity.service" u.after;
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
}
{
# The agent's name, and nothing derived from it: the daemon builds its
@ -140,24 +143,16 @@ let
&& !(lib.any (lib.hasInfix "swarm/agents") (lib.attrValues e));
}
{
# 🩸 A secret is a path: every `BAO_*` entry is the store's address, a
# file under this unit's own credentials directory, or the one file of
# the live pair ./bao.nix keeps that its name stands for — never bytes
# in an environment `/proc/<pid>/environ` publishes. The live pair is
# spelled out rather than read from the option, so a default that
# turned into a value would fail here too.
# 🩸 A secret is a path: every `BAO_*` entry is the store's address or a
# file under this unit's own credentials directory, never bytes in an
# environment `/proc/<pid>/environ` publishes.
name = "the matrix daemon is handed store paths and never store values";
ok =
let
store = lib.filterAttrs (n: _: lib.hasPrefix "BAO_" n) (daemon agentMatrixBao).environment;
livePair = {
BAO_CLIENT_CERT = "/run/hive-agent-bao-identity/current/cert";
BAO_CLIENT_KEY = "/run/hive-agent-bao-identity/current/key";
};
isPath =
n: n == "BAO_ADDR" || lib.hasPrefix "%d/" store.${n} || (livePair.${n} or null) == store.${n};
in
store != { } && lib.all isPath (lib.attrNames store);
store != { }
&& lib.all (n: n == "BAO_ADDR" || lib.hasPrefix "%d/" store.${n}) (lib.attrNames store);
}
{
# A token the swarm mints or replaces in the store changes no file, so

View file

@ -87,30 +87,20 @@ let
&& builtins.elem "hive-agent-bao-server-ca" c;
}
{
# The live pair sits in the unit's own runtime directory, which outlives
# each run so the timer's next run logs in with what the last one
# wrote. The address is the option's value rather than a literal that
# agrees with it today.
name = "the identity check presents the live pair it keeps in its runtime directory";
# `%d` and not a path under the agent's state dir: the host file is
# `0600` to the hive daemon, so the only copy this unprivileged unit can
# open is the one systemd puts in its own credentials directory. The
# address is the option's value rather than a literal that agrees with
# it today.
name = "the identity check presents its certificate out of the credentials directory";
ok =
let
u = agentBaoIdentity agentBao;
e = u.environment;
e = (agentBaoIdentity agentBao).environment;
in
e.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& e.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
&& u.serviceConfig.RuntimeDirectory == "hive-agent-bao-identity"
&& u.serviceConfig.RuntimeDirectoryPreserve == "yes"
e.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& e.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"
&& e.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr;
}
{
# A leaf the controller re-issues reaches a running container only
# through this re-run, and an active unit cannot be started again.
name = "the identity check re-runs on a timer";
ok =
(agentBaoIdentity agentBao).serviceConfig.RemainAfterExit == false
&& agentBao.systemd.timers.hive-agent-bao-identity.timerConfig.OnUnitInactiveSec == "10min";
}
{
# Same 403-not-a-miss reason as the hive-side readers: the path
# `swarm_secret_client::mtls::identity_path` builds is the one this
@ -143,22 +133,22 @@ let
{
# Nothing about the identity may be printed, and the read-back is where
# that could slip: `bao kv get` on this path answers with certificate
# material and a private key. The answer goes to a file in the unit's
# own `0700` directory.
# material, and the object beside it is a private key. The check needs
# only whether the read succeeded.
#
# The path goes through `lib.escapeShellArg` here for the same reason the
# module passes it through one — that helper decides whether an argument
# needs quotes at all, and this one (only `[a-z0-9/-]`) comes back bare.
# Spelling the quotes in by hand asserts a rendering nixpkgs chooses
# rather than the redirect this property is about.
name = "the identity check writes what it reads back to a file";
name = "the identity check discards what it reads back";
ok =
let
m = agentBao;
name = m.services.hyperhive.agent.user.name;
arg = lib.escapeShellArg "secret/swarm/agents/${name}/bao-mtls";
in
lib.hasInfix "bao kv get -format=json ${arg} >\"$raw\"" (agentBaoIdentity m).script;
lib.hasInfix "bao kv get -field=cert ${arg} >/dev/null" (agentBaoIdentity m).script;
}
{
# The absence arm, and what makes the four above able to fail. An agent
@ -170,16 +160,17 @@ let
}
{
# The harness reads its per-agent queue secret from the store itself,
# under the same store identity the check above keeps current, because
# there is one identity per agent.
# under the same store identity the check above proves, because there is
# one identity per agent.
name = "the harness presents the agent's own store identity";
ok =
let
u = agentHarness agentBao;
in
u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
&& builtins.elem "hive-agent-bao-identity.service" u.after
builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"
&& u.environment.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr;
}
{
@ -209,7 +200,7 @@ let
in
!(u.environment ? BAO_ADDR)
&& !(u.environment ? HIVE_AGENT_NAME)
&& !(builtins.elem "hive-agent-bao-server-ca" (u.serviceConfig.LoadCredential or [ ]));
&& !(builtins.elem "hive-agent-bao-cert" (u.serviceConfig.LoadCredential or [ ]));
}
];
in

View file

@ -113,11 +113,15 @@ let
lib.all
(
u:
u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ]
u.serviceConfig.LoadCredential == [
"hive-agent-bao-cert"
"hive-agent-bao-key"
"hive-agent-bao-server-ca"
]
&& u.environment.HIVE_AGENT_NAME == "a1"
&& u.environment.BAO_ADDR == "https://bao.t.local:8200"
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"
)
[
(subagent claudeBao)
@ -131,11 +135,15 @@ let
let
u = subagent opencodeBao;
in
u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ]
u.serviceConfig.LoadCredential == [
"hive-agent-bao-cert"
"hive-agent-bao-key"
"hive-agent-bao-server-ca"
]
&& u.environment.HIVE_AGENT_NAME == "a1"
&& u.environment.BAO_ADDR == "https://bao.t.local:8200"
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key";
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
}
{
name = "an opencode agent with no store hands its subagent daemon no store identity";

View file

@ -1,7 +1,6 @@
# `checks.script-test-agent-bao-fetch` — runs the agent units that log in to
# the swarm secret store and fetch a secret, ../agent-modules/forge-token.nix
# and ../agent-modules/bao.nix, against a stub `bao`. The cases are in
# ./agent-bao-fetch.sh.
# `checks.script-test-agent-bao-fetch` — runs the agent unit that logs in to
# the swarm secret store and fetches a secret, ../agent-modules/forge-token.nix,
# against a stub `bao`. The cases are in ./agent-bao-fetch.sh.
#
# What runs is the unit's rendered `ExecStart`, on the unit's own `PATH` with
# the stub in front. The one edit is the unit's `/run/<unit>/` prefix, moved
@ -43,16 +42,11 @@ let
# Answers `login` and `kv get` from `FAKE_BAO_*` variables and logs every
# call. A `kv` call without the token `login` printed fails, so a script that
# drops `BAO_TOKEN` between the two cannot pass. A login presenting the
# certificate `FAKE_BAO_REFUSE_CERT` is refused.
# drops `BAO_TOKEN` between the two cannot pass.
fakeBao = pkgs.writeShellScriptBin "bao" ''
echo "$*" >> "$FAKE_BAO_LOG"
case "$1" in
login)
if [ -n "''${FAKE_BAO_REFUSE_CERT-}" ] && [ "$(<"$BAO_CLIENT_CERT")" = "$FAKE_BAO_REFUSE_CERT" ]; then
echo "fake bao: certificate refused" >&2
exit 2
fi
printf '%s' "$FAKE_BAO_LOGIN_ERR" >&2
printf '%s' "$FAKE_BAO_LOGIN_OUT"
exit "$FAKE_BAO_LOGIN_RC"
@ -76,7 +70,6 @@ in
pkgs.runCommand "hyperhive-script-test-agent-bao-fetch"
(
unitEnv "FORGE" "hive-agent-forge-token"
// unitEnv "IDENT" "hive-agent-bao-identity"
// {
FAKE_BAO_BIN = "${fakeBao}/bin";
}

View file

@ -36,17 +36,8 @@ setup() {
log=$dir/bao.log
mkdir -m 0700 "$rt"
mkdir "$creds"
# Per-case stubs that shadow the unit's own tools; empty unless a case adds one.
mkdir "$dir/bin"
printf cert >"$creds/hive-agent-bao-cert"
printf key >"$creds/hive-agent-bao-key"
# The identity pair the unit presents: the delivered one for the fetch, the
# live copy it keeps for the identity unit.
if [ "$prefix" = IDENT ]; then
client_cert=$rt/current/cert client_key=$rt/current/key
else
client_cert=$creds/hive-agent-bao-cert client_key=$creds/hive-agent-bao-key
fi
: >"$log"
if ! sed "s|/run/$unit/|$rt/|g" "${!script_var}" >"$dir/script"; then
echo "cannot read the rendered script for $unit" >&2
@ -62,7 +53,6 @@ setup() {
kv_rc=0
kv_out=the-secret
kv_err=
refuse_cert=
}
go() {
@ -71,10 +61,8 @@ go() {
umask 0377
cd "$dir" || exit 99
exec env -i \
PATH="$dir/bin:$FAKE_BAO_BIN:$unit_path" \
PATH="$FAKE_BAO_BIN:$unit_path" \
BAO_ADDR="$bao_addr" \
BAO_CLIENT_CERT="$client_cert" \
BAO_CLIENT_KEY="$client_key" \
CREDENTIALS_DIRECTORY="$creds" \
FAKE_BAO_LOG="$log" \
FAKE_BAO_LOGIN_RC="$login_rc" \
@ -83,7 +71,6 @@ go() {
FAKE_BAO_KV_RC="$kv_rc" \
FAKE_BAO_KV_OUT="$kv_out" \
FAKE_BAO_KV_ERR="$kv_err" \
FAKE_BAO_REFUSE_CERT="$refuse_cert" \
"$dir/script"
) >"$dir/out" 2>"$dir/err"
rc=$?
@ -247,141 +234,6 @@ expect_out "fetched this agent's forge token"
expect_file "$rt/token" the-secret
expect_no_file "$rt/token.new"
ident_path=secret/swarm/agents/a1/bao-mtls
ident_kv_call="kv get -format=json $ident_path"
leaf() { printf '{"data":{"data":{"cert":"%s","key":"%s"}}}' "$1" "$2"; }
expect_pair() {
expect_file "$rt/current/cert" "$1"
expect_file "$rt/current/key" "$2"
}
expect_pairs() {
local d n=0
for d in "$rt"/pair.*; do
if [ -d "$d" ]; then n=$((n + 1)); fi
done
if [ "$n" != "$1" ]; then fail "$n pair directories left, expected $1"; fi
}
# live_pair <name> <cert> <key> — a pair an earlier run made live.
live_pair() {
mkdir -m 0700 "$rt/pair.$1"
printf '%s' "$2" >"$rt/pair.$1/cert"
printf '%s' "$3" >"$rt/pair.$1/key"
chmod 0400 "$rt/pair.$1/cert" "$rt/pair.$1/key"
ln -sfn "pair.$1" "$rt/current"
}
# stop_at <n> — the n-th `ln` or `mv` the script runs fails, which leaves
# things as a run killed just before that step would.
stop_at() {
local tool real
mkdir "$dir/steps"
for tool in ln mv; do
real=$(PATH=$unit_path command -v "$tool")
cat >"$dir/bin/$tool" <<STUB
#!$BASH
mktemp -p "$dir/steps" >/dev/null
steps=("$dir/steps"/*)
if [ "\${#steps[@]}" -ge "$1" ]; then exit 1; fi
exec "$real" "\$@"
STUB
chmod +x "$dir/bin/$tool"
done
}
setup IDENT "no store identity delivered"
: >"$creds/hive-agent-bao-cert"
go
expect_rc 1
expect_err "has no store identity"
expect_calls
if [ -e "$rt/current" ]; then fail "a live pair was made from nothing"; fi
setup IDENT "a first run makes the delivered pair live"
kv_out=$(leaf cert key)
go
expect_rc 0
expect_out "is unchanged"
expect_calls "$login_call" "$ident_kv_call"
if [ ! -L "$rt/current" ]; then fail "$rt/current is not a symlink"; fi
expect_pair cert key
if [ "$(stat -L -c %a "$rt/current/key")" != 400 ]; then fail "key is mode $(stat -L -c %a "$rt/current/key"), expected 400"; fi
expect_pairs 1
expect_no_file "$rt/bao-mtls.json"
setup IDENT "the live pair outlives the delivered one"
live_pair old cert2 key2
kv_out=$(leaf cert2 key2)
go
expect_rc 0
expect_out "is unchanged"
expect_pair cert2 key2
expect_pairs 1
setup IDENT "a re-issued leaf goes live once a login with it succeeds"
kv_out=$(leaf cert2 key2)
go
expect_rc 0
expect_out "replaced this agent's store certificate"
expect_calls "$login_call" "$ident_kv_call" "$login_call"
expect_pair cert2 key2
# The replaced pair stays for a reader that resolved the link before the swap.
expect_pairs 2
go
expect_rc 0
expect_out "is unchanged"
expect_pairs 1
setup IDENT "a re-issued leaf the store refuses does not go live"
kv_out=$(leaf cert2 key2)
refuse_cert=cert2
go
expect_rc 1
expect_err "refused the certificate at $ident_path"
expect_pair cert key
expect_pairs 1
setup IDENT "an object without a key keeps the live pair"
kv_out='{"data":{"data":{"cert":"cert2"}}}'
go
expect_rc 1
expect_err "holds no certificate and key"
expect_pair cert key
expect_pairs 1
setup IDENT "what a stopped run left behind is removed"
live_pair old cert key
mkdir -m 0700 "$rt/pair.stale"
printf cert2 >"$rt/pair.stale/cert"
ln -s pair.stale "$rt/current.new"
kv_out=$(leaf cert key)
go
expect_rc 0
expect_pair cert key
expect_pairs 1
if [ -L "$rt/current.new" ]; then fail "current.new left behind"; fi
# The swap is atomic: wherever a run stops, the link names one whole pair,
# and the next run finishes the job.
for n in 1 2 3; do
setup IDENT "a swap stopped at its step $n leaves one whole pair live"
live_pair old cert key
kv_out=$(leaf cert2 key2)
stop_at "$n"
go
got="$(<"$rt/current/cert") $(<"$rt/current/key")"
case "$got" in
"cert key" | "cert2 key2") ;;
*) fail "the live pair is '$got', one file of each" ;;
esac
rm -f "$dir/bin/ln" "$dir/bin/mv"
go
expect_rc 0
expect_pair cert2 key2
done
if [ "$failures" -ne 0 ]; then
echo "script-test-agent-bao-fetch: $failures failed assertions over $count runs" >&2
exit 1

View file

@ -8,12 +8,12 @@
//! the controller's own record, [`queue::AgentCredential::minted_at`]; a secret
//! without one is stamped, value unchanged ([`SecretStep::Backfill`]).
//!
//! A running agent re-reads its certificate every ten minutes under the one it
//! holds (`nix/agent-modules/bao.nix`); the old one stays valid until it
//! expires. A new secret reaches the agent at its next start, when the
//! container fetches it. The old secret stops working at once: the queue checks
//! it only at `CONNECT`, so an open connection is unaffected, but a reconnect
//! before that restart presents the old secret and is denied.
//! Either replacement reaches the agent at its next start, when the container
//! is handed the certificate and fetches the secret; nothing pulls either into
//! a running container. The old certificate stays valid until it expires. The
//! old secret stops working at once: the queue checks it only at `CONNECT`, so
//! an open connection is unaffected, but a reconnect before that restart
//! presents the old secret and is denied.
//!
//! [`spawn`]'s pass, once the queue is up and every [`RECONCILE_INTERVAL`],
//! inserts a job per agent with anything due: `MintAgentIdentity` for the

View file

@ -9,11 +9,9 @@
//! container.
//!
//! 🔑 The recursion this shape looks like it has — *a credential fetched from
//! the store that is what opens the store* — is broken by who reads it first.
//! The first reader is the hive, which already holds its own leaf, so nothing
//! has to already be an agent in order to obtain an agent's identity. After
//! that the agent re-reads its own path under the leaf it holds, which picks
//! up a re-issued leaf only while the held one is still valid.
//! the store that is what opens the store* — is broken by who reads it. The
//! reader is the hive, never the agent, and a hive already holds its own leaf.
//! Nothing has to already be an agent in order to obtain an agent's identity.
use serde::{Deserialize, Serialize};