Files
homelab/hosts/mars/hermes-agent.nix
T
darmanandClaude Opus 5 50f83971de hermes: stop provisioning luna a working copy, fix her git/tea access
Three fixes to how luna's git/tea credentials are set up on mars, all found
against the running instance on 2026-08-23.

Drop the host-side clone. hermes-agent-prepare-dirs used to clone this repo
into ${hermesHome}/workspace/homelab, but nothing ever told luna at runtime
that it was there — she self-manages config/profiles/memories, so a path
baked into this file never reached her. She searched /opt/data/homelab and
/workspace, found neither, and concluded she had no repo at all. The
credentials are what actually grant access; any checkout is hers to make
anywhere inside HERMES_WRITE_SAFE_ROOT. The stale directory left by the old
version is deliberately not cleaned up, just unmanaged from here on.

Point credential.helper at the CONTAINER's path. It was written as the host
path (${hermesHome}/.git-credentials), which does not exist inside the
container where git actually reads the config — broken this way from 3c1f3e5
until now. Nothing host-side consumes those credentials any more, so the
container's view is the only one that has to be right; added `containerHome`
to make the distinction explicit at the point of use.

Chown what the oneshot writes. The image's cont-init only chowns the top
level of hermesHome and its own state — it does not recurse into the
root-owned 0600 files this unit drops there (.git-credentials, and tea's
config.yml, which tea also writes 0600), even though it runs afterwards. The
symptom was not an error but an absence: git reported no credential helper
and tea no login. Uses `if` rather than `[ -d x ] && chown` because under
`set -e` a false test on the left of an && list aborts the unit.

gitea.nix carries the matching comment updates: the luna provisioning unit is
server-side only, and her token needs write:repository,write:issue,read:user.
write:issue is the one that is easy to miss — a pull request IS an issue in
gitea's data model, so /pulls endpoints gate on the issue scope category and
`tea pr create` fails with write:repository alone even though clone, fetch
and push all work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 06:30:28 +02:00

268 lines
14 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";
# 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}
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
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.
"/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" ];
};
}