{ 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 ` == `sudo podman exec -it hermes-agent hermes `. 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 `. 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/), 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/; "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/; 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" ''; }; }