diff --git a/backend/routers/hermes_ui.py b/backend/routers/hermes_ui.py
index a668ffa..727979d 100644
--- a/backend/routers/hermes_ui.py
+++ b/backend/routers/hermes_ui.py
@@ -6,14 +6,19 @@ Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRE
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
-Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
-Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
-MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
+Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin
+durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
`:9119/...` weiter.
+
+Seit Hermes v0.21 hat das Dashboard eine eigene Anmeldung: `/` leitet auf `/login?next=…` um,
+und die Anmeldeseite schickt an `/auth/password-login`. Beide Pfade sind absolut und kennen
+das Präfix nicht — MC2 reichte die Umleitung unverändert weiter, und der Knopf „Hermes-GUI
+öffnen“ landete auf MC2s eigener „Diese Seite gibt es nicht“ (gefunden 23.09.2026). Deshalb
+schreibt der Proxy Umleitungen und diese Pfade im HTML auf `/hermes-ui/…` um.
"""
import asyncio
@@ -91,6 +96,24 @@ async def _proxy_ws(ws: WebSocket, path: str) -> None:
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
_HEAD_INJECT = b"
"
+# Absolute Anmelde-Pfade in der (server-gerenderten) Login-Seite: "/auth/…", "/login", "/logout".
+_ANMELDE_PFADE = re.compile(rb'(["\'(=])/(auth|login|logout)(?=[/?"\')\s])')
+
+
+def praefixiere_ort(ort: str) -> str:
+ """Umleitungsziel unter /hermes-ui/ holen. Fremde Ziele (http…, //host) bleiben."""
+ if ort.startswith("/") and not ort.startswith(("//", f"/{_BASE}/")):
+ return f"/{_BASE}{ort}"
+ return ort
+
+
+def praefixiere_html(body: bytes) -> bytes:
+ """Basis-Pfad der SPA setzen und die absoluten Anmelde-Pfade unter /hermes-ui/ legen."""
+ if _BASE_PATH_RE.search(body):
+ body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
+ elif b"" in body:
+ body = body.replace(b"", _HEAD_INJECT, 1)
+ return _ANMELDE_PFADE.sub(rb"\1/" + _BASE.encode() + rb"/\2", body)
async def _proxy_http(request: Request, path: str = "") -> Response:
@@ -113,12 +136,18 @@ async def _proxy_http(request: Request, path: str = "") -> Response:
)
body = r.content
if "text/html" in r.headers.get("content-type", "").lower():
- if _BASE_PATH_RE.search(body):
- body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
- elif b"" in body:
- body = body.replace(b"", _HEAD_INJECT, 1)
- resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
- return Response(content=body, status_code=r.status_code, headers=resp_headers)
+ body = praefixiere_html(body)
+ resp = Response(content=body, status_code=r.status_code)
+ # multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
+ # dürfen nicht zu einer zusammenfallen.
+ for k, v in r.headers.multi_items():
+ kl = k.lower()
+ if kl in _DROP:
+ continue
+ if kl == "location":
+ v = praefixiere_ort(v)
+ resp.raw_headers.append((kl.encode("latin-1"), v.encode("latin-1")))
+ return resp
@router.get(f"/{_BASE}")
diff --git a/backend/tests/test_hermes_ui.py b/backend/tests/test_hermes_ui.py
new file mode 100644
index 0000000..ada6aa7
--- /dev/null
+++ b/backend/tests/test_hermes_ui.py
@@ -0,0 +1,33 @@
+"""Tests für die Pfad-Umschreibung des Hermes-Dashboard-Proxys (Anmeldung seit Hermes v0.21)."""
+
+from routers import hermes_ui
+
+
+def test_umleitung_auf_login_bekommt_praefix():
+ assert hermes_ui.praefixiere_ort("/login?next=%2F") == "/hermes-ui/login?next=%2F"
+ assert hermes_ui.praefixiere_ort("/") == "/hermes-ui/"
+
+
+def test_fremde_oder_schon_praefixierte_ziele_bleiben():
+ assert hermes_ui.praefixiere_ort("/hermes-ui/sessions") == "/hermes-ui/sessions"
+ assert hermes_ui.praefixiere_ort("https://example.org/x") == "https://example.org/x"
+ assert hermes_ui.praefixiere_ort("//evil.example/x") == "//evil.example/x"
+
+
+def test_anmeldeseite_schickt_an_praefixierten_pfad():
+ html = (b'Login'
+ b''
+ b''
+ b'')
+ neu = hermes_ui.praefixiere_html(html)
+ assert b'action="/hermes-ui/auth/password-login"' in neu
+ assert b'fetch("/hermes-ui/auth/password-login"' in neu
+ assert b'"/hermes-ui/login?x=1"' in neu
+ assert b'window.__HERMES_BASE_PATH__="/hermes-ui"' in neu
+
+
+def test_andere_absolute_pfade_bleiben_unberuehrt():
+ html = b'xy'
+ neu = hermes_ui.praefixiere_html(html)
+ assert b'href="/assets/x.js"' in neu
+ assert b'href="/authors"' in neu
diff --git a/backend/tests/test_modell_nutzung.py b/backend/tests/test_modell_nutzung.py
index 4c8313f..6b0d64b 100644
--- a/backend/tests/test_modell_nutzung.py
+++ b/backend/tests/test_modell_nutzung.py
@@ -5,17 +5,17 @@ from datetime import datetime
from services import modell_nutzung
ZEILEN = [
- '2026-09-23T03:12:44+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
- '"POST /v1/chat/completions HTTP/1.1" 200 10254 "Go-http-client/1.1" 1.2s',
- '2026-09-23T03:12:50+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
- '"POST /v1/chat/completions HTTP/1.1" 200 9000 "Go-http-client/1.1" 1.1s',
- '2026-09-23T07:01:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
- '"POST /v1/chat/completions HTTP/1.1" 200 800 "python-httpx/0.28.1" 3s',
- '2026-09-23T07:02:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
- '"POST /v1/embeddings HTTP/1.1" 200 800 "python-httpx/0.28.1" 20ms',
+ ('2026-09-23T03:12:44+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
+ '"POST /v1/chat/completions HTTP/1.1" 200 10254 "Go-http-client/1.1" 1.2s'),
+ ('2026-09-23T03:12:50+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
+ '"POST /v1/chat/completions HTTP/1.1" 200 9000 "Go-http-client/1.1" 1.1s'),
+ ('2026-09-23T07:01:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
+ '"POST /v1/chat/completions HTTP/1.1" 200 800 "python-httpx/0.28.1" 3s'),
+ ('2026-09-23T07:02:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
+ '"POST /v1/embeddings HTTP/1.1" 200 800 "python-httpx/0.28.1" 20ms'),
"2026-09-17T19:59:00+02:00 box llama-swap[1]: [INFO] Health check passed on http://localhost:5805/health",
- '2026-09-10T03:00:00+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
- '"POST /v1/chat/completions HTTP/1.1" 200 1 "Go-http-client/1.1" 1s',
+ ('2026-09-10T03:00:00+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
+ '"POST /v1/chat/completions HTTP/1.1" 200 1 "Go-http-client/1.1" 1s'),
]
NAMEN = {"127.0.0.1": "Lucy und OpenChamber über MC2", "192.168.178.28": "NerdQuiz auf Arcane"}
diff --git a/backend/tests/test_waechter.py b/backend/tests/test_waechter.py
index 6cabc47..e1694d8 100644
--- a/backend/tests/test_waechter.py
+++ b/backend/tests/test_waechter.py
@@ -10,14 +10,14 @@ PROJEKTE_SYNC_LOG = [
]
ERRORS_LOG = [
- '2026-09-23 07:02:41,584 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
- 'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md: '
- '/tmp/news-text.md exists"}',
- '2026-09-23 07:02:58,516 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
- 'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md"}',
+ ('2026-09-23 07:02:41,584 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
+ 'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md: '
+ '/tmp/news-text.md exists"}'),
+ ('2026-09-23 07:02:58,516 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
+ 'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md"}'),
"2026-09-23 07:03:39,596 WARNING gateway.mirror: Mirror: no session found for telegram:1 thread=None",
- '2026-09-22 07:01:10,000 WARNING [cron_195e479e8e30_20260922_070017] agent.tool_executor: '
- 'Tool web_extract returned error (0.10s): {"error": "timeout"}',
+ ('2026-09-22 07:01:10,000 WARNING [cron_195e479e8e30_20260922_070017] agent.tool_executor: '
+ 'Tool web_extract returned error (0.10s): {"error": "timeout"}'),
]
diff --git a/deploy/jobs/news-melden.sh b/deploy/jobs/news-melden.sh
index 2778d38..bd461a6 100644
--- a/deploy/jobs/news-melden.sh
+++ b/deploy/jobs/news-melden.sh
@@ -43,6 +43,9 @@ if [ "${1:-}" = "--nur-stimme" ]; then
--data "$(python3 -c 'import json,sys; print(json.dumps({"text": open(sys.argv[1], encoding="utf-8").read()[:1500]}))' "$SPRECHDATEI")" \
-o "$TON" -w '%{http_code}' 2>/dev/null)
+ # Sprechfassung wegraeumen (Grund siehe Textfassung unten) — auch wenn die Stimme scheiterte.
+ mv -f "$SPRECHDATEI" "${SPRECHDATEI%.txt}.gesendet.txt" 2>/dev/null || true
+
[ "$CODE" = "200" ] && [ -s "$TON" ] || exit 0
# Telegram zeigt eine ECHTE Sprachnachricht (runde Blase, Wellenform) nur bei
@@ -57,8 +60,16 @@ fi
# ------------------------------------------- Erster Durchgang: Text + Start ---
TEXTDATEI="${1:-/tmp/news-text.md}"
SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
+GESENDET="${TEXTDATEI%.md}.gesendet.md"
if [ ! -s "$TEXTDATEI" ]; then
+ # Zweiter Aufruf desselben Laufs, nachdem der erste den Bericht schon verschickt und
+ # weggeraeumt hat? Dann ist nichts zu tun (die Doppelversand-Sperre unten sieht die
+ # Datei nicht mehr).
+ if [ -s "$GESENDET" ] && [ $(( $(date +%s) - $(stat -c %Y "$GESENDET" 2>/dev/null || echo 0) )) -lt 7200 ]; then
+ echo "gemeldet (dieser Bericht ging bereits raus — nicht doppelt geschickt)"
+ exit 0
+ fi
bash "$NOTIFY" -s "[News]" "Der Nachrichtenbericht ist leer geblieben ($TEXTDATEI). Da lief etwas schief."
exit 1
fi
@@ -78,6 +89,11 @@ find /tmp -maxdepth 1 -name '.news-gesendet-*' -mtime +2 -delete 2>/dev/null
bash "$NOTIFY" -s "" "$(cat "$TEXTDATEI")"
+# Bericht wegraeumen (23.09.2026): Hermes' write_file ueberschreibt keine vorhandene Datei.
+# Blieb der Bericht vom Vortag liegen, scheiterte jeder Morgenlauf erst an
+# „Refusing to overwrite /tmp/news-text.md“ (4 von 5 Versuchen, jeden Tag seit Wochen).
+mv -f "$TEXTDATEI" "$GESENDET" 2>/dev/null && touch "$GESENDET" 2>/dev/null || true
+
# Stimme abkoppeln: eigene Sitzung, kein Elternprozess, keine offenen Deskriptoren.
# Damit ueberlebt sie das Ende dieses Skripts und blockiert es nicht.
if [ -s "$SPRECHDATEI" ]; then