The hook registered with no events at all and delivered nothing. "pull_request_review_comment" and "pull_request_review_rejected" are real HookEventTypes and real X-GitHub-Event-Type values, but they are not things gitea's hook API accepts. updateHookEvents (routers/api/v1/utils/hook.go) matches a fixed list of api names and silently ignores anything else, so every event flag stayed false, the POST succeeded, and the hook sat there inert. There is no narrower api name: HasEvent (models/webhook/webhook.go) collapses approved, rejected and review-comment onto HookEventPullRequestReview, so `pull_request_review` is a single switch for all three. Approvals consequently cannot be excluded at the hook any more. They now cross the wire as "pull_request_approved", which is not in the route's event list, so Hermes ignores them on the event match -- before the filter script and before any LLM call. Gitea's delivery log will show them answered 200/ignored, which is intended. That makes three namespaces for the same event rather than two, so the tables in both nix files and the README now carry the api column, and the README warns about the silent-ignore behaviour that hid this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
521 lines
27 KiB
Nix
521 lines
27 KiB
Nix
{ config, pkgs, ... }:
|
|
|
|
# Hermes Agent — moved here from jupiter (hosts/jupiter/hermes-agent.nix,
|
|
# see its git history / b5fa599 / 713d91d for the terra->jupiter->mars
|
|
# lineage). mars is dedicated to this one service, on-site, with no big
|
|
# data array of its own — unlike jupiter it has nothing under /mnt/data, so
|
|
# state lives on the local OS disk and the shared dropbox rides jupiter's
|
|
# samba share as a CIFS client instead of being served locally.
|
|
#
|
|
# Runs the OFFICIAL published image (docker.io/nousresearch/hermes-agent —
|
|
# real and actively maintained, contrary to what the checked-out repo's own
|
|
# README/docker-compose.yml suggested; verified directly on Docker Hub) as a
|
|
# plain podman container. It never sets HERMES_MANAGED or writes .managed, so
|
|
# Hermes fully self-manages config.yaml, profiles, memories and skills at
|
|
# runtime — no redeploy needed except to bump the pinned digest below.
|
|
#
|
|
# Security posture:
|
|
# - Reachable paths: its own local state dir, the small shared "dropbox"
|
|
# (via the jupiter samba mount) for darman to hand files to Hermes, and
|
|
# `git`/`tea`, logged in as the `luna` gitea account (PR-tier only —
|
|
# see services/dev/gitea.nix). No working copy of this repo is
|
|
# provisioned for her: an earlier version cloned one into
|
|
# ${hermesHome}/workspace/homelab, dropped again because nothing ever
|
|
# told her at runtime where it was (she self-manages config/profiles/
|
|
# memories, so a host-side path in this file never reached her) — she
|
|
# searched /opt/data/homelab and /workspace, found neither, and
|
|
# concluded she had no repo at all. She can clone one herself if she
|
|
# wants; the credentials below are what actually grants the access.
|
|
# Nothing else on jupiter's array or the host is reachable if a
|
|
# command goes wrong or gets injected via Telegram/tool output.
|
|
# - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT
|
|
# TELEGRAM_ALLOWED_USERS.
|
|
# - Runs as a rootful podman container (services/containers.nix) with its
|
|
# OWN numeric uid/gid — not darman, who is in the "hermes" group for
|
|
# host-level debugging only (`hermes ...` alias below, needs sudo since
|
|
# the container itself runs under root's podman, not darman's rootless
|
|
# one).
|
|
# - git/tea access is direct CLI, not a narrow wrapper: darman explicitly
|
|
# chose this over a purpose-built MCP server (tried first, scrapped —
|
|
# see git history) in favor of simplicity. The backstop is entirely
|
|
# server-side: gitea's branch protection on `master` (only darman can
|
|
# push/merge/approve there) is what actually keeps a bad or injected
|
|
# command from reaching the base branch, not anything client-side here.
|
|
#
|
|
# Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik, same setup as on
|
|
# jupiter. Its default bind (0.0.0.0:9119) fails closed without an auth
|
|
# provider registered, and 0.0.0.0 (not loopback) is required so neptun's
|
|
# Caddy can reach it over tailscale0 — reachability itself stays LAN-closed
|
|
# (no networking.firewall.allowedTCPPorts entry; tailscale0 is already a
|
|
# trustedInterface, services/vpn/tailscale.nix). Public route: neptun's
|
|
# hermes.mgaction.town vhost (hosts/neptun/configuration.nix) proxies to this
|
|
# over the tailnet. mars runs no Caddy of its own (single-purpose box), so
|
|
# there is no LAN vhost — reach the dashboard directly via mars's tailnet
|
|
# name (mars.orbit.sol:9119) or LAN IP:9119 for local debugging.
|
|
#
|
|
# Uses upstream's generic self-hosted OIDC plugin, same Authentik
|
|
# application as before (slug `hermes`) — the client ID/secret didn't need
|
|
# to change since the public redirect URI (hermes.mgaction.town) didn't.
|
|
#
|
|
# Data migration: this starts with a FRESH state dir. jupiter's instance was
|
|
# itself reset to fresh on 2026-08-21 (see its old hermes-agent.nix), so
|
|
# there was nothing irreplaceable to carry forward; if that turns out to be
|
|
# wrong, jupiter's old data is backed up at
|
|
# /mnt/data/AppData/hermes.bak-2026-08-21 and can be rsynced into
|
|
# ${hermesHome} below before the first switch on mars.
|
|
let
|
|
stateDir = "/var/lib/hermes";
|
|
hermesHome = "${stateDir}/.hermes";
|
|
# Shared drop-in folder: darman can put files here from any host. Lives on
|
|
# jupiter's array (reachable at /mnt/jupiter, the samba mount below) rather
|
|
# than locally, so it's the same physical location it always was — only
|
|
# the container reading it moved. Mounted under /opt/data so it falls
|
|
# inside Hermes's own sealed write-safe root (HERMES_WRITE_SAFE_ROOT=
|
|
# /opt/data) rather than a path its own tooling would treat as untrusted.
|
|
dropboxDir = "/mnt/jupiter/AppData/hermes-dropbox";
|
|
|
|
# Pinned by digest (captured 2026-08-21 via `podman image inspect
|
|
# docker.io/nousresearch/hermes-agent:latest --format '{{.Digest}}'` on
|
|
# jupiter) rather than floating `:latest`, so a redeploy is reproducible —
|
|
# bumping Hermes is an explicit edit here, not silent drift on next pull.
|
|
hermesImage = "docker.io/nousresearch/hermes-agent@sha256:5342e518734a08f6c66b89b4262434813c28a77abbc59c230c8f1637df71a259";
|
|
|
|
# Kept identical to jupiter's instance purely so nothing else needs to
|
|
# change if state ever gets migrated over.
|
|
hermesUid = "986";
|
|
hermesGid = "983";
|
|
|
|
# luna's gitea identity (account + PR-tier repo access provisioned in
|
|
# services/dev/gitea.nix). Only the server is pinned here — any checkout
|
|
# is hers to make, anywhere inside HERMES_WRITE_SAFE_ROOT=/opt/data.
|
|
giteaHost = "git.mgaction.town";
|
|
|
|
# luna's webhook filters, mounted READ-ONLY below. They live in the nix store
|
|
# rather than being written into hermesHome because hermesHome IS
|
|
# 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
|
|
# it back out. Deleting it would fail closed (Hermes treats a missing
|
|
# script as "ignore"), but rewriting it to always-allow would silently
|
|
# restore the reply loop. Read-only from the store makes that impossible
|
|
# and keeps the guard versioned in git — same reasoning as the git/tea
|
|
# binaries mounted below.
|
|
prCommentFilter = pkgs.writeText "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 prompts. These are NOT mounted into the container: the route
|
|
# config below embeds them as strings, and jq reads them from these store
|
|
# paths host-side with --rawfile. Keeping them in files rather than inline
|
|
# nix strings is still what makes that work — they are ~60 lines of markdown
|
|
# 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" (
|
|
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
|
|
# written host-side that gets READ back inside the container must use this
|
|
# prefix, not hermesHome — see the credential.helper below, which was
|
|
# broken exactly that way from 3c1f3e5 until 2026-08-23.
|
|
containerHome = "/opt/data";
|
|
in
|
|
{
|
|
# Browsing convenience (ssh access to the bind-mounted local state) — does
|
|
# NOT touch the container, which keeps using HERMES_UID/GID above
|
|
# regardless of what's declared here.
|
|
users.groups.hermes.gid = 983;
|
|
users.users.darman.extraGroups = [ "hermes" ];
|
|
|
|
# `hermes <args>` on mars == `sudo podman exec -it hermes-agent hermes <args>`.
|
|
# sudo is required: virtualisation.oci-containers runs rootful (system)
|
|
# podman, a separate namespace from darman's own rootless `podman`/`docker`
|
|
# — darman's "hermes"/"docker" group membership only grants filesystem
|
|
# access to the bind-mounted state dir, not to root's container socket.
|
|
programs.zsh.shellAliases.hermes = "sudo podman exec -it hermes-agent hermes";
|
|
|
|
systemd.tmpfiles.rules = [
|
|
"d ${stateDir} 0750 root hermes -"
|
|
];
|
|
|
|
# podman requires the bind-mount source to already exist (no auto-create),
|
|
# and the dropbox lives on the CIFS mount below — mkdir there works fine
|
|
# over cifs, no server-side (jupiter) config needed.
|
|
#
|
|
# Also provisions luna's git/tea access: writes a git credential-store file
|
|
# and runs `tea logins add` INTO hermesHome (i.e. paths that appear at
|
|
# /opt/data/... once the container is up). Both run on the HOST as root,
|
|
# before the container starts, and both therefore have to chown what they
|
|
# write themselves — see the chown at the end of the script. Do NOT assume
|
|
# the image's cont-init fixes ownership under hermesHome: it does not
|
|
# recurse into what this oneshot drops there, even though it runs after it.
|
|
#
|
|
# It deliberately does NOT clone the repo for her any more (see the
|
|
# header). The stale ${hermesHome}/workspace/homelab left behind by the
|
|
# version that did is not cleaned up here either — it just stops being
|
|
# managed, and stops being updated. Remove it by hand if you want it gone.
|
|
#
|
|
# Delete-then-add for the tea login (not a "does it exist" check): tea can
|
|
# leave a login entry behind even when `add` reports failure (e.g. a token
|
|
# missing a scope errors out AFTER the entry is written — observed
|
|
# directly against the real instance during the first version of this
|
|
# setup). Delete-then-add is idempotent either way and picks up a rotated
|
|
# token for free.
|
|
systemd.services.hermes-agent-prepare-dirs = {
|
|
description = "Create Hermes state dirs + luna's git/tea access before the container starts";
|
|
before = [ "podman-hermes-agent.service" ];
|
|
wantedBy = [ "podman-hermes-agent.service" ];
|
|
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
|
|
path = [ pkgs.git pkgs.tea ];
|
|
serviceConfig.Type = "oneshot";
|
|
script = ''
|
|
mkdir -p ${hermesHome}
|
|
mkdir -p ${dropboxDir}
|
|
# Parent for the read-only filters bind-mounted at
|
|
# /opt/data/scripts/gitea-pr-*-filter.py. /opt/data is itself a bind
|
|
# mount of hermesHome, so this directory has to exist HOST-side before
|
|
# podman can mount a file inside it.
|
|
mkdir -p ${hermesHome}/scripts
|
|
|
|
export HOME=${hermesHome}
|
|
export GIT_CONFIG_GLOBAL=${hermesHome}/.gitconfig
|
|
export XDG_CONFIG_HOME=${hermesHome}/.config
|
|
token_file=${config.sops.secrets.gitea_luna_token.path}
|
|
|
|
# Never embed the token in a remote URL (it would land in that
|
|
# clone's .git/config in plaintext) — the credential helper reads it
|
|
# from this file instead.
|
|
install -m 0600 /dev/null ${hermesHome}/.git-credentials
|
|
printf 'https://luna:%s@${giteaHost}\n' "$(cat "$token_file")" \
|
|
> ${hermesHome}/.git-credentials
|
|
# containerHome, NOT hermesHome: git reads this .gitconfig from INSIDE
|
|
# the container, where the host path does not exist. Nothing host-side
|
|
# consumes these credentials any more (the clone that used to is gone),
|
|
# so the container's view is the only one that has to be right.
|
|
git config --global credential.helper "store --file=${containerHome}/.git-credentials"
|
|
git config --global user.name "luna"
|
|
git config --global user.email "luna@${giteaHost}"
|
|
|
|
tea logins delete luna 2>/dev/null || true
|
|
GITEA_SERVER_TOKEN="$(cat "$token_file")" tea logins add \
|
|
--name luna --url "https://${giteaHost}" --no-version-check
|
|
|
|
# Hand everything written above to the container's uid/gid. This does
|
|
# NOT happen by itself: the image's cont-init only chowns hermesHome's
|
|
# top level and its own state, so root-owned 0600 files dropped here by
|
|
# this oneshot (.git-credentials, and tea's config.yml — tea writes it
|
|
# 0600 too) are simply unreadable to uid ${hermesUid}. Symptom is not an
|
|
# error but an absence: git reports no credential helper and tea reports
|
|
# no login, i.e. "they're missing". Confirmed on the real instance
|
|
# 2026-08-23 — cont-init ran AFTER these files were written and left
|
|
# them root-owned regardless.
|
|
#
|
|
# `if`, not `[ -d x ] && chown`: this script runs under `set -e`, where
|
|
# a false test as the left side of an && list takes the whole list's
|
|
# non-zero status and aborts the unit.
|
|
chown ${hermesUid}:${hermesGid} \
|
|
${hermesHome}/.gitconfig \
|
|
${hermesHome}/.git-credentials
|
|
# Same cont-init caveat as the files above: the directory is created
|
|
# here as root, and Hermes reads its scripts as uid ${hermesUid}. The
|
|
# mounted filters themselves are world-readable 0444 from the store, so
|
|
# only the directory needs handing over.
|
|
chown ${hermesUid}:${hermesGid} ${hermesHome}/scripts
|
|
|
|
if [ -d ${hermesHome}/.config ]; then
|
|
chown ${hermesUid}:${hermesGid} ${hermesHome}/.config
|
|
fi
|
|
if [ -d ${hermesHome}/.config/tea ]; then
|
|
chown -R ${hermesUid}:${hermesGid} ${hermesHome}/.config/tea
|
|
fi
|
|
'';
|
|
};
|
|
|
|
virtualisation.oci-containers.containers.hermes-agent = {
|
|
image = hermesImage;
|
|
autoStart = true;
|
|
# Host networking: Hermes only long-polls Telegram outbound, no inbound
|
|
# ports to publish (same reasoning as clonarr on jupiter).
|
|
extraOptions = [ "--network=host" ];
|
|
# Upstream's own documented single-mount pattern (docker/docker-compose.yml):
|
|
# ~/.hermes:/opt/data.
|
|
volumes = [
|
|
"${hermesHome}:/opt/data"
|
|
"${dropboxDir}:/opt/data/dropbox"
|
|
|
|
# git/tea for luna: the image doesn't ship `tea` (and shouldn't be
|
|
# trusted to have a known-good `git` either), so both come from this
|
|
# host's Nix store instead — mounted read-only at fixed PATH-visible
|
|
# locations. /nix/store itself has to come along too since both
|
|
# binaries are dynamically linked against paths inside it; the store
|
|
# is read-only content-addressed build output, not a source of
|
|
# secrets, so mounting the whole thing read-only costs nothing beyond
|
|
# the two specific binaries actually being reachable.
|
|
# Read-only: see prCommentFilter above. Hermes resolves route scripts
|
|
# 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"
|
|
"${prReviewFilter}:/opt/data/scripts/gitea-pr-review-filter.py:ro"
|
|
|
|
"/nix/store:/nix/store:ro"
|
|
"${pkgs.git}/bin/git:/usr/local/bin/git:ro"
|
|
"${pkgs.tea}/bin/tea:/usr/local/bin/tea:ro"
|
|
];
|
|
environment = {
|
|
HERMES_UID = hermesUid;
|
|
HERMES_GID = hermesGid;
|
|
TZ = "Europe/Berlin";
|
|
|
|
# Point git/tea at the config the prepare-dirs oneshot wrote into
|
|
# hermesHome (visible here as /opt/data/...) — the credential-store
|
|
# helper, the luna gitea login, and (implicitly, via HOME not being
|
|
# overridden) darman's Hermes state stays wherever it already was.
|
|
GIT_CONFIG_GLOBAL = "/opt/data/.gitconfig";
|
|
XDG_CONFIG_HOME = "/opt/data/.config";
|
|
# HERMES_TIMEZONE is the highest-priority source hermes_time.py checks
|
|
# (ahead of config.yaml's `timezone` key) — the container has no host
|
|
# /etc/localtime bind-mount, so it defaults to UTC otherwise (fixed in
|
|
# 9403122 on jupiter; carried forward here).
|
|
HERMES_TIMEZONE = "Europe/Berlin";
|
|
|
|
# Dashboard + Authentik OIDC gate — see the file-level comment above.
|
|
HERMES_DASHBOARD = "1";
|
|
HERMES_DASHBOARD_HOST = "0.0.0.0"; # must be tailscale0-reachable, not just loopback
|
|
HERMES_DASHBOARD_OIDC_ISSUER = "https://auth.mgaction.town/application/o/hermes/";
|
|
HERMES_DASHBOARD_OIDC_CLIENT_ID = "4BqdJu3htnMtSZnyEu5zHnsSOvlEbw3Ie3mYVlh6";
|
|
# uvicorn's proxy_headers=True (web_server.py) only trusts
|
|
# X-Forwarded-Proto from forwarded_allow_ips, which defaults to
|
|
# 127.0.0.1 — neptun's Caddy reaches this over the tailnet (a real
|
|
# routed IP), so without this the dashboard sees the raw scheme (http)
|
|
# and builds an http:// redirect_uri that Authentik rejects against its
|
|
# registered https:// one. Safe to trust any peer here: 9119 is already
|
|
# scoped to loopback + tailscale0 only (no LAN firewall rule), so
|
|
# nothing untrusted can reach this process to begin with.
|
|
FORWARDED_ALLOW_IPS = "*";
|
|
};
|
|
environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
|
|
cmd = [ "gateway" "run" ];
|
|
};
|
|
|
|
systemd.services.podman-hermes-agent = {
|
|
after = [
|
|
"hermes-agent-prepare-dirs.service"
|
|
"systemd-tmpfiles-setup.service"
|
|
];
|
|
requires = [ "hermes-agent-prepare-dirs.service" ];
|
|
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
|
|
};
|
|
|
|
# The two Gitea webhook routes, written as config rather than created with
|
|
# `hermes webhook subscribe`.
|
|
#
|
|
# Gitea posts straight at Hermes (jupiter's gitea-hermes-webhook-provision
|
|
# 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.
|
|
#
|
|
# WHY NOT `hermes webhook subscribe`: it has no --toolsets flag, and without
|
|
# a toolset override a webhook run gets Hermes's constrained default
|
|
# (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). Gitea spells the same events three
|
|
# different ways and two of the spellings collide — from
|
|
# HookEventType.Event() in modules/webhook/type.go, and updateHookEvents in
|
|
# routers/api/v1/utils/hook.go for the api column:
|
|
#
|
|
# HookEventType wire name (here) api name (gitea.nix)
|
|
# --------------------------- ---------------------- --------------------
|
|
# issue_comment issue_comment issue_comment
|
|
# pull_request_comment issue_comment pull_request_comment
|
|
# pull_request_review_comment pull_request_comment pull_request_review
|
|
# pull_request_review_rejected pull_request_rejected pull_request_review
|
|
# pull_request_review_approved pull_request_approved pull_request_review
|
|
#
|
|
# Hermes matches these against X-GitHub-Event, i.e. the WIRE name. So
|
|
# "pull_request_comment" HERE means a review and "issue_comment" HERE means
|
|
# a comment — the exact inversion of how they read. X-GitHub-Event-Type
|
|
# carries the HookEventType, but Hermes does not look at it. This file and
|
|
# services/dev/gitea.nix therefore name the same event differently on
|
|
# purpose; neither is a typo.
|
|
#
|
|
# The api column is not a third alias but a coarser set: HasEvent
|
|
# (models/webhook/webhook.go) collapses all three review types onto
|
|
# pull_request_review, so the gitea hook cannot subscribe them separately.
|
|
# Approvals arrive here as a result and are dropped by NOT being in
|
|
# prReviewEvents — Hermes answers {"status": "ignored"} on the event match,
|
|
# before the filter script and before any LLM call. Widening to approvals is
|
|
# a mars-side change only: add "pull_request_approved" to prReviewEvents and
|
|
# "pull_request_review_approved" to the filter's ALLOWED_REVIEW_TYPES.
|
|
#
|
|
# issue_comment on the wire covers comments on plain issues too; the hook
|
|
# does not subscribe those, and the comment filter's is_pull check drops
|
|
# them anyway if the hook is ever widened.
|
|
#
|
|
# deliver is "log", not a chat target: both prompts tell her to answer in
|
|
# the 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,
|
|
# 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 filters are
|
|
# bind-mounted read-only from the store above so the agent cannot edit her
|
|
# own guard out. Hermes resolves the name relative to ~/.hermes/scripts,
|
|
# hence the bare filename.
|
|
#
|
|
# What read-only does NOT buy: it protects the sources, and this unit
|
|
# re-asserts prompt, filter, events and toolsets from them on every start,
|
|
# so a restart restores the intended config. The live file is inside the
|
|
# agent's own write-safe root, so a self-modification sticks until this unit
|
|
# next runs.
|
|
#
|
|
# Routes this unit does not name are left alone (the merge below is
|
|
# per-key), so retiring an old one stays a deliberate one-off:
|
|
# sudo podman exec hermes-agent hermes webhook remove <name>
|
|
systemd.services.hermes-agent-webhook-routes = {
|
|
description = "Write Hermes's Gitea webhook route config";
|
|
wantedBy = [ "multi-user.target" ];
|
|
# after, but not requires: this only writes a file that hermesHome must
|
|
# already exist for. A container that fails to come up should not also
|
|
# 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 = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
};
|
|
script = ''
|
|
set -euo pipefail
|
|
|
|
conf=${hermesHome}/webhook_subscriptions.json
|
|
tmp="$conf.new"
|
|
trap 'rm -f "$tmp"' EXIT
|
|
|
|
# --slurpfile below cannot read a file that does not exist. Creating it
|
|
# empty is safe: this only ever happens before the first run, when there
|
|
# are no routes to lose. If it exists but is not valid JSON, slurpfile
|
|
# 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"
|
|
|
|
# The secret reaches jq via --rawfile, never argv: /proc/<pid>/cmdline
|
|
# is world-readable, so `--arg secret "$(cat ...)"` would publish it to
|
|
# every user on the box for the lifetime of the process. Same reason the
|
|
# prompts come in by path rather than by value.
|
|
#
|
|
# sops stores this one without a trailing newline (see secrets.nix), but
|
|
# rtrimstr is kept anyway: a stray newline would silently change the key
|
|
# the HMAC is computed with and fail every delivery afterwards.
|
|
#
|
|
# The emptiness guards are load-bearing. Without them a truncated secret
|
|
# file or an unreadable prompt yields "", and the route is written with
|
|
# an empty secret — which fails EVERY signature check while the unit
|
|
# still reports success.
|
|
jq -n \
|
|
--slurpfile existing "$conf" \
|
|
--rawfile rawSecret "$SECRET_FILE" \
|
|
--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"
|
|
'';
|
|
};
|
|
}
|