Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-09-24 16:35:40 +02:00 • committed by mara
commit dd32a395f7
16 changed files with 501 additions and 156 deletions

View file

@ -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