feat(hermes): add private voice and isolated workflows
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 188

This commit is contained in:
jenkins 2026-08-10 00:42:37 -03:00
parent 706073eef5
commit 1b2053511b
23 changed files with 1679 additions and 98 deletions

View File

@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
# dockerfiles/Dockerfile.hermes-jetson-stt
FROM dustynv/whisper@sha256:725c23a68ace3ee667b6465468c38d90e6a0cda9891923a7dd98b851d557569e
USER root
# The JetPack 5 base supplies the CUDA-enabled PyTorch build. Keep Whisper new
# enough for the multilingual turbo model while preserving that Jetson stack.
RUN python3 -m pip install --no-cache-dir --force-reinstall --no-deps \
openai-whisper==20250625
# Keep model acquisition in the audited image build. Runtime pods never need
# public egress and Whisper verifies the same digest when loading this cache.
ADD --checksum=sha256:aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c9839262a19a --chmod=0444 \
https://openaipublic.azureedge.net/main/whisper/models/aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c9839262a19a/large-v3-turbo.pt \
/opt/models/whisper/large-v3-turbo.pt
COPY dockerfiles/hermes-jetson-stt-server.py /opt/atlas/hermes-jetson-stt-server.py
RUN chmod 0555 /opt/atlas/hermes-jetson-stt-server.py
# The vendor image defaults to /opt/whisper, whose 2023 source checkout would
# shadow the pinned Python package. Run Atlas code from its own directory.
WORKDIR /opt/atlas
# Import the Xavier CUDA stack and confirm Whisper resolves the baked artifact.
# Full GPU warm-up is covered by the Kubernetes startup probe on titan-21.
RUN python3 -c "from pathlib import Path; import torch, whisper; print(whisper.__file__, whisper.__version__, whisper.available_models()); assert 'large-v3-turbo' in whisper.available_models(); assert Path('/opt/models/whisper/large-v3-turbo.pt').is_file(); print(torch.__version__)"
ENV HERMES_STT_HOST=0.0.0.0 \
HERMES_STT_PORT=9000 \
HERMES_STT_MODEL=large-v3-turbo \
HERMES_STT_CACHE=/opt/models/whisper \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
EXPOSE 9000
ENTRYPOINT ["python3", "/opt/atlas/hermes-jetson-stt-server.py"]

View File

@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1
# dockerfiles/Dockerfile.hermes-jetson-tts
FROM python:3.11-slim-bookworm@sha256:d29f48a31a8b408ed19272ca1e7b10ebae13b240a27e862d3d4217c528e2e0c3
RUN python -m pip install --no-cache-dir piper-tts==1.5.0
# Pin the voice data in the image so synthesis has no runtime dependency on
# Hugging Face or mutable model metadata.
ADD --checksum=sha256:4cabf7c3a638017137f34a1516522032d4fe3f38228a843cc9b764ddcbcd9e09 --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/lessac/high/en_US-lessac-high.onnx?download=true \
/opt/models/piper/en_US-lessac-high.onnx
ADD --checksum=sha256:db42b97d9859f257bc1561b8ed980e7fb2398402050a74ddd6cbec931a92412f --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/lessac/high/en_US-lessac-high.onnx.json?download=true \
/opt/models/piper/en_US-lessac-high.onnx.json
COPY dockerfiles/hermes-jetson-tts-server.py /opt/atlas/hermes-jetson-tts-server.py
RUN chmod 0555 /opt/atlas/hermes-jetson-tts-server.py
# Load the actual pinned voice during the ARM64 build. This catches package or
# model-format drift before the image can reach Flux.
RUN python -c "from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); v=PiperVoice.load(p/'en_US-lessac-high.onnx', p/'en_US-lessac-high.onnx.json', use_cuda=False, download_dir=p); assert v.config.sample_rate > 0"
ENV HERMES_TTS_HOST=0.0.0.0 \
HERMES_TTS_PORT=9001 \
HERMES_TTS_VOICE=en_US-lessac-high \
HERMES_TTS_CACHE=/opt/models/piper \
OMP_NUM_THREADS=2 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
EXPOSE 9001
ENTRYPOINT ["python", "/opt/atlas/hermes-jetson-tts-server.py"]

View File

@ -2,7 +2,7 @@
# dockerfiles/Dockerfile.hermes-webui # dockerfiles/Dockerfile.hermes-webui
FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui
FROM registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f FROM registry.bstein.dev/bstein/hermes-agent@sha256:a09d36b7467d5810bd814b05d72004629695850d188a2c2a6041af8e2539ba08
USER root USER root
@ -29,12 +29,71 @@ before = ' <div class="reasoning-option" data-effort="max">Max</div>\
if before not in source: if before not in source:
raise SystemExit("Hermes WebUI xhigh UI patch context changed") raise SystemExit("Hermes WebUI xhigh UI patch context changed")
index.write_text(source.replace(before, "", 1), encoding="utf-8") index.write_text(source.replace(before, "", 1), encoding="utf-8")
# oauth2-proxy returns 401 for browser API and health probes when the secure
# session expires. Re-enter OIDC with the complete return path instead of
# presenting an endless, inaccurate "connection lost" loop.
ui = Path("/opt/hermes-webui/static/ui.js")
source = ui.read_text(encoding="utf-8")
before = ''' const res=await fetcher(_offlineHealthUrl(),opts);
return !!(res&&res.ok);
'''
after = ''' const res=await fetcher(_offlineHealthUrl(),opts);
if(res&&(res.status===401||res.status===403)){
const rd=window.location.pathname+window.location.search+window.location.hash;
window.location.assign('/oauth2/start?rd='+encodeURIComponent(rd));
return false;
}
return !!(res&&res.ok);
'''
if source.count(before) != 1:
raise SystemExit("Hermes WebUI auth-recovery patch context changed")
ui.write_text(source.replace(before, after, 1), encoding="utf-8")
# Make delegated session hierarchy obvious and collapsible in the sidebar.
sessions = Path("/opt/hermes-webui/static/sessions.js")
source = sessions.read_text(encoding="utf-8")
before = ''' const childLabel=t('session_meta_children', childCount);
childCountEl.textContent=childLabel;
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
'''
after = ''' const childLabel=t('session_meta_children', childCount);
const childrenExpanded=_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw;
childCountEl.textContent=(childrenExpanded?'▾ ':'▸ ')+childLabel;
childCountEl.setAttribute('aria-expanded',childrenExpanded?'true':'false');
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
'''
if source.count(before) != 1:
raise SystemExit("Hermes WebUI child-session toggle patch context changed")
sessions.write_text(source.replace(before, after, 1), encoding="utf-8")
# A profile's model is only its default; a session-level selector can override
# it. Label the scope so the dropdown does not contradict the effective model.
panels = Path("/opt/hermes-webui/static/panels.js")
source = panels.read_text(encoding="utf-8")
before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n"
after = " if (typeof p.model === 'string' && p.model) meta.push('profile default: ' + p.model.split('/').pop());\n"
if source.count(before) != 2:
raise SystemExit("Hermes WebUI profile-model label patch context changed")
panels.write_text(source.replace(before, after, 2), encoding="utf-8")
PY PY
# Add the Atlas voice bridge as a narrow integration layer. It activates only
# when a tenant's server-side STT capability reports the private Jetson route.
COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py
COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py
RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \ && grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \
/opt/hermes-webui/api/config.py \ /opt/hermes-webui/api/config.py \
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html && ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
&& grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \
&& grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \
&& grep -Fq "profile default: ' + p.model" /opt/hermes-webui/static/panels.js \
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
&& grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js
# Exercise the real server process in the target architecture before publish. # Exercise the real server process in the target architecture before publish.
RUN set -eu; \ RUN set -eu; \

