hermes: write the webhook routes as config, and add a PR-review route

`hermes webhook subscribe` has no --toolsets flag, so a webhook run got
Hermes's constrained default (web_search, web_extract, vision_analyze,
clarify) -- no shell, no file access, which meant neither prompt could
actually be carried out: luna was woken, read the comment, and had no way
to act on it. Upstream's documented answer is to add the `toolsets` key to
webhook_subscriptions.json by hand, and a hand edit does not survive this
unit's re-provision. So the whole route definition moves here and the CLI
is not used at all.

The file is written host-side with jq. hermesHome is the bind-mount source
for /opt/data, so the container sees the same inode and hot-reloads it on
the next delivery -- no podman exec, no readiness loop, and no quoting
chain between nix and the prompt text. The merge is per-route: routes this
unit does not name survive, created_at is carried over, and every other
key is replaced outright so a hand-added `deliver_only` or `filters`
cannot linger.

The secret now comes from the sops file directly instead of being read
back out of the container's environment, which drops podman-hermes-agent
from restartUnits (the ordering constraint it existed for is gone) and
takes GITEA_HERMES_WEBHOOK_SECRET out of an env var luna can read.

The new gitea-pr-reviews route covers reviews with a body and
changes-requested. Those are not IssueCommentPayloads: gitea sends a
PullRequestPayload with action "reviewed" and a `review` object of exactly
{type, content} -- no review id, no line comments. So the prompt fetches
them with `tea pulls review-comments` and acts only on ones whose
`resolver` is empty, resolving each as it goes; with no stable id in the
payload, resolved state is the only workable duplicate-delivery guard.
An empty review body is deliberately NOT a drop, unlike in the comment
filter: a review whose substance is entirely in line comments has none.

