The option had a `http://localhost:3000` default, which is only ever correct when the forge shares the caller's network namespace — inside an agent's netns `localhost` is the agent, and the forge may well be on another host. Making it *required* instead was worse: the flake's own container configs are what hive-c0re extends per agent, so the value they needed in order to evaluate became a second definition on every agent and collided with the real one. `null` resolves both. It is not a URL, so nothing can quietly talk to the wrong machine, and it needs no placeholder anywhere: the bases evaluate as they are, so nothing deployment-shaped sits on the config agents inherit from. The units that would consume the URL — tea-login and forge-avatar-sync — are simply not generated without one, making an absent forge an absent integration rather than a misdirected one. hive-forge-notify is unaffected: it reads HIVE_FORGE_URL from the forwarded global environment, not from this option. Verified: agent-base/ruth evaluate with forge.url = null, zero failing assertions bare base: tea_login_present = false, avatar_present = false, notify_present = true extended with a rendered URL: FORGE_URL=http://forge.real.test Refs #2860
251 lines
11 KiB
Nix
251 lines
11 KiB
Nix
# 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.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
|
||
];
|
||
|
||
# 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
|
||
'';
|
||
};
|
||
};
|
||
}
|