Files
homelab/hosts/mars/hermes-agent.nix
T
darmanandClaude Opus 5 812c1af1f2 mars(kittentts): install the wheel under its real filename
uv reads the version from the wheel filename, and the fetchurl store path's
hash prefix made it reject the file ("invalid version"). Expose it via a
linkFarm under kittentts-0.8.1-py3-none-any.whl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:59:01 +02:00

681 lines
34 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=";
};
# uv parses the version out of the filename; the store hash prefix breaks it.
kittenttsWheelFile = "${pkgs.linkFarm "kittentts-wheel" {
"kittentts-0.8.1-py3-none-any.whl" = kittenttsWheel;
}}/kittentts-0.8.1-py3-none-any.whl";
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, 0755 so
# the uid-986 delivery step can read it; nothing in it is secret):
# 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" ];
# diffutils: `cmp` in the fast path; missing, it silently re-delivers every boot.
path = [ pkgs.uv pkgs.coreutils pkgs.diffutils pkgs.util-linux ];
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.
# 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"
# 0755: the setpriv'd uid-986 delivery below must be able to read it.
chmod 0755 "$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) — but only for the
# CURRENT inputs, else a bump delivers the old venv under a new stamp.
if [ "$(cat "$stampFile" 2>/dev/null)" != "$inputHash" ] \
|| ! [ -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 \
${kittenttsWheelFile}
# 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 \
${pkgs.runtimeShell} -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"
'';
};
}