Completes the subscription: it had a secret, a delivery target and a script,
but no prompt and no event list, so it woke the agent on every forwarded
event with nothing to tell her what to do.
--events pull_request_comment narrows the route to the one event the prompt
handles. This only works because the relay copies X-Gitea-Event into
X-GitHub-Event; without that every delivery arrives as "unknown" and matches
nothing. Gitea sends pull_request_comment distinctly from issue_comment, so
plain issue comments no longer reach the agent at all. The Gitea-side hook
still posts the full event set to the relay and Hermes drops the rest before
any LLM call.
The prompt lives in hosts/mars/gitea-pr-comment-prompt.md, mounted read-only
next to the filter, and is read with $(cat) at subscribe time rather than
passed inline. That is not only about escaping: the text has to survive nix
`` string escaping, the systemd unit file, and `podman exec sh -c '...'`
single-quoting. It contains an apostrophe ("the PR's head branch") that
would terminate that single-quoted string early. Read from a file at runtime
the content never passes through shell source, so it can contain anything.
Verified end to end against the rendered unit with stubbed podman/hermes:
the value reaching --prompt is byte-identical to the repo file apart from
the trailing newline that command substitution strips.
`set -eu` inside the container shell is load-bearing. Without it a missing
prompt file makes cat fail, the substitution yields "", and the subscription
is created with an empty prompt -- a silent failure that still looks like a
healthy unit.
On 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
prompt, filter and events. The live subscription itself lives in
webhook_subscriptions.json under /opt/data and is hot-reloaded, which is
inside the agent's own write-safe root -- a self-modification would stick
until this unit next runs.
The prompt keeps its own stop conditions even though the filter already drops
those deliveries, and says explicitly that reaching them means the filter
failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
307 lines
16 KiB
Nix
307 lines
16 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 filter, mounted READ-ONLY below. It lives 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
|
|
);
|
|
|
|
# The route prompt, mounted read-only for the same reason as the filter and
|
|
# kept in a file rather than inline in the subscribe command: it is 60 lines
|
|
# of markdown containing apostrophes and {placeholders}, which would have to
|
|
# survive nix string escaping, the systemd unit, and `podman exec sh -c`
|
|
# quoting. A file crosses all three untouched and stays diffable in git.
|
|
prCommentPrompt = pkgs.writeText "gitea-pr-comment-prompt.md" (
|
|
builtins.readFile ./gitea-pr-comment-prompt.md
|
|
);
|
|
|
|
# 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 filter bind-mounted at
|
|
# /opt/data/scripts/gitea-pr-comment-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
|
|
mkdir -p ${hermesHome}/prompts
|
|
|
|
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 filter itself is world-readable 0444 from the store, so only
|
|
# the directory needs handing over.
|
|
chown ${hermesUid}:${hermesGid} ${hermesHome}/scripts ${hermesHome}/prompts
|
|
|
|
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.
|
|
"${prCommentFilter}:/opt/data/scripts/gitea-pr-comment-filter.py:ro"
|
|
"${prCommentPrompt}:/opt/data/prompts/gitea-pr-comment.md: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" ];
|
|
};
|
|
}
|