Renames the subscription to gitea-pr-comments (it handles one event; the old
gitea-events name promised more than it delivered) and drops --deliver.
Rather than move the hardcoded route from one constant to another, the relay
now reads it from the request path: POST /gitea/<route> forwards to
<base>/webhooks/<route>. The route name was the last thing tying this service
to a specific subscription, so a second Hermes route is now a `hermes webhook
subscribe <name>` plus a Gitea hook at /gitea/<name>, with no relay change --
previously it would also have needed a second relay URL baked in here.
The path segment is interpolated into an outbound URL, so it is validated
against ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ and refused rather than sanitised
when it does not match. The path is matched raw and never URL-decoded, so
percent-encoded separators fail the charset check instead of surviving it;
requiring an alphanumeric first character also rejects "." and "..". Without
this, POST /gitea/..%2fadmin would let anything that can reach the relay
steer it at other Hermes endpoints. Tests cover traversal, encoded traversal,
embedded slashes, leading dot/dash, and the length bound, and assert nothing
reaches the stub Hermes in any of those cases.
Dropping --deliver leaves it at its default of `log`. The prompt tells her to
answer in the pull request, so the PR comment is the delivery and a Telegram
copy would only duplicate it; this also removes the hardcoded chat id that
was a third copy of TELEGRAM_HOME_CHANNEL.
Provisioning retires the pre-rename hook by its EXACT old URL rather than by
"points at the relay". Now that sibling hooks for other routes are the
intended pattern, a prefix match would delete them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
166 lines
7.6 KiB
Nix
166 lines
7.6 KiB
Nix
{ 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=<hmac>`, 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/<route>), 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 <name>` plus a
|
|
# Gitea hook pointing at /gitea/<name>, 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"
|
|
'
|
|
'';
|
|
};
|
|
}
|