hyperhive/nix/agent-modules/forge.nix
atlas 399c6f7422 agent: derive the forge git credential scope instead of hand-writing it
Nothing in the tree rendered a `[credential "<forge>"]` entry for an
agent, so every agent's `~/.gitconfig` accumulated one by hand, per
generation of forge address. Append-only, none ever removed, and after
the domain move the live one absent entirely:

    [credential "http://forge.<old-hive>"]
    [credential "http://localhost:3000"]

The absence of a writer is the defect. A value interpolated at eval time
follows a rename; a value captured into a mutable home file does not.
hive-c0re's own gitconfig already derives its scope from
`swarm.forge.domain` and moved correctly for exactly that reason.

What made it expensive to diagnose is that it does not present as 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, long after the move, as
`could not read Username for '<new host>'`. That names a host the agent
was never configured for, which reads like DNS or TLS.

Same class as the CI runner keeping its registered address, one tier
down.

The shape is `github.nix`'s, unchanged: a small credential helper that
reads the token from the agent's state file at invocation, with the
token PATH baked in rather than the value, because claude's Bash tool
runs in a minimal env that never sources /etc/set-environment.

`environment.etc."gitconfig"` is already bound by github.nix; the two
merge rather than collide because the option is `lines`. Verified by
eval with both modules defining it before this was written -- a silent
last-wins there would drop one integration's credentials and look
exactly like this bug again.
2026-08-26 21:36:17 +02:00

305 lines
14 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 `tea` CLI login
# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge
# avatar sync.
{
pkgs,
lib,
config,
...
}:
let
userName = config.hyperhive.user.name;
homeDir = "/home/${userName}";
# 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 (the avatar-sync unit below
# is gated on `hyperhive.icon != null`).
iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } ''
rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.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 the agent's state 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
TOKEN_FILE="/agents/${userName}/state/forge-token"
[ -r "$TOKEN_FILE" ] || exit 0
printf 'username=%s\n' ${lib.escapeShellArg userName}
printf 'password=%s\n' "$(cat "$TOKEN_FILE")"
'';
in
{
options.hyperhive.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. 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).
**`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
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.hyperhive.forge.url == null
|| lib.hasPrefix "http://" config.hyperhive.forge.url
|| lib.hasPrefix "https://" config.hyperhive.forge.url;
message = "hyperhive.forge.url must be an http:// or https:// URL, or null for no forge (got: \"${toString config.hyperhive.forge.url}\")";
}
];
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.
config.hyperhive.packages.hive-forge
]
++ lib.optional (config.hyperhive.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.
environment.etc."gitconfig" = lib.mkIf (config.hyperhive.forge.url != null) {
text = ''
[credential "${lib.removeSuffix "/" config.hyperhive.forge.url}"]
helper = git-credential-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.hyperhive.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;
};
};
# One-shot: tea config.yml from the seeded forge token. Shape
# contract (always exit 0, no set -e, skip-silently, re-runnable):
# docs/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.hyperhive.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.hyperhive.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 the moment
# `<state>/forge-token` appears. Mirrors the hive-matrix-daemon
# token-watcher pattern — 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/persistence.md::forge-avatar-sync.
systemd.paths.forge-avatar-sync = lib.mkIf (config.hyperhive.icon != null) {
description = "trigger forge-avatar-sync when forge-token appears";
wantedBy = [ "multi-user.target" ];
pathConfig.PathExistsGlob = "/agents/*/state/forge-token";
};
# One-shot: hyperhive.icon → Forgejo profile avatar. Shape contract:
# docs/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.hyperhive.icon != null && config.hyperhive.forge.url != null)
{
description = "sync agent icon to Forgejo user avatar (best-effort)";
wantedBy = [ "multi-user.target" ];
after = [ "tea-login.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.hyperhive.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
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
'';
};
};
}