hyperhive/nix/agent-modules/forge.nix
atlas dd32a395f7 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
2026-09-24 17:48:53 +02:00

269 lines
13 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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,
config,
...
}:
let
userName = config.services.hyperhive.agent.user.name;
# 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
# unit below, the one thing that references this, is gated on both).
iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } ''
rsvg-convert -f png -w 512 -h 512 ${config.services.hyperhive.agent.icon} -o $out
'';
# 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 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`.
#
# The alternative --- a token spliced into `remote.origin.url` --- is worse
# than it looks: any command that prints a remote (`git remote -v`, a push
# failure) writes the secret into `harness/bash-tasks/*.{out,err}`, which is
# bind-mounted rw into the agent and never swept.
gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-forge" ''
# git credential-helper protocol: only `get` needs an answer.
[ "''${1:-}" = "get" ] || exit 0
${pickTokenFile}
[ -n "$TOKEN_FILE" ] || exit 0
printf 'username=%s\n' ${lib.escapeShellArg userName}
printf 'password=%s\n' "$(cat "$TOKEN_FILE")"
'';
in
{
options.services.hyperhive.agent.forge.url = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "http://forge.internal:3000";
description = ''
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 credential helper and avatar-sync units are not
generated at all --- an absent integration, never a misdirected
one.
On a real hive it is always set: hive-c0re renders it into every
agent's config from the host's `HIVE_FORGE_URL` (which
`hive-c0re.nix` sets unconditionally) and refuses to render a meta
flake without it, so `null` only survives where the modules are
evaluated outside a hive --- exactly the case that has no forge.
'';
};
config = {
assertions = [
# Only a *set* value is constrained. `null` is the legitimate
# "no forge here" state (see the option doc) and is handled by
# not generating the units below, so it must not trip this.
# The empty string, by contrast, is the one non-null value the
# type permits that cannot be a URL --- it is what a caller
# supplies when they have nothing, which is precisely what `null`
# is for, so reject it and name the option.
{
assertion =
config.services.hyperhive.agent.forge.url == null
|| lib.hasPrefix "http://" config.services.hyperhive.agent.forge.url
|| lib.hasPrefix "https://" config.services.hyperhive.agent.forge.url;
message = "services.hyperhive.agent.forge.url must be an http:// or https:// URL, or null for no forge (got: \"${toString config.services.hyperhive.agent.forge.url}\")";
}
];
environment.systemPackages = [
# 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.
config.services.hyperhive.agent.packages.hive-forge
]
++ lib.optional (config.services.hyperhive.agent.forge.url != null) gitCredHelper;
# Wire the forge credential helper for `git push`, scoped to the forge
# this agent is configured for.
#
# ⚠️ THE SCOPE IS DERIVED, AND THAT IS THE ENTIRE POINT. Before this
# existed nothing rendered it, so each agent's `~/.gitconfig` accumulated a
# hand-written `[credential "<url>"]` per generation of forge address —
# append-only, none removed, and after a domain move the live one absent.
# A value interpolated at eval time follows a rename; a value captured into
# a mutable home file does not.
#
# The failure it caused is worth naming because it does not look like a
# credential problem: `git fetch` against a stale remote still SUCCEEDS
# (the old name redirects, and a public read needs no auth), so the break
# surfaces only at the first authenticated push, as
# `could not read Username for '<new host>'` — naming a host the agent was
# never configured for, which reads like DNS or TLS.
#
# Trailing slash stripped: git matches a credential section by
# scheme+host+port, and `http://host/` is not that.
#
# Nested-path binding, matching `./github.nix` — and the two MERGE rather
# than collide, because `environment.etc.<name>.text` is `lines`. Verified
# by eval with both modules defining it, not assumed: a silent last-wins
# here would drop one integration's credentials and look exactly like this
# bug again.
# ⚠️ `hive-forge`, NOT `git-credential-hive-forge`: git prepends
# `git-credential-` to a helper value that is not an absolute path, so
# the longer spelling resolves to `git-credential-git-credential-hive-
# forge` and NO helper runs. `./github.nix` always had the short form.
#
# The failure is quiet, which is why it survived: git prints the
# unresolved name to stderr and returns no credential, but a push still
# works for any agent whose personal `~/.gitconfig` names the helper by
# ABSOLUTE path — so this is masked exactly where it would be noticed,
# and bites a fresh agent that has no such file.
environment.etc."gitconfig" = lib.mkIf (config.services.hyperhive.agent.forge.url != null) {
text = ''
[credential "${lib.removeSuffix "/" config.services.hyperhive.agent.forge.url}"]
helper = hive-forge
username = ${userName}
'';
};
# Forge notification poller — a long-running sibling of
# `hive-bash-daemon` / `hive-matrix-daemon`. Polls the agent's unread
# notification list and upserts each thread as a todo on the harness's
# in-agent socket; it needs nothing else from the harness, which is why
# it is its own process rather than a task inside the serve loop.
systemd.services.hive-forge-notify = {
description = "Forgejo notification poller for this agent";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment = {
# In-agent todo socket the harness serves (loose-ends v2). Must
# match the harness's HIVE_AGENT_SOCKET (agent-service.nix) — the
# poller upserts one todo per forge thread here rather than firing
# a hive-c0re wake.
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info";
# HIVE_FORGE_URL and HYPERHIVE_STATE_DIR come from
# systemd.globalEnvironment (forwarded into every container by the
# meta flake) — the poller reads the forge base URL from the first
# and the agent's `forge-token` from under the second.
};
serviceConfig = {
ExecStart = "${config.services.hyperhive.agent.packages.hive-forge-notify}/bin/hive-forge-notify";
SyslogIdentifier = "hive-forge-notify";
# `on-failure`, NOT `always`: an agent with no forge account is a
# supported configuration, and the poller reports that by logging
# why and exiting 0. Under `always` that clean exit would become a
# restart loop on every forge-less agent. A crash still restarts.
Restart = "on-failure";
RestartSec = 5;
User = userName;
Group = userName;
};
};
# 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 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.
#
# ⚠️ 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
# into multi-user.target that can only ever fail to activate. The two
# halves are one feature and appear together or not at all --- which is
# what `forge.url`'s own option doc promises.
systemd.paths.forge-avatar-sync =
lib.mkIf
(config.services.hyperhive.agent.icon != null && config.services.hyperhive.agent.forge.url != null)
{
description = "trigger forge-avatar-sync when forge-token appears";
wantedBy = [ "multi-user.target" ];
pathConfig.PathChanged = fetchedTokenFile;
};
# One-shot: services.hyperhive.agent.icon → Forgejo profile avatar. Shape contract:
# docs/process/conventions.md::Best-effort oneshot services.
# RemainAfterExit = false so the .path trigger above can re-fire
# this unit when the forge-token arrives after boot. The PNG is
# rasterized at build time (`iconPng`, shared shape with the matrix
# avatar sync), so the unit only exists when an icon is configured
# and needs no librsvg at runtime — Forgejo's Go image library
# can't decode SVG, hence PNG.
systemd.services.forge-avatar-sync =
lib.mkIf
(config.services.hyperhive.agent.icon != null && config.services.hyperhive.agent.forge.url != null)
{
description = "sync agent icon to Forgejo user avatar (best-effort)";
wantedBy = [ "multi-user.target" ];
after = [ "hive-agent-forge-token.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = false;
# Pin the journal identity (else it's the `script` store-path wrapper).
SyslogIdentifier = "forge-avatar-sync";
};
path = [
pkgs.curl
pkgs.coreutils
pkgs.jq
];
script = ''
FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url}
${pickTokenFile}
if [ -z "$TOKEN_FILE" ]; then
echo "forge-avatar-sync: no forge-token found; skipping"
exit 0
fi
TOKEN=$(cat "$TOKEN_FILE")
IMAGE=$(base64 -w 0 < ${iconPng})
# Forgejo POST /user/avatar expects {"image":"<base64>"} — just the
# raw base64 string, NOT a data URI (data:image/png;base64,...).
# Use jq to build the payload so the large base64 value is safely quoted.
PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}')
RESP=$(curl -sf --max-time 10 \
-X POST "$FORGE_URL/api/v1/user/avatar" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
-w "\n%{http_code}" 2>/dev/null || true)
CODE=$(printf '%s' "$RESP" | tail -1)
if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then
echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)"
else
echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)"
fi
'';
};
};
}