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:
@@ -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
|
||||||
|
|||||||
+18
-4
@@ -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,12 +96,18 @@ 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():
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
+19
-10
@@ -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:
|
||||||
@@ -165,17 +167,24 @@ def check_versions_cached() -> dict:
|
|||||||
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.
|
||||||
|
# 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"]
|
||||||
|
|
||||||
data = {
|
mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
"mc2": get_git_info(mc2_path),
|
data = {
|
||||||
"engine": get_engine_version(),
|
"mc2": get_git_info(mc2_path),
|
||||||
"hermes_ui": get_git_info("~/hermes-webui"),
|
"engine": get_engine_version(),
|
||||||
"hermes_agent": find_hermes_agent_git()
|
"hermes_ui": get_git_info("~/hermes-webui"),
|
||||||
}
|
"hermes_agent": find_hermes_agent_git()
|
||||||
_VERSION_CACHE["ts"] = now
|
}
|
||||||
_VERSION_CACHE["data"] = data
|
_VERSION_CACHE["ts"] = now
|
||||||
return data
|
_VERSION_CACHE["data"] = data
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def system_status() -> dict:
|
def system_status() -> dict:
|
||||||
|
|||||||
Reference in New Issue
Block a user