hyperhive/nix/agent-modules/forge.nix
atlas 7ef9905e9f fix(#2860): make hyperhive.forge.url required, drop the loopback default
The default `http://localhost:3000` was a guess, and a guess that
evaluates is worse than one that doesn't: the forge may run on a
different host from the agents, and inside an agent's network namespace
`localhost` reaches the agent itself, not the forge. hive-c0re renders
this option into every agent's config from the host's `HIVE_FORGE_URL`,
which `hive-c0re.nix` sets unconditionally, so nothing legitimate was
relying on the default to be there.

Also tightens the URL assertion, which accepted `""`. That escape hatch
only existed because the default made the empty case unreachable; with
the default gone, `""` is exactly what a caller supplies when they have
nothing, so it now fails naming the option instead of silently building
a config that talks to nowhere.

Refs #2860
2026-07-31 22:53:51 +02:00

236 lines
10 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
'';
in
{
options.hyperhive.forge.url = lib.mkOption {
type = lib.types.str;
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).
**Required, deliberately undefaulted.** hive-c0re renders it into
every agent's config from the host's `HIVE_FORGE_URL`, which
`hive-c0re.nix` sets unconditionally --- the forge is mandatory.
A loopback default would be a guess: the forge may run on a
different host from the agents, and inside an agent's network
namespace `localhost` reaches the agent, not the forge. An
unevaluatable config is better than one that builds and then
talks to the wrong machine.
'';
};
config = {
assertions = [
# The empty string is the one value the type permits that cannot
# be a URL, and it is what a caller supplies when they have
# nothing --- exactly the case the removed loopback default used
# to paper over. Reject it here so the failure names the option.
{
assertion =
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 (got: \"${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
];
# 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.
systemd.services.tea-login = {
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) {
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
'';
};
};
}