Antigravity-Review-Fixes: Traversal-Guard, Client-Pooling, Cache-Lock, Doku

Vier verifizierte Befunde aus dem externen Review (Gemini 3.1 Pro):
- app.py: SPA-Fallback gegen Path-Traversal gehaertet (resolve + is_relative_to,
  liefert nur noch Dateien INNERHALB von frontend/dist aus).
- app.py/gateway_proxy.py: geteilter httpx.AsyncClient im lifespan statt neuer
  Client pro /v1-Anfrage (Keep-Alive/Pooling, spart Sockets unter parallelen Agent-Stroemen).
- system.py: check_versions_cached() mit threading.Lock + Double-Check gegen Scan-Stampede.
- AGENTS.md: Zeitzonen-Drift korrigiert (Box laeuft Europe/Berlin, nicht UTC).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-05 10:21:32 +02:00
parent ff1b9a085f
commit 12c387a6de
4 changed files with 51 additions and 30 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy. - **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
## Zeit & Umgebung ## Zeit & Umgebung
- **Box = Ubuntu, läuft in UTC.** Dev-PC = Windows. Naive/lokale Zeiten immer über - **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
`MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
(siehe `backend/services/reminders.py`). (siehe `backend/services/reminders.py`).
## Backend-Konventionen ## Backend-Konventionen
+19 -5
View File
@@ -11,6 +11,7 @@ import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -44,11 +45,18 @@ async def lifespan(app: FastAPI):
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop())) tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info("Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)", log.info("Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
memory_svc.AUTO_DEDUPE_INTERVAL, memory_svc.AUTO_DEDUPE_THRESHOLD) memory_svc.AUTO_DEDUPE_INTERVAL, memory_svc.AUTO_DEDUPE_THRESHOLD)
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
app.state.gw_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
limits=httpx.Limits(max_keepalive_connections=32, max_connections=64),
)
try: try:
yield yield
finally: finally:
for task in tasks: for task in tasks:
task.cancel() task.cancel()
await app.state.gw_client.aclose()
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan) app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
@@ -88,13 +96,19 @@ app.include_router(console.router) # Box-Konsole (ttyd) same-origin durchreiche
if FRONTEND_DIST.exists(): if FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
_DIST_ROOT = FRONTEND_DIST.resolve()
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
def spa(full_path: str): def spa(full_path: str):
# Falls die Datei direkt in FRONTEND_DIST liegt (z.B. manifest.webmanifest, favicon.ico), liefere sie aus # Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
target = FRONTEND_DIST / full_path # NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
if target.is_file(): try:
return FileResponse(target) target = (FRONTEND_DIST / full_path).resolve()
if target.is_relative_to(_DIST_ROOT) and target.is_file():
return FileResponse(target)
except (ValueError, OSError):
pass
index = FRONTEND_DIST / "index.html" index = FRONTEND_DIST / "index.html"
if index.exists(): if index.exists():
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle. # index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
+9 -11
View File
@@ -1,6 +1,5 @@
import os import os
import httpx
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
@@ -97,20 +96,19 @@ async def _proxy(path: str, request: Request):
_inject_language(body, alias) _inject_language(body, alias)
url = f"{LLAMA_SWAP_URL}{path}" url = f"{LLAMA_SWAP_URL}{path}"
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
if body.get("stream"): if body.get("stream"):
async def gen(): async def gen():
async with httpx.AsyncClient(timeout=None) as c: async with client.stream("POST", url, json=body, timeout=None) as r:
async with c.stream("POST", url, json=body) as r: async for chunk in r.aiter_raw():
async for chunk in r.aiter_raw(): record_stream_chunk(chunk, alias)
record_stream_chunk(chunk, alias) yield chunk
yield chunk
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
async with httpx.AsyncClient(timeout=600) as c: r = await client.post(url, json=body, timeout=600.0)
r = await c.post(url, json=body) resp_json = r.json()
resp_json = r.json() record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias) return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
@router.post("/chat/completions") @router.post("/chat/completions")
+21 -12
View File
@@ -9,6 +9,7 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
import glob import glob
import os import os
import subprocess import subprocess
import threading
import psutil import psutil
@@ -157,6 +158,7 @@ def get_engine_version() -> dict:
_VERSION_CACHE = {"ts": 0.0, "data": {}} _VERSION_CACHE = {"ts": 0.0, "data": {}}
_VERSION_LOCK = threading.Lock()
def check_versions_cached() -> dict: def check_versions_cached() -> dict:
@@ -164,18 +166,25 @@ def check_versions_cached() -> dict:
now = time.time() now = time.time()
if now - _VERSION_CACHE["ts"] < 30.0: if now - _VERSION_CACHE["ts"] < 30.0:
return _VERSION_CACHE["data"] return _VERSION_CACHE["data"]
mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) # Lock gegen Scan-Stampede: FastAPI führt sync-Routen im Threadpool aus → ohne Lock würden
# parallele Dashboard-/Agent-Aufrufe die git-/Versions-Scans mehrfach gleichzeitig anwerfen.
data = { # Doppelt geprüft, damit ein zweiter Thread den frisch gefüllten Cache nimmt statt neu zu scannen.
"mc2": get_git_info(mc2_path), with _VERSION_LOCK:
"engine": get_engine_version(), now = time.time()
"hermes_ui": get_git_info("~/hermes-webui"), if now - _VERSION_CACHE["ts"] < 30.0:
"hermes_agent": find_hermes_agent_git() return _VERSION_CACHE["data"]
}
_VERSION_CACHE["ts"] = now mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
_VERSION_CACHE["data"] = data data = {
return data "mc2": get_git_info(mc2_path),
"engine": get_engine_version(),
"hermes_ui": get_git_info("~/hermes-webui"),
"hermes_agent": find_hermes_agent_git()
}
_VERSION_CACHE["ts"] = now
_VERSION_CACHE["data"] = data
return data
def system_status() -> dict: def system_status() -> dict: