1: stageVenv declared with the other path vars before the early-exit reads it (set -u no longer kills the self-heal path on later boots). 2: delivery is rm+cp as the container uid via setpriv — no mv -Tf rename-replace on a non-empty directory (that failed every re-provision); chown -R step gone with it. 3: network-online.target back in after= — this unit CAN download at boot, so uv must not run pre-network. 4: root never writes into hermesHome at all. refs/main, snapshots and venv staging happen under root-owned staging; delivery drops to uid 986 via setpriv, rm -rf + cp -a from the staging sources. Symlink-takeover of refs/install targets and the rm/cp race on the delivered tree are structurally gone. Minor: sanity check now constructs KittenTTS against the staged hub tree, not just imports; fetchurls pinned to the commit sha matching refs/main; fast-path comment matches behavior (staging venv skips rebuild on damaged delivery).
672 lines
33 KiB
Nix
672 lines
33 KiB
Nix
{ config, pkgs, ... }:
|
|
|
|
# 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 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 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 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.
|
|
#
|
|
# 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 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";
|
|
|
|
# v2026.9.14, pinned by index digest rather than floating
|
|
# :latest, so bumping Hermes is an explicit edit here, not silent drift.
|
|
hermesImage = "docker.io/nousresearch/hermes-agent@sha256:99641e57ec762c59e54cb44aa6746b7fc68c18b3c5ddb088af54234c613d9294";
|
|
|
|
# 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 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
|
|
);
|
|
prReviewFilter = pkgs.writeText "gitea-pr-review-filter.py" (
|
|
builtins.readFile ./gitea-pr-review-filter.py
|
|
);
|
|
|
|
# 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
|
|
);
|
|
prReviewPrompt = pkgs.writeText "gitea-pr-review-prompt.md" (
|
|
builtins.readFile ./gitea-pr-review-prompt.md
|
|
);
|
|
|
|
# Mnemosyne memory provider: third-party plugin, not in the image.
|
|
mnemosyneEnv = pkgs.callPackage ../../pkgs/mnemosyne-env.nix { };
|
|
|
|
# KittenTTS voice provider inputs (CPU-only; model + wheel hash-pinned).
|
|
# Provisioning unit near the bottom of this file; background on why the
|
|
# deps deviate from upstream's declaration lives in
|
|
# ./kittentts/requirements.txt + ./kittentts/kitten-misaki-stub.py.
|
|
kittenttsWheel = pkgs.fetchurl {
|
|
url = "https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl";
|
|
sha256 = "sha256-SCpDbE8fMZIVNxA3bkWf82iVF+vNp8KwUeL9QYe0GFE=";
|
|
};
|
|
kittenttsReqs = pkgs.writeText "kittentts-requirements.txt" (
|
|
builtins.readFile ./kittentts/requirements.txt
|
|
);
|
|
kittenttsStub = pkgs.writeText "kitten-tts-stub.py" (
|
|
builtins.readFile ./kittentts/kitten-misaki-stub.py
|
|
);
|
|
kittenttsModelOnnx = pkgs.fetchurl {
|
|
url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/c02725660cea441db4c383af69f1f26f5cd00947/kitten_tts_mini_v0_8.onnx";
|
|
sha256 = "sha256-D1u65PxIAMmNvFRKh+z6eVEN4vuCItsw0S5b/pF335E=";
|
|
};
|
|
kittenttsModelVoices = pkgs.fetchurl {
|
|
url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/c02725660cea441db4c383af69f1f26f5cd00947/voices.npz";
|
|
sha256 = "sha256-QK0mOJUrd7ey8wEn4mCOFp/GndJWtTvYqqNAmjMZPEI=";
|
|
};
|
|
kittenttsModelConfig = pkgs.fetchurl {
|
|
url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/c02725660cea441db4c383af69f1f26f5cd00947/config.json";
|
|
sha256 = "sha256-axYLybGeJOyyHoS8FPin2iH99H7HLUJFC8XPUUthgEo=";
|
|
};
|
|
|
|
# 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'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. Anything written host-side that gets
|
|
# READ back inside the container must use this prefix, not hermesHome.
|
|
containerHome = "/opt/data";
|
|
in
|
|
{
|
|
# 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>` == `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 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 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.
|
|
#
|
|
# 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 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" ];
|
|
wantedBy = [ "podman-hermes-agent.service" ];
|
|
wants = [ "network-online.target" ];
|
|
after = [ "network-online.target" ];
|
|
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
|
|
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.
|
|
serviceConfig.TimeoutStartSec = "120";
|
|
script = ''
|
|
mkdir -p ${hermesHome}
|
|
mkdir -p ${dropboxDir}
|
|
# 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}
|
|
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, 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}"
|
|
|
|
# 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
|
|
gitea_up=1
|
|
break
|
|
fi
|
|
echo "${giteaHost} unreachable (attempt $attempt/3); retrying in 5s" >&2
|
|
sleep 5
|
|
done
|
|
|
|
if [ "$gitea_up" = 1 ]; then
|
|
# 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
|
|
# 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 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`, 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: this dir is created as root, and Hermes reads
|
|
# scripts as uid ${hermesUid}.
|
|
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"
|
|
|
|
# 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 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"
|
|
|
|
"/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";
|
|
|
|
# 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";
|
|
# 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.
|
|
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 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 = "*";
|
|
|
|
# KittenTTS: point huggingface_hub at the pre-seeded offline cache and
|
|
# forbid network — no model drift, no boot-time fetch (review #4).
|
|
HF_HOME = "/opt/data/kittentts-hf";
|
|
HF_HUB_OFFLINE = "1";
|
|
};
|
|
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 (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.
|
|
#
|
|
# 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.
|
|
#
|
|
# 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)
|
|
# --------------------------- ---------------------- --------------------
|
|
# 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
|
|
#
|
|
# So "pull_request_comment" HERE means a review and "issue_comment" HERE
|
|
# means a comment — neither this file nor gitea.nix has a typo.
|
|
#
|
|
# 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 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 answer directly in the
|
|
# pull request.
|
|
#
|
|
# `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.
|
|
#
|
|
# 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 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" ];
|
|
# 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 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"
|
|
|
|
# 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" \
|
|
--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 and the only key carried over from any
|
|
# existing route; everything else is replaced outright so a
|
|
# leftover key from an earlier definition cannot survive here.
|
|
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: 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"
|
|
mv -f "$tmp" "$conf"
|
|
'';
|
|
};
|
|
|
|
# Hermes discovers memory providers under $HERMES_HOME/plugins; the target
|
|
# is a store path, readable in the container via the /nix/store ro mount.
|
|
# wantedBy, not requiredBy: a failure leaves Hermes on built-in memory.
|
|
systemd.services.hermes-agent-mnemosyne-plugin = {
|
|
description = "Link Mnemosyne provider into the Hermes plugin dir";
|
|
before = [ "podman-hermes-agent.service" ];
|
|
wantedBy = [ "podman-hermes-agent.service" ];
|
|
after = [ "hermes-agent-prepare-dirs.service" ];
|
|
requires = [ "hermes-agent-prepare-dirs.service" ];
|
|
path = [ pkgs.coreutils ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
};
|
|
script = ''
|
|
set -euo pipefail
|
|
pluginsDir=${hermesHome}/plugins
|
|
pluginDir=$pluginsDir/mnemosyne
|
|
target=${mnemosyneEnv}/${mnemosyneEnv.sitePackages}/hermes_memory_provider
|
|
[ -d "$target" ] || { echo "$target missing" >&2; exit 1; }
|
|
|
|
mkdir -p "$pluginsDir"
|
|
chown ${hermesUid}:${hermesGid} "$pluginsDir"
|
|
ln -sfn "$target" "$pluginDir.new"
|
|
mv -Tf "$pluginDir.new" "$pluginDir"
|
|
chown -h ${hermesUid}:${hermesGid} "$pluginDir"
|
|
'';
|
|
};
|
|
|
|
# ---- KittenTTS voice provider ------------------------------------------
|
|
# CPU-only onnxruntime TTS (no GPU on mars), mini model per darman. The
|
|
# upstream `misaki[en]` declaration is deliberately not honored — it pulls
|
|
# torch→CUDA (5.6 GB verified); the runtime phonemizes with espeak-ng only,
|
|
# so the dead `from misaki import en, espeak` import is satisfied by a
|
|
# .pth-registered stub (kitten-misaki-stub.py) that fails loudly if misaki
|
|
# is ever actually used.
|
|
#
|
|
# Provisioner invariants (shaped by the Mnemosyne + KittenTTS review rounds):
|
|
# - Build in a ROOT-OWNED staging dir (/var/lib/hermes-kittentts, 0700):
|
|
# stamp, staging venv, uv cache and staging model copies all live there.
|
|
# Root never reads or executes anything the container can write — the
|
|
# container cannot symlink-takeover the stamp (review #1) or plant a
|
|
# wheel in the uv cache (review #2); only FINISHED artifacts are copied
|
|
# into hermesHome, and the stamp compares the delivered copy against
|
|
# staging byte-for-byte.
|
|
# - Root runs python ONLY from the staging venv (never from the delivered
|
|
# uid-986-owned tree in hermesHome) — after a root-side offline import
|
|
# check; a failing build aborts before anything is delivered.
|
|
# - HF model cache delivered to hermesHome from staging (refs/main ->
|
|
# snapshots/<real commit sha>), and the container env pins
|
|
# HF_HOME=/opt/data/kittentts-hf + HF_HUB_OFFLINE=1: zero boot-time
|
|
# network, no drift.
|
|
# - Idempotency stamp keyed on the FULL input set (requirements + wheel +
|
|
# stub + model files), not just requirements (review #5); a deleted
|
|
# delivered file falls out of the fast path and re-provisions cheaply.
|
|
# - Order after prepare-dirs (review #6) — mirrors the mnemosyne unit.
|
|
#
|
|
# Trust boundary: the DELIVERED venv lives inside hermesHome
|
|
# (HERMES_WRITE_SAFE_ROOT), so luna can alter her own TTS engine — and a
|
|
# deleted/modified copy just triggers a fresh delivery from the root-owned
|
|
# staging area on next boot (self-heals instead of wedging). Deliberate: it's
|
|
# her voice, not her jail — the webhook filter scripts remain the only
|
|
# write-protected-but-load-bearing items.
|
|
systemd.services.hermes-agent-kittentts-provision = {
|
|
description = "Provision KittenTTS voice provider (side venv + offline HF cache)";
|
|
before = [ "podman-hermes-agent.service" ];
|
|
wantedBy = [ "podman-hermes-agent.service" ];
|
|
wants = [ "network-online.target" ];
|
|
# after prepare-dirs (review #6): on a fresh state dir this unit must not
|
|
# create hermesHome root-owned before prepare-dirs sets the ownership
|
|
# layout — same ordering contract the mnemosyne unit has. PLUS
|
|
# network-online ordering (review2 #3): this unit CAN download at boot
|
|
# (unlike mnemosyne's store-path build), so uv must not run before the
|
|
# network is actually up.
|
|
after = [
|
|
"network-online.target"
|
|
"hermes-agent-prepare-dirs.service"
|
|
];
|
|
requires = [ "hermes-agent-prepare-dirs.service" ];
|
|
path = [ pkgs.uv pkgs.coreutils pkgs.setpriv ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
TimeoutStartSec = 600;
|
|
};
|
|
script = ''
|
|
set -euo pipefail
|
|
|
|
venv=${hermesHome}/kittentts-venv
|
|
hubDir=${hermesHome}/kittentts-hf/hub/models--KittenML--kitten-tts-mini-0.8
|
|
# Real upstream commit SHA as snapshot dir: hf_hub_download resolves
|
|
# refs/main -> snapshots/<sha>; "kitten" (review #3) is never found
|
|
# offline and silently triggers a re-download.
|
|
modelSha=c02725660cea441db4c383af69f1f26f5cd00947
|
|
snap=$hubDir/snapshots/$modelSha
|
|
# REVIEW #1/#2: nothing root touches lives in hermesHome. Stamp, staging
|
|
# venv and uv cache live root-owned under /var/lib/hermes-kittentts (a
|
|
# path the container can not pathwrite or symlinks into its own tree);
|
|
# the FINISHED staging venv and model files are the only things copied
|
|
# into hermesHome, and only after being validated. Root doesn't follow
|
|
# any uid-986-writable path while running as root.
|
|
stageDir=/var/lib/hermes-kittentts
|
|
# Root-side staging of the DELIVERED TREE (venv + full HF hub layout
|
|
# including refs/main) — everything root writes lives here, 0700.
|
|
# review2 #4: root never writes into hermesHome; delivery happens as
|
|
# the container uid via setpriv, copying from these root-owned sources.
|
|
stageVenv=$stageDir/venv
|
|
stageHub=$stageDir/kittentts-hf
|
|
stageSnap=$stageDir/hf-model
|
|
# Input key: requirements + wheel + stub + model files + resolved script.
|
|
# Review #5 — a miss on the old requirements-only stamp let a changed
|
|
# wheel/stub/model URL keep running the stale install forever.
|
|
inputHash=$(cat ${kittenttsReqs} ${kittenttsWheel} ${kittenttsStub} \
|
|
${kittenttsModelOnnx} ${kittenttsModelVoices} ${kittenttsModelConfig} \
|
|
| sha256sum | cut -d' ' -f1)
|
|
stampFile=$stageDir/provision.stamp
|
|
|
|
# Idempotent early exit: stamp matches the full input hash, staging venv
|
|
# validated, delivered copy intact check runs below.
|
|
if [ -f "$stampFile" ] && [ "$(cat "$stampFile")" = "$inputHash" ] \
|
|
&& [ -x "$stageVenv/bin/python" ] \
|
|
&& [ -f "$stageSnap/config.json" ]; then
|
|
# Delivered artifacts in hermesHome must ALSO match the staging copy:
|
|
# luna can rewrite her copy, that's fine — but then the missing file
|
|
# forces a re-provision (cheap copy, not a rebuild) so deletions
|
|
# cannot wedge the gateway without a voice.
|
|
if [ -x "$venv/bin/python" ] \
|
|
&& cmp -s "$stageSnap/config.json" "$snap/config.json" 2>/dev/null \
|
|
&& cmp -s "$stageSnap/kitten_tts_mini_v0_8.onnx" "$snap/kitten_tts_mini_v0_8.onnx" 2>/dev/null \
|
|
&& cmp -s "$stageSnap/voices.npz" "$snap/voices.npz" 2>/dev/null; then
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
# ---- staging venv + staged hub tree: root-owned path, uv cache
|
|
# included. Root runs python from HERE (container can write nothing in
|
|
# this tree); the FINISHED result is copied into hermesHome AS THE
|
|
# CONTAINER USER via setpriv (review2 #4) — root never writes into
|
|
# hermesHome, so no uid-986-controlled path is ever followed while
|
|
# running as root; symlink-takeover of stamps/refs/install targets is
|
|
# structurally impossible. ----
|
|
mkdir -p "$stageDir" "$stageSnap" "$stageVenv" "$stageHub"
|
|
chmod 0700 "$stageDir"
|
|
install -m 0444 ${kittenttsModelConfig} "$stageSnap/config.json"
|
|
install -m 0444 ${kittenttsModelOnnx} "$stageSnap/kitten_tts_mini_v0_8.onnx"
|
|
install -m 0444 ${kittenttsModelVoices} "$stageSnap/voices.npz"
|
|
|
|
# Skip the venv REBUILD when staging is still valid (review2 minor:
|
|
# damaged delivery should be a copy, not a rebuild).
|
|
if ! [ -x "$stageVenv/bin/python" ] \
|
|
|| ! [ -f "$stageVenv/lib/python3.13/site-packages/kitten_tts_stub.py" ]; then
|
|
rm -rf "$stageVenv"
|
|
UV_CACHE_DIR=$stageDir/uv-cache \
|
|
uv venv "$stageVenv" --python ${pkgs.python313}/bin/python3 --quiet
|
|
UV_CACHE_DIR=$stageDir/uv-cache \
|
|
uv pip install --python "$stageVenv/bin/python" --quiet \
|
|
--requirement ${kittenttsReqs}
|
|
# kittentts --no-deps: its overlay of spacy/misaki[en] is what drags in
|
|
# the CUDA tree; the requirements freeze already covers its real needs.
|
|
UV_CACHE_DIR=$stageDir/uv-cache \
|
|
uv pip install --python "$stageVenv/bin/python" --quiet --no-deps \
|
|
${kittenttsWheel}
|
|
|
|
# Dead-import shim: .pth auto-loads kitten_tts_stub at interpreter
|
|
# start so `from misaki import en, espeak` resolves without the real
|
|
# misaki.en.
|
|
siteDir=$("$stageVenv/bin/python" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
|
|
cp ${kittenttsStub} "$siteDir/kitten_tts_stub.py"
|
|
printf 'import kitten_tts_stub\n' > "$siteDir/zz-kitten-stub.pth"
|
|
fi
|
|
|
|
# Stage the full delivered hub tree (exact hf_hub_download layout:
|
|
# refs/main -> snapshots/<sha>; review1 #3) under root-owned staging.
|
|
stageModelDir=$stageHub/hub/models--KittenML--kitten-tts-mini-0.8
|
|
mkdir -p "$stageModelDir/refs" "$stageModelDir/snapshots/$modelSha"
|
|
printf '%s' "$modelSha" > "$stageModelDir/refs/main"
|
|
install -m 0444 ${kittenttsModelOnnx} "$stageModelDir/snapshots/$modelSha/kitten_tts_mini_v0_8.onnx"
|
|
install -m 0444 ${kittenttsModelVoices} "$stageModelDir/snapshots/$modelSha/voices.npz"
|
|
install -m 0444 ${kittenttsModelConfig} "$stageModelDir/snapshots/$modelSha/config.json"
|
|
# No blobs/ indirection: kittentts reads paths RETURNED by
|
|
# hf_hub_download, which serves the resolved snapshot file directly
|
|
# (prefer-dir layout works offline for fully-materialized files).
|
|
|
|
# Root-side sanity: the staged interpreter must construct the model
|
|
# END-TO-END offline (review2 minor — import alone doesn't exercise
|
|
# hf_hub_download; a broken cache layout must fail HERE, not in the
|
|
# gateway). Points HF_HOME at the staged hub tree itself.
|
|
HF_HOME=$stageHub \
|
|
HF_HUB_OFFLINE=1 \
|
|
PHONEMIZER_ESPEAK_LIBRARY="$("$stageVenv/bin/python" -c 'import espeakng_loader,pathlib;print(pathlib.Path(espeakng_loader.get_library_path()))')" \
|
|
PHONEMIZER_ESPEAK_DATA_PATH="$("$stageVenv/bin/python" -c 'import espeakng_loader,pathlib;print(pathlib.Path(espeakng_loader.get_data_path()))')" \
|
|
"$stageVenv/bin/python" -c 'from kittentts import KittenTTS; KittenTTS("KittenML/kitten-tts-mini-0.8"); print("kittentts offline build ok")' >/dev/null
|
|
|
|
# ---- deliver AS THE CONTAINER USER (review2 #4): root never writes
|
|
# into hermesHome, so no symlink race and no `chown` step. setpriv
|
|
# drops to uid 986, rm -rf's the old delivered copies and copies the
|
|
# fresh staging tree in. cp-as-986 also fixes review2 #2: rm+cp in one
|
|
# step, no mv -Tf rename-replace on a non-empty directory.
|
|
setpriv --reuid=${hermesUid} --regid=${hermesGid} --clear-groups \
|
|
env HOME="$hermesHome" sh -c '
|
|
set -eu
|
|
rm -rf "$1" "$2"
|
|
cp -a "$3" "$1"
|
|
cp -a "$4" "$2"
|
|
' _ \
|
|
"${hermesHome}/kittentts-venv" \
|
|
"${hermesHome}/kittentts-hf" \
|
|
"$stageVenv" \
|
|
"$stageHub"
|
|
|
|
# Stamp LAST, root-owned outside hermesHome — luna can delete it (which
|
|
# forces a cheap re-delivery on next boot), not tamper via symlink.
|
|
printf '%s' "$inputHash" > "$stampFile"
|
|
'';
|
|
};
|
|
}
|