# 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 : 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 ""]` 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 ''` — 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..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":""} — 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 ''; }; }; }