View File

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Small OpenAI-compatible Whisper service for the dedicated Jetson."""
from __future__ import annotations
import cgi
import json
import os
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import torch
import whisper
HOST = os.getenv("HERMES_STT_HOST", "0.0.0.0")
PORT = int(os.getenv("HERMES_STT_PORT", "9000"))
MODEL_NAME = os.getenv("HERMES_STT_MODEL", "large-v3-turbo")
CACHE_DIR = Path(os.getenv("HERMES_STT_CACHE", "/cache/whisper"))
MAX_AUDIO_BYTES = 30 * 1024 * 1024
MODEL_LOCK = threading.Lock()
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
handler.wfile.write(body)
class SpeechHandler(BaseHTTPRequestHandler):
"""Serve health and transcription without exposing a general runtime."""
server_version = "AtlasWhisper/1"
def log_message(self, message: str, *args: object) -> None:
print(f"[stt] {self.address_string()} {message % args}", flush=True)
def do_GET(self) -> None:
if self.path != "/health":
_json(self, 404, {"error": "not found"})
return
_json(
self,
200,
{
"ok": True,
"model": MODEL_NAME,
"device": "cuda" if torch.cuda.is_available() else "cpu",
},
)
def do_POST(self) -> None:
if self.path != "/v1/audio/transcriptions":
_json(self, 404, {"error": "not found"})
return
content_length = int(self.headers.get("Content-Length", "0") or "0")
if content_length <= 0 or content_length > MAX_AUDIO_BYTES:
_json(self, 413, {"error": "audio payload is missing or too large"})
return
content_type = self.headers.get("Content-Type", "")
if not content_type.lower().startswith("multipart/form-data"):
_json(self, 400, {"error": "multipart/form-data is required"})
return
form = cgi.FieldStorage(
fp=self.rfile,
headers=self.headers,
environ={
"REQUEST_METHOD": "POST",
"CONTENT_TYPE": content_type,
"CONTENT_LENGTH": str(content_length),
},
)
audio = form["file"] if "file" in form else None
if audio is None or not getattr(audio, "file", None):
_json(self, 400, {"error": "file is required"})
return
suffix = Path(getattr(audio, "filename", "audio.wav") or "audio.wav").suffix
suffix = suffix if suffix in {".wav", ".webm", ".ogg", ".mp3", ".m4a"} else ".wav"
language = str(form.getfirst("language", "auto") or "auto").strip().lower()
temp_path = ""
try:
with tempfile.NamedTemporaryFile(prefix="atlas-stt-", suffix=suffix, delete=False) as temp:
temp_path = temp.name
while True:
chunk = audio.file.read(1024 * 1024)
if not chunk:
break
temp.write(chunk)
with MODEL_LOCK:
result = self.server.model.transcribe( # type: ignore[attr-defined]
temp_path,
language=None if language in {"", "auto"} else language,
task="transcribe",
fp16=torch.cuda.is_available(),
condition_on_previous_text=False,
temperature=0,
verbose=False,
)
transcript = str(result.get("text") or "").strip()
_json(self, 200, {"text": transcript, "model": MODEL_NAME})
except Exception as exc:
print(f"[stt] transcription failed: {exc}", flush=True)
_json(self, 500, {"error": "transcription failed"})
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
def main() -> None:
"""Warm Whisper once, then serve concurrent clients through one GPU lock."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
if not torch.cuda.is_available():
raise RuntimeError("CUDA is required for the Atlas Whisper service")
print(f"[stt] loading Whisper {MODEL_NAME} into CUDA", flush=True)
model = whisper.load_model(MODEL_NAME, device="cuda", download_root=str(CACHE_DIR))
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
server.model = model # type: ignore[attr-defined]
print(f"[stt] ready on {HOST}:{PORT}", flush=True)
server.serve_forever(poll_interval=0.25)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""CPU-only Piper service shared with the routing Jetson."""
from __future__ import annotations
import io
import json
import os
import threading
import wave
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from piper import PiperVoice, SynthesisConfig
HOST = os.getenv("HERMES_TTS_HOST", "0.0.0.0")
PORT = int(os.getenv("HERMES_TTS_PORT", "9001"))
VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-lessac-high")
CACHE_DIR = Path(os.getenv("HERMES_TTS_CACHE", "/cache/piper"))
MAX_TEXT_CHARS = 5000
VOICE_LOCK = threading.Lock()
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
handler.wfile.write(body)
class SpeechHandler(BaseHTTPRequestHandler):
"""Serve health and bounded local speech synthesis."""
server_version = "AtlasPiper/1"
def log_message(self, message: str, *args: object) -> None:
print(f"[tts] {self.address_string()} {message % args}", flush=True)
def do_GET(self) -> None:
if self.path != "/health":
_json(self, 404, {"error": "not found"})
return
_json(self, 200, {"ok": True, "voice": VOICE_NAME, "device": "cpu"})
def do_POST(self) -> None:
if self.path != "/v1/audio/speech":
_json(self, 404, {"error": "not found"})
return
content_length = int(self.headers.get("Content-Length", "0") or "0")
if content_length <= 0 or content_length > 64 * 1024:
_json(self, 413, {"error": "request is missing or too large"})
return
try:
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
text = str(payload.get("input") or payload.get("text") or "").strip()
speed = float(payload.get("speed") or 1.0)
except (TypeError, ValueError, json.JSONDecodeError):
_json(self, 400, {"error": "invalid JSON request"})
return
if not text:
_json(self, 400, {"error": "input is required"})
return
if len(text) > MAX_TEXT_CHARS:
_json(self, 400, {"error": "input is too long"})
return
speed = min(2.0, max(0.5, speed))
output = io.BytesIO()
try:
with VOICE_LOCK, wave.open(output, "wb") as wav_file:
self.server.voice.synthesize_wav( # type: ignore[attr-defined]
text,
wav_file,
SynthesisConfig(length_scale=1.0 / speed),
)
audio = output.getvalue()
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(audio)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(audio)
except Exception as exc:
print(f"[tts] synthesis failed: {exc}", flush=True)
_json(self, 500, {"error": "speech synthesis failed"})
def main() -> None:
"""Load the checksum-pinned voice from the image and serve it on CPU."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
model_path = CACHE_DIR / f"{VOICE_NAME}.onnx"
config_path = CACHE_DIR / f"{VOICE_NAME}.onnx.json"
if not model_path.exists() or not config_path.exists():
raise RuntimeError(f"baked Piper voice is missing: {VOICE_NAME}")
print(f"[tts] loading Piper voice {VOICE_NAME} on CPU", flush=True)
voice = PiperVoice.load(model_path, config_path, use_cuda=False, download_dir=CACHE_DIR)
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
server.voice = voice # type: ignore[attr-defined]
print(f"[tts] ready on {HOST}:{PORT}", flush=True)
server.serve_forever(poll_interval=0.25)
if __name__ == "__main__":
main()

View File

