fix(boxwart): Nachrichten-Job raeumt nach dem Versand auf, Hermes-Dashboard-Link repariert

- 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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-23 21:18:43 +02:00
co-authored by Claude Opus 5.5
parent 12dadfe6ef
commit 3d2c9549fb
5 changed files with 104 additions and 26 deletions
+38 -9
View File
@@ -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 (/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). 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 Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
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`, 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 `/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 liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
`:9119/...` weiter. `: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 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_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"' _BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>" _HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
# 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"<head>" in body:
body = body.replace(b"<head>", _HEAD_INJECT, 1)
return _ANMELDE_PFADE.sub(rb"\1/" + _BASE.encode() + rb"/\2", body)
async def _proxy_http(request: Request, path: str = "") -> Response: 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 body = r.content
if "text/html" in r.headers.get("content-type", "").lower(): if "text/html" in r.headers.get("content-type", "").lower():
if _BASE_PATH_RE.search(body): body = praefixiere_html(body)
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body) resp = Response(content=body, status_code=r.status_code)
elif b"<head>" in body: # multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
body = body.replace(b"<head>", _HEAD_INJECT, 1) # dürfen nicht zu einer zusammenfallen.
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP} for k, v in r.headers.multi_items():
return Response(content=body, status_code=r.status_code, headers=resp_headers) 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}") @router.get(f"/{_BASE}")
+33
View File
@@ -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'<html><head><title>Login</title></head><body>'
b'<form action="/auth/password-login" method="post"></form>'
b'<script>fetch("/auth/password-login",{method:"POST"});location.href="/login?x=1"</script>'
b'</body></html>')
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'<head></head><a href="/assets/x.js">x</a><a href="/authors">y</a>'
neu = hermes_ui.praefixiere_html(html)
assert b'href="/assets/x.js"' in neu
assert b'href="/authors"' in neu
+10 -10
View File
@@ -5,17 +5,17 @@ from datetime import datetime
from services import modell_nutzung from services import modell_nutzung
ZEILEN = [ ZEILEN = [
'2026-09-23T03:12:44+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 ' ('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', '"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 ' ('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', '"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 ' ('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', '"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 ' ('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', '"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] <Qwen3.8-27B> Health check passed on http://localhost:5805/health", "2026-09-17T19:59:00+02:00 box llama-swap[1]: [INFO] <Qwen3.8-27B> 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 ' ('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', '"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"} NAMEN = {"127.0.0.1": "Lucy und OpenChamber über MC2", "192.168.178.28": "NerdQuiz auf Arcane"}
+6 -6
View File
@@ -10,14 +10,14 @@ PROJEKTE_SYNC_LOG = [
] ]
ERRORS_LOG = [ ERRORS_LOG = [
'2026-09-23 07:02:41,584 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: ' ('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: ' 'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md: '
'/tmp/news-text.md exists"}', '/tmp/news-text.md exists"}'),
'2026-09-23 07:02:58,516 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: ' ('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"}', '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-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: ' ('2026-09-22 07:01:10,000 WARNING [cron_195e479e8e30_20260922_070017] agent.tool_executor: '
'Tool web_extract returned error (0.10s): {"error": "timeout"}', 'Tool web_extract returned error (0.10s): {"error": "timeout"}'),
] ]
+16
View File
@@ -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")" \ --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) -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 [ "$CODE" = "200" ] && [ -s "$TON" ] || exit 0
# Telegram zeigt eine ECHTE Sprachnachricht (runde Blase, Wellenform) nur bei # Telegram zeigt eine ECHTE Sprachnachricht (runde Blase, Wellenform) nur bei
@@ -57,8 +60,16 @@ fi
# ------------------------------------------- Erster Durchgang: Text + Start --- # ------------------------------------------- Erster Durchgang: Text + Start ---
TEXTDATEI="${1:-/tmp/news-text.md}" TEXTDATEI="${1:-/tmp/news-text.md}"
SPRECHDATEI="${2:-/tmp/news-sprich.txt}" SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
GESENDET="${TEXTDATEI%.md}.gesendet.md"
if [ ! -s "$TEXTDATEI" ]; then 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." bash "$NOTIFY" -s "[News]" "Der Nachrichtenbericht ist leer geblieben ($TEXTDATEI). Da lief etwas schief."
exit 1 exit 1
fi fi
@@ -78,6 +89,11 @@ find /tmp -maxdepth 1 -name '.news-gesendet-*' -mtime +2 -delete 2>/dev/null
bash "$NOTIFY" -s "" "$(cat "$TEXTDATEI")" 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. # Stimme abkoppeln: eigene Sitzung, kein Elternprozess, keine offenen Deskriptoren.
# Damit ueberlebt sie das Ende dieses Skripts und blockiert es nicht. # Damit ueberlebt sie das Ende dieses Skripts und blockiert es nicht.
if [ -s "$SPRECHDATEI" ]; then if [ -s "$SPRECHDATEI" ]; then