Files
homelab/hosts/mars/hermes-agent.nix
T

585 lines
28 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 (local SQLite, third-party plugin — not bundled
# with Hermes). Requirements pins live in ./mnemosyne/requirements.txt; see
# the provisioning unit near the bottom of this file. The venv lives at
# ${hermesHome}/mnemosyne-venv and the plugin discovery symlink
# (${hermesHome}/plugins/mnemosyne) is made RELATIVE — ../mnemosyne-venv/…
# — so it resolves identically on the host and inside the container, where
# /opt/data is a bind mount of hermesHome and hosts/containers see
# different mountpoint prefixes for the same tree.
mnemosyneReqs = pkgs.writeText "mnemosyne-requirements.txt" (
builtins.readFile ./mnemosyne/requirements.txt
);
# config.yaml provider-cue setter (PyYAML, preserves all other keys) — run
# host-side by the provisioning unit against the just-built, still
# root-owned venv's interpreter, BEFORE that venv is chowned to the
# container uid (root never executes from a luna-writable tree).
mnemosyneSetProvider = pkgs.writeText "mnemosyne-set-provider.py" (
builtins.readFile ./mnemosyne/set-provider.py
);
# 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 = "*";
};
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"
'';
};
# ---- Mnemosyne memory provider ----------------------------------------
# Third-party plugin (PyPI: mnemosyne-hermes + mnemosyne-memory), not
# bundled with the official image. Two pieces must exist before the gateway
# starts for memory.provider = mnemosyne to activate:
#
# 1. ${hermesHome}/plugins/mnemosyne — a RELATIVE symlink to the plugin
# package inside the side venv (../mnemosyne-venv/lib/…/site-packages/
# hermes_memory_provider). Hermes discovers providers by scanning
# $HERMES_HOME/plugins; resolve() keeps the traversal inside the
# bind-mounted tree, so it lands on the same files whether read
# host-side (/var/lib/hermes/…) or container-side (/opt/data/…).
#
# 2. The side venv (${hermesHome}/mnemosyne-venv), built with the pinned
# pins in ./mnemosyne/requirements.txt. Inside hermesHome so it lives
# under the container's HERMES_WRITE_SAFE_ROOT and survives image
# rebuilds.
#
# Import mechanics (verified against the plugin, not assumed): Hermes's own
# interpreter imports hermes_memory_provider OFF THE SYMLINK; that module's
# __init__.py itself inserts Path(__file__).resolve().parent.parent — the
# venv's site-packages — into sys.path before importing `mnemosyne.*`. So
# nothing needs to EXECUTE the venv's interpreter inside the container: the
# interpreter is only used host-side, by this unit, at provisioning time.
#
# Idempotent: marked done by a stamp file keyed by the hash of the
# requirements text; the console script is verified before the stamp is
# written, so a half-install (venv present but install died) re-provisions
# rather than exiting on a stale stamp. A pin change also re-provisions.
#
# Re-provisioning always wipes and recreates the venv (`uv venv --clear`,
# plus an explicit rm -f for a leftover non-directory): `uv venv` refuses
# to reuse an existing dir, and a wipe-and-rebuild is precisely what a
# changed stamp is supposed to mean. Working under root against a
# luna-writable ${hermesHome} means Python must never be made to IMPORT
# from a tree she has written to — that's why siteDir is composed here
# (python3.13 is pinned in the uv venv path) instead of executing
# $venv/bin/python to ask it, and why the rm -f/ln -sfn pair cannot leave
# stale venv content behind. Deleting first is what guarantees the new
# venv is hermetic to root, not incremental.
#
# Ordering: before podman-hermes-agent (the gateway needs the plugin at
# import time), after network (uv fetches wheels on first provision,
# ~largest payload is onnxruntime), with a bounded timeout so a broken
# proxy cannot hang boot.
#
# Deliberately NOT Required= / requiredBy: a failed provision leaves the
# container running as before, without mnemosyne (RETAINED on purpose —
# hermes-webhook-routes and the gateway keep working, and config.yaml
# stays untouched, so a plain retry after fixing the network/mirror is
# enough). If mnemosyne activation itself should hard-fail boot, that
# needs an explicit decision from darman — the default here errs toward
# "don't take memory down along with everything else".
#
# No RequiresMountsFor: this unit only touches ${stateDir}, which is on
# the local filesystem (not a mount) on mars.
systemd.services.hermes-agent-mnemosyne-provision = {
description = "Provision Mnemosyne memory provider (side venv + plugin symlink)";
before = [ "podman-hermes-agent.service" ];
wantedBy = [ "podman-hermes-agent.service" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
path = [ pkgs.uv pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
TimeoutStartSec = 600;
};
environment = {
UV_CACHE_DIR = "${hermesHome}/mnemosyne-uv/cache";
UV_COMPILE_BYTECODE = "1";
};
script = ''
set -euo pipefail
venv=${hermesHome}/mnemosyne-venv
pluginsDir=${hermesHome}/plugins
pluginDir=$pluginsDir/mnemosyne
stampFile=${hermesHome}/mnemosyne-provision.stamp
reqHash=$(sha256sum ${mnemosyneReqs} | cut -d" " -f1)
if [ -x "$venv/bin/mnemosyne-hermes" ] && [ -f "$stampFile" ] \
&& [ "$(cat "$stampFile")" = "$reqHash" ] \
&& [ -e "$pluginDir" ]; then
exit 0
fi
mkdir -p "$pluginsDir"
# Recreate from scratch so she cannot place anything into it that
# provisioning would then execute or trust (root/luna trust boundary:
# root only ever IMPORTS from a venv IT just built).
rm -rf "$venv"
uv venv "$venv" --python ${pkgs.python313}/bin/python3 --quiet --clear
uv pip install \
--python "$venv/bin/python" \
--requirement ${mnemosyneReqs} --quiet
# Console script EXISTENCE is the success marker — verified before the
# stamp, so a half-install cannot be trusted on the next run.
[ -x "$venv/bin/mnemosyne-hermes" ] || {
echo "mnemosyne-hermes console script missing after install" >&2; exit 1;
}
# The plugin package dir name hermes_memory_provider is fixed by the
# upstream wheel; catching a rename here costs one ls per provision
# and is cheaper than importing from a mid-name drift.
siteDir="$venv/lib/python3.13/site-packages"
target="hermes_memory_provider"
[ -d "$siteDir/$target" ] || {
echo "mnemosyne plugin package not found in venv" >&2; exit 1;
}
# RELATIVE symlink: unambiguous across the bind mount (host prefix
# /var/lib/hermes vs container prefix /opt/data point at the same
# tree; build the traversal from plugins/mnemosyne, not from any
# absolute path baked in either direction).
rm -f "$pluginDir"
ln -sfn "../mnemosyne-venv/lib/python3.13/site-packages/$target" "$pluginDir"
chmod 0755 "$(dirname "$pluginDir")" "$pluginsDir"
chown ${hermesUid}:${hermesGid} "$(dirname "$pluginDir")"
# Config cue FIRST — the venv is still root-owned here, so root is
# executing its own freshly built interpreter, not a container-uid
# tree (the chown below hands that tree over; nothing executes from
# it after that point).
cfg=${hermesHome}/config.yaml
# A pre-existing wrong `provider:` value, `memory: null` / `memory: {}`,
# a missing memory section and a missing config.yaml are all handled
# inside the helper (see its header) — never a bare sed on YAML.
if [ -f "$cfg" ]; then
"$venv/bin/python" ${mnemosyneSetProvider} "$cfg"
chown ${hermesUid}:${hermesGid} "$cfg"
else
# Keep the cue out of first-run's way; just logged, not fatal.
echo "WARNING: $cfg not found; skipping provider cue (first-run will seed it)" >&2
fi
printf '%s' "$reqHash" > "$stampFile"
chown -R ${hermesUid}:${hermesGid} \
"$venv" "$pluginsDir" "$stampFile" \
${hermesHome}/mnemosyne-uv
'';
};
}