@ -50,12 +50,13 @@ registry.register(
schema={ schema={
"name": "python_sandbox", "name": "python_sandbox",
"description": ( "description": (
"Run Python in this user's separate credential-free computation " "Run Python in this user's credential-free computation sandbox. "
"sandbox. Use it for statistics, probability, Monte Carlo simulation, " "Its /workspace and /opt/data/workspace paths expose the same private "
"data transforms, and calculations. The sandbox has no Kubernetes, " "workspace, and /tmp is writable for temporary verifiers. "
"Vault, model-provider credentials, or access to other users. Public " "Use it for file verification, statistics, probability, Monte Carlo "
"research belongs in web_search; pass only the data needed for the " "simulation, data transforms, and calculations. The sandbox has no "
"calculation and print the result." "Kubernetes, Vault, model-provider credentials, or access to other "
"users. Public research belongs in web_search."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI."""
from pathlib import Path
ROOT = Path("/opt/hermes-webui")
def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
"""Replace an exact upstream fragment and fail when the pin has drifted."""
source = path.read_text(encoding="utf-8")
if source.count(before) != count:
raise SystemExit(f"Atlas voice patch context changed in {path}: {before[:80]!r}")
path.write_text(source.replace(before, after, count), encoding="utf-8")
index = ROOT / "static/index.html"
replace_exact(
index,
'<option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
'<option value="atlas">Atlas Jetson (private)</option><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
)
replace_exact(
index,
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>',
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>\n<script src="static/atlas-voice.js?v=__WEBUI_VERSION__" defer></script>',
)
ui = ROOT / "static/ui.js"
replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){")
replace_exact(
ui,
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})",
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
)
replace_exact(
ui,
"if(engine==='edge'){\n _playEdgeTtsChunked(clean, btn);",
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, btn, engine);",
)
replace_exact(
ui,
"if(engine==='edge'){\n _playEdgeTtsChunked(clean, null);",
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, null, engine);",
)
routes = ROOT / "api/routes.py"
marker = " # ── ElevenLabs TTS ──────────────────────────────────────────────────\n"
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
if engine == "atlas":
atlas_url = os.getenv("HERMES_WEBUI_ATLAS_TTS_URL", "").strip()
expected_url = "http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
if atlas_url != expected_url:
from api.helpers import bad as _bad
return _bad(handler, "Atlas private TTS is not configured", 503)
speed = 1.0
if rate_str:
try:
speed = max(0.5, min(2.0, 1.0 + (float(rate_str.rstrip("%")) / 100.0)))
except ValueError:
speed = 1.0
request_body = json.dumps({
"model": "piper",
"input": text,
"voice": "en_US-lessac-high",
"speed": speed,
}).encode("utf-8")
request = Request(atlas_url, data=request_body, headers={
"Content-Type": "application/json",
"Accept": "audio/wav",
})
try:
with _tts_open(
request,
timeout=45,
opener_factory=lambda: build_opener(ProxyHandler({}), _NoRedirectTtsHandler()),
) as response:
audio_data = _buffer_tts_audio_response(response)
except Exception:
logger.exception("Atlas private TTS generation failed")
from api.helpers import bad as _bad
return _bad(handler, "Atlas private TTS generation failed", 502)
handler.send_response(200)
handler.send_header("Content-Type", "audio/wav")
handler.send_header("Cache-Control", "no-store")
handler.send_header("Content-Length", str(len(audio_data)))
handler.end_headers()
try:
handler.wfile.write(audio_data)
except (BrokenPipeError, ConnectionResetError):
pass
return True
'''
replace_exact(routes, marker, atlas + marker)

View File

@ -0,0 +1,287 @@
// Natural turn-taking for chat.hermes.bstein.dev using the private Jetsons.
(function(){
'use strict';
const modeBtn=document.getElementById('btnVoiceMode');
const bar=document.getElementById('voiceModeBar');
const indicator=document.getElementById('voiceModeIndicator');
const label=document.getElementById('voiceModeLabel');
const composer=document.getElementById('msg');
if(!modeBtn||!bar||!indicator||!label||!composer||!navigator.mediaDevices||!window.MediaRecorder) return;
let ready=false;
let active=false;
let state='idle';
let generation=0;
let recorder=null;
let stream=null;
let audioContext=null;
let vadTimer=null;
let currentAudio=null;
let thinkingSession=null;
const originalAutoRead=window.autoReadLastAssistant;
const originalApplyPreference=window._applyVoiceModePref;
function toast(message){
if(typeof window.showToast==='function') window.showToast(message,3000);
}
function setState(next, customLabel){
state=next;
indicator.className='voice-mode-indicator '+next;
label.textContent=customLabel||(next==='listening'?'Listening…':next==='speaking'?'Speaking…':next==='thinking'?'Thinking…':'');
bar.style.display=active&&next!=='idle'?'':'none';
}
function stopCapture(){
if(vadTimer){clearInterval(vadTimer);vadTimer=null;}
if(recorder&&recorder.state!=='inactive'){
try{recorder.stop();}catch(_){ }
}
recorder=null;
if(stream){stream.getTracks().forEach(function(track){track.stop();});stream=null;}
if(audioContext){try{audioContext.close();}catch(_){ }audioContext=null;}
}
function stopPlayback(){
if(!currentAudio) return;
try{currentAudio.pause();currentAudio.currentTime=0;}catch(_){ }
currentAudio=null;
}
function deactivate(showMessage){
generation+=1;
active=false;
state='idle';
thinkingSession=null;
stopCapture();
stopPlayback();
modeBtn.classList.remove('active');
bar.style.display='none';
if(showMessage) toast('Hands-free voice mode off');
}
function restartSoon(token, delay){
window.setTimeout(function(){
if(active&&token===generation) startListening(token);
},delay||500);
}
function sendTranscript(transcript, token){
if(!active||token!==generation) return;
const text=String(transcript||'').trim();
if(!text){restartSoon(token,350);return;}
composer.value=text;
if(typeof window.autoResize==='function') window.autoResize();
thinkingSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
setState('thinking');
if(typeof window.send==='function') window.send();
}
async function transcribe(blob, token){
if(!active||token!==generation) return;
setState('thinking','Transcribing…');
const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm';
const form=new FormData();
form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext}));
try{
const response=await fetch('/api/transcribe',{method:'POST',body:form});
const payload=await response.json().catch(function(){return {};});
if(!response.ok) throw new Error(payload.error||('Whisper request failed: '+response.status));
sendTranscript(payload.transcript,token);
}catch(error){
if(!active||token!==generation) return;
deactivate(false);
toast((error&&error.message)||'Private Whisper is unavailable');
// If the browser supplies its own recognizer, hand control back to the
// upstream voice implementation until the Jetson becomes healthy again.
if(window.SpeechRecognition||window.webkitSpeechRecognition){
modeBtn.removeEventListener('click',onVoiceClick,true);
window.setTimeout(function(){modeBtn.click();},50);
}
}
}
async function startListening(token){
if(!active||token!==generation) return;
stopCapture();
setState('listening');
try{
const capture=await navigator.mediaDevices.getUserMedia({
audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true},
});
if(!active||token!==generation){capture.getTracks().forEach(function(track){track.stop();});return;}
stream=capture;
const Context=window.AudioContext||window.webkitAudioContext;
audioContext=new Context();
const analyser=audioContext.createAnalyser();
analyser.fftSize=1024;
audioContext.createMediaStreamSource(stream).connect(analyser);
const samples=new Uint8Array(analyser.fftSize);
const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/webm'];
const mime=mimeTypes.find(function(value){return MediaRecorder.isTypeSupported(value);})||'';
const chunks=[];
let heardSpeech=false;
let lastSpeech=Date.now();
const started=Date.now();
recorder=new MediaRecorder(stream,mime?{mimeType:mime}:undefined);
recorder.ondataavailable=function(event){if(event.data&&event.data.size) chunks.push(event.data);};
recorder.onstop=function(){
if(vadTimer){clearInterval(vadTimer);vadTimer=null;}
const recordedStream=stream;
stream=null;
if(recordedStream) recordedStream.getTracks().forEach(function(track){track.stop();});
if(audioContext){try{audioContext.close();}catch(_){ }audioContext=null;}
recorder=null;
if(!active||token!==generation) return;
if(!heardSpeech||!chunks.length){restartSoon(token,300);return;}
transcribe(new Blob(chunks,{type:mime||'audio/webm'}),token);
};
recorder.start(250);
const silenceMs=Math.max(900,parseInt(localStorage.getItem('hermes-voice-silence-ms')||'1600',10)||1600);
vadTimer=window.setInterval(function(){
if(!active||token!==generation||!recorder||recorder.state==='inactive') return;
analyser.getByteTimeDomainData(samples);
let energy=0;
for(let i=0;i<samples.length;i++){
const normalized=(samples[i]-128)/128;
energy+=normalized*normalized;
}
const rms=Math.sqrt(energy/samples.length);
const now=Date.now();
if(rms>0.025){heardSpeech=true;lastSpeech=now;}
const finished=heardSpeech&&(now-lastSpeech)>=silenceMs;
const timedOut=now-started>=90000;
const idle=(!heardSpeech)&&(now-started)>=20000;
if(finished||timedOut||idle){
if(vadTimer){clearInterval(vadTimer);vadTimer=null;}
try{recorder.stop();}catch(_){ }
}
},100);
}catch(error){
if(!active||token!==generation) return;
deactivate(false);
toast((error&&error.message)||'Microphone permission is required');
}
}
function cleanForSpeech(text){
if(typeof window._stripForTTS==='function') return window._stripForTTS(text);
return String(text||'').replace(/```[\s\S]*?```/g,' code block ').replace(/\s+/g,' ').trim();
}
function playBlob(blob, token){
return new Promise(function(resolve,reject){
if(!active||token!==generation){resolve();return;}
const url=URL.createObjectURL(blob);
const audio=new Audio(url);
currentAudio=audio;
function cleanup(){
if(currentAudio===audio) currentAudio=null;
URL.revokeObjectURL(url);
}
audio.onended=function(){cleanup();resolve();};
audio.onerror=function(){cleanup();reject(new Error('Local speech playback failed'));};
audio.play().catch(function(error){cleanup();reject(error);});
});
}
async function speakResponse(token){
if(!active||token!==generation) return;
const currentSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
if(thinkingSession&&currentSession&&thinkingSession!==currentSession){
thinkingSession=null;
restartSoon(token,250);
return;
}
thinkingSession=null;
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
if(!rows.length){restartSoon(token,250);return;}
const text=cleanForSpeech(rows[rows.length-1].dataset.rawText||'');
if(!text){restartSoon(token,250);return;}
setState('speaking');
const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,900):[text];
try{
for(const chunk of chunks){
if(!active||token!==generation) return;
const response=await fetch('/api/tts',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({text:chunk,engine:'atlas'}),
});
if(!response.ok){
const payload=await response.json().catch(function(){return {};});
throw new Error(payload.error||('Local speech request failed: '+response.status));
}
await playBlob(await response.blob(),token);
}
}catch(error){
if(active&&token===generation) toast((error&&error.message)||'Local speech is unavailable');
}
restartSoon(token,450);
}
function activate(){
generation+=1;
const token=generation;
active=true;
modeBtn.classList.add('active');
toast('Hands-free private voice mode on');
if(typeof window.stopTTS==='function') window.stopTTS();
if(typeof S!=='undefined'&&S.busy){setState('thinking');return;}
startListening(token);
}
function onVoiceClick(event){
if(!ready) return;
event.preventDefault();
event.stopImmediatePropagation();
if(active) deactivate(true); else activate();
}
async function initialize(){
try{
const response=await fetch('/api/transcribe/capability',{cache:'no-store'});
const capability=await response.json().catch(function(){return {};});
if(!response.ok||!capability.available||capability.provider!=='local_command') return;
ready=true;
if(localStorage.getItem('hermes-atlas-voice-initialized')!=='1'){
localStorage.setItem('hermes-atlas-voice-initialized','1');
localStorage.setItem('hermes-voice-mode-button','true');
localStorage.setItem('hermes-tts-engine','atlas');
localStorage.setItem('hermes-tts-enabled','true');
localStorage.setItem('hermes-voice-silence-ms','1600');
}
const selector=document.getElementById('settingsTtsEngine');
if(selector&&!selector.querySelector('option[value="atlas"]')){
const option=document.createElement('option');
option.value='atlas';
option.textContent='Atlas Jetson (private)';
selector.insertBefore(option,selector.firstChild);
}
modeBtn.style.display=localStorage.getItem('hermes-voice-mode-button')==='false'?'none':'';
modeBtn.addEventListener('click',onVoiceClick,true);
window._applyVoiceModePref=function(){
if(typeof originalApplyPreference==='function') originalApplyPreference();
if(ready){
const enabled=localStorage.getItem('hermes-voice-mode-button')!=='false';
modeBtn.style.display=enabled?'':'none';
if(!enabled&&active) deactivate(false);
}
};
window.autoReadLastAssistant=function(){
if(active&&state==='thinking'){speakResponse(generation);return;}
if(typeof originalAutoRead==='function') originalAutoRead.apply(this,arguments);
};
window._voiceModeActive=function(){return active;};
window._voiceModeDeactivate=function(){deactivate(false);};
window._voiceModeImmediateSend=function(){
if(active&&recorder&&recorder.state!=='inactive') recorder.stop();
};
}catch(_){
// The upstream browser voice implementation remains available as fallback.
}
}
initialize();
})();

View File

@ -21,7 +21,7 @@ spec:
app: ollama app: ollama
annotations: annotations:
ai.bstein.dev/model: qwen2.5:3b-instruct-q4_0,qwen2.5:14b-instruct-q4_0 ai.bstein.dev/model: qwen2.5:3b-instruct-q4_0,qwen2.5:14b-instruct-q4_0
ai.bstein.dev/gpu: GPU pool (titan-20/21) ai.bstein.dev/gpu: titan-20 shared routing GPU
ai.bstein.dev/restartedAt: "2026-01-26T12:00:00Z" ai.bstein.dev/restartedAt: "2026-01-26T12:00:00Z"
spec: spec:
affinity: affinity:
@ -33,7 +33,6 @@ spec:
operator: In operator: In
values: values:
- titan-20 - titan-20
- titan-21
runtimeClassName: nvidia runtimeClassName: nvidia
volumes: volumes:
- name: models - name: models

View File

@ -99,7 +99,10 @@ data:
home_mode: auto home_mode: auto
approvals: approvals:
mode: smart # This owner-only workspace is already bounded by non-root execution,
# repository scope, and read-only cluster RBAC. Keep routine engineering
# work non-interactive while retaining explicit hard denies below.
mode: "off"
deny: deny:
- "*kubectl apply*" - "*kubectl apply*"
- "*kubectl delete*" - "*kubectl delete*"
@ -111,6 +114,10 @@ data:
- "*flux suspend*" - "*flux suspend*"
- "*flux resume*" - "*flux resume*"
- "*vault kv*" - "*vault kv*"
- "*git push --force*"
- "*git push -f*"
- "*git reset --hard*"
- "*git clean -f*"
dashboard: dashboard:
public_url: https://agent.hermes.bstein.dev public_url: https://agent.hermes.bstein.dev
@ -204,9 +211,15 @@ data:
`herdr-dispatch --shape <implementation|architecture|review> --effort <low|medium|high|xhigh> [--provider codex|claude]` `herdr-dispatch --shape <implementation|architecture|review> --effort <low|medium|high|xhigh> [--provider codex|claude]`
Add `--start --project <path> --task <short-name> --prompt <objective>` to Add `--start --project <path> --task <short-name> --prompt <objective>` to
create a Herdr workspace and launch the selected CLI. Use `herdr agent list`, create a separate Herdr worker space and launch the selected CLI. The visible
`herdr agent wait`, `herdr agent read`, and `herdr agent prompt` to supervise project tab remains a single Hermes coordinator pane. Never split Codex,
it. If Codex reports its first-use login requirement, run Claude, or a second Hermes process into that tab. Worker-space labels include
the parent project, provider, and task (for example
`cassandra-claude-review`) so the left rail preserves ownership and purpose.
Keep completed workers available as labeled spaces until their evidence has
been synthesized; do not tile them over the coordinator. Use `herdr agent
list`, `herdr agent wait`, `herdr agent read`, and `herdr agent prompt` to
supervise them. If Codex reports its first-use login requirement, run
`codex login --device-auth` once and ask Brad to complete the displayed code. `codex login --device-auth` once and ask Brad to complete the displayed code.
A hosted capacity failure should fall across providers at the same effort A hosted capacity failure should fall across providers at the same effort

View File

@ -24,7 +24,7 @@ spec:
ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
ai.bstein.dev/config-rev: "20260809-terminal-recovery-v2" ai.bstein.dev/config-rev: "20260810-worker-spaces-permissions"
vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
@ -350,7 +350,7 @@ spec:
requests: {cpu: 250m, memory: 512Mi} requests: {cpu: 250m, memory: 512Mi}
limits: {cpu: "2", memory: 4Gi} limits: {cpu: "2", memory: 4Gi}
- name: webui - name: webui
image: registry.bstein.dev/bstein/hermes-webui@sha256:a771858bd668d25e19c74864baea5425101c8cd5215d1ba3a312f3312ce6c5e1 image: registry.bstein.dev/bstein/hermes-webui@sha256:06f195df381abc60e97f31c0676044faffd1a51cb365cda77a919eee04827c71
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:

View File

@ -35,6 +35,12 @@ data:
backend: ddgs backend: ddgs
search_backend: ddgs search_backend: ddgs
extract_backend: public-extract extract_backend: public-extract
stt:
enabled: true
provider: local_command
local:
model: large-v3-turbo
language: auto
model_catalog: model_catalog:
enabled: true enabled: true
ttl_hours: 1 ttl_hours: 1
@ -81,16 +87,26 @@ data:
user's private workspace and may use this user's private memory, skills, user's private workspace and may use this user's private memory, skills,
profiles, and task list. Never attempt cluster administration, private profiles, and task list. Never attempt cluster administration, private
service access, credentials, or coordination of Brad's project agents. service access, credentials, or coordination of Brad's project agents.
Python may run only through the credential-free sandbox tool. The user's Python may run only through the credential-free sandbox tool. Its
conversations and files must never be mixed with another Keycloak user's `/workspace` and `/opt/data/workspace` both expose the same private 10 GiB
state. workspace, and its writable `/tmp` is available for temporary
verification scripts. Read and verify generated artifacts directly there;
do not claim the sandbox is disconnected from the user's files. The user's
conversations and files must never be mixed with another Keycloak user's state.
Voice conversations use the same assistant, session, AUTO route, tools, and
private workspace as typed conversations. Whisper and speech synthesis are
transport services only; they do not select or replace the answering model.
AGENTS.md: | AGENTS.md: |
# Private Hermes chat # Private Hermes chat
This runtime belongs to one authenticated Keycloak identity and one private This runtime belongs to one authenticated Keycloak identity and one private
persistent volume. Provide conversational help with the private workspace, persistent workspace. Provide conversational help with the private workspace,
memory, skills, profiles, task list, session search, public web tools, and memory, skills, profiles, task list, session search, public web tools, and
the separate per-tenant browser and Python sandbox. Use delegation selectively for the per-tenant browser and Python sandbox. The Python sandbox sees the same
independent research or verification, then present a single final answer. workspace at both `/workspace` and `/opt/data/workspace`, and may use `/tmp`
for bounded temporary work. Use
delegation selectively for independent research or verification, then
present a single final answer.
Do not claim access to Kubernetes, Vault, Gitea, Brad's projects, other Do not claim access to Kubernetes, Vault, Gitea, Brad's projects, other
users, the agent coordinator, or automated triage. users, the agent coordinator, or automated triage.

View File

@ -12,39 +12,147 @@ spec:
selector: selector:
app: hermes-chat-sandbox app: hermes-chat-sandbox
ports: ports:
- name: http - {name: http, port: 9080, targetPort: http, protocol: TCP}
port: 9080
targetPort: http
--- ---
apiVersion: apps/v1 apiVersion: apps/v1
kind: StatefulSet kind: Deployment
metadata: metadata:
name: hermes-chat-sandbox name: hermes-chat-sandbox-0
namespace: hermes namespace: hermes
labels: labels:
app: hermes-chat-sandbox app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "0"
spec: spec:
serviceName: hermes-chat-sandbox replicas: 1
replicas: 4
podManagementPolicy: Parallel
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
selector: selector:
matchLabels: matchLabels:
app: hermes-chat-sandbox app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "0"
template: template:
metadata: metadata:
labels: labels:
app: hermes-chat-sandbox app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "0"
annotations: annotations:
ai.bstein.dev/role: isolated-user-computation ai.bstein.dev/role: isolated-user-computation
ai.bstein.dev/isolation: one credential-free sandbox per chat tenant ai.bstein.dev/isolation: credential-free and default-deny network
spec: spec:
hostname: hermes-chat-sandbox-0
subdomain: hermes-chat-sandbox
automountServiceAccountToken: false automountServiceAccountToken: false
enableServiceLinks: false enableServiceLinks: false
securityContext: securityContext:
fsGroup: 20000 fsGroup: 10000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
affinity: &sandbox-affinity
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: In
values: [arm64]
- key: node-role.kubernetes.io/worker
operator: In
values: ["true"]
- key: kubernetes.io/hostname
operator: NotIn
values: [titan-05, titan-08, titan-13, titan-14, titan-17, titan-18, titan-19]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: hardware
operator: In
values: [rpi5]
- weight: 40
preference:
matchExpressions:
- key: hardware
operator: In
values: [rpi4]
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: hermes-chat-sandbox
topologyKey: kubernetes.io/hostname
containers:
- &sandbox-container
name: sandbox
image: registry.bstein.dev/bstein/hermes-chat-sandbox@sha256:17ee62b8e61c08573a3a8cca903b38ec43800cb44ec29340e1bc095176544bca
imagePullPolicy: IfNotPresent
ports:
- {name: http, containerPort: 9080, protocol: TCP}
volumeMounts:
- {name: workspace, mountPath: /workspace}
- {name: workspace, mountPath: /opt/data/workspace}
- {name: tmp, mountPath: /tmp}
readinessProbe:
httpGet: {path: /health, port: http}
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet: {path: /health, port: http}
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 3
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 20000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi}
volumes:
- name: workspace
persistentVolumeClaim:
claimName: workspace-hermes-chat-tenant-0
- &sandbox-tmp
name: tmp
emptyDir:
sizeLimit: 256Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hermes-chat-sandbox-1
namespace: hermes
labels:
app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "1"
spec:
replicas: 1
selector:
matchLabels:
app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "1"
template:
metadata:
labels:
app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "1"
annotations:
ai.bstein.dev/role: isolated-user-computation
ai.bstein.dev/isolation: credential-free and default-deny network
spec:
hostname: hermes-chat-sandbox-1
subdomain: hermes-chat-sandbox
automountServiceAccountToken: false
enableServiceLinks: false
securityContext:
fsGroup: 10000
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
seccompProfile: seccompProfile:
type: RuntimeDefault type: RuntimeDefault
@ -61,7 +169,7 @@ spec:
values: ["true"] values: ["true"]
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: NotIn operator: NotIn
values: [titan-05, titan-08, titan-13, titan-14, titan-17, titan-18] values: [titan-05, titan-08, titan-13, titan-14, titan-17, titan-18, titan-19]
preferredDuringSchedulingIgnoredDuringExecution: preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100 - weight: 100
preference: preference:
@ -91,6 +199,7 @@ spec:
- {name: http, containerPort: 9080, protocol: TCP} - {name: http, containerPort: 9080, protocol: TCP}
volumeMounts: volumeMounts:
- {name: workspace, mountPath: /workspace} - {name: workspace, mountPath: /workspace}
- {name: workspace, mountPath: /opt/data/workspace}
- {name: tmp, mountPath: /tmp} - {name: tmp, mountPath: /tmp}
readinessProbe: readinessProbe:
httpGet: {path: /health, port: http} httpGet: {path: /health, port: http}
@ -109,24 +218,232 @@ spec:
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
runAsNonRoot: true runAsNonRoot: true
runAsUser: 20000 runAsUser: 20000
runAsGroup: 20000 runAsGroup: 10000
seccompProfile: seccompProfile:
type: RuntimeDefault type: RuntimeDefault
resources: resources:
requests: {cpu: 50m, memory: 128Mi} requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi} limits: {cpu: "1", memory: 1Gi}
volumes: volumes:
- name: workspace
persistentVolumeClaim:
claimName: workspace-hermes-chat-tenant-1
- name: tmp
emptyDir:
sizeLimit: 256Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hermes-chat-sandbox-2
namespace: hermes
labels:
app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "2"
spec:
replicas: 1
selector:
matchLabels:
app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "2"
template:
metadata:
labels:
app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "2"
annotations:
ai.bstein.dev/role: isolated-user-computation
ai.bstein.dev/isolation: credential-free and default-deny network
spec:
hostname: hermes-chat-sandbox-2
subdomain: hermes-chat-sandbox
automountServiceAccountToken: false
enableServiceLinks: false
securityContext:
fsGroup: 10000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: In
values: [arm64]
- key: node-role.kubernetes.io/worker
operator: In
values: ["true"]
- key: kubernetes.io/hostname
operator: NotIn
values: [titan-05, titan-08, titan-13, titan-14, titan-17, titan-18, titan-19]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: hardware
operator: In
values: [rpi5]
- weight: 40
preference:
matchExpressions:
- key: hardware
operator: In
values: [rpi4]
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: hermes-chat-sandbox
topologyKey: kubernetes.io/hostname
containers:
- name: sandbox
image: registry.bstein.dev/bstein/hermes-chat-sandbox@sha256:17ee62b8e61c08573a3a8cca903b38ec43800cb44ec29340e1bc095176544bca
imagePullPolicy: IfNotPresent
ports:
- {name: http, containerPort: 9080, protocol: TCP}
volumeMounts:
- {name: workspace, mountPath: /workspace}
- {name: workspace, mountPath: /opt/data/workspace}
- {name: tmp, mountPath: /tmp}
readinessProbe:
httpGet: {path: /health, port: http}
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet: {path: /health, port: http}
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 3
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 20000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi}
volumes:
- name: workspace
persistentVolumeClaim:
claimName: workspace-hermes-chat-tenant-2
- name: tmp
emptyDir:
sizeLimit: 256Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hermes-chat-sandbox-3
namespace: hermes
labels:
app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "3"
spec:
replicas: 1
selector:
matchLabels:
app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "3"
template:
metadata:
labels:
app: hermes-chat-sandbox
ai.bstein.dev/tenant-ordinal: "3"
annotations:
ai.bstein.dev/role: isolated-user-computation
ai.bstein.dev/isolation: credential-free and default-deny network
spec:
hostname: hermes-chat-sandbox-3
subdomain: hermes-chat-sandbox
automountServiceAccountToken: false
enableServiceLinks: false
securityContext:
fsGroup: 10000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: In
values: [arm64]
- key: node-role.kubernetes.io/worker
operator: In
values: ["true"]
- key: kubernetes.io/hostname
operator: NotIn
values: [titan-05, titan-08, titan-13, titan-14, titan-17, titan-18, titan-19]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: hardware
operator: In
values: [rpi5]
- weight: 40
preference:
matchExpressions:
- key: hardware
operator: In
values: [rpi4]
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: hermes-chat-sandbox
topologyKey: kubernetes.io/hostname
containers:
- name: sandbox
image: registry.bstein.dev/bstein/hermes-chat-sandbox@sha256:17ee62b8e61c08573a3a8cca903b38ec43800cb44ec29340e1bc095176544bca
imagePullPolicy: IfNotPresent
ports:
- {name: http, containerPort: 9080, protocol: TCP}
volumeMounts:
- {name: workspace, mountPath: /workspace}
- {name: workspace, mountPath: /opt/data/workspace}
- {name: tmp, mountPath: /tmp}
readinessProbe:
httpGet: {path: /health, port: http}
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet: {path: /health, port: http}
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 3
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 20000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi}
volumes:
- name: workspace
persistentVolumeClaim:
claimName: workspace-hermes-chat-tenant-3
- name: tmp - name: tmp
emptyDir: emptyDir:
sizeLimit: 256Mi sizeLimit: 256Mi
volumeClaimTemplates:
- metadata:
name: workspace
labels:
app: hermes-chat-sandbox
spec:
accessModes: [ReadWriteOnce]
storageClassName: astreae
resources:
requests:
storage: 2Gi

View File

@ -28,7 +28,7 @@ spec:
ai.bstein.dev/role: isolated-user-chat ai.bstein.dev/role: isolated-user-chat
ai.bstein.dev/isolation: one Hermes process and PVC per Keycloak subject ai.bstein.dev/isolation: one Hermes process and PVC per Keycloak subject
ai.bstein.dev/model-policy: uniform automatic policy with per-user overrides ai.bstein.dev/model-policy: uniform automatic policy with per-user overrides
ai.bstein.dev/config-rev: "20260809-browser-runtime-path" ai.bstein.dev/config-rev: "20260810-natural-jetson-voice"
vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-chat vault.hashicorp.com/role: hermes-chat
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
@ -100,6 +100,12 @@ spec:
- | - |
set -eu set -eu
mkdir -p /opt/data/home/.local/bin /opt/data/logs /opt/data/workspace mkdir -p /opt/data/home/.local/bin /opt/data/logs /opt/data/workspace
if [ ! -e /opt/data/workspace/.hermes-workspace-v1 ]; then
if [ -d /legacy-home/workspace ]; then
cp -a /legacy-home/workspace/. /opt/data/workspace/
fi
touch /opt/data/workspace/.hermes-workspace-v1
fi
cp /config/config.yaml /opt/data/config.yaml cp /config/config.yaml /opt/data/config.yaml
cp /config/SOUL.md /opt/data/SOUL.md cp /config/SOUL.md /opt/data/SOUL.md
cp /config/AGENTS.md /opt/data/workspace/AGENTS.md cp /config/AGENTS.md /opt/data/workspace/AGENTS.md
@ -142,6 +148,8 @@ spec:
type: RuntimeDefault type: RuntimeDefault
volumeMounts: volumeMounts:
- {name: home, mountPath: /opt/data} - {name: home, mountPath: /opt/data}
- {name: home, mountPath: /legacy-home, readOnly: true}
- {name: workspace, mountPath: /opt/data/workspace}
- {name: config, mountPath: /config, readOnly: true} - {name: config, mountPath: /config, readOnly: true}
resources: resources:
requests: {cpu: 25m, memory: 32Mi} requests: {cpu: 25m, memory: 32Mi}
@ -195,6 +203,7 @@ spec:
- {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev} - {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev}
volumeMounts: volumeMounts:
- {name: home, mountPath: /opt/data} - {name: home, mountPath: /opt/data}
- {name: workspace, mountPath: /opt/data/workspace}
- {name: provider-auth, mountPath: /shared-auth, readOnly: true} - {name: provider-auth, mountPath: /shared-auth, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
readinessProbe: readinessProbe:
@ -217,7 +226,7 @@ spec:
requests: {cpu: 250m, memory: 512Mi} requests: {cpu: 250m, memory: 512Mi}
limits: {cpu: "1", memory: 2Gi} limits: {cpu: "1", memory: 2Gi}
- name: webui - name: webui
image: registry.bstein.dev/bstein/hermes-webui@sha256:a771858bd668d25e19c74864baea5425101c8cd5215d1ba3a312f3312ce6c5e1 image: registry.bstein.dev/bstein/hermes-webui@sha256:06f195df381abc60e97f31c0676044faffd1a51cb365cda77a919eee04827c71
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:
@ -246,9 +255,14 @@ spec:
- {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.hermes.bstein.dev} - {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.hermes.bstein.dev}
- {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"} - {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"}
- {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"} - {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"}
- {name: HERMES_STT_URL, value: http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions}
- {name: HERMES_LOCAL_STT_COMMAND, value: "/opt/hermes/.venv/bin/python /opt/coordinator/hermes_stt_client.py {input_path} --output-dir {output_dir} --language {language} --model {model}"}
- {name: HERMES_WEBUI_ATLAS_TTS_URL, value: http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech}
volumeMounts: volumeMounts:
- {name: home, mountPath: /opt/data} - {name: home, mountPath: /opt/data}
- {name: workspace, mountPath: /opt/data/workspace}
- {name: provider-auth, mountPath: /shared-auth, readOnly: true} - {name: provider-auth, mountPath: /shared-auth, readOnly: true}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: tmp, mountPath: /tmp} - {name: tmp, mountPath: /tmp}
readinessProbe: readinessProbe:
httpGet: {path: /health, port: webui} httpGet: {path: /health, port: webui}
@ -299,3 +313,14 @@ spec:
resources: resources:
requests: requests:
storage: 10Gi storage: 10Gi
- metadata:
name: workspace
labels:
app: hermes-chat-tenant
ai.bstein.dev/data: user-workspace
spec:
accessModes: [ReadWriteMany]
storageClassName: astreae
resources:
requests:
storage: 10Gi

View File

@ -345,7 +345,7 @@ spec:
cpu: "2" cpu: "2"
memory: 4Gi memory: 4Gi
- name: webui - name: webui
image: registry.bstein.dev/bstein/hermes-webui@sha256:a771858bd668d25e19c74864baea5425101c8cd5215d1ba3a312f3312ce6c5e1 image: registry.bstein.dev/bstein/hermes-webui@sha256:06f195df381abc60e97f31c0676044faffd1a51cb365cda77a919eee04827c71
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:

View File

@ -20,6 +20,7 @@ resources:
- ollama-deployment.yaml - ollama-deployment.yaml
- deployment.yaml - deployment.yaml
- agent-deployment.yaml - agent-deployment.yaml
- voice-deployment.yaml
- chat-statefulset.yaml - chat-statefulset.yaml
- chat-sandbox.yaml - chat-sandbox.yaml
- chat-router.yaml - chat-router.yaml
@ -49,6 +50,7 @@ configMapGenerator:
- herdr_tab_router.py=scripts/herdr_tab_router.py - herdr_tab_router.py=scripts/herdr_tab_router.py
- hermes_coordinator.py=scripts/hermes_coordinator.py - hermes_coordinator.py=scripts/hermes_coordinator.py
- hermes_model_routing.py=scripts/hermes_model_routing.py - hermes_model_routing.py=scripts/hermes_model_routing.py
- hermes_stt_client.py=scripts/hermes_stt_client.py
- patch_hermes_auth.py=scripts/patch_hermes_auth.py - patch_hermes_auth.py=scripts/patch_hermes_auth.py
- patch_ttyd_index.py=scripts/patch_ttyd_index.py - patch_ttyd_index.py=scripts/patch_ttyd_index.py
options: options:

View File

@ -164,14 +164,13 @@ spec:
podSelector: podSelector:
matchLabels: matchLabels:
app: hermes-chat-sandbox app: hermes-chat-sandbox
apps.kubernetes.io/pod-index: "0" ai.bstein.dev/tenant-ordinal: "0"
policyTypes: [Ingress] policyTypes: [Ingress]
ingress: ingress:
- from: - from:
- podSelector: - podSelector:
matchLabels: matchLabels:
app: hermes-chat-tenant statefulset.kubernetes.io/pod-name: hermes-chat-tenant-0
apps.kubernetes.io/pod-index: "0"
ports: ports:
- {protocol: TCP, port: 9080} - {protocol: TCP, port: 9080}
--- ---
@ -184,14 +183,13 @@ spec:
podSelector: podSelector:
matchLabels: matchLabels:
app: hermes-chat-sandbox app: hermes-chat-sandbox
apps.kubernetes.io/pod-index: "1" ai.bstein.dev/tenant-ordinal: "1"
policyTypes: [Ingress] policyTypes: [Ingress]
ingress: ingress:
- from: - from:
- podSelector: - podSelector:
matchLabels: matchLabels:
app: hermes-chat-tenant statefulset.kubernetes.io/pod-name: hermes-chat-tenant-1
apps.kubernetes.io/pod-index: "1"
ports: ports:
- {protocol: TCP, port: 9080} - {protocol: TCP, port: 9080}
--- ---
@ -204,14 +202,13 @@ spec:
podSelector: podSelector:
matchLabels: matchLabels:
app: hermes-chat-sandbox app: hermes-chat-sandbox
apps.kubernetes.io/pod-index: "2" ai.bstein.dev/tenant-ordinal: "2"
policyTypes: [Ingress] policyTypes: [Ingress]
ingress: ingress:
- from: - from:
- podSelector: - podSelector:
matchLabels: matchLabels:
app: hermes-chat-tenant statefulset.kubernetes.io/pod-name: hermes-chat-tenant-2
apps.kubernetes.io/pod-index: "2"
ports: ports:
- {protocol: TCP, port: 9080} - {protocol: TCP, port: 9080}
--- ---
@ -224,14 +221,13 @@ spec:
podSelector: podSelector:
matchLabels: matchLabels:
app: hermes-chat-sandbox app: hermes-chat-sandbox
apps.kubernetes.io/pod-index: "3" ai.bstein.dev/tenant-ordinal: "3"
policyTypes: [Ingress] policyTypes: [Ingress]
ingress: ingress:
- from: - from:
- podSelector: - podSelector:
matchLabels: matchLabels:
app: hermes-chat-tenant statefulset.kubernetes.io/pod-name: hermes-chat-tenant-3
apps.kubernetes.io/pod-index: "3"
ports: ports:
- {protocol: TCP, port: 9080} - {protocol: TCP, port: 9080}
--- ---
@ -294,6 +290,15 @@ spec:
app: hermes-chat-sandbox app: hermes-chat-sandbox
ports: ports:
- {protocol: TCP, port: 9080} - {protocol: TCP, port: 9080}
- to:
- podSelector:
matchExpressions:
- key: app
operator: In
values: [hermes-stt, hermes-tts]
ports:
- {protocol: TCP, port: 9000}
- {protocol: TCP, port: 9001}
- to: - to:
- ipBlock: - ipBlock:
cidr: 0.0.0.0/0 cidr: 0.0.0.0/0
@ -426,3 +431,35 @@ spec:
- {protocol: TCP, port: 7681} - {protocol: TCP, port: 7681}
- {protocol: TCP, port: 8787} - {protocol: TCP, port: 8787}
- {protocol: TCP, port: 8080} - {protocol: TCP, port: 8080}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: hermes-private-voice
namespace: hermes
spec:
podSelector:
matchExpressions:
- key: app
operator: In
values: [hermes-stt, hermes-tts]
policyTypes: [Ingress, Egress]
ingress:
- from:
- podSelector:
matchLabels:
app: hermes-chat-tenant
ports:
- {protocol: TCP, port: 9000}
- {protocol: TCP, port: 9001}
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- {protocol: UDP, port: 53}
- {protocol: TCP, port: 53}

View File

@ -192,7 +192,9 @@ spec:
- --cookie-csrf-per-request=true - --cookie-csrf-per-request=true
- --cookie-csrf-per-request-limit=8 - --cookie-csrf-per-request-limit=8
- --cookie-refresh=1h - --cookie-refresh=1h
- --cookie-expire=8h - --cookie-expire=168h
- --api-route=^/api/
- --api-route=^/health$
- --upstream=http://hermes-triage.hermes.svc.cluster.local:8787 - --upstream=http://hermes-triage.hermes.svc.cluster.local:8787
- --http-address=0.0.0.0:4180 - --http-address=0.0.0.0:4180
- --skip-provider-button=true - --skip-provider-button=true
@ -286,9 +288,11 @@ spec:
- --cookie-csrf-per-request=true - --cookie-csrf-per-request=true
- --cookie-csrf-per-request-limit=8 - --cookie-csrf-per-request-limit=8
- --cookie-refresh=1h - --cookie-refresh=1h
- --cookie-expire=8h - --cookie-expire=168h
- --custom-templates-dir=/etc/oauth2-proxy/templates - --custom-templates-dir=/etc/oauth2-proxy/templates
- '--skip-auth-route=GET=^/sw[.]js([?].*)?$' - '--skip-auth-route=GET=^/sw[.]js([?].*)?$'
- --api-route=^/api/
- --api-route=^/health$
- --upstream=http://hermes-chat-router.hermes.svc.cluster.local:8080 - --upstream=http://hermes-chat-router.hermes.svc.cluster.local:8080
- --http-address=0.0.0.0:4180 - --http-address=0.0.0.0:4180
- --skip-provider-button=true - --skip-provider-button=true

View File

@ -18,7 +18,7 @@ HERDR_BIN = Path("/opt/data/tools/bin/herdr")
CODEX_AUTH = Path("/opt/data/home/.codex/auth.json") CODEX_AUTH = Path("/opt/data/home/.codex/auth.json")
PROMPT_READY_MARKERS = { PROMPT_READY_MARKERS = {
"codex": "OpenAI Codex", "codex": "OpenAI Codex",
"claude": "accept edits on", "claude": "Claude Code",
} }
@ -122,7 +122,9 @@ def launch_worker(
"PATH": "/opt/data/tools/bin:" + env.get("PATH", ""), "PATH": "/opt/data/tools/bin:" + env.get("PATH", ""),
} }
) )
label = _slug(task) project_label = _slug(project.name, 12)
task_label = _slug(task, 14)
label = _slug(f"{project_label}-{plan['worker']}-{task_label}")
created = _run( created = _run(
[ [
str(HERDR_BIN), str(HERDR_BIN),
@ -141,7 +143,7 @@ def launch_worker(
except (KeyError, TypeError) as error: except (KeyError, TypeError) as error:
raise RuntimeError("Herdr workspace response omitted the root pane") from error raise RuntimeError("Herdr workspace response omitted the root pane") from error
agent_name = _slug(f"{plan['worker']}-{task}") agent_name = label
command = [ command = [
str(HERDR_BIN), str(HERDR_BIN),
"agent", "agent",
@ -162,10 +164,9 @@ def launch_worker(
plan["model"], plan["model"],
"-c", "-c",
f'model_reasoning_effort="{plan["effort"]}"', f'model_reasoning_effort="{plan["effort"]}"',
"-c", "--approve-for-me",
'approval_policy="on-request"', "--sandbox",
"-c", "workspace-write",
'sandbox_mode="workspace-write"',
] ]
) )
else: else:
@ -176,7 +177,7 @@ def launch_worker(
"--effort", "--effort",
plan["effort"], plan["effort"],
"--permission-mode", "--permission-mode",
"acceptEdits", "auto",
] ]
) )
started = _run(command, env) started = _run(command, env)

View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Send one Hermes local-command STT request to the private Whisper service."""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import secrets
from pathlib import Path
from urllib.request import Request, urlopen
def _multipart(audio: Path, language: str, model: str) -> tuple[bytes, str]:
boundary = f"atlas-hermes-{secrets.token_hex(12)}"
mime = mimetypes.guess_type(audio.name)[0] or "application/octet-stream"
chunks: list[bytes] = []
def field(name: str, value: str) -> None:
chunks.extend(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
value.encode(),
b"\r\n",
]
)
field("language", language)
field("model", model)
chunks.extend(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{audio.name}"\r\n'.encode(),
f"Content-Type: {mime}\r\n\r\n".encode(),
audio.read_bytes(),
b"\r\n",
f"--{boundary}--\r\n".encode(),
]
)
return b"".join(chunks), boundary
def main() -> None:
"""Transcribe one file and emit the .txt contract Hermes expects."""
parser = argparse.ArgumentParser()
parser.add_argument("input_path", type=Path)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--language", default="auto")
parser.add_argument("--model", default="large-v3-turbo")
args = parser.parse_args()
body, boundary = _multipart(args.input_path, args.language, args.model)
request = Request(
os.getenv(
"HERMES_STT_URL",
"http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions",
),
data=body,
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Accept": "application/json",
},
method="POST",
)
with urlopen(request, timeout=120) as response:
result = json.loads(response.read().decode("utf-8"))
transcript = str(result.get("text") or "").strip()
args.output_dir.mkdir(parents=True, exist_ok=True)
output = args.output_dir / f"{args.input_path.stem}.txt"
output.write_text(transcript, encoding="utf-8")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,170 @@
# services/hermes/voice-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: hermes-stt
namespace: hermes
labels:
app: hermes-stt
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: hermes-stt
template:
metadata:
labels:
app: hermes-stt
annotations:
ai.bstein.dev/role: private-chat-speech-to-text
ai.bstein.dev/model: whisper-turbo
ai.bstein.dev/gpu: titan-21 dedicated speech time-slice
spec:
runtimeClassName: nvidia
nodeSelector:
kubernetes.io/hostname: titan-21
containers:
- name: stt
image: registry.bstein.dev/bstein/hermes-jetson-stt@sha256:a67494014716a65152b7595122b1717a742db206c6f556c740fecc110452f65b
imagePullPolicy: IfNotPresent
ports:
- {name: http, containerPort: 9000, protocol: TCP}
env:
- {name: HOME, value: /tmp}
- {name: XDG_CACHE_HOME, value: /tmp/cache}
- {name: HERMES_STT_MODEL, value: large-v3-turbo}
- {name: HERMES_STT_CACHE, value: /opt/models/whisper}
- {name: NVIDIA_VISIBLE_DEVICES, value: all}
- {name: NVIDIA_DRIVER_CAPABILITIES, value: compute,utility}
- {name: JETSON_JETPACK, value: "5"}
startupProbe:
httpGet: {path: /health, port: http}
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 90
readinessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: tmp, mountPath: /tmp}
resources:
requests:
cpu: "2"
memory: 4Gi
nvidia.com/gpu.shared: 1
limits:
cpu: "6"
memory: 10Gi
nvidia.com/gpu.shared: 1
volumes:
- name: tmp
emptyDir:
sizeLimit: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: hermes-stt
namespace: hermes
spec:
selector:
app: hermes-stt
ports:
- {name: http, port: 9000, targetPort: http, protocol: TCP}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hermes-tts
namespace: hermes
labels:
app: hermes-tts
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: hermes-tts
template:
metadata:
labels:
app: hermes-tts
annotations:
ai.bstein.dev/role: private-chat-text-to-speech
ai.bstein.dev/model: piper-en-us-lessac-high
ai.bstein.dev/gpu: CPU-only on routing node
spec:
nodeSelector:
kubernetes.io/hostname: titan-20
containers:
- name: tts
image: registry.bstein.dev/bstein/hermes-jetson-tts@sha256:6483ca4e89d0663c770b7b3e3c9c9ff85d457ae877f2e83c55765cadc901d9b7
imagePullPolicy: IfNotPresent
ports:
- {name: http, containerPort: 9001, protocol: TCP}
env:
- {name: HOME, value: /tmp}
- {name: XDG_CACHE_HOME, value: /tmp/cache}
- {name: HERMES_TTS_VOICE, value: en_US-lessac-high}
- {name: HERMES_TTS_CACHE, value: /opt/models/piper}
startupProbe:
httpGet: {path: /health, port: http}
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
readinessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet: {path: /health, port: http}
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: tmp, mountPath: /tmp}
resources:
requests: {cpu: 500m, memory: 512Mi}
limits: {cpu: "2", memory: 2Gi}
volumes:
- name: tmp
emptyDir:
sizeLimit: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: hermes-tts
namespace: hermes
spec:
selector:
app: hermes-tts
ports:
- {name: http, port: 9001, targetPort: http, protocol: TCP}

View File

@ -41,44 +41,68 @@ def test_chat_config_enables_real_research_compute_and_delegation():
assert "code_execution" not in toolsets assert "code_execution" not in toolsets
def test_sandbox_has_no_credentials_token_or_egress(): def test_sandbox_shares_only_the_tenant_workspace_without_credentials():
sandbox_docs = _documents(HERMES / "chat-sandbox.yaml") sandbox_docs = _documents(HERMES / "chat-sandbox.yaml")
statefulset = next(doc for doc in sandbox_docs if doc["kind"] == "StatefulSet") deployments = [doc for doc in sandbox_docs if doc["kind"] == "Deployment"]
pod_spec = statefulset["spec"]["template"]["spec"] assert len(deployments) == 4
container = pod_spec["containers"][0] for ordinal, deployment in enumerate(deployments):
pod_spec = deployment["spec"]["template"]["spec"]
container = pod_spec["containers"][0]
assert pod_spec["automountServiceAccountToken"] is False
assert container["securityContext"]["readOnlyRootFilesystem"] is True
assert container["securityContext"]["runAsNonRoot"] is True
assert container["securityContext"]["runAsGroup"] == 10000
assert not container.get("env")
assert {mount["mountPath"] for mount in container["volumeMounts"]} == {
"/tmp",
"/workspace",
"/opt/data/workspace",
}
workspace_volume = next(
item for item in pod_spec["volumes"] if item["name"] == "workspace"
)
assert workspace_volume["persistentVolumeClaim"]["claimName"] == (
f"workspace-hermes-chat-tenant-{ordinal}"
)
assert pod_spec["automountServiceAccountToken"] is False statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
assert container["securityContext"]["readOnlyRootFilesystem"] is True templates = statefulset["spec"]["volumeClaimTemplates"]
assert container["securityContext"]["runAsNonRoot"] is True workspace = next(item for item in templates if item["metadata"]["name"] == "workspace")
assert not container.get("env") assert workspace["spec"]["resources"]["requests"]["storage"] == "10Gi"
assert {mount["mountPath"] for mount in container["volumeMounts"]} == { assert workspace["spec"]["accessModes"] == ["ReadWriteMany"]
"/tmp",
"/workspace", pod_spec = statefulset["spec"]["template"]["spec"]
} hermes = next(item for item in pod_spec["containers"] if item["name"] == "hermes")
startup = hermes["args"][0]
assert "hermes-chat-sandbox-${ordinal}.hermes-chat-sandbox" in startup
assert any(
mount["name"] == "workspace" and mount["mountPath"] == "/opt/data/workspace"
for mount in hermes["volumeMounts"]
)
policies = _documents(HERMES / "networkpolicy.yaml") policies = _documents(HERMES / "networkpolicy.yaml")
deny = next( deny = next(
doc item
for doc in policies for item in policies
if doc["kind"] == "NetworkPolicy" if item["metadata"]["name"] == "hermes-chat-sandbox-deny"
and doc["metadata"]["name"] == "hermes-chat-sandbox-deny"
) )
assert deny["spec"]["policyTypes"] == ["Ingress", "Egress"] assert deny["spec"]["ingress"] == []
assert deny["spec"]["egress"] == [] assert deny["spec"]["egress"] == []
for ordinal in range(4): for ordinal in range(4):
policy = next( policy = next(
doc item
for doc in policies for item in policies
if doc["kind"] == "NetworkPolicy" if item["metadata"]["name"] == f"hermes-chat-sandbox-tenant-{ordinal}"
and doc["metadata"]["name"] == f"hermes-chat-sandbox-tenant-{ordinal}"
) )
assert policy["spec"]["podSelector"]["matchLabels"][ assert policy["spec"]["podSelector"]["matchLabels"][
"apps.kubernetes.io/pod-index" "ai.bstein.dev/tenant-ordinal"
] == str(ordinal) ] == str(ordinal)
source = policy["spec"]["ingress"][0]["from"][0]["podSelector"][ source = policy["spec"]["ingress"][0]["from"][0]["podSelector"][
"matchLabels" "matchLabels"
] ]
assert source["apps.kubernetes.io/pod-index"] == str(ordinal) assert source["statefulset.kubernetes.io/pod-name"] == (
f"hermes-chat-tenant-{ordinal}"
)
def test_gateway_image_honors_ui_model_and_caps_reasoning(): def test_gateway_image_honors_ui_model_and_caps_reasoning():
@ -114,6 +138,100 @@ def test_chat_oauth_allows_stale_service_worker_retirement():
assert "--cookie-csrf-per-request=true" in args assert "--cookie-csrf-per-request=true" in args
assert "--cookie-csrf-per-request-limit=8" in args assert "--cookie-csrf-per-request-limit=8" in args
assert "--trusted-proxy-ip=10.42.0.0/16" in args assert "--trusted-proxy-ip=10.42.0.0/16" in args
assert "--api-route=^/api/" in args
assert "--api-route=^/health$" in args
assert "--cookie-expire=168h" in args
def test_webui_recovers_auth_and_labels_session_scoped_controls():
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
assert "res.status===401||res.status===403" in dockerfile
assert "window.location.assign('/oauth2/start?rd='" in dockerfile
assert "childrenExpanded?'':''" in dockerfile
assert "profile default: ' + p.model" in dockerfile
def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
containers = statefulset["spec"]["template"]["spec"]["containers"]
webui = next(item for item in containers if item["name"] == "webui")
env = {item["name"]: item["value"] for item in webui["env"]}
assert env["HERMES_STT_URL"] == (
"http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions"
)
assert env["HERMES_WEBUI_ATLAS_TTS_URL"] == (
"http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
)
assert "hermes_stt_client.py" in env["HERMES_LOCAL_STT_COMMAND"]
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
config = yaml.safe_load(configmap["data"]["config.yaml"])
assert config["stt"] == {
"enabled": True,
"provider": "local_command",
"local": {"model": "large-v3-turbo", "language": "auto"},
}
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
assert "hermes-webui-atlas-patch.py" in dockerfile
assert "hermes-webui-atlas-voice.js" in dockerfile
voice_script = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.js").read_text()
assert "/api/transcribe/capability" in voice_script
assert "/api/transcribe" in voice_script
assert "/api/tts" in voice_script
assert "speakResponse(generation)" in voice_script
assert "restartSoon(token,450)" in voice_script
def test_voice_models_are_baked_and_runtime_has_no_public_egress():
stt_dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-stt").read_text()
tts_dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-tts").read_text()
assert "ADD --checksum=sha256:aff26ae4" in stt_dockerfile
assert "--chmod=0444" in stt_dockerfile
assert "HERMES_STT_CACHE=/opt/models/whisper" in stt_dockerfile
assert "ADD --checksum=sha256:4cabf7c3" in tts_dockerfile
assert "ADD --checksum=sha256:db42b97d" in tts_dockerfile
assert tts_dockerfile.count("--chmod=0444") == 2
assert "HERMES_TTS_CACHE=/opt/models/piper" in tts_dockerfile
tts_server = (ROOT / "dockerfiles" / "hermes-jetson-tts-server.py").read_text()
assert "download_voice" not in tts_server
assert "baked Piper voice is missing" in tts_server
policies = _documents(HERMES / "networkpolicy.yaml")
voice_policy = next(
item for item in policies if item["metadata"]["name"] == "hermes-private-voice"
)
assert voice_policy["spec"]["policyTypes"] == ["Ingress", "Egress"]
assert not any(
"ipBlock" in destination
for rule in voice_policy["spec"]["egress"]
for destination in rule.get("to", [])
)
def test_voice_workloads_have_deliberate_xavier_placement():
documents = _documents(HERMES / "voice-deployment.yaml")
deployments = {
item["metadata"]["name"]: item
for item in documents
if item["kind"] == "Deployment"
}
stt = deployments["hermes-stt"]["spec"]["template"]["spec"]
tts = deployments["hermes-tts"]["spec"]["template"]["spec"]
assert "@sha256:" in stt["containers"][0]["image"]
assert "@sha256:" in tts["containers"][0]["image"]
assert stt["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"}
assert tts["nodeSelector"] == {"kubernetes.io/hostname": "titan-20"}
assert stt["runtimeClassName"] == "nvidia"
stt_resources = stt["containers"][0]["resources"]
assert stt_resources["requests"]["nvidia.com/gpu.shared"] == 1
assert stt_resources["limits"]["nvidia.com/gpu.shared"] == 1
assert "nvidia.com/gpu.shared" not in tts["containers"][0]["resources"]["requests"]
assert all("hostPath" not in volume for volume in stt["volumes"])
assert all("hostPath" not in volume for volume in tts["volumes"])
def test_chat_auth_file_mount_survives_atomic_provider_refresh(): def test_chat_auth_file_mount_survives_atomic_provider_refresh():

