mars(kittentts): review fixes — root-owned staging, offline cache resolved, full-input stamp
Review round 1 fixes: 1+2 (security): build+stamp+uv-cache moved out of hermesHome into a 0700 root-owned staging dir (/var/lib/hermes-kittentts). Root executes python only from the staging venv after an offline import check; finished artifacts are copied into hermesHome and the stamp compares delivered copy vs staging byte-for-byte. No root code path follows anything the container can write, so no stamp symlink-takeover and no planted-wheel-in-cache execution. 3: HF snapshot dir is the real commit sha with refs/main written, so hf_hub_download resolves the seeded cache offline (was 'kitten', never found). 4: container env pins HF_HOME=/opt/data/kittentts-hf + HF_HUB_OFFLINE=1. 5: stamp keyed on the full input set (requirements+wheel+stub+models), not requirements alone; deletion of a delivered file drops out of the fast path and re-provisions cheaply. 6: orders after/requires hermes-agent-prepare-dirs.
This commit is contained in:
+119
-39
@@ -301,6 +301,11 @@ in
|
|||||||
# Authentik rejects. Safe to trust any peer: 9119 is already scoped to
|
# Authentik rejects. Safe to trust any peer: 9119 is already scoped to
|
||||||
# loopback + tailscale0 only.
|
# loopback + tailscale0 only.
|
||||||
FORWARDED_ALLOW_IPS = "*";
|
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 ];
|
environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
|
||||||
cmd = [ "gateway" "run" ];
|
cmd = [ "gateway" "run" ];
|
||||||
@@ -486,26 +491,42 @@ in
|
|||||||
# .pth-registered stub (kitten-misaki-stub.py) that fails loudly if misaki
|
# .pth-registered stub (kitten-misaki-stub.py) that fails loudly if misaki
|
||||||
# is ever actually used.
|
# is ever actually used.
|
||||||
#
|
#
|
||||||
# Provisioner invariants (shaped by the Mnemosyne-round review):
|
# Provisioner invariants (shaped by the Mnemosyne + KittenTTS review rounds):
|
||||||
# - `uv venv --clear`: re-provision cannot wedge on an existing dir.
|
# - Build in a ROOT-OWNED staging dir (/var/lib/hermes-kittentts, 0700):
|
||||||
# - Root runs NO python from this venv: the unit itself does only fs
|
# stamp, staging venv, uv cache and staging model copies all live there.
|
||||||
# writes; the venv is chowned to the container uid before Hermes ever
|
# Root never reads or executes anything the container can write — the
|
||||||
# imports from it. (Hermes executes provider code as uid 986.)
|
# container cannot symlink-takeover the stamp (review #1) or plant a
|
||||||
# - HF model cache is PRE-SEEDED from hash-pinned store paths, so
|
# wheel in the uv cache (review #2); only FINISHED artifacts are copied
|
||||||
# HF_HUB_OFFLINE=1 gives zero boot-time network and no drift.
|
# into hermesHome, and the stamp compares the delivered copy against
|
||||||
# - Idempotent via a stamp keyed on the requirements hash; checked
|
# staging byte-for-byte.
|
||||||
# against BOTH the venv python and model files being intact.
|
# - 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 venv lives inside hermesHome (HERMES_WRITE_SAFE_ROOT),
|
# Trust boundary: the DELIVERED venv lives inside hermesHome
|
||||||
# so luna can technically alter her own TTS engine. Deliberate: it's her
|
# (HERMES_WRITE_SAFE_ROOT), so luna can alter her own TTS engine — and a
|
||||||
# voice, not her jail — the webhook filter scripts remain the only
|
# 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.
|
# write-protected-but-load-bearing items.
|
||||||
systemd.services.hermes-agent-kittentts-provision = {
|
systemd.services.hermes-agent-kittentts-provision = {
|
||||||
description = "Provision KittenTTS voice provider (side venv + offline HF cache)";
|
description = "Provision KittenTTS voice provider (side venv + offline HF cache)";
|
||||||
before = [ "podman-hermes-agent.service" ];
|
before = [ "podman-hermes-agent.service" ];
|
||||||
wantedBy = [ "podman-hermes-agent.service" ];
|
wantedBy = [ "podman-hermes-agent.service" ];
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" ];
|
||||||
after = [ "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.
|
||||||
|
after = [ "hermes-agent-prepare-dirs.service" ];
|
||||||
|
requires = [ "hermes-agent-prepare-dirs.service" ];
|
||||||
path = [ pkgs.uv pkgs.coreutils ];
|
path = [ pkgs.uv pkgs.coreutils ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
@@ -516,51 +537,110 @@ in
|
|||||||
|
|
||||||
venv=${hermesHome}/kittentts-venv
|
venv=${hermesHome}/kittentts-venv
|
||||||
hubDir=${hermesHome}/kittentts-hf/hub/models--KittenML--kitten-tts-mini-0.8
|
hubDir=${hermesHome}/kittentts-hf/hub/models--KittenML--kitten-tts-mini-0.8
|
||||||
snap=$hubDir/snapshots/kitten
|
# Real upstream commit SHA as snapshot dir: hf_hub_download resolves
|
||||||
stampFile=${hermesHome}/kittentts-provision.stamp
|
# refs/main -> snapshots/<sha>; "kitten" (review #3) is never found
|
||||||
reqHash=$(sha256sum ${kittenttsReqs} | cut -d' ' -f1)
|
# 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 model files (hash-pinned store paths; the
|
||||||
|
# delivered copy under hermesHome is compared against these).
|
||||||
|
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 + venv + all three model files intact.
|
# Idempotent early exit: stamp matches the full input hash, staging venv
|
||||||
if [ -f "$stampFile" ] && [ "$(cat "$stampFile")" = "$reqHash" ] \
|
# validated, delivered copy intact check runs below.
|
||||||
&& [ -x "$venv/bin/python" ] \
|
if [ -f "$stampFile" ] && [ "$(cat "$stampFile")" = "$inputHash" ] \
|
||||||
&& [ -f "$snap/kitten_tts_mini_v0_8.onnx" ] \
|
&& [ -x "$stageVenv/bin/python" ] \
|
||||||
&& [ -f "$snap/voices.npz" ] \
|
&& [ -f "$stageSnap/config.json" ]; then
|
||||||
&& [ -f "$venv/lib/python3.13/site-packages/kitten_tts_stub.py" ]; then
|
# Delivered artifacts in hermesHome must ALSO match the staging copy:
|
||||||
exit 0
|
# 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
|
fi
|
||||||
|
|
||||||
# Venv (rebuilt rather than broken on --no-clear: uv exits 2 otherwise).
|
# ---- staging venv: root-owned path, uv cache included. Root runs
|
||||||
uv venv "$venv" --python ${pkgs.python313}/bin/python3 --clear --quiet
|
# python from HERE (container can write nothing in this tree), then the
|
||||||
UV_CACHE_DIR=${hermesHome}/kittentts-uv-cache \
|
# FINISHED result is copied into hermesHome. Review #1: a stamp symlink
|
||||||
uv pip install --python "$venv/bin/python" --quiet \
|
# into /etc/shadow can't redirect us — the stamp dir is root-only. ----
|
||||||
|
stageVenv=$stageDir/venv
|
||||||
|
mkdir -p "$stageDir" "$stageSnap"
|
||||||
|
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"
|
||||||
|
|
||||||
|
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}
|
--requirement ${kittenttsReqs}
|
||||||
# kittentts --no-deps: its overlay of spacy/misaki[en] is what drags in
|
# kittentts --no-deps: its overlay of spacy/misaki[en] is what drags in
|
||||||
# the CUDA tree; the requirements freeze already covers its real needs.
|
# the CUDA tree; the requirements freeze already covers its real needs.
|
||||||
UV_CACHE_DIR=${hermesHome}/kittentts-uv-cache \
|
UV_CACHE_DIR=$stageDir/uv-cache \
|
||||||
uv pip install --python "$venv/bin/python" --quiet --no-deps \
|
uv pip install --python "$stageVenv/bin/python" --quiet --no-deps \
|
||||||
${kittenttsWheel}
|
${kittenttsWheel}
|
||||||
|
|
||||||
# Dead-import shim: .pth auto-loads kitten_tts_stub at interpreter start
|
# Dead-import shim: .pth auto-loads kitten_tts_stub at interpreter start
|
||||||
# so `from misaki import en, espeak` resolves without the real misaki.en.
|
# so `from misaki import en, espeak` resolves without the real misaki.en.
|
||||||
siteDir=$("$venv/bin/python" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
|
siteDir=$("$stageVenv/bin/python" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
|
||||||
cp ${kittenttsStub} "$siteDir/kitten_tts_stub.py"
|
cp ${kittenttsStub} "$siteDir/kitten_tts_stub.py"
|
||||||
printf 'import kitten_tts_stub\n' > "$siteDir/zz-kitten-stub.pth"
|
printf 'import kitten_tts_stub\n' > "$siteDir/zz-kitten-stub.pth"
|
||||||
|
|
||||||
# Seed the HF cache with the hashed model files (exact hub layout;
|
# Root-side sanity: the venv python must import the plugin entrypoint
|
||||||
# hf_hub_download scans refs/snapshots on disk offline).
|
# cleanly offline; a failing build aborts BEFORE anything lands in
|
||||||
mkdir -p "$hubDir/refs" "$snap"
|
# hermesHome (fail closed, not half-delivered).
|
||||||
|
HF_HOME=$stageDir/hf-cache \
|
||||||
|
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; print("kittentts import ok")' >/dev/null
|
||||||
|
|
||||||
|
# ---- deliver: only finished artifacts into hermesHome. Copy, not
|
||||||
|
# move — staging stays root-owned for the integrity check above. ----
|
||||||
|
hubDir=${hermesHome}/kittentts-hf/hub/models--KittenML--kitten-tts-mini-0.8
|
||||||
|
snap=$hubDir/snapshots/$modelSha
|
||||||
|
rm -rf "$venv.tmp"
|
||||||
|
cp -a "$stageVenv" "$venv.tmp"
|
||||||
|
mv -Tf "$venv.tmp" "$venv" # atomic-ish swap of the delivered venv
|
||||||
|
|
||||||
|
mkdir -p "$hubDir/refs" "$snap" "$hubDir/blobs"
|
||||||
|
printf '%s' "$modelSha" > "$hubDir/refs/main" # review #3: resolves offline
|
||||||
install -m 0444 ${kittenttsModelOnnx} "$snap/kitten_tts_mini_v0_8.onnx"
|
install -m 0444 ${kittenttsModelOnnx} "$snap/kitten_tts_mini_v0_8.onnx"
|
||||||
install -m 0444 ${kittenttsModelVoices} "$snap/voices.npz"
|
install -m 0444 ${kittenttsModelVoices} "$snap/voices.npz"
|
||||||
install -m 0444 ${kittenttsModelConfig} "$snap/config.json"
|
install -m 0444 ${kittenttsModelConfig} "$snap/config.json"
|
||||||
|
# HF hub layout puts the blob under blobs/<sha256> with snapshot
|
||||||
|
# symlinks pointing back — but kittentts reads config/model paths
|
||||||
|
# RETURNED by hf_hub_download, which serves the resolved snapshot file
|
||||||
|
# directly. Files-as-plain-content suffices (no LFS indirection here:
|
||||||
|
# pre-materialized), no blob indirection needed.
|
||||||
|
|
||||||
# Hand ownership to the container uid BEFORE any python runs in this
|
# Hand ownership to the container uid BEFORE any python runs in
|
||||||
# tree (root never imports from it — that was Mnemosyne review #3).
|
# hermesHome's copy — root does its python in $stageVenv, not here.
|
||||||
chown -R ${hermesUid}:${hermesGid} "$venv" "$hubDir"
|
chown -R ${hermesUid}:${hermesGid} "$venv" "$hubDir"
|
||||||
|
|
||||||
# Stamp LAST — a half-provisioned venv fails the integrity check and
|
# Stamp LAST, root-owned outside hermesHome — luna can delete it (which
|
||||||
# re-provisions on next boot rather than being trusted.
|
# just forces a cheap re-provision copy), not tamper via symlink.
|
||||||
printf '%s' "$reqHash" > "$stampFile"
|
printf '%s' "$inputHash" > "$stampFile"
|
||||||
chown ${hermesUid}:${hermesGid} "$stampFile"
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user