{ config, pkgs, ... }: # Gitea -> Hermes webhook relay. # # Why this exists at all, since Gitea could POST straight at Hermes's own # webhook port (8644, already tailnet-reachable — tailscale0 is a # trustedInterface): AUTH would work directly. Gitea's addDefaultHeaders() # signs every webhook type with `X-Hub-Signature-256: sha256=`, the # exact GitHub scheme, and Hermes accepts that header on any route with no # per-route provider gating. What does NOT work directly is EVENT SELECTION. # Hermes reads the event name from `X-GitHub-Event`/`X-GitLab-Event`, then # the payload's `event_type`/`type` keys, then gives up and calls it # "unknown". Gitea sends `X-Gitea-Event` and no such payload key, so a direct # hook authenticates fine and then arrives as "unknown" forever — which makes # `hermes webhook subscribe --events ...` unable to select anything, i.e. the # "Hermes owns event policy" split this module is built around cannot exist # without something copying that one header. # # So that is all this does: verify the signature, copy X-Gitea-Event into # X-GitHub-Event, forward body and signature untouched. No re-signing, no # payload rewriting, no event/repo/action filtering. # # It binds 0.0.0.0 but gets no allowedTCPPorts entry, so it is reachable over # tailscale0 only — same posture as the Hermes dashboard on 9119. let relayScript = pkgs.writeText "gitea-hermes-webhook-relay.py" ( builtins.readFile ./gitea-hermes-webhook-relay.py ); in { systemd.services.gitea-hermes-webhook-relay = { description = "Relay Gitea webhooks to Hermes with a Hermes-readable event header"; wantedBy = [ "multi-user.target" ]; wants = [ "network-online.target" ]; after = [ "network-online.target" "podman-hermes-agent.service" "tailscaled-autoconnect.service" ]; environment = { LISTEN_HOST = "0.0.0.0"; LISTEN_PORT = "8645"; # Base only. The Hermes route rides in the request path # (/gitea/), so this relay is not tied to any one subscription; # DEFAULT_ROUTE only serves the legacy bare /gitea path. HERMES_WEBHOOK_BASE = "http://127.0.0.1:8644/webhooks"; DEFAULT_ROUTE = "gitea-pr-comments"; MAX_BODY_BYTES = "1048576"; }; serviceConfig = { ExecStart = "${pkgs.python3}/bin/python ${relayScript}"; LoadCredential = [ "webhook_secret:${config.sops.secrets.gitea_hermes_webhook_secret.path}" ]; DynamicUser = true; Restart = "on-failure"; RestartSec = 5; PrivateDevices = true; PrivateTmp = true; ProtectHome = true; ProtectSystem = "strict"; NoNewPrivileges = true; RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; RestrictRealtime = true; UMask = "0077"; }; }; # The relay forwards into a generic Hermes webhook subscription. Keep the # subscription declaratively present without putting event policy or prompt # text in this transport unit. Hermes owns interpretation and response policy. # # `--events pull_request_comment` narrows this route to the one event the # prompt below actually knows how to handle. It works only because the relay # supplies X-GitHub-Event — see the header comment above; without that every # delivery would arrive as "unknown" and match nothing. Gitea sends # pull_request_comment as a value distinct from issue_comment, so plain issue # comments do not reach the agent. # # A route carries exactly one prompt, so widening this list means branching # inside the prompt on {action}, or adding a second subscription. The second # subscription is cheap now: the relay takes its target route from the # request path, so it is a new `hermes webhook subscribe ` plus a # Gitea hook pointing at /gitea/, with no relay change at all. The # Gitea-side hook still sends the full event set; Hermes drops the # non-matching ones cheaply, before any LLM call. # # No --deliver: it defaults to `log`. The prompt tells her to answer in the # pull request, so the PR comment IS the delivery, and a Telegram copy would # just duplicate it. This also drops the hardcoded chat id that used to be a # third copy of TELEGRAM_HOME_CHANNEL. # # --script does the selection that MUST NOT be retunable at runtime. # hosts/mars/gitea-pr-comment-filter.py drops luna's own comments before # any LLM call, which is what stops the reply loop: the prompt tells her to # answer on the PR, and her answer is itself a pull_request_comment. It is # bind-mounted read-only from the nix store (see hosts/mars/hermes-agent.nix) # so the agent cannot edit its own guard out. Hermes resolves the name # relative to ~/.hermes/scripts, hence the bare filename here. # # The prompt is read from a read-only mount rather than passed inline: see # hosts/mars/gitea-pr-comment-prompt.md and the mounts in hermes-agent.nix. # Note what read-only does and does not buy. It protects the SOURCES, and # this unit re-subscribes from them on every start, so a restart restores # the intended prompt, filter and event list. It does not make the live # subscription immutable: Hermes stores it in webhook_subscriptions.json # under /opt/data and hot-reloads it, which is inside the agent's own # write-safe root. A self-modification would therefore stick until the next # restart of this unit. # # The secret is read from the CONTAINER's environment ($GITEA_HERMES_ # WEBHOOK_SECRET, injected via sops.templates."hermes-agent.env"), which is # why hosts/mars/secrets.nix restarts podman-hermes-agent BEFORE this unit # on rotation — re-subscribing against a container still holding the old # value would silently pin the stale secret. systemd.services.hermes-agent-webhook-route = { description = "Configure Hermes Gitea event webhook route"; wantedBy = [ "multi-user.target" ]; after = [ "podman-hermes-agent.service" ]; requires = [ "podman-hermes-agent.service" ]; path = [ pkgs.podman ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; script = '' set -euo pipefail # The container unit is ordered before us, but its gateway may still be # warming up while the image initializes its persistent state directory. for _ in $(seq 1 60); do if podman exec hermes-agent hermes webhook list >/dev/null 2>&1; then break fi sleep 1 done # gitea-events is the old name of this route (renamed to say what it # actually handles); removing it keeps a redeployed host from serving # both. The second remove is the idempotency step for the subscribe # below, not cleanup. podman exec hermes-agent hermes webhook remove gitea-events >/dev/null 2>&1 || true podman exec hermes-agent hermes webhook remove gitea-pr-comments >/dev/null 2>&1 || true # `set -eu` inside the container shell is load-bearing: without it a # missing prompt file makes `cat` fail, the command substitution yields # an empty string, and the subscription is created with an EMPTY prompt # -- a silent failure that looks like a healthy unit. Fail loudly here # instead so the oneshot goes red. podman exec hermes-agent sh -c ' set -eu prompt="$(cat /opt/data/prompts/gitea-pr-comment.md)" [ -n "$prompt" ] || { echo "gitea-pr-comment prompt is empty" >&2; exit 1; } hermes webhook subscribe gitea-pr-comments \ --secret "$GITEA_HERMES_WEBHOOK_SECRET" \ --description "Gitea PR comments -> L.U.N.A." \ --events pull_request_comment \ --script gitea-pr-comment-filter.py \ --prompt "$prompt" ' ''; }; }