View File

@ -82,7 +82,7 @@ def test_claude_worker_waits_for_prompt_readiness(tmp_path: Path, monkeypatch):
"wait-output", "wait-output",
"w2:p1", "w2:p1",
"--match", "--match",
"accept edits on", "Claude Code",
"--source", "--source",
"recent", "recent",
"--lines", "--lines",
@ -94,7 +94,7 @@ def test_claude_worker_waits_for_prompt_readiness(tmp_path: Path, monkeypatch):
assert prompt[1:5] == [ assert prompt[1:5] == [
"agent", "agent",
"prompt", "prompt",
"claude-review", "project-claude-review",
"Check the implementation.", "Check the implementation.",
] ]
assert prompt[-9:] == [ assert prompt[-9:] == [
@ -109,6 +109,53 @@ def test_claude_worker_waits_for_prompt_readiness(tmp_path: Path, monkeypatch):
"15000", "15000",
] ]
workspace_create = calls[0]
assert workspace_create[-3:] == ["--label", "project-claude-review", "--no-focus"]
start = calls[1]
assert start[1:4] == ["agent", "start", "project-claude-review"]
assert start[-2:] == ["--permission-mode", "auto"]
def test_codex_worker_uses_automatic_review_without_prompts(tmp_path: Path, monkeypatch):
herdr = tmp_path / "herdr"
herdr.touch()
project = tmp_path / "cassandra"
project.mkdir()
auth = tmp_path / "auth.json"
auth.write_text("{}", encoding="utf-8")
calls = []
def fake_run(command, env):
calls.append(command)
if command[1:3] == ["workspace", "create"]:
return {"result": {"root_pane": {"pane_id": "w3:p1"}}}
return {"result": {"ok": True}}
monkeypatch.setattr(dispatch, "HERDR_BIN", herdr)
monkeypatch.setattr(dispatch, "CODEX_AUTH", auth)
monkeypatch.setattr(dispatch, "_run", fake_run)
plan = {"worker": "codex", "model": "gpt-5.6-sol", "effort": "high"}
dispatch.launch_worker(plan, project, "api-fix", None)
assert calls[0][-3:] == ["--label", "cassandra-codex-api-fix", "--no-focus"]
start = calls[1]
assert start[1:4] == ["agent", "start", "cassandra-codex-api-fix"]
assert "--approve-for-me" in start
assert start[-2:] == ["--sandbox", "workspace-write"]
assert not any("on-request" in value for value in start)
def test_agent_config_uses_bounded_noninteractive_approvals():
configmap = yaml.safe_load((HERMES / "agent-configmap.yaml").read_text())
config = yaml.safe_load(configmap["data"]["config.yaml"])
assert config["approvals"]["mode"] == "off"
denied = config["approvals"]["deny"]
assert "*kubectl apply*" in denied
assert "*git push --force*" in denied
assert "*git reset --hard*" in denied
def test_auth_patch_honors_explicit_shared_store(tmp_path: Path): def test_auth_patch_honors_explicit_shared_store(tmp_path: Path):
source = tmp_path / "auth.py" source = tmp_path / "auth.py"