Provisions a dedicated PR-tier gitea account (luna) with branch protection restricting master push/merge/approve to darman only, then wires git and tea directly into the hermes-agent container (mounted from the host's Nix store, credential-store + tea login set up by a host-side prepare oneshot, repo cloned inside Hermes's own writable sandbox root at /opt/data/workspace/homelab). Replaces an earlier standalone MCP-server approach, scrapped in favor of direct CLI access for simplicity. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
234 lines
12 KiB
Nix
234 lines
12 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
|
|
# — new — a clone of THIS repo at ${workspaceDir}/homelab plus `git`/
|
|
# `tea` (logged in as the `luna` gitea account, PR-tier only — see
|
|
# services/dev/gitea.nix). 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 own working copy of this repo (git+PR account provisioned in
|
|
# services/dev/gitea.nix). Lives under hermesHome specifically so it falls
|
|
# inside HERMES_WRITE_SAFE_ROOT=/opt/data — Hermes's own file-editing
|
|
# tools can reach it the same way they reach anything else it manages,
|
|
# without a separate bind mount or sandbox root.
|
|
workspaceDir = "${hermesHome}/workspace";
|
|
repoDir = "${workspaceDir}/homelab";
|
|
giteaHost = "git.mgaction.town";
|
|
giteaRepo = "darman/homelab";
|
|
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), and clones this repo if it
|
|
# isn't already there. All of this runs on the HOST as root, before the
|
|
# container starts — the container's own entrypoint is what fixes
|
|
# ownership to HERMES_UID/HERMES_GID on first boot (same mechanism
|
|
# already relied on for the rest of hermesHome; nothing new here).
|
|
#
|
|
# 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}
|
|
mkdir -p ${workspaceDir}
|
|
|
|
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 the remote URL (would land in
|
|
# repoDir/.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
|
|
git config --global credential.helper "store --file=${hermesHome}/.git-credentials"
|
|
git config --global user.name "luna"
|
|
git config --global user.email "luna@${giteaHost}"
|
|
|
|
if [ ! -d ${repoDir}/.git ]; then
|
|
git clone "https://${giteaHost}/${giteaRepo}.git" ${repoDir}
|
|
fi
|
|
|
|
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
|
|
'';
|
|
};
|
|
|
|
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" ];
|
|
};
|
|
}
|