From 3d2c9549fbafe4035f01c0379f9ef97d9daf771f Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Wed, 23 Sep 2026 21:18:43 +0200 Subject: [PATCH] fix(boxwart): Nachrichten-Job raeumt nach dem Versand auf, Hermes-Dashboard-Link repariert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - news-melden.sh verschiebt Text- und Sprechfassung nach dem Versand nach *.gesendet.*. Hermes' write_file ueberschreibt keine vorhandene Datei; der liegengebliebene Bericht vom Vortag liess jeden Morgenlauf 4 von 5 Schreibversuchen scheitern. Ein zweiter Aufruf desselben Laufs erkennt den schon verschickten Bericht weiterhin. - hermes_ui-Proxy: Seit Hermes v0.21 leitet das Dashboard auf /login um und die Anmeldeseite schickt an /auth/password-login — beides ohne /hermes-ui-Praefix. Der Knopf „Hermes-GUI oeffnen“ landete deshalb auf MC2s „Diese Seite gibt es nicht“. Umleitungen und Anmelde-Pfade werden jetzt umgeschrieben, mehrere Set-Cookie bleiben. - Tests fuer die Pfad-Umschreibung (12 Backend-Tests gesamt). Co-Authored-By: Claude Opus 5.5 --- backend/routers/hermes_ui.py | 47 ++++++++++++++++++++++------ backend/tests/test_hermes_ui.py | 33 +++++++++++++++++++ backend/tests/test_modell_nutzung.py | 20 ++++++------ backend/tests/test_waechter.py | 14 ++++----- deploy/jobs/news-melden.sh | 16 ++++++++++ 5 files changed, 104 insertions(+), 26 deletions(-) create mode 100644 backend/tests/test_hermes_ui.py 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