docs: condense comments across the repo

Comments had drifted into multi-paragraph narrative (git commit
lineage, debugging stories, restated code) in several hot spots
(scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix).
Trim every comment to its load-bearing "why" — gotchas, safety
warnings, and non-obvious rationale survive verbatim in substance,
just tightened to 1-2 sentences; historical narrative and anything
already covered in CLAUDE.md is cut. No code/logic changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
This commit is contained in:
2026-09-18 21:36:30 +02:00
co-authored by Claude Sonnet 5
parent 3899290c5b
commit 6f24ab69ad
47 changed files with 1051 additions and 1965 deletions
+159 -317
View File
@@ -1,83 +1,45 @@
{ 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.
# Hermes Agent runs on mars, which has no big data array — state lives on the
# local OS disk, and the shared dropbox reaches jupiter's array 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.
# Runs the official docker.io/nousresearch/hermes-agent image (verified on
# Docker Hub) as a plain podman container. It never sets HERMES_MANAGED, so
# Hermes fully self-manages config.yaml, profiles, memories and skills.
#
# 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.
# Security posture: reachable paths are only Hermes's own state dir, the
# shared dropbox, and git/tea as the PR-tier `luna` gitea account (see
# services/dev/gitea.nix) — no working copy of this repo is provisioned, and
# nothing else on jupiter's array or host is reachable if a command goes
# wrong or gets injected via Telegram/tool output. It runs its own Telegram
# bot with an explicit TELEGRAM_ALLOWED_USERS, and as a rootful podman
# container under its own uid/gid (not darman's). git/tea access is direct
# CLI rather than a wrapper; the real backstop is server-side gitea branch
# protection on `master` (only darman can push/merge/approve), 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's own Caddy (luna-sites.nix) only serves luna's apps
# and has no vhost for this — reach the dashboard directly via mars's tailnet
# name (mars.orbit.sol:9119) or LAN IP:9119 for local debugging.
# Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik like jupiter's; it
# fails closed without a registered auth provider. Binds 0.0.0.0:9119 (not
# loopback) so neptun's Caddy can reach it over tailscale0, but stays
# LAN-closed since there's no firewall rule opening it — reach it directly at
# mars.orbit.sol:9119 or via the public hermes.mgaction.town vhost on neptun.
# Uses upstream's generic self-hosted OIDC plugin against the same Authentik
# application (slug `hermes`) as before.
#
# 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.
# Starts with a fresh state dir — jupiter's instance was already reset to
# fresh on 2026-08-21, so nothing needed carrying forward. Its old data is
# backed up at /mnt/data/AppData/hermes.bak-2026-08-21 if that's ever wrong.
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.
# Shared drop-in folder for darman to hand files to Hermes, on jupiter's
# array (CIFS mount below) rather than locally. Mounted under /opt/data so
# it's inside Hermes's own write-safe root (HERMES_WRITE_SAFE_ROOT).
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.
# Pinned by digest (captured 2026-08-21 from jupiter) rather than floating
# :latest, so bumping Hermes is an explicit edit here, not silent drift.
hermesImage = "docker.io/nousresearch/hermes-agent@sha256:5342e518734a08f6c66b89b4262434813c28a77abbc59c230c8f1637df71a259";
# Kept identical to jupiter's instance purely so nothing else needs to
@@ -90,15 +52,10 @@ let
# 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.
# luna's webhook filters, mounted READ-ONLY from the nix store rather than
# written into hermesHome: that IS her write-safe root, so a writable copy
# would let her edit her own loop guard back out. A missing script fails
# closed (Hermes ignores it); read-only from the store rules out a rewrite.
prCommentFilter = pkgs.writeText "gitea-pr-comment-filter.py" (
builtins.readFile ./gitea-pr-comment-filter.py
);
@@ -106,13 +63,10 @@ let
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.
# Route prompts: not mounted into the container, but embedded as strings by
# the route config below via jq --rawfile, which lets ~60 lines of markdown
# full of apostrophes/{placeholders} skip nix string escaping and stay
# diffable in git.
prCommentPrompt = pkgs.writeText "gitea-pr-comment-prompt.md" (
builtins.readFile ./gitea-pr-comment-prompt.md
);
@@ -126,88 +80,55 @@ let
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.
# Toolsets granted to both routes' agent runs. Hermes's webhook default
# (web_search, web_extract, vision_analyze, clarify) has no shell/file/edit
# access, so neither prompt could act without this — and it REPLACES the
# default rather than merging, hence "web" being re-listed. luna could in
# principle self-grant via webhook_subscriptions.json (it's under her own
# HERMES_WRITE_SAFE_ROOT, and she has edited it before), so this only makes
# the grant reviewable and reasserted on restart, not unforgeable — the
# real backstop stays 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.
# hermesHome as the CONTAINER sees it. Anything written host-side that gets
# READ back inside the container must use this prefix, not hermesHome.
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.
# ssh browsing convenience only — the container still uses HERMES_UID/GID
# above regardless of this.
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.
# `hermes <args>` == `sudo podman exec -it hermes-agent hermes <args>`. sudo
# is needed because oci-containers runs rootful podman, a separate
# namespace from darman's own rootless one.
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.
# podman needs the bind-mount sources to exist first; the dropbox lives on
# the CIFS mount below, which is fine to mkdir into directly.
#
# 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.
# Also provisions luna's git/tea access as root, before the container
# starts, and chowns what it writes itself — the image's cont-init only
# fixes ownership of hermesHome's top level, not what this oneshot drops
# into it. No longer clones the repo for her (see the header); the version
# that did left a stale ${hermesHome}/workspace/homelab that this does not
# clean up.
#
# 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
# Delete-then-add for the tea login, not an existence check: tea can leave
# a login entry behind even when `add` itself reports failure, so
# delete-then-add is the only idempotent option and picks up a rotated
# token for free.
#
# `tea logins add` is the ONLY step in here that touches the network, and
# ordering is what makes it survivable. switch-to-configuration restarts
# NetworkManager and starts this unit in the SAME pass: on 2026-09-11 the
# two landed in the same second, tea's connect went out over an interface
# that was still coming back, and the kernel spent 2m48s on SYN retries
# before reporting "connection timed out". That failed this unit, which
# podman-hermes-agent Requires=, so a five-second network blip took the
# whole container down and returned 4 from the deploy. Hence
# network-online.target below, the bounded reachability probe in the script,
# and TimeoutStartSec as the backstop — no single blocking call in here may
# outlive the deploy that started it.
# `tea logins add` is the only network call here, and ordering matters:
# switch-to-configuration restarts NetworkManager in the same pass as this
# unit, and on 2026-09-11 that raced badly enough to hang the unit for
# minutes and take the whole container down. Hence network-online.target,
# the bounded probe below, and TimeoutStartSec as a backstop.
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" ];
@@ -218,16 +139,13 @@ in
path = [ pkgs.git pkgs.tea pkgs.curl pkgs.coreutils ];
serviceConfig.Type = "oneshot";
# Everything here is either local or bounded to ~30s by the probe loop, so
# anything past two minutes is a hang, not slowness. Failing at that point
# is strictly better than holding the deploy open.
# anything past two minutes is a hang, not slowness.
serviceConfig.TimeoutStartSec = "120";
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.
# Parent dir for the read-only filters bind-mounted below; must exist
# host-side first since /opt/data is itself a bind mount of hermesHome.
mkdir -p ${hermesHome}/scripts
export HOME=${hermesHome}
@@ -241,25 +159,18 @@ in
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.
# containerHome, not hermesHome: git reads this .gitconfig from inside
# the container, and nothing host-side needs it any more.
git config --global credential.helper "store --file=${containerHome}/.git-credentials"
git config --global user.name "luna"
git config --global user.email "luna@${giteaHost}"
# Probe before touching the login, with a hard per-attempt timeout: a
# bare TCP connect to an interface that is still coming up hangs for
# ~3 minutes on kernel SYN retries, and tea has no timeout flag of its
# own. /api/v1/version is unauthenticated, so this says "is gitea
# reachable", never "is the token good" the token is the add's job.
#
# Probing FIRST (rather than retrying the add) is what protects the
# login that is already there. delete-then-add is not atomic: an add
# that fails because the network is down leaves luna with no login at
# all, strictly worse than the stale-but-working one we started with.
# Unreachable therefore means skip the refresh entirely and warn.
# A bare TCP connect to an interface still coming up can hang ~3min on
# kernel SYN retries, and tea has no timeout flag, so probe first with a
# hard per-attempt timeout. /api/v1/version is unauthenticated (tests
# reachability only). Probing before touching the login (rather than
# retrying the add) protects it: delete-then-add isn't atomic, so an add
# that fails on a down network would leave luna with no login at all.
gitea_up=0
for attempt in 1 2 3; do
if curl -fsS --max-time 5 -o /dev/null "https://${giteaHost}/api/v1/version"; then
@@ -271,42 +182,29 @@ in
done
if [ "$gitea_up" = 1 ]; then
# Reachable but the add still fails == a real problem (revoked or
# under-scoped token, gitea rejecting the login), and that stays
# fatal: it is a config error, it will not fix itself on the next
# boot, and it should be loud.
# Reachable but still failing means a real problem (revoked/under-
# scoped token) stays fatal since it won't fix itself on reboot.
tea logins delete luna 2>/dev/null || true
GITEA_SERVER_TOKEN="$(cat "$token_file")" timeout 60 tea logins add \
--name luna --url "https://${giteaHost}" --no-version-check
else
# Deliberately not fatal. Every other thing this unit does is local,
# and podman-hermes-agent Requires= it failing here would take
# Telegram and the dashboard down over a transient blip. luna keeps
# git (the credential helper above needs no network to be written)
# and loses only the tea CLI until the next start re-runs this.
# Not fatal: everything else here is local, and podman-hermes-agent
# Requires= this unit failing here would take Telegram/dashboard
# down over a transient blip instead of just the tea CLI.
echo "WARNING: ${giteaHost} unreachable; left luna's tea login untouched." >&2
fi
# 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.
# Hand written files to the container's uid/gid: the image's cont-init
# only chowns hermesHome's top level, so root-owned files dropped here
# (confirmed on 2026-08-23) are otherwise unreadable to Hermes.
#
# `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.
# `if`, not `[ -d x ] && chown`: this script runs under `set -e`, and a
# false test on the left of && would abort the whole 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.
# Same cont-init caveat: this dir is created as root, and Hermes reads
# scripts as uid ${hermesUid}.
chown ${hermesUid}:${hermesGid} ${hermesHome}/scripts
if [ -d ${hermesHome}/.config ]; then
@@ -330,25 +228,18 @@ in
"${hermesHome}:/opt/data"
"${dropboxDir}:/opt/data/dropbox"
# luna's Obsidian vault, kept in sync with CouchDB on jupiter by
# livesync-bridge.nix. Under /opt/data so it lands inside
# HERMES_WRITE_SAFE_ROOT and she can write notes, not just read them —
# same reasoning as the dropbox above. The bridge runs as this very
# uid/gid, so no ownership fixup is needed on either side.
# luna's Obsidian vault, synced with CouchDB on jupiter by
# livesync-bridge.nix. Under /opt/data so she can write notes, not just
# read them; the bridge runs as this same uid/gid so no chown is needed.
"/var/lib/livesync-bridge/vault:/opt/data/vault"
# 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.
# git/tea for luna: the image ships neither (and its own git shouldn't
# be trusted), so both come from this host's Nix store, read-only.
# /nix/store must come along too since both binaries are dynamically
# linked against it.
# Filters mounted read-only (see prCommentFilter above), where Hermes
# resolves route scripts (~/.hermes/scripts). Prompts are NOT mounted —
# they're embedded directly in the route config the unit below writes.
"${prCommentFilter}:/opt/data/scripts/gitea-pr-comment-filter.py:ro"
"${prReviewFilter}:/opt/data/scripts/gitea-pr-review-filter.py:ro"
@@ -361,16 +252,12 @@ in
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.
# Points git/tea at the config prepare-dirs wrote into hermesHome
# (visible here as /opt/data/...).
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).
# Highest-priority source hermes_time.py checks; without it the
# container defaults to UTC (no /etc/localtime bind-mount).
HERMES_TIMEZONE = "Europe/Berlin";
# Dashboard + Authentik OIDC gate — see the file-level comment above.
@@ -378,14 +265,11 @@ in
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.
# uvicorn only trusts X-Forwarded-Proto from forwarded_allow_ips
# (default 127.0.0.1); neptun's Caddy reaches this over a real routed
# tailnet IP, so without this it builds an http:// redirect_uri that
# Authentik rejects. Safe to trust any peer: 9119 is already scoped to
# loopback + tailscale0 only.
FORWARDED_ALLOW_IPS = "*";
};
environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
@@ -401,35 +285,19 @@ in
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
};
# The two Gitea webhook routes, written as config rather than created with
# `hermes webhook subscribe`.
# The two Gitea webhook routes, written as config (not via `hermes webhook
# subscribe`, which has no --toolsets flag — see routeToolsets above).
# Gitea posts directly to Hermes with X-Hub-Signature-256 and
# X-GitHub-Event, which is what Hermes validates against and reads the
# event name from.
#
# 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.
# Written host-side into hermesHome (bind-mounted at /opt/data), so the
# webhook adapter hot-reloads it on the next delivery — no container
# restart needed.
#
# 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:
# Events below are WIRE names (X-GitHub-Event), not the api names
# gitea.nix's hooks use — gitea spells the same events three ways and two
# spellings collide:
#
# HookEventType wire name (here) api name (gitea.nix)
# --------------------------- ---------------------- --------------------
@@ -439,46 +307,31 @@ in
# 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.
# So "pull_request_comment" HERE means a review and "issue_comment" HERE
# means a comment — neither this file nor gitea.nix has 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.
# api names collapse all three review types onto pull_request_review, so
# approvals can't be subscribed separately — they arrive here and are
# dropped by omission from prReviewEvents. Widen by adding
# "pull_request_approved" here and 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.
# issue_comment on the wire also covers plain-issue comments; the comment
# filter's is_pull check drops those 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.
# deliver is "log", not a chat target both prompts answer directly in the
# pull request.
#
# `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.
# `script` must not be retunable at runtime: the filter drops luna's own
# comments before any LLM call (what stops the reply loop, since her PR
# answer is itself a pull_request_comment), and is mounted read-only so she
# can't edit her own guard out.
#
# 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.
# Read-only protects the source only — this unit re-asserts prompt, filter,
# events and toolsets on every start, so a live self-modification only
# sticks until the next restart.
#
# 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>
# Routes not named here are left alone (the merge below is per-key);
# retire one with `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" ];
@@ -504,26 +357,19 @@ in
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.
# --slurpfile needs the file to exist; empty is safe pre-first-run.
# Invalid JSON fails the unit loudly and leaves it untouched 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.
# Secret goes to jq via --rawfile, never argv (cmdline is world
# readable) same reason the prompts come in by path, not value.
# sops stores this without a trailing newline, but rtrimstr guards
# against one anyway: it would silently change the HMAC key.
# The emptiness guards are load-bearing: without them a truncated
# secret or unreadable prompt yields "", and the route is written with
# an empty secret that fails every signature check while reporting
# success.
jq -n \
--slurpfile existing "$conf" \
--rawfile rawSecret "$SECRET_FILE" \
@@ -547,12 +393,9 @@ in
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.
# created_at is cosmetic and the only key carried over from any
# existing route; everything else is replaced outright so a
# leftover key from an earlier definition can't survive here.
def upsert($name; $r):
.[$name] = ($r + { created_at: (.[$name].created_at // (now | todate)) });
@@ -566,10 +409,9 @@ in
$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
# 0600: holds the HMAC secret in cleartext. Owned by the container's
# uid since Hermes rewrites this file itself on `webhook subscribe`.
# mv is an atomic rename, so a delivery mid-write never sees a half
# written config.
chmod 0600 "$tmp"
chown ${hermesUid}:${hermesGid} "$tmp"