# 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 --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"` (= `/agents//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 : 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 # `/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//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":""} — 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 ''; }; }; }