Approvals are left unsubscribed -- an approval is darman signing off, not
asking for work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
This commit is contained in:
2026-08-24 03:10:33 +02:00
co-authored by Claude Opus 5
parent 6f99a1fed1
commit c18413d16d
2 changed files with 208 additions and 113 deletions
+193 -91
View File
@@ -90,7 +90,7 @@ let
# is hers to make, anywhere inside HERMES_WRITE_SAFE_ROOT=/opt/data. # is hers to make, anywhere inside HERMES_WRITE_SAFE_ROOT=/opt/data.
giteaHost = "git.mgaction.town"; giteaHost = "git.mgaction.town";
# luna's webhook filter, mounted READ-ONLY below. It lives in the nix store # luna's webhook filters, mounted READ-ONLY below. They live in the nix store
# rather than being written into hermesHome because hermesHome IS # rather than being written into hermesHome because hermesHome IS
# HERMES_WRITE_SAFE_ROOT: a filter dropped there is a loop guard sitting # HERMES_WRITE_SAFE_ROOT: a filter dropped there is a loop guard sitting
# inside the writable root of the agent it constrains, and she could edit # inside the writable root of the agent it constrains, and she could edit
@@ -102,15 +102,52 @@ let
prCommentFilter = pkgs.writeText "gitea-pr-comment-filter.py" ( prCommentFilter = pkgs.writeText "gitea-pr-comment-filter.py" (
builtins.readFile ./gitea-pr-comment-filter.py builtins.readFile ./gitea-pr-comment-filter.py
); );
prReviewFilter = pkgs.writeText "gitea-pr-review-filter.py" (
builtins.readFile ./gitea-pr-review-filter.py
);
# The route prompt, mounted read-only for the same reason as the filter and # The route prompts. These are NOT mounted into the container: the route
# kept in a file rather than inline in the subscribe command: it is 60 lines # config below embeds them as strings, and jq reads them from these store
# of markdown containing apostrophes and {placeholders}, which would have to # paths host-side with --rawfile. Keeping them in files rather than inline
# survive nix string escaping, the systemd unit, and `podman exec sh -c` # nix strings is still what makes that work — they are ~60 lines of markdown
# quoting. A file crosses all three untouched and stays diffable in git. # full of apostrophes and {placeholders} that would otherwise have to
# survive nix string escaping on the way into a shell command. --rawfile
# crosses all of that untouched, and they stay diffable in git.
prCommentPrompt = pkgs.writeText "gitea-pr-comment-prompt.md" ( prCommentPrompt = pkgs.writeText "gitea-pr-comment-prompt.md" (
builtins.readFile ./gitea-pr-comment-prompt.md builtins.readFile ./gitea-pr-comment-prompt.md
); );
prReviewPrompt = pkgs.writeText "gitea-pr-review-prompt.md" (
builtins.readFile ./gitea-pr-review-prompt.md
);
# Wire event names (X-GitHub-Event) each route accepts — NOT the
# subscription names the gitea hooks in services/dev/gitea.nix use. The two
# namespaces collide; see the long comment on the route unit below.
prCommentEvents = [ "issue_comment" ];
prReviewEvents = [ "pull_request_comment" "pull_request_rejected" ];
# Toolsets granted to both routes' agent runs.
#
# Hermes defaults webhook runs to a deliberately narrow set (web_search,
# web_extract, vision_analyze, clarify) because a webhook payload is
# third-party content. That default cannot clone, edit or push, so neither
# prompt was executable under it: the run would be woken, read the comment,
# and have no way to act on it.
#
# This list REPLACES the platform default for these routes rather than
# merging with it, so anything the default provided has to be re-listed —
# "web" is here for that reason, not because the prompts ask for research.
#
# Upstream's stated boundary is that `hermes webhook subscribe` has no
# --toolsets flag, so "an agent creating its own subscription at runtime
# cannot self-grant terminal". That boundary does NOT hold here and must not
# be relied on: webhook_subscriptions.json lives under /opt/data, which is
# HERMES_WRITE_SAFE_ROOT, so luna can edit her own grant — she already did
# once, which is why this moved into nix. What this buys is that the grant
# is deliberate, reviewable and re-asserted on every restart, not that it is
# unforgeable. The real backstop stays server-side: gitea's branch
# protection on master.
routeToolsets = [ "terminal" "file" "web" ];
# hermesHome as the CONTAINER sees it (the bind mount below). Anything # hermesHome as the CONTAINER sees it (the bind mount below). Anything
# written host-side that gets READ back inside the container must use this # written host-side that gets READ back inside the container must use this
@@ -169,12 +206,11 @@ in
script = '' script = ''
mkdir -p ${hermesHome} mkdir -p ${hermesHome}
mkdir -p ${dropboxDir} mkdir -p ${dropboxDir}
# Parent for the read-only filter bind-mounted at # Parent for the read-only filters bind-mounted at
# /opt/data/scripts/gitea-pr-comment-filter.py. /opt/data is itself a # /opt/data/scripts/gitea-pr-*-filter.py. /opt/data is itself a bind
# bind mount of hermesHome, so this directory has to exist HOST-side # mount of hermesHome, so this directory has to exist HOST-side before
# before podman can mount a file inside it. # podman can mount a file inside it.
mkdir -p ${hermesHome}/scripts mkdir -p ${hermesHome}/scripts
mkdir -p ${hermesHome}/prompts
export HOME=${hermesHome} export HOME=${hermesHome}
export GIT_CONFIG_GLOBAL=${hermesHome}/.gitconfig export GIT_CONFIG_GLOBAL=${hermesHome}/.gitconfig
@@ -217,9 +253,9 @@ in
${hermesHome}/.git-credentials ${hermesHome}/.git-credentials
# Same cont-init caveat as the files above: the directory is created # Same cont-init caveat as the files above: the directory is created
# here as root, and Hermes reads its scripts as uid ${hermesUid}. The # here as root, and Hermes reads its scripts as uid ${hermesUid}. The
# mounted filter itself is world-readable 0444 from the store, so only # mounted filters themselves are world-readable 0444 from the store, so
# the directory needs handing over. # only the directory needs handing over.
chown ${hermesUid}:${hermesGid} ${hermesHome}/scripts ${hermesHome}/prompts chown ${hermesUid}:${hermesGid} ${hermesHome}/scripts
if [ -d ${hermesHome}/.config ]; then if [ -d ${hermesHome}/.config ]; then
chown ${hermesUid}:${hermesGid} ${hermesHome}/.config chown ${hermesUid}:${hermesGid} ${hermesHome}/.config
@@ -251,9 +287,11 @@ in
# secrets, so mounting the whole thing read-only costs nothing beyond # secrets, so mounting the whole thing read-only costs nothing beyond
# the two specific binaries actually being reachable. # the two specific binaries actually being reachable.
# Read-only: see prCommentFilter above. Hermes resolves route scripts # Read-only: see prCommentFilter above. Hermes resolves route scripts
# under ~/.hermes/scripts, which is /opt/data/scripts in here. # under ~/.hermes/scripts, which is /opt/data/scripts in here. The route
# prompts are NOT mounted — they are embedded in the route config the
# unit below writes, so nothing inside the container reads them.
"${prCommentFilter}:/opt/data/scripts/gitea-pr-comment-filter.py:ro" "${prCommentFilter}:/opt/data/scripts/gitea-pr-comment-filter.py:ro"
"${prCommentPrompt}:/opt/data/prompts/gitea-pr-comment.md:ro" "${prReviewFilter}:/opt/data/scripts/gitea-pr-review-filter.py:ro"
"/nix/store:/nix/store:ro" "/nix/store:/nix/store:ro"
"${pkgs.git}/bin/git:/usr/local/bin/git:ro" "${pkgs.git}/bin/git:/usr/local/bin/git:ro"
@@ -304,66 +342,88 @@ in
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ]; unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
}; };
# The Gitea PR-comment route. Gitea posts straight here (jupiter's # The two Gitea webhook routes, written as config rather than created with
# gitea-hermes-webhook-provision registers the hook at # `hermes webhook subscribe`.
# http://mars.orbit.sol:8644/webhooks/gitea-pr-comments) -- there is no relay
# in between. Gitea's addDefaultHeaders sends X-Hub-Signature-256 in GitHub's
# exact format AND X-GitHub-Event, unconditionally, for every webhook type,
# which is precisely what Hermes validates and reads the event name from.
# #
# --events issue_comment, NOT pull_request_comment. Gitea uses the same # Gitea posts straight at Hermes (jupiter's gitea-hermes-webhook-provision
# strings in two different namespaces and they collide: # registers one hook per route at http://mars.orbit.sol:8644/webhooks/<name>)
# — there is no relay in between. Gitea's addDefaultHeaders sends
# X-Hub-Signature-256 in GitHub's exact format AND X-GitHub-Event,
# unconditionally, for every webhook type, which is precisely what Hermes
# validates and reads the event name from.
# #
# subscription name wire name (X-GitHub-Event) what it is # WHY NOT `hermes webhook subscribe`: it has no --toolsets flag, and without
# ----------------------- -------------------------- ---------------- # a toolset override a webhook run gets Hermes's constrained default
# pull_request_comment issue_comment comment on a PR # (web_search, web_extract, vision_analyze, clarify) — no shell, no file
# access, so neither prompt below can actually be carried out. Upstream's
# documented answer is to write the `toolsets` key into
# webhook_subscriptions.json by hand. Doing that by hand does not survive
# this unit, which re-provisions on every start, so the whole route
# definition moves here instead and the CLI is not used at all. See
# routeToolsets above for what that costs.
#
# This writes the file HOST-side. hermesHome is bind-mounted at /opt/data,
# so the container sees the same inode, and the webhook adapter hot-reloads
# the file (mtime-gated) on the next delivery — no container restart, and no
# `podman exec` quoting chain between nix and the prompt text.
#
# Events are WIRE names (X-GitHub-Event), not subscription names. Gitea uses
# the same strings in two namespaces and they collide — from
# HookEventType.Event() in modules/webhook/type.go:
#
# subscription name wire name what it is
# --------------------------- ---------------------- ------------------
# issue_comment issue_comment comment on an issue # issue_comment issue_comment comment on an issue
# pull_request_review_comment pull_request_comment review on a PR # pull_request_comment issue_comment comment on a PR
# pull_request_review_comment pull_request_comment review with a body
# pull_request_review_rejected pull_request_rejected changes requested
# pull_request_review_approved pull_request_approved approval
# #
# The hook's `events` array (services/dev/gitea.nix) takes the SUBSCRIPTION # The hooks' `events` arrays in services/dev/gitea.nix take the SUBSCRIPTION
# name; Hermes matches --events against X-GitHub-Event, i.e. the WIRE name, # name; Hermes matches these against X-GitHub-Event, i.e. the WIRE name. So
# which comes from HookEventType.Event() in modules/webhook/type.go. So # "pull_request_comment" HERE means a review and "issue_comment" HERE means
# "pull_request_comment" here would match review submissions and never a # a comment — the exact inversion of how they read. X-GitHub-Event-Type
# comment -- the exact inversion of what it reads like. X-GitHub-Event-Type # carries the subscription name, but Hermes does not look at it. Both files
# carries the subscription name, but Hermes does not look at it. # therefore name the same event differently on purpose; neither is a typo.
# #
# issue_comment on the wire covers comments on plain issues too; the hook # issue_comment on the wire covers comments on plain issues too; the hook
# does not subscribe those, and the filter's is_pull check drops them anyway # does not subscribe those, and the comment filter's is_pull check drops
# if the hook is ever widened. # them anyway if the hook is ever widened.
# #
# A route carries exactly one prompt, so another event means either branching # deliver is "log", not a chat target: both prompts tell her to answer in
# on {action} in the prompt or a second subscription plus a second Gitea hook # the pull request, so the PR comment IS the delivery.
# at /webhooks/<name>. Review comments would need that: they arrive as a
# PullRequestPayload with action "reviewed" and no comment object at all.
# #
# No --deliver: it defaults to `log`. The prompt tells her to answer in the # `script` is the selection that MUST NOT be retunable at runtime.
# pull request, so the PR comment IS the delivery.
#
# --script is the selection that MUST NOT be retunable at runtime.
# gitea-pr-comment-filter.py drops luna's own comments before any LLM call, # 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 # 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. Both it and the prompt # PR, and her answer is itself a pull_request_comment. Both filters are
# are bind-mounted read-only from the store above so the agent cannot edit # bind-mounted read-only from the store above so the agent cannot edit her
# its own guard out. Hermes resolves both names relative to ~/.hermes, hence # own guard out. Hermes resolves the name relative to ~/.hermes/scripts,
# the bare filename. # hence the bare filename.
# #
# What read-only does NOT buy: it protects the sources, and this unit # What read-only does NOT buy: it protects the sources, and this unit
# re-subscribes from them on every start, so a restart restores the intended # re-asserts prompt, filter, events and toolsets from them on every start,
# prompt, filter and event list. The live subscription lives in # so a restart restores the intended config. The live file is inside the
# webhook_subscriptions.json under /opt/data and is hot-reloaded, which is # agent's own write-safe root, so a self-modification sticks until this unit
# inside the agent's own write-safe root -- a self-modification sticks until # next runs.
# this unit next runs.
# #
# The secret comes from the CONTAINER's environment, injected via # Routes this unit does not name are left alone (the merge below is
# sops.templates."hermes-agent.env", which is why secrets.nix restarts # per-key), so retiring an old one stays a deliberate one-off:
# podman-hermes-agent BEFORE this unit on rotation: re-subscribing against a # sudo podman exec hermes-agent hermes webhook remove <name>
# container still holding the old value would silently pin the stale secret. systemd.services.hermes-agent-webhook-routes = {
systemd.services.hermes-agent-webhook-route = { description = "Write Hermes's Gitea webhook route config";
description = "Configure Hermes Gitea PR-comment webhook route";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "podman-hermes-agent.service" ]; # after, but not requires: this only writes a file that hermesHome must
requires = [ "podman-hermes-agent.service" ]; # already exist for. A container that fails to come up should not also
path = [ pkgs.podman ]; # leave the routes unconfigured — the file is hot-reloaded whenever the
# gateway does start.
after = [
"hermes-agent-prepare-dirs.service"
"podman-hermes-agent.service"
];
requires = [ "hermes-agent-prepare-dirs.service" ];
path = [ pkgs.jq ];
environment.SECRET_FILE = config.sops.secrets.gitea_hermes_webhook_secret.path;
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
@@ -371,38 +431,80 @@ in
script = '' script = ''
set -euo pipefail set -euo pipefail
# The container unit is ordered before us, but its gateway may still be conf=${hermesHome}/webhook_subscriptions.json
# warming up while the image initializes its persistent state directory. tmp="$conf.new"
for _ in $(seq 1 60); do trap 'rm -f "$tmp"' EXIT
if podman exec hermes-agent hermes webhook list >/dev/null 2>&1; then
break
fi
sleep 1
done
# Idempotency for the subscribe below, not cleanup: this removes only the # --slurpfile below cannot read a file that does not exist. Creating it
# route this unit owns. Retiring an old route is a one-off done by hand, # empty is safe: this only ever happens before the first run, when there
# so that a redeploy never silently deletes one added on purpose. # are no routes to lose. If it exists but is not valid JSON, slurpfile
podman exec hermes-agent hermes webhook remove gitea-pr-comments >/dev/null 2>&1 || true # fails the unit loudly and leaves it untouched, which is the right
# direction better a failed unit than silently discarded routes.
[ -e "$conf" ] || printf '%s\n' '{}' > "$conf"
# `set -eu` plus both emptiness checks are load-bearing. Without them a # The secret reaches jq via --rawfile, never argv: /proc/<pid>/cmdline
# missing prompt file or an unset secret yields an empty string, and the # is world-readable, so `--arg secret "$(cat ...)"` would publish it to
# subscription is created with an empty prompt or -- worse -- an empty # every user on the box for the lifetime of the process. Same reason the
# secret, which silently fails EVERY delivery signature check afterwards # prompts come in by path rather than by value.
# while the unit still looks healthy. Fail loudly here instead. #
podman exec hermes-agent sh -c ' # sops stores this one without a trailing newline (see secrets.nix), but
set -eu # rtrimstr is kept anyway: a stray newline would silently change the key
[ -n "''${GITEA_HERMES_WEBHOOK_SECRET:-}" ] || { # the HMAC is computed with and fail every delivery afterwards.
echo "GITEA_HERMES_WEBHOOK_SECRET is unset in the container" >&2; exit 1; } #
prompt="$(cat /opt/data/prompts/gitea-pr-comment.md)" # The emptiness guards are load-bearing. Without them a truncated secret
[ -n "$prompt" ] || { echo "gitea-pr-comment prompt is empty" >&2; exit 1; } # file or an unreadable prompt yields "", and the route is written with
hermes webhook subscribe gitea-pr-comments \ # an empty secret which fails EVERY signature check while the unit
--secret "$GITEA_HERMES_WEBHOOK_SECRET" \ # still reports success.
--description "Gitea PR comments -> L.U.N.A." \ jq -n \
--events issue_comment \ --slurpfile existing "$conf" \
--script gitea-pr-comment-filter.py \ --rawfile rawSecret "$SECRET_FILE" \
--prompt "$prompt" --rawfile commentPrompt ${prCommentPrompt} \
--rawfile reviewPrompt ${prReviewPrompt} \
--argjson commentEvents '${builtins.toJSON prCommentEvents}' \
--argjson reviewEvents '${builtins.toJSON prReviewEvents}' \
--argjson toolsets '${builtins.toJSON routeToolsets}' \
' '
def nonempty($what): if length == 0 then error("\($what) is empty") else . end;
($rawSecret | rtrimstr("\n") | nonempty("gitea_hermes_webhook_secret")) as $secret
| def route($desc; $events; $prompt; $script):
{ description: $desc,
events: $events,
secret: $secret,
prompt: ($prompt | nonempty("\($script) prompt")),
skills: [],
script: $script,
deliver: "log",
toolsets: $toolsets };
# created_at is cosmetic (hermes webhook list prints it) and is the
# one key carried over from whatever is already there, so it keeps
# reading as when the route first appeared rather than as the last
# deploy. Everything else is replaced outright: a leftover key from
# an earlier definition or from a hand edit would otherwise
# survive here forever.
def upsert($name; $r):
.[$name] = ($r + { created_at: (.[$name].created_at // (now | todate)) });
($existing[0] // {})
| if type != "object" then error("webhook_subscriptions.json is not a JSON object") else . end
| upsert("gitea-pr-comments";
route("Gitea PR comments -> L.U.N.A.";
$commentEvents; $commentPrompt; "gitea-pr-comment-filter.py"))
| upsert("gitea-pr-reviews";
route("Gitea PR reviews -> L.U.N.A.";
$reviewEvents; $reviewPrompt; "gitea-pr-review-filter.py"))
' > "$tmp"
# 0600 because the file holds the HMAC secret in cleartext, and owned by
# the container's uid because Hermes rewrites it itself whenever anything
# calls `hermes webhook subscribe`. mv is an atomic rename within the
# same directory, so a delivery landing mid-write never reads a half
# written config.
chmod 0600 "$tmp"
chown ${hermesUid}:${hermesGid} "$tmp"
mv -f "$tmp" "$conf"
''; '';
}; };
} }
+13 -20
View File
@@ -28,27 +28,21 @@
sops.secrets.opencode_go_api_key = { }; sops.secrets.opencode_go_api_key = { };
sops.secrets.telegram_bot_token = { }; sops.secrets.telegram_bot_token = { };
sops.secrets.hermes_dashboard_oidc_client_secret = { }; sops.secrets.hermes_dashboard_oidc_client_secret = { };
# Add the same value to secrets/mars.yaml before deploying Mars, and store # Same value as in secrets/jupiter.yaml (the sending side), stored WITHOUT a
# it WITHOUT a trailing newline: it reaches Hermes through the env template # trailing newline — a stray newline would change the key the HMAC is
# below, where a newline would both corrupt the env file and change the key # computed with and fail every delivery. `scripts/edit_secrets` writes a
# the HMAC is computed with. `scripts/edit_secrets` writes a bare value. # bare value. hermes-agent.nix trims one anyway, belt and braces.
# #
# podman-hermes-agent is in restartUnits for a reason that is easy to miss: # This is NOT in the container's env any more. It used to be, because
# the secret reaches the container only through sops.templates, whose # hermes-agent-webhook-route ran `hermes webhook subscribe` inside the
# rendered PATH never changes, so the container unit's definition is # container and read the secret back out of its environment — which meant
# identical before and after the secret is added and systemd will NOT # podman-hermes-agent had to be restarted first on rotation, or the
# restart it on its own. Without this line the very first deploy leaves the # subscription silently pinned the stale value. The route config is now
# container holding an empty GITEA_HERMES_WEBHOOK_SECRET, and # written host-side (hermes-agent-webhook-routes reads this file directly),
# hermes-agent-webhook-route (which reads it back out of the running # so that ordering constraint is gone and the secret no longer sits in an
# container) subscribes with an empty secret — every delivery then fails # env var luna can read with `env`.
# signature validation inside Hermes with no obvious cause. That unit now
# refuses to subscribe on an unset secret rather than doing it quietly, but
# the ordering here is still what makes the rotation correct.
sops.secrets.gitea_hermes_webhook_secret = { sops.secrets.gitea_hermes_webhook_secret = {
restartUnits = [ restartUnits = [ "hermes-agent-webhook-routes.service" ];
"podman-hermes-agent.service"
"hermes-agent-webhook-route.service"
];
}; };
sops.templates."hermes-agent.env".content = '' sops.templates."hermes-agent.env".content = ''
OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key} OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key}
@@ -57,7 +51,6 @@
TELEGRAM_ALLOWED_USERS=15151223 TELEGRAM_ALLOWED_USERS=15151223
WEBHOOK_ENABLED=true WEBHOOK_ENABLED=true
WEBHOOK_PORT=8644 WEBHOOK_PORT=8644
GITEA_HERMES_WEBHOOK_SECRET=${config.sops.placeholder.gitea_hermes_webhook_secret}
HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret} HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret}
''; '';