From 12c387a6de10966cc0e4a9171db2632623490288 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 5 Jul 2026 10:21:32 +0200 Subject: [PATCH] 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 --- AGENTS.md | 4 ++-- backend/app.py | 24 ++++++++++++++++++----- backend/routers/gateway_proxy.py | 20 +++++++++---------- backend/services/system.py | 33 ++++++++++++++++++++------------ 4 files changed, 51 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 184ca5f..81df035 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. ## Zeit & Umgebung -- **Box = Ubuntu, läuft in UTC.** Dev-PC = Windows. Naive/lokale Zeiten immer über - `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen +- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows. + Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen (siehe `backend/services/reminders.py`). ## Backend-Konventionen diff --git a/backend/app.py b/backend/app.py index f60ff2f..4cf1713 100644 --- a/backend/app.py +++ b/backend/app.py @@ -11,6 +11,7 @@ import logging import os from contextlib import asynccontextmanager +import httpx from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse @@ -44,11 +45,18 @@ async def lifespan(app: FastAPI): tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop())) log.info("Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)", 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: yield finally: for task in tasks: task.cancel() + await app.state.gw_client.aclose() 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(): app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") + _DIST_ROOT = FRONTEND_DIST.resolve() + @app.get("/{full_path:path}") def spa(full_path: str): - # Falls die Datei direkt in FRONTEND_DIST liegt (z.B. manifest.webmanifest, favicon.ico), liefere sie aus - target = FRONTEND_DIST / full_path - if target.is_file(): - return FileResponse(target) - + # Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber + # NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus. + try: + 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" if index.exists(): # index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle. diff --git a/backend/routers/gateway_proxy.py b/backend/routers/gateway_proxy.py index 260a2f1..c8c83fe 100644 --- a/backend/routers/gateway_proxy.py +++ b/backend/routers/gateway_proxy.py @@ -1,6 +1,5 @@ import os -import httpx from fastapi import APIRouter, Request from fastapi.responses import JSONResponse, StreamingResponse @@ -97,20 +96,19 @@ async def _proxy(path: str, request: Request): _inject_language(body, alias) url = f"{LLAMA_SWAP_URL}{path}" + client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) if body.get("stream"): async def gen(): - async with httpx.AsyncClient(timeout=None) as c: - async with c.stream("POST", url, json=body) as r: - async for chunk in r.aiter_raw(): - record_stream_chunk(chunk, alias) - yield chunk + async with client.stream("POST", url, json=body, timeout=None) as r: + async for chunk in r.aiter_raw(): + record_stream_chunk(chunk, alias) + yield chunk return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) - async with httpx.AsyncClient(timeout=600) as c: - r = await c.post(url, json=body) - resp_json = r.json() - 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) + r = await client.post(url, json=body, timeout=600.0) + resp_json = r.json() + 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) @router.post("/chat/completions") diff --git a/backend/services/system.py b/backend/services/system.py index 04478a8..be610d6 100644 --- a/backend/services/system.py +++ b/backend/services/system.py @@ -9,6 +9,7 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt import glob import os import subprocess +import threading import psutil @@ -157,6 +158,7 @@ def get_engine_version() -> dict: _VERSION_CACHE = {"ts": 0.0, "data": {}} +_VERSION_LOCK = threading.Lock() def check_versions_cached() -> dict: @@ -164,18 +166,25 @@ def check_versions_cached() -> dict: now = time.time() if now - _VERSION_CACHE["ts"] < 30.0: return _VERSION_CACHE["data"] - - mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) - - 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 + + # 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. + # Doppelt geprüft, damit ein zweiter Thread den frisch gefüllten Cache nimmt statt neu zu scannen. + with _VERSION_LOCK: + now = time.time() + if now - _VERSION_CACHE["ts"] < 30.0: + return _VERSION_CACHE["data"] + + mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + 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: