agents: pull the forge token from bao; drop tea-login
forge-token.nix fetches swarm/agents/<agent>/forge-token under the agent's own store identity into /run/hive-agent-forge-token/token, and re-fetches on a timer so a rotation lands. hive-forge, the git credential helper, hive-forge-notify, forge-avatar-sync and the web UI read that file first and fall back to <state>/forge-token. tea-login is deleted: it copied the token into ~/.config/tea, which docs/swarm/credentials.md forbids for a store secret. hive-forge covers the same verbs. swarmctl gains agent mint-forge-token. Refs #3782
This commit is contained in:
parent
52c8c0b0de
commit
dd32a395f7
16 changed files with 501 additions and 156 deletions
|
|
@ -46,6 +46,7 @@ in
|
|||
./dashboard-links.nix
|
||||
./docs.nix
|
||||
./forge.nix
|
||||
./forge-token.nix
|
||||
./frontend.nix
|
||||
./github.nix
|
||||
./logs.nix
|
||||
|
|
|
|||
182
nix/agent-modules/forge-token.nix
Normal file
182
nix/agent-modules/forge-token.nix
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# This agent's own forge access token, fetched from the swarm secret store by
|
||||
# the agent itself.
|
||||
#
|
||||
# `swarm-controller` mints the token with the forge's admin API and writes it
|
||||
# to `swarm/agents/<agent>/forge-token`
|
||||
# (`swarm_secret_client::forge::agent_token_path`); no hive is in that chain.
|
||||
# This unit logs in to the store with the certificate ./bao.nix already proves
|
||||
# it can log in with, and reads its own path. The shape is ./queue-identity.nix's,
|
||||
# for the same reason: a hive handing the token over would be the hive reading
|
||||
# a secret on the agent's behalf.
|
||||
#
|
||||
# Unlike the queue credential this one rotates: the controller replaces the
|
||||
# token when the forge's copy stops matching the stored one. So the unit is
|
||||
# re-run by a timer, and it swaps the file in by rename, only when the value
|
||||
# changed, so a reader never sees half a token and a watcher on the file
|
||||
# (forge-avatar-sync.path) fires only on a real change.
|
||||
#
|
||||
# Consumers read `services.hyperhive.agent.forge.tokenFile` first and fall back
|
||||
# to `<state>/forge-token`, the file the hive wrote before this existed.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.agent.bao;
|
||||
|
||||
# The name `swarm-controller` minted the token under — see ./queue-identity.nix.
|
||||
agentName = config.services.hyperhive.agent.user.name;
|
||||
|
||||
# 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";
|
||||
|
||||
# The nix half of `swarm_secret_client::forge::agent_token_path` plus
|
||||
# `path::MOUNT`.
|
||||
tokenPath = "secret/swarm/agents/${agentName}/forge-token";
|
||||
|
||||
runtimeDir = unitName;
|
||||
tokenFile = "/run/${runtimeDir}/token";
|
||||
# Beside the token, so the rename that replaces it stays in one directory.
|
||||
stagingFile = "/run/${runtimeDir}/token.new";
|
||||
|
||||
# The store's address is the whole switch, as in ./bao.nix and
|
||||
# ./queue-identity.nix.
|
||||
configured = cfg.addr != null;
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.agent.forge.tokenFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
readOnly = true;
|
||||
default = tokenFile;
|
||||
description = ''
|
||||
Path this agent's own forge token is fetched to. Read-only: it is a
|
||||
fact about where `${unitName}.service` writes, not a knob.
|
||||
|
||||
🩸 A PATH and never a value. The file is `0400` to the agent user.
|
||||
|
||||
The file exists only once the swarm has minted a token for this agent
|
||||
and the agent has a store identity to fetch it with. Until then every
|
||||
consumer falls back to `$HYPERHIVE_STATE_DIR/forge-token`.
|
||||
'';
|
||||
};
|
||||
|
||||
config = lib.mkIf configured {
|
||||
# Every unit and the bash-task runner (where `hive-forge` and `git` run)
|
||||
# resolve the token through this, the same way they find
|
||||
# `$HYPERHIVE_STATE_DIR`. A path, never the value.
|
||||
systemd.globalEnvironment.HIVE_FORGE_TOKEN_FILE = tokenFile;
|
||||
environment.variables.HIVE_FORGE_TOKEN_FILE = tokenFile;
|
||||
|
||||
systemd.services.${unitName} = {
|
||||
description = "fetch this agent's own forge token from the secret store";
|
||||
after = [
|
||||
"network.target"
|
||||
# Ordering only, for the reason ./queue-identity.nix gives.
|
||||
"hive-agent-bao-identity.service"
|
||||
];
|
||||
before = [ "hive-forge-notify.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [
|
||||
pkgs.openbao
|
||||
pkgs.coreutils
|
||||
pkgs.diffutils
|
||||
];
|
||||
startLimitBurst = 4;
|
||||
startLimitIntervalSec = 300;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
# Not `RemainAfterExit`, unlike the queue fetch: the timer below has to
|
||||
# be able to start this unit again, and an active unit cannot be
|
||||
# started. `RuntimeDirectoryPreserve` is what keeps the directory, and
|
||||
# the token in it, alive between runs instead.
|
||||
RemainAfterExit = false;
|
||||
TimeoutStartSec = 30;
|
||||
Restart = "on-failure";
|
||||
RestartSec = 15;
|
||||
User = agentName;
|
||||
Group = agentName;
|
||||
RuntimeDirectory = runtimeDir;
|
||||
RuntimeDirectoryMode = "0700";
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
UMask = "0377";
|
||||
LoadCredential = [
|
||||
certCredential
|
||||
keyCredential
|
||||
serverCaCredential
|
||||
];
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = cfg.addr;
|
||||
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 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
|
||||
done
|
||||
|
||||
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
|
||||
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
|
||||
fi
|
||||
|
||||
err="$(mktemp)"
|
||||
trap 'rm -f "$err" ${lib.escapeShellArg stagingFile}' EXIT
|
||||
|
||||
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 cat "$err" >&2; fi
|
||||
exit 1
|
||||
fi
|
||||
export BAO_TOKEN
|
||||
|
||||
# 🩸 Degrades rather than fails, for the reason ./queue-identity.nix
|
||||
# gives: the policy stanza that let the login read `bao-mtls` covers
|
||||
# this path too, so a refusal here is a token not minted yet. The
|
||||
# file already in place, if any, is kept: a store that is briefly
|
||||
# unreachable must not take a working token away.
|
||||
#
|
||||
# ⚠️ Written by redirect, never echoed: the field is the secret. The
|
||||
# staging file is removed first so the redirect creates it — a file
|
||||
# `UMask=0377` left behind is `0400` and could not be reopened for
|
||||
# writing.
|
||||
rm -f ${lib.escapeShellArg stagingFile}
|
||||
if ! bao kv get -field=value ${lib.escapeShellArg tokenPath} > ${lib.escapeShellArg stagingFile} 2>"$err"; then
|
||||
echo "no forge token at ${tokenPath} yet; consumers keep using the state-dir token if there is one." >&2
|
||||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if cmp -s ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg tokenFile}; then
|
||||
echo "this agent's forge token at ${tokenPath} is unchanged."
|
||||
exit 0
|
||||
fi
|
||||
mv -f ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg tokenFile}
|
||||
echo "fetched this agent's forge token from ${tokenPath}."
|
||||
'';
|
||||
};
|
||||
|
||||
# The controller re-checks every agent's token every five minutes; this
|
||||
# picks up a rotation within about ten more.
|
||||
systemd.timers.${unitName} = {
|
||||
description = "re-fetch this agent's forge token from the secret store";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnUnitInactiveSec = "10min";
|
||||
RandomizedDelaySec = "1min";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# In-container forge (Forgejo) integration: the `tea` CLI login
|
||||
# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge
|
||||
# avatar sync.
|
||||
# In-container forge (Forgejo) integration: the `hive-forge` verb CLI on
|
||||
# PATH, the git credential helper, the notification poller, and the icon →
|
||||
# forge avatar sync. The token itself is fetched by ./forge-token.nix.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
|
|
@ -9,7 +9,18 @@
|
|||
}:
|
||||
let
|
||||
userName = config.services.hyperhive.agent.user.name;
|
||||
homeDir = "/home/${userName}";
|
||||
# The token the agent fetched from the swarm secret store
|
||||
# (./forge-token.nix), then the state-dir file the hive used to write,
|
||||
# which is still the only copy for an agent without a store identity.
|
||||
# Both are PATHS; each reader below takes the first that holds a token.
|
||||
fetchedTokenFile = config.services.hyperhive.agent.forge.tokenFile;
|
||||
stateTokenFile = "/agents/${userName}/state/forge-token";
|
||||
pickTokenFile = ''
|
||||
TOKEN_FILE=
|
||||
for f in ${lib.escapeShellArg fetchedTokenFile} ${lib.escapeShellArg stateTokenFile}; do
|
||||
if [ -s "$f" ] && [ -r "$f" ]; then TOKEN_FILE="$f"; break; fi
|
||||
done
|
||||
'';
|
||||
# Same 512×512 rasterization of the agent icon the matrix avatar
|
||||
# sync uses (./matrix.nix — identical derivation, same store path).
|
||||
# Only forced when an icon is configured AND a forge is (the avatar-sync
|
||||
|
|
@ -20,7 +31,7 @@ let
|
|||
|
||||
# git credential helper for the hive forge --- the exact shape
|
||||
# `./github.nix` uses for github.com, for the same two reasons: the token
|
||||
# is read from the agent's state file AT INVOCATION (so a re-issued token
|
||||
# is read from its file AT INVOCATION (so a re-issued token
|
||||
# takes effect with no rebuild), and the token PATH is baked in at build
|
||||
# time rather than read from the environment, because claude's Bash tool
|
||||
# runs `bash -c` in a minimal env that never sources `/etc/set-environment`.
|
||||
|
|
@ -32,8 +43,8 @@ let
|
|||
gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-forge" ''
|
||||
# git credential-helper protocol: only `get` needs an answer.
|
||||
[ "''${1:-}" = "get" ] || exit 0
|
||||
TOKEN_FILE="/agents/${userName}/state/forge-token"
|
||||
[ -r "$TOKEN_FILE" ] || exit 0
|
||||
${pickTokenFile}
|
||||
[ -n "$TOKEN_FILE" ] || exit 0
|
||||
printf 'username=%s\n' ${lib.escapeShellArg userName}
|
||||
printf 'password=%s\n' "$(cat "$TOKEN_FILE")"
|
||||
'';
|
||||
|
|
@ -44,20 +55,16 @@ in
|
|||
default = null;
|
||||
example = "http://forge.internal:3000";
|
||||
description = ''
|
||||
Base URL of the hyperhive-managed Forgejo. Used at container
|
||||
boot by a oneshot systemd unit that calls
|
||||
`tea login add --url <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
|
||||
(= `/agents/<name>/state/forge-token`) so the agent's claude can
|
||||
shell out to `tea` without an extra auth dance. No-op when the
|
||||
forge-token file is missing (i.e. hive-forge isn't running on
|
||||
the host).
|
||||
Base URL of the hyperhive-managed Forgejo. Scopes the git
|
||||
credential helper to this forge, and is where the avatar sync
|
||||
uploads the agent's icon.
|
||||
|
||||
**`null` means "no forge", not "guess one".** There is deliberately
|
||||
no loopback default: the forge may run on a different host from
|
||||
the agents, and inside an agent's network namespace `localhost`
|
||||
reaches the agent rather than the forge, so a default would be a
|
||||
value that builds fine and then talks to the wrong machine.
|
||||
When this is `null` the tea-login and avatar-sync units are not
|
||||
When this is `null` the credential helper and avatar-sync units are not
|
||||
generated at all --- an absent integration, never a misdirected
|
||||
one.
|
||||
|
||||
|
|
@ -88,10 +95,6 @@ in
|
|||
];
|
||||
|
||||
environment.systemPackages = [
|
||||
# tea: gitea/forgejo CLI client. Configured at boot by the
|
||||
# tea-login oneshot below if /state/forge-token is present, so
|
||||
# claude can `tea repos create`, `tea pulls create`, etc.
|
||||
pkgs.tea
|
||||
# hive-forge <verb>: CLI wrapping common Forgejo REST API operations
|
||||
# (view, pr, issue, comment, assign, close, labels, branches, etc.).
|
||||
# The per-bin split package — narrow closure, no hivectl/wireguard.
|
||||
|
|
@ -178,94 +181,22 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# One-shot: tea config.yml from the seeded forge token. Shape
|
||||
# contract (always exit 0, no set -e, skip-silently, re-runnable):
|
||||
# docs/process/conventions.md::Best-effort oneshot services.
|
||||
# Not generated at all when no forge is configured: an absent
|
||||
# integration rather than one pointed at a guessed address.
|
||||
systemd.services.tea-login = lib.mkIf (config.services.hyperhive.agent.forge.url != null) {
|
||||
description = "configure tea CLI from hive-forge token (best-effort)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "local-fs.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||
SyslogIdentifier = "tea-login";
|
||||
};
|
||||
path = [
|
||||
pkgs.curl
|
||||
pkgs.jq
|
||||
pkgs.coreutils
|
||||
];
|
||||
environment.HOME_DIR = homeDir;
|
||||
environment.AGENT_USER = userName;
|
||||
script = ''
|
||||
# No `set -e`: best-effort posture (see docs pointer above).
|
||||
FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url}
|
||||
# $HYPERHIVE_STATE_DIR is system-wide via the meta flake.
|
||||
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "tea-login: no forge-token at $TOKEN_FILE; skipping"
|
||||
exit 0
|
||||
fi
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
# Resolve the agent username from the forge API.
|
||||
USER=$(curl -sf --max-time 5 \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
"$FORGE_URL/api/v1/user" \
|
||||
| jq -r '.login // empty' 2>/dev/null || true)
|
||||
if [ -z "$USER" ]; then
|
||||
echo "tea-login: could not resolve username from forge API; skipping"
|
||||
exit 0
|
||||
fi
|
||||
# Config under the agent user's home, chown'd to them;
|
||||
# service stays root-owned (see docs pointer above).
|
||||
CONFIG="$HOME_DIR/.config/tea/config.yml"
|
||||
mkdir -p "$(dirname "$CONFIG")" || true
|
||||
cat > "$CONFIG" << EOF
|
||||
logins:
|
||||
- name: forge
|
||||
url: $FORGE_URL
|
||||
token: $TOKEN
|
||||
default: true
|
||||
ssh_host: ""
|
||||
ssh_key: ""
|
||||
insecure: false
|
||||
ssh_agent: false
|
||||
user: $USER
|
||||
preferences:
|
||||
editor: false
|
||||
flag_defaults:
|
||||
remote: ""
|
||||
EOF
|
||||
chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true
|
||||
echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)"
|
||||
'';
|
||||
};
|
||||
|
||||
# Path-trigger sibling: re-fires forge-avatar-sync when
|
||||
# `<state>/forge-token` is written. On first agent deployment the
|
||||
# container boots before hive-c0re has provisioned the forge-token, so
|
||||
# the service fires too early and exits with "no forge-token found".
|
||||
# Without this path unit, RemainAfterExit=true would prevent systemd
|
||||
# from ever re-running the service. See
|
||||
# docs/agent-lifecycle/persistence.md::forge-avatar-sync.
|
||||
# Path-trigger sibling: re-fires forge-avatar-sync when the fetched
|
||||
# forge token (./forge-token.nix) appears or is replaced. On first
|
||||
# agent deployment the container can boot before the swarm has minted
|
||||
# the token, so the service fires too early and exits with "no
|
||||
# forge-token found". Without this path unit, nothing would ever
|
||||
# re-run it. See docs/agent-lifecycle/persistence.md::forge-avatar-sync.
|
||||
#
|
||||
# PathChanged=, not PathExists=: a PathExists= condition that already
|
||||
# holds re-activates the unit immediately every time the path unit
|
||||
# re-arms, and a oneshot re-arms it by deactivating — so it loops until
|
||||
# systemd's start limit stops it. PathChanged= does not fire on an
|
||||
# already-present path, and hive-priv writes this file in place
|
||||
# (write_state_file_nofollow: O_TRUNC, no rename), so close-after-write
|
||||
# still triggers it. Same directive and same reason as
|
||||
# swarm-controller.nix's queue-credential watcher.
|
||||
# already-present path, and does fire on the rename the fetch unit
|
||||
# swaps a new token in with. The fetch renames only when the value
|
||||
# changed, so its timer does not re-upload the avatar every tick.
|
||||
#
|
||||
# ⚠️ This agent's own token, not a glob over `/agents/*/`. Every agent's
|
||||
# state dir is visible from inside every container, so a wildcard here
|
||||
# watches paths this unit has no business reacting to.
|
||||
# The service reads `$HYPERHIVE_STATE_DIR/forge-token`; this is the same
|
||||
# file, spelled the way `tea-login` above already spells it.
|
||||
# ⚠️ This agent's own token, not a glob.
|
||||
#
|
||||
# ⚠️ Gated on the SAME condition as the service it triggers, not just on
|
||||
# the icon: a `.path` unit whose `Unit=` does not exist is a unit pulled
|
||||
|
|
@ -278,7 +209,7 @@ in
|
|||
{
|
||||
description = "trigger forge-avatar-sync when forge-token appears";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
pathConfig.PathChanged = "/agents/${userName}/state/forge-token";
|
||||
pathConfig.PathChanged = fetchedTokenFile;
|
||||
};
|
||||
|
||||
# One-shot: services.hyperhive.agent.icon → Forgejo profile avatar. Shape contract:
|
||||
|
|
@ -295,7 +226,7 @@ in
|
|||
{
|
||||
description = "sync agent icon to Forgejo user avatar (best-effort)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "tea-login.service" ];
|
||||
after = [ "hive-agent-forge-token.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = false;
|
||||
|
|
@ -309,10 +240,8 @@ in
|
|||
];
|
||||
script = ''
|
||||
FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url}
|
||||
# $HYPERHIVE_STATE_DIR is set system-wide by the meta flake
|
||||
# (systemd.globalEnvironment) to `/agents/<name>/state`.
|
||||
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
${pickTokenFile}
|
||||
if [ -z "$TOKEN_FILE" ]; then
|
||||
echo "forge-avatar-sync: no forge-token found; skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
# `/etc/hyperhive-bridge-dns` (containing the gateway IP) since
|
||||
# isolation is always on; the oneshot reads it and rewrites
|
||||
# resolv.conf on every boot. Ordered before the first DNS consumer
|
||||
# (tea-login) and the network targets so name resolution works for
|
||||
# (the forge-token fetch) and the network targets so name resolution works for
|
||||
# the very first turn.
|
||||
systemd.services.hyperhive-isolated-dns = {
|
||||
description = "point resolv.conf at the hive bridge resolver (isolated containers)";
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
# is a harmless no-op when matrix is disabled (the unit is absent).
|
||||
before = [
|
||||
"network-online.target"
|
||||
"tea-login.service"
|
||||
"hive-agent-forge-token.service"
|
||||
"hive-agent.service"
|
||||
"hive-matrix-daemon.service